diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 84568225f..b118dd077 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed narrow terminals crashing when the editor's bottom scroll indicator exceeded the terminal width ([#6962](https://github.com/earendil-works/pi/issues/6962)). + ## [0.81.1] - 2026-07-21 ## [0.81.0] - 2026-07-21 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 706324c77..8b831f4cb 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -9,7 +9,7 @@ import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, - truncateToWidth, + sliceByColumn, visibleWidth, } from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; @@ -256,6 +256,17 @@ function buildDebouncePattern(triggerCharacters: string[]): RegExp { return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); } +function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string { + const availableWidth = Math.max(0, width); + const indicator = `─── ${direction} ${hiddenLineCount} more `; + const remaining = availableWidth - visibleWidth(indicator); + if (remaining >= 0) return indicator + "─".repeat(remaining); + + const ellipsis = "...".slice(0, availableWidth); + const indicatorWidth = availableWidth - visibleWidth(ellipsis); + return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis; +} + export class Editor implements Component, Focusable { private state: EditorState = { lines: [""], @@ -513,13 +524,8 @@ export class Editor implements Component, Focusable { // Render top border (with scroll indicator if scrolled down) if (this.scrollOffset > 0) { - const indicator = `─── ↑ ${this.scrollOffset} more `; - const remaining = width - visibleWidth(indicator); - if (remaining >= 0) { - result.push(this.borderColor(indicator + "─".repeat(remaining))); - } else { - result.push(this.borderColor(truncateToWidth(indicator, width))); - } + const border = createScrollBorder("↑", this.scrollOffset, width); + result.push(this.borderColor(border)); } else { result.push(horizontal.repeat(width)); } @@ -575,9 +581,8 @@ export class Editor implements Component, Focusable { // Render bottom border (with scroll indicator if more content below) const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); if (linesBelow > 0) { - const indicator = `─── ↓ ${linesBelow} more `; - const remaining = width - visibleWidth(indicator); - result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + const border = createScrollBorder("↓", linesBelow, width); + result.push(this.borderColor(border)); } else { result.push(horizontal.repeat(width)); } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index d2c278d76..950f488f5 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -699,6 +699,31 @@ describe("Editor component", () => { }); }); + describe("Scroll indicators", () => { + it("keeps truncated scroll indicators within width and preserves their color (issue #6962)", () => { + const width = 10; + const borderColor = (text: string) => `\x1b[35m${text}\x1b[39m`; + const editor = new Editor(createTestTUI(width), { ...defaultEditorTheme, borderColor }); + editor.setText(Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\n")); + + // Render once to initialize wrapping, then move the cursor so content remains above and below the viewport. + editor.render(width); + for (let index = 0; index < 10; index++) editor.handleInput("\x1b[A"); + + const lines = editor.render(width); + const topBorder = lines[0]!; + const bottomBorder = lines.at(-1)!; + + assert.match(stripVTControlCharacters(topBorder), /^─── ↑/); + assert.match(stripVTControlCharacters(bottomBorder), /^─── ↓/); + assert.strictEqual(topBorder, borderColor(stripVTControlCharacters(topBorder))); + assert.strictEqual(bottomBorder, borderColor(stripVTControlCharacters(bottomBorder))); + for (const line of lines) { + assert.strictEqual(visibleWidth(line), width, `line exceeds width ${width}: ${JSON.stringify(line)}`); + } + }); + }); + describe("Grapheme-aware text wrapping", () => { it("wraps lines correctly when text contains wide emojis", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme);