From a647f255a2968bc3fb74db4266b24fc9f3f3fb56 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 8 May 2026 15:58:02 +0200 Subject: [PATCH] Add workspace terminal panel --- package-lock.json | 34 +++ package.json | 3 + src/client/src/api.ts | 6 +- src/client/src/api/clients.ts | 8 + src/client/src/api/parsers.ts | 7 +- src/client/src/api/sockets.ts | 4 + src/client/src/components/TerminalPanel.ts | 257 ++++++++++++++++++++ src/client/src/components/WorkspacePanel.ts | 9 +- src/client/src/plugins/core/panels.ts | 11 + src/client/src/plugins/registry.test.ts | 2 +- src/server/index.ts | 2 + src/server/sessiond.ts | 5 + src/server/terminalProxyRoutes.ts | 74 ++++++ src/server/terminals/terminalRoutes.ts | 72 ++++++ src/server/terminals/terminalService.ts | 131 ++++++++++ src/shared/apiTypes.ts | 9 + 16 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 src/client/src/components/TerminalPanel.ts create mode 100644 src/server/terminalProxyRoutes.ts create mode 100644 src/server/terminals/terminalRoutes.ts create mode 100644 src/server/terminals/terminalService.ts diff --git a/package-lock.json b/package-lock.json index f2dd261..fc56d1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,13 @@ "@earendil-works/pi-coding-agent": "^0.74.0", "@fastify/static": "^8.3.0", "@fastify/websocket": "^11.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "fastify": "^5.6.1", "lit": "^3.3.1", "marked": "^18.0.3", + "node-pty": "^1.1.0", "ws": "^8.18.3" }, "devDependencies": { @@ -3968,6 +3971,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -5909,6 +5927,12 @@ "node": ">= 0.4.0" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -5947,6 +5971,16 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 54a59aa..352fc45 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,13 @@ "@earendil-works/pi-coding-agent": "^0.74.0", "@fastify/static": "^8.3.0", "@fastify/websocket": "^11.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "fastify": "^5.6.1", "lit": "^3.3.1", "marked": "^18.0.3", + "node-pty": "^1.1.0", "ws": "^8.18.3" }, "devDependencies": { diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 031927f..0ba6786 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ -export { api, filesApi, gitApi, projectsApi, sessionsApi, workspacesApi } from "./api/clients"; -export { globalSessionEvents, sessionEvents } from "./api/sockets"; -export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, Workspace } from "../../shared/apiTypes"; +export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; +export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets"; +export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 4bd46c0..f0677a4 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -19,6 +19,7 @@ import { parseSessionStatus, parseSlashCommand, parseStopped, + parseTerminalInfo, parseWorkspace, } from "./parsers"; import { gitDiffUrl, messageUrl } from "./urls"; @@ -52,6 +53,12 @@ export const sessionsApi = { restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }), }; +export const terminalsApi = { + terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)), + startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }), + closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }), +}; + export const filesApi = { files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), }; @@ -65,6 +72,7 @@ export const api = { ...projectsApi, ...workspacesApi, ...sessionsApi, + ...terminalsApi, ...filesApi, ...gitApi, }; diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c13b022..5dd8905 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, Workspace } from "../../../shared/apiTypes"; +import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -194,6 +194,11 @@ export function parseGitDiffResponse(value: unknown): GitDiffResponse { return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") }; } +export function parseTerminalInfo(value: unknown): TerminalInfo { + const record = requireRecord(value); + return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) }; +} + export function parseCommandResult(value: unknown): CommandResult { const record = requireRecord(value); const type = requireString(record, "type"); diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index 762a106..f3141ff 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -6,6 +6,10 @@ export function globalSessionEvents(): WebSocket { return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`); } +export function terminalSocket(projectId: string, workspaceId: string, terminalId: string): WebSocket { + return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket`); +} + function webSocketBaseUrl(): string { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${location.host}`; diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts new file mode 100644 index 0000000..5c23637 --- /dev/null +++ b/src/client/src/components/TerminalPanel.ts @@ -0,0 +1,257 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import { terminalSocket, terminalsApi, type TerminalInfo, type Workspace } from "../api"; + +@customElement("terminal-panel") +export class TerminalPanel extends LitElement { + @property({ attribute: false }) workspace: Workspace | undefined; + @query(".terminal-host") private terminalHost?: HTMLDivElement; + @state() private terminals: TerminalInfo[] = []; + @state() private selectedId: string | undefined; + @state() private loading = false; + @state() private error: string | undefined; + @state() private visible = false; + + private terminal: Terminal | undefined; + private fitAddon: FitAddon | undefined; + private socket: WebSocket | undefined; + private resizeObserver: ResizeObserver | undefined; + private intersectionObserver: IntersectionObserver | undefined; + private observedCwd: string | undefined; + private loadedCwd: string | undefined; + + override firstUpdated(): void { + this.intersectionObserver = new IntersectionObserver((entries) => { + this.visible = entries[0]?.isIntersecting === true; + }); + this.intersectionObserver.observe(this); + } + + override disconnectedCallback(): void { + this.intersectionObserver?.disconnect(); + this.intersectionObserver = undefined; + this.disposeTerminalView(); + super.disconnectedCallback(); + } + + override willUpdate(): void { + const cwd = this.workspace?.path; + if (cwd !== this.observedCwd) { + this.observedCwd = cwd; + this.loadedCwd = undefined; + this.terminals = []; + this.selectedId = undefined; + this.disposeTerminalView(); + } + } + + override updated(): void { + this.loadVisibleWorkspaceTerminals(); + this.ensureTerminalView(); + } + + private loadVisibleWorkspaceTerminals(): void { + const cwd = this.workspace?.path; + if (!this.visible || cwd === undefined || cwd === this.loadedCwd) return; + this.loadedCwd = cwd; + void this.loadTerminals(); + } + + private async loadTerminals(): Promise { + this.loading = true; + this.error = undefined; + try { + if (this.workspace === undefined) return; + const terminals = await terminalsApi.terminals(this.workspace.projectId, this.workspace.id); + this.terminals = terminals; + this.selectedId = terminals.find((terminal) => !terminal.exited)?.id ?? terminals[0]?.id; + if (terminals.length === 0) await this.startTerminal(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.loading = false; + } + } + + private async startTerminal(): Promise { + if (this.workspace === undefined) return; + this.error = undefined; + try { + const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, { cols: 100, rows: 30 }); + this.terminals = [...this.terminals, terminal]; + this.selectTerminal(terminal.id); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } + } + + private async closeTerminal(id: string, event: Event): Promise { + event.stopPropagation(); + try { + if (this.workspace === undefined) return; + await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id); + const next = this.terminals.filter((terminal) => terminal.id !== id); + this.terminals = next; + if (this.selectedId === id) { + this.selectedId = next[0]?.id; + this.disposeTerminalView(); + } + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } + } + + private selectTerminal(id: string): void { + if (this.selectedId === id) return; + this.selectedId = id; + this.disposeTerminalView(); + } + + private ensureTerminalView(): void { + const workspace = this.workspace; + if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || this.terminalHost === undefined || workspace === undefined) return; + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + fontSize: 13, + theme: { background: "#05070a", foreground: "#e6edf3", cursor: "#58a6ff", selectionBackground: "#264f78" }, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(this.terminalHost); + this.terminal = terminal; + this.fitAddon = fitAddon; + this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); }); + this.resizeObserver.observe(this.terminalHost); + terminal.onData((data) => { this.send({ type: "input", data }); }); + this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal); + requestAnimationFrame(() => { this.fitAndNotify(); }); + terminal.focus(); + } + + private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal): void { + const socket = terminalSocket(projectId, workspaceId, terminalId); + socket.binaryType = "arraybuffer"; + this.socket = socket; + socket.addEventListener("open", () => { this.fitAndNotify(); }); + socket.addEventListener("message", (event) => { + void this.handleSocketMessage(event.data, terminalId, terminal); + }); + socket.addEventListener("close", () => { + if (this.socket === socket) this.socket = undefined; + }); + } + + private async handleSocketMessage(data: unknown, terminalId: string, terminal: Terminal): Promise { + try { + const message = parseServerMessage(await socketDataToString(data)); + if (message.type === "output") terminal.write(message.data); + if (message.type === "exit") { + terminal.writeln(`\r\n[process exited${message.exitCode === undefined ? "" : ` with code ${String(message.exitCode)}`}]`); + this.terminals = this.terminals.map((item) => item.id === terminalId ? { ...item, exited: true, ...(message.exitCode === undefined ? {} : { exitCode: message.exitCode }) } : item); + } + if (message.type === "error") terminal.writeln(`\r\n[terminal error: ${message.message}]`); + } catch (error) { + terminal.writeln(`\r\n[terminal error: ${error instanceof Error ? error.message : String(error)}]`); + } + } + + private fitAndNotify(): void { + if (this.fitAddon === undefined || this.terminal === undefined) return; + this.fitAddon.fit(); + this.send({ type: "resize", cols: this.terminal.cols, rows: this.terminal.rows }); + } + + private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); + } + + private disposeTerminalView(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.socket?.close(); + this.socket = undefined; + this.terminal?.dispose(); + this.terminal = undefined; + this.fitAddon = undefined; + } + + override render() { + return html` +
+
+ ${this.terminals.map((terminal) => html` + + `)} + +
+ ${this.error === undefined ? null : html`

${this.error}

`} + ${this.loading ? html`

Loading terminals…

` : null} +
+
+ `; + } + + static override styles = css` + :host { flex: 1 1 auto; min-height: 0; display: flex; } + .terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #05070a; } + .terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid #21262d; background: #0d1117; overflow: auto; } + button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; } + button.selected { border-color: #58a6ff; background: #0d2847; } + button.new { flex: 0 0 auto; color: #8b949e; } + button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + button small { color: #8b949e; font-size: 14px; line-height: 1; } + button small:hover { color: #ff7b72; } + button:disabled { opacity: .5; cursor: not-allowed; } + .terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; } + .terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; } + .terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; } + .terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; } + .terminal-host .xterm-helper-textarea { position: absolute !important; left: -9999em !important; top: 0 !important; width: 0 !important; height: 0 !important; min-width: 0 !important; min-height: 0 !important; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5 !important; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; } + .terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: #05070a; } + .terminal-host .xterm-screen { position: relative; } + .terminal-host .xterm-screen canvas { position: absolute; left: 0; top: 0; } + .terminal-host .xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; } + .terminal-host .xterm-accessibility:not(.debug), .terminal-host .xterm-message { position: absolute; inset: 0; z-index: 10; color: transparent; pointer-events: none; } + .terminal-host .xterm-accessibility-tree:not(.debug) *::selection { color: transparent; } + .terminal-host .xterm-accessibility-tree { font-family: monospace; user-select: text; white-space: pre; } + .terminal-host .xterm-accessibility-tree > div { transform-origin: left; width: fit-content; } + .terminal-host .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } + .error { flex: 0 0 auto; margin: 0; padding: 8px; color: #ff7b72; border-bottom: 1px solid #30363d; background: #161b22; } + .muted { margin: 10px; color: #8b949e; } + .xterm { height: 100%; } + `; +} + +type ServerTerminalMessage = + | { type: "output"; data: string } + | { type: "exit"; exitCode?: number } + | { type: "error"; message: string }; + +function parseServerMessage(data: string): ServerTerminalMessage { + const value: unknown = JSON.parse(data); + if (!isRecord(value)) return { type: "error", message: "Invalid terminal message" }; + const record = value; + if (record["type"] === "output" && typeof record["data"] === "string") return { type: "output", data: record["data"] }; + if (record["type"] === "exit") return { type: "exit", ...(typeof record["exitCode"] === "number" ? { exitCode: record["exitCode"] } : {}) }; + if (record["type"] === "error" && typeof record["message"] === "string") return { type: "error", message: record["message"] }; + return { type: "error", message: "Invalid terminal message" }; +} + +async function socketDataToString(data: unknown): Promise { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + if (data instanceof Blob) return await data.text(); + return String(data); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 9b1b315..751ba02 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -27,8 +27,9 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined; override render() { - if (!this.workspace) return html`
Select a workspace.
`; - const visiblePanels = this.panels.filter((panel) => panel.visible?.(this.workspace as Workspace) ?? true); + const workspace = this.workspace; + if (workspace === undefined) return html`
Select a workspace.
`; + const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true); const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0]; return html`
@@ -37,9 +38,9 @@ export class WorkspacePanel extends LitElement { `)} - ${this.workspace.label} + ${workspace.label}
- ${selectedPanel === undefined ? html`
No workspace panels registered.
` : selectedPanel.render(this.createPanelContext(this.workspace))} + ${selectedPanel === undefined ? html`
No workspace panels registered.
` : selectedPanel.render(this.createPanelContext(workspace))} `; } diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 460c865..f173aae 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -2,6 +2,7 @@ import { html, type TemplateResult } from "lit"; import type { FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api"; import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types"; import "../../components/CodeViewer"; +import "../../components/TerminalPanel"; export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { return [ @@ -18,6 +19,12 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { visible: (workspace) => workspace.isGitRepo, render: renderGit, }, + { + id: "workspace.terminal", + title: "Terminal", + order: 30, + render: renderTerminal, + }, ]; } @@ -67,6 +74,10 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult { `; } +function renderTerminal(context: WorkspacePanelContext): TemplateResult { + return html``; +} + function renderGit(context: WorkspacePanelContext): TemplateResult { const status = context.gitStatus; return html` diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index c231262..25c575a 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -28,7 +28,7 @@ describe("PluginRegistry", () => { registry.register(corePlugin); expect(registry.getActions(createContext().context).some((action) => action.id === "core:actions.show")).toBe(true); - expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git"]); + expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]); }); it("rejects duplicate ids within the same namespace", () => { diff --git a/src/server/index.ts b/src/server/index.ts index 42b1c79..3335dab 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,6 +11,7 @@ import { listDirectorySuggestions } from "./projects/directorySuggestions.js"; import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js"; import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js"; import { registerGitRoutes } from "./gitRoutes.js"; +import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; const app = Fastify({ logger: true }); await app.register(fastifyWebsocket); @@ -57,6 +58,7 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces" registerSessionProxyRoutes(app); registerWorkspaceExplorerRoutes(app, projects, workspaces); registerGitRoutes(app, projects, workspaces); +registerTerminalProxyRoutes(app, projects, workspaces); app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => { if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 9691733..2aabcab 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -6,13 +6,17 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; import { sessiondSocketPath } from "./sessiond/config.js"; +import { TerminalService } from "./terminals/terminalService.js"; +import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; const app = Fastify({ logger: true }); await app.register(fastifyWebsocket); const eventHub = new SessionEventHub(); const sessions = new PiSessionService(eventHub); +const terminals = new TerminalService(); registerSessionRoutes(app, sessions, eventHub); +registerTerminalRoutes(app, terminals); app.get("/health", () => ({ ok: true, activeSessions: sessions.activeCount(), checkedAt: new Date().toISOString() })); @@ -21,6 +25,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { if (shuttingDown) return; shuttingDown = true; app.log.info({ signal }, "shutting down session daemon"); + terminals.dispose(); await sessions.dispose(); await app.close(); } diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts new file mode 100644 index 0000000..1ec648d --- /dev/null +++ b/src/server/terminalProxyRoutes.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance, FastifyReply } from "fastify"; +import { WebSocket, type RawData } from "ws"; +import type { ProjectService } from "./projects/projectService.js"; +import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; +import type { WorkspaceService } from "./workspaces/workspaceService.js"; + +export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void { + app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply); + } catch (error) { + requestFailed(reply, error); + return undefined; + } + }); + + app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply); + } catch (error) { + requestFailed(reply, error); + return undefined; + } + }); + + app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => { + try { + await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply); + } catch (error) { + requestFailed(reply, error); + return undefined; + } + }); + + app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => { + try { + await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + bridgeSockets(socket, daemon.connectWebSocket(`/terminals/${request.params.terminalId}/socket`)); + } catch (error) { + socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })); + socket.close(); + } + }); +} + +async function proxyJson(daemon: SessionDaemonClient, method: string, path: string, body: unknown, reply: FastifyReply): Promise { + const upstream = await daemon.request(method, path, body); + reply.code(upstream.statusCode); + const contentType = upstream.headers["content-type"]; + if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType); + const value: unknown = upstream.body !== "" ? JSON.parse(upstream.body) : undefined; + return value; +} + +function requestFailed(reply: FastifyReply, error: unknown): void { + reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); +} + +function bridgeSockets(client: WebSocket, upstream: WebSocket): void { + client.on("message", (data) => { sendIfOpen(upstream, data); }); + upstream.on("message", (data) => { sendIfOpen(client, data); }); + client.on("close", () => { upstream.close(); }); + upstream.on("close", () => { client.close(); }); + upstream.on("error", () => { client.close(); }); + client.on("error", () => { upstream.close(); }); +} + +function sendIfOpen(socket: WebSocket, data: RawData): void { + if (socket.readyState === WebSocket.OPEN) socket.send(data); +} diff --git a/src/server/terminals/terminalRoutes.ts b/src/server/terminals/terminalRoutes.ts new file mode 100644 index 0000000..5276b1a --- /dev/null +++ b/src/server/terminals/terminalRoutes.ts @@ -0,0 +1,72 @@ +import type { FastifyInstance } from "fastify"; +import type { RawData } from "ws"; +import type { TerminalService } from "./terminalService.js"; + +export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalService, prefix = ""): void { + app.get<{ Querystring: { cwd?: string } }>(`${prefix}/terminals`, (request, reply) => { + if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); + return terminals.list(request.query.cwd); + }); + + app.post<{ Body: { cwd: string; name?: string; cols?: number; rows?: number } }>(`${prefix}/terminals`, (request, reply) => { + try { + return terminals.create(request.body); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.delete<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId`, (request) => { + terminals.close(request.params.terminalId); + return { closed: true }; + }); + + app.get<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId/socket`, { websocket: true }, (socket, request) => { + let detach: (() => void) | undefined; + try { + detach = terminals.attach(request.params.terminalId, { + output: (data) => { socket.send(JSON.stringify({ type: "output", data })); }, + exit: (exitCode) => { socket.send(JSON.stringify({ type: "exit", exitCode })); }, + }); + } catch (error) { + socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })); + socket.close(); + return; + } + + socket.on("message", (data) => { + try { + const message = parseClientMessage(data); + if (message.type === "input") terminals.write(request.params.terminalId, message.data); + if (message.type === "resize") terminals.resize(request.params.terminalId, message.cols, message.rows); + } catch (error) { + socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })); + } + }); + socket.on("close", () => { detach(); }); + socket.on("error", () => { detach(); }); + }); +} + +type ClientTerminalMessage = + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number }; + +function parseClientMessage(data: RawData): ClientTerminalMessage { + const value: unknown = JSON.parse(rawDataToString(data)); + if (!isRecord(value) || typeof value["type"] !== "string") throw new Error("Invalid terminal message"); + if (value["type"] === "input" && typeof value["data"] === "string") return { type: "input", data: value["data"] }; + if (value["type"] === "resize" && typeof value["cols"] === "number" && typeof value["rows"] === "number") return { type: "resize", cols: value["cols"], rows: value["rows"] }; + throw new Error("Invalid terminal message"); +} + +function rawDataToString(data: RawData): string { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return data.toString("utf8"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts new file mode 100644 index 0000000..e55b01a --- /dev/null +++ b/src/server/terminals/terminalService.ts @@ -0,0 +1,131 @@ +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import * as pty from "node-pty"; + +const MAX_REPLAY_BUFFER = 200_000; + +export interface TerminalInfo { + id: string; + cwd: string; + name: string; + createdAt: string; + exited: boolean; + exitCode?: number; +} + +interface TerminalRecord extends TerminalInfo { + pty: pty.IPty; + buffer: string; + events: EventEmitter; +} + +export class TerminalService { + private readonly terminals = new Map(); + + list(cwd: string): TerminalInfo[] { + return [...this.terminals.values()] + .filter((terminal) => terminal.cwd === cwd) + .map(toInfo); + } + + create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo { + if (options.cwd === "") throw new Error("cwd is required"); + const id = randomUUID(); + const createdAt = new Date().toISOString(); + const shell = process.env["SHELL"] ?? "/bin/bash"; + const terminal = pty.spawn(shell, [], { + name: "xterm-256color", + cwd: options.cwd, + cols: options.cols ?? 100, + rows: options.rows ?? 30, + env: { ...process.env, TERM: "xterm-256color" }, + }); + const requestedName = options.name?.trim(); + const record: TerminalRecord = { + id, + cwd: options.cwd, + name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`, + createdAt, + exited: false, + pty: terminal, + buffer: "", + events: new EventEmitter(), + }; + terminal.onData((data) => { + record.buffer = trimReplayBuffer(record.buffer + data); + record.events.emit("output", data); + }); + terminal.onExit(({ exitCode }) => { + record.exited = true; + record.exitCode = exitCode; + record.events.emit("exit", exitCode); + }); + this.terminals.set(id, record); + return toInfo(record); + } + + get(id: string): TerminalInfo | undefined { + const terminal = this.terminals.get(id); + return terminal === undefined ? undefined : toInfo(terminal); + } + + attach(id: string, handlers: { output: (data: string) => void; exit: (exitCode: number | undefined) => void }): () => void { + const terminal = this.require(id); + if (terminal.buffer !== "") handlers.output(terminal.buffer); + if (terminal.exited) handlers.exit(terminal.exitCode); + const onOutput = (data: string) => { handlers.output(data); }; + const onExit = (exitCode: number | undefined) => { handlers.exit(exitCode); }; + terminal.events.on("output", onOutput); + terminal.events.on("exit", onExit); + return () => { + terminal.events.off("output", onOutput); + terminal.events.off("exit", onExit); + }; + } + + write(id: string, data: string): void { + const terminal = this.require(id); + if (!terminal.exited) terminal.pty.write(data); + } + + resize(id: string, cols: number, rows: number): void { + const terminal = this.require(id); + if (!terminal.exited && Number.isFinite(cols) && Number.isFinite(rows) && cols > 0 && rows > 0) { + terminal.pty.resize(Math.floor(cols), Math.floor(rows)); + } + } + + close(id: string): void { + const terminal = this.terminals.get(id); + if (terminal === undefined) return; + this.terminals.delete(id); + terminal.events.removeAllListeners(); + if (!terminal.exited) terminal.pty.kill(); + } + + dispose(): void { + for (const id of [...this.terminals.keys()]) this.close(id); + } + + private require(id: string): TerminalRecord { + const terminal = this.terminals.get(id); + if (terminal === undefined) throw new Error("Terminal not found"); + return terminal; + } +} + +function toInfo(record: TerminalRecord): TerminalInfo { + return { + id: record.id, + cwd: record.cwd, + name: record.name, + createdAt: record.createdAt, + exited: record.exited, + ...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }), + }; +} + +function trimReplayBuffer(buffer: string): string { + if (buffer.length <= MAX_REPLAY_BUFFER) return buffer; + return buffer.slice(buffer.length - MAX_REPLAY_BUFFER); +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index ce23abc..47eb8e7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -114,6 +114,15 @@ export interface GitDiffResponse { truncated: boolean; } +export interface TerminalInfo { + id: string; + cwd: string; + name: string; + createdAt: string; + exited: boolean; + exitCode?: number; +} + export interface CommandOption { value: string; label: string;