Skip to content

fix(tui): let the prompt Down arrow reach the end of the text - #40163

Open
3351163616 wants to merge 4 commits into
anomalyco:devfrom
3351163616:prompt-cursor-end
Open

fix(tui): let the prompt Down arrow reach the end of the text#40163
3351163616 wants to merge 4 commits into
anomalyco:devfrom
3351163616:prompt-cursor-end

Conversation

@3351163616

Copy link
Copy Markdown

Issue for this PR

Closes #40161

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The textarea's cursorOffset is 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/tui used input.plainText.length. Any wide character makes that smaller than the real end offset (你好世界\n第二行文字\n第三行 ends at 26, plainText.length is 14). So when the cursor was already at the end, prompt.history.next decided it was not, reset the offset to 14 — the middle of the text — and the built-in input.move.down moved it back to 25. Pressing Down just bounced between two positions and never reached the end.
  • The run-mode composer used 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 compared visualCursor.visualRow against area.height, but visualRow is viewport-relative, so in a scrolled prompt Down could move the cursor backwards.

Fix: use promptOffsetWidth (already in packages/tui/src/prompt/display.ts, it counts newlines as 1) for the arithmetic, and call gotoBufferEnd() / gotoBufferHome() where only cursor placement is needed so the widget measures for itself. Added promptOnFirstRow / promptOnLastRow, which add scrollY to visualRow and compare against getTotalVirtualLineCount() — the document row rather than the viewport row.

Both handlers still return false after the jump, which is what lets the textarea layer's move-down run; that fallthrough is intentional and unchanged.

promptOffsetWidth also needed a tab case: the widget gives a tab two columns, Bun.stringWidth gives 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 — checks promptOffsetWidth against what gotoBufferEnd() 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 with Expected: 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: 24 and Expected: >= 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 test in packages/tui (194 pass), bun test test/cli/run/ in packages/opencode (200 pass), and bun typecheck (30/30).

Screenshots / recordings

Not a visual change — cursor position only. The measured offsets are in the section above.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

3351163616 and others added 2 commits August 2, 2026 22:31
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
@sigco3111

Copy link
Copy Markdown

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.
@3351163616

3351163616 commented Aug 3, 2026

Copy link
Copy Markdown
Author

Thanks — there was a real bug here, though not in the composition state.

An IME can commit as separate jamo (U+1112 U+1161 U+11AB) rather than U+D55C. Intl.Segmenter clusters those correctly but Bun.stringWidth was summing each codepoint, so NFD 한국어 measured 11 where the widget reports 6. Assigning that offset back doesn't clamp — it snaps the cursor to 0, which may be the glitch your users are seeing. Fixed in 28e4e83 by composing multi-codepoint clusters before measuring; ASCII is unaffected.

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. display-offset.test.tsx now covers NFC/NFD hangul, decomposed kana, and standalone jamo against the live widget — what it can't cover is the byte sequences a real Korean IME writes to the TTY. If you can capture those from a session, that would reach ground I can't.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUI prompt Down arrow cannot reach the end of the text

2 participants