diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fe55e653..82379c57 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed interactive input fields backed by the TUI `Input` component to scroll by visual column width for wide Unicode text (CJK, fullwidth characters), preventing rendered line overflow and TUI crashes in places like search and filter inputs ([#1982](https://github.com/badlogic/pi-mono/issues/1982)) + ## [0.57.1] - 2026-03-07 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cfbc1360..fa9156ab 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `Input` horizontal scrolling for wide Unicode text (CJK, fullwidth characters) to use visual column width instead of string length, preventing rendered line overflow and TUI crashes ([#1982](https://github.com/badlogic/pi-mono/issues/1982)) + ## [0.57.1] - 2026-03-07 ### Added diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index ff982c4e..750218cc 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -3,7 +3,7 @@ import { decodeKittyPrintable } from "../keys.js"; import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js"; import { UndoStack } from "../undo-stack.js"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js"; const segmenter = getSegmenter(); @@ -442,56 +442,38 @@ export class Input implements Component, Focusable { let visibleText = ""; let cursorDisplay = this.cursor; + const totalWidth = visibleWidth(this.value); - if (this.value.length < availableWidth) { + if (totalWidth < availableWidth) { // Everything fits (leave room for cursor at end) visibleText = this.value; } else { // Need horizontal scrolling - // Reserve one character for cursor if it's at the end + // Reserve one column for cursor if it's at the end const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; - const halfWidth = Math.floor(scrollWidth / 2); + const cursorCol = visibleWidth(this.value.slice(0, this.cursor)); - const findValidStart = (start: number) => { - while (start < this.value.length) { - const charCode = this.value.charCodeAt(start); - // this is low surrogate, not a valid start - if (charCode >= 0xdc00 && charCode < 0xe000) { - start++; - continue; - } - break; + if (scrollWidth > 0) { + const halfWidth = Math.floor(scrollWidth / 2); + let startCol = 0; + + if (cursorCol < halfWidth) { + // Cursor near start + startCol = 0; + } else if (cursorCol > totalWidth - halfWidth) { + // Cursor near end + startCol = Math.max(0, totalWidth - scrollWidth); + } else { + // Cursor in middle + startCol = Math.max(0, cursorCol - halfWidth); } - return start; - }; - const findValidEnd = (end: number) => { - while (end > 0) { - const charCode = this.value.charCodeAt(end - 1); - // this is high surrogate, might be split. - if (charCode >= 0xd800 && charCode < 0xdc00) { - end--; - continue; - } - break; - } - return end; - }; - - if (this.cursor < halfWidth) { - // Cursor near start - visibleText = this.value.slice(0, findValidEnd(scrollWidth)); - cursorDisplay = this.cursor; - } else if (this.cursor > this.value.length - halfWidth) { - // Cursor near end - const start = findValidStart(this.value.length - scrollWidth); - visibleText = this.value.slice(start); - cursorDisplay = this.cursor - start; + visibleText = sliceByColumn(this.value, startCol, scrollWidth); + const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol)); + cursorDisplay = beforeCursor.length; } else { - // Cursor in middle - const start = findValidStart(this.cursor - halfWidth); - visibleText = this.value.slice(start, findValidEnd(start + scrollWidth)); - cursorDisplay = halfWidth; + visibleText = ""; + cursorDisplay = 0; } } diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 78397a4d..d2f4047a 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { Input } from "../src/components/input.js"; +import { visibleWidth } from "../src/utils.js"; describe("Input component", () => { it("submits value including backslash on Enter", () => { @@ -33,6 +34,42 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "\\x"); }); + describe("render", () => { + it("does not overflow with wide CJK and fullwidth text", () => { + const width = 93; + const cases = [ + "가나다라마바사아자차카타파하 한글 텍스트가 터미널 너비를 초과하면 크래시가 발생합니다 이것은 재현용 테스트입니다", + "これはテスト文章です。日本語のテキストが正しく表示されるかどうかを確認するためのサンプルテキストです。あいうえお", + "这是一段测试文本,用于验证中文字符在终端中的显示宽度是否被正确计算,如果不正确就会导致用户界面崩溃的问题", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklm", + ]; + + for (const text of cases) { + const input = new Input(); + input.setValue(text); + input.focused = true; + + const [line] = input.render(width); + assert.ok(line); + assert.ok(visibleWidth(line) <= width, `rendered line overflowed for ${text}`); + } + }); + + it("keeps the cursor visible when horizontally scrolling wide text", () => { + const input = new Input(); + const width = 20; + const text = "가나다라마바사아자차카타파하"; + input.setValue(text); + input.focused = true; + input.handleInput("\x01"); + for (let i = 0; i < 5; i++) input.handleInput("\x1b[C"); + + const [line] = input.render(width); + assert.ok(line); + assert.ok(visibleWidth(line) <= width); + }); + }); + describe("Kill ring", () => { it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => { const input = new Input();