-
Notifications
You must be signed in to change notification settings - Fork 3.5k
BashExecutionComponent render callback ignores width parameter, crashes in split terminals #2569
Description
What happened?
I use Ghostty with split panes and this crash happens every now and then:
Error: Rendered line 3346 exceeds terminal width (273 > 136).
This is likely caused by a custom TUI component not truncating its output.
Use visibleWidth() to measure and truncateToWidth() to truncate lines.
I initially assumed it was a minor CJK width issue on my end (I use Korean text heavily), so I've been working around it by feeding the crash log back to pi and letting it patch bash-execution.js in node_modules each time. But the patch keeps getting wiped on every pi update, so I dug deeper.
Turns out it's not CJK-related at all. The error message blames a "custom TUI component", but the rendered block layout in the crash log (separator / $ pwd header / preview lines / separator) matches pi's own BashExecutionComponent:
[3344] (w=136) ────────────────────────────────────── (separator)
[3345] (w=136) $ pwd (command header -- fits)
[3346] (w=273) (empty line -- 2x too wide)
[3347] (w=273) /Users/.../temp-20260324-071918 (output -- 2x too wide)
[3348] (w=273) (empty line -- 2x too wide)
[3349] (w=136) ────────────────────────────────────── (separator)
Lines 3346-3348 are the collapsed bash preview, pre-computed at width 273 but validated against the actual render width 136.
Root cause: In bash-execution.ts lines ~152-158, the collapsed output render callback ignores the width parameter:
const { visualLines } = truncateToVisualLines(
`\n${styledOutput}`,
PREVIEW_LINES,
this.ui.terminal.columns, // <-- width captured at updateDisplay() time
1,
);
this.contentContainer.addChild({ render: () => visualLines, invalidate: () => {} });
// ^^
// width parameter from render() is ignoredComponent.render(width) must return lines that fit width, and tui.js enforces this at render time (visibleWidth(line) > width throws). This callback ignores the width it receives and returns lines pre-computed for this.ui.terminal.columns, which can be stale.
TUI.doRender() reads this.terminal.columns at render time and cascades it down via Container.render(width) -> child.render(width). But BashExecutionComponent.updateDisplay() captures this.ui.terminal.columns at a different time (when output arrives or command completes). In split-pane setups these values drift apart easily.
TUI.doRender()
width = this.terminal.columns // 136 at render time
this.render(width)
...
DynamicBorder.render(136) // uses width -- OK
Text(header).render(136) // uses width -- OK
{ render: () => visualLines }(136) // IGNORES width, returns lines for 273 -- CRASH
...
The correct pattern already exists in the same codebase -- core/tools/bash.ts lines ~211-232 does it right:
component.addChild({
render: (width: number) => {
if (state.cachedLines === undefined || state.cachedWidth !== width) {
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
state.cachedLines = preview.visualLines;
state.cachedSkipped = preview.skippedCount;
state.cachedWidth = width;
}
// ...
return ["", ...(state.cachedLines ?? [])];
},
invalidate: () => {
state.cachedWidth = undefined;
state.cachedLines = undefined;
state.cachedSkipped = undefined;
},
});Suggested fix -- apply the same pattern from core/tools/bash.ts:
--- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
+++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
@@ -149,11 +149,21 @@
} else {
// Use shared visual truncation utility
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;
+ },
+ });
}Related: #941 fixed a similar width overflow in tool-execution.ts hint line (different location, same class of bug).
Steps to reproduce
- Run pi in a wide terminal window or pane
- Execute any bash command (
pwd,ls) and let it complete (collapsed view) - Resize or split the terminal so the width drops
Timing-dependent -- updateDisplay() caches lines at the old width, and the next doRender() validates against the new width. Split panes make this easier to hit. In my case (Ghostty splits) it happens a few times a week.
Expected behavior
The collapsed bash preview should re-compute its lines when the terminal width changes, not crash. The render(width) callback should use the width parameter it receives, like core/tools/bash.ts already does.
Version
0.62.0 (Node v22.14.0, macOS, Ghostty)