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:
@@ -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" }],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user