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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user