feat(launch): rendered launch logs from daemon terminal byte streams

- Changed daemon log reads to return both sanitized display text and a raw `terminalText` slice, and included it on log RPC responses for PTY runs when grep was not used.
- Extended the logs result contract and launch tool rendering to consume `terminalText`, reconstruct terminal output, and display it in framed, preview-capped sections.
- Kept terminal row layout stable by writing space characters for empty cells when reading rows, preserving spacing during output reconstruction.
This commit is contained in:
can1357
2026-07-13 19:18:17 +02:00
parent f80fb4836f
commit 4c1c5f40d8
9 changed files with 115 additions and 44 deletions
@@ -102,24 +102,33 @@ export const shellFixtures: Record<string, GalleryFixture> = {
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" }],
+28 -13
View File
@@ -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<string> {
async read(head: boolean, lines: number, grep?: string): Promise<DaemonLogRead> {
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<string> {
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<string> {
): Promise<DaemonLogRead> {
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<void> {
@@ -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,
@@ -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),
+8 -2
View File
@@ -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;
+31 -8
View File
@@ -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),
@@ -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 */
@@ -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;
}
@@ -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(
@@ -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");