diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1eb8a80fd..336d3015a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,7 @@ import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; +import { createTerminalCommand } from "./commands/terminal/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; @@ -143,6 +144,9 @@ export function createCli(): Command { // Chat commands program.addCommand(createChatCommand()); + // Terminal commands + program.addCommand(createTerminalCommand()); + // Loop commands program.addCommand(createLoopCommand()); diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts new file mode 100644 index 000000000..2dc8f2180 --- /dev/null +++ b/packages/cli/src/commands/terminal/capture.ts @@ -0,0 +1,99 @@ +import type { Command } from "commander"; +import { renderError, toCommandError } from "../../output/render.js"; +import { + connectTerminalClient, + resolveTerminalId, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; + +export interface TerminalCaptureOptions extends TerminalCommandOptions { + start?: string; + end?: string; + scrollback?: boolean; + ansi?: boolean; +} + +export async function runCaptureCommand( + terminalId: string, + _options: TerminalCaptureOptions, + command: Command, +): Promise { + const options = command.optsWithGlobals() as TerminalCaptureOptions; + + try { + const payload = await executeCaptureCommand(terminalId, options); + if (options.json) { + process.stdout.write( + JSON.stringify( + { + terminalId: payload.terminalId, + lines: payload.lines, + totalLines: payload.totalLines, + }, + null, + 2, + ) + "\n", + ); + return; + } + + if (payload.lines.length > 0) { + process.stdout.write(payload.lines.join("\n") + "\n"); + } + } catch (err) { + const output = renderError(toCommandError(err), { + format: options.json ? "json" : "table", + noColor: options.color === false, + }); + process.stderr.write(output + "\n"); + process.exit(1); + } +} + +async function executeCaptureCommand( + terminalId: string, + options: TerminalCaptureOptions, +): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { + const { client } = await connectTerminalClient(options.host); + + try { + const resolvedId = await resolveTerminalId(client, terminalId); + if (!resolvedId) { + throw { + code: "TERMINAL_NOT_FOUND", + message: `No terminal found matching: ${terminalId}`, + details: "Use `paseo terminal ls --all` to list available terminals.", + }; + } + + const start = options.scrollback ? 0 : parseLineNumber("--start", options.start); + const end = parseLineNumber("--end", options.end); + + return await client.captureTerminal(resolvedId, { + ...(start === undefined ? {} : { start }), + ...(end === undefined ? {} : { end }), + stripAnsi: !options.ansi, + }); + } catch (err) { + throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err); + } finally { + await client.close().catch(() => {}); + } +} + +function parseLineNumber(flag: string, value?: string): number | undefined { + if (value === undefined) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed)) { + throw { + code: "INVALID_LINE_NUMBER", + message: `Invalid ${flag} value: ${value}`, + details: "Use an integer line number.", + }; + } + return parsed; +} diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts new file mode 100644 index 000000000..71e662052 --- /dev/null +++ b/packages/cli/src/commands/terminal/create.ts @@ -0,0 +1,41 @@ +import type { Command } from "commander"; +import type { SingleResult, CommandError } from "../../output/index.js"; +import { + connectTerminalClient, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; +import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; + +export interface TerminalCreateOptions extends TerminalCommandOptions { + cwd?: string; + name?: string; +} + +export async function runCreateCommand( + options: TerminalCreateOptions, + _command: Command, +): Promise> { + const { client } = await connectTerminalClient(options.host); + const cwd = options.cwd ?? process.cwd(); + + try { + const payload = await client.createTerminal(cwd, options.name); + if (!payload.terminal) { + const error: CommandError = { + code: "TERMINAL_CREATE_FAILED", + message: payload.error ?? "Failed to create terminal", + }; + throw error; + } + return { + type: "single", + data: toTerminalRow(payload.terminal), + schema: terminalSchema, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/terminal/index.ts b/packages/cli/src/commands/terminal/index.ts new file mode 100644 index 000000000..0d0ddfd8b --- /dev/null +++ b/packages/cli/src/commands/terminal/index.ts @@ -0,0 +1,59 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addDaemonHostOption, addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runCaptureCommand } from "./capture.js"; +import { runCreateCommand } from "./create.js"; +import { runKillCommand } from "./kill.js"; +import { runLsCommand } from "./ls.js"; +import { runSendKeysCommand } from "./send-keys.js"; + +export function createTerminalCommand(): Command { + const terminal = new Command("terminal").description("Manage workspace terminals"); + + addJsonAndDaemonHostOptions( + terminal + .command("ls") + .description("List terminals") + .option("--all", "List terminals across all workspaces") + .option("--cwd ", "Workspace directory"), + ).action(withOutput(runLsCommand)); + + addJsonAndDaemonHostOptions( + terminal + .command("create") + .description("Create a terminal") + .option("--cwd ", "Workspace directory") + .option("--name ", "Terminal name"), + ).action(withOutput(runCreateCommand)); + + addJsonAndDaemonHostOptions( + terminal + .command("kill") + .description("Kill a terminal") + .argument("", "Terminal ID, ID prefix, or name"), + ).action(withOutput(runKillCommand)); + + addDaemonHostOption( + terminal + .command("capture") + .description("Capture terminal output") + .argument("", "Terminal ID, ID prefix, or name") + .option("--start ", "Capture start line") + .option("--end ", "Capture end line") + .option("-S, --scrollback", "Capture from the beginning of scrollback") + .option("--ansi", "Preserve ANSI escape codes") + .option("--json", "Output in JSON format"), + ).action(runCaptureCommand); + + addDaemonHostOption( + terminal + .command("send-keys") + .description("Send keys to a terminal") + .argument("", "Terminal ID, ID prefix, or name") + .argument("", "Keys to send") + .option("-l, --literal", "Send raw keys without interpreting special tokens") + .option("--json", "Output in JSON format"), + ).action(runSendKeysCommand); + + return terminal; +} diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts new file mode 100644 index 000000000..0a574e16d --- /dev/null +++ b/packages/cli/src/commands/terminal/kill.ts @@ -0,0 +1,51 @@ +import type { Command } from "commander"; +import type { CommandError, SingleResult } from "../../output/index.js"; +import { + connectTerminalClient, + resolveTerminalId, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; +import { terminalKillSchema, type TerminalKillRow } from "./schema.js"; + +export async function runKillCommand( + terminalId: string, + options: TerminalCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectTerminalClient(options.host); + + try { + const resolvedId = await requireTerminalId(client, terminalId); + const payload = await client.killTerminal(resolvedId); + return { + type: "single", + data: { + terminalId: payload.terminalId, + success: payload.success, + }, + schema: terminalKillSchema, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err); + } finally { + await client.close().catch(() => {}); + } +} + +async function requireTerminalId( + client: Awaited>["client"], + terminalId: string, +): Promise { + const resolvedId = await resolveTerminalId(client, terminalId); + if (resolvedId) { + return resolvedId; + } + + const error: CommandError = { + code: "TERMINAL_NOT_FOUND", + message: `No terminal found matching: ${terminalId}`, + details: "Use `paseo terminal ls --all` to list available terminals.", + }; + throw error; +} diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts new file mode 100644 index 000000000..7f47bee18 --- /dev/null +++ b/packages/cli/src/commands/terminal/ls.ts @@ -0,0 +1,34 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + connectTerminalClient, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; +import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; + +export interface TerminalLsOptions extends TerminalCommandOptions { + all?: boolean; + cwd?: string; +} + +export async function runLsCommand( + options: TerminalLsOptions, + _command: Command, +): Promise> { + const { client } = await connectTerminalClient(options.host); + const cwd = options.all ? undefined : (options.cwd ?? process.cwd()); + + try { + const payload = cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd); + return { + type: "list", + data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)), + schema: terminalSchema, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/terminal/schema.ts b/packages/cli/src/commands/terminal/schema.ts new file mode 100644 index 000000000..9b777ad19 --- /dev/null +++ b/packages/cli/src/commands/terminal/schema.ts @@ -0,0 +1,44 @@ +import type { OutputSchema } from "../../output/index.js"; + +export interface TerminalRow { + id: string; + name: string; + cwd: string; +} + +export interface TerminalKillRow { + terminalId: string; + success: boolean; +} + +export const terminalSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "ID", field: (row) => row.id.slice(0, 8), width: 8 }, + { header: "NAME", field: "name", width: 24 }, + { header: "CWD", field: "cwd", width: 48 }, + ], +}; + +export const terminalKillSchema: OutputSchema = { + idField: "terminalId", + columns: [ + { header: "ID", field: (row) => row.terminalId.slice(0, 8), width: 8 }, + { header: "SUCCESS", field: "success", width: 8 }, + ], +}; + +export function toTerminalRow( + terminal: { + id: string; + name: string; + cwd?: string; + }, + cwd?: string, +): TerminalRow { + return { + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd ?? cwd ?? "-", + }; +} diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts new file mode 100644 index 000000000..f109cece6 --- /dev/null +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -0,0 +1,99 @@ +import type { Command } from "commander"; +import { renderError, toCommandError } from "../../output/render.js"; +import { + connectTerminalClient, + resolveTerminalId, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; + +export interface TerminalSendKeysOptions extends TerminalCommandOptions { + literal?: boolean; +} + +export async function runSendKeysCommand( + terminalId: string, + keys: string[], + _options: TerminalSendKeysOptions, + command: Command, +): Promise { + const options = command.optsWithGlobals() as TerminalSendKeysOptions; + + try { + const payload = await executeSendKeysCommand(terminalId, keys, options); + if (options.json) { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); + } + } catch (err) { + const output = renderError(toCommandError(err), { + format: options.json ? "json" : "table", + noColor: options.color === false, + }); + process.stderr.write(output + "\n"); + process.exit(1); + } +} + +async function executeSendKeysCommand( + terminalId: string, + keys: string[], + options: TerminalSendKeysOptions, +): Promise<{ terminalId: string; keysSent: number }> { + const { client } = await connectTerminalClient(options.host); + + try { + const resolvedId = await resolveTerminalId(client, terminalId); + if (!resolvedId) { + throw { + code: "TERMINAL_NOT_FOUND", + message: `No terminal found matching: ${terminalId}`, + details: "Use `paseo terminal ls --all` to list available terminals.", + }; + } + + const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join(""); + client.sendTerminalInput(resolvedId, { type: "input", data }); + + return { + terminalId: resolvedId, + keysSent: data.length, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err); + } finally { + await client.close().catch(() => {}); + } +} + +function resolveKeyToken(key: string, literal: boolean): string { + if (literal) { + return key; + } + + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; + } +} diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts new file mode 100644 index 000000000..6e0493657 --- /dev/null +++ b/packages/cli/src/commands/terminal/shared.ts @@ -0,0 +1,87 @@ +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; + +export interface TerminalCommandOptions extends CommandOptions { + host?: string; +} + +interface TerminalLike { + id: string; + name?: string | null; +} + +export async function connectTerminalClient(host?: string) { + const daemonHost = getDaemonHost({ host }); + try { + const client = await connectToDaemon({ host }); + return { client, daemonHost }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${daemonHost}: ${message}`, + details: "Start the daemon with: paseo daemon start", + }; + throw error; + } +} + +export function toTerminalCommandError(code: string, action: string, err: unknown): CommandError { + if (err && typeof err === "object" && "code" in err && "message" in err) { + return err as CommandError; + } + + const message = err instanceof Error ? err.message : String(err); + const rpcCode = + typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" + ? err.code + : undefined; + + return { + code: rpcCode ?? code, + message: `Failed to ${action}: ${message}`, + }; +} + +export async function resolveTerminalId( + client: Awaited>, + idOrName: string, +): Promise { + const payload = await client.listTerminals(); + return resolveTerminalIdentifier(idOrName, payload.terminals); +} + +function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null { + if (!idOrName || terminals.length === 0) { + return null; + } + + const query = idOrName.toLowerCase(); + + const exactMatch = terminals.find((terminal) => terminal.id === idOrName); + if (exactMatch) { + return exactMatch.id; + } + + const prefixMatches = terminals.filter((terminal) => terminal.id.toLowerCase().startsWith(query)); + if (prefixMatches.length === 1 && prefixMatches[0]) { + return prefixMatches[0].id; + } + if (prefixMatches.length > 1) { + return null; + } + + const nameMatches = terminals.filter((terminal) => terminal.name?.toLowerCase() === query); + if (nameMatches.length === 1 && nameMatches[0]) { + return nameMatches[0].id; + } + + const partialNameMatches = terminals.filter((terminal) => + terminal.name?.toLowerCase().includes(query), + ); + if (partialNameMatches.length === 1 && partialNameMatches[0]) { + return partialNameMatches[0].id; + } + + return null; +} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 450fa05da..13d35ab3e 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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 { + async listTerminals(cwd?: string, requestId?: string): Promise { 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 { + 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 { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 24873b13b..0f66a29c8 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -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", + }, + ]); + }); + }); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8b5ecbbd8..45bb25437 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + 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 { if (!this.terminalManager) { this.emit({ @@ -7488,6 +7508,68 @@ export class Session { }); } + private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise { + 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; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 9885aaf27..84f771030 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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; export type TerminalState = z.infer; export type KillTerminalRequest = z.infer; export type KillTerminalResponse = z.infer; +export type CaptureTerminalRequest = z.infer; +export type CaptureTerminalResponse = z.infer; export type TerminalStreamExit = z.infer; // ============================================================================ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 28e2601c7..b6b80b302 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -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 { const { cwd, diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 711857266..1ac7355f2 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -154,6 +154,59 @@ paseo chat wait --timeout paseo chat delete ``` +## Terminal Commands + +Manage workspace terminals: create, inspect, send keystrokes, capture output. + +```bash +# List terminals (scoped to current directory by default) +paseo terminal ls # Terminals in current directory +paseo terminal ls --all # All terminals across all workspaces +paseo terminal ls --cwd ~/dev/myapp # Terminals in a specific directory + +# Create a terminal +paseo terminal create # In current directory +paseo terminal create --cwd ~/dev/myapp # In a specific directory +paseo terminal create --name "build-runner" # With a custom name + +# Kill a terminal (supports short ID prefixes and name matching) +paseo terminal kill +paseo terminal kill abc123 # Short prefix +paseo terminal kill build-runner # By name + +# Capture terminal output as plain text (like tmux capture-pane -p) +paseo terminal capture # Visible pane only, ANSI stripped +paseo terminal capture --scrollback # Full scrollback + visible +paseo terminal capture -S # Short form of --scrollback +paseo terminal capture --start 0 --end 10 # Line range (tmux-style) +paseo terminal capture --start -5 # Last 5 lines +paseo terminal capture --ansi # Preserve ANSI escape codes +paseo terminal capture --json # JSON output with metadata + +# Send keystrokes (like tmux send-keys) +paseo terminal send-keys "ls -la" Enter +paseo terminal send-keys "echo hello" Enter +paseo terminal send-keys C-c # Ctrl+C +paseo terminal send-keys C-d # Ctrl+D +paseo terminal send-keys --literal "raw text" # No special token interpretation +``` + +**Special key tokens** (interpreted by default, use `--literal` to send raw): +`Enter`, `Tab`, `Escape`, `Space`, `BSpace`, `C-c`, `C-d`, `C-z`, `C-l`, `C-a`, `C-e` + +**Common pattern — launch a process and interact with it:** +```bash +id=$(paseo terminal create --name "my-shell" -q) +paseo terminal send-keys "$id" "claude" Enter +sleep 5 +paseo terminal capture "$id" --scrollback # See what happened +paseo terminal send-keys "$id" "Hello!" Enter +sleep 10 +paseo terminal capture "$id" --scrollback # See the response +paseo terminal send-keys "$id" "/exit" Enter +paseo terminal kill "$id" +``` + ## Available Models **Claude (default provider)** — use aliases, CLI resolves to latest version: