feat: add integrated terminal backend

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 23:15:16 +01:00
co-authored by Copilot
parent 651a7d27fc
commit 251316596c
14 changed files with 953 additions and 2 deletions
+84
View File
@@ -69,6 +69,7 @@ import {
type SessionQueryResult,
} from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
import {
applySessionApprovalSettings,
applySessionModelConfig,
@@ -93,6 +94,7 @@ import {
import {
createSessionToolingSelection,
listApprovalToolNames,
normalizeTerminalHeight,
normalizeTheme,
resolveProjectToolingSettings,
resolveWorkspaceToolingSettings,
@@ -129,12 +131,15 @@ import {
import { getStoredToken } from '@main/services/mcpTokenStore';
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber';
import { PtyManager } from '@main/services/ptyManager';
const { dialog, shell } = electron;
type AppServiceEvents = {
'workspace-updated': [WorkspaceState];
'session-event': [SessionEventRecord];
'terminal-data': [string];
'terminal-exit': [TerminalExitInfo];
};
type PendingApprovalHandle = {
@@ -182,6 +187,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private readonly configScanner = new ConfigScannerRegistry();
private readonly customizationScanner = new ProjectCustomizationScanner();
private readonly probeMcpServers = probeServers;
private readonly ptyManager = new PtyManager();
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
private workspace?: WorkspaceState;
@@ -190,6 +196,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private didScheduleInitialProjectGitRefresh = false;
private mcpProbeUpdateQueue = Promise.resolve();
constructor() {
super();
this.ptyManager.on('data', (data) => {
this.emit('terminal-data', data);
});
this.ptyManager.on('exit', (info) => {
this.emit('terminal-exit', info);
});
}
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
return this.loadSidecarCapabilities();
}
@@ -241,6 +258,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
async dispose(): Promise<void> {
this.ptyManager.dispose();
await this.sidecar.dispose();
void this.secretStore;
}
@@ -464,6 +482,53 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setTerminalHeight(height?: number): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const normalizedHeight = normalizeTerminalHeight(height);
if (normalizedHeight === undefined) {
if (workspace.settings.terminalHeight === undefined) {
return workspace;
}
delete workspace.settings.terminalHeight;
return this.persistAndBroadcast(workspace);
}
if (workspace.settings.terminalHeight === normalizedHeight) {
return workspace;
}
workspace.settings.terminalHeight = normalizedHeight;
return this.persistAndBroadcast(workspace);
}
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
return this.ptyManager.getSnapshot();
}
async createTerminal(): Promise<TerminalSnapshot> {
const workspace = await this.loadWorkspace();
return this.ptyManager.create(this.resolveTerminalWorkingDirectory(workspace));
}
async restartTerminal(): Promise<TerminalSnapshot> {
const workspace = await this.loadWorkspace();
return this.ptyManager.restart(this.resolveTerminalWorkingDirectory(workspace));
}
async killTerminal(): Promise<void> {
this.ptyManager.kill();
}
writeTerminal(data: string): void {
this.ptyManager.write(data);
}
resizeTerminal(cols: number, rows: number): void {
this.ptyManager.resize(cols, rows);
}
async deletePattern(patternId: string): Promise<WorkspaceState> {
if (isBuiltinPattern(patternId)) {
throw new Error('Built-in patterns cannot be deleted.');
@@ -1317,6 +1382,25 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return project;
}
private resolveTerminalWorkingDirectory(workspace: WorkspaceState): string {
const selectedSession = workspace.selectedSessionId
? workspace.sessions.find((session) => session.id === workspace.selectedSessionId)
: undefined;
if (selectedSession) {
const project = this.requireProject(workspace, selectedSession.projectId);
return selectedSession.cwd ?? project.path;
}
const selectedProject = workspace.selectedProjectId
? workspace.projects.find((project) => project.id === workspace.selectedProjectId)
: workspace.projects[0];
if (!selectedProject) {
throw new Error('Open a project or session before starting the integrated terminal.');
}
return selectedProject.path;
}
private async refreshGitContextForProject(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.git) {