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
+4 -1
View File
@@ -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:
+5
View File
@@ -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=="],
+2 -1
View File
@@ -66,6 +66,7 @@
"yaml": "^2.8.3"
},
"dependencies": {
"keytar": "^7.9.0"
"keytar": "^7.9.0",
"node-pty": "^1.1.0"
}
}
+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 } : {}),
};
}
+189
View File
@@ -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>): ProjectRecord {
return {
id: 'project-1',
name: 'Project One',
path: 'C:\\workspace\\project-one',
addedAt: TIMESTAMP,
...overrides,
};
}
function createSession(patternId: string, overrides?: Partial<SessionRecord>): 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>): 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<string, unknown>;
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);
});
});
+209
View File
@@ -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<string> {
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');
});
});
+7
View File
@@ -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',