Skip to content

Conversation

@hughescr
Copy link

@hughescr hughescr commented Oct 4, 2025

Problem

When multiple keypresses arrive in the same event loop tick (common in automation/testing scenarios), Ink's handleReadable() method reads all input as a single chunk but only processes the first keypress, swallowing the rest.

Example scenario:
Imagine you have a simple navigation menu with 5 items and want to automate it using tmux:

# Running an Ink app in tmux
tmux send-keys -t my-session "Down" "Down" "Down" "Enter"

When these keys arrive rapidly:

  • Terminal receives: \x1b[B\x1b[B\x1b[B\r (3 Down arrows + Enter)
  • Ink processes: Only the first \x1b[B
  • Result: Cursor still on item 1 (moved down only once), Enter keypress is lost
  • Expected: Cursor on item 3, selection confirmed

This makes it impossible to reliably automate Ink-based UIs using standard automation tools like:

  • tmux send-keys
  • Expect scripts
  • Testing frameworks
  • LLM-controlled interfaces

Solution

This PR adds an opt-in splitRapidInput option to the render() function that:

  1. Parses input chunks into individual keypresses using a new parseKeypresses() function
  2. Preserves ANSI escape sequences (arrow keys, function keys, etc.) as atomic units
  3. Maintains backward compatibility - defaults to false, existing behavior unchanged

Key Changes

  • Added parseKeypresses() function in App.tsx that correctly handles:

    • Regular characters
    • CSI sequences (Control Sequence Introducer): ESC [ <params> <letter>
    • SS3 sequences (Single Shift 3): ESC O <char>
  • Modified handleReadable() to conditionally split input when option is enabled

  • Added splitRapidInput property to Options type with JSDoc documentation

  • Updated README with usage examples and explanation

Usage

import {render} from 'ink';

// Enable per-keypress processing for automation/testing
render(<App />, {
  splitRapidInput: true
});

Testing

Verified with automation using tmux send-keys:

# Create tmux session with Ink app
tmux new-session -d -s test-ink './my-ink-app'

# Send rapid keypresses - all are now processed correctly
tmux send-keys -t test-ink "Down" "Down" "Down" "Down" "Down"
# Result: Cursor moves exactly 5 positions

# Complex sequence also works
tmux send-keys -t test-ink "Down" "Down" "Enter"
# Result: Moves down 2, then selects correct item

Use Cases

  • TUI Testing: Write automated tests for Ink applications
  • CI/CD Integration: Run Ink CLIs in automated pipelines with scripted input
  • LLM/AI Control: Allow AI agents to control terminal interfaces programmatically
  • Accessibility: Enable screen reader automation and accessibility testing
  • Documentation: Create reproducible demos and tutorials

Backward Compatibility

  • Feature is opt-in (defaults to false)
  • No changes to existing behavior unless explicitly enabled
  • No breaking changes to API
  • All existing tests pass unchanged

This commit adds an opt-in `splitRapidInput` option to Ink's render()
function to properly handle rapid keypresses from automation tools and
LLM-controlled interfaces.

Problem:
When multiple keypresses arrive in the same event loop tick (common in
automation/testing), Ink's handleReadable() reads all input as a single
chunk but only processes the first keypress, swallowing the rest.

For example, sending 5 Down arrows rapidly would result in only 1
navigation instead of 5.

Solution:
- Added parseKeypresses() function to parse input chunks into individual
  keypresses while preserving ANSI escape sequences (arrow keys, function
  keys) as atomic units
- Modified handleReadable() to conditionally split input when the option
  is enabled
- Added splitRapidInput property to Options type with JSDoc documentation
- Updated README with usage examples and explanation

The feature is opt-in (defaults to false) to maintain backward
compatibility. Existing behavior is unchanged unless users explicitly
enable the option.

Use case: Enables reliable TUI automation for testing and LLM-based
control of terminal interfaces.
@sindresorhus
Copy link
Collaborator

What problems would it cause to make this the default behavior? Seems like a good change, just not clear how it's a breaking change.

hughescr added a commit to hughescr/mcp-proxy-processor that referenced this pull request Oct 5, 2025
Enable Ink's splitRapidInput option to allow per-keypress processing
of rapid input streams from automation tools (e.g., tmux send-keys).
This ensures each keypress is handled individually rather than being
batched, which is essential for reliable automated testing of the
admin TUI.

Depends on: vadimdemedes/ink#782
@hughescr
Copy link
Author

hughescr commented Oct 5, 2025

I think that pasting blocks of text can get broken by this. It's good for "navigation" type characters like escape sequences and enter/return, etc but it's often not what you want for pasting text into fields. In my testing, pasting in iTerm2 seems to often have only the last character of the paste show up, but send-keys through tmux works. When I'm using this flag and want to type to the thing in iTerm, I just use "paste special -> paste slowly" and that works.

It definitely breaks existing functionality though, or can.

- Replace hex escapes with Unicode escapes (\x1b → \u001B)
- Replace substring() with slice() for better performance
- Use operator assignment (i += 3 instead of i = i + 3)

Addresses unicorn/escape-case, unicorn/no-hex-escape, unicorn/prefer-string-slice, and operator-assignment linting violations.
@sindresorhus
Copy link
Collaborator

  1. Do not emit one event per charactr. Your parser splits plain text into single-char events (keypresses.push(chunk[i])). That kills paste performance and breaks Unicode surrogate pairs. Coalesce contiguous non-escape text into one string (and thereby keep surrogate pairs intact).

  2. Fix CSI final-byte detection
    You stop on [a-zA-Z~]. CSI final bytes are [\x40-\x7E] (includes @, `, ^, _, {, |, }), otherwise you’ll mis-parse sequences like \x1b[@. Update the regex and the in-code comment.

  3. Make the behavior default; drop the option
    This is a bug fix, not a feature: multiple ANSI sequences in a single chunk must be handled individally. Adding splitRapidInput papers over the bug and forces users to discover an option.

  4. Consistency: use the existing escape constant
    You already have const escape = '\u001B'. Use it in the parser instead of hardcoding '\u001B'. Keeps style consistent with tab, shiftTab.

  5. Add tests
    Cover:
    \x1b[B\x1b[B\x1b[B → three events
    hello world → one event
    • Mixed: \x1b[Bhello\x1b[B → three events
    • CSI with non-letter finals: e.g. \x1b[@, \x1b[200~
    • High-Unicode paste (emoji) stays intact (no surrogate splitting)

@sindresorhus
Copy link
Collaborator

Make this change in handleReadable:

handleReadable = (): void => {
		let chunk;
		while ((chunk = this.props.stdin.read() as string | null) !== null) {
				const keypresses = parseKeypresses(chunk);
				for (const keypress of keypresses) {
						this.handleInput(keypress);
						this.internal_eventEmitter.emit('input', keypress);
				}
		}
};

…hink this is how upstream writes these tests from the other existing tests
  - buffer contiguous plain text so pastes (including surrogate pairs) stay intact
  - consume full CSI/SS3 sequences with [\u0040-\u007E] finals, reusing the shared escape constant
  - recurse through nested ESC runs so meta combos (e.g. ESC ESC [A) emit as single keypresses
@hughescr
Copy link
Author

hughescr commented Oct 8, 2025

Updates per review feedback:

  • Rewrote parseKeypresses so contiguous plain text is buffered and emitted as a single event. Pastes (including surrogate pairs like emoji) now stay intact instead of being split per character.
  • Escape handling now consumes full CSI/SS3 runs using the official final-byte range [\u0040-\u007E], and everything funnels through the shared escape constant.
  • Added a small readEscapeSequence helper that walks nested ESC runs; meta combos such as \u001B\u001B[A will arrive as one keypress instead of ESC + leftover text.
  • handleReadable always routes a chunk through the parser, so the previous splitRapidInput toggle is gone—correct behavior is the default.
  • Added coverage with new AVA fixtures for rapid CSI sequences, mixed CSI/text input, non-letter finals, and high-Unicode pastes. I think I did those the way the project intends but I was just copying other examples so lmk if I should have done it another way - not super familiar with ava :)

What's not implemented here but might want thought: the parser doesn’t try to bundle OSC/DCS-style sequences (ESC ] …, ESC P …) into a single event. Those originate from terminals rather than keyboard input, so it matches current Ink behavior, but we’d need more changes if we ever want to treat them atomically.

Otherwise this should address the paste/meta regressions called out in the review.

@sindresorhus
Copy link
Collaborator

You need to handle chunk-boundary splits (incomplete ESC/CSI/SS3). if a chunk ends mid-sequence (e.g. \u001B, \u001B[, \u001B[1;5), parseKeypresses emits a partial token instead of buffering it to join with the next read. Same for Alt+<char> when \u001B is the last byte of a chunk. The current parser is stateless. Fix it by keeping a carry-over buffer between read() calls and only emitting complete tokens.

and some tests for this:

  • CSI split across chunks: "\u001B[" + "200~" (start bracketed paste) → do not emit until complete.
  • ESC at chunk end + letter next chunk: "\u001B" + "b" → one Meta-b keypress, not two events.

@hughescr
Copy link
Author

hughescr commented Oct 8, 2025

Good catch -- will add.

@hughescr
Copy link
Author

hughescr commented Oct 8, 2025

Hmm, trouble with that as I'm thinking it through is what do you do with the carryover buffer if the end of the sequence never comes? Like you're mid-sequence and no more events come in...

@hughescr
Copy link
Author

hughescr commented Oct 8, 2025

In practice, people don't type escape sequences manually - I would expect they pretty much always come in as one event to be split rather than multiple events that need the carry-over buffering?

@hughescr
Copy link
Author

hughescr commented Oct 8, 2025

The only alternative I can think of is introducing some timeout that's refreshed with every event and flushes the buffer when it expires.

@sindresorhus
Copy link
Collaborator

sindresorhus commented Oct 9, 2025

Terminals send raw bytes, so a key sequence can be split across chunks.

Timeout is not feasible.

Only one case needs special handling: a lone ESC. It’s either the Escape key or the Alt/Meta prefix for the next char. When you see a single ESC, schedule a next-tick decision with setImmediate: if no more bytes arrive before that tick, emit ESC; if bytes do arrive, cancel the setImmediate and parse the full sequence.

For any other partial (ESC[, ESC[1;5, ESCO), don’t guess. Keep it in a carry buffer and emit nothing until it completes. If the stream ends, drop the partial. Add a small cap (≈4 KB) to prevent runaway buffers.

bracketed paste: terminals wrap pastes with \x1b[200~\x1b[201~. While inside those markers, treat everything as plain text, don’t parse escapes, so pasted content is never misinterpreted.

@hughescr
Copy link
Author

hughescr commented Oct 9, 2025

setImmediate makes sense. I was definitely leary of adding an actual timer - I didn't even think of setImmediate.

  - make the chunk reader stateful so it buffers incomplete CSI/SS3
    sequences, defers lone ESC decisions with setImmediate, honours
    bracketed paste blocks, and discards leftovers once the stream
    ends
  - reset parser state whenever raw mode listeners are removed
  - expand the use-input fixture to verify split CSI/meta handling,
    bracketed paste payload integrity, delayed meta fallbacks, and
    dropping partial escapes when the stream closes
  - add AVA tests for delayed meta, bracketed paste, and the partial
    escape drop path
@hughescr
Copy link
Author

hughescr commented Oct 9, 2025

Still some issues here I think with various cases that might get split across 2 input chunks. Let me add some more test cases and think through the logic a bit more.

@hughescr
Copy link
Author

Several of my test cases weren't working reliably with a single setImmediate because, I think, the next chunk could arrive on the next tick but only after our setImmediate callback fires. So I wait two ticks instead with a double setImmediate... that seems to work for all the test cases.

Also added comprehensive useInput regression coverage for complex escape parsing and Unicode handling. The fixtures now validate OSC titles, OSC hyperlinks, DCS payloads, escape-depth boundaries and overflows, plus extended Unicode scenarios (flag emoji, variation selectors, keycaps, and isolated surrogate sequencing). The AVA suite has matching tests to guard against future regressions.

@hughescr
Copy link
Author

Any additional feedback?

@sindresorhus
Copy link
Collaborator

  • Emoji grapheme handling is incomplet. You don’t include Unicode variation selectors (VS15/VS16 U+FE0E/U+FE0F and U+E0100–U+E01EF) in your manual grapheme fallback. That will split many emoji-with-text/emoji-presentation sequences and some flag/ideograph cases. Add VS ranges to the “continuation” checks in getNextGrapheme and shouldHoldGraphemeForContinuation, and add tests for e.g. "✈️", "♥️", multi-codepoint flags, and emoji + VS16 across chunk boundaries

  • You currently emit ESC [ 200 ~ and ESC [ 201 ~ to userland in addition to the pasted text. When ESC [ 200 ~ arrives, buffer until ESC [ 201 ~ (handle chunk splits). Deliver one callback with the exact pasted bytes (no normalization), plus isPaste: true. Do not pass the wrapper to userland. Needs a test.

  • Move the parser to a separate file.

Comment on lines 7 to 27
const arrowCountRef = React.useRef(0);
const textEventCountRef = React.useRef(0);
const mixedStageRef = React.useRef(0);
const csiStageRef = React.useRef(0);
const emojiCountRef = React.useRef(0);
const emojiFamilyCountRef = React.useRef(0);
const chunkedCsiCountRef = React.useRef(0);
const chunkedMetaCountRef = React.useRef(0);
const bracketedStageRef = React.useRef(0);
const emptyBracketedStageRef = React.useRef(0);
const bracketedContent = 'hello \u001B[Bworld';
const chunkedMetaDelayedStageRef = React.useRef(0);
const partialEscapeHandledRef = React.useRef(false);
const invalidCsiStageRef = React.useRef(0);
const oscTitleStageRef = React.useRef(0);
const oscHyperlinkStageRef = React.useRef(0);
const dcsStageRef = React.useRef(0);
const escapeDepthStageRef = React.useRef(0);
const escapeDepthExceededStageRef = React.useRef(0);
const surrogateSeenRef = React.useRef(false);
const surrogateTailSeenRef = React.useRef(false);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use so many refs. Use a single typed probe with counters, stages, and flags.

Something like this (untested):

import React from "react";

type CounterKey =
	| "arrowCount"
	| "textEventCount"
	| "emojiCount"
	| "emojiFamilyCount"
	| "chunkedCsiCount"
	| "chunkedMetaCount";

type StageKey =
	| "mixedStage"
	| "csiStage"
	| "chunkedMetaDelayedStage"
	| "bracketedStage"
	| "emptyBracketedStage"
	| "invalidCsiStage"
	| "oscTitleStage"
	| "oscHyperlinkStage"
	| "dcsStage"
	| "escapeDepthStage"
	| "escapeDepthExceededStage";

type FlagKey =
	| "partialEscapeHandled"
	| "surrogateSeen"
	| "surrogateTailSeen";

type Counters = Record<CounterKey, number>;
type Stages = Record<StageKey, number>;
type Flags = Record<FlagKey, boolean>;

function makeZeroed<T extends string>(keys: readonly T[]): Record<T, number> {
	const out = Object.create(null) as Record<T, number>;
	for (const key of keys) {
		out[key] = 0;
	}
	return out;
}

function makeFalsey<T extends string>(keys: readonly T[]): Record<T, boolean> {
	const out = Object.create(null) as Record<T, boolean>;
	for (const key of keys) {
		out[key] = false;
	}
	return out;
}

const COUNTER_KEYS = [
	"arrowCount",
	"textEventCount",
	"emojiCount",
	"emojiFamilyCount",
	"chunkedCsiCount",
        "chunkedMetaCount",
] as const;

const STAGE_KEYS = [
	"mixedStage",
	"csiStage",
	"chunkedMetaDelayedStage",
	"bracketedStage",
	"emptyBracketedStage",
	"invalidCsiStage",
	"oscTitleStage",
	"oscHyperlinkStage",
	"dcsStage",
	"escapeDepthStage",
	"escapeDepthExceededStage",
] as const;

const FLAG_KEYS = [
	"partialEscapeHandled",
	"surrogateSeen",
	"surrogateTailSeen",
] as const;

export type Probe = {
	readonly counters: Counters;
	readonly stages: Stages;
	readonly flags: Flags;
	inc: (key: CounterKey, by?: number) => void;
	nextStage: (key: StageKey) => void;
	setStage: (key: StageKey, value: number) => void;
	setFlag: (key: FlagKey, value?: boolean) => void;
	reset: () => void;
	snapshot: () => { counters: Counters; stages: Stages; flags: Flags };
};

export function createProbe(): Probe {
	const counters = makeZeroed(COUNTER_KEYS);
	const stages = makeZeroed(STAGE_KEYS);
	const flags = makeFalsey(FLAG_KEYS);

	const api: Probe = {
		counters,
		stages,
		flags,
		inc(key, by = 1) {
			counters[key] += by;
		},
		nextStage(key) {
			stages[key] += 1;
		},
		setStage(key, value) {
			stages[key] = value;
		},
		setFlag(key, value = true) {
			flags[key] = value;
		},
		reset() {
			for (const k of COUNTER_KEYS) counters[k] = 0;
			for (const k of STAGE_KEYS) stages[k] = 0;
			for (const k of FLAG_KEYS) flags[k] = false;
		},
		snapshot() {
			return {
				counters: { ...counters },
				stages: { ...stages },
				flags: { ...flags },
			};
		},
	};

	return api;
}

export function useProbe(): Probe {
	const ref = React.useRef<Probe>();
	if (!ref.current) {
		ref.current = createProbe();
	}
	return ref.current;
}

// Example constant kept as-is from your code:
export const bracketedContent = "hello \u001B[Bworld";

usage:

const probe = useProbe();

// before: arrowCountRef.current++
probe.inc("arrowCount");

// before: mixedStageRef.current++
probe.nextStage("mixedStage");

// before: partialEscapeHandledRef.current = true
probe.setFlag("partialEscapeHandled");

…id-input

* upstream/master:
  Add test for vadimdemedes#692
  Code style
  Add Nanocoder to "Who's Using Ink" section (vadimdemedes#790)
  Document missing `stderr` option in `render()` (vadimdemedes#797)
  Fix imports in examples (vadimdemedes#789)
  Update `@types/react-reconciler` dependency (vadimdemedes#794)
  Add failing test for dim+bold text (vadimdemedes#785)
  Add ink-picture to the list of useful components (vadimdemedes#783)
Replace 20 individual React refs with a single typed probe object that
groups state into counters, stages, and flags. This addresses PR feedback
requesting consolidation and improves maintainability.

Changes:
- Add Probe interface with inc/nextStage/setStage/setFlag/reset/snapshot API
- Add createProbe() factory and useProbe() React hook
- Remove 20 individual useRef declarations
- Convert ~50+ ref.current accesses to probe API calls

Type system uses readonly key arrays for compile-time safety.
All 48 useInput tests pass with identical behavior.
Address upstream reviewer feedback:

1. Extract parser to separate module
   - Move 316 lines from App.tsx to src/parser/
   - Create modular structure: types.ts, constants.ts, index.ts
   - Improve maintainability and testability

2. Fix bracketed paste behavior
   - Stop emitting ESC[200~ and ESC[201~ markers to userland
   - Add isPaste flag to Key type for paste detection
   - Thread metadata through parser → App → useInput hook
   - Prevents paste markers from leaking to application code

3. Add Unicode variation selector support
   - Support VS15 (U+FE0E), VS16 (U+FE0F) for emoji presentation
   - Support extended variation selectors (U+E0100–U+E01EF)
   - Fix combining marks handling across chunk boundaries
   - Add comprehensive continuation mark detection
   - Fixes emoji like ✈️, ♥️, and complex sequences

4. Add security protections
   - Add 1MB max buffer size for bracketed paste (prevent DoS)
   - Comprehensive Unicode edge case handling

5. Improve code quality
   - Add JSDoc documentation to parser API
   - Extract constants and improve naming
   - Add inline comments for Unicode ranges
   - Remove dead code

Tests updated:
   - Fix tests using [200~ marker (changed to [100~)
   - Add chunked variation selector tests
   - Update paste test expectations for new behavior
Replace hardcoded 3-second delays in test harness with dynamic ready
detection. Test fixtures now emit a unique UUID marker when Ink finishes
rendering, allowing tests to proceed immediately instead of waiting
arbitrarily. Reduces test suite runtime by 84% (~165s to ~27s).
@hughescr
Copy link
Author

I merged back in from master to get the latest changes then incorporated the suggestions. I also included a patch that changes the 3s wait on each test because waiting ~3 minutes each time to run just the ~60 "hooks" tests was kililng me :)

There's a regression on one test which I'm looking into now:

✘ [fail]: render › intercept console methods and display result above output

Previously, test fixtures write UUID tokens to stdout via writeReadySignal()
to signal readiness. Only test/hooks.tsx was filtering these tokens from
captured output, while test/render.tsx and test/helpers/run.ts were not,
causing test failures when tokens appeared in output assertions.

This change:
- Extracts filtering logic into a reusable ReadySignalFilter class
- Applies consistent filtering across all three test harnesses
- Fixes test failures caused by unfiltered ready signals in output
- Follows DRY principle by eliminating duplicate filtering code

The filter handles both \r\n and \n line endings and tracks ready state,
ensuring synchronization tokens are cleanly removed from test output.
@hughescr
Copy link
Author

Ok, addressed that issue with console method interception introduced by the ready-signal updates to testing. Should be all good now.

@hughescr
Copy link
Author

Any further feedback?

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