feat(cli): add paseo terminal command group for managing workspace terminals

New CLI commands: ls, create, kill, capture (tmux capture-pane style with
line range slicing and ANSI stripping), and send-keys (with special token
interpretation). Server-side adds capture_terminal_request RPC and
list-all-terminals support (optional CWD). Includes e2e tests and skill docs.
This commit is contained in:
Mohamed Boudra
2026-03-30 17:05:14 +07:00
parent 5b22aedaff
commit 0ba2f6b194
15 changed files with 938 additions and 9 deletions
+27 -2
View File
@@ -44,6 +44,7 @@ import type {
SubscribeTerminalResponse,
TerminalState,
KillTerminalResponse,
CaptureTerminalResponse,
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
@@ -237,6 +238,7 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
@@ -2709,11 +2711,11 @@ export class DaemonClient {
});
}
async listTerminals(cwd: string, requestId?: string): Promise<ListTerminalsPayload> {
async listTerminals(cwd?: string, requestId?: string): Promise<ListTerminalsPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "list_terminals_request",
cwd,
...(cwd === undefined ? {} : { cwd }),
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
@@ -2827,6 +2829,29 @@ export class DaemonClient {
});
}
async captureTerminal(
terminalId: string,
options?: { start?: number; end?: number; stripAnsi?: boolean },
requestId?: string,
): Promise<CaptureTerminalPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "capture_terminal_request",
terminalId,
...(options?.start === undefined ? {} : { start: options.start }),
...(options?.end === undefined ? {} : { end: options.end }),
...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }),
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "capture_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
}
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
@@ -415,12 +415,17 @@ async function subscribeRawTerminal(
describe("daemon E2E terminal", () => {
let ctx: DaemonTestContext;
let tempDirs: string[];
beforeEach(async () => {
ctx = await createDaemonTestContext();
tempDirs = [];
});
afterEach(async () => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
await ctx.cleanup();
}, 60000);
@@ -1055,4 +1060,161 @@ describe("daemon E2E terminal", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 40000);
describe("capture", () => {
test("captures visible terminal output as plain text", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo hello world\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
const capture = await ctx.client.captureTerminal(terminalId);
expect(capture.lines.join("\n")).toContain("hello world");
expect(capture.totalLines).toBeGreaterThan(0);
}, 15000);
test("captures with start/end line range", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo line1\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo line2\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo line3\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000);
const fullCapture = await ctx.client.captureTerminal(terminalId);
const rangedCapture = await ctx.client.captureTerminal(terminalId, {
start: 0,
end: 2,
});
expect(rangedCapture.lines).toHaveLength(3);
expect(rangedCapture.totalLines).toBe(fullCapture.totalLines);
}, 15000);
test("supports negative line indices", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo alpha\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo beta\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo gamma\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000);
const capture = await ctx.client.captureTerminal(terminalId, {
start: -3,
});
expect(capture.lines).toHaveLength(3);
}, 15000);
test("strips ANSI by default", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf '\\033[31mred text\\033[0m\\n'\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
const capture = await ctx.client.captureTerminal(terminalId);
const capturedText = capture.lines.join("\n");
expect(capturedText).toContain("red text");
expect(capturedText).not.toContain("\u001b[31m");
}, 15000);
test("returns empty for non-existent terminal", async () => {
const capture = await ctx.client.captureTerminal("terminal-does-not-exist");
expect(capture.lines).toEqual([]);
expect(capture.totalLines).toBe(0);
});
});
describe("list terminals across directories", () => {
test("lists terminals from all directories when cwd is omitted", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
tempDirs.push(cwd1, cwd2);
const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal");
const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal");
const list = await ctx.client.listTerminals();
expect(list).not.toHaveProperty("cwd");
expect(list.terminals).toEqual(
expect.arrayContaining([
{
id: firstCreated.terminal!.id,
name: "first-terminal",
},
{
id: secondCreated.terminal!.id,
name: "second-terminal",
},
]),
);
});
test("lists terminals for specific directory when cwd is provided", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
tempDirs.push(cwd1, cwd2);
const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal");
await ctx.client.createTerminal(cwd2, "cwd-two-terminal");
const list = await ctx.client.listTerminals(cwd1);
expect(list.cwd).toBe(cwd1);
expect(list.terminals).toEqual([
{
id: firstCreated.terminal!.id,
name: "cwd-one-terminal",
},
]);
});
});
});
+87 -5
View File
@@ -23,6 +23,7 @@ import {
type UnsubscribeTerminalRequest,
type TerminalInput,
type KillTerminalRequest,
type CaptureTerminalRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
@@ -31,7 +32,7 @@ import {
type WorkspaceStateBucket,
} from "./messages.js";
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import { captureTerminalLines, type TerminalSession } from "../terminal/terminal.js";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
@@ -1697,6 +1698,10 @@ export class Session {
await this.handleKillTerminalRequest(msg);
break;
case "capture_terminal_request":
await this.handleCaptureTerminalRequest(msg);
break;
case "chat/create":
await this.handleChatCreateRequest(msg);
break;
@@ -7283,7 +7288,7 @@ export class Session {
this.emit({
type: "list_terminals_response",
payload: {
cwd: msg.cwd,
...(msg.cwd ? { cwd: msg.cwd } : {}),
terminals: [],
requestId: msg.requestId,
},
@@ -7292,14 +7297,17 @@ export class Session {
}
try {
const terminals = await this.terminalManager.getTerminals(msg.cwd);
const terminals =
typeof msg.cwd === "string"
? await this.terminalManager.getTerminals(msg.cwd)
: await this.getAllTerminalSessions();
for (const terminal of terminals) {
this.ensureTerminalExitSubscription(terminal);
}
this.emit({
type: "list_terminals_response",
payload: {
cwd: msg.cwd,
...(msg.cwd ? { cwd: msg.cwd } : {}),
terminals: terminals.map((t) => ({ id: t.id, name: t.name })),
requestId: msg.requestId,
},
@@ -7309,7 +7317,7 @@ export class Session {
this.emit({
type: "list_terminals_response",
payload: {
cwd: msg.cwd,
...(msg.cwd ? { cwd: msg.cwd } : {}),
terminals: [],
requestId: msg.requestId,
},
@@ -7317,6 +7325,18 @@ export class Session {
}
}
private async getAllTerminalSessions(): Promise<TerminalSession[]> {
if (!this.terminalManager) {
return [];
}
const directories = this.terminalManager.listDirectories();
const terminalsByDirectory = await Promise.all(
directories.map((cwd) => this.terminalManager!.getTerminals(cwd)),
);
return terminalsByDirectory.flat();
}
private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
@@ -7488,6 +7508,68 @@ export class Session {
});
}
private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: [],
totalLines: 0,
requestId: msg.requestId,
},
});
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: [],
totalLines: 0,
requestId: msg.requestId,
},
});
return;
}
this.ensureTerminalExitSubscription(session);
try {
const capture = captureTerminalLines(session, {
start: msg.start,
end: msg.end,
stripAnsi: msg.stripAnsi,
});
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: capture.lines,
totalLines: capture.totalLines,
requestId: msg.requestId,
},
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, terminalId: msg.terminalId },
"Failed to capture terminal",
);
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: [],
totalLines: 0,
requestId: msg.requestId,
},
});
}
}
private bindActiveTerminalStream(terminal: TerminalSession): number | null {
if (!this.onBinaryMessage) {
return null;
+25 -2
View File
@@ -1116,7 +1116,7 @@ export const RegisterPushTokenMessageSchema = z.object({
export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"),
cwd: z.string(),
cwd: z.string().optional(),
requestId: z.string(),
});
@@ -1172,6 +1172,15 @@ export const KillTerminalRequestSchema = z.object({
requestId: z.string(),
});
export const CaptureTerminalRequestSchema = z.object({
type: z.literal("capture_terminal_request"),
terminalId: z.string(),
start: z.number().int().optional(),
end: z.number().int().optional(),
stripAnsi: z.boolean().default(true),
requestId: z.string(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
@@ -1235,6 +1244,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema,
TerminalInputSchema,
KillTerminalRequestSchema,
CaptureTerminalRequestSchema,
ChatCreateRequestSchema,
ChatListRequestSchema,
ChatInspectRequestSchema,
@@ -2158,7 +2168,7 @@ export const TerminalStateSchema = z
export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"),
payload: z.object({
cwd: z.string(),
cwd: z.string().optional(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
requestId: z.string(),
}),
@@ -2207,6 +2217,16 @@ export const KillTerminalResponseSchema = z.object({
}),
});
export const CaptureTerminalResponseSchema = z.object({
type: z.literal("capture_terminal_response"),
payload: z.object({
terminalId: z.string(),
lines: z.array(z.string()),
totalLines: z.number().int().nonnegative(),
requestId: z.string(),
}),
});
export const TerminalStreamExitSchema = z.object({
type: z.literal("terminal_stream_exit"),
payload: z.object({
@@ -2276,6 +2296,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema,
KillTerminalResponseSchema,
CaptureTerminalResponseSchema,
TerminalStreamExitSchema,
ChatCreateResponseSchema,
ChatListResponseSchema,
@@ -2468,6 +2489,8 @@ export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
export type TerminalState = z.infer<typeof TerminalStateSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema>;
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================
+66
View File
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
import { chmodSync, existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import stripAnsi from "strip-ansi";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm;
@@ -40,6 +41,17 @@ export interface CreateTerminalOptions {
name?: string;
}
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
type EnsureNodePtySpawnHelperExecutableOptions = {
packageRoot?: string;
platform?: NodeJS.Platform;
@@ -230,6 +242,60 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
};
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells.map((cell) => cell.char).join("").trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,