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:
@@ -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());
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<SingleResult<TerminalRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 <path>", "Workspace directory"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
terminal
|
||||
.command("create")
|
||||
.description("Create a terminal")
|
||||
.option("--cwd <path>", "Workspace directory")
|
||||
.option("--name <name>", "Terminal name"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
terminal
|
||||
.command("kill")
|
||||
.description("Kill a terminal")
|
||||
.argument("<terminal-id>", "Terminal ID, ID prefix, or name"),
|
||||
).action(withOutput(runKillCommand));
|
||||
|
||||
addDaemonHostOption(
|
||||
terminal
|
||||
.command("capture")
|
||||
.description("Capture terminal output")
|
||||
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
|
||||
.option("--start <n>", "Capture start line")
|
||||
.option("--end <n>", "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>", "Terminal ID, ID prefix, or name")
|
||||
.argument("<keys...>", "Keys to send")
|
||||
.option("-l, --literal", "Send raw keys without interpreting special tokens")
|
||||
.option("--json", "Output in JSON format"),
|
||||
).action(runSendKeysCommand);
|
||||
|
||||
return terminal;
|
||||
}
|
||||
@@ -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<SingleResult<TerminalKillRow>> {
|
||||
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<ReturnType<typeof connectTerminalClient>>["client"],
|
||||
terminalId: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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<ListResult<TerminalRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<TerminalRow> = {
|
||||
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<TerminalKillRow> = {
|
||||
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 ?? "-",
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof connectToDaemon>>,
|
||||
idOrName: string,
|
||||
): Promise<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -154,6 +154,59 @@ paseo chat wait <room> --timeout <duration>
|
||||
paseo chat delete <name-or-id>
|
||||
```
|
||||
|
||||
## 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 <terminal-id>
|
||||
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 <terminal-id> # Visible pane only, ANSI stripped
|
||||
paseo terminal capture <terminal-id> --scrollback # Full scrollback + visible
|
||||
paseo terminal capture <terminal-id> -S # Short form of --scrollback
|
||||
paseo terminal capture <terminal-id> --start 0 --end 10 # Line range (tmux-style)
|
||||
paseo terminal capture <terminal-id> --start -5 # Last 5 lines
|
||||
paseo terminal capture <terminal-id> --ansi # Preserve ANSI escape codes
|
||||
paseo terminal capture <terminal-id> --json # JSON output with metadata
|
||||
|
||||
# Send keystrokes (like tmux send-keys)
|
||||
paseo terminal send-keys <terminal-id> "ls -la" Enter
|
||||
paseo terminal send-keys <terminal-id> "echo hello" Enter
|
||||
paseo terminal send-keys <terminal-id> C-c # Ctrl+C
|
||||
paseo terminal send-keys <terminal-id> C-d # Ctrl+D
|
||||
paseo terminal send-keys <terminal-id> --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:
|
||||
|
||||
Reference in New Issue
Block a user