Skip to content

Add IME cursor positioning and Synchronized Update Mode#866

Merged
sindresorhus merged 28 commits into
vadimdemedes:masterfrom
juniqlim:fix/synchronized-update-v2
Feb 8, 2026
Merged

Add IME cursor positioning and Synchronized Update Mode#866
sindresorhus merged 28 commits into
vadimdemedes:masterfrom
juniqlim:fix/synchronized-update-v2

Conversation

@juniqlim

@juniqlim juniqlim commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Expose a useCursor() hook so apps can position the terminal cursor after each render — enabling CJK IME composition characters to appear at the correct location
  • Wrap terminal output with Synchronized Update Mode (CSI ? 2026 h/l) to prevent terminal multiplexers (tmux, Zellij) from reading intermediate cursor positions during rendering
  • Fix cursor state leak in concurrent mode — cursor position from suspended (abandoned) renders no longer bleeds into fallback output

Inspired by #846 (by @jedipunkz), which tackles the same problem. This PR takes a different implementation approach:

  • Synchronized Update at write time — immediate-write paths (screen reader, static output, clearTerminal, writeToStdout/Stderr) wrap BSU/ESU around the writes directly; the throttled render path wraps BSU/ESU inside the throttle callback so that trailing invocations are also correctly synchronized
  • Cursor-only fast path — when output is unchanged but cursor moved, skip erase/rewrite and reposition the cursor only
  • Shared helpers (buildCursorSuffix / buildReturnToBottom) — cursor logic reused by both createStandard and createIncremental renderers instead of being duplicated

Design note: setCursorPosition and concurrent mode safety

setCursorPosition is called in the component render body:

function TextInput() {
  const {setCursorPosition} = useCursor();
  setCursorPosition({x: cursorX, y: cursorY});
  return <Text>...</Text>;
}

Internally, the call only stores the position in a local ref — no external state is mutated during render. The actual propagation to log-update happens in a useInsertionEffect, which:

  1. Runs during commit phase (before resetAfterCommitonRender)
  2. Does not run for abandoned renders (e.g. suspended components in concurrent mode)

This prevents cursor state from leaking across render boundaries while avoiding the 1-frame delay that useEffect would introduce.

Design note: BSU/ESU and throttled writes

onRender has multiple output paths with different timing characteristics:

  • Immediate paths (screen reader, static output, clearTerminal): writes happen synchronously within onRender, so BSU/ESU wraps the entire block
  • Throttled path (this.throttledLog): the trailing throttle invocation fires after onRender returns, so wrapping BSU/ESU at the onRender level would leave the trailing write unprotected. Instead, BSU/ESU is wrapped inside the throttle callback itself, ensuring every actual stream.write — whether leading or trailing — is synchronized

Changed files

New (4):

File Purpose
src/write-synchronized.ts BSU/ESU constants + shouldSynchronize guard
src/components/CursorContext.ts Cursor position React Context
src/hooks/use-cursor.ts useCursor() hook — stores position in ref during render, propagates via useInsertionEffect during commit
examples/cursor-ime/cursor-ime.tsx Korean IME input example

Modified (5):

File Change
src/log-update.ts Cursor position tracking, render-then-move, cursor-only path, cursorDirty flag, isCursorDirty() accessor
src/components/App.tsx Provide CursorContext, remove redundant cliCursor.hide
src/ink.tsx Forward setCursorPosition; call throttledLog when cursor is dirty even if output unchanged
src/index.ts Export useCursor, CursorPosition
src/reconciler.ts Concurrent mode scheduler integration

Tests (6):

File Coverage
test/log-update.tsx 10 cursor unit tests
test/cursor.tsx 7 IME integration tests (incl. unmount cleanup, concurrent Suspense leak)
test/write-synchronized.tsx 4 shouldSynchronize + constant tests
test/render.tsx Strip BSU/ESU in clear output assertion + BSU/ESU throttle integration test
test/cursor-helpers.tsx 17 pure function unit tests

Test plan

  • npx xo passes (0 errors)
  • npx tsc --noEmit passes
  • npx ava — same 6 pre-existing failures as master, no new failures
  • Manual: npx tsx examples/cursor-ime/cursor-ime.tsx — Korean IME cursor tracks input
  • Manual: Verify in tmux

References

🤖 Generated with Claude Code

juniqlim and others added 7 commits February 6, 2026 18:08
Enable CJK IME composition characters to appear at the correct cursor
position by exposing a useCursor() hook and wrapping terminal output
with Synchronized Update Mode (CSI ? 2026).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When cursor was positioned mid-screen via setCursorPosition,
clear() would erase from the cursor position instead of the bottom,
leaving stale lines on screen. Now clear() returns the cursor to
the bottom before erasing, and resets cursor state afterward.

Also fix misleading test name for cursorUp assertion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move 5 side-effect-free cursor helpers (buildCursorSuffix, buildReturnToBottom,
cursorPositionChanged, buildCursorOnlySequence, buildReturnToBottomPrefix) from
log-update.ts into a dedicated cursor-helpers.ts module with 17 unit tests.
This eliminates duplicated logic between createStandard and createIncremental.

