fix(tui): let the prompt Down arrow reach the end of the text - #40163
fix(tui): let the prompt Down arrow reach the end of the text#401633351163616 wants to merge 4 commits into
Conversation
The textarea's cursorOffset is measured in display columns, where a newline takes one position and a tab takes two. Both prompt implementations compared it against something else: - packages/tui: input.plainText.length, so any wide character made the end offset too small. The command then decided the cursor was not at the end, reset it to the middle of the text, and the built-in move-down brought it back - the cursor bounced between two positions and never reached the end. - run-mode composer: Bun.stringWidth, which counts newlines as zero, so it was off by one per newline even for ASCII. It also compared visualRow (viewport-relative) against area.height, which made the cursor jump backwards in a scrolled prompt. Use promptOffsetWidth for the offset arithmetic, and gotoBufferEnd / gotoBufferHome where only cursor placement is needed so the widget does the measuring. Add promptOnFirstRow/promptOnLastRow, which add scrollY to visualRow and compare against getTotalVirtualLineCount. Fixes anomalyco#40161
|
Hi! Quick question about this TUI Down arrow fix — does it correctly handle the case where a user is mid-IME-composition? For example, in Korean input, when typing 'ㄱ' + 'ㅏ' → '가', the Down arrow sometimes breaks the composition state in similar TUI implementations. I'm asking because we've been seeing intermittent reports from Korean opencode users about cursor position glitches during IME composition. A test case or note in the PR description about IME-aware behavior would help us verify this on our end. If a test plan is welcome, I'm happy to draft a small CJK IME test case (Korean / Japanese / Chinese) that could be added to the TUI test suite. Let me know if useful! Thanks for the consistent TUI improvements — using opencode daily. 🙏 |
An IME can commit hangul as separate jamo (U+1112 U+1161 U+11AB for 한) and Japanese kana as a base plus a combining mark (か + U+3099). The widget draws one character per cluster and charges its width once, but Bun.stringWidth was being handed the raw cluster and added up each codepoint: NFD "한국어" measured 11 where the widget reports 6. That made promptOffsetWidth overshoot on decomposed text, so the down-arrow end check never matched and assigning the offset back snapped the cursor to 0 instead of clamping. Normalize multi-codepoint clusters to NFC before measuring. Single codepoints cannot decompose, so they skip the call and ASCII stays on the same path as before.
|
Thanks — there was a real bug here, though not in the composition state. An IME can commit I don't think composition state is at risk: over a TTY the terminal renders preedit itself and only delivers committed text, so the textarea never sees a composition to break. This change touches measurement only — it doesn't intercept keys or change when text lands in the buffer. A test case would be welcome. |
Issue for this PR
Closes #40161
Type of change
What does this PR do?
The textarea's
cursorOffsetis measured in display columns, and in that space a newline takes one position and a tab takes two. Both prompt implementations compared it against something else:packages/tuiusedinput.plainText.length. Any wide character makes that smaller than the real end offset (你好世界\n第二行文字\n第三行ends at 26,plainText.lengthis 14). So when the cursor was already at the end,prompt.history.nextdecided it was not, reset the offset to 14 — the middle of the text — and the built-ininput.move.downmoved it back to 25. Pressing Down just bounced between two positions and never reached the end.Bun.stringWidth, which counts a newline as zero, so its end offset was short by one per newline and failed on plain ASCII too. It also comparedvisualCursor.visualRowagainstarea.height, butvisualRowis viewport-relative, so in a scrolled prompt Down could move the cursor backwards.Fix: use
promptOffsetWidth(already inpackages/tui/src/prompt/display.ts, it counts newlines as 1) for the arithmetic, and callgotoBufferEnd()/gotoBufferHome()where only cursor placement is needed so the widget measures for itself. AddedpromptOnFirstRow/promptOnLastRow, which addscrollYtovisualRowand compare againstgetTotalVirtualLineCount()— the document row rather than the viewport row.Both handlers still
return falseafter the jump, which is what lets the textarea layer'smove-downrun; that fallthrough is intentional and unchanged.promptOffsetWidthalso needed a tab case: the widget gives a tab two columns,Bun.stringWidthgives it zero.How did you verify your code works?
Reproduced first, against the real opentui renderer, then wrote the tests to fail before the fix:
packages/tui/test/prompt/display-offset.test.tsx— checkspromptOffsetWidthagainst whatgotoBufferEnd()reports for 14 strings (newlines, CJK, tabs, wrapped text), and walks a scrolled 8-line buffer row by row to confirm the row predicates never fire early. Reverting just the tab case makes it fail withExpected: 9, Received: 7.packages/opencode/test/cli/run/footer.prompt.cursor.test.tsx— drives the run composer through real arrow keys: Down to the end of multi-line and CJK text, no backwards movement in a scrolled prompt, Up to the start, and history recall from the end of a multi-line draft. 3 of 5 fail without the fix (Expected: 26, Received: 24andExpected: >= 21, Received: 19).To confirm by hand: run
opencode, type Chinese text across three lines with shift+enter, put the cursor in the middle, hold Down. Before, it sticks one position short of the end; after, it walks to the end.Also ran
bun testinpackages/tui(194 pass),bun test test/cli/run/inpackages/opencode(200 pass), andbun typecheck(30/30).Screenshots / recordings
Not a visual change — cursor position only. The measured offsets are in the section above.
Checklist