diff --git a/README.md b/README.md index 9a9f9317..d70400c1 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ OpenPI 把成熟 Coding Agent 的工作习惯做成 Pi-native 能力,但不复 | 统一配置 | `/openpi-setup` 管理 OpenPI 自有模型、并发、Footer、输出密度与 Post-edit 偏好 | | 模型授权 | `/login google-antigravity`;实验性的 `/login cursor`(支持 Pi 工具,不执行 Cursor 原生工具) | -Code Mode 的终端视图由 OpenPI 通过 Pi 原生工具渲染接口提供:默认收起脚本与长参数,展示运行状态、子调用统计和短结果预览;失败与取消保留明显提示。`Ctrl+O` 展开脚本、子调用参数与原始输出。该呈现增强需要 Pi 提供 `registerToolRenderer`(已核对 Pi 1.0.2);旧版本保留原生显示。它不改变 Code Mode 执行、权限、模型可见结果或默认启用设置。 +Code Mode 的终端视图由 OpenPI 通过 Pi 原生工具渲染接口提供:默认收起脚本、长参数、结果正文及完整输出路径,仅展示运行状态,并按原顺序逐条展示全部已记录的子调用摘要,不折叠调用数量;Bash 命令预览最多显示 80 个终端列,超出部分用省略号隐藏,窄终端会进一步截断;子调用沿用普通工具的线框图标,执行中只用灰色表示,不重复显示运行标记;失败与取消保留明显提示。`Ctrl+O` 展开脚本、子调用参数、原始输出与完整输出路径。该呈现增强需要 Pi 提供 `registerToolRenderer`(已核对 Pi 1.0.2);旧版本保留原生显示。它不改变 Code Mode 执行、权限、模型可见结果或默认启用设置。 OpenPI 采用 [MIT License](LICENSE);第三方来源与保留声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 diff --git a/extensions/codemode-display/render.ts b/extensions/codemode-display/render.ts index 1a1be131..e5cd60a5 100644 --- a/extensions/codemode-display/render.ts +++ b/extensions/codemode-display/render.ts @@ -12,17 +12,15 @@ import { } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; +import { toolActivityIcon } from "../shared/tool-activity.ts"; export type CodemodeRenderers = Pick< ToolDefinition, "renderShell" | "renderCall" | "renderResult" >; -const RECENT_CALLS = 4; -const SALIENT_CALLS = 2; -const OUTPUT_LINES = 5; +const BASH_HINT_COLUMNS = 80; const JSON_LIMIT = 32_768; -const PREVIEW_CHARS = 4_096; const HEADER = /^Script (completed|failed)\nWall time (\d+(?:\.\d+)?) seconds\nOutput:\n$/u; @@ -127,14 +125,27 @@ function framed( /** * The first top-level string argument (path, command, pattern…) identifies a - * call at a glance. Native previews are JSON truncated at 200 chars, so this - * reads only a complete leading string literal and otherwise shows nothing. + * call at a glance. Pi truncates JSON previews to 197 chars plus "...". + * Retain the verified string prefix without inventing the missing suffix; + * a cut through an escape sequence drops only that incomplete escape. */ function argHint(args: unknown) { - const match = /^\{"[^"\\]*":("(?:[^"\\]|\\.)*")/u.exec(string(args)); - if (!match) return ""; + const raw = string(args); + const complete = /^\{"[^"\\]*":("(?:[^"\\]|\\.)*")/u.exec(raw); try { - return inline(JSON.parse(match[1]!)).trim(); + if (complete) return inline(JSON.parse(complete[1]!)).trim(); + if (raw.length !== 200 || !raw.endsWith("...")) return ""; + const preview = raw.slice(0, -3); + const prefix = + /^\{"[^"\\]*":("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\da-f]{4}))*)/iu.exec( + preview, + ); + if (!prefix) return ""; + const remainder = preview.slice(prefix[0].length); + if (remainder && !/^\\(?:u[\da-f]{0,3})?$/iu.test(remainder)) return ""; + const decoded: string = JSON.parse(`${prefix[1]}"`); + // A truncated surrogate pair must not leave a broken terminal glyph. + return `${inline(decoded.replace(/[\ud800-\udbff]$/u, "")).trimEnd()}…`; } catch { return ""; } @@ -148,10 +159,14 @@ function callLine( ) { const status = callStatus(call); const name = expanded ? safe(call.name) : inline(call.name); + const toolIcon = expanded ? "✓" : toolActivityIcon(string(call.name)); + const label = + toolIcon === "✓" ? name : `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + const quiet = !expanded && status === "running"; const meta: string[] = []; if (string(call.error)) meta.push(expanded ? "error" : `error: ${inline(call.error)}`); - else if (status !== "ok") meta.push(status); + else if (status !== "ok" && !quiet) meta.push(status); const time = duration(call.durationMs); if (time) meta.push(time); if ( @@ -161,10 +176,18 @@ function callLine( ) meta.push(`$${call.cost.toPrecision(3)}`); const tail = meta.length ? ` · ${meta.join(" · ")}` : ""; - let line = `${theme.fg(ICON_COLOR[status], ICON[status])} ${theme.fg(status === "error" ? "error" : "toolTitle", name)}`; + const toolPrefix = toolIcon === "✓" ? "" : `${theme.fg("dim", toolIcon)} `; + const statusPrefix = quiet + ? " " + : `${theme.fg(ICON_COLOR[status], ICON[status])} `; + let line = `${statusPrefix}${toolPrefix}${theme.fg(status === "error" ? "error" : quiet ? "muted" : "toolTitle", label)}`; // The hint yields its width to status, error and timing facts. const hint = expanded ? "" : argHint(call.args); - const budget = width - visibleWidth(line) - visibleWidth(tail) - 1; + const available = width - visibleWidth(line) - visibleWidth(tail) - 1; + const budget = + string(call.name).toLowerCase() === "bash" + ? Math.min(available, BASH_HINT_COLUMNS) + : available; if (hint && budget >= 4) line += ` ${theme.fg("muted", truncateToWidth(hint, budget, "…"))}`; if (tail) line += theme.fg("dim", tail); @@ -212,7 +235,6 @@ function resultComponent( const output = (knownHeader ? result.content.slice(1) : result.content) .filter((item) => item.type === "text") .map((item) => item.text); - const projections = output.map(outputProjection); const images = result.content.filter((item) => item.type === "image").length; const failed = isError || knownHeader?.[1] === "failed"; const outcome = partial @@ -242,14 +264,19 @@ function resultComponent( finished: [ICON.unknown, "warning"], } as const; const [icon, iconColor] = outcomeIcon[outcome]; - const outcomeText = `${theme.fg(iconColor, outcome)}${theme.fg("muted", ` · ${facts.join(" · ")}`)}`; - const statusLine = `${theme.fg(iconColor, icon)} ${outcomeText}`; + const quiet = partial && !expanded; + const iconText = quiet ? "" : theme.fg(iconColor, icon); + const outcomeText = quiet + ? theme.fg("muted", facts.join(" · ")) + : `${theme.fg(iconColor, outcome)}${theme.fg("muted", ` · ${facts.join(" · ")}`)}`; + const statusLine = `${iconText ? `${iconText} ` : ""}${outcomeText}`; // A call header rendered from the same row state carries the status line. const merged = state?.codemodeHeader === true; if (state) { state.codemodeTone = tone; state.codemodeResult = true; - state.codemodeIcon = theme.fg(iconColor, icon); + state.codemodeIcon = iconText; + state.codemodePending = quiet; state.codemodeOutcome = outcomeText; } const outputColor = failed ? "error" : "toolOutput"; @@ -276,8 +303,8 @@ function resultComponent( ...wrapped("(no recorded nested calls)", theme, width, "muted"), ); rows.push(...wrapped("Output", theme, width, "muted")); - output.forEach((raw, index) => { - const projection = projections[index]!; + output.forEach((raw) => { + const projection = outputProjection(raw); if (projection.decoded) { rows.push( ...wrapped( @@ -316,67 +343,10 @@ function resultComponent( const compactRows = (width: number) => { const rows = merged ? [] : [statusLine]; const indent = width > 16 ? " " : ""; - const issue = (call: Record) => - ["error", "cancelled", "unknown"].includes(callStatus(call)); - const recentStart = Math.max(0, calls.length - RECENT_CALLS); - const salient = calls - .slice(0, recentStart) - .filter(issue) - .slice(-SALIENT_CALLS); - const recent = calls.slice(recentStart); - const hidden = calls.length - salient.length - recent.length; - // Nested outcomes are not the outer script's status. A total row keeps - // any issue that the bounded call list cannot show visible. - const shown = [...salient, ...recent]; - for (const status of ["error", "cancelled", "unknown"] as const) { - const count = counts[status]; - if (count > shown.filter((call) => callStatus(call) === status).length) - rows.push( - theme.fg( - ICON_COLOR[status], - `${ICON[status]} ${count} nested ${status}`, - ), - ); - } - const line = (call: Record) => - indent + callLine(call, false, theme, width - indent.length); - rows.push(...salient.map(line)); - if (hidden) - rows.push( - theme.fg( - "dim", - `${indent}⋯ ${hidden} more ${hidden === 1 ? "call" : "calls"}`, - ), - ); - rows.push(...recent.map(line)); - let more = ""; - if (partial) rows.push(theme.fg("muted", "Output pending")); - else if (output.length) { - const joined = projections - .map((projection) => projection.text) - .join("\n"); - // Blank lines spend the preview budget without showing anything. - const visible = joined - .slice(0, PREVIEW_CHARS) - .split("\n") - .filter((row) => row.trim()) - .join("\n"); - // A gutter separates script output from the call list above it. - const gutter = width > 16 ? theme.fg("dim", "│ ") : ""; - const lines = wrapped( - safe(visible), - theme, - width - visibleWidth(gutter), - outputColor, - ); - rows.push(...lines.slice(0, OUTPUT_LINES).map((row) => gutter + row)); - if (joined.length > PREVIEW_CHARS) more = "… more output · "; - else if (lines.length > OUTPUT_LINES) { - const count = lines.length - OUTPUT_LINES; - more = `… ${count} more ${count === 1 ? "line" : "lines"} · `; - } - } - rows.push(theme.fg("dim", more) + expandHint()); + for (const call of calls) + rows.push(indent + callLine(call, false, theme, width - indent.length)); + // Output is evidence for the expanded view, not part of the activity list. + rows.push(expandHint()); return rows.map((row) => truncateToWidth(row, width, "")); }; @@ -396,14 +366,15 @@ function resultComponent( "", ), ); - if (string(details?.fullOutputPath)) { - const path = `Full output: ${expanded ? safe(details?.fullOutputPath) : inline(details?.fullOutputPath)}`; + if (expanded && string(details?.fullOutputPath)) rows.push( - ...(expanded - ? wrapped(path, theme, width, "muted") - : [truncateToWidth(theme.fg("muted", path), width, "")]), + ...wrapped( + `Full output: ${safe(details?.fullOutputPath)}`, + theme, + width, + "muted", + ), ); - } return rows; }, { top: false, bottom: () => true }, @@ -425,11 +396,15 @@ export const codemodeRenderers: CodemodeRenderers = { ? "invalid script argument" : "receiving script"; if (state) state.codemodeHeader = true; - const name = theme.fg("toolTitle", theme.bold("codemode")); const title = () => { + const name = theme.fg( + state?.codemodePending ? "muted" : "toolTitle", + theme.bold("codemode"), + ); const outcome = string(state?.codemodeOutcome); + const icon = string(state?.codemodeIcon); return outcome - ? `${string(state?.codemodeIcon)} ${name} ${outcome}${theme.fg("muted", ` · ${detail}`)}` + ? `${icon ? `${icon} ` : ""}${name} ${outcome}${theme.fg("muted", ` · ${detail}`)}` : `${name}${theme.fg("muted", ` · ${detail}`)}`; }; return framed( diff --git a/extensions/shared/tool-activity.ts b/extensions/shared/tool-activity.ts index 06087a10..6214f540 100644 --- a/extensions/shared/tool-activity.ts +++ b/extensions/shared/tool-activity.ts @@ -208,7 +208,7 @@ function pendingVerb(name: string) { } } -function activityIcon(name: string) { +export function toolActivityIcon(name: string) { switch (canonicalName(name)) { case "read": return "\ueaa4"; @@ -297,7 +297,7 @@ export function toolActivityText( } if (duration) parts.push(theme.fg("dim", duration)); const detail = parts.join(theme.fg("dim", " · ")); - return `${theme.fg("dim", activityIcon(activity.name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`; + return `${theme.fg("dim", toolActivityIcon(activity.name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`; } export function renderToolActivityLine( diff --git a/tests/extensions/codemode-display/native.test.ts b/tests/extensions/codemode-display/native.test.ts index 7b9bc661..0ee2d9f4 100644 --- a/tests/extensions/codemode-display/native.test.ts +++ b/tests/extensions/codemode-display/native.test.ts @@ -143,6 +143,7 @@ test("native loader selects the public renderer seam while retaining native Code { type: "text", text: output }, ]; const details = { + fullOutputPath: "/tmp/codemode-output-evidence.txt", calls: [ { id: "1", @@ -159,20 +160,56 @@ test("native loader selects the public renderer seam while retaining native Code }, ...Array.from({ length: 6 }, (_, index) => ({ id: String(index + 3), - name: "read", - args: "{}", + name: index === 5 ? "bash" : "read", + args: + index === 5 + ? `${JSON.stringify({ command: `git status --short; ${"echo long; ".repeat(40)}` }).slice(0, 197)}...` + : "{}", status: "ok", durationMs: 2, })), ], }; + component.updateResult( + { + content, + details: { + ...details, + calls: details.calls.map((call) => ({ + ...call, + status: "running", + error: undefined, + })), + }, + isError: false, + }, + true, + ); + const pending = component + .render(300) + .map(stripTerminalSequences) + .join("\n"); + assert.doesNotMatch( + pending, + /^\s*…|codemode running| · running|more calls?/mu, + ); + assert.match(pending, /earlier-read example\.ts/u); + assert.match(pending, /earlier-bash long shell/u); + assert.match(pending, /Bash git status --short.*… · 2ms/u); component.updateResult({ content, details, isError: false }, false); const compact = component.render(80).map(stripTerminalSequences).join("\n"); - assert.match(compact, /codemode · 2 script lines/u); + assert.match( + compact, + /codemode completed · 1\.7s · 8 calls · 2 script lines/u, + ); assert.doesNotMatch(compact, /very long shell command/u); - assert.match(compact, /✗ earlier-read · error: file missing/u); - assert.match(compact, /⊘ earlier-bash · cancelled/u); - assert.doesNotMatch(compact, /\\n/u); + assert.match(compact, /✗ earlier-read example\.ts · error: file missing/u); + assert.match(compact, /⊘ earlier-bash long shell · cancelled/u); + assert.match(compact, /Bash git status --short.*… · 2ms/u); + assert.doesNotMatch( + compact, + /\\n|first output line|Full output:|codemode-output-evidence/u, + ); const artifacts = process.env.OPENPI_CODEMODE_RENDER_ARTIFACT_DIR; if (artifacts) await mkdir(artifacts, { recursive: true }); for (const width of [80, 40]) { @@ -192,6 +229,10 @@ test("native loader selects the public renderer seam while retaining native Code assert.match(expanded, /Calls/u); assert.match(expanded, /Output/u); assert.match(expanded, /Raw output/u); + assert.match(expanded, /first output line/u); + assert.match(expanded, /Full output:/u); + assert.doesNotMatch(collapsed, /first output line|Full output:/u); + assert.match(collapsed, /Bash git status/u); if (artifacts) { await writeFile( path.join(artifacts, `compact-${width}.txt`), diff --git a/tests/extensions/codemode-display/render.test.ts b/tests/extensions/codemode-display/render.test.ts index 5c9fea6a..9636d710 100644 --- a/tests/extensions/codemode-display/render.test.ts +++ b/tests/extensions/codemode-display/render.test.ts @@ -124,13 +124,17 @@ test("all nested statuses include earlier failures and cancellations despite out assert.match(text, /✓ completed · 2\.3s · 15 calls/u); assert.match(text, /✗ early-read · error: permission denied/u); assert.match(text, /⊘ early-cancel · cancelled/u); - assert.match(text, /… pending · running/u); - assert.match(text, /⋯ 9 more calls/u); + assert.match(text, /^ +pending$/mu); + assert.doesNotMatch(text, /… pending|pending · running/u); + assert.doesNotMatch(text, /more calls?/u); + for (let index = 0; index < 12; index++) + assert.match(text, new RegExp(`read-${index} · 3ms`, "u")); assert.match(text, /ctrl\+o.*to expand/iu); - assert.doesNotMatch(text, /PRIVATE ARGS|read-0/u); - assert.ok( - rows.length <= 15, - "collapsed height does not grow with the ledger", + assert.doesNotMatch(text, /PRIVATE ARGS/u); + assert.equal( + rows.length, + calls.length + 3, + "each call gets exactly one compact row", ); const full = rendered(result("done", calls), { expanded: true }).join("\n"); assert.match(full, /Calls/u); @@ -148,8 +152,9 @@ test("partial updates, outer errors and unknown historical evidence do not claim result("not-final", [{ name: "read", status: "running" }]), { partial: true }, ).join("\n"); - assert.match(partial, /… running · 1 call/u); - assert.match(partial, /Output pending/u); + assert.match(partial, /1 call/u); + assert.doesNotMatch(partial, /…|running/u); + assert.doesNotMatch(partial, /Output pending/u); assert.doesNotMatch(partial, /not-final/u); assert.match( rendered(result("failed"), { error: true }).join("\n"), @@ -178,7 +183,7 @@ test("partial updates, outer errors and unknown historical evidence do not claim test("outer JSON string becomes readable text with raw evidence retained on expansion", () => { const raw = JSON.stringify("first line\nsecond line\nthird line"); const compact = rendered(result(raw)).join("\n"); - assert.match(compact, /│ first line\n│ second line\n│ third line/u); + assert.doesNotMatch(compact, /first line|second line|third line/u); assert.doesNotMatch(compact, /\\n/u); const expanded = rendered(result(raw), { expanded: true }).join("\n"); assert.match(expanded, /Display projection \(outer JSON decoded\)/u); @@ -186,6 +191,41 @@ test("outer JSON string becomes readable text with raw evidence retained on expa assert.ok(expanded.includes(raw)); }); +test("compact hides all output and spill paths without changing expanded evidence or results", () => { + const output = + "Warning: truncated output\nPRIVATE_OUTPUT\n" + "x".repeat(8_000); + const calls = [ + { name: "bash", args: '{"command":"echo hello"}', status: "ok" }, + { + name: "read", + args: '{"path":"missing.ts"}', + status: "error", + error: "denied", + }, + { name: "edit", status: "cancelled" }, + { name: "rg", status: "unknown" }, + ]; + const value = result(output, calls); + value.details = { calls, fullOutputPath: "/tmp/private-output-evidence.txt" }; + const before = JSON.stringify(value); + for (const options of [{}, { partial: true }, { error: true }]) { + const compact = rendered(value, options).join("\n"); + assert.doesNotMatch( + compact, + /PRIVATE_OUTPUT|Warning:|Full output:|private-output-evidence/u, + ); + assert.match(compact, /✓ \uea85 Bash echo hello/u); + assert.match(compact, /✗ \ueaa4 Read missing\.ts · error: denied/u); + assert.match(compact, /⊘ \uea73 Edit · cancelled/u); + assert.match(compact, /\? \uea6d Rg · unknown/u); + assert.match(compact, /ctrl\+o.*to expand/iu); + } + const expanded = rendered(value, { expanded: true, width: 200 }).join("\n"); + assert.match(expanded, /Warning: truncated output\nPRIVATE_OUTPUT/u); + assert.match(expanded, /Full output: \/tmp\/private-output-evidence\.txt/u); + assert.equal(JSON.stringify(value), before); +}); + test("structured JSON remains structured and nested JSON-looking strings are not interpreted", () => { const raw = JSON.stringify({ content: [{ text: "literal\\ntext" }], @@ -288,7 +328,7 @@ test("expanded mode retains every call, Script/Calls/Output sections and spilled assert.match(text, /Full output: \/tmp\/full-evidence.txt/u); }); -test("early cancellation stays identifiable at widths 1, 4 and 8 even when salient rows select later errors", () => { +test("all cancellation and error rows remain visible at widths 1, 4 and 8", () => { const calls = [ { name: "first cancelled", status: "cancelled" }, { name: "later error", status: "error" }, @@ -367,14 +407,132 @@ test("compact call rows name their first string argument within the row budget", ]; const rows = rendered(result("done", calls), { width: 60 }); const text = rows.join("\n"); - assert.match(text, /✓ read package\.json · 6ms/u); - assert.match(text, /✓ bash x+… · 16ms/u); - assert.match(text, /^ {2}✓ bash$/mu, "truncated JSON yields no guessed hint"); + assert.match(text, /✓ \ueaa4 Read package\.json · 6ms/u); + assert.match(text, /✓ \uea85 Bash x+… · 16ms/u); + assert.match( + text, + /^ {2}✓ \uea85 Bash$/mu, + "truncated JSON yields no guessed hint", + ); assert.match(text, /✗ web cats · error: boom/u); assert.ok(rows.every((row) => visibleWidth(row) <= 60)); }); -test("a shared row puts the status in the call header and skips blank preview lines", () => { +test("compact shows every call once and in order without a more-calls placeholder", () => { + for (const count of [5, 20, 100]) { + const calls = Array.from({ length: count }, (_, index) => ({ + name: `call-${index}`, + status: index % 2 ? "running" : "ok", + })); + const rows = rendered(result("HIDDEN_OUTPUT", calls)); + const displayed = rows.filter((row) => /call-\d+/u.test(row)); + assert.equal(displayed.length, count); + displayed.forEach((row, index) => { + assert.match(row, new RegExp(`\\bcall-${index}$`, "u")); + }); + assert.doesNotMatch(rows.join("\n"), /more calls?|HIDDEN_OUTPUT/u); + } +}); + +test("compact pending rows use muted text without running labels or status dots", () => { + const muted = { + ...theme, + fg: (color: string, text: string) => `<${color}>${text}`, + } as Theme; + const shared = context(); + const header = codemodeRenderers.renderCall!({ code }, muted, shared); + const body = codemodeRenderers.renderResult!( + result("hidden", [ + { name: "bash", args: '{"command":"echo hello"}', status: "running" }, + ]), + { expanded: false, isPartial: true }, + muted, + shared, + ); + const text = [...header.render(300), ...body.render(300)].join("\n"); + assert.match(text, /codemode<\/muted>/u); + assert.match(text, /Bash<\/muted>/u); + assert.match(text, /echo hello/u); + assert.doesNotMatch(text, /…|running|hidden/u); + const expanded = rendered( + result("pending", [{ name: "bash", status: "running" }]), + { expanded: true, partial: true }, + ).join("\n"); + assert.match(expanded, /running/u); +}); + +test("Bash command previews stop at 80 columns even in wide terminals", () => { + for (const command of [ + `printf ${"x".repeat(120)} HIDDEN_SUFFIX`, + `printf ${"界".repeat(80)} HIDDEN_SUFFIX`, + ]) { + const args = JSON.stringify({ command }); + for (const status of ["ok", "running", "error", "cancelled", "unknown"]) { + const value = result("hidden", [ + { name: "bash", args, status, durationMs: 102 }, + ]); + for (const width of [60, 120, 300]) { + const rows = rendered(value, { width }); + const row = rows.find((row) => row.includes("Bash printf")); + assert.ok(row, `command preview remains visible at ${width}`); + const preview = row.split("Bash ")[1]!.split(" · ")[0]!; + assert.ok(visibleWidth(preview) <= 80, `preview width at ${width}`); + assert.match(preview, /…$/u); + assert.doesNotMatch(row, /HIDDEN_SUFFIX/u); + assert.ok(rows.every((row) => visibleWidth(row) <= width)); + } + assert.ok( + rendered(value, { expanded: true, width: 300 }) + .join("\n") + .includes(args), + ); + } + } +}); + +test("native 200-character previews keep long Bash commands and Read paths visible", () => { + const command = `git status --short; echo ${"long-command ".repeat(40)}`; + const path = `/workspace/${"long-directory/".repeat(30)}README.md`; + const preview = (args: unknown) => `${JSON.stringify(args).slice(0, 197)}...`; + const value = result("HIDDEN_OUTPUT", [ + { name: "bash", status: "ok", args: preview({ command }), durationMs: 102 }, + { name: "read", status: "ok", args: preview({ path }), durationMs: 19 }, + ]); + const before = JSON.stringify(value); + for (const width of [60, 120, 300]) { + const rows = rendered(value, { width }); + const text = rows.join("\n"); + assert.match( + text, + /Bash git status --short; echo long-command.*… · 102ms/u, + ); + assert.match(text, /Read \/workspace\/long-directory\/.*… · 19ms/u); + assert.doesNotMatch(text, /HIDDEN_OUTPUT/u); + assert.ok(rows.every((row) => visibleWidth(row) <= width)); + } + assert.equal(JSON.stringify(value), before); + const expanded = rendered(value, { expanded: true, width: 300 }).join("\n"); + assert.ok(expanded.includes(preview({ command }))); +}); + +test("native preview cuts through escapes and Unicode retain a safe command prefix", () => { + for (const suffix of ['"', "\\", "\n", "\t", "\u0001", "🧑‍💻", "你好"]) { + for (let fill = 175; fill <= 187; fill++) { + const command = `printf ${"x".repeat(fill)}${suffix}${"tail".repeat(30)}`; + const raw = `${JSON.stringify({ command }).slice(0, 197)}...`; + const value = result("hidden", [ + { name: "bash", status: "ok", args: raw, durationMs: 102 }, + ]); + const rows = rendered(value, { width: 300 }); + const compact = rows.join("\n"); + assert.match(compact, /Bash printf x+.*… · 102ms/u); + assert.doesNotMatch(compact, /[\u0001\u001b\ud800-\udfff]/u); + assert.ok(rows.every((row) => visibleWidth(row) <= 300)); + } + } +}); + +test("a shared row puts the status in the call header and hides output", () => { const state = {}; const shared = { ...context(), state }; const header = codemodeRenderers.renderCall!({ code }, theme, shared); @@ -392,5 +550,5 @@ test("a shared row puts the status in the call header and skips blank preview li "✓ codemode completed · 2.3s · 1 call · 2 script lines", ]); assert.doesNotMatch(body.join("\n"), /completed/u); - assert.match(body.join("\n"), /│ first\n│ second/u); + assert.doesNotMatch(body.join("\n"), /first|second|│/u); });