diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index eb30b7f9c..5ed21f2a3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed bordered `Editor` rendering 1–2 cells past the terminal width when the end-of-line cursor glyph landed past a wide trailing grapheme (CJK comma `,`, emoji, etc.), wrapping the bottom-right corner (`╯`) to its own row. The right chrome (padding + `─` + corner) now shrinks by the exact cursor overflow cell count instead of a 1-cell boolean, so the box stays inside `width` for any `paddingX` ([#3431](https://github.com/can1357/oh-my-pi/issues/3431)). + ## [16.1.17] - 2026-06-24 ### Added diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 0cc229b8b..34187d9b9 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -837,7 +837,7 @@ export class Editor implements Component, Focusable { const layoutLine = visibleLayoutLines[visibleIndex]!; let displayText = layoutLine.text; let displayWidth = visibleWidth(layoutLine.text); - let cursorInPadding = false; + let cursorPaddingOverflow = 0; let decorated = false; const showPromptGutter = promptGutter !== undefined && visibleIndex === 0; const gutterText = @@ -963,7 +963,7 @@ export class Editor implements Component, Focusable { displayWidth += cursorWidth; } if (displayWidth > lineContentWidth && paddingX > 0) { - cursorInPadding = true; + cursorPaddingOverflow = displayWidth - lineContentWidth; } } } @@ -982,18 +982,22 @@ export class Editor implements Component, Focusable { continue; } - // All lines have consistent borders based on padding + // All lines have consistent borders based on padding. When the end-of-line cursor + // glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the + // right chrome by the exact overflow count: drop padding spaces first, then the + // trailing `─`, but never the corner/vertical bar itself. const isLastLine = visibleIndex === visibleLayoutLines.length - 1; - const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0)); + const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow); if (isLastLine) { - const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0)); + const rightPad = Math.max(0, rightChromeCells - 2); + const includeHorizontal = rightChromeCells >= 2; const bottomRightAdjusted = this.borderColor( - `${padding(bottomRightPadding)}${box.horizontal}${box.bottomRight}`, + `${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`, ); result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`); } else { const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`); - const rightBorder = this.borderColor(`${padding(rightPaddingWidth)}${box.vertical}`); + const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${box.vertical}`); result.push(leftBorder + displayText + linePad + rightBorder); } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 6ab2b78ca..d451c758e 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -754,6 +754,28 @@ describe("Editor component", () => { expect(visibleWidth(contentLine)).toBeLessThanOrEqual(width); }); + it("keeps the bordered editor inside `width` when the cursor lands past a wide trailing grapheme (#3431)", () => { + // Regression: typing a fullwidth char (e.g. CJK comma `,`, U+FF0C) at the end + // of the input used to push the bottom-right `─╯` 1–2 cells past the terminal + // edge, wrapping `╯` to its own row. The end-of-line cursor glyph + wide grapheme + // extends into the right padding zone; the right chrome must shrink by the exact + // overflow cell count. + for (const paddingX of [1, 2]) { + const theme = { ...defaultEditorTheme, editorPaddingX: paddingX }; + const minContentWidth = 2 * (paddingX + 1) + 3; // chrome + "," (2) + cursor (1) + for (let width = minContentWidth; width <= minContentWidth + 6; width++) { + const editor = new Editor(theme); + editor.focused = true; + for (const c of "asd,") editor.handleInput(c); + const lines = editor.render(width); + for (const line of lines) { + const stripped = line.replaceAll(CURSOR_MARKER, ""); + expect(visibleWidth(stripped)).toBeLessThanOrEqual(width); + } + } + } + }); + it("shows cursor at end before wrap and wraps on next char", () => { for (const paddingX of [0, 1]) { const editor = new Editor({ ...defaultEditorTheme, editorPaddingX: paddingX });