From d08ab9b9c1360419766d57503d6c5e6c7aa55feb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 24 Mar 2026 20:09:43 +0100 Subject: [PATCH] fix(coding-agent): recompute interactive bash preview width closes #2569 --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/bash-execution.ts | 28 ++++--- .../test/bash-execution-width.test.ts | 80 +++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/bash-execution-width.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e1475392..508800dd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed interactive bash execution collapsed previews to recompute visual line wrapping at render time, so previews respect the current terminal width after resizes and split-pane width changes ([#2569](https://github.com/badlogic/pi-mono/issues/2569)) - Fixed RPC `get_session_stats` to expose `contextUsage`, so headless clients can read actual current context-window usage instead of deriving it from token totals ([#2550](https://github.com/badlogic/pi-mono/issues/2550)) - Fixed `pi update` for git packages to fetch only the tracked target branch with `--no-tags`, reducing unrelated branch and tag noise while preserving force-push-safe updates ([#2548](https://github.com/badlogic/pi-mono/issues/2548)) diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index be6cf8f1..5f04daea 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -28,12 +28,10 @@ export class BashExecutionComponent extends Container { private fullOutputPath?: string; private expanded = false; private contentContainer: Container; - private ui: TUI; constructor(command: string, ui: TUI, excludeFromContext = false) { super(); this.command = command; - this.ui = ui; // Use dim border for excluded-from-context commands (!! prefix) const colorKey = excludeFromContext ? "dim" : "bashMode"; @@ -147,15 +145,25 @@ export class BashExecutionComponent extends Container { const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n"); this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); } else { - // Use shared visual truncation utility + // Use shared visual truncation utility with width-aware caching const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n"); - const { visualLines } = truncateToVisualLines( - `\n${styledOutput}`, - PREVIEW_LINES, - this.ui.terminal.columns, - 1, // padding - ); - this.contentContainer.addChild({ render: () => visualLines, invalidate: () => {} }); + const styledInput = `\n${styledOutput}`; + let cachedWidth: number | undefined; + let cachedLines: string[] | undefined; + this.contentContainer.addChild({ + render: (width: number) => { + if (cachedLines === undefined || cachedWidth !== width) { + const result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, 1); + cachedLines = result.visualLines; + cachedWidth = width; + } + return cachedLines ?? []; + }, + invalidate: () => { + cachedWidth = undefined; + cachedLines = undefined; + }, + }); } } diff --git a/packages/coding-agent/test/bash-execution-width.test.ts b/packages/coding-agent/test/bash-execution-width.test.ts new file mode 100644 index 00000000..8a40189c --- /dev/null +++ b/packages/coding-agent/test/bash-execution-width.test.ts @@ -0,0 +1,80 @@ +/** + * Test that BashExecutionComponent's collapsed output respects the render-time width, + * not a stale captured width. Regression test for #2569. + */ +import { visibleWidth } from "@mariozechner/pi-tui"; +import { beforeAll, describe, expect, it } from "vitest"; +import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +/** Minimal TUI stub that only exposes terminal.columns */ +function createTuiStub(columns: number): { columns: number; stub: any } { + const state = { columns }; + const stub = { + terminal: { + get columns() { + return state.columns; + }, + get rows() { + return 24; + }, + }, + // Loader calls ui.addInterval / ui.removeInterval + addInterval: (_cb: () => void, _ms: number) => ({ dispose: () => {} }), + removeInterval: () => {}, + requestRender: () => {}, + }; + return { columns: state.columns, stub }; +} + +describe("BashExecutionComponent width handling (#2569)", () => { + beforeAll(() => { + initTheme(undefined, false); + }); + + it("collapsed preview lines respect render-time width, not construction-time width", () => { + const wideWidth = 200; + const narrowWidth = 80; + + const { stub } = createTuiStub(wideWidth); + const component = new BashExecutionComponent("pwd", stub); + + // Add output with long lines that will wrap differently at different widths + const longLine = "x".repeat(150); + component.appendOutput(`${longLine}\n${longLine}\n`); + + // Complete the command so it enters collapsed mode + component.setComplete(0, false); + + // Render at the narrow width (simulating a resize or split pane) + const lines = component.render(narrowWidth); + + // Every rendered line must fit within the narrow width + for (let i = 0; i < lines.length; i++) { + const w = visibleWidth(lines[i]); + expect(w, `Line ${i} visibleWidth=${w} > ${narrowWidth}`).toBeLessThanOrEqual(narrowWidth); + } + }); + + it("re-computes lines when width changes between renders", () => { + const { stub } = createTuiStub(200); + const component = new BashExecutionComponent("echo hello", stub); + + const longLine = "abcdefghij".repeat(20); // 200 chars + component.appendOutput(`${longLine}\n`); + component.setComplete(0, false); + + // First render at width 200 + const lines200 = component.render(200); + for (const line of lines200) { + expect(visibleWidth(line)).toBeLessThanOrEqual(200); + } + + // Second render at width 60 (split pane scenario) + const lines60 = component.render(60); + for (let i = 0; i < lines60.length; i++) { + const w = visibleWidth(lines60[i]); + expect(w, `Line ${i} visibleWidth=${w} > 60`).toBeLessThanOrEqual(60); + } + }); +});