Also fix cursor-ime example import path (../src → ../../src).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
juniqlim and others added 8 commits February 6, 2026 20:09
Move Synchronized Update wrapping from writeSynchronized() utility
to onRender/writeToStdout/writeToStderr entry/exit points.
This reduces the number of BSU/ESU pairs from N writes to 1 per cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move Synchronized Update wrapping into throttledLog callback so that
trailing throttle invocations are properly wrapped with BSU/ESU at
actual write time, preventing tmux/Zellij from reading intermediate
cursor positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add cursorDirty flag so log-update only uses cursor position when
setCursorPosition was actively called since the last render. This
prevents stale cursor positions from persisting after a component
using useCursor unmounts.

Also add useEffect cleanup in useCursor as a secondary safety net,
update write-synchronized tests to match current API, and add
BSU/ESU throttle integration test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move cursor position propagation from render phase to commit phase
using useInsertionEffect, which runs before resetAfterCommit and
does not execute for abandoned renders. Also expose isCursorDirty()
from log-update so onRender can trigger throttledLog even when
rendered output is unchanged but cursor position changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Terminal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mports

- Rename BSU/ESU constants to bsu/esu (naming-convention)
- Merge duplicate React imports in cursor.tsx
- Capitalize comments starting with lowercase
- Fix prettier formatting and use .includes()/.has() over .some()
- Add eslint-disable for unavoidable unsafe return/call in test stubs
- Fix sync() to write cursor suffix when cursor is dirty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sindresorhus

Copy link
Copy Markdown
Collaborator

Can you fix the merge conflicts?

@sindresorhus

Copy link
Copy Markdown
Collaborator
  1. [P1] Cursor visibility leak in fullscreen sync() path

    • src/ink.tsx:335 calls this.log.sync(output) on clear-terminal/fullscreen renders.
    • src/log-update.ts:114 and src/log-update.ts:283 only write cursor escapes in sync() when activeCursor exists, but do not hide cursor when cursor was previously shown and is now undefined.
    • Result: if useCursor was active, then removed, and render goes through fullscreen clear path, cursor can remain visible unexpectedly.
  2. [P1] New test is CI-hostile / incorrect expectation under CI

    • test/write-synchronized.tsx:22 expects shouldSynchronize() to be true for TTY.
    • But src/write-synchronized.ts:8 explicitly returns false in CI (!isInCi).
    • Repro: CI=true npx ava test/write-synchronized.tsx fails at test/write-synchronized.tsx:24.
  3. [P3] Redundant BSU/ESU wrappers on no-op rerenders (optimization, not correctness)

    • src/hooks/use-cursor.ts:29 marks cursor dirty on every commit.
    • src/ink.tsx:369 then triggers throttled write path, and src/ink.tsx:119 emits BSU/ESU even when this.log(output) produces no content write.
    • This is extra control-sequence churn; I would treat it as a perf/cleanliness issue, not a functional bug.

@juniqlim

juniqlim commented Feb 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all review items and included merge-conflict resolution.

  • Merged latest origin/master into this branch and resolved conflicts: 684a8e4
  • [P1] Fixed cursor visibility leak in fullscreen sync() path: 164b773
  • [P1] Fixed CI-hostile shouldSynchronize test expectation: a89a325
  • [P3] Reduced redundant BSU/ESU on no-op cursor rerenders: c4a2724

Validation:

  • npm test passed (477 passed, 3 known failures, 1 todo)

@sindresorhus

Copy link
Copy Markdown
Collaborator
  1. [P1] npm test still fails on this branch due new lint/prettier errors in changed files.
    test/log-update.tsx:341, test/log-update.tsx:375, test/cursor.tsx:362, test/cursor.tsx:370, test/render.tsx:461, test/helpers/create-stdin.ts:16.

  2. [P3] New useCursor README snippet is not copy-paste runnable.
    readme.md:1971 example uses useState, Box, and Text without importing them, which will confuse users trying the snippet directly.

@juniqlim

juniqlim commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the catch. I rechecked the exact files/lines you listed on the latest PR head (06ac3b6a5b15d0e36f469441c613892a06411850):

  • test/log-update.tsx:341, test/log-update.tsx:375
  • test/cursor.tsx:362, test/cursor.tsx:370
  • test/render.tsx:461
  • test/helpers/create-stdin.ts:16

I ran:

npx xo test/log-update.tsx test/cursor.tsx test/render.tsx test/helpers/create-stdin.ts

Result: pass (no lint/prettier errors).

I also re-ran full checks on the same head (including in a clean clone), and they pass as well:

  • npm test -- --serial
  • 479 tests passed, 3 known failures, 1 todo (same known baseline)

Could you please re-run checks on the latest head and let me know if you still see a failure?

juniqlim and others added 4 commits February 8, 2026 10:30
Removed redundant line break in the documentation comment.
Moved string-width usage and example reference to the 'x' section for clarity.
@sindresorhus sindresorhus merged commit 165b861 into vadimdemedes:master Feb 8, 2026
1 check passed
@juniqlim

juniqlim commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and merge!

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.

2 participants