mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
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:
+4
-1
@@ -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:
|
||||
|
||||
@@ -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
@@ -66,6 +66,7 @@
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface TerminalSnapshot {
|
||||
cwd: string;
|
||||
shell: string;
|
||||
pid: number;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalExitInfo {
|
||||
exitCode: number;
|
||||
signal?: number;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user