Reviewed GitHub issue and image.
UnderstandExamined terminal UI editor code.
UnderstandFixed screen corruption bug.
BuildRemoved unnecessary test helper file.
EditMerged fix and cleaned branch.
CleanupAnalyze GitHub issue(s): https://github.com/badlogic/pi-mono/issues/732 you will have to pull downt he image and read it as well to understand.
For each issue:
Read the issue in full, including all comments and linked issues/PRs.
For bugs:
For feature requests:
Do NOT implement unless explicitly asked. Analyze and propose only.
The user wants me to analyze GitHub issue #732. Let me fetch the issue details first.
>gh issue view 732 --repo badlogic/pi-mono --json title,body,comments,labels,stateThe issue is about the TUI editor component breaking when setEditorText loads a prompt larger than the screen. Let me download and view the image to understand the visual bug better.
Let me download and view that image to understand the visual issue:
>curl -L -o /tmp/issue732.png "https://github.com/user-attachments/assets/19a877b1-07e8-4a8a-94fe-ecf6558ec469"Good, the image is downloaded. Let me view it to understand the bug.
The image shows a severe rendering bug where the content is being double-rendered (ghosting/overlapping lines). Lines appear duplicated, and the entire layout is corrupted. This looks like a classic terminal rendering issue where content is being written beyond the visible terminal area, causing screen corruption.
The issue mentions:
setEditorText with text larger than screen causes corruptionLet me look at the TUI package, specifically the editor component and how setEditorText works.
The image shows severe screen corruption with doubled/overlapping lines. This is a classic terminal rendering issue where content overflows the visible area. Let me examine the TUI editor code:
>find packages/tui -name "*.ts" | head -30>grep -r "setEditorText" packages/tui packages/coding-agent --include="*.ts" -lLet me read the editor component in the TUI package to understand how it handles text and rendering.
Now I need to understand how the TUI rendering works and how setEditorText is used. The editor component itself handles text layout and word-wrapping, but there's no scrolling within the editor. Let me look at the TUI rendering system to understand how the screen is managed.
Now let me examine the main TUI rendering system:
Now I need to see how the editor is used in the coding-agent and how setEditorText is called. Let me also check the interactive mode to understand the layout.
Now let me look at how the editor is used in the interactive mode:
Let me continue reading the interactive mode to see more about how the editor is handled, especially for large texts and the rendering system.
The file is large. Let me search for the key methods related to setEditorText and the editor layout:
>grep -n "setEditorText\|setText\|render\(" packages/tui/src/components/editor.ts | head -50>grep -n "maxHeight\|height\|scroll" packages/tui/src/components/editor.tsThe editor has no height constraint. Let me understand how the TUI lays out components and why there's no scrolling.
The issue is clear: the Editor component in the TUI renders ALL lines, with no maximum height or scrolling capability. When setEditorText sets text larger than the terminal can fit, the editor renders all those lines, which causes the TUI's rendering to overflow the terminal, resulting in:
The fix would be to add a maximum height to the editor and implement vertical scrolling within it. Let me look more closely at how rendering works and propose a specific fix.
>grep -n "render\|layoutText\|height\|contentWidth" packages/tui/src/components/editor.ts | head -40Now let me check the render method and layoutText more closely to understand how it handles the number of lines.
Now I see the problem clearly. The render() method in the Editor component simply renders all layoutLines without any height constraint. Let me also check how the TUI manages the viewport to understand the full picture.
Let me check how the TUI's doRender manages screen overflow:
Let me look at the doRender method specifically to understand how it handles content that exceeds terminal height:
Now I understand the full picture. The issue is:
cursorRow) gets out of syncThe key insight is in doRender():
\r\n sequencesnewLines.length > terminal height, the lines wrap/scroll in unpredictable wayscursorRow is set to newLines.length - 1, but this doesn't account for terminal scrollingThe editor is the problematic component because:
setEditorText sets a large prompt, the editor could generate dozens of linesLet me also check if there's any maxHeight concept for regular (non-overlay) components.
Now let me check the CustomEditor component that's used in interactive mode:
I now have a complete picture. The CustomEditor extends Editor but doesn't add any height constraints. Let me also check the TUI README/docs for understanding the expected behavior:
Let me check the TUI documentation:
Now I have a complete understanding of the problem:
The issue is that the Editor component renders ALL lines without any maximum height constraint. When setEditorText is called with text that produces more lines than the terminal can display:
Editor.render() method generates ALL layoutLines (see line 325-390 in editor.ts)doRender() outputs ALL these lines sequentially with \r\ncursorRow) becomes incorrectThe image shows the classic "double-rendering" artifact where:
ESC stops working because:
handleInput() being called on the focused componentHowever, Ctrl+C still works because it's handled at a lower level (SIGINT).
The Editor component needs a maximum height constraint with vertical scrolling. Here's the approach:
Add maxHeight?: number parameter to Editor (passed during render or set externally)
Track a scrollOffset for vertical scrolling
In render(), only output lines from scrollOffset to scrollOffset + maxHeight
Ensure the cursor line is always visible by adjusting scrollOffset
The cursor movement methods need to call a method to ensure the cursor is in view
Alternatively, the TUI could pass available height to components, but this requires changing the Component interface and is less ideal.
I need to add state variables for maxLines (defaulting to Infinity, configurable) and scrollOffset (starting at 0) to track the viewport. Then modify render() to slice the layoutLines array between scrollOffset and scrollOffset + maxLines, calling ensureCursorVisible() first to adjust the scroll position so the cursor stays in view.
The ensureCursorVisible() method should locate which layout line has the cursor, then adjust scrollOffset upward if the cursor is above the current view or downward if it's below. I'll also add a setMaxLines() setter for external control, which the interactive mode can call after calculating the available terminal height minus space for other UI elements.
Changes needed in packages/tui/src/components/editor.ts for the scrolling logic, and potentially packages/coding-agent/src/modes/interactive/interactive-mode.ts to set the editor's max height based on terminal dimensions.
The cleanest approach avoids breaking the Component interface—instead of modifying render(width: number) to accept height, I'll add an optional maxLines property directly to the Editor class. This follows the existing pattern used by SelectList with its maxVisible parameter, keeping the implementation consistent with other height-constrained components in the codebase.
Now I have a complete understanding of the issue.
When setEditorText loads text that produces more lines than fit in the terminal, the TUI rendering breaks completely, causing visual corruption (doubled/overlapping lines) and ESC stops working.
The Editor component has no height constraint. It renders ALL layout lines regardless of terminal size:
In packages/tui/src/components/editor.ts (lines 318-390):
render(width: number): string[] { const layoutLines = this.layoutText(width); // Can be unlimited lines const result: string[] = []; result.push(horizontal.repeat(width)); // top border // ❌ PROBLEM: Renders ALL layout lines without any limit for (const layoutLine of layoutLines) { // ... render each line result.push(displayText + padding); } result.push(horizontal.repeat(width)); // bottom border return result;}When the total rendered lines exceed terminal height:
\r\n, causing terminal scrollingcursorRow in TUI) becomes desynchronizedThe TUI's input forwarding depends on consistent state. When rendering breaks, the component may not receive or correctly process input. Ctrl+C still works because it triggers SIGINT at the OS level.
Add vertical scrolling with a max height constraint to the Editor, following the same pattern as SelectList which already has maxVisible:
Changes to packages/tui/src/components/editor.ts:
private maxLines: number = Infinity; // Max visible content linesprivate scrollOffset: number = 0; // First visible line indexsetMaxLines(max: number): void { this.maxLines = max;}private ensureCursorVisible(visualLines: LayoutLine[]): void { // Find which visual line has the cursor const cursorVisualLine = visualLines.findIndex(vl => vl.hasCursor); if (cursorVisualLine === -1) return; // Adjust scroll if cursor is out of view if (cursorVisualLine < this.scrollOffset) { this.scrollOffset = cursorVisualLine; } else if (cursorVisualLine >= this.scrollOffset + this.maxLines) { this.scrollOffset = cursorVisualLine - this.maxLines + 1; }}render() to slice visible lines:render(width: number): string[] { const layoutLines = this.layoutText(width); // Ensure cursor is visible before slicing this.ensureCursorVisible(layoutLines); // Slice to visible range const visibleLines = this.maxLines === Infinity ? layoutLines : layoutLines.slice(this.scrollOffset, this.scrollOffset + this.maxLines); const result: string[] = []; result.push(horizontal.repeat(width)); for (const layoutLine of visibleLines) { // Only visible lines // ... existing rendering logic } result.push(horizontal.repeat(width)); // ... autocomplete rendering return result;}ensureCursorVisible() after cursor movements (or just let render() handle it)Changes to packages/coding-agent/src/modes/interactive/interactive-mode.ts:
In init() or wherever the editor is configured, calculate and set a reasonable max height based on terminal dimensions minus other UI elements (header, footer, status, etc.).
A more invasive fix would change the Component interface to pass both width and height:
render(width: number, height?: number): string[];However, this is a breaking change and affects all components. The maxLines approach is self-contained to the Editor.
packages/tui/src/components/editor.ts - Add scrolling logic (~30-40 lines)packages/coding-agent/src/modes/interactive/interactive-mode.ts - Set editor max height (optional, could use a sensible default like terminal height - 10)editor has access to tui, which has access to terminal, which tells us rows and cols, no?
The user is asking if the Editor has access to the TUI, which would give it access to terminal dimensions. Let me check if that's the case.
Looking at the Editor constructor:
constructor(theme: EditorTheme) { this.theme = theme; this.borderColor = theme.borderColor;}No, the Editor doesn't receive a TUI instance. It only gets a theme.
Let me check the CustomEditor:
constructor(theme: EditorTheme, keybindings: KeybindingsManager) { super(theme); this.keybindings = keybindings;}Also no TUI reference.
However, looking at how components like Loader work:
const loader = new Loader( tui, // TUI instance for render updates (s) => chalk.cyan(s), // spinner color function ...);Loader gets the TUI passed to it. But Editor doesn't.
The user is suggesting that we could pass the TUI to the Editor, and then the Editor could query tui.terminal.rows to know the available height dynamically during render.
This would be a cleaner solution than having to externally set maxLines. The Editor could self-limit based on available terminal height.
Let me verify the TUI structure:
TUI has terminal: TerminalTerminal interface has get rows(): number and get columns(): numberSo yes, if the Editor had access to the TUI (or just the Terminal), it could query the height during render and auto-limit itself.
The question is whether to:
Option 1 or 2 would work. Option 2 is cleaner (less coupling). But actually, looking at how the coding-agent creates the editor:
this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings);And the TUI is created right there too:
this.ui = new TUI(new ProcessTerminal());So it would be easy to pass this.ui or this.ui.terminal to the editor. Since the TUI is public, the Editor could access tui.terminal.rows directly. The simplest approach might be passing a getMaxHeight?: () => number callback function to the Editor constructor, allowing it to query available height dynamically during rendering without tight coupling to the TUI instance.
You're right, let me check:
>grep -n "new CustomEditor\|new Editor" packages/coding-agent/src/modes/interactive/interactive-mode.ts>grep -n "constructor" packages/tui/src/components/editor.ts | head -5So the Editor constructor only takes a theme. It doesn't have access to TUI or Terminal. But the user is right that we could pass it in.
Looking at the interactive-mode.ts, the TUI is created at line ~240-243:
this.ui = new TUI(new ProcessTerminal());...this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings);So we could pass this.ui to the CustomEditor constructor, or just pass this.ui.terminal for the rows/cols.
The cleanest approach would be to:
terminal?: Terminal parameter to Editor constructorterminal.rows to calculate max visible linesOr even simpler: just pass a getHeight?: () => number function.
Currently, Editor doesn't receive TUI or Terminal:
// Editor constructor - only gets themeconstructor(theme: EditorTheme) { this.theme = theme; this.borderColor = theme.borderColor;}
// CustomEditor - gets theme + keybindings, no TUIconstructor(theme: EditorTheme, keybindings: KeybindingsManager) { super(theme); this.keybindings = keybindings;}But you're right, it would be cleaner to pass a reference. Two options:
Option A: Pass Terminal to Editor
constructor(theme: EditorTheme, terminal?: Terminal) { // In render(), use terminal?.rows to auto-limit}Option B: Pass a height getter (more decoupled)
constructor(theme: EditorTheme, options?: { getAvailableHeight?: () => number }) { // In render(), call getAvailableHeight?.() }Option B is nice because the caller can account for other UI elements (header, footer, status) when computing available height. The Editor doesn't need to know about the full layout.
In interactive-mode.ts, it would be:
this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings,ok, then lets have the editor (and customeditor) receive Tui as a parameter to the constructor (first parameter)
The user wants to pass TUI as the first parameter to both Editor and CustomEditor constructors. This will give the Editor access to terminal dimensions via tui.terminal.rows.
Let me outline the changes needed:
packages/tui/src/components/editor.ts:
this.tui.terminal.rows to calculate max visible linespackages/coding-agent/src/modes/interactive/components/custom-editor.ts:
packages/coding-agent/src/modes/interactive/interactive-mode.ts:
this.uiAny other places that create Editor instances need to be updated
Let me check for all Editor usages:
Let me find all places where Editor/CustomEditor is instantiated:
>grep -rn "new Editor\|new CustomEditor" packages/ --include="*.ts" | grep -v "\.d\.ts" | grep -v node_modulesThat's a lot of places. The tests use Editor without TUI. We need to make TUI optional to maintain backward compatibility, OR we need to update all tests.
Actually, for tests, we could pass undefined or create a mock TUI. But making it optional is cleaner for backward compatibility.
Wait, the user said "first parameter". If TUI is optional and first, the signature becomes awkward:
constructor(tui: TUI | undefined, theme: EditorTheme)That's not great. Better options:
Let me reconsider. The user specifically wants TUI as first parameter. For tests, we could:
null and have Editor handle it gracefullyActually, looking at tests, they don't really need the height limiting behavior - they're unit tests for editing behavior. So we could make TUI optional (can be undefined), and if undefined, the editor just doesn't limit height (current behavior).
constructor(tui: TUI | undefined, theme: EditorTheme) { this.tui = tui; // ...}In render:
const maxLines = this.tui ? this.calculateMaxLines() : Infinity;This maintains backward compatibility for tests while enabling the feature when TUI is provided.
Actually wait, looking at the user's request again - they said TUI as first parameter. I should make it TUI | undefined to keep tests working without major changes.
Let me draft the changes:
Editor: constructor(tui: TUI | undefined, theme: EditorTheme)
CustomEditor: constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager)
Update all usages
Tests pass undefined as the first argument, while production code passes the actual TUI instance. I should also check how extension examples and extension-editor.ts instantiate editors to see if they need TUI access.
There are many usages, mostly in tests. I'll make TUI the first parameter but allow undefined for backward compatibility in tests. When undefined, the editor won't limit height (current behavior).
Here's the proposed fix:
Files to modify:
packages/tui/src/components/editor.ts - Add TUI param, implement scrollingpackages/coding-agent/src/modes/interactive/components/custom-editor.ts - Add TUI parampackages/coding-agent/src/modes/interactive/interactive-mode.ts - Pass TUIpackages/coding-agent/src/modes/interactive/components/extension-editor.ts - Pass TUIpackages/coding-agent/examples/extensions/question.ts - Pass TUIpackages/coding-agent/examples/extensions/questionnaire.ts - Pass TUIpackages/tui/test/editor.test.ts - Pass undefined as first argpackages/tui/test/chat-simple.ts - Pass TUIShould I implement this now?
no backward compatibility
The user wants no backward compatibility - TUI should be a required first parameter. This means all usages must be updated to pass a TUI instance.
For tests, we'll need to either:
Looking at the test file, they probably have access to TUI/VirtualTerminal already since this is the TUI package. Let me check what's available in the test setup.
Actually, for unit tests of editor behavior (text manipulation, cursor movement, etc.), we don't really need a real terminal. We could create a minimal TUI with a mock terminal that just returns fixed rows/cols.
Let me outline the changes:
packages/tui/src/components/editor.ts:
constructor(tui: TUI, theme: EditorTheme)this.tui = tuithis.tui.terminal.rowspackages/coding-agent/src/modes/interactive/components/custom-editor.ts:
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager)All other files - update to pass TUI
For tests, we need to create a TUI instance. Looking at the test files, they likely can import TUI and create one with a VirtualTerminal or mock.
This is a clean breaking change - TUI is required.
Got it. TUI is required, all call sites must be updated.
Summary of changes:
packages/tui/src/components/editor.ts
constructor(tui: TUI, theme: EditorTheme)render(): limit visible lines based on this.tui.terminal.rows, keep cursor in viewpackages/coding-agent/src/modes/interactive/components/custom-editor.ts
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager)packages/coding-agent/src/modes/interactive/interactive-mode.ts
this.ui to CustomEditorpackages/coding-agent/src/modes/interactive/components/extension-editor.ts
packages/coding-agent/examples/extensions/question.ts
packages/coding-agent/examples/extensions/questionnaire.ts
packages/tui/test/editor.test.ts
packages/tui/test/chat-simple.ts
Should I implement?
create a feature branch, then go to town
you still there?
The user wants me to create a feature branch and implement the fix. Let me start by creating the branch and then implementing all the changes.
Yes, on it!
>cd /Users/badlogic/workspaces/pi-mono && git checkout -b fix/editor-overflow-scrollingGood, branch created. Now let me implement the changes systematically:
Let me start with the Editor component.
Let me see the EditorState and class definition and constructor:
Now I need to see the imports and understand what TUI is. Let me check the tui.ts file for the TUI class:
>grep -n "^export class TUI\|^export interface TUI\|export { TUI" packages/tui/src/tui.ts | head -5Good, TUI is a class exported from tui.ts. Now let me check the imports in editor.ts and what we need to import:
Now let me implement the changes. First, update the Editor:
Now I need to check if TUI is exported from tui.ts. Let me verify:
>grep -n "export.*TUI\|export type.*TUI" packages/tui/src/tui.tsGood, TUI is exported. But wait, I'm importing TUI as a type but it's a class. Let me check if we need to import it differently. Actually, using import type { TUI } works for type annotations, but since we're storing a reference to it, we might need the actual import.
Actually, import type is fine because we're only using TUI for type annotations (the parameter type and the stored property type). We don't need to instantiate it.
Now let me update the class to add the TUI parameter and scrolling logic:
Now I need to update the render method to implement scrolling. Let me read the render method again:
Now I need to modify the render method to:
Let me rewrite the render method:
Good. Now I need to also reset scrollOffset when text is cleared (setText). Let me find and update that method:
>grep -n "setText\|setTextInternal" packages/tui/src/components/editor.ts | head -10Let me read the setTextInternal and setText methods to add scrollOffset reset:
I should reset the scrollOffset in setTextInternal since the cursor will be at the end of the text and we want to ensure it's visible:
Now I need to also reset scrollOffset when submitting (clearing the editor). Let me find the submit handler:
>grep -n "onSubmit\|state = {" packages/tui/src/components/editor.ts | head -20Let me see line 616 where state is reset on submit:
I need to also reset scrollOffset when submitting:
Now I need to update CustomEditor to pass TUI:
Now update the CustomEditor:
I need to update CustomEditor to:
Now I need to check if TUI is exported from pi-tui. Let me check the exports:
>grep -n "export.*TUI" packages/tui/src/index.tsLet me check the tui package's main exports:
>cat packages/tui/src/index.tsTUI is exported from index.ts. Now I need to update the usages of Editor/CustomEditor. Let me start with interactive-mode.ts:
Now update interactive-mode.ts:
>grep -n "new CustomEditor" packages/coding-agent/src/modes/interactive/interactive-mode.tsLet me see the context around line 243 to understand the code flow:
I see that this.ui is created just before the editor. I need to pass this.ui to CustomEditor:
Now let me update extension-editor.ts:
I need to update line 47 where the Editor is created:
Now let me update the extension examples:
I need to see more context to understand where tui comes from:
Good, I can see that tui is available from the factory function parameters. Let me update the Editor creation to pass tui:
Now let me check the questionnaire.ts example:
Let me see more context to get the parameter names:
I can see tui is passed in the same way. Let me update this:
Now let me update the tests. First, let me see what's available in the test files:
Now update the test file:
I need to create a mock TUI for tests. Let me check if there's a VirtualTerminal or mock terminal available:
>grep -rn "VirtualTerminal\|MockTerminal" packages/tui --include="*.ts" | head -10There's a VirtualTerminal. I can use that to create a TUI for tests. Let me look at how it's used in other tests:
I can use VirtualTerminal with TUI for tests. Now let me update the editor.test.ts file. I need to:
new Editor(defaultEditorTheme) calls to pass the TUILet me first check how many calls there are and then create a helper function at the top:
>grep -c "new Editor(defaultEditorTheme)" packages/tui/test/editor.test.ts41 instances. I'll create a helper function and then do a global replace. Let me update the imports and add the helper, then do the replace:
Now let me do a global replace:
>cd /Users/badlogic/workspaces/pi-mono && sed -i '' 's/new Editor(defaultEditorTheme)/new Editor(createTestTUI(), defaultEditorTheme)/g' packages/tui/test/editor.test.tsNow let me check that the changes were applied correctly:
>grep -c "new Editor(createTestTUI(), defaultEditorTheme)" packages/tui/test/editor.test.tsGood. Now let me update the chat-simple.ts test file:
The tui is already created before the editor, so I just need to pass it to the Editor constructor:
render(width: number): string[] { const layoutLines = this.layoutText(width); // Can be unlimited lines const result: string[] = []; result.push(horizontal.repeat(width)); // top border // ❌ PROBLEM: Renders ALL layout lines without any limit for (const layoutLine of layoutLines) { // ... render each line result.push(displayText + padding); } result.push(horizontal.repeat(width)); // bottom border return result;}private maxLines: number = Infinity; // Max visible content linesprivate scrollOffset: number = 0; // First visible line indexsetMaxLines(max: number): void { this.maxLines = max;}private ensureCursorVisible(visualLines: LayoutLine[]): void { // Find which visual line has the cursor const cursorVisualLine = visualLines.findIndex(vl => vl.hasCursor); if (cursorVisualLine === -1) return; // Adjust scroll if cursor is out of view if (cursorVisualLine < this.scrollOffset) { this.scrollOffset = cursorVisualLine; } else if (cursorVisualLine >= this.scrollOffset + this.maxLines) { this.scrollOffset = cursorVisualLine - this.maxLines + 1; }}render(width: number): string[] { const layoutLines = this.layoutText(width); // Ensure cursor is visible before slicing this.ensureCursorVisible(layoutLines); // Slice to visible range const visibleLines = this.maxLines === Infinity ? layoutLines : layoutLines.slice(this.scrollOffset, this.scrollOffset + this.maxLines); const result: string[] = []; result.push(horizontal.repeat(width)); for (const layoutLine of visibleLines) { // Only visible lines // ... existing rendering logic } result.push(horizontal.repeat(width)); // ... autocomplete rendering return result;}render(width: number, height?: number): string[];constructor(theme: EditorTheme) { this.theme = theme; this.borderColor = theme.borderColor;}constructor(theme: EditorTheme, keybindings: KeybindingsManager) { super(theme); this.keybindings = keybindings;}const loader = new Loader( tui, // TUI instance for render updates (s) => chalk.cyan(s), // spinner color function ...);this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings);this.ui = new TUI(new ProcessTerminal());this.ui = new TUI(new ProcessTerminal());...this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings);// Editor constructor - only gets themeconstructor(theme: EditorTheme) { this.theme = theme; this.borderColor = theme.borderColor;}
// CustomEditor - gets theme + keybindings, no TUIconstructor(theme: EditorTheme, keybindings: KeybindingsManager) { super(theme); this.keybindings = keybindings;}constructor(theme: EditorTheme, terminal?: Terminal) { // In render(), use terminal?.rows to auto-limit}constructor(theme: EditorTheme, options?: { getAvailableHeight?: () => number }) { // In render(), call getAvailableHeight?.() }this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings,constructor(tui: TUI | undefined, theme: EditorTheme)constructor(tui: TUI | undefined, theme: EditorTheme) { this.tui = tui; // ...}const maxLines = this.tui ? this.calculateMaxLines() : Infinity;