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
+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", () => {