feat: add integrated terminal backend

- add a PTY manager with platform shell resolution and streamed data/exit events
- expose terminal lifecycle and terminal height IPC through preload and app service
- persist terminal panel height in workspace settings and document the backend contract
- add backend tests and validate native packaging with bun run package

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 23:15:16 +01:00
co-authored by Copilot
parent 651a7d27fc
commit 251316596c
14 changed files with 953 additions and 2 deletions
+84
View File
@@ -69,6 +69,7 @@ import {
type SessionQueryResult,
} from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
import {
applySessionApprovalSettings,
applySessionModelConfig,
@@ -93,6 +94,7 @@ import {
import {
createSessionToolingSelection,
listApprovalToolNames,
normalizeTerminalHeight,
normalizeTheme,
resolveProjectToolingSettings,
resolveWorkspaceToolingSettings,
@@ -129,12 +131,15 @@ import {
import { getStoredToken } from '@main/services/mcpTokenStore';
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber';
import { PtyManager } from '@main/services/ptyManager';
const { dialog, shell } = electron;
type AppServiceEvents = {
'workspace-updated': [WorkspaceState];
'session-event': [SessionEventRecord];
'terminal-data': [string];
'terminal-exit': [TerminalExitInfo];
};
type PendingApprovalHandle = {
@@ -182,6 +187,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private readonly configScanner = new ConfigScannerRegistry();
private readonly customizationScanner = new ProjectCustomizationScanner();
private readonly probeMcpServers = probeServers;
private readonly ptyManager = new PtyManager();
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
private workspace?: WorkspaceState;
@@ -190,6 +196,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private didScheduleInitialProjectGitRefresh = false;
private mcpProbeUpdateQueue = Promise.resolve();
constructor() {
super();
this.ptyManager.on('data', (data) => {
this.emit('terminal-data', data);
});
this.ptyManager.on('exit', (info) => {
this.emit('terminal-exit', info);
});
}
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
return this.loadSidecarCapabilities();
}
@@ -241,6 +258,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
async dispose(): Promise<void> {
this.ptyManager.dispose();
await this.sidecar.dispose();
void this.secretStore;
}
@@ -464,6 +482,53 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setTerminalHeight(height?: number): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const normalizedHeight = normalizeTerminalHeight(height);
if (normalizedHeight === undefined) {
if (workspace.settings.terminalHeight === undefined) {
return workspace;
}
delete workspace.settings.terminalHeight;
return this.persistAndBroadcast(workspace);
}
if (workspace.settings.terminalHeight === normalizedHeight) {
return workspace;
}
workspace.settings.terminalHeight = normalizedHeight;
return this.persistAndBroadcast(workspace);
}
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
return this.ptyManager.getSnapshot();
}
async createTerminal(): Promise<TerminalSnapshot> {
const workspace = await this.loadWorkspace();
return this.ptyManager.create(this.resolveTerminalWorkingDirectory(workspace));
}
async restartTerminal(): Promise<TerminalSnapshot> {
const workspace = await this.loadWorkspace();
return this.ptyManager.restart(this.resolveTerminalWorkingDirectory(workspace));
}
async killTerminal(): Promise<void> {
this.ptyManager.kill();
}
writeTerminal(data: string): void {
this.ptyManager.write(data);
}
resizeTerminal(cols: number, rows: number): void {
this.ptyManager.resize(cols, rows);
}
async deletePattern(patternId: string): Promise<WorkspaceState> {
if (isBuiltinPattern(patternId)) {
throw new Error('Built-in patterns cannot be deleted.');
@@ -1317,6 +1382,25 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return project;
}
private resolveTerminalWorkingDirectory(workspace: WorkspaceState): string {
const selectedSession = workspace.selectedSessionId
? workspace.sessions.find((session) => session.id === workspace.selectedSessionId)
: undefined;
if (selectedSession) {
const project = this.requireProject(workspace, selectedSession.projectId);
return selectedSession.cwd ?? project.path;
}
const selectedProject = workspace.selectedProjectId
? workspace.projects.find((project) => project.id === workspace.selectedProjectId)
: workspace.projects[0];
if (!selectedProject) {
throw new Error('Open a project or session before starting the integrated terminal.');
}
return selectedProject.path;
}
private async refreshGitContextForProject(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.git) {
+24
View File
@@ -26,6 +26,8 @@ import type {
SetSessionArchivedInput,
SetSessionInteractionModeInput,
SetSessionPinnedInput,
SetTerminalHeightInput,
ResizeTerminalInput,
UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
@@ -80,6 +82,10 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
applyTitleBarTheme(window, theme);
return result;
});
ipcMain.handle(
ipcChannels.setTerminalHeight,
(_event, input: SetTerminalHeightInput) => service.setTerminalHeight(input.height),
);
ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) =>
service.saveMcpServer(input.server),
);
@@ -92,6 +98,16 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
service.deleteLspProfile(profileId),
);
ipcMain.handle(ipcChannels.describeTerminal, () => service.describeTerminal());
ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal());
ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal());
ipcMain.handle(ipcChannels.killTerminal, () => service.killTerminal());
ipcMain.on(ipcChannels.writeTerminal, (_event, data: string) => {
service.writeTerminal(data);
});
ipcMain.on(ipcChannels.resizeTerminal, (_event, input: ResizeTerminalInput) => {
service.resizeTerminal(input.cols, input.rows);
});
ipcMain.handle(ipcChannels.updateSessionTooling, (_event, input: UpdateSessionToolingInput) =>
service.updateSessionTooling(
input.sessionId,
@@ -165,4 +181,12 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
service.on('session-event', (event) => {
window.webContents.send(ipcChannels.sessionEvent, event);
});
service.on('terminal-data', (data) => {
window.webContents.send(ipcChannels.terminalData, data);
});
service.on('terminal-exit', (info) => {
window.webContents.send(ipcChannels.terminalExit, info);
});
}
+352
View File
@@ -0,0 +1,352 @@
import { EventEmitter } from 'node:events';
import { constants as fsConstants } from 'node:fs';
import { access, stat } from 'node:fs/promises';
import { basename, delimiter, isAbsolute, join } from 'node:path';
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
const DEFAULT_COLS = 80;
const DEFAULT_ROWS = 24;
const DEFAULT_TERMINAL_NAME = 'xterm-256color';
const DEFAULT_UNIX_SHELL = '/bin/bash';
const DEFAULT_WINDOWS_FALLBACK_SHELL = 'cmd.exe';
type Disposable = {
dispose(): void;
};
interface ManagedPty {
readonly pid: number;
write(data: string): void;
resize(cols: number, rows: number): void;
kill(signal?: string): void;
onData(listener: (data: string) => void): Disposable;
onExit(listener: (event: TerminalExitInfo) => void): Disposable;
}
type PtySpawnOptions = {
name: string;
cols: number;
rows: number;
cwd: string;
env: Record<string, string>;
};
type PtySpawn = (
file: string,
args: string[],
options: PtySpawnOptions,
) => ManagedPty | Promise<ManagedPty>;
type CommandExists = (
command: string,
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
) => Promise<boolean>;
type ActiveTerminal = {
pty: ManagedPty;
snapshot: TerminalSnapshot;
dataSubscription: Disposable;
exitSubscription: Disposable;
};
type PtyManagerEvents = {
data: [string];
exit: [TerminalExitInfo];
};
export interface PtyManagerOptions {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
spawnPty?: PtySpawn;
commandExists?: CommandExists;
}
export class PtyManager extends EventEmitter<PtyManagerEvents> {
private readonly platform: NodeJS.Platform;
private readonly env: NodeJS.ProcessEnv;
private readonly spawnPty: PtySpawn;
private readonly commandExists: CommandExists;
private activeTerminal?: ActiveTerminal;
constructor(options: PtyManagerOptions = {}) {
super();
this.platform = options.platform ?? process.platform;
this.env = options.env ?? process.env;
this.spawnPty = options.spawnPty ?? defaultSpawnPty;
this.commandExists = options.commandExists ?? commandExistsOnPath;
}
get isRunning(): boolean {
return this.activeTerminal !== undefined;
}
getSnapshot(): TerminalSnapshot | undefined {
return this.activeTerminal ? { ...this.activeTerminal.snapshot } : undefined;
}
async create(cwd: string, cols = DEFAULT_COLS, rows = DEFAULT_ROWS): Promise<TerminalSnapshot> {
if (this.activeTerminal) {
return { ...this.activeTerminal.snapshot };
}
return this.spawnTerminal(cwd, cols, rows);
}
async restart(
cwd: string,
cols = this.activeTerminal?.snapshot.cols ?? DEFAULT_COLS,
rows = this.activeTerminal?.snapshot.rows ?? DEFAULT_ROWS,
): Promise<TerminalSnapshot> {
this.disposeActiveTerminal();
return this.spawnTerminal(cwd, cols, rows);
}
write(data: string): void {
if (!data) {
return;
}
if (!this.activeTerminal) {
console.warn('[aryx terminal] Ignoring terminal write because no terminal is running.');
return;
}
this.activeTerminal.pty.write(data);
}
resize(cols: number, rows: number): void {
if (!this.activeTerminal) {
console.warn('[aryx terminal] Ignoring terminal resize because no terminal is running.');
return;
}
const nextCols = normalizeDimension(cols, DEFAULT_COLS);
const nextRows = normalizeDimension(rows, DEFAULT_ROWS);
this.activeTerminal.pty.resize(nextCols, nextRows);
this.activeTerminal.snapshot.cols = nextCols;
this.activeTerminal.snapshot.rows = nextRows;
}
kill(): void {
this.activeTerminal?.pty.kill();
}
dispose(): void {
this.disposeActiveTerminal();
}
private async spawnTerminal(cwd: string, cols: number, rows: number): Promise<TerminalSnapshot> {
await assertDirectory(cwd);
const nextCols = normalizeDimension(cols, DEFAULT_COLS);
const nextRows = normalizeDimension(rows, DEFAULT_ROWS);
const shell = await resolveShellCommand(this.platform, this.env, this.commandExists);
const pty = await this.spawnPty(shell.command, shell.args, {
name: DEFAULT_TERMINAL_NAME,
cols: nextCols,
rows: nextRows,
cwd,
env: sanitizeEnvironment(this.env),
});
const snapshot: TerminalSnapshot = {
cwd,
shell: shell.label,
pid: pty.pid,
cols: nextCols,
rows: nextRows,
};
const active: ActiveTerminal = {
pty,
snapshot,
dataSubscription: { dispose() {} },
exitSubscription: { dispose() {} },
};
active.dataSubscription = pty.onData((data) => {
if (this.activeTerminal?.pty !== pty) {
return;
}
this.emit('data', data);
});
active.exitSubscription = pty.onExit((event) => {
if (this.activeTerminal?.pty !== pty) {
return;
}
this.activeTerminal = undefined;
active.dataSubscription.dispose();
active.exitSubscription.dispose();
this.emit('exit', event);
});
this.activeTerminal = active;
return { ...snapshot };
}
private disposeActiveTerminal(): void {
const active = this.activeTerminal;
if (!active) {
return;
}
this.activeTerminal = undefined;
active.dataSubscription.dispose();
active.exitSubscription.dispose();
try {
active.pty.kill();
} catch (error) {
console.warn('[aryx terminal] Failed to stop terminal during cleanup.', error);
}
}
}
async function defaultSpawnPty(
file: string,
args: string[],
options: PtySpawnOptions,
): Promise<ManagedPty> {
const { spawn } = await import('node-pty');
return spawn(file, args, options) as ManagedPty;
}
async function resolveShellCommand(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
commandExists: CommandExists,
): Promise<{ command: string; args: string[]; label: string }> {
if (platform === 'win32') {
const windowsPowerShellPath = resolveWindowsPowerShellPath(env);
const candidates = [
{ command: 'pwsh.exe', args: ['-NoLogo'], label: 'PowerShell' },
{ command: windowsPowerShellPath, args: ['-NoLogo'], label: 'PowerShell' },
...(env.COMSPEC || env.ComSpec
? [{
command: env.COMSPEC ?? env.ComSpec ?? DEFAULT_WINDOWS_FALLBACK_SHELL,
args: [],
label: resolveShellLabel(env.COMSPEC ?? env.ComSpec ?? DEFAULT_WINDOWS_FALLBACK_SHELL),
}]
: []),
{ command: DEFAULT_WINDOWS_FALLBACK_SHELL, args: [], label: 'Command Prompt' },
] satisfies Array<{ command: string; args: string[]; label: string }>;
for (const candidate of candidates) {
if (await commandExists(candidate.command, env, platform)) {
return candidate;
}
}
return candidates[candidates.length - 1]!;
}
const configuredShell = env.SHELL?.trim();
if (configuredShell && await commandExists(configuredShell, env, platform)) {
return { command: configuredShell, args: [], label: resolveShellLabel(configuredShell) };
}
return { command: DEFAULT_UNIX_SHELL, args: [], label: resolveShellLabel(DEFAULT_UNIX_SHELL) };
}
async function commandExistsOnPath(
command: string,
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): Promise<boolean> {
if (isAbsolute(command)) {
return fileExists(command, platform);
}
const searchPath = env.PATH ?? env.Path ?? '';
const pathEntries = searchPath.split(delimiter).filter((entry) => entry.length > 0);
const commandNames = platform === 'win32'
? expandWindowsCommandCandidates(command)
: [command];
for (const entry of pathEntries) {
for (const candidate of commandNames) {
if (await fileExists(join(entry, candidate), platform)) {
return true;
}
}
}
return false;
}
async function fileExists(path: string, platform: NodeJS.Platform): Promise<boolean> {
try {
await access(path, platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}
function expandWindowsCommandCandidates(command: string): string[] {
if (command.includes('.')) {
return [command];
}
return [
`${command}.exe`,
`${command}.cmd`,
`${command}.bat`,
command,
];
}
function resolveWindowsPowerShellPath(env: NodeJS.ProcessEnv): string {
const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? 'C:\\Windows';
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
}
function resolveShellLabel(command: string): string {
const baseName = basename(command).replace(/\.(exe|cmd|bat)$/i, '');
if (baseName === 'pwsh' || baseName === 'powershell') {
return 'PowerShell';
}
if (baseName === 'cmd') {
return 'Command Prompt';
}
return baseName;
}
function sanitizeEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries({
...env,
TERM: DEFAULT_TERMINAL_NAME,
}).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
);
}
function normalizeDimension(value: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
const normalized = Math.round(value);
return normalized >= 1 ? normalized : fallback;
}
async function assertDirectory(path: string): Promise<void> {
try {
const entry = await stat(path);
if (!entry.isDirectory()) {
throw new Error(`Terminal working directory "${path}" is not a directory.`);
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Terminal working directory "${path}" is unavailable.`, { cause: error });
}
throw error;
}
}
+25
View File
@@ -25,10 +25,21 @@ const api: ElectronApi = {
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
describeTerminal: () => ipcRenderer.invoke(ipcChannels.describeTerminal),
createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal),
restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal),
killTerminal: () => ipcRenderer.invoke(ipcChannels.killTerminal),
writeTerminal: (data) => {
ipcRenderer.send(ipcChannels.writeTerminal, data);
},
resizeTerminal: (input) => {
ipcRenderer.send(ipcChannels.resizeTerminal, input);
},
updateSessionTooling: (input) => ipcRenderer.invoke(ipcChannels.updateSessionTooling, input),
updateSessionApprovalSettings: (input) =>
ipcRenderer.invoke(ipcChannels.updateSessionApprovalSettings, input),
@@ -54,6 +65,20 @@ const api: ElectronApi = {
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
onTerminalData: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, data: Parameters<typeof listener>[0]) =>
listener(data);
ipcRenderer.on(ipcChannels.terminalData, handler);
return () => ipcRenderer.off(ipcChannels.terminalData, handler);
},
onTerminalExit: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, info: Parameters<typeof listener>[0]) =>
listener(info);
ipcRenderer.on(ipcChannels.terminalExit, handler);
return () => ipcRenderer.off(ipcChannels.terminalExit, handler);
},
onWorkspaceUpdated:(listener) => {
const handler = (_event: Electron.IpcRendererEvent, workspace: Awaited<ReturnType<ElectronApi['loadWorkspace']>>) =>
listener(workspace);
+9
View File
@@ -14,10 +14,17 @@ export const ipcChannels = {
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
setTheme: 'settings:set-theme',
setTerminalHeight: 'settings:set-terminal-height',
saveMcpServer: 'tooling:mcp:save',
deleteMcpServer: 'tooling:mcp:delete',
saveLspProfile: 'tooling:lsp:save',
deleteLspProfile: 'tooling:lsp:delete',
describeTerminal: 'terminal:describe',
createTerminal: 'terminal:create',
restartTerminal: 'terminal:restart',
writeTerminal: 'terminal:write',
resizeTerminal: 'terminal:resize',
killTerminal: 'terminal:kill',
updateSessionTooling: 'sessions:update-tooling',
updateSessionApprovalSettings: 'sessions:update-approval-settings',
createSession: 'sessions:create',
@@ -41,6 +48,8 @@ export const ipcChannels = {
selectSession: 'selection:session',
openAppDataFolder: 'troubleshooting:open-app-data-folder',
resetLocalWorkspace: 'troubleshooting:reset-local-workspace',
terminalData: 'terminal:data',
terminalExit: 'terminal:exit',
workspaceUpdated: 'workspace:updated',
sessionEvent: 'sessions:event',
} as const;
+19
View File
@@ -4,6 +4,7 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
import type { ProjectRecord } from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
import type {
LspProfileDefinition,
McpServerDefinition,
@@ -142,6 +143,15 @@ export interface DeleteSessionInput {
sessionId: string;
}
export interface ResizeTerminalInput {
cols: number;
rows: number;
}
export interface SetTerminalHeightInput {
height?: number;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -183,8 +193,17 @@ export interface ElectronApi {
selectSession(sessionId?: string): Promise<WorkspaceState>;
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
describeTerminal(): Promise<TerminalSnapshot | undefined>;
createTerminal(): Promise<TerminalSnapshot>;
restartTerminal(): Promise<TerminalSnapshot>;
killTerminal(): Promise<void>;
writeTerminal(data: string): void;
resizeTerminal(input: ResizeTerminalInput): void;
openAppDataFolder(): Promise<void>;
resetLocalWorkspace(): Promise<WorkspaceState>;
onTerminalData(listener: (data: string) => void): () => void;
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
}
+12
View File
@@ -0,0 +1,12 @@
export interface TerminalSnapshot {
cwd: string;
shell: string;
pid: number;
cols: number;
rows: number;
}
export interface TerminalExitInfo {
exitCode: number;
signal?: number;
}
+12
View File
@@ -63,6 +63,7 @@ export interface WorkspaceSettings {
theme: AppearanceTheme;
tooling: WorkspaceToolingSettings;
discoveredUserTooling: DiscoveredToolingState;
terminalHeight?: number;
}
export interface SessionToolingSelection {
@@ -185,7 +186,17 @@ export function normalizeTheme(value?: string): AppearanceTheme {
return validThemes.has(value ?? '') ? (value as AppearanceTheme) : 'dark';
}
export function normalizeTerminalHeight(value?: number): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const normalized = Math.round(value);
return normalized >= 120 ? normalized : undefined;
}
export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>): WorkspaceSettings {
const terminalHeight = normalizeTerminalHeight(settings?.terminalHeight);
return {
theme: normalizeTheme(settings?.theme),
tooling: {
@@ -193,6 +204,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
},
discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling),
...(terminalHeight !== undefined ? { terminalHeight } : {}),
};
}