Add workspace terminal panel

This commit is contained in:
Federico Jaramillo Martinez
2026-05-08 15:58:02 +02:00
parent 8b355f414b
commit a647f255a2
16 changed files with 625 additions and 9 deletions
+34
View File
@@ -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",
+3
View File
@@ -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": {
+3 -3
View File
@@ -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";
+8
View File
@@ -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,
};
+6 -1
View File
@@ -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<string, unknown> {
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");
+4
View File
@@ -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}`;
+257
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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`
<section class="terminal-shell">
<div class="terminal-tabs">
${this.terminals.map((terminal) => html`
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
<span>${terminal.name}${terminal.exited ? " · exited" : ""}</span>
<small @click=${(event: Event) => { void this.closeTerminal(terminal.id, event); }}>×</small>
</button>
`)}
<button class="new" ?disabled=${this.workspace === undefined} @click=${() => { void this.startTerminal(); }}>+ Shell</button>
</div>
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
<div class="terminal-host"></div>
</section>
`;
}
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<string> {
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<string, unknown> {
return typeof value === "object" && value !== null;
}
+5 -4
View File
@@ -27,8 +27,9 @@ export class WorkspacePanel extends LitElement {
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
override render() {
if (!this.workspace) return html`<section class="empty">Select a workspace.</section>`;
const visiblePanels = this.panels.filter((panel) => panel.visible?.(this.workspace as Workspace) ?? true);
const workspace = this.workspace;
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true);
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
return html`
<header>
@@ -37,9 +38,9 @@ export class WorkspacePanel extends LitElement {
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${panel.title}</button>
`)}
</div>
<small title=${this.workspace.path}>${this.workspace.label}</small>
<small title=${workspace.path}>${workspace.label}</small>
</header>
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(this.workspace))}
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(workspace))}
`;
}
+11
View File
@@ -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`<terminal-panel .workspace=${context.workspace}></terminal-panel>`;
}
function renderGit(context: WorkspacePanelContext): TemplateResult {
const status = context.gitStatus;
return html`
+1 -1
View File
@@ -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", () => {
+2
View File
@@ -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" });
+5
View File
@@ -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<void> {
if (shuttingDown) return;
shuttingDown = true;
app.log.info({ signal }, "shutting down session daemon");
terminals.dispose();
await sessions.dispose();
await app.close();
}
+74
View File
@@ -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<unknown> {
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);
}
+72
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null;
}
+131
View File
@@ -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<string, TerminalRecord>();
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);
}
+9
View File
@@ -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;