From 963864e13b137d43523b09f1e50368325303e57e Mon Sep 17 00:00:00 2001 From: Matt <2986108228@qq.com> Date: Sun, 30 Aug 2026 03:16:30 +0800 Subject: [PATCH 1/6] fix(plan-mode): render plan_ready output as Markdown plan_ready registered no renderResult, so the TUI fell back to its plain-text renderer and the recorded plan appeared as raw Markdown source. Render it with the Markdown component, the same treatment subagent results already get. Validated with bun run check (format, lint, tsc) and bun run test (959 node:test cases and 30 vitest cases pass) on Linux. --- extensions/plan-mode/index.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index 47917282..6420ccaa 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -26,11 +26,13 @@ * may predate the plan and still hold the full tool set. */ -import type { - ExtensionAPI, - ExtensionCommandContext, - ExtensionContext, +import { + getMarkdownTheme, + type ExtensionAPI, + type ExtensionCommandContext, + type ExtensionContext, } from "@earendil-works/pi-coding-agent"; +import { Markdown, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { PLAN_MODE_CHANNEL, @@ -450,6 +452,15 @@ export default function planMode(pi: ExtensionAPI) { terminate: true, }; }, + renderResult(result, _options, theme) { + const plan = (result.details as { plan?: string } | undefined)?.plan; + if (!plan) { + return new Text(theme.fg("muted", "(no plan content)"), 0, 0); + } + // A plan is prose to read, not source to inspect. Without a renderer the + // TUI falls back to plain text and shows raw Markdown syntax. + return new Markdown(plan, 0, 0, getMarkdownTheme()); + }, }); pi.registerCommand("plan", { From 8a2fc7b4225d7f93615cb741368ddb1716ec4b4c Mon Sep 17 00:00:00 2001 From: Matt <2986108228@qq.com> Date: Sun, 30 Aug 2026 04:25:01 +0800 Subject: [PATCH 2/6] test(plan-mode): cover plan_ready Markdown rendering Nine cases pin that the renderer is registered and that a realistic plan survives it: headings, ordered and nested lists, block quotes, tables, inline code and bold, links, three fenced code shapes, a thematic break, a 40K plan, and unicode. Assertions strip ANSI first, because the Markdown component colors itself from the global theme rather than the theme passed to renderResult. Verified red against the pre-fix code (all nine fail with "plan_ready must supply renderResult"), so these catch the regression rather than just documenting it. --- .../plan-mode/result-rendering.test.ts | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 tests/extensions/plan-mode/result-rendering.test.ts diff --git a/tests/extensions/plan-mode/result-rendering.test.ts b/tests/extensions/plan-mode/result-rendering.test.ts new file mode 100644 index 00000000..f7338142 --- /dev/null +++ b/tests/extensions/plan-mode/result-rendering.test.ts @@ -0,0 +1,253 @@ +/** + * plan_ready renders the recorded plan as Markdown. + * + * These tests pin two things: the renderer is wired at all (the regression + * that motivated it was a missing `renderResult`, which silently fell back to + * plain text), and a realistic plan survives rendering. + * + * Note on styling: `Markdown` colors itself from the global theme via + * `getMarkdownTheme()`, not from the `theme` argument passed to `renderResult`. + * That argument is only used for the fallback placeholder here. So rendered + * output carries ANSI codes whenever the global theme is initialized, and + * these assertions run against ANSI-stripped text. + * + * Assertions stay structural — heading markers consumed, content preserved, + * nothing throws — rather than pinning pi-tui's exact visual treatment. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { initTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +initTheme("dark", false); + +/** Matches the SGR sequences the Markdown component emits. */ +const ANSI = /\u001b\[[0-9;]*m/g; + +function stripAnsi(text: string): string { + return text.replace(ANSI, ""); +} + +const THEME = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + underline: (text: string) => text, + strikethrough: (text: string) => text, + inverse: (text: string) => text, +}; + +interface RenderedComponent { + render(width: number): string[]; +} + +interface PlanTool { + name: string; + renderResult?: ( + result: { details?: { plan?: string } | null }, + options: { expanded?: boolean }, + theme: unknown, + ) => RenderedComponent; +} + +/** Wide enough that line wrapping does not interfere with assertions. */ +const WIDTH = 200; + +async function loadPlanReady(): Promise { + const { default: planMode } = await import( + "../../../extensions/plan-mode/index.ts" + ); + const tools = new Map(); + const pi = { + on() {}, + registerTool(definition: PlanTool) { + tools.set(definition.name, definition); + }, + registerCommand() {}, + appendEntry() {}, + sendMessage() {}, + } as unknown as ExtensionAPI; + planMode(pi); + + const tool = tools.get("plan_ready"); + assert.ok(tool, "plan_ready must be registered"); + assert.ok(tool.renderResult, "plan_ready must supply renderResult"); + return tool; +} + +function renderPlan( + tool: PlanTool, + details: { plan?: string } | null | undefined, + expanded = false, +): string { + const component = tool.renderResult?.({ details }, { expanded }, THEME); + assert.ok(component, "renderResult must return a component"); + return stripAnsi(component.render(WIDTH).join("\n")); +} + +const RICH_PLAN = [ + "# Migration plan", + "", + "## Goal", + "Move the queue off Redis.", + "", + "### Steps", + "1. Snapshot the queue", + "2. Drain consumers", + " - verify depth is zero", + " - stop the workers", + "3. Cut over", + "", + "> Do not run steps 2 and 3 in the same window.", + "", + "| Phase | Owner |", + "| --- | --- |", + "| snapshot | platform |", + "| cutover | platform |", + "", + "Use `queuectl drain` first, then **stop** the workers.", + "", + "See [the runbook](https://example.com/runbook) for detail.", + "", + "```bash", + "queuectl drain --timeout 30s", + "```", + "", + "```", + "plain fence with no language", + "```", + "", + "```python", + "def check(depth):", + " return depth == 0", + "```", + "", + "---", + "", + "Inline `code` and a trailing [link](https://example.com).", +].join("\n"); + +test("plan_ready is registered with a renderer", async () => { + const tool = await loadPlanReady(); + assert.equal(tool.name, "plan_ready"); + assert.equal(typeof tool.renderResult, "function"); +}); + +test("headings lose their markers and keep their text", async () => { + const tool = await loadPlanReady(); + const out = renderPlan(tool, { + plan: "# Migration plan\n\n## Goal\n\n### Steps\n", + }); + + assert.doesNotMatch(out, /^#\s/m, "no bare ATX heading markers"); + assert.match(out, /Migration plan/); + assert.match(out, /Goal/); + assert.match(out, /Steps/); +}); + +test("a rich plan renders without throwing and keeps its content", async () => { + const tool = await loadPlanReady(); + const out = renderPlan(tool, { plan: RICH_PLAN }); + + // Headings and body. + assert.match(out, /Migration plan/); + assert.match(out, /Move the queue off Redis\./); + + // Ordered list plus a nested unordered sub-list. + assert.match(out, /1\. Snapshot the queue/); + assert.match(out, /2\. Drain consumers/); + assert.match(out, /- verify depth is zero/); + + // Block quote: rendered with a gutter, text preserved. + assert.match(out, /Do not run steps 2 and 3 in the same window\./); + + // Table: drawn with box characters, not echoed as pipes. + assert.match(out, /┌/, "table has a top border"); + assert.match(out, /Phase/); + assert.match(out, /platform/); + assert.doesNotMatch(out, /\|\s*---\s*\|/, "delimiter row is consumed"); + + // Inline code and bold: markers consumed, text kept. + assert.match(out, /Use queuectl drain first, then stop the workers\./); + assert.doesNotMatch(out, /\*\*stop\*\*/, "bold markers are consumed"); + + // Link: label kept, URL surfaced. + assert.match(out, /the runbook/); + assert.match(out, /https:\/\/example\.com\/runbook/); + + // All three fences, with and without a language. + assert.match(out, /queuectl drain --timeout 30s/); + assert.match(out, /plain fence with no language/); + assert.match(out, /def check\(depth\):/); + assert.match(out, /return depth == 0/); + + // Thematic break becomes a rule, not a literal ---. + assert.match(out, /─{10,}/, "thematic break is drawn as a rule"); + + assert.match(out, /Inline code and a trailing link/); +}); + +test("fenced code body is indented while the fence stays visible", async () => { + const tool = await loadPlanReady(); + const out = renderPlan(tool, { plan: "```\nhello\n```\n" }); + + // pi-tui colors the fence markers but does not draw a background block, so + // the backticks remain; the body is what gets indented. + assert.match(out, /```/); + // Lines are padded to the render width, so allow trailing whitespace. + assert.match(out, /^ {2}hello\s*$/m, "code body indented two spaces"); +}); + +test("a missing or empty plan falls back to a placeholder", async () => { + const tool = await loadPlanReady(); + + assert.match(renderPlan(tool, undefined), /\(no plan content\)/); + assert.match(renderPlan(tool, null), /\(no plan content\)/); + assert.match(renderPlan(tool, { plan: "" }), /\(no plan content\)/); +}); + +test("plain prose with no markup renders as itself", async () => { + const tool = await loadPlanReady(); + const out = renderPlan(tool, { plan: "Just a sentence.\nAnd another.\n" }); + + assert.match(out, /Just a sentence\./); + assert.match(out, /And another\./); +}); + +test("a plan near the documented size cap still renders", async () => { + const tool = await loadPlanReady(); + // MAX_READY_PLAN_CHARS is 50_000; stay under it but well past one screen. + const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}`; + assert.ok(plan.length > 40_000, "fixture is meaningfully large"); + + const out = renderPlan(tool, { plan }); + assert.match(out, /Long/); + assert.match(out, /item line to pad the plan out/); +}); + +test("unusual characters do not break the renderer", async () => { + const tool = await loadPlanReady(); + // execute() sanitizes before storing, so this is not a path the renderer + // normally sees. It should degrade, not throw. + const out = renderPlan(tool, { + plan: "# Ünïcode ✓\n\n- 日本語のテキスト\n\n```\nemoji 🎉 here\n```\n", + }); + + assert.match(out, /Ünïcode/); + assert.match(out, /日本語のテキスト/); + assert.match(out, /emoji 🎉 here/); +}); + +test("expanded and collapsed both render the plan body", async () => { + const tool = await loadPlanReady(); + const plan = "# Plan\n\n- one\n- two\n"; + const collapsed = renderPlan(tool, { plan }, false); + const expanded = renderPlan(tool, { plan }, true); + + // The renderer is not expansion-gated: the plan is the whole point, and Pi + // owns the collapse affordance. + assert.match(collapsed, /one/); + assert.match(expanded, /one/); + assert.equal(collapsed, expanded, "expansion does not change the output"); +}); From 80a6e2c04864bfee7f46d0fa449904cdf0b7c453 Mon Sep 17 00:00:00 2001 From: Matt <2986108228@qq.com> Date: Sun, 30 Aug 2026 16:30:39 +0800 Subject: [PATCH 3/6] fix(plan-mode): bound collapsed plan_ready preview Collapsed state was identical to expanded: the custom renderResult ignored the expanded flag, so a plan near the 48KiB cap flooded the transcript and Ctrl+O produced no visible change. Pi never truncates custom renderer output; the renderer owns the collapsed/expanded contract (same pattern as git-read and renderWaitResult). Collapsed now renders one bounded line with the line count and an expand hint via keyHint; expanded renders the full Markdown as before. Replaces the old case that pinned collapsed == expanded with two cases: collapsed stay bounded and expanded restores the full plan. --- extensions/plan-mode/index.ts | 16 ++++- .../plan-mode/result-rendering.test.ts | 68 +++++++++++++------ 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index 6420ccaa..64397c12 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -28,6 +28,7 @@ import { getMarkdownTheme, + keyHint, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, @@ -452,11 +453,24 @@ export default function planMode(pi: ExtensionAPI) { terminate: true, }; }, - renderResult(result, _options, theme) { + renderResult(result, { expanded }, theme) { const plan = (result.details as { plan?: string } | undefined)?.plan; if (!plan) { return new Text(theme.fg("muted", "(no plan content)"), 0, 0); } + if (!expanded) { + // Collapsed is one bounded line, so a long plan does not flood the + // transcript and Ctrl+O produces a visible change. Pi never truncates + // custom renderer output; the renderer owns this contract. + return new Text( + theme.fg( + "muted", + `Plan ready · ${plan.split("\n").length} lines · `, + ) + keyHint("app.tools.expand", "to expand"), + 0, + 0, + ); + } // A plan is prose to read, not source to inspect. Without a renderer the // TUI falls back to plain text and shows raw Markdown syntax. return new Markdown(plan, 0, 0, getMarkdownTheme()); diff --git a/tests/extensions/plan-mode/result-rendering.test.ts b/tests/extensions/plan-mode/result-rendering.test.ts index f7338142..512e1fe1 100644 --- a/tests/extensions/plan-mode/result-rendering.test.ts +++ b/tests/extensions/plan-mode/result-rendering.test.ts @@ -136,9 +136,13 @@ test("plan_ready is registered with a renderer", async () => { test("headings lose their markers and keep their text", async () => { const tool = await loadPlanReady(); - const out = renderPlan(tool, { - plan: "# Migration plan\n\n## Goal\n\n### Steps\n", - }); + const out = renderPlan( + tool, + { + plan: "# Migration plan\n\n## Goal\n\n### Steps\n", + }, + true, + ); assert.doesNotMatch(out, /^#\s/m, "no bare ATX heading markers"); assert.match(out, /Migration plan/); @@ -148,7 +152,7 @@ test("headings lose their markers and keep their text", async () => { test("a rich plan renders without throwing and keeps its content", async () => { const tool = await loadPlanReady(); - const out = renderPlan(tool, { plan: RICH_PLAN }); + const out = renderPlan(tool, { plan: RICH_PLAN }, true); // Headings and body. assert.match(out, /Migration plan/); @@ -190,7 +194,7 @@ test("a rich plan renders without throwing and keeps its content", async () => { test("fenced code body is indented while the fence stays visible", async () => { const tool = await loadPlanReady(); - const out = renderPlan(tool, { plan: "```\nhello\n```\n" }); + const out = renderPlan(tool, { plan: "```\nhello\n```\n" }, true); // pi-tui colors the fence markers but does not draw a background block, so // the backticks remain; the body is what gets indented. @@ -209,7 +213,11 @@ test("a missing or empty plan falls back to a placeholder", async () => { test("plain prose with no markup renders as itself", async () => { const tool = await loadPlanReady(); - const out = renderPlan(tool, { plan: "Just a sentence.\nAnd another.\n" }); + const out = renderPlan( + tool, + { plan: "Just a sentence.\nAnd another.\n" }, + true, + ); assert.match(out, /Just a sentence\./); assert.match(out, /And another\./); @@ -221,7 +229,7 @@ test("a plan near the documented size cap still renders", async () => { const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}`; assert.ok(plan.length > 40_000, "fixture is meaningfully large"); - const out = renderPlan(tool, { plan }); + const out = renderPlan(tool, { plan }, true); assert.match(out, /Long/); assert.match(out, /item line to pad the plan out/); }); @@ -230,24 +238,44 @@ test("unusual characters do not break the renderer", async () => { const tool = await loadPlanReady(); // execute() sanitizes before storing, so this is not a path the renderer // normally sees. It should degrade, not throw. - const out = renderPlan(tool, { - plan: "# Ünïcode ✓\n\n- 日本語のテキスト\n\n```\nemoji 🎉 here\n```\n", - }); + const out = renderPlan( + tool, + { + plan: "# Ünïcode ✓\n\n- 日本語のテキスト\n\n```\nemoji 🎉 here\n```\n", + }, + true, + ); assert.match(out, /Ünïcode/); assert.match(out, /日本語のテキスト/); assert.match(out, /emoji 🎉 here/); }); -test("expanded and collapsed both render the plan body", async () => { +test("collapsed preview is a single bounded line", async () => { + const tool = await loadPlanReady(); + const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}`; + const out = renderPlan(tool, { plan }, false); + + // One summary line with the row count and an expand hint; never the body. + assert.match(out, /^Plan ready · \d+ lines · /); + assert.match(out, /to expand/); + assert.doesNotMatch( + out, + /item line to pad the plan out/, + "body stays hidden", + ); +}); + +test("expanded restores the full plan", async () => { const tool = await loadPlanReady(); - const plan = "# Plan\n\n- one\n- two\n"; - const collapsed = renderPlan(tool, { plan }, false); - const expanded = renderPlan(tool, { plan }, true); - - // The renderer is not expansion-gated: the plan is the whole point, and Pi - // owns the collapse affordance. - assert.match(collapsed, /one/); - assert.match(expanded, /one/); - assert.equal(collapsed, expanded, "expansion does not change the output"); + const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}`; + const out = renderPlan(tool, { plan }, true); + + assert.match(out, /Long/); + assert.match(out, /item line to pad the plan out/, "last body line present"); + assert.doesNotMatch( + out, + /^Plan ready · /, + "no collapsed summary when expanded", + ); }); From 459d507bf490581d33f56f14ddaae409aa817760 Mon Sep 17 00:00:00 2001 From: Matt <2986108228@qq.com> Date: Sun, 30 Aug 2026 19:19:31 +0800 Subject: [PATCH 4/6] docs(plan-mode): document renderResult and rendering test helpers CodeRabbit docstring coverage scoped to diff-touched functions requires 80%. Adds JSDoc to plan_ready renderResult (index.ts) and the stripAnsi, loadPlanReady and renderPlan helpers (result-rendering.test.ts). --- extensions/plan-mode/index.ts | 7 +++++++ tests/extensions/plan-mode/result-rendering.test.ts | 3 +++ 2 files changed, 10 insertions(+) diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index 64397c12..b6f00541 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -453,6 +453,13 @@ export default function planMode(pi: ExtensionAPI) { terminate: true, }; }, + /** + * Render a completed plan for the TUI. Collapsed shows one bounded + * summary line with a line count and an expand hint; expanded renders + * the full plan as Markdown. Pi only reflects the expanded flag — it + * never truncates custom renderer output, so this renderer owns the + * collapsed/expanded contract. + */ renderResult(result, { expanded }, theme) { const plan = (result.details as { plan?: string } | undefined)?.plan; if (!plan) { diff --git a/tests/extensions/plan-mode/result-rendering.test.ts b/tests/extensions/plan-mode/result-rendering.test.ts index 512e1fe1..fc8cef2f 100644 --- a/tests/extensions/plan-mode/result-rendering.test.ts +++ b/tests/extensions/plan-mode/result-rendering.test.ts @@ -24,6 +24,7 @@ initTheme("dark", false); /** Matches the SGR sequences the Markdown component emits. */ const ANSI = /\u001b\[[0-9;]*m/g; +/** Strip SGR escape sequences the Markdown component emits. */ function stripAnsi(text: string): string { return text.replace(ANSI, ""); } @@ -54,6 +55,7 @@ interface PlanTool { /** Wide enough that line wrapping does not interfere with assertions. */ const WIDTH = 200; +/** Load plan-mode under a stub pi API and return the registered plan_ready tool. */ async function loadPlanReady(): Promise { const { default: planMode } = await import( "../../../extensions/plan-mode/index.ts" @@ -76,6 +78,7 @@ async function loadPlanReady(): Promise { return tool; } +/** Render a plan through renderResult, ANSI-stripped, at the test width. */ function renderPlan( tool: PlanTool, details: { plan?: string } | null | undefined, From c39f119373ad1a44402a46418d46144a030559c4 Mon Sep 17 00:00:00 2001 From: Matt <2986108228@qq.com> Date: Mon, 31 Aug 2026 16:11:52 +0800 Subject: [PATCH 5/6] fix(plan-mode): surface a bounded preview in collapsed plan_ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare line count made the collapsed result impossible to scan in a long session. Collapsed now shows the header (Plan ready · N lines · expand hint) followed by the first PLAN_PREVIEW_LINES (10) plan lines and a ... (N more lines) tail; the expand hint and tail disappear when the plan fits the preview, matching bash/fallback and git-read conventions. Tests: long-plan preview bounded with tail hidden, short plan without hint, count boundaries at the cap (1/10/11 lines), trailing newline semantics, narrow-width wrapping of unbroken lines, deterministic output, and the expanded tail sentinel restore. Red-verified: disabling the expanded gate fails the five collapsed cases. --- extensions/plan-mode/index.ts | 36 ++++--- .../plan-mode/result-rendering.test.ts | 99 +++++++++++++++++-- 2 files changed, 110 insertions(+), 25 deletions(-) diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index b6f00541..af084d80 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -49,6 +49,8 @@ import { planBashDecision } from "./bash-policy.ts"; export const MAX_READY_PLAN_CHARS = 50_000; export const MAX_READY_PLAN_UTF8_BYTES = 48_000; +/** Preview lines shown in the collapsed plan_ready result. */ +const PLAN_PREVIEW_LINES = 10; export const PLAN_MODE_STATE_ENTRY = "my-pi-setup-plan-mode-state"; export type PersistedPlanModeState = @@ -454,11 +456,11 @@ export default function planMode(pi: ExtensionAPI) { }; }, /** - * Render a completed plan for the TUI. Collapsed shows one bounded - * summary line with a line count and an expand hint; expanded renders - * the full plan as Markdown. Pi only reflects the expanded flag — it - * never truncates custom renderer output, so this renderer owns the - * collapsed/expanded contract. + * Render a completed plan for the TUI. Collapsed shows the line count + * plus a bounded preview (PLAN_PREVIEW_LINES) with an expand hint; + * expanded renders the full plan as Markdown. Pi only reflects the + * expanded flag — it never truncates custom renderer output, so this + * renderer owns the collapsed/expanded contract. */ renderResult(result, { expanded }, theme) { const plan = (result.details as { plan?: string } | undefined)?.plan; @@ -466,17 +468,19 @@ export default function planMode(pi: ExtensionAPI) { return new Text(theme.fg("muted", "(no plan content)"), 0, 0); } if (!expanded) { - // Collapsed is one bounded line, so a long plan does not flood the - // transcript and Ctrl+O produces a visible change. Pi never truncates - // custom renderer output; the renderer owns this contract. - return new Text( - theme.fg( - "muted", - `Plan ready · ${plan.split("\n").length} lines · `, - ) + keyHint("app.tools.expand", "to expand"), - 0, - 0, - ); + const lines = plan.split("\n"); + let text = theme.fg("muted", `Plan ready · ${lines.length} lines`); + if (lines.length > PLAN_PREVIEW_LINES) { + text += + theme.fg("muted", " · ") + keyHint("app.tools.expand", "to expand"); + } + for (const line of lines.slice(0, PLAN_PREVIEW_LINES)) { + text += `\n${theme.fg("toolOutput", line)}`; + } + if (lines.length > PLAN_PREVIEW_LINES) { + text += `\n${theme.fg("muted", `... (${lines.length - PLAN_PREVIEW_LINES} more lines)`)}`; + } + return new Text(text, 0, 0); } // A plan is prose to read, not source to inspect. Without a renderer the // TUI falls back to plain text and shows raw Markdown syntax. diff --git a/tests/extensions/plan-mode/result-rendering.test.ts b/tests/extensions/plan-mode/result-rendering.test.ts index fc8cef2f..c2b1675f 100644 --- a/tests/extensions/plan-mode/result-rendering.test.ts +++ b/tests/extensions/plan-mode/result-rendering.test.ts @@ -254,28 +254,109 @@ test("unusual characters do not break the renderer", async () => { assert.match(out, /emoji 🎉 here/); }); -test("collapsed preview is a single bounded line", async () => { +test("collapsed shows a bounded preview of a long plan", async () => { const tool = await loadPlanReady(); - const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}`; + const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}Final sentinel line`; const out = renderPlan(tool, { plan }, false); - // One summary line with the row count and an expand hint; never the body. + // Header line carries line count and expand hint. assert.match(out, /^Plan ready · \d+ lines · /); assert.match(out, /to expand/); - assert.doesNotMatch( - out, - /item line to pad the plan out/, - "body stays hidden", + // Preview exposes the first content lines ... + assert.match(out, /Long/); + assert.match(out, /item line to pad the plan out/); + // ... the block is bounded: header + 10 preview rows + '... more' row. + const rowCount = out.split("\n").length; + assert.ok(rowCount <= 13, `bounded rows, got ${rowCount}`); + // ... and content past the cap stays hidden. + assert.doesNotMatch(out, /Final sentinel line/, "tail stays hidden"); +}); + +test("collapsed short plan omits the expand hint", async () => { + const tool = await loadPlanReady(); + const out = renderPlan(tool, { plan: "# Plan\n\n- one\n- two" }, false); + + // No hint and no 'more lines' when the preview already shows everything. + assert.match(out, /^Plan ready · 4 lines/); + assert.doesNotMatch(out, /to expand/); + assert.doesNotMatch(out, /more lines/); + assert.match(out, /- one/); + assert.match(out, /- two/); +}); + +test("collapsed line-count boundaries at the preview cap", async () => { + const tool = await loadPlanReady(); + // One line. + const one = renderPlan(tool, { plan: "just a line" }, false); + assert.match(one, /^Plan ready · 1 lines/); + // Exactly at the cap: no hint, no 'more', all rows shown. + const atCap = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const ten = renderPlan(tool, { plan: atCap }, false); + assert.match(ten, /^Plan ready · 10 lines/); + assert.doesNotMatch(ten, /to expand/); + assert.doesNotMatch(ten, /more lines/); + assert.match(ten, /line 10/); + // One past the cap: hint and '... (N more lines)' appear; row 11 hidden. + const pastCap = Array.from({ length: 11 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const eleven = renderPlan(tool, { plan: pastCap }, false); + assert.match(eleven, /^Plan ready · 11 lines · /); + assert.match(eleven, /to expand/); + assert.match(eleven, /\.\.\. \(1 more lines\)/); + assert.match(eleven, /line 10/); + assert.doesNotMatch(eleven, /line 11/); +}); + +test("collapsed counts a trailing newline", async () => { + const tool = await loadPlanReady(); + // "a\n".split("\n") → ["a", ""] → the trailing empty element counts. + const out = renderPlan(tool, { plan: "a\n" }, false); + assert.match(out, /^Plan ready · 2 lines/); +}); + +test("collapsed preview wraps unbroken long lines at narrow width", async () => { + const tool = await loadPlanReady(); + const plan = "short start\n" + "x".repeat(400) + "\nend line\n"; + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const lines = stripAnsi(component.render(40).join("\n")).split("\n"); + assert.ok( + lines.every((line) => line.length <= 40), + "no overflow beyond width", + ); + assert.match(lines.join("\n"), /^Plan ready · 4 lines/); +}); + +test("rendering is stable across repeated calls", async () => { + const tool = await loadPlanReady(); + const plan = "# Plan\n\n- one\n- two\n"; + assert.equal( + renderPlan(tool, { plan }, false), + renderPlan(tool, { plan }, false), + "collapsed output is deterministic", + ); + assert.equal( + renderPlan(tool, { plan }, true), + renderPlan(tool, { plan }, true), + "expanded output is deterministic", ); }); test("expanded restores the full plan", async () => { const tool = await loadPlanReady(); - const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}`; + const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}Final sentinel line`; const out = renderPlan(tool, { plan }, true); assert.match(out, /Long/); - assert.match(out, /item line to pad the plan out/, "last body line present"); + assert.match(out, /item line to pad the plan out/); + assert.match(out, /Final sentinel line/, "tail restorable when expanded"); assert.doesNotMatch( out, /^Plan ready · /, From 887bd223d005c8463bc62c87e8f72fda2ef2d77a Mon Sep 17 00:00:00 2001 From: Matt <2986108228@qq.com> Date: Tue, 1 Sep 2026 10:33:42 +0800 Subject: [PATCH 6/6] fix(plan-mode): bound collapsed preview and surface failure reason Collapsed plan_ready clipped by source lines, so a single long line wrapped past a thousand terminal rows at narrow widths and flooded the transcript. The preview is now bounded in rendered rows (header + PLAN_PREVIEW_LINES rows + expand-hint row + tail row, each truncated to the viewport width, matching renderWaitResult's fixedRows contract), and the hint stays on its own row instead of being clipped with the header. Failed invocations (not in plan mode, empty plan, size cap, abort) yield {content:[{text:reason}], details:{}} from agent-loop's createErrorToolResult; renderResult now surfaces the reason instead of the "(no plan content)" placeholder, mirroring subagent_spawn's fallback. Coverage: 24 rendering tests (row bounds at narrow and wide widths, wide glyphs, grapheme clusters, degenerate widths, rendered-row tail deltas, error-text fallback, the real ToolExecutionComponent shell, invalidate stability, expand/collapse symmetry). bun run check and the plan-mode suites are green; manual TUI smoke confirmed the 13-row collapsed layout and the real ctrl+o hint. --- extensions/plan-mode/index.ts | 65 ++- .../plan-mode/result-rendering.test.ts | 381 +++++++++++++++++- 2 files changed, 420 insertions(+), 26 deletions(-) diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index af084d80..f26aa739 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -33,7 +33,7 @@ import { type ExtensionCommandContext, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; -import { Markdown, Text } from "@earendil-works/pi-tui"; +import { Markdown, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { PLAN_MODE_CHANNEL, @@ -465,22 +465,57 @@ export default function planMode(pi: ExtensionAPI) { renderResult(result, { expanded }, theme) { const plan = (result.details as { plan?: string } | undefined)?.plan; if (!plan) { - return new Text(theme.fg("muted", "(no plan content)"), 0, 0); + // Failed invocations (not in plan mode, empty plan, size cap, abort) + // produce {content:[{text:reason}], details:{}} from agent-loop's + // createErrorToolResult; surface the real reason instead of a + // placeholder, mirroring subagent_spawn's fallback. + const first = result.content?.[0]; + return new Text( + first?.type === "text" + ? first.text + : theme.fg("muted", "(no plan content)"), + 0, + 0, + ); } if (!expanded) { - const lines = plan.split("\n"); - let text = theme.fg("muted", `Plan ready · ${lines.length} lines`); - if (lines.length > PLAN_PREVIEW_LINES) { - text += - theme.fg("muted", " · ") + keyHint("app.tools.expand", "to expand"); - } - for (const line of lines.slice(0, PLAN_PREVIEW_LINES)) { - text += `\n${theme.fg("toolOutput", line)}`; - } - if (lines.length > PLAN_PREVIEW_LINES) { - text += `\n${theme.fg("muted", `... (${lines.length - PLAN_PREVIEW_LINES} more lines)`)}`; - } - return new Text(text, 0, 0); + // Bound the collapsed preview by rendered rows, not source lines: a + // single long source line wraps into many terminal rows at narrow + // widths, so render the body at the caller's width, keep the first + // PLAN_PREVIEW_LINES rows, and truncate every row to the viewport + // width (same contract as renderWaitResult's fixedRows). + const body = new Text( + plan + .split("\n") + .map((line) => theme.fg("toolOutput", line)) + .join("\n"), + 0, + 0, + ); + return { + render(width: number) { + const bodyRows = body.render(width); + const shown = bodyRows.slice(0, PLAN_PREVIEW_LINES); + const hidden = bodyRows.length - shown.length; + const header = theme.fg( + "muted", + `Plan ready · ${plan.split("\n").length} lines`, + ); + const rows = [header, ...shown]; + if (hidden > 0) { + // The hint gets its own row: tucking it into the header tail + // lets a narrow viewport clip it away along with the rest of + // the header, hiding the expand affordance exactly when the + // preview is clipped. + rows.push(keyHint("app.tools.expand", "to expand")); + rows.push(theme.fg("muted", `... (${hidden} more rows)`)); + } + return rows.map((row) => truncateToWidth(row, Math.max(1, width))); + }, + invalidate() { + body.invalidate(); + }, + }; } // A plan is prose to read, not source to inspect. Without a renderer the // TUI falls back to plain text and shows raw Markdown syntax. diff --git a/tests/extensions/plan-mode/result-rendering.test.ts b/tests/extensions/plan-mode/result-rendering.test.ts index c2b1675f..cad743cd 100644 --- a/tests/extensions/plan-mode/result-rendering.test.ts +++ b/tests/extensions/plan-mode/result-rendering.test.ts @@ -11,13 +11,31 @@ * output carries ANSI codes whenever the global theme is initialized, and * these assertions run against ANSI-stripped text. * + * Two more global singletons are relied on: `initTheme("dark", false)` at + * module scope (the `keyHint` helper reads the global theme, and expanded + * rendering reads `getMarkdownTheme()`) and pi-tui's keybindings singleton + * (keyHint's key name resolves from it — unset in this test process, so hint + * rows render as bare " to expand"; assertions treat both that and the real + * "ctrl+o to expand" layout structurally). + * * Assertions stay structural — heading markers consumed, content preserved, * nothing throws — rather than pinning pi-tui's exact visual treatment. */ import assert from "node:assert/strict"; +import { stripVTControlCharacters } from "node:util"; import test from "node:test"; -import { initTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + initTheme, + ToolExecutionComponent, + type ExtensionAPI, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { + visibleWidth, + wrapTextWithAnsi, + type TUI, +} from "@earendil-works/pi-tui"; initTheme("dark", false); @@ -41,12 +59,16 @@ const THEME = { interface RenderedComponent { render(width: number): string[]; + invalidate?(): void; } interface PlanTool { name: string; renderResult?: ( - result: { details?: { plan?: string } | null }, + result: { + details?: { plan?: string } | null; + content?: { type?: string; text?: string }[]; + }, options: { expanded?: boolean }, theme: unknown, ) => RenderedComponent; @@ -259,8 +281,9 @@ test("collapsed shows a bounded preview of a long plan", async () => { const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}Final sentinel line`; const out = renderPlan(tool, { plan }, false); - // Header line carries line count and expand hint. - assert.match(out, /^Plan ready · \d+ lines · /); + // Header line carries the source line count. + assert.match(out, /^Plan ready · \d+ lines/); + // The expand hint and tail live on their own rows. assert.match(out, /to expand/); // Preview exposes the first content lines ... assert.match(out, /Long/); @@ -276,10 +299,10 @@ test("collapsed short plan omits the expand hint", async () => { const tool = await loadPlanReady(); const out = renderPlan(tool, { plan: "# Plan\n\n- one\n- two" }, false); - // No hint and no 'more lines' when the preview already shows everything. + // No hint and no 'more rows' when the preview already shows everything. assert.match(out, /^Plan ready · 4 lines/); assert.doesNotMatch(out, /to expand/); - assert.doesNotMatch(out, /more lines/); + assert.doesNotMatch(out, /more/); assert.match(out, /- one/); assert.match(out, /- two/); }); @@ -296,16 +319,16 @@ test("collapsed line-count boundaries at the preview cap", async () => { const ten = renderPlan(tool, { plan: atCap }, false); assert.match(ten, /^Plan ready · 10 lines/); assert.doesNotMatch(ten, /to expand/); - assert.doesNotMatch(ten, /more lines/); + assert.doesNotMatch(ten, /more/); assert.match(ten, /line 10/); - // One past the cap: hint and '... (N more lines)' appear; row 11 hidden. + // One past the cap: hint and '... (N more rows)' appear; row 11 hidden. const pastCap = Array.from({ length: 11 }, (_, i) => `line ${i + 1}`).join( "\n", ); const eleven = renderPlan(tool, { plan: pastCap }, false); - assert.match(eleven, /^Plan ready · 11 lines · /); + assert.match(eleven, /^Plan ready · 11 lines/); assert.match(eleven, /to expand/); - assert.match(eleven, /\.\.\. \(1 more lines\)/); + assert.match(eleven, /\.\.\. \(1 more rows\)/); assert.match(eleven, /line 10/); assert.doesNotMatch(eleven, /line 11/); }); @@ -328,10 +351,137 @@ test("collapsed preview wraps unbroken long lines at narrow width", async () => assert.ok(component, "renderResult must return a component"); const lines = stripAnsi(component.render(40).join("\n")).split("\n"); assert.ok( - lines.every((line) => line.length <= 40), + lines.every((line) => visibleWidth(line) <= 40), "no overflow beyond width", ); assert.match(lines.join("\n"), /^Plan ready · 4 lines/); + // The preview must stay bounded in rendered rows (header + preview + tail), + // not just keep each row within the width. + assert.ok(lines.length <= 13, `bounded rows, got ${lines.length}`); +}); + +test("collapsed long single line stays bounded at a narrow width", async () => { + const tool = await loadPlanReady(); + // A 47k-char single source line (under MAX_READY_PLAN_CHARS) wraps into + // more than a thousand rows at width 40; the preview must clip by rendered + // rows, keep the expand hint, and never flood the transcript. + const plan = "a".repeat(47_000); + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const lines = stripAnsi(component.render(40).join("\n")).split("\n"); + assert.ok( + lines.every((line) => visibleWidth(line) <= 40), + "every row fits the viewport width", + ); + assert.ok(lines.length <= 13, `bounded rows, got ${lines.length}`); + // The hint is not a header suffix: it survives on its own row even + // though the header ("Plan ready · 1 lines") plus the preview already + // fill a 40-wide viewport. The hint row is keybinding-aware in a real + // layout ("ctrl+o to expand"), so assert structurally: a row that + // contains the hint text and is not the header. + assert.ok( + lines.some( + (line) => line.includes("to expand") && !line.includes("Plan ready"), + ), + "hint is its own leading row, not a clipped header tail", + ); + assert.match( + lines.join("\n"), + /\.\.\. \(\d+ more rows\)/, + "rendered-row tail surfaces", + ); +}); + +test("collapsed preview handles wide glyphs at a narrow width", async () => { + const tool = await loadPlanReady(); + const plan = [ + "# 中文计划标题", + "", + "- 步骤一:迁移队列", + "- 步骤二:停写", + "", + "\uD83C\uDF89 完成", + "很长的中文段落".repeat(50), + ].join("\n"); + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const lines = stripAnsi(component.render(40).join("\n")).split("\n"); + assert.ok( + lines.every((line) => visibleWidth(line) <= 40), + "wide glyphs never overflow the viewport width", + ); + assert.ok(lines.length <= 13, `bounded rows, got ${lines.length}`); + assert.match(lines.join("\n"), /中文计划标题/, "preview keeps wide content"); + assert.match( + lines.join("\n"), + /to expand/, + "hint surfaces for clipped wide content", + ); +}); + +test("collapsed preview stays bounded and width-clean on a wide viewport", async () => { + const tool = await loadPlanReady(); + // 25 source lines that do not wrap at a wide width: rendered rows equal + // source lines, the preview clips past the 10-row cap (so hint + tail must + // appear), and no row may ever exceed the viewport width. + const plan = Array.from( + { length: 25 }, + (_, i) => `任务 ${i + 1} - "${"x".repeat(60)}"`, + ).join("\n"); + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const width = 3000; + const lines = stripAnsi(component.render(width).join("\n")).split("\n"); + assert.ok( + lines.every((line) => visibleWidth(line) <= width), + "no preview row exceeds the wide viewport", + ); + assert.ok(lines.length <= 13, `bounded rows, got ${lines.length}`); + assert.match(lines.join("\n"), /^Plan ready · 25 lines/); + assert.match(lines.join("\n"), /to expand/); + // Tail count is the rendered-row delta (body rows minus the 10-row cap), + // cross-checked against the same wrapping primitive the renderer uses. + const bodyRows = wrapTextWithAnsi(plan, width).length; + assert.match( + lines.join("\n"), + new RegExp(`\\.\\.\\. \\(${bodyRows - 10} more rows\\)`), + "wide-viewport tail matches the rendered-row delta", + ); +}); + +test("collapsed preview tolerates degenerate viewport widths", async () => { + const tool = await loadPlanReady(); + const plan = ["# x", "", "body line", "second body line"].join("\n"); + for (const width of [0, 1]) { + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const lines = stripAnsi(component.render(width).join("\n")).split("\n"); + assert.ok(lines.length >= 1, "still renders at degenerate width"); + assert.ok( + lines.every((line) => visibleWidth(line) <= 1), + "rows stay within the degenerate width", + ); + assert.ok( + lines.every((line) => !line.includes("\uFFFD")), + "no broken surrogate artifacts at degenerate width", + ); + } }); test("rendering is stable across repeated calls", async () => { @@ -349,6 +499,215 @@ test("rendering is stable across repeated calls", async () => { ); }); +test("expanded survives edge-content plans at extreme widths", async () => { + const tool = await loadPlanReady(); + // Plan text is model-generated Markdown, so renderers must not throw or + // corrupt output on hostile-but-plausible content, at any viewport width. + // (width=1 is excluded from the width-discipline check: pi-tui's wrapper + // keeps a wide glyph whole rather than splitting it, one cell over — the + // expanded view is deliberately untruncated, so this is accepted upstream.) + const edgePlans: Record = { + "47k-single-line": "a".repeat(47_000), + "huge-table": [ + "| col0 | col1 | col2 | col3 | col4 | col5 | col6 | col7 | col8 | col9 |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + "| v0 | v1 | v2 | v3 | v4 | v5 | v6 | v7 | v8 | v9 |", + "| w0 | w1 | w2 | w3 | w4 | w5 | w6 | w7 | w8 | w9 |", + ].join("\n"), + "long-link": `[label](https://example.com/${"x".repeat(10_000)})`, + "unclosed-html": "bold without close & italic", + "nested-fence": "```\n```js\ncode\n```\n```", + "zwj-family": "family 👨‍👩‍👧‍👦 and 👩‍👩‍👧‍👦 repeated ".repeat(50), + flags: "🇨🇳 🇺🇸 🇯🇵 ".repeat(100), + }; + for (const [name, plan] of Object.entries(edgePlans)) { + for (const width of [0, 1, 40, 200, 100000]) { + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: true }, + THEME, + ); + assert.ok(component, `${name} @ ${width}: must return a component`); + const rows = component.render(width); // must not throw + const cleaned = rows.map(stripAnsi); + assert.ok( + cleaned.every((row) => !/\x1b/.test(row)), + `${name} @ ${width}: ANSI leaked past strip`, + ); + if (width >= 2) { + assert.ok( + cleaned.every((row) => visibleWidth(row) <= width), + `${name} @ ${width}: row wider than viewport`, + ); + } + } + } +}); + +test("collapsed preview stays clean on grapheme-heavy plans", async () => { + const tool = await loadPlanReady(); + // The collapsed renderer truncates every row with truncateToWidth, which + // must never split a grapheme cluster (ZWJ families, flags, combining + // marks), or surface a broken character at degenerate widths. Note the + // plan is always pre-sanitized (sanitizeTerminalText strips OSC/ESC), so + // escape-bearing input is out of contract here; existing SGR styling is + // handled by truncateToWidth's own ANSI tracking. + const graphemePlans: Record = { + zwj: "👨‍👩‍👧‍👦".repeat(100), + flags: "🇨🇳".repeat(100), + combining: "e\u0301".repeat(200), + "emoji-only": "🎉".repeat(200), + }; + for (const [name, plan] of Object.entries(graphemePlans)) { + for (const width of [0, 1, 2, 3, 40]) { + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, `${name} @ ${width}: must return a component`); + const rows = component.render(width); // must not throw + const cleaned = rows.map(stripAnsi); + assert.ok( + cleaned.every((row) => !/\x1b/.test(row)), + `${name} @ ${width}: ANSI leaked past strip`, + ); + if (width >= 1) { + assert.ok( + cleaned.every((row) => visibleWidth(row) <= width), + `${name} @ ${width}: row wider than viewport`, + ); + } + assert.ok( + rows.length <= 13, + `${name} @ ${width}: bounded rows, got ${rows.length}`, + ); + assert.ok( + cleaned.every((row) => !row.includes("\uFFFD")), + `${name} @ ${width}: replacement character surfaced`, + ); + } + } +}); + +test("failed plan_ready results surface the real error message", async () => { + const tool = await loadPlanReady(); + // agent-loop's createErrorToolResult produces {content:[{text}], details:{}} + // for every failure (not in plan mode, empty plan, size cap, abort, block); + // the renderer must surface the reason instead of "(no plan content)". + for (const expanded of [false, true]) { + const component = tool.renderResult?.( + { + content: [ + { type: "text", text: "plan_ready requires active Plan Mode." }, + ], + details: {}, + }, + { expanded }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const out = stripAnsi(component.render(200).join("\n")); + assert.match( + out, + /requires active Plan Mode/, + "error reason is surfaced in both states", + ); + assert.doesNotMatch(out, /no plan content/, "placeholder not shown"); + } + // With neither plan nor content, keep the placeholder. + const empty = tool.renderResult?.( + { content: [], details: {} }, + { expanded: false }, + THEME, + ); + assert.ok(empty, "renderResult must return a component"); + assert.match(stripAnsi(empty.render(200).join("\n")), /no plan content/); +}); + +test("real ToolExecutionComponent shell keeps the preview bounded and expands fully", async () => { + const tool = await loadPlanReady(); + const plan = "task x\n".repeat(40); + const definition = { + name: "plan_ready", + renderShell: "default", + renderCall: undefined, + renderResult: tool.renderResult, + } as unknown as ToolDefinition; + const ui = { requestRender() {} } as unknown as TUI; + const component = new ToolExecutionComponent( + "plan_ready", + "plan-component", + { plan }, + { showImages: false }, + definition, + ui, + "/workspace", + ); + component.markExecutionStarted(); + component.setArgsComplete(); + component.updateResult({ + content: [{ type: "text", text: "ok" }], + details: { plan }, + isError: false, + }); + + const nonEmpty = () => + component + .render(42) + .map((line) => stripVTControlCharacters(line)) + .filter((line) => line.trim() !== ""); + + // Box(1,1) shell: tool title row + bounded preview (content width 40). + const rows = nonEmpty(); + assert.ok( + rows.length >= 13 && rows.length <= 15, + `shell keeps the preview bounded, got ${rows.length}`, + ); + assert.ok( + rows.every((row) => visibleWidth(row) <= 42), + "shell rows fit the outer viewport width", + ); + assert.match(rows.join("\n"), /Plan ready · 41 lines/); + assert.match(rows.join("\n"), /to expand/); + assert.match(rows.join("\n"), /\.\.\. \(\d+ more rows\)/); + + component.setExpanded(true); + const expanded = nonEmpty(); + assert.ok( + expanded.length > rows.length, + "expanded shows the full plan, not the bounded preview", + ); + assert.ok( + expanded.some((row) => row.includes("task x")), + "expanded keeps plan content", + ); + + component.setExpanded(false); + assert.deepEqual(nonEmpty(), rows, "collapse restores the exact preview"); +}); + +test("collapsed component invalidates cleanly and stays bounded across widths", async () => { + const tool = await loadPlanReady(); + const plan = "line x\n".repeat(30); + const component = tool.renderResult?.( + { details: { plan } }, + { expanded: false }, + THEME, + ); + assert.ok(component, "renderResult must return a component"); + const before = component.render(40); + assert.equal(typeof component.invalidate, "function"); + component.invalidate?.(); + assert.deepEqual( + component.render(40), + before, + "invalidate must not change the output", + ); + assert.ok(component.render(3000).length <= 13, "wide render stays bounded"); + assert.ok(component.render(40).length <= 13, "narrow render stays bounded"); +}); + test("expanded restores the full plan", async () => { const tool = await loadPlanReady(); const plan = `# Long\n\n${"- item line to pad the plan out\n".repeat(1500)}Final sentinel line`;