feat: run workspace operations in terminals

This commit is contained in:
Federico Jaramillo Martinez
2026-05-25 15:01:56 +02:00
parent 57a6a4a69f
commit 711c4f3d98
34 changed files with 1631 additions and 300 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@jmfederico/pi-web": patch
"@jmfederico/pi-web-actions": patch
---
Run workspace deletion and configurable workspace actions in visible PI WEB terminals with reload-safe command-run tracking, mobile-friendly cancellation, and shell continuation after command completion.
+3 -2
View File
@@ -348,7 +348,8 @@ Notes:
- Other `state` fields may exist at runtime, but they are PI WEB internals and can change quickly.
- `enabled` is evaluated when the action palette asks for actions.
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal after creating one through the terminal API.
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. PI WEB may attach `piWebInternal` fields at runtime for first-party dogfooding; plugins should not depend on those fields because they can change or disappear without notice.
#### Keyboard shortcuts
@@ -399,7 +400,7 @@ interface WorkspacePanelContext {
}
```
`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. Use `openTerminal({ terminalId })` when a panel creates a terminal and wants PI WEB to navigate to that specific terminal. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated.
`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated.
Useful workspace shape:
+2 -2
View File
@@ -2,7 +2,7 @@
Configurable workspace actions for PI WEB.
The plugin adds an **Actions** workspace tab. Actions create a new PI WEB terminal, send the configured shell command, and switch to that terminal so the user can monitor progress or take over.
The plugin adds an **Actions** workspace tab. Actions run the configured shell command in a dedicated PI WEB terminal and switch to that terminal so the user can monitor progress.
## Configuration
@@ -68,7 +68,7 @@ npm pack --workspace @jmfederico/pi-web-actions --dry-run
## Beta/private API note
This plugin intentionally dogfoods private PI WEB browser APIs for reading workspace files and creating/writing terminals. Those APIs are not yet stable public plugin APIs, so compatibility is best-effort and may require updates alongside PI WEB releases.
This first-party plugin dogfoods PI WEB's internal terminal command-run helper for command execution while that API incubates. It also reads `.pi-web/actions.json` through PI WEB's private workspace file endpoint. These internals are not stable public plugin APIs yet, so compatibility is best-effort and may require updates alongside PI WEB releases.
## Notes
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { runWorkspaceActionInTerminal } from "./actionRunner";
import type { WorkspaceAction } from "./config";
import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
const run: InternalTerminalCommandRun = {
id: "run1",
origin: "actions",
projectId: workspace.projectId,
workspaceId: workspace.id,
terminalId: "term1",
title: "Build",
command: "npm run build",
status: "running",
createdAt: "2026-05-25T00:00:00.000Z",
metadata: { "pi.plugin": "actions", "action.id": "build" },
};
describe("action runner", () => {
it("starts workspace actions through the internal terminal command-run helper", async () => {
const action: WorkspaceAction = { id: "build", title: "Build", command: "npm run build", confirm: false };
const runCommand = vi.fn<InternalTerminalCommandRunsRuntime["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
const terminal: InternalTerminalCommandRunsRuntime = {
runCommand,
open: vi.fn(),
};
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
expect(handle.run).toEqual(run);
await expect(handle.completed).resolves.toEqual(run);
expect(runCommand).toHaveBeenCalledWith({
workspace,
title: "Build",
command: "npm run build",
open: true,
metadata: { "pi.plugin": "actions", "action.id": "build" },
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspaceAction } from "./config.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
export function runWorkspaceActionInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, action: WorkspaceAction): ReturnType<InternalTerminalCommandRunsRuntime["runCommand"]> {
return terminal.runCommand({
workspace,
title: action.title,
command: action.command,
open: true,
metadata: {
"pi.plugin": "actions",
"action.id": action.id,
},
});
}
+22 -10
View File
@@ -1,7 +1,8 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js";
import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js";
import { runWorkspaceActionInTerminal } from "./actionRunner.js";
import { requestPiWebRender } from "./piWebPrivateUi.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js";
export const actionsPanelTagName = "pi-web-actions-panel";
@@ -30,6 +31,7 @@ export function actionsPanelBadge(workspace: Workspace): string | number | undef
class PiWebActionsPanel extends HTMLElement {
private workspaceValue: Workspace | undefined;
private openTerminalValue: OpenTerminal | undefined;
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
private runningActionId: string | undefined;
private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined;
private readonly root: ShadowRoot;
@@ -51,6 +53,10 @@ class PiWebActionsPanel extends HTMLElement {
this.openTerminalValue = value;
}
set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
this.terminalCommandRunsValue = value;
}
connectedCallback(): void {
window.addEventListener(configChangedEvent, this.onConfigChanged);
this.render();
@@ -104,7 +110,7 @@ class PiWebActionsPanel extends HTMLElement {
if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`;
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>${this.renderStatus()}`;
return `
<p class="muted">Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
${renderActionGroups(state.config.actions, this.runningActionId)}
${this.renderStatus()}
`;
@@ -132,24 +138,26 @@ class PiWebActionsPanel extends HTMLElement {
if (this.runningActionId !== undefined) return;
if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return;
const terminal = this.terminalCommandRunsValue;
if (terminal === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal command helpers to plugins." };
this.render();
return;
}
this.runningActionId = action.id;
this.status = { kind: "info", message: `Creating terminal for ${action.title}` };
this.status = { kind: "info", message: `Starting ${action.title}` };
this.render();
try {
const terminal = await createWorkspaceTerminal(workspace, action.title);
this.status = { kind: "info", message: `Dispatching command to ${terminal.name}` };
this.render();
await sendTerminalCommand(workspace, terminal.id, action.command);
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
this.status = {
kind: "success",
message: `Dispatched to terminal “${terminal.name}”.`,
message: `Started terminal command ${handle.run.title}”.`,
detail: action.command,
};
this.runningActionId = undefined;
this.render();
this.openWorkspaceTerminal(terminal.id);
} catch (error) {
this.runningActionId = undefined;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
@@ -158,6 +166,10 @@ class PiWebActionsPanel extends HTMLElement {
}
private openWorkspaceTerminal(terminalId?: string): void {
if (this.terminalCommandRunsValue !== undefined) {
this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId });
return;
}
if (this.openTerminalValue === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." };
this.render();
+2 -1
View File
@@ -1,6 +1,7 @@
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { ACTIONS_CONFIG_PATH } from "./config.js";
import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js";
import { terminalCommandRunsFromContext } from "./piWebInternal.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
@@ -29,7 +30,7 @@ const plugin: PiWebPlugin = {
title: "Actions",
order: 40,
badge: ({ workspace }) => actionsPanelBadge(workspace),
render: ({ workspace, openTerminal }) => html`<pi-web-actions-panel .workspace=${workspace} .openTerminal=${openTerminal}></pi-web-actions-panel>`,
render: (context) => html`<pi-web-actions-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-actions-panel>`,
},
],
},
+62
View File
@@ -0,0 +1,62 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
export interface InternalRunTerminalCommandInput {
workspace: Workspace;
title: string;
command: string;
metadata?: Record<string, string>;
open?: boolean;
}
export interface InternalTerminalCommandRun {
id: string;
origin: string;
projectId: string;
workspaceId: string;
terminalId: string;
title: string;
command: string;
status: "queued" | "running" | "succeeded" | "failed";
exitCode?: number;
createdAt: string;
startedAt?: string;
completedAt?: string;
metadata: Record<string, string>;
}
export interface InternalTerminalCommandRunHandle {
run: InternalTerminalCommandRun;
completed: Promise<InternalTerminalCommandRun>;
}
export interface InternalTerminalCommandRunsRuntime {
runCommand(input: InternalRunTerminalCommandInput): Promise<InternalTerminalCommandRunHandle>;
open(options?: { terminalId?: string | undefined }): void;
}
export function terminalCommandRunsFromContext(context: unknown): InternalTerminalCommandRunsRuntime | undefined {
if (!isRecord(context)) return undefined;
const internal = context["piWebInternal"];
if (!isRecord(internal)) return undefined;
const terminalCommandRuns = internal["terminalCommandRuns"];
if (!isRecord(terminalCommandRuns)) return undefined;
const runCommand = terminalCommandRuns["runCommand"];
const open = terminalCommandRuns["open"];
if (!isRunCommand(runCommand) || !isOpen(open)) return undefined;
return {
runCommand: (input) => runCommand(input),
open: (options) => { open(options); },
};
}
function isRunCommand(value: unknown): value is InternalTerminalCommandRunsRuntime["runCommand"] {
return typeof value === "function";
}
function isOpen(value: unknown): value is InternalTerminalCommandRunsRuntime["open"] {
return typeof value === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { actionTerminalCols, actionTerminalRows, createWorkspaceTerminal, normalizeTerminalCommand, parseTerminalInfo, parseTerminalSocketMessage, terminalSocketUrl, type FetchLike } from "./terminalDispatcher";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
describe("terminal dispatcher", () => {
it("normalizes commands for terminal input", () => {
expect(normalizeTerminalCommand("npm test")).toBe("npm test\r");
expect(normalizeTerminalCommand("npm test\n")).toBe("npm test\r");
expect(normalizeTerminalCommand("npm test\r\n")).toBe("npm test\r");
});
it("builds terminal socket URLs from the current host", () => {
expect(terminalSocketUrl(workspace, "term/1", { protocol: "https:", host: "example.test" })).toBe(
`wss://example.test/api/projects/project%2F1/workspaces/workspace%201/terminals/term%2F1/socket?cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`,
);
});
it("parses terminal socket messages", () => {
expect(parseTerminalSocketMessage(JSON.stringify({ type: "error", message: "boom" }))).toEqual({ type: "error", message: "boom" });
expect(parseTerminalSocketMessage(JSON.stringify({ type: "output", data: "hello" }))).toEqual({ type: "output" });
expect(parseTerminalSocketMessage("not json")).toBeUndefined();
});
it("creates terminals through the private workspace terminal endpoint", async () => {
let capturedRequest: { input: string; init: RequestInit | undefined } | undefined;
const fetcher: FetchLike = (input, init) => {
capturedRequest = { input, init };
return Promise.resolve(new Response(JSON.stringify({ id: "t1", name: "Action: Build" }), { status: 200 }));
};
await expect(createWorkspaceTerminal(workspace, "Build", fetcher)).resolves.toEqual({ id: "t1", name: "Action: Build" });
if (capturedRequest === undefined) throw new Error("Expected terminal request");
expect(capturedRequest.input).toBe("/api/projects/project%2F1/workspaces/workspace%201/terminals");
expect(capturedRequest.init?.method).toBe("POST");
expect(capturedRequest.init?.body).toBe(JSON.stringify({ name: "Action: Build", cols: actionTerminalCols, rows: actionTerminalRows }));
});
it("falls back to a generated terminal name when the response omits one", () => {
expect(parseTerminalInfo({ id: "t1" }, "Build")).toEqual({ id: "t1", name: "Action: Build" });
});
});
-161
View File
@@ -1,161 +0,0 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
export const actionTerminalCols = 120;
export const actionTerminalRows = 32;
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export interface TerminalInfo {
id: string;
name: string;
}
interface ServerTerminalMessage {
type: string;
message?: string;
}
interface TerminalCommandDeps {
createWebSocket: (url: string) => WebSocket;
setTimeout: typeof window.setTimeout;
clearTimeout: typeof window.clearTimeout;
}
interface TerminalLocation {
protocol: string;
host: string;
}
export async function createWorkspaceTerminal(
workspace: Workspace,
actionTitle: string,
fetcher: FetchLike = window.fetch.bind(window),
): Promise<TerminalInfo> {
const response = await fetcher(`/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `Action: ${actionTitle}`, cols: actionTerminalCols, rows: actionTerminalRows }),
});
if (!response.ok) throw new Error(`Failed to create terminal: ${String(response.status)}`);
return parseTerminalInfo(await response.json(), actionTitle);
}
export function parseTerminalInfo(value: unknown, actionTitle: string): TerminalInfo {
if (!isRecord(value) || typeof value["id"] !== "string") throw new Error("Failed to create terminal: invalid response");
const name = value["name"];
return { id: value["id"], name: typeof name === "string" && name !== "" ? name : `Action: ${actionTitle}` };
}
export function sendTerminalCommand(workspace: Workspace, terminalId: string, command: string, deps = defaultTerminalCommandDeps()): Promise<void> {
return new Promise((resolve, reject) => {
const socket = deps.createWebSocket(terminalSocketUrl(workspace, terminalId));
const input = normalizeTerminalCommand(command);
let settled = false;
let sent = false;
let fallbackTimer: number | undefined;
let completionTimer: number | undefined;
const timeout = deps.setTimeout(() => {
finish(new Error("Timed out while dispatching command to terminal"));
}, 15000);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
deps.clearTimeout(timeout);
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
if (completionTimer !== undefined) deps.clearTimeout(completionTimer);
try {
socket.close();
} catch {
// Ignore close failures.
}
if (error === undefined) resolve();
else reject(error);
};
const scheduleFinishAfterOutput = () => {
completionTimer = deps.setTimeout(() => { finish(); }, 300);
};
const send = () => {
if (settled || sent || socket.readyState !== WebSocket.OPEN) return;
sent = true;
socket.send(JSON.stringify({ type: "input", data: input }));
completionTimer = deps.setTimeout(() => { finish(); }, 5000);
};
socket.addEventListener("open", () => {
fallbackTimer = deps.setTimeout(send, 3000);
});
socket.addEventListener("message", (event: MessageEvent<unknown>) => {
void socketDataToText(event.data).then((text) => {
const message = parseTerminalSocketMessage(text);
if (message?.type === "error") {
finish(new Error(message.message ?? "Terminal socket error"));
return;
}
if (sent) {
scheduleFinishAfterOutput();
return;
}
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
deps.setTimeout(send, 100);
}).catch(() => {
if (sent) {
scheduleFinishAfterOutput();
return;
}
if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer);
deps.setTimeout(send, 100);
});
});
socket.addEventListener("close", () => {
if (!sent) finish(new Error("Terminal socket closed before the command was dispatched"));
else finish();
});
socket.addEventListener("error", () => { finish(new Error("Failed to connect to terminal socket")); });
});
}
export function normalizeTerminalCommand(command: string): string {
return `${command.replace(/\r?\n$/u, "")}\r`;
}
export async function socketDataToText(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);
}
export function parseTerminalSocketMessage(text: string): ServerTerminalMessage | undefined {
try {
const message: unknown = JSON.parse(text);
if (!isRecord(message) || typeof message["type"] !== "string") return undefined;
const rawMessage = message["message"];
return {
type: message["type"],
...(typeof rawMessage === "string" ? { message: rawMessage } : {}),
};
} catch {
return undefined;
}
}
export function terminalSocketUrl(workspace: Workspace, terminalId: string, location: TerminalLocation = window.location): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const query = `cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`;
return `${protocol}//${location.host}/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals/${encodeURIComponent(terminalId)}/socket?${query}`;
}
function defaultTerminalCommandDeps(): TerminalCommandDeps {
return {
createWebSocket: (url) => new WebSocket(url),
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+39 -1
View File
@@ -1,4 +1,4 @@
import type { FileSuggestion } from "../../../shared/apiTypes";
import type { FileSuggestion, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -24,6 +24,7 @@ import {
parseSessionStatus,
parseSlashCommand,
parseStopped,
parseTerminalCommandRun,
parseTerminalInfo,
parseThinkingLevelsResponse,
parseWorkspace,
@@ -93,8 +94,45 @@ 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" }),
continueTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
cancelCommandRun: (runId: string) => request(`/api/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
};
async function getOptionalTerminalCommandRun(runId: string): Promise<TerminalCommandRun | undefined> {
const response = await fetch(`/api/terminal-command-runs/${encodeURIComponent(runId)}`);
if (response.status === 404) return undefined;
if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({}));
throw new Error(apiErrorMessage(body) ?? response.statusText);
}
return parseTerminalCommandRun(await response.json());
}
function terminalCommandRunFilterQuery(filter: TerminalCommandRunFilter | undefined): string {
if (filter === undefined) return "";
const params = new URLSearchParams();
if (filter.projectId !== undefined) params.set("projectId", filter.projectId);
if (filter.workspaceId !== undefined) params.set("workspaceId", filter.workspaceId);
if (filter.terminalId !== undefined) params.set("terminalId", filter.terminalId);
if (filter.statuses !== undefined && filter.statuses.length > 0) params.set("statuses", filter.statuses.join(","));
if (filter.metadata !== undefined && Object.keys(filter.metadata).length > 0) params.set("metadata", JSON.stringify(filter.metadata));
const query = params.toString();
return query === "" ? "" : `?${query}`;
}
function apiErrorMessage(value: unknown): string | undefined {
if (!isRecord(value)) return undefined;
const error = value["error"];
return typeof error === "string" ? error : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
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)),
};
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("accepts legacy array message pages and paged message responses", () => {
@@ -70,6 +70,61 @@ describe("API parsers", () => {
expect(() => parseFileContentResponse({ ...textFile, mediaType: "video" })).toThrow("Invalid file media type");
});
it("parses terminal info with optional command-run ownership", () => {
expect(parseTerminalInfo({
id: "t1",
cwd: "/repo",
name: "Build",
createdAt: "now",
exited: false,
commandRunId: "run1",
})).toMatchObject({ id: "t1", commandRunId: "run1" });
});
it("parses terminal command runs", () => {
expect(parseTerminalCommandRun({
id: "run1",
origin: "core",
projectId: "p1",
workspaceId: "w1",
terminalId: "t1",
title: "Build",
command: "npm run build",
status: "succeeded",
exitCode: 0,
createdAt: "now",
startedAt: "then",
completedAt: "later",
metadata: { "pi.operation": "test" },
})).toEqual({
id: "run1",
origin: "core",
projectId: "p1",
workspaceId: "w1",
terminalId: "t1",
title: "Build",
command: "npm run build",
status: "succeeded",
exitCode: 0,
createdAt: "now",
startedAt: "then",
completedAt: "later",
metadata: { "pi.operation": "test" },
});
expect(() => parseTerminalCommandRun({
id: "run1",
origin: "core",
projectId: "p1",
workspaceId: "w1",
terminalId: "t1",
title: "Build",
command: "npm run build",
status: "done",
createdAt: "now",
metadata: {},
})).toThrow("Invalid terminal command run status");
});
it("parses command result variants", () => {
expect(parseCommandResult({ type: "unsupported", message: "nope" })).toEqual({ type: "unsupported", message: "nope" });
expect(parseCommandResult({ type: "select", requestId: "r1", title: "Pick", options: [{ value: "v", label: "Label", description: "desc" }] })).toEqual({ type: "select", requestId: "r1", title: "Pick", options: [{ value: "v", label: "Label", description: "desc" }] });
+34 -2
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -306,7 +306,39 @@ export function parseGitDiffResponse(value: unknown): GitDiffResponse {
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")) };
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")), ...optionalField("commandRunId", optionalString(record, "commandRunId")) };
}
export function parseTerminalCommandRun(value: unknown): TerminalCommandRun {
const record = requireRecord(value);
return {
id: requireString(record, "id"),
origin: requireString(record, "origin"),
projectId: requireString(record, "projectId"),
workspaceId: requireString(record, "workspaceId"),
terminalId: requireString(record, "terminalId"),
title: requireString(record, "title"),
command: requireString(record, "command"),
status: parseTerminalCommandRunStatus(record["status"]),
...optionalField("exitCode", optionalNumber(record, "exitCode")),
createdAt: requireString(record, "createdAt"),
...optionalField("startedAt", optionalString(record, "startedAt")),
...optionalField("completedAt", optionalString(record, "completedAt")),
metadata: parseStringRecord(record["metadata"], "metadata"),
};
}
function parseTerminalCommandRunStatus(value: unknown): TerminalCommandRunStatus {
if (value !== "queued" && value !== "running" && value !== "succeeded" && value !== "failed") throw new Error("Invalid terminal command run status");
return value;
}
function parseStringRecord(value: unknown, key: string): Record<string, string> {
const record = requireRecord(value);
return Object.fromEntries(Object.entries(record).map(([field, fieldValue]) => {
if (typeof fieldValue !== "string") throw new Error(`Expected string record field: ${key}.${field}`);
return [field, fieldValue];
}));
}
export function parseWorkspaceActivity(value: unknown): WorkspaceActivity {
+3 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
@@ -22,6 +22,7 @@ export interface AppState {
sessionActivities: Record<string, SessionActivity>;
workspaceActivities: Record<string, WorkspaceActivity>;
workspacesByProjectId: Record<string, Workspace[]>;
workspaceDeletionRuns: Record<string, TerminalCommandRun>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
@@ -109,6 +110,7 @@ export function initialAppState(): AppState {
sessionActivities: {},
workspaceActivities: {},
workspacesByProjectId: {},
workspaceDeletionRuns: {},
commandDialog: undefined,
modelDialog: undefined,
thinkingDialog: undefined,
+161 -10
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -10,19 +10,21 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController } from "../controllers/workspaceController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { RealtimeSocket } from "../sessionSocket";
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types";
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
import { loadExternalPlugins } from "../plugins/external";
import { PluginRegistry } from "../plugins/registry";
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode";
import { readRoute, writeRoute, type AppRoute } from "../route";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
@@ -107,6 +109,10 @@ export class PiWebApp extends LitElement {
private mobileTabsResizeObserver: ResizeObserver | undefined;
private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined;
private workspaceDeletionPollTimer: number | undefined;
private refreshingWorkspaceDeletionRuns = false;
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
private routeRestoreInProgress = false;
private restoringRouteTerminalId: string | undefined;
private readonly plugins = createPluginRegistry();
@@ -129,12 +135,14 @@ export class PiWebApp extends LitElement {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.refreshWorkspaceDeletionRuns();
};
private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.refreshWorkspaceDeletionRuns();
}
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
@@ -209,6 +217,8 @@ export class PiWebApp extends LitElement {
this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined;
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
this.workspaceDeletionPollTimer = undefined;
this.contextItemsResizeObserver?.disconnect();
this.contextItemsResizeObserver = undefined;
this.observedContextItems = undefined;
@@ -244,6 +254,7 @@ export class PiWebApp extends LitElement {
private async loadProjectsAndRestoreRoute() {
await this.projects.loadProjects();
await this.withChatScrollTransition(() => this.restoreRoute(false));
await this.refreshWorkspaceDeletionRuns();
}
private async refreshPiWebStatus(): Promise<void> {
@@ -272,6 +283,7 @@ export class PiWebApp extends LitElement {
this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(),
this.refreshWorkspaceActivity(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
]);
} finally {
@@ -377,6 +389,21 @@ export class PiWebApp extends LitElement {
this.openWorkspaceTool("core:workspace.terminal");
}
private terminalCommandRunsForOrigin(origin: string): TerminalCommandRunsInternalRuntime {
const existing = this.terminalCommandRunRuntimes.get(origin);
if (existing !== undefined) return existing;
const runtime = createTerminalCommandRunsRuntime(origin, {
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(workspace, options); },
});
this.terminalCommandRunRuntimes.set(origin, runtime);
return runtime;
}
private async openRuntimeTerminal(workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
if (workspace !== undefined && this.state.selectedWorkspace?.id !== workspace.id) await this.workspaces.selectWorkspace(workspace);
this.openTerminal(options);
}
private selectTerminal(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void {
this.rememberSelectedTerminal(terminalId);
this.setState({ selectedTerminalId: terminalId });
@@ -413,6 +440,7 @@ export class PiWebApp extends LitElement {
if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true });
if (next.selectedWorkspace === undefined) return;
void this.refreshActiveTerminals(next.selectedWorkspace);
void this.refreshWorkspaceDeletionRuns();
this.refreshSelectedWorkspaceTool(next.workspaceTool);
this.git.updatePolling();
}
@@ -430,8 +458,10 @@ export class PiWebApp extends LitElement {
private handleRealtimeEvent(event: RealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (isTerminalEvent(event)) this.applyTerminalEvent(event);
else this.sessions.applyGlobalEvent(event);
else if (isTerminalEvent(event)) {
this.applyTerminalEvent(event);
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
} else this.sessions.applyGlobalEvent(event);
}
private applyTerminalEvent(event: TerminalUiEvent): void {
@@ -516,6 +546,7 @@ export class PiWebApp extends LitElement {
.workspaces=${this.state.workspaces}
.selected=${this.state.selectedWorkspace}
.activities=${this.state.workspaceActivities}
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
@@ -524,6 +555,7 @@ export class PiWebApp extends LitElement {
this.expandNavigationSection("sessions");
await this.workspaces.selectWorkspace(workspace);
})}
.onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
></workspace-list>
<session-list
.sessions=${this.state.sessions}
@@ -636,9 +668,10 @@ export class PiWebApp extends LitElement {
}
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
return {
const createContext = (origin: string): WorkspacePanelContext => installWorkspacePanelScope({
workspace,
state: this.state,
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
fileTree: this.state.fileTree,
expandedDirs: this.state.expandedDirs,
selectedFilePath: this.state.selectedFilePath,
@@ -659,7 +692,8 @@ export class PiWebApp extends LitElement {
onRefreshGit: () => { void this.git.refreshGit(); },
onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); },
};
}, createContext);
return createContext("core");
}
private getActions(): AppAction[] {
@@ -684,8 +718,9 @@ export class PiWebApp extends LitElement {
}
private createPluginRuntimeContext(): PluginRuntimeContext {
return {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) },
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); },
addProject: () => { this.setState({ projectDialogOpen: true }); },
@@ -699,10 +734,114 @@ export class PiWebApp extends LitElement {
refreshGit: () => this.git.refreshGit(),
refreshAppData: () => this.refreshAppData(),
reloadPage: () => { this.hardReloadApp(); },
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
archiveSession: () => this.sessions.archiveSession(),
stopActiveWork: () => this.sessions.stopActiveWork(),
};
}, createContext);
return createContext("core");
}
private async deleteWorkspace(workspace = this.state.selectedWorkspace): Promise<void> {
if (workspace === undefined) return;
if (!canDeleteWorkspace(workspace)) {
this.setState({ error: "Only secondary Git worktrees can be deleted" });
return;
}
if (isWorkspaceDeletionPending(this.state, workspace)) return;
const label = workspace.branch ?? workspace.label;
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
if (!confirmed) return;
try {
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
if (mainWorkspace === undefined) {
this.setState({ error: "Project main workspace not found" });
return;
}
const handle = await this.terminalCommandRunsForOrigin("core").runCommand({
workspace: mainWorkspace,
title: `Delete workspace: ${label}`,
command: `git worktree remove ${shellQuote(workspace.path)}`,
open: true,
metadata: workspaceDeletionMetadata(workspace),
});
this.recordWorkspaceDeletionRun(handle.run);
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run)).catch((error: unknown) => {
this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
});
} catch (error) {
this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
}
}
private async mainWorkspaceForProject(projectId: string): Promise<Workspace | undefined> {
let workspaces = this.state.selectedProject?.id === projectId ? this.state.workspaces : this.state.workspacesByProjectId[projectId];
if (workspaces === undefined || workspaces.length === 0) workspaces = await this.workspaces.refreshProjectWorkspaces(projectId);
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
}
private recordWorkspaceDeletionRun(run: TerminalCommandRun): void {
const workspaceId = targetWorkspaceIdForRun(run);
if (workspaceId === undefined) return;
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
this.updateWorkspaceDeletionPolling();
}
private async refreshWorkspaceDeletionRuns(): Promise<void> {
if (this.refreshingWorkspaceDeletionRuns) return;
const project = this.state.selectedProject;
if (project === undefined) {
this.setState({ workspaceDeletionRuns: {} });
this.updateWorkspaceDeletionPolling();
return;
}
this.refreshingWorkspaceDeletionRuns = true;
try {
const runs = await this.terminalCommandRunsForOrigin("core").listCommandRuns(workspaceDeletionRunFilter(project.id));
const latestRuns = latestWorkspaceDeletionRuns(runs);
this.setState({ workspaceDeletionRuns: latestRuns });
for (const run of Object.values(latestRuns)) {
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run);
}
} catch (error) {
console.warn("Failed to refresh workspace deletion runs", error);
} finally {
this.refreshingWorkspaceDeletionRuns = false;
this.updateWorkspaceDeletionPolling();
}
}
private updateWorkspaceDeletionPolling(): void {
const hasPendingDeletion = Object.values(this.state.workspaceDeletionRuns).some(isWorkspaceDeletionRunPending);
if (hasPendingDeletion && this.workspaceDeletionPollTimer === undefined) {
this.workspaceDeletionPollTimer = window.setInterval(() => { void this.refreshWorkspaceDeletionRuns(); }, 1000);
return;
}
if (!hasPendingDeletion && this.workspaceDeletionPollTimer !== undefined) {
window.clearInterval(this.workspaceDeletionPollTimer);
this.workspaceDeletionPollTimer = undefined;
}
}
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun): Promise<void> {
if (this.handledWorkspaceDeletionRunIds.has(run.id)) return;
const workspaceId = targetWorkspaceIdForRun(run);
if (workspaceId === undefined) return;
this.handledWorkspaceDeletionRunIds.add(run.id);
if (run.status === "succeeded") {
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
this.updateWorkspaceDeletionPolling();
return;
}
if (run.status === "failed") {
this.setState({ error: "Workspace deletion failed. See terminal output." });
this.updateWorkspaceDeletionPolling();
}
}
private runAction(action: AppAction): void {
@@ -1135,6 +1274,18 @@ function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function omitWorkspaceDeletionRun(runs: Record<string, TerminalCommandRun>, workspaceId: string): Record<string, TerminalCommandRun> {
return Object.fromEntries(Object.entries(runs).filter(([candidate]) => candidate !== workspaceId));
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
}
+143 -3
View File
@@ -3,7 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { terminalSocket, terminalsApi, type TerminalInfo, type Workspace } from "../api";
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
@@ -14,6 +14,7 @@ const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
};
const DEFAULT_TERMINAL_SIZE: TerminalSize = { cols: 100, rows: 30 };
const COMMAND_RUN_POLL_INTERVAL_MS = 1000;
@customElement("terminal-panel")
export class TerminalPanel extends LitElement {
@@ -23,10 +24,13 @@ export class TerminalPanel extends LitElement {
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
@state() private terminals: TerminalInfo[] = [];
@state() private commandRuns: TerminalCommandRun[] = [];
@state() private selectedId: string | undefined;
@state() private loading = false;
@state() private error: string | undefined;
@state() private visible = false;
@state() private cancellingRunIds: string[] = [];
@state() private continuingTerminalIds: string[] = [];
private terminal: Terminal | undefined;
private fitAddon: FitAddon | undefined;
@@ -38,6 +42,7 @@ export class TerminalPanel extends LitElement {
private observedCwd: string | undefined;
private loadedCwd: string | undefined;
private autoStartConsumedCwd: string | undefined;
private commandRunPollTimer: number | undefined;
override connectedCallback(): void {
super.connectedCallback();
@@ -57,6 +62,7 @@ export class TerminalPanel extends LitElement {
this.intersectionObserver = undefined;
this.themeObserver?.disconnect();
this.themeObserver = undefined;
this.updateCommandRunPolling(false);
this.disposeTerminalView();
super.disconnectedCallback();
}
@@ -68,7 +74,11 @@ export class TerminalPanel extends LitElement {
this.loadedCwd = undefined;
this.autoStartConsumedCwd = undefined;
this.terminals = [];
this.commandRuns = [];
this.selectedId = undefined;
this.cancellingRunIds = [];
this.continuingTerminalIds = [];
this.updateCommandRunPolling(false);
this.disposeTerminalView();
return;
}
@@ -84,6 +94,8 @@ export class TerminalPanel extends LitElement {
}
override updated(changed: PropertyValues<this>): void {
if (!this.visible) this.updateCommandRunPolling(false);
else if (this.hasPendingCommandRuns()) this.updateCommandRunPolling(true);
this.loadVisibleWorkspaceTerminals();
if (changed.has("selectedTerminalId") && this.shouldReloadForRequestedTerminal()) void this.loadTerminals();
this.ensureTerminalView();
@@ -100,11 +112,17 @@ export class TerminalPanel extends LitElement {
this.loading = true;
this.error = undefined;
try {
if (this.workspace === undefined) return;
const workspace = this.workspace;
if (workspace === undefined) return;
const shouldAutoStart = this.consumeAutoStart();
const terminals = await terminalsApi.terminals(this.workspace.projectId, this.workspace.id);
const [terminals, commandRuns] = await Promise.all([
terminalsApi.terminals(workspace.projectId, workspace.id),
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
]);
this.terminals = terminals;
this.commandRuns = commandRuns;
this.selectPreferredLoadedTerminal({ replaceUrl: true });
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
if (terminals.length === 0 && shouldAutoStart) await this.startTerminal();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
@@ -185,6 +203,75 @@ export class TerminalPanel extends LitElement {
this.onSelectTerminal(id);
}
private selectedTerminalInfo(): TerminalInfo | undefined {
return this.terminals.find((terminal) => terminal.id === this.selectedId);
}
private selectedCommandRun(): TerminalCommandRun | undefined {
const commandRunId = this.selectedTerminalInfo()?.commandRunId;
if (commandRunId === undefined) return undefined;
return this.commandRuns.find((run) => run.id === commandRunId);
}
private async loadCommandRuns(): Promise<void> {
const workspace = this.workspace;
if (workspace === undefined) return;
try {
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id });
this.commandRuns = commandRuns;
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
}
}
private updateCommandRunPolling(shouldPoll: boolean): void {
if (shouldPoll && this.commandRunPollTimer === undefined) {
this.commandRunPollTimer = window.setInterval(() => { void this.loadCommandRuns(); }, COMMAND_RUN_POLL_INTERVAL_MS);
return;
}
if (!shouldPoll && this.commandRunPollTimer !== undefined) {
window.clearInterval(this.commandRunPollTimer);
this.commandRunPollTimer = undefined;
}
}
private hasPendingCommandRuns(commandRuns = this.commandRuns): boolean {
return commandRuns.some(isCommandRunPending);
}
private async cancelCommandRun(run: TerminalCommandRun): Promise<void> {
if (!isCommandRunPending(run) || this.cancellingRunIds.includes(run.id)) return;
this.error = undefined;
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
try {
await terminalsApi.cancelCommandRun(run.id);
await this.loadCommandRuns();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => runId !== run.id);
}
}
private async continueTerminal(id: string): Promise<void> {
if (this.workspace === undefined || this.continuingTerminalIds.includes(id)) return;
this.error = undefined;
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
try {
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id);
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
if (this.socket === undefined) this.disposeTerminalView();
this.fitAndNotify();
this.terminal?.focus();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.continuingTerminalIds = this.continuingTerminalIds.filter((terminalId) => terminalId !== id);
}
}
private ensureTerminalView(): void {
const workspace = this.workspace;
const terminalHost = this.terminalHostElement();
@@ -230,6 +317,7 @@ export class TerminalPanel extends LitElement {
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);
void this.loadCommandRuns();
}
if (message.type === "error") terminal.writeln(`\r\n[terminal error: ${message.message}]`);
} catch (error) {
@@ -301,6 +389,39 @@ export class TerminalPanel extends LitElement {
this.fitAddon = undefined;
}
private renderCommandRunNotice() {
const run = this.selectedCommandRun();
if (run === undefined) return null;
const terminal = this.selectedTerminalInfo();
if (isCommandRunPending(run)) {
const cancelling = this.cancellingRunIds.includes(run.id);
return html`
<section class="command-run-notice running">
<div>
<strong>${run.title}</strong>
<p>Command is running. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> or use the button to cancel.</p>
<code>${run.command}</code>
</div>
<button class="danger" ?disabled=${cancelling} @click=${() => { void this.cancelCommandRun(run); }}>${cancelling ? "Cancel sent…" : "Cancel command"}</button>
</section>
`;
}
if (terminal?.exited === true) {
const continuing = this.continuingTerminalIds.includes(terminal.id);
return html`
<section class=${`command-run-notice ${run.status}`}>
<div>
<strong>${commandRunCompletionLabel(run)}</strong>
<p>Output is preserved. Continue in a shell to inspect or run follow-up commands.</p>
<code>${run.command}</code>
</div>
<button ?disabled=${continuing} @click=${() => { void this.continueTerminal(terminal.id); }}>${continuing ? "Starting shell…" : "Continue in shell"}</button>
</section>
`;
}
return null;
}
override render() {
return html`
<section class="terminal-shell">
@@ -314,6 +435,7 @@ export class TerminalPanel extends LitElement {
<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.renderCommandRunNotice()}
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
<div class="terminal-host"></div>
</section>
@@ -330,7 +452,16 @@ export class TerminalPanel extends LitElement {
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
button small:hover { color: var(--pi-danger); }
button.danger { color: var(--pi-danger); }
button:disabled { opacity: .5; cursor: not-allowed; }
.command-run-notice { flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 8px 10px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); color: var(--pi-text); }
.command-run-notice.running { border-color: var(--pi-warning-border); }
.command-run-notice.succeeded { border-color: var(--pi-success-border); }
.command-run-notice.failed { border-color: var(--pi-danger); }
.command-run-notice p { margin: 3px 0; color: var(--pi-muted); }
.command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.command-run-notice button { justify-self: end; max-width: none; }
.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; }
@@ -361,6 +492,15 @@ type ServerTerminalMessage =
| { type: "exit"; exitCode?: number }
| { type: "error"; message: string };
function isCommandRunPending(run: TerminalCommandRun): boolean {
return run.status === "queued" || run.status === "running";
}
function commandRunCompletionLabel(run: TerminalCommandRun): string {
if (run.status === "succeeded") return `Command succeeded${run.exitCode === undefined ? "" : ` with exit code ${String(run.exitCode)}`}`;
return `Command failed${run.exitCode === undefined ? "" : ` with exit code ${String(run.exitCode)}`}`;
}
function parseServerMessage(data: string): ServerTerminalMessage {
const value: unknown = JSON.parse(data);
if (!isRecord(value)) return { type: "error", message: "Invalid terminal message" };
@@ -17,7 +17,9 @@ export class WorkspaceList extends LitElement {
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@state() private openMenuWorkspaceId: string | undefined;
@state() private menuStyle = "";
@@ -86,6 +88,7 @@ export class WorkspaceList extends LitElement {
<span class="workspace-primary">
${this.renderActivity(workspace)}
<span class="workspace-primary-label">${label}</span>
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
</span>
${items.length === 0 ? null : html`
<small class="workspace-secondary">
@@ -110,6 +113,7 @@ export class WorkspaceList extends LitElement {
>⋯</button>
${open ? html`
<div class="action-menu-panel workspace-menu-panel" id=${menuId} style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
${this.renderWorkspaceActions(workspace)}
${this.renderWorkspaceDetails(label, items, workspace)}
</div>
` : null}
@@ -117,6 +121,16 @@ export class WorkspaceList extends LitElement {
`;
}
private renderWorkspaceActions(workspace: Workspace): TemplateResult | undefined {
if (!canDeleteWorkspace(workspace)) return undefined;
const deleting = this.isDeleting(workspace);
return html`
<div class="workspace-menu-actions">
<button class="danger" title=${deleting ? "Workspace deletion in progress" : "Delete workspace"} ?disabled=${deleting} @click=${() => { this.delete(workspace); }}>${deleting ? "Deleting…" : "Delete workspace"}</button>
</div>
`;
}
private renderWorkspaceDetails(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
return html`
<dl class="workspace-menu-details">
@@ -138,6 +152,16 @@ export class WorkspaceList extends LitElement {
`;
}
private delete(workspace: Workspace): void {
if (this.isDeleting(workspace)) return;
this.openMenuWorkspaceId = undefined;
this.onDelete?.(workspace);
}
private isDeleting(workspace: Workspace): boolean {
return this.deletingWorkspaceIds.includes(workspace.id);
}
private toggleMenu(workspaceId: string, target: EventTarget | null): void {
if (this.openMenuWorkspaceId === workspaceId) {
this.openMenuWorkspaceId = undefined;
@@ -168,6 +192,10 @@ function workspacePrimaryLabel(workspace: Workspace): string {
return `${workspace.branch ?? workspace.label}${workspace.isMain ? " · main" : ""}`;
}
function canDeleteWorkspace(workspace: Workspace): boolean {
return workspace.isGitWorktree && !workspace.isMain;
}
function workspaceMenuId(workspaceId: string): string {
return `workspace-menu-${workspaceId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
+4
View File
@@ -207,8 +207,12 @@ export const listStyles = css`
.workspace-primary { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
.workspace-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; }
.workspace-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-status { flex: 0 0 auto; color: var(--pi-warning); font-size: 12px; }
.workspace-secondary { margin-top: 3px; }
.workspace-menu-panel { width: max-content; min-width: min(120px, calc(100vw - 16px)); padding: 8px; }
.workspace-menu-actions { margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--pi-border-muted); }
.workspace-menu-actions button.danger { color: var(--pi-danger); }
.workspace-menu-actions button.danger:hover, .workspace-menu-actions button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
.workspace-menu-details { display: grid; gap: 6px; margin: 0; }
.workspace-detail-row { display: grid; grid-template-columns: minmax(58px, max-content) minmax(0, 1fr); gap: 8px; align-items: baseline; }
.workspace-detail-row dt { color: var(--pi-muted); font-size: 12px; white-space: normal; }
@@ -1,18 +1,27 @@
import { api, type Project, type Workspace } from "../api";
import { api as defaultApi, type Project, type Workspace } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import { mergeCachedNewSessions } from "../cachedNewSessions";
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
import type { SessionController } from "./sessionController";
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
export interface WorkspaceControllerDependencies {
api?: Pick<typeof defaultApi, "sessions" | "workspaces">;
}
export class WorkspaceController {
private readonly api: Pick<typeof defaultApi, "sessions" | "workspaces">;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
private readonly updateUrl: UpdateUrl,
private readonly sessions: SessionController,
private readonly sessions: Pick<SessionController, "clearActiveSession" | "preferredSession" | "selectSession">,
private readonly workspaceSelection: WorkspaceSelectionMemory = new InMemoryWorkspaceSelectionMemory(),
) {}
deps: WorkspaceControllerDependencies = {},
) {
this.api = deps.api ?? defaultApi;
}
clearSelection(options?: { updateUrl?: boolean | undefined }) {
this.sessions.clearActiveSession();
@@ -30,7 +39,7 @@ export class WorkspaceController {
this.sessions.clearActiveSession();
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
try {
const workspaces = await api.workspaces(project.id);
const workspaces = await this.api.workspaces(project.id);
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
@@ -45,7 +54,7 @@ export class WorkspaceController {
this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
try {
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path));
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path));
this.setState({ sessions });
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
@@ -54,4 +63,38 @@ export class WorkspaceController {
this.setState({ error: String(error) });
}
}
async refreshProjectWorkspaces(projectId: string): Promise<Workspace[]> {
const project = this.getState().projects.find((candidate) => candidate.id === projectId);
if (project === undefined) throw new Error("Project not found");
const workspaces = await this.api.workspaces(project.id);
this.applyProjectWorkspaces(project.id, workspaces);
return workspaces;
}
async refreshAfterWorkspaceDeleted(projectId: string, workspaceId: string): Promise<void> {
const workspaces = await this.refreshProjectWorkspaces(projectId);
const state = this.getState();
if (state.selectedProject?.id !== projectId || state.selectedWorkspace?.id !== workspaceId) return;
const fallback = selectFallbackWorkspace(workspaces);
if (fallback !== undefined) await this.selectWorkspace(fallback);
else this.clearSelection();
}
private applyProjectWorkspaces(projectId: string, workspaces: Workspace[]): void {
const state = this.getState();
const workspacesByProjectId = { ...state.workspacesByProjectId, [projectId]: workspaces };
if (state.selectedProject?.id === projectId) this.setState({ workspaces, workspacesByProjectId });
else this.setState({ workspacesByProjectId });
}
}
export function canDeleteWorkspace(workspace: Workspace | undefined): boolean {
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain;
}
function selectFallbackWorkspace(workspaces: Workspace[]): Workspace | undefined {
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
}
+14
View File
@@ -1,5 +1,6 @@
import { isSessionActive } from "../../../../shared/activity";
import type { AppState } from "../../appState";
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
import type { PluginAction } from "../types";
export function createCoreActions(): PluginAction[] {
@@ -116,6 +117,14 @@ export function createCoreActions(): PluginAction[] {
enabled: hasWorkspace,
run: (context) => context.state.workspaceTool === "core:workspace.git" && context.state.selectedWorkspace?.isGitRepo === true ? context.refreshGit() : context.refreshFiles(),
},
{
id: "workspace.delete",
title: "Delete Workspace",
description: "Remove the selected Git worktree",
group: "Workspace",
enabled: hasDeletableWorkspace,
run: (context) => context.deleteWorkspace(),
},
{
id: "session.start",
title: "Start Session",
@@ -150,3 +159,8 @@ function hasWorkspace(context: { state: AppState }): boolean {
function hasGitWorkspace(context: { state: AppState }): boolean {
return context.state.selectedWorkspace?.isGitRepo === true;
}
function hasDeletableWorkspace(context: { state: AppState }): boolean {
const workspace = context.state.selectedWorkspace;
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
}
+26 -2
View File
@@ -10,6 +10,14 @@ function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
const context: PluginRuntimeContext = {
state: { ...initialAppState(), ...statePatch },
piWebInternal: {
terminalCommandRuns: {
runCommand: vi.fn(),
listCommandRuns: vi.fn(),
getCommandRun: vi.fn(),
open: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`terminal.open:${options?.terminalId ?? ""}`); }),
},
},
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
addProject: vi.fn(() => { calls.push("addProject"); }),
@@ -23,6 +31,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
refreshGit: vi.fn(() => { calls.push("refreshGit"); }),
refreshAppData: vi.fn(() => { calls.push("refreshAppData"); }),
reloadPage: vi.fn(() => { calls.push("reloadPage"); }),
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
startSession: vi.fn(() => { calls.push("startSession"); }),
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
@@ -72,6 +81,21 @@ describe("PluginRegistry", () => {
expect(inactive.find((action) => action.id === "core:view.terminal")?.enabled).toBe(false);
expect(active.find((action) => action.id === "core:view.files")?.enabled).toBe(true);
expect(active.find((action) => action.id === "core:view.terminal")?.enabled).toBe(true);
expect(active.find((action) => action.id === "core:workspace.delete")?.enabled).toBe(false);
const deletable = registry.getActions(createContext({ selectedWorkspace: testWorkspace({ isMain: false, isGitWorktree: true }) }).context);
expect(deletable.find((action) => action.id === "core:workspace.delete")?.enabled).toBe(true);
});
it("routes workspace delete through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({ selectedWorkspace: testWorkspace({ isMain: false, isGitWorktree: true }) });
const action = registry.getActions(context).find((candidate) => candidate.id === "core:workspace.delete");
if (action !== undefined) void action.run();
expect(calls).toEqual(["deleteWorkspace"]);
});
it("routes refresh current to the active core workspace panel", () => {
@@ -205,8 +229,8 @@ describe("PluginRegistry", () => {
});
});
function testWorkspace(): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
}
function testThemeTokens(): ThemeTokens {
+33 -4
View File
@@ -1,10 +1,12 @@
import { html } from "lit";
import type { AppState } from "../appState";
import type { Workspace } from "../api";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
const idPattern = /^[a-z][a-z0-9.-]*$/u;
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
const pluginRuntimeScopes = new WeakMap<PluginRuntimeContext, (pluginId: string) => PluginRuntimeContext>();
const workspacePanelScopes = new WeakMap<WorkspacePanelContext, (pluginId: string) => WorkspacePanelContext>();
type RegisteredPluginAction = Omit<PluginAction, "id"> & {
id: QualifiedContributionId;
@@ -40,13 +42,14 @@ export class PluginRegistry {
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.map((action) => {
const enabled = action.enabled?.(context);
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext);
const qualified: QualifiedPluginAction = {
id: action.id,
pluginId: action.pluginId,
localId: action.localId,
title: action.title,
run: () => action.run(context),
run: () => action.run(scopedContext),
};
if (action.description !== undefined) qualified.description = action.description;
if (action.shortcut !== undefined) qualified.shortcut = action.shortcut;
@@ -85,7 +88,15 @@ export class PluginRegistry {
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
const id = this.qualify(pluginId, panel.id);
return { ...panel, id, pluginId, localId: panel.id };
const badge = panel.badge;
return {
...panel,
id,
pluginId,
localId: panel.id,
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
};
}
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution): QualifiedWorkspaceLabelContribution {
@@ -131,3 +142,21 @@ export class PluginRegistry {
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
}
}
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
return pluginRuntimeScopes.get(context)?.(pluginId) ?? context;
}
function workspacePanelContextFor(context: WorkspacePanelContext, pluginId: string): WorkspacePanelContext {
return workspacePanelScopes.get(context)?.(pluginId) ?? context;
}
export function installPluginRuntimeScope(context: PluginRuntimeContext, scope: (pluginId: string) => PluginRuntimeContext): PluginRuntimeContext {
pluginRuntimeScopes.set(context, scope);
return context;
}
export function installWorkspacePanelScope(context: WorkspacePanelContext, scope: (pluginId: string) => WorkspacePanelContext): WorkspacePanelContext {
workspacePanelScopes.set(context, scope);
return context;
}
+15 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { AppAction } from "../actions";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
import type { AppState } from "../appState";
export type PluginId = string;
@@ -37,8 +37,20 @@ export interface PluginContributions {
themePairs?: ThemePairContribution[];
}
export interface PiWebInternalRuntimeContext {
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
}
export interface TerminalCommandRunsInternalRuntime {
runCommand(input: RunTerminalCommandInput): Promise<TerminalCommandRunHandle>;
listCommandRuns(filter?: TerminalCommandRunFilter): Promise<TerminalCommandRun[]>;
getCommandRun(runId: string): Promise<TerminalCommandRun | undefined>;
open(options?: { terminalId?: string | undefined }): void;
}
export interface PluginRuntimeContext {
state: AppState;
piWebInternal?: PiWebInternalRuntimeContext;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
@@ -52,6 +64,7 @@ export interface PluginRuntimeContext {
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
reloadPage: () => void;
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
@@ -80,6 +93,7 @@ export interface WorkspacePanelVisibilityContext {
export interface WorkspacePanelContext {
workspace: Workspace;
state: AppState;
piWebInternal?: PiWebInternalRuntimeContext;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined;
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RunTerminalCommandInput, TerminalCommandRun, Workspace } from "../api";
import { createTerminalCommandRunsRuntime } from "./terminalRuntime";
const workspace: Workspace = {
id: "w1",
projectId: "p1",
path: "/repo",
label: "repo",
isMain: true,
isGitRepo: true,
isGitWorktree: true,
};
const runningRun: TerminalCommandRun = {
id: "run1",
origin: "plugin",
projectId: "p1",
workspaceId: "w1",
terminalId: "t1",
title: "Build",
command: "npm run build",
status: "running",
createdAt: "2026-05-25T00:00:00.000Z",
metadata: {},
};
const succeededRun: TerminalCommandRun = { ...runningRun, status: "succeeded", exitCode: 0, completedAt: "2026-05-25T00:00:01.000Z" };
afterEach(() => {
vi.useRealTimers();
});
describe("terminal runtime", () => {
it("starts commands with the assigned origin and opens the returned terminal when requested", async () => {
const openTerminal = vi.fn();
const api = {
runTerminalCommand: vi.fn((origin: string, input: RunTerminalCommandInput) => {
void origin;
void input;
return Promise.resolve(succeededRun);
}),
listCommandRuns: vi.fn(),
getCommandRun: vi.fn(),
};
const runtime = createTerminalCommandRunsRuntime("actions", { api, openTerminal });
const handle = await runtime.runCommand({ workspace, title: "Build", command: "npm run build", open: true });
expect(api.runTerminalCommand).toHaveBeenCalledWith("actions", { workspace, title: "Build", command: "npm run build", open: true });
expect(openTerminal).toHaveBeenCalledWith(workspace, { terminalId: "t1" });
await expect(handle.completed).resolves.toEqual(succeededRun);
});
it("polls command-run records until completion", async () => {
vi.useFakeTimers();
const api = {
runTerminalCommand: vi.fn(() => Promise.resolve(runningRun)),
listCommandRuns: vi.fn(),
getCommandRun: vi.fn(() => Promise.resolve(succeededRun)),
};
const runtime = createTerminalCommandRunsRuntime("core", {
api,
openTerminal: vi.fn(),
pollIntervalMs: 25,
setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout),
clearTimeout: (id) => { globalThis.clearTimeout(id); },
});
const handle = await runtime.runCommand({ workspace, title: "Build", command: "npm run build" });
await vi.advanceTimersByTimeAsync(25);
await expect(handle.completed).resolves.toEqual(succeededRun);
expect(api.getCommandRun).toHaveBeenCalledWith("run1");
});
});
+84
View File
@@ -0,0 +1,84 @@
import { terminalsApi as defaultApi, type RunTerminalCommandInput, type TerminalCommandRun, type TerminalCommandRunFilter, type Workspace } from "../api";
import type { TerminalCommandRunsInternalRuntime } from "../plugins/types";
type TimerId = ReturnType<typeof globalThis.setTimeout>;
type SetTimer = (handler: () => void, timeout: number) => TimerId;
type ClearTimer = (id: TimerId) => void;
export interface TerminalCommandRunsRuntimeDependencies {
api?: Pick<typeof defaultApi, "runTerminalCommand" | "listCommandRuns" | "getCommandRun">;
openTerminal: (workspace: Workspace | undefined, options?: { terminalId?: string | undefined }) => void | Promise<void>;
pollIntervalMs?: number;
setTimeout?: SetTimer;
clearTimeout?: ClearTimer;
}
export function createTerminalCommandRunsRuntime(origin: string, deps: TerminalCommandRunsRuntimeDependencies): TerminalCommandRunsInternalRuntime {
const api = deps.api ?? defaultApi;
const pollIntervalMs = deps.pollIntervalMs ?? 1000;
const setTimer = deps.setTimeout ?? defaultSetTimeout();
const clearTimer = deps.clearTimeout ?? defaultClearTimeout();
return {
async runCommand(input: RunTerminalCommandInput) {
const run = await api.runTerminalCommand(origin, input);
if (input.open === true) void deps.openTerminal(input.workspace, { terminalId: run.terminalId });
return { run, completed: waitForCommandRunCompletion(run, api, pollIntervalMs, setTimer, clearTimer) };
},
listCommandRuns: (filter?: TerminalCommandRunFilter) => api.listCommandRuns(filter),
getCommandRun: (runId: string) => api.getCommandRun(runId),
open: (options?: { terminalId?: string | undefined }) => { void deps.openTerminal(undefined, options); },
};
}
function waitForCommandRunCompletion(
initialRun: TerminalCommandRun,
api: Pick<typeof defaultApi, "getCommandRun">,
pollIntervalMs: number,
setTimer: SetTimer,
clearTimer: ClearTimer,
): Promise<TerminalCommandRun> {
if (isTerminalCommandRunFinal(initialRun)) return Promise.resolve(initialRun);
return new Promise((resolve, reject) => {
let timer: TimerId | undefined;
let settled = false;
const finish = (result: TerminalCommandRun) => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimer(timer);
resolve(result);
};
const fail = (error: unknown) => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimer(timer);
reject(error instanceof Error ? error : new Error(String(error)));
};
const poll = () => {
void api.getCommandRun(initialRun.id).then((run) => {
if (run !== undefined && isTerminalCommandRunFinal(run)) {
finish(run);
return;
}
timer = setTimer(poll, pollIntervalMs);
}).catch(fail);
};
timer = setTimer(poll, pollIntervalMs);
});
}
function isTerminalCommandRunFinal(run: TerminalCommandRun): boolean {
return run.status === "succeeded" || run.status === "failed";
}
function defaultSetTimeout(): SetTimer {
return (handler, timeout) => globalThis.setTimeout(handler, timeout);
}
function defaultClearTimeout(): ClearTimer {
return (id) => { globalThis.clearTimeout(id); };
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { TerminalCommandRun, Workspace } from "./api";
import { isWorkspaceDeletionPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, workspaceDeleteOperation, workspaceDeletionMetadata } from "./workspaceDeletion";
const workspace: Workspace = {
id: "w1",
projectId: "p1",
path: "/repo/worktree",
label: "worktree",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
function run(id: string, workspaceId: string, createdAt: string, status: TerminalCommandRun["status"]): TerminalCommandRun {
return {
id,
origin: "core",
projectId: "p1",
workspaceId: "main",
terminalId: `t-${id}`,
title: "Delete workspace",
command: "git worktree remove '/repo/worktree'",
status,
createdAt,
metadata: { "pi.operation": workspaceDeleteOperation, "target.workspaceId": workspaceId, "target.workspacePath": "/repo/worktree" },
};
}
describe("workspace deletion state", () => {
it("builds command-run metadata for workspace deletion", () => {
expect(workspaceDeletionMetadata(workspace)).toEqual({
"pi.operation": "workspace.delete",
"target.workspaceId": "w1",
"target.workspacePath": "/repo/worktree",
});
});
it("tracks the latest deletion run per target workspace", () => {
expect(latestWorkspaceDeletionRuns([
run("old", "w1", "2026-05-25T00:00:00.000Z", "failed"),
run("new", "w1", "2026-05-25T00:00:01.000Z", "running"),
run("other", "w2", "2026-05-25T00:00:00.000Z", "running"),
])).toMatchObject({
w1: { id: "new", status: "running" },
w2: { id: "other", status: "running" },
});
});
it("reports pending workspace deletions for disabling repeated actions", () => {
const state = { workspaceDeletionRuns: { w1: run("new", "w1", "2026-05-25T00:00:01.000Z", "running") } };
expect(isWorkspaceDeletionPending(state, workspace)).toBe(true);
expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1"]);
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { AppState } from "./appState";
import type { TerminalCommandRun, Workspace } from "./api";
export const workspaceDeleteOperation = "workspace.delete";
export const workspaceDeleteOperationMetadataKey = "pi.operation";
export const targetWorkspaceIdMetadataKey = "target.workspaceId";
export const targetWorkspacePathMetadataKey = "target.workspacePath";
export function workspaceDeletionMetadata(workspace: Workspace): Record<string, string> {
return {
[workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation,
[targetWorkspaceIdMetadataKey]: workspace.id,
[targetWorkspacePathMetadataKey]: workspace.path,
};
}
export function workspaceDeletionRunFilter(projectId?: string): { projectId?: string; metadata: Record<string, string> } {
return {
...(projectId === undefined ? {} : { projectId }),
metadata: { [workspaceDeleteOperationMetadataKey]: workspaceDeleteOperation },
};
}
export function latestWorkspaceDeletionRuns(runs: TerminalCommandRun[]): Record<string, TerminalCommandRun> {
const latest: Record<string, TerminalCommandRun> = {};
for (const run of runs) {
const workspaceId = targetWorkspaceIdForRun(run);
if (workspaceId === undefined) continue;
const current = latest[workspaceId];
if (current === undefined || run.createdAt.localeCompare(current.createdAt) >= 0) latest[workspaceId] = run;
}
return latest;
}
export function pendingWorkspaceDeletionIds(runsByWorkspaceId: Record<string, TerminalCommandRun>): string[] {
return Object.entries(runsByWorkspaceId)
.filter(([, run]) => isWorkspaceDeletionRunPending(run))
.map(([workspaceId]) => workspaceId);
}
export function isWorkspaceDeletionPending(state: Pick<AppState, "workspaceDeletionRuns">, workspace: Workspace | undefined): boolean {
if (workspace === undefined) return false;
const run = state.workspaceDeletionRuns[workspace.id];
return run !== undefined && isWorkspaceDeletionRunPending(run);
}
export function targetWorkspaceIdForRun(run: TerminalCommandRun): string | undefined {
return run.metadata[targetWorkspaceIdMetadataKey];
}
export function targetWorkspacePathForRun(run: TerminalCommandRun): string | undefined {
return run.metadata[targetWorkspacePathMetadataKey];
}
export function isWorkspaceDeletionRunPending(run: TerminalCommandRun): boolean {
return run.status === "queued" || run.status === "running";
}
export function isWorkspaceDeletionRun(run: TerminalCommandRun): boolean {
return run.metadata[workspaceDeleteOperationMetadataKey] === workspaceDeleteOperation;
}
+81
View File
@@ -27,6 +27,16 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, 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);
@@ -37,6 +47,51 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
origin: request.body.origin,
projectId: request.params.projectId,
workspaceId: request.params.workspaceId,
cwd: context.root,
title: request.body.title,
command: request.body.command,
metadata: request.body.metadata ?? {},
}, reply);
} catch (error) {
requestFailed(reply, error);
return undefined;
}
});
app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => {
try {
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
} catch (error) {
requestFailed(reply, error);
return undefined;
}
});
app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => {
try {
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
} catch (error) {
requestFailed(reply, error);
return undefined;
}
});
app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => {
try {
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
} catch (error) {
requestFailed(reply, error);
return undefined;
}
});
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: 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);
@@ -49,6 +104,32 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
});
}
interface TerminalCommandRunRequest {
origin: string;
title: string;
command: string;
metadata?: Record<string, string>;
}
interface TerminalCommandRunQuery {
projectId?: string;
workspaceId?: string;
terminalId?: string;
statuses?: string;
metadata?: string;
}
function terminalCommandRunQuery(filter: TerminalCommandRunQuery): string {
const params = new URLSearchParams();
if (filter.projectId !== undefined) params.set("projectId", filter.projectId);
if (filter.workspaceId !== undefined) params.set("workspaceId", filter.workspaceId);
if (filter.terminalId !== undefined) params.set("terminalId", filter.terminalId);
if (filter.statuses !== undefined) params.set("statuses", filter.statuses);
if (filter.metadata !== undefined) params.set("metadata", filter.metadata);
const query = params.toString();
return query === "" ? "" : `?${query}`;
}
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);
+73 -2
View File
@@ -2,7 +2,8 @@ import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { WebSocket, type RawData } from "ws";
import type { TerminalInfo } from "./terminalService.js";
import type { TerminalCommandRun, TerminalCommandRunFilter } from "../../shared/apiTypes.js";
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
import { registerTerminalRoutes, type TerminalRouteService } from "./terminalRoutes.js";
let app: FastifyInstance;
@@ -20,7 +21,7 @@ afterEach(async () => {
await app.close();
});
describe("terminal socket routes", () => {
describe("terminal routes", () => {
it("applies the initial socket size before attaching and replaying output", async () => {
const socket = new WebSocket(`${serverUrl(app)}/terminals/t1/socket?cols=120.9&rows=40.2`);
@@ -29,10 +30,37 @@ describe("terminal socket routes", () => {
socket.close();
});
it("creates and lists terminal command runs with filters", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/terminal-command-runs",
payload: { origin: "core", projectId: "p1", workspaceId: "w1", cwd: "/repo", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json<TerminalCommandRun>()).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" });
const listResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?projectId=p1&statuses=running&metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": "test" }))}` });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<TerminalCommandRun[]>()).toHaveLength(1);
expect(terminals.filters).toEqual([{ projectId: "p1", statuses: ["running"], metadata: { "pi.operation": "test" } }]);
const cancelResponse = await app.inject({ method: "POST", url: "/terminal-command-runs/run1/cancel" });
expect(cancelResponse.statusCode).toBe(200);
expect(terminals.events).toContain("cancel:run1");
const continueResponse = await app.inject({ method: "POST", url: "/terminals/t-run/continue" });
expect(continueResponse.statusCode).toBe(200);
expect(terminals.events).toContain("continue:t-run");
});
});
class FakeTerminals implements TerminalRouteService {
readonly events: string[] = [];
readonly filters: TerminalCommandRunFilter[] = [];
private readonly commandRuns = new Map<string, TerminalCommandRun>();
list(cwd: string): TerminalInfo[] {
void cwd;
@@ -68,6 +96,49 @@ class FakeTerminals implements TerminalRouteService {
resize(id: string, cols: number, rows: number): void {
this.events.push(`resize:${id}:${String(cols)}x${String(rows)}`);
}
continue(id: string): TerminalInfo {
this.events.push(`continue:${id}`);
return { id, cwd: "/repo", name: "Shell 1", createdAt: "2026-05-13T00:00:00.000Z", exited: false };
}
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
const run: TerminalCommandRun = {
id: "run1",
origin: options.origin,
projectId: options.projectId,
workspaceId: options.workspaceId,
terminalId: "t-run",
title: options.title,
command: options.command,
status: "running",
createdAt: "2026-05-13T00:00:00.000Z",
metadata: routeMetadata(options.metadata),
};
this.commandRuns.set(run.id, run);
return run;
}
listCommandRuns(filter: TerminalCommandRunFilter = {}): TerminalCommandRun[] {
this.filters.push(filter);
return [...this.commandRuns.values()];
}
getCommandRun(runId: string): TerminalCommandRun | undefined {
return this.commandRuns.get(runId);
}
cancelCommandRun(runId: string): TerminalCommandRun {
const run = this.commandRuns.get(runId);
if (run === undefined) throw new Error("Terminal command run not found");
this.events.push(`cancel:${runId}`);
return run;
}
}
function routeMetadata(value: unknown): Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
}
function serverUrl(instance: FastifyInstance): string {
+80 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import type { RawData } from "ws";
import type { TerminalInfo } from "./terminalService.js";
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus } from "../../shared/apiTypes.js";
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
import { parseTerminalSize } from "./terminalSize.js";
export interface TerminalRouteService {
@@ -10,6 +11,11 @@ export interface TerminalRouteService {
attach(id: string, handlers: { output: (data: string, replay: boolean) => void; exit: (exitCode: number | undefined) => void }): () => void;
write(id: string, data: string): void;
resize(id: string, cols: number, rows: number): void;
continue(id: string): TerminalInfo;
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun;
listCommandRuns(filter?: TerminalCommandRunFilter): TerminalCommandRun[];
getCommandRun(runId: string): TerminalCommandRun | undefined;
cancelCommandRun(runId: string): TerminalCommandRun;
}
export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalRouteService, prefix = ""): void {
@@ -26,13 +32,51 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal
}
});
app.post<{ Body: RunTerminalCommandOptions }>(`${prefix}/terminal-command-runs`, (request, reply) => {
try {
return terminals.runCommand(request.body);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, (request, reply) => {
try {
return terminals.listCommandRuns(parseCommandRunFilter(request.query));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, (request, reply) => {
try {
return terminals.cancelCommandRun(request.params.runId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, (request, reply) => {
const run = terminals.getCommandRun(request.params.runId);
if (run === undefined) return reply.code(404).send({ error: "Terminal command run not found" });
return run;
});
app.post<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId/continue`, (request, reply) => {
try {
return terminals.continue(request.params.terminalId);
} 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 }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/terminals/:terminalId/socket`, { websocket: true }, (socket, request) => {
let detach: (() => void) | undefined;
let detach: () => void = () => undefined;
try {
const initialSize = parseTerminalSize(request.query.cols, request.query.rows);
if (initialSize !== undefined) terminals.resize(request.params.terminalId, initialSize.cols, initialSize.rows);
@@ -64,6 +108,40 @@ type ClientTerminalMessage =
| { type: "input"; data: string }
| { type: "resize"; cols: number; rows: number };
interface TerminalCommandRunQuery {
projectId?: string;
workspaceId?: string;
terminalId?: string;
statuses?: string;
metadata?: string;
}
function parseCommandRunFilter(query: TerminalCommandRunQuery): TerminalCommandRunFilter {
const metadata = query.metadata === undefined || query.metadata === "" ? undefined : parseMetadataFilter(query.metadata);
const statuses = query.statuses === undefined || query.statuses === "" ? undefined : query.statuses.split(",").filter((status) => status !== "").map(parseCommandRunStatus);
return {
...(query.projectId === undefined ? {} : { projectId: query.projectId }),
...(query.workspaceId === undefined ? {} : { workspaceId: query.workspaceId }),
...(query.terminalId === undefined ? {} : { terminalId: query.terminalId }),
...(statuses === undefined ? {} : { statuses }),
...(metadata === undefined ? {} : { metadata }),
};
}
function parseCommandRunStatus(value: string): TerminalCommandRunStatus {
if (value !== "queued" && value !== "running" && value !== "succeeded" && value !== "failed") throw new Error(`Invalid command run status: ${value}`);
return value;
}
function parseMetadataFilter(value: string): Record<string, string> {
const parsed: unknown = JSON.parse(value);
if (!isRecord(parsed) || Array.isArray(parsed)) throw new Error("metadata filter must be an object");
return Object.fromEntries(Object.entries(parsed).map(([key, metadataValue]) => {
if (typeof metadataValue !== "string") throw new Error(`metadata filter value must be a string: ${key}`);
return [key, metadataValue];
}));
}
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");
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { TerminalService } from "./terminalService";
describe("TerminalService command runs", () => {
it("tracks dedicated terminal command runs through completion", async () => {
const service = new TerminalService();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd: process.cwd(),
title: "Test command",
command: "printf 'hello'",
metadata: { "pi.operation": "test" },
});
expect(run).toMatchObject({ status: "running", origin: "core", projectId: "p1", workspaceId: "w1", metadata: { "pi.operation": "test" } });
expect(service.get(run.terminalId)).toMatchObject({ commandRunId: run.id });
expect(service.listCommandRuns({ metadata: { "pi.operation": "test" } })).toHaveLength(1);
const output = await terminalExit(service, run.terminalId);
expect(output).toContain("$ printf 'hello'");
expect(output).toContain("hello");
expect(service.getCommandRun(run.id)).toMatchObject({ status: "succeeded", exitCode: 0, terminalId: run.terminalId });
expect(service.listCommandRuns({ statuses: ["succeeded"] }).map((candidate) => candidate.id)).toEqual([run.id]);
} finally {
service.dispose();
}
});
it("continues an exited command-run terminal as an interactive shell", async () => {
const service = new TerminalService();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd: process.cwd(),
title: "Done command",
command: "true",
});
await terminalExit(service, run.terminalId);
const continued = service.continue(run.terminalId);
expect(continued).toMatchObject({ id: run.terminalId, exited: false });
expect(continued.commandRunId).toBeUndefined();
expect(service.get(run.terminalId)?.commandRunId).toBeUndefined();
expect(await terminalReplay(service, run.terminalId)).toContain("[continued in interactive shell]");
} finally {
service.dispose();
}
});
it("marks failed command runs when the command exits non-zero", async () => {
const service = new TerminalService();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd: process.cwd(),
title: "Failing command",
command: "exit 7",
});
await terminalExit(service, run.terminalId);
expect(service.getCommandRun(run.id)).toMatchObject({ status: "failed", exitCode: 7 });
} finally {
service.dispose();
}
});
});
function terminalReplay(service: TerminalService, terminalId: string): Promise<string> {
let output = "";
const detach = service.attach(terminalId, {
output: (data) => { output += data; },
exit: () => undefined,
});
detach();
return Promise.resolve(output);
}
function terminalExit(service: TerminalService, terminalId: string): Promise<string> {
const output: string[] = [];
return new Promise((resolve, reject) => {
try {
service.attach(terminalId, {
output: (data) => { output.push(data); },
exit: () => { resolve(output.join("")); },
});
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
+211 -37
View File
@@ -1,7 +1,7 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import * as pty from "node-pty";
import type { TerminalUiEvent } from "../../shared/apiTypes.js";
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus, TerminalUiEvent } from "../../shared/apiTypes.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
@@ -14,16 +14,31 @@ export interface TerminalInfo {
createdAt: string;
exited: boolean;
exitCode?: number;
commandRunId?: string;
}
export interface RunTerminalCommandOptions {
origin: string;
projectId: string;
workspaceId: string;
cwd: string;
title: string;
command: string;
metadata?: unknown;
cols?: number;
rows?: number;
}
interface TerminalRecord extends TerminalInfo {
pty: pty.IPty;
buffer: string;
events: EventEmitter;
commandRunId?: string;
}
export class TerminalService {
private readonly terminals = new Map<string, TerminalRecord>();
private readonly commandRuns = new Map<string, TerminalCommandRun>();
constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal">) {}
@@ -34,45 +49,67 @@ export class TerminalService {
}
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
if (options.cwd === "") throw new Error("cwd is required");
const id = randomUUID();
return this.createTerminal({ ...options, shellArgs: [] });
}
runCommand(options: RunTerminalCommandOptions): TerminalCommandRun {
validateCommandRunOptions(options);
const commandRunId = randomUUID();
const terminalId = 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)}`,
const metadata = parseMetadata(options.metadata);
const queued: TerminalCommandRun = {
id: commandRunId,
origin: options.origin,
projectId: options.projectId,
workspaceId: options.workspaceId,
terminalId,
title: options.title,
command: options.command,
status: "queued",
createdAt,
exited: false,
pty: terminal,
buffer: "",
events: new EventEmitter(),
metadata,
};
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);
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.exited", terminal: info });
});
this.terminals.set(id, record);
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.created", terminal: info });
return info;
const running: TerminalCommandRun = { ...queued, status: "running", startedAt: new Date().toISOString() };
this.commandRuns.set(commandRunId, running);
try {
this.createTerminal({
id: terminalId,
cwd: options.cwd,
name: options.title,
...(options.cols === undefined ? {} : { cols: options.cols }),
...(options.rows === undefined ? {} : { rows: options.rows }),
shellArgs: ["-lc", commandRunShellScript(options.command)],
commandRunId,
});
} catch (error) {
this.commandRuns.delete(commandRunId);
throw error;
}
return copyCommandRun(this.commandRuns.get(commandRunId) ?? running);
}
listCommandRuns(filter: TerminalCommandRunFilter = {}): TerminalCommandRun[] {
return [...this.commandRuns.values()]
.filter((run) => matchesCommandRunFilter(run, filter))
.map(copyCommandRun);
}
getCommandRun(runId: string): TerminalCommandRun | undefined {
const run = this.commandRuns.get(runId);
return run === undefined ? undefined : copyCommandRun(run);
}
cancelCommandRun(runId: string): TerminalCommandRun {
const run = this.commandRuns.get(runId);
if (run === undefined) throw new Error("Terminal command run not found");
if (isTerminalCommandRunFinal(run.status)) return copyCommandRun(run);
const terminal = this.terminals.get(run.terminalId);
if (terminal === undefined) throw new Error("Terminal not found");
if (!terminal.exited) terminal.pty.write("\x03");
return copyCommandRun(run);
}
get(id: string): TerminalInfo | undefined {
@@ -106,6 +143,30 @@ export class TerminalService {
}
}
continue(id: string): TerminalInfo {
const record = this.require(id);
if (!record.exited) return toInfo(record);
delete record.exitCode;
delete record.commandRunId;
record.exited = false;
const marker = "\r\n[continued in interactive shell]\r\n";
record.buffer = trimReplayBuffer(record.buffer + marker);
record.events.emit("output", marker);
const shell = process.env["SHELL"] ?? "/bin/bash";
record.pty = pty.spawn(shell, [], {
name: "xterm-256color",
cwd: record.cwd,
cols: 100,
rows: 30,
env: { ...process.env, TERM: "xterm-256color" },
});
this.attachPtyEvents(record);
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.created", terminal: info });
return info;
}
close(id: string): void {
const terminal = this.terminals.get(id);
if (terminal === undefined) return;
@@ -120,6 +181,67 @@ export class TerminalService {
for (const id of [...this.terminals.keys()]) this.close(id);
}
private createTerminal(options: { id?: string; cwd: string; name?: string; cols?: number; rows?: number; shellArgs: string[]; commandRunId?: string }): TerminalInfo {
if (options.cwd === "") throw new Error("cwd is required");
const id = options.id ?? randomUUID();
const createdAt = new Date().toISOString();
const shell = process.env["SHELL"] ?? "/bin/bash";
const terminal = pty.spawn(shell, options.shellArgs, {
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(),
...(options.commandRunId === undefined ? {} : { commandRunId: options.commandRunId }),
};
this.attachPtyEvents(record);
this.terminals.set(id, record);
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.created", terminal: info });
return info;
}
private attachPtyEvents(record: TerminalRecord): void {
record.pty.onData((data) => {
record.buffer = trimReplayBuffer(record.buffer + data);
record.events.emit("output", data);
});
record.pty.onExit(({ exitCode }) => {
record.exited = true;
record.exitCode = exitCode;
this.completeCommandRun(record.commandRunId, exitCode);
record.events.emit("exit", exitCode);
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.exited", terminal: info });
});
}
private completeCommandRun(runId: string | undefined, exitCode: number | undefined): void {
if (runId === undefined) return;
const run = this.commandRuns.get(runId);
if (run === undefined || isTerminalCommandRunFinal(run.status)) return;
const completed: TerminalCommandRun = {
...run,
status: exitCode === 0 ? "succeeded" : "failed",
...(exitCode === undefined ? {} : { exitCode }),
completedAt: new Date().toISOString(),
};
this.commandRuns.set(runId, completed);
}
private require(id: string): TerminalRecord {
const terminal = this.terminals.get(id);
if (terminal === undefined) throw new Error("Terminal not found");
@@ -139,6 +261,7 @@ function toInfo(record: TerminalRecord): TerminalInfo {
createdAt: record.createdAt,
exited: record.exited,
...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
...(record.commandRunId === undefined ? {} : { commandRunId: record.commandRunId }),
};
}
@@ -146,3 +269,54 @@ function trimReplayBuffer(buffer: string): string {
if (buffer.length <= MAX_REPLAY_BUFFER) return buffer;
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
}
function commandRunShellScript(command: string): string {
return `printf '%s\\n' ${shellQuote(`$ ${command}`)}\n${command}`;
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function validateCommandRunOptions(options: RunTerminalCommandOptions): void {
if (options.origin.trim() === "") throw new Error("origin is required");
if (options.projectId.trim() === "") throw new Error("projectId is required");
if (options.workspaceId.trim() === "") throw new Error("workspaceId is required");
if (options.cwd.trim() === "") throw new Error("cwd is required");
if (options.title.trim() === "") throw new Error("title is required");
if (options.command.trim() === "") throw new Error("command is required");
parseMetadata(options.metadata);
}
function parseMetadata(value: unknown): Record<string, string> {
if (value === undefined || value === null) return {};
if (!isRecord(value) || Array.isArray(value)) throw new Error("metadata must be an object");
return Object.fromEntries(Object.entries(value).map(([key, metadataValue]) => {
if (key.trim() === "") throw new Error("metadata keys must not be empty");
if (typeof metadataValue !== "string") throw new Error("metadata values must be strings");
return [key, metadataValue];
}));
}
function matchesCommandRunFilter(run: TerminalCommandRun, filter: TerminalCommandRunFilter): boolean {
if (filter.projectId !== undefined && run.projectId !== filter.projectId) return false;
if (filter.workspaceId !== undefined && run.workspaceId !== filter.workspaceId) return false;
if (filter.terminalId !== undefined && run.terminalId !== filter.terminalId) return false;
if (filter.statuses !== undefined && filter.statuses.length > 0 && !filter.statuses.includes(run.status)) return false;
for (const [key, value] of Object.entries(filter.metadata ?? {})) {
if (run.metadata[key] !== value) return false;
}
return true;
}
function isTerminalCommandRunFinal(status: TerminalCommandRunStatus): boolean {
return status === "succeeded" || status === "failed";
}
function copyCommandRun(run: TerminalCommandRun): TerminalCommandRun {
return { ...run, metadata: { ...run.metadata } };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+40
View File
@@ -201,6 +201,46 @@ export interface TerminalInfo {
createdAt: string;
exited: boolean;
exitCode?: number;
commandRunId?: string;
}
export type TerminalCommandRunStatus = "queued" | "running" | "succeeded" | "failed";
export interface TerminalCommandRun {
id: string;
origin: string;
projectId: string;
workspaceId: string;
terminalId: string;
title: string;
command: string;
status: TerminalCommandRunStatus;
exitCode?: number;
createdAt: string;
startedAt?: string;
completedAt?: string;
metadata: Record<string, string>;
}
export interface RunTerminalCommandInput {
workspace: Workspace;
title: string;
command: string;
metadata?: Record<string, string>;
open?: boolean;
}
export interface TerminalCommandRunHandle {
run: TerminalCommandRun;
completed: Promise<TerminalCommandRun>;
}
export interface TerminalCommandRunFilter {
projectId?: string;
workspaceId?: string;
terminalId?: string;
statuses?: TerminalCommandRunStatus[];
metadata?: Record<string, string>;
}
export type PiWebServiceComponent = "web" | "sessiond";