mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 12:48:48 +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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user