mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +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;
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user