diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 80d6176..ed2bf03 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,7 +55,7 @@ flowchart LR | --- | --- | --- | --- | | Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events | | Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` | -| Main process | Workspace mutation, persistence, git inspection, session lifecycle, native window state, sidecar lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar | +| Main process | Workspace mutation, persistence, git inspection, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | | Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio | | External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar | @@ -187,11 +187,14 @@ Typical examples: - create session - send message - update theme +- create or restart the integrated terminal - toggle session tooling - update session approval overrides The renderer does not reach into Electron or the filesystem directly. It talks through a constrained API surface. +The integrated terminal uses the same boundary. The renderer never opens a shell directly; it asks the main process to create or restart a PTY, sends fire-and-forget input and resize messages over IPC, and listens for streamed terminal data and exit events pushed back through preload. + ### 2. Main process <-> sidecar This is a structured stdio protocol used for: diff --git a/bun.lock b/bun.lock index 31c8618..344cfbc 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "kopaya", "dependencies": { "keytar": "^7.9.0", + "node-pty": "^1.1.0", }, "devDependencies": { "@dagrejs/dagre": "^3.0.0", @@ -836,6 +837,8 @@ "node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], @@ -1094,6 +1097,8 @@ "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "node-pty/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/package.json b/package.json index db9f1aa..7a987a4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "yaml": "^2.8.3" }, "dependencies": { - "keytar": "^7.9.0" + "keytar": "^7.9.0", + "node-pty": "^1.1.0" } } diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 38444cc..5746d66 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { private readonly configScanner = new ConfigScannerRegistry(); private readonly customizationScanner = new ProjectCustomizationScanner(); private readonly probeMcpServers = probeServers; + private readonly ptyManager = new PtyManager(); private readonly pendingApprovalHandles = new Map(); private readonly pendingUserInputHandles = new Map(); private workspace?: WorkspaceState; @@ -190,6 +196,17 @@ export class AryxAppService extends EventEmitter { 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 { return this.loadSidecarCapabilities(); } @@ -241,6 +258,7 @@ export class AryxAppService extends EventEmitter { } async dispose(): Promise { + this.ptyManager.dispose(); await this.sidecar.dispose(); void this.secretStore; } @@ -464,6 +482,53 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setTerminalHeight(height?: number): Promise { + 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 { + return this.ptyManager.getSnapshot(); + } + + async createTerminal(): Promise { + const workspace = await this.loadWorkspace(); + return this.ptyManager.create(this.resolveTerminalWorkingDirectory(workspace)); + } + + async restartTerminal(): Promise { + const workspace = await this.loadWorkspace(); + return this.ptyManager.restart(this.resolveTerminalWorkingDirectory(workspace)); + } + + async killTerminal(): Promise { + 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 { if (isBuiltinPattern(patternId)) { throw new Error('Built-in patterns cannot be deleted.'); @@ -1317,6 +1382,25 @@ export class AryxAppService extends EventEmitter { 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 { if (isScratchpadProject(project)) { if (!project.git) { diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 4f020d6..0d8cc15 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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); + }); } diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts new file mode 100644 index 0000000..3812b4c --- /dev/null +++ b/src/main/services/ptyManager.ts @@ -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; +}; + +type PtySpawn = ( + file: string, + args: string[], + options: PtySpawnOptions, +) => ManagedPty | Promise; + +type CommandExists = ( + command: string, + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +) => Promise; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c54db14..dc7e6d5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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[0]) => + listener(data); + + ipcRenderer.on(ipcChannels.terminalData, handler); + return () => ipcRenderer.off(ipcChannels.terminalData, handler); + }, + onTerminalExit: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, info: Parameters[0]) => + listener(info); + + ipcRenderer.on(ipcChannels.terminalExit, handler); + return () => ipcRenderer.off(ipcChannels.terminalExit, handler); + }, onWorkspaceUpdated:(listener) => { const handler = (_event: Electron.IpcRendererEvent, workspace: Awaited>) => listener(workspace); diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 1d3809d..15b3435 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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; diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 3f560fd..cf22cab 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -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; refreshSidecarCapabilities(): Promise; @@ -183,8 +193,17 @@ export interface ElectronApi { selectSession(sessionId?: string): Promise; setPatternFavorite(input: SetPatternFavoriteInput): Promise; setTheme(theme: AppearanceTheme): Promise; + setTerminalHeight(input: SetTerminalHeightInput): Promise; + describeTerminal(): Promise; + createTerminal(): Promise; + restartTerminal(): Promise; + killTerminal(): Promise; + writeTerminal(data: string): void; + resizeTerminal(input: ResizeTerminalInput): void; openAppDataFolder(): Promise; resetLocalWorkspace(): Promise; + onTerminalData(listener: (data: string) => void): () => void; + onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void; onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void; onSessionEvent(listener: (event: SessionEventRecord) => void): () => void; } diff --git a/src/shared/domain/terminal.ts b/src/shared/domain/terminal.ts new file mode 100644 index 0000000..bdb6da6 --- /dev/null +++ b/src/shared/domain/terminal.ts @@ -0,0 +1,12 @@ +export interface TerminalSnapshot { + cwd: string; + shell: string; + pid: number; + cols: number; + rows: number; +} + +export interface TerminalExitInfo { + exitCode: number; + signal?: number; +} diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index e1c23bb..609edee 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -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 { + const terminalHeight = normalizeTerminalHeight(settings?.terminalHeight); return { theme: normalizeTheme(settings?.theme), tooling: { @@ -193,6 +204,7 @@ export function normalizeWorkspaceSettings(settings?: Partial lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition), }, discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling), + ...(terminalHeight !== undefined ? { terminalHeight } : {}), }; } diff --git a/tests/main/appServiceTerminal.test.ts b/tests/main/appServiceTerminal.test.ts new file mode 100644 index 0000000..6fa8425 --- /dev/null +++ b/tests/main/appServiceTerminal.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import type { ProjectRecord } from '@shared/domain/project'; +import type { TerminalSnapshot } from '@shared/domain/terminal'; +import type { SessionRecord } from '@shared/domain/session'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +const TIMESTAMP = '2026-03-28T00:00:00.000Z'; + +mock.module('electron', () => { + const electronMock = { + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', + getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures', + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { AryxAppService } = await import('@main/AryxAppService'); + +function createProject(overrides?: Partial): ProjectRecord { + return { + id: 'project-1', + name: 'Project One', + path: 'C:\\workspace\\project-one', + addedAt: TIMESTAMP, + ...overrides, + }; +} + +function createSession(patternId: string, overrides?: Partial): SessionRecord { + return { + id: 'session-1', + projectId: 'project-1', + patternId, + title: 'Terminal Session', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + status: 'idle', + messages: [], + runs: [], + ...overrides, + }; +} + +function createTerminalSnapshot(overrides?: Partial): TerminalSnapshot { + return { + cwd: 'C:\\workspace\\project-one', + shell: 'PowerShell', + pid: 100, + cols: 80, + rows: 24, + ...overrides, + }; +} + +function createService(workspace: WorkspaceState, terminalSnapshot = createTerminalSnapshot()) { + let saveCalls = 0; + const terminalCreateCwds: string[] = []; + const terminalRestartCwds: string[] = []; + const writes: string[] = []; + const resizes: Array<{ cols: number; rows: number }> = []; + let killCalls = 0; + const service = new AryxAppService(); + const internals = service as unknown as Record; + + internals.workspaceRepository = { + load: async () => workspace, + save: async () => { + saveCalls += 1; + }, + }; + internals.syncUserDiscoveredTooling = async () => false; + internals.syncProjectDiscoveredTooling = async () => false; + internals.syncProjectCustomization = async () => false; + internals.pruneUnavailableSessionToolingSelections = () => false; + internals.pruneUnavailableApprovalTools = async () => false; + internals.didScheduleInitialProjectGitRefresh = true; + internals.ptyManager = { + create: async (cwd: string) => { + terminalCreateCwds.push(cwd); + return { ...terminalSnapshot, cwd }; + }, + restart: async (cwd: string) => { + terminalRestartCwds.push(cwd); + return { ...terminalSnapshot, cwd }; + }, + kill: () => { + killCalls += 1; + }, + write: (data: string) => { + writes.push(data); + }, + resize: (cols: number, rows: number) => { + resizes.push({ cols, rows }); + }, + getSnapshot: () => terminalSnapshot, + dispose: () => undefined, + on: () => internals.ptyManager, + }; + + return { + service, + getSaveCalls: () => saveCalls, + terminalCreateCwds, + terminalRestartCwds, + writes, + resizes, + getKillCalls: () => killCalls, + }; +} + +describe('AryxAppService terminal integration', () => { + test('uses the selected session cwd when creating and restarting the terminal', async () => { + const workspace = createWorkspaceSeed(); + const pattern = workspace.patterns[0]; + if (!pattern) { + throw new Error('Expected a seeded pattern.'); + } + + workspace.projects = [createProject()]; + workspace.sessions = [createSession(pattern.id, { cwd: 'C:\\workspace\\scratchpad\\session-1' })]; + workspace.selectedProjectId = 'project-1'; + workspace.selectedSessionId = 'session-1'; + + const { service, terminalCreateCwds, terminalRestartCwds } = createService(workspace); + + await service.createTerminal(); + await service.restartTerminal(); + + expect(terminalCreateCwds).toEqual(['C:\\workspace\\scratchpad\\session-1']); + expect(terminalRestartCwds).toEqual(['C:\\workspace\\scratchpad\\session-1']); + }); + + test('falls back to the selected project path and delegates terminal controls', async () => { + const workspace = createWorkspaceSeed(); + workspace.projects = [createProject()]; + workspace.selectedProjectId = 'project-1'; + + const { service, terminalCreateCwds, writes, resizes, getKillCalls } = createService(workspace); + + await expect(service.describeTerminal()).resolves.toEqual(createTerminalSnapshot()); + await service.createTerminal(); + service.writeTerminal('dir\r'); + service.resizeTerminal(120, 40); + await service.killTerminal(); + + expect(terminalCreateCwds).toEqual(['C:\\workspace\\project-one']); + expect(writes).toEqual(['dir\r']); + expect(resizes).toEqual([{ cols: 120, rows: 40 }]); + expect(getKillCalls()).toBe(1); + }); + + test('normalizes and persists terminal height settings', async () => { + const workspace = createWorkspaceSeed(); + const { service, getSaveCalls } = createService(workspace); + + await service.setTerminalHeight(240.4); + expect(workspace.settings.terminalHeight).toBe(240); + expect(getSaveCalls()).toBe(1); + + await service.setTerminalHeight(240); + expect(getSaveCalls()).toBe(1); + + await service.setTerminalHeight(0); + expect(workspace.settings.terminalHeight).toBeUndefined(); + expect(getSaveCalls()).toBe(2); + }); +}); diff --git a/tests/main/ptyManager.test.ts b/tests/main/ptyManager.test.ts new file mode 100644 index 0000000..133e7a3 --- /dev/null +++ b/tests/main/ptyManager.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal'; +import { PtyManager } from '@main/services/ptyManager'; + +class FakePty { + readonly pid: number; + readonly writes: string[] = []; + readonly resizeCalls: Array<{ cols: number; rows: number }> = []; + killCalls = 0; + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: TerminalExitInfo) => void>(); + + constructor(pid: number) { + this.pid = pid; + } + + write(data: string): void { + this.writes.push(data); + } + + resize(cols: number, rows: number): void { + this.resizeCalls.push({ cols, rows }); + } + + kill(): void { + this.killCalls += 1; + this.emitExit({ exitCode: 0 }); + } + + onData(listener: (data: string) => void): { dispose(): void } { + this.dataListeners.add(listener); + return { + dispose: () => { + this.dataListeners.delete(listener); + }, + }; + } + + onExit(listener: (event: TerminalExitInfo) => void): { dispose(): void } { + this.exitListeners.add(listener); + return { + dispose: () => { + this.exitListeners.delete(listener); + }, + }; + } + + emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + emitExit(event: TerminalExitInfo): void { + for (const listener of this.exitListeners) { + listener(event); + } + } +} + +const tempDirectories: string[] = []; + +async function createTempDirectory(): Promise { + const path = await mkdtemp(join(tmpdir(), 'aryx-pty-')); + tempDirectories.push(path); + return path; +} + +afterEach(async () => { + while (tempDirectories.length > 0) { + const path = tempDirectories.pop(); + if (path) { + await rm(path, { force: true, recursive: true }); + } + } +}); + +describe('PtyManager', () => { + test('prefers PowerShell on Windows when available', async () => { + const cwd = await createTempDirectory(); + const spawnCalls: Array<{ file: string; args: string[]; options: { cwd: string } }> = []; + const pty = new FakePty(101); + const manager = new PtyManager({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + commandExists: async (command) => command === 'pwsh.exe', + spawnPty: async (file, args, options) => { + spawnCalls.push({ file, args, options }); + return pty; + }, + }); + + const snapshot = await manager.create(cwd); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]?.file).toBe('pwsh.exe'); + expect(spawnCalls[0]?.args).toEqual(['-NoLogo']); + expect(spawnCalls[0]?.options.cwd).toBe(cwd); + expect(snapshot).toEqual({ + cwd, + shell: 'PowerShell', + pid: 101, + cols: 80, + rows: 24, + } satisfies TerminalSnapshot); + }); + + test('forwards data, writes input, and tracks resized dimensions', async () => { + const cwd = await createTempDirectory(); + const pty = new FakePty(202); + const chunks: string[] = []; + const manager = new PtyManager({ + platform: 'linux', + env: { SHELL: '/bin/zsh', PATH: '/bin' }, + commandExists: async () => true, + spawnPty: async () => pty, + }); + + manager.on('data', (data) => { + chunks.push(data); + }); + + await manager.create(cwd); + pty.emitData('$ ready'); + manager.write('npm test\r'); + manager.resize(120.2, 41.6); + + expect(chunks).toEqual(['$ ready']); + expect(pty.writes).toEqual(['npm test\r']); + expect(pty.resizeCalls).toEqual([{ cols: 120, rows: 42 }]); + expect(manager.getSnapshot()).toEqual({ + cwd, + shell: 'zsh', + pid: 202, + cols: 120, + rows: 42, + } satisfies TerminalSnapshot); + }); + + test('kills the active terminal, emits exit, and clears the snapshot', async () => { + const cwd = await createTempDirectory(); + const exits: TerminalExitInfo[] = []; + const pty = new FakePty(303); + const manager = new PtyManager({ + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/bin' }, + commandExists: async () => true, + spawnPty: async () => pty, + }); + + manager.on('exit', (event) => { + exits.push(event); + }); + + await manager.create(cwd); + manager.kill(); + + expect(exits).toEqual([{ exitCode: 0 }]); + expect(manager.getSnapshot()).toBeUndefined(); + }); + + test('restarts without forwarding the replaced terminal exit event', async () => { + const cwd = await createTempDirectory(); + const exits: TerminalExitInfo[] = []; + const ptys = [new FakePty(404), new FakePty(405)]; + let spawnIndex = 0; + const manager = new PtyManager({ + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/bin' }, + commandExists: async () => true, + spawnPty: async () => ptys[spawnIndex++]!, + }); + + manager.on('exit', (event) => { + exits.push(event); + }); + + await manager.create(cwd); + manager.resize(132, 36); + const restarted = await manager.restart(cwd); + + expect(ptys[0].killCalls).toBe(1); + expect(exits).toEqual([]); + expect(restarted).toEqual({ + cwd, + shell: 'bash', + pid: 405, + cols: 132, + rows: 36, + } satisfies TerminalSnapshot); + }); + + test('throws when the working directory does not exist', async () => { + const manager = new PtyManager({ + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/bin' }, + commandExists: async () => true, + spawnPty: async () => new FakePty(500), + }); + + await expect(manager.create('C:\\workspace\\personal\\repositories\\aryx\\does-not-exist')) + .rejects + .toThrow('Terminal working directory'); + }); +}); diff --git a/tests/shared/tooling.test.ts b/tests/shared/tooling.test.ts index e114a07..d55924c 100644 --- a/tests/shared/tooling.test.ts +++ b/tests/shared/tooling.test.ts @@ -21,6 +21,7 @@ const TIMESTAMP = '2026-03-23T00:00:00.000Z'; describe('tooling settings helpers', () => { test('normalizes persisted MCP and LSP definitions into trimmed stable settings', () => { const workspaceSettings = normalizeWorkspaceSettings({ + terminalHeight: 240.4, tooling: { mcpServers: [ { @@ -64,6 +65,7 @@ describe('tooling settings helpers', () => { expect(workspaceSettings).toEqual({ theme: 'dark', + terminalHeight: 240, tooling: { mcpServers: [ { @@ -109,6 +111,11 @@ describe('tooling settings helpers', () => { }); }); + test('drops invalid persisted terminal height values', () => { + expect(normalizeWorkspaceSettings({ terminalHeight: 119 }).terminalHeight).toBeUndefined(); + expect(normalizeWorkspaceSettings({ terminalHeight: Number.NaN }).terminalHeight).toBeUndefined(); + }); + test('validates required MCP transport settings', () => { const localServer: McpServerDefinition = { id: 'mcp-local',