Skip to content

Conversation

@jedipunkz
Copy link

@jedipunkz jedipunkz commented Jan 10, 2026

Summary

This PR implements Synchronized Update Mode (CSI ? 2026) and cursor positioning APIs to fix IME (Input Method Editor) rendering issues affecting users of Chinese, Japanese, and Korean languages in terminal multiplexers.

Problem

When using Ink applications with IME input in terminal multiplexers like Zellij and tmux, users experience:

  • Misaligned composition characters: IME preedit characters appear offset to the right
  • Input latency: 200-500ms delay during text composition
  • Duplicate IME candidate windows
  • Incorrect cursor positioning

Root Cause

React Ink does not send Synchronized Update Mode escape sequences, which prevents terminal multiplexers from detecting proper frame boundaries. This causes inaccurate cursor position reporting to the terminal emulator, resulting in IME candidate windows appearing at incorrect positions.

Solution

1. Synchronized Update Mode with TTY Guard

Wraps terminal output with Synchronized Update Mode escape sequences:

  • \^[[?2026h - Begin Synchronized Update (BSU)
  • \^[[?2026l - End Synchronized Update (ESU)

TTY Guard: Only emits escape sequences when:

  • stream.isTTY === true (real TTY, not piped output)
  • Not running in CI environment

This addresses the reviewer feedback about CI/non-TTY environments.

2. useCursor Hook

New hook for controlling cursor position after rendering, essential for IME support:

const { setCursorPosition } = useCursor();

setCursorPosition({
  x: cursorColumn,     // Column position (0-based)
  y: lineFromBottom,   // Row from bottom (1 = last line)
  visible: true        // Show cursor for IME
});

Modified Files

src/log-update.ts

  • Added shouldSynchronize() helper with TTY/CI guard
  • Added sequences to render() in both createStandard() and createIncremental() functions
  • Added cursor position tracking for IME support

src/ink.tsx

  • Added TTY guard for synchronized update
  • Added sequences to all output locations in onRender() method
  • Added handleCursorPositionChange() for cursor position updates

src/components/App.tsx

  • Integrated with upstream's function component refactoring
  • Added CursorContext provider for useCursor hook

src/hooks/use-cursor.ts & src/components/CursorContext.tsx

  • New useCursor hook implementation

Benefits

  • Fixes IME candidate window positioning
  • Eliminates input latency (200-500ms to near zero)
  • Reduces screen flickering
  • Proper operation in terminal multiplexers (Zellij, tmux)
  • Backward compatible (ignored by legacy terminals)
  • No escape sequences in CI or non-TTY environments

Supported Terminals

Modern terminals that support Synchronized Update Mode:

  • iTerm2, Alacritty, Kitty, WezTerm, Ghostty, Terminal.app
  • tmux, Zellij

Legacy terminals simply ignore these sequences, maintaining backward compatibility.

Testing

425 tests passed
3 known failures
1 test todo

Tested environments:

  • macOS: WezTerm, Alacritty, Ghostty, Terminal.app
  • Windows (WSL): Alacritty
  • All with tmux and Zellij

Usage Example

import React, { useState, useLayoutEffect } from "react";
import stringWidth from "string-width";
import { render, useInput, useApp, useCursor, Box, Text } from "ink";

function ImeInputDemo() {
  const { exit } = useApp();
  const { setCursorPosition } = useCursor();
  const [inputText, setInputText] = useState("");

  useLayoutEffect(() => {
    const prefixWidth = 5;
    const textWidth = stringWidth(inputText);
    setCursorPosition({
      x: prefixWidth + textWidth,
      y: 2,
      visible: true,
    });
    return () => setCursorPosition(undefined);
  }, [inputText, setCursorPosition]);

  useInput((input, key) => {
    if (key.escape || (key.ctrl && input === "c")) {
      exit();
      return;
    }
    if (key.backspace || key.delete) {
      setInputText((prev) => prev.slice(0, -1));
      return;
    }
    if (input && !key.ctrl && !key.meta) {
      setInputText((prev) => prev + input);
    }
  });

  return (
    <Box borderStyle="round" borderColor="yellow" paddingX={1}>
      <Text color="yellow">{"> "}</Text>
      <Text>{inputText}</Text>
    </Box>
  );
}

render(<ImeInputDemo />);

Future Work

After this PR is merged, I plan to create a PR to ink-text-input to integrate these APIs, providing automatic IME support without manual cursor calculation.

References

@sindresorhus
Copy link
Collaborator

This is a good idea. Thanks for working on it.

@sindresorhus
Copy link
Collaborator

CI is failing.

@sindresorhus
Copy link
Collaborator

AI review:


1) It prints escape sequences in CI and other non-TTY contexts

Ink currently has explicit “CI mode” branches to avoid ANSI tricks. This PR adds ESC[?2026h/l even in those CI branches (and also in debug). Example: in onRender() it wraps staticOutput in the isInCi path.

That means CI logs/snapshots (and any redirected output) can now contain raw \u001B[?2026h / \u001B[?2026l noise, breaking golden output and making logs harder to read. Same problem in writeToStdout/writeToStderr: it emits these sequences even when isInCi is true.

Fix: gate synchronized-update emission behind stream.isTTY === true (and probably TERM !== "dumb"), and almost certainly disable in CI regardless.

2) It writes synchronized-update sequences to stderr unconditionally

writeToStderr() now wraps stderr writes with ESC[?2026h/l.

If stderr is redirected to a file (common), those escape sequences now pollute that file. Even if stderr is a TTY, multiplexers and terminals typically care about the display stream; bracketing stderr independently can create confusing “frames” interleaved with stdout frames.

Fix: only use synchronized update on stderr if stderr.isTTY and it is effectively the same terminal as stdout (same fd), otherwise do not emit it on stderr.

3) “Frame boundaries” are not actually frame boundaries (split across multiple mini-writes)

The PR tends to do:

  • BSU
  • write one chunk
  • ESU
  • later BSU
  • write next chunk
  • ESU

Examples: screen-reader mode wraps erasing/static output separately from the subsequent wrapped main output.
In normal mode it wraps staticOutput write separately, while this.log(output) (log-update) will independently wrap its own rendering.

If the whole point is “don’t let the multiplexer observe intermediate cursor states during a render”, splitting the render into multiple synchronized regions weakens the guarantee. It may still improve things, but it’s not the clean model the PR description implies.

Fix: treat one Ink render as one synchronized update, and bracket the entire render’s output (including clears/erases and static+dynamic output) with a single BSU/ESU pair.

4) Duplicate responsibility: Ink and log-update both inject BSU/ESU

log-update.ts is modified to always inject BSU/ESU around its writes.
Ink also injects BSU/ESU around direct stdout.write() calls.

This increases the chance of:

  • inconsistent boundaries (some writes bracketing, some not),
  • interleaving issues (more separate write() calls),
  • future regressions (someone adds a new write path and forgets the wrapping).

Fix: pick one layer to own synchronized update:

  • Preferred: Ink owns “frame” bracketing once per render; log-update stays dumb.
  • Alternative: log-update owns it, and Ink never writes raw output without going through a “synchronized write” helper.

Smaller but real issues

  • The PR emits BSU/ESU via three separate write() calls everywhere. That increases interleaving risk with other output (user process.stdout.write, uncaught logs, etc).
    Better: one write(BSU + payload + ESU) where possible.

  • String literals are repeated everywhere ('\u001B[?2026h', '\u001B[?2026l'). That’s an easy way to miss a site later.

What I’d change before merging

  1. Add a single helper like writeSynchronized(stream, text) that:
  • no-ops unless stream.isTTY && !isInCi && TERM !== "dumb" (or similar),
  1. Make Ink bracket one render with one BSU/ESU, not per sub-write.

  2. Remove BSU/ESU from log-update.ts if Ink owns the bracketing (or vice versa). Keeping both is asking for edge-case bugs.

- Strip synchronized update control characters in test stdout helper
- Remove async from ErrorBoundary render method in tests to fix React 19 compatibility
- Fix log-update tests to strip control characters
- Enable colors in wrap-ansi test to ensure correct trimming behavior
@jedipunkz
Copy link
Author

This commit: 828f891 may be an improvement. Can you give it a try?

@sindresorhus
Copy link
Collaborator

Yes, it's an improvement, but it still requires more work for it to be in a mergable shape.

- Update createStdout helper to support skipping empty outputs (control characters only)
- Use skipEmpty option in renderToString to handle Static component output behavior
- Strip control characters in measure-element tests
- Fix lint errors
@jedipunkz
Copy link
Author

I commited 5459615 . I hope ci doesn't fail :)

- Handle exit code 13 from Node.js top-level await warnings
- Filter Node.js warning messages from test output assertions
- Strip Synchronized Update Mode sequences in measure-element test
- Use flexible assertion for dim+bold ANSI code order in text test
- Update focus tests to use notThrows() instead of empty output check
@jedipunkz
Copy link
Author

Please wait a moment, I'll test it with a small sample app and let you know.

@jedipunkz
Copy link
Author

jedipunkz commented Jan 17, 2026

render1768637157458

Overview

I tested example code (examples/ime-input/index.ts) with alacritty (mac and windows wsl), wezterm(mac), ghostty(mac), terminal(mac) & zellij, tmux.

This PR adds IME (Input Method Editor) support.

Tests

npm test
...
  367 tests passed
  3 known failures
  1 test todo

Key Features

  1. Synchronized Update Mode - Wraps terminal output with \u001B[?2026h and \u001B[?2026l escape sequences to prevent terminal multiplexers from reading intermediate cursor positions during rendering.

  2. useCursor hook - Provides setCursorPosition({x, y, visible}) API to control cursor position after rendering.

  3. visible property - When set to true, shows the terminal cursor at the specified position, which is required for IME candidate windows to appear correctly (especially in Zellij).

Usage Example

import React, { useState, useLayoutEffect } from "react";
import stringWidth from "string-width";
import { render, useInput, useApp, useCursor, Box, Text } from "ink";

function ImeInputDemo() {
  const { exit } = useApp();
  const { setCursorPosition } = useCursor();
  const [inputText, setInputText] = useState("");

  // Set cursor position for IME candidate window
  useLayoutEffect(() => {
    // Calculate cursor position based on your layout
    // x: column position (0-based)
    // y: row from bottom (1 = last line, 2 = second to last, etc.)
    const prefixWidth = 5; // Adjust based on your layout (borders, padding, prompt)
    const textWidth = stringWidth(inputText);
    const cursorX = prefixWidth + textWidth;
    const cursorY = 2; // Line where input text is displayed

    setCursorPosition({
      x: cursorX,
      y: cursorY,
      visible: true, // Show cursor for IME support
    });

    return () => {
      setCursorPosition(undefined);
    };
  }, [inputText, setCursorPosition]);

  useInput((input, key) => {
    if (key.escape || (key.ctrl && input === "c")) {
      exit();
      return;
    }
    if (key.return) {
      // Handle submit
      setInputText("");
      return;
    }
    if (key.backspace || key.delete) {
      setInputText((prev) => prev.slice(0, -1));
      return;
    }
    if (input && !key.ctrl && !key.meta) {
      setInputText((prev) => prev + input);
    }
  });

  return (
    <Box flexDirection="column" paddingX={1}>
      <Box borderStyle="round" borderColor="yellow" paddingX={1}>
        <Text color="yellow">{"> "}</Text>
        <Text>{inputText}</Text>
      </Box>
    </Box>
  );
}

render(<ImeInputDemo />);

Important Notes

Manual cursor position calculation is required. The current implementation provides low-level APIs for IME support. Application developers need to:

  1. Calculate the cursor X position based on layout (borders, padding, prompt text, input text width)
  2. Calculate the cursor Y position based on which line the input is on (counted from bottom)
  3. Use useLayoutEffect to set cursor position synchronously after render

This approach is flexible but requires careful calculation based on your specific layout.

Future Work: ink-text-input Integration

To provide a better developer experience, the ink-text-input package should be updated to use these APIs internally. This would allow developers to simply use <TextInput /> component with automatic IME support, without manual cursor position calculation.

I will create A PR to ink-text-input after this PR is merged.

Tested Environments

This implementation has been tested on the following environments:

macOS

  • WezTerm
  • Alacritty
  • Ghostty
  • Tmux and Zellij on each terminal emulators

Windows (WSL)

  • Alacritty
  • Tmux and Zellij on Alacritty

All environments confirmed working with Japanese IME input.

@juniqlim
Copy link
Contributor

juniqlim commented Feb 6, 2026

I tackled the same problem with a different approach in #866 — BSU/ESU is emitted once per render cycle (not per individual write) with TTY/CI guard, and cursor logic is split into pure helper functions. Happy to collaborate on whichever direction is preferred.

- Merged upstream/master changes including:
  - App component refactored from class to function component
  - ErrorBoundary component for error handling
  - Concurrent mode support with Suspense
  - Various test improvements

- Added TTY guard for synchronized update mode (BSU/ESU):
  - Only emit escape sequences on TTY streams
  - Disabled in CI environments
  - Addresses reviewer feedback on PR vadimdemedes#846

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@jedipunkz
Copy link
Author

@juniqlim Thanks for your work on #866! I've just updated this PR with some improvements based on the reviewer feedback:

  1. Added TTY/CI guard - Now only emits BSU/ESU when stream.isTTY === true and not in CI environment, using shouldSynchronize() helper function
  2. Merged upstream/master - Integrated with the App component refactoring (class → function component) and ErrorBoundary changes

Your approach of emitting BSU/ESU once per render cycle is cleaner than per-write. I'm happy to collaborate - we could potentially merge the best parts of both PRs. Would you be open to reviewing this updated implementation, or should we consolidate efforts into one PR?

@jedipunkz
Copy link
Author

jedipunkz commented Feb 8, 2026

@sindresorhus I've addressed the feedback.

  1. Added TTY/CI guard - BSU/ESU sequences are now only emitted when stream.isTTY === true and not in CI environment. This prevents escape sequence pollution in CI logs and non-TTY contexts.

  2. Merged upstream/master - Integrated with the latest changes including the App component refactoring and ErrorBoundary.

  3. All tests passing - 425 tests passed with the existing 3 known failures and 1 todo.

The main remaining difference from the ideal approach (as noted in your review) is that BSU/ESU is still emitted per-write rather than once per render cycle. @juniqlim's #866 takes that approach - happy to collaborate on consolidating if preferred.

Please re-run CI when you have a chance. Thanks!

@sindresorhus
Copy link
Collaborator

@juniqlim's #866 takes that approach - happy to collaborate on consolidating if preferred.

I think that approach is better.

@sindresorhus
Copy link
Collaborator

Going to close this, but I'm happy to consider follow up improvements to #866

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.

3 participants