diff --git a/packages/coding-agent/src/cli/gallery-fixtures/shell.ts b/packages/coding-agent/src/cli/gallery-fixtures/shell.ts index d734a7da3..1261b672c 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/shell.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/shell.ts @@ -102,24 +102,33 @@ export const shellFixtures: Record = { launch_logs: { label: "Launch", renderer: "launch", - args: { op: "logs", name: "web", lines: 100, follow: true, cursor: 1842, timeout: 30 }, + args: { op: "logs", name: "comp-debug", lines: 100, follow: true, cursor: 233_512, timeout: 30 }, result: { content: [ { type: "text", text: [ - "$ bun run dev", - " VITE v6.0.3 ready in 312 ms", - "", - " ➜ Local: http://localhost:5173/", - " ➜ Network: use --host to expose", - "12:04:11 [vite] hmr update /src/App.tsx", - "12:04:15 [vite] hmr update /src/components/Chart.tsx", - "[web: running; cursor=2210]", + "Breakpoint 1: 3 locations.", + "(lldb) run", + "Process 726 launched: '/tmp/compiler'", + "frame #0: 0x0000000100012f80 compiler`parse_expression", + "[comp-debug: ready; cursor=233797]", ].join("\n"), }, ], - details: { op: "logs", cursor: 2210, timedOut: false, state: "running" }, + details: { + op: "logs", + cursor: 233_797, + timedOut: false, + state: "ready", + terminalRows: [ + "\x1b[0mBreakpoint 1: 3 locations.", + "\x1b[0m(lldb) run", + "\x1b[0mProcess 726 launched: '/tmp/compiler'", + "\x1b[0mframe #0: 0x0000000100012f80 compiler`parse_expression", + "\x1b[0m\x1b[1;38;5;2m(lldb)\x1b[0m ", + ], + }, }, errorResult: { content: [{ type: "text", text: "No daemon named web" }], diff --git a/packages/coding-agent/src/launch/broker.ts b/packages/coding-agent/src/launch/broker.ts index 9c8aa5db2..9511ae9a9 100644 --- a/packages/coding-agent/src/launch/broker.ts +++ b/packages/coding-agent/src/launch/broker.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Process, type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives"; import { isEexist, isEnoent, logger, postmortem, sanitizeText } from "@oh-my-pi/pi-utils"; -import { truncateHead, truncateTail } from "../session/streaming-output"; +import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output"; import { workerEnvFromParent } from "../subprocess/worker-client"; import { daemonBrokerEndpoint } from "./paths"; import { hasLiveDaemonProjectPresence } from "./presence"; @@ -76,6 +76,11 @@ interface BrokerLease { instanceId: string; } +interface DaemonLogRead { + text: string; + terminalText: string; +} + function quoteShellArg(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } @@ -155,7 +160,7 @@ class DaemonLog { return text; } - async read(head: boolean, lines: number, grep?: string): Promise { + async read(head: boolean, lines: number, grep?: string): Promise { await this.#queue; await this.#writer.flush(); return DaemonLog.readFiles(this.#path, this.#previousPath, head, lines, grep); @@ -168,19 +173,19 @@ class DaemonLog { await this.#writer.end(); } - static async readDir(dir: string, head: boolean, lines: number, grep?: string): Promise { - return DaemonLog.readFiles(path.join(dir, LOG_FILE), path.join(dir, PREVIOUS_LOG_FILE), head, lines, grep); - } - static async readFiles( logPath: string, previousPath: string, head: boolean, lines: number, grep?: string, - ): Promise { + ): Promise { const [previous, current] = await Promise.all([fileTextSlice(previousPath, head), fileTextSlice(logPath, head)]); - let text = `${previous}${previous && current && !previous.endsWith("\n") ? "\n" : ""}${current}`; + const combined = `${previous}${previous && current && !previous.endsWith("\n") ? "\n" : ""}${current}`; + const terminalText = head + ? truncateHeadBytes(combined, LOG_READ_BYTES).text + : truncateTailBytes(combined, LOG_READ_BYTES).text; + let text = sanitizeText(terminalText); if (grep) { let pattern: RegExp; try { @@ -190,11 +195,14 @@ class DaemonLog { } text = text .split("\n") - .filter(line => pattern.test(sanitizeText(line))) + .filter(line => pattern.test(line)) .join("\n"); } const options = { maxLines: lines, maxBytes: 256 * 1024 }; - return head ? truncateHead(text, options).content : truncateTail(text, options).content; + return { + text: head ? truncateHead(text, options).content : truncateTail(text, options).content, + terminalText, + }; } async #rotate(): Promise { @@ -755,13 +763,20 @@ class DaemonBroker { timedOut = !changed; } const lines = Math.max(1, Math.min(1_000, Math.floor(operation.lines))); - const text = record.log + const output = record.log ? await record.log.read(operation.head, lines, operation.grep) - : await DaemonLog.readDir(record.dir, operation.head, lines, operation.grep); + : await DaemonLog.readFiles( + path.join(record.dir, LOG_FILE), + path.join(record.dir, PREVIOUS_LOG_FILE), + operation.head, + lines, + operation.grep, + ); return { op: "logs", name: record.snapshot.name, - text, + text: output.text, + terminalText: record.spec.pty && operation.grep === undefined ? output.terminalText : undefined, cursor: record.snapshot.outputBytes, timedOut, state: record.snapshot.state, diff --git a/packages/coding-agent/src/launch/protocol.ts b/packages/coding-agent/src/launch/protocol.ts index 0ad3e31cf..5ea71b2a6 100644 --- a/packages/coding-agent/src/launch/protocol.ts +++ b/packages/coding-agent/src/launch/protocol.ts @@ -101,6 +101,8 @@ export type DaemonRpcResult = op: "logs"; name: string; text: string; + /** Raw PTY byte stream used only to reconstruct the terminal screen. */ + terminalText?: string; cursor: number; timedOut: boolean; state: DaemonState; @@ -353,6 +355,8 @@ export function parseDaemonRpcResult(operation: DaemonOperation, value: unknown) op: "logs", name: stringValue(source.name, "result.name"), text: typeof source.text === "string" ? source.text : "", + terminalText: + source.terminalText === undefined ? undefined : rawString(source.terminalText, "result.terminalText"), cursor: numberValue(source.cursor, "result.cursor"), timedOut: booleanValue(source.timedOut, "result.timedOut"), state: daemonState(source.state), diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index e272c230c..ccfea33c1 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -36,12 +36,18 @@ import { stripRawOutputArtifactNotice, } from "./output-meta"; import { resolveToCwd } from "./path-utils"; -import { capPreviewLines, formatToolWorkingDirectory, previewWindowRows, replaceTabs } from "./render-utils"; +import { + capPreviewLines, + DEFAULT_TERMINAL_PREVIEW_LINES, + formatToolWorkingDirectory, + previewWindowRows, + replaceTabs, +} from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts"; -export const BASH_DEFAULT_PREVIEW_LINES = 10; +export const BASH_DEFAULT_PREVIEW_LINES = DEFAULT_TERMINAL_PREVIEW_LINES; const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000; diff --git a/packages/coding-agent/src/tools/launch.ts b/packages/coding-agent/src/tools/launch.ts index 31c628bbf..2c68ae446 100644 --- a/packages/coding-agent/src/tools/launch.ts +++ b/packages/coding-agent/src/tools/launch.ts @@ -16,12 +16,13 @@ import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, Daem import { renderTerminalOutput } from "../launch/terminal-output"; import type { Theme, ThemeColor } from "../modes/theme/theme"; import launchDescription from "../prompts/tools/launch.md" with { type: "text" }; -import { renderStatusLine } from "../tui"; +import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../tui"; import type { ToolSession } from "."; import { resolveToCwd } from "./path-utils"; import { capPreviewLines, createCachedComponent, + DEFAULT_TERMINAL_PREVIEW_LINES, formatDuration, formatExpandHint, formatMoreItems, @@ -283,10 +284,13 @@ async function toolDetails(result: DaemonRpcResult, params: LaunchParams): Promi case "list": return { op: "list", daemons: result.daemons }; case "logs": { - const terminalRows = await renderTerminalOutput(result.text, { - head: params.head ?? false, - maxRows: Math.min(1_000, Math.floor(params.lines ?? 100)), - }); + const terminalRows = + result.terminalText === undefined + ? undefined + : await renderTerminalOutput(result.terminalText, { + head: params.head ?? false, + maxRows: Math.min(1_000, Math.floor(params.lines ?? 100)), + }); return { op: "logs", cursor: result.cursor, @@ -600,13 +604,32 @@ export const launchToolRenderer = { theme, ); + if (op === "logs") { + return framedBlock(theme, width => { + const innerWidth = outputBlockContentWidth(width); + const rows = body.map(line => truncateToWidth(line, innerWidth)); + return { + header, + state: options.isPartial ? "pending" : failed ? "error" : "success", + sections: [ + { + label: theme.fg("toolTitle", "Output"), + lines: capPreviewLines(rows, theme, { + expanded: options.expanded, + max: DEFAULT_TERMINAL_PREVIEW_LINES, + }), + }, + ], + width, + }; + }); + } + return createCachedComponent( () => options.expanded, (width, expanded) => { let visible = body; - if (op === "logs") { - visible = capPreviewLines(body, theme, { expanded }); - } else if (!expanded && op === "list" && body.length > PREVIEW_LIMITS.COLLAPSED_ITEMS) { + if (!expanded && op === "list" && body.length > PREVIEW_LIMITS.COLLAPSED_ITEMS) { const remaining = body.length - PREVIEW_LIMITS.COLLAPSED_ITEMS; visible = [ ...body.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS), diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 12228a1cb..9a6e857af 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -62,6 +62,9 @@ export const PREVIEW_LIMITS = { DIFF_COLLAPSED_LINES: 40, } as const; +/** Default number of terminal output rows shown before expansion. */ +export const DEFAULT_TERMINAL_PREVIEW_LINES = 10; + /** Truncation lengths for different content types */ export const TRUNCATE_LENGTHS = { /** Short titles, labels */ diff --git a/packages/coding-agent/src/tools/terminal-output.ts b/packages/coding-agent/src/tools/terminal-output.ts index e77dc1f4e..6f1043ff4 100644 --- a/packages/coding-agent/src/tools/terminal-output.ts +++ b/packages/coding-agent/src/tools/terminal-output.ts @@ -114,10 +114,8 @@ export function readTerminalRows(terminal: XtermTerminal, startRow: number, rowC if (!cell) break; const chars = cell.getChars(); const width = Math.max(1, cell.getWidth()); - if (chars) { - cells.push({ chars, style: cellStyle(cell) }); - if (chars !== " ") lastContent = cells.length - 1; - } + cells.push({ chars: chars || " ", style: cellStyle(cell) }); + if (chars && chars !== " ") lastContent = cells.length - 1; column += width; } diff --git a/packages/coding-agent/test/tools/launch-renderer.test.ts b/packages/coding-agent/test/tools/launch-renderer.test.ts index 9dfb27f47..fea9522ce 100644 --- a/packages/coding-agent/test/tools/launch-renderer.test.ts +++ b/packages/coding-agent/test/tools/launch-renderer.test.ts @@ -79,17 +79,24 @@ describe("launchToolRenderer", () => { ); expect(rendered[0]).toContain("Launch logs"); expect(rendered[0]).toContain("cursor 2210"); - expect(rendered).toContain("line one"); - expect(rendered).toContain("line two"); + expect(rendered.some(line => line.includes("line one"))).toBe(true); + expect(rendered.some(line => line.includes("line two"))).toBe(true); expect(rendered.some(line => line.includes("[web: running"))).toBe(false); + expect(rendered[0]).toContain("╭"); + expect(rendered.some(line => line.includes("Output"))).toBe(true); + expect(rendered.at(-1)).toContain("╰"); }); it("replays terminal screen rows so cursor rewrites retain their final color and weight", async () => { - const terminalRows = await renderTerminalOutput("\x1b[1;31mold\x1b[0m\r\x1b[1;32mready\x1b[0m\x1b[K", { - head: false, - maxRows: 10, - }); + const terminalRows = await renderTerminalOutput( + "\x1b[1;31mold\x1b[0m\r\x1b[2K\x1b[12G\x1b[1;32mready\x1b[0m\x1b[K", + { + head: false, + maxRows: 10, + }, + ); if (terminalRows === undefined) throw new Error("terminal replay failed"); + expect(Bun.stripANSI(terminalRows[0] ?? "")).toBe(" ready"); const uiTheme = await theme(); const component = launchToolRenderer.renderResult( diff --git a/packages/coding-agent/test/tools/launch.test.ts b/packages/coding-agent/test/tools/launch.test.ts index 84b6bd48c..df0bb0bda 100644 --- a/packages/coding-agent/test/tools/launch.test.ts +++ b/packages/coding-agent/test/tools/launch.test.ts @@ -59,6 +59,8 @@ describe("daemon broker", () => { `process.stdin.setRawMode?.(true); process.stdin.setEncoding("utf8"); process.stdin.resume(); +process.stdout.write("\\x1b[2J\\x1b[H"); +for (let index = 0; index < 25; index++) process.stdout.write("BOOT:" + index + "\\n"); process.stdout.write("\\x1b[1;32mREADY\\x1b[0m\\n"); process.stdin.on("data", chunk => process.stdout.write("INPUT:" + JSON.stringify(chunk) + "\\n")); setInterval(() => {}, 1000); @@ -114,8 +116,12 @@ setInterval(() => {}, 1000); expect(logs.op).toBe("logs"); if (logs.op !== "logs") throw new Error("unexpected logs result"); expect(logs.text).toContain("READY"); - expect(logs.text).toContain("\x1b[1;32mREADY\x1b[0m"); + expect(logs.text).not.toContain("\x1b"); + expect(logs.text).not.toContain("BOOT:0"); expect(logs.text).toContain('INPUT:"run\\r"'); + expect(logs.terminalText).toContain("\x1b[2J\x1b[H"); + expect(logs.terminalText).toContain("\x1b[1;32mREADY\x1b[0m"); + expect(logs.terminalText).toContain("BOOT:0"); const stopped = await first.request({ op: "stop", name: "debugger", timeoutMs: 2_000 }); expect(stopped.op).toBe("stop");