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
+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);
}