-
-
Notifications
You must be signed in to change notification settings - Fork 784
Add splitRapidInput option for automation and testing #782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
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.
|
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. |
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
|
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.
|
|
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
|
Updates per review feedback:
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. |
|
You need to handle chunk-boundary splits (incomplete ESC/CSI/SS3). if a chunk ends mid-sequence (e.g. and some tests for this:
|
|
Good catch -- will add. |
|
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... |
|
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? |
|
The only alternative I can think of is introducing some timeout that's refreshed with every event and flushes the buffer when it expires. |
|
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 For any other partial ( bracketed paste: terminals wrap pastes with |
|
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
|
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. |
|
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. |
|
Any additional feedback? |
|
test/fixtures/use-input.tsx
Outdated
| 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); |
There was a problem hiding this comment.
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).
|
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.
|
Ok, addressed that issue with console method interception introduced by the ready-signal updates to testing. Should be all good now. |
|
Any further feedback? |
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:
When these keys arrive rapidly:
\x1b[B\x1b[B\x1b[B\r(3 Down arrows + Enter)\x1b[BThis makes it impossible to reliably automate Ink-based UIs using standard automation tools like:
tmux send-keysSolution
This PR adds an opt-in
splitRapidInputoption to therender()function that:parseKeypresses()functionfalse, existing behavior unchangedKey Changes
Added
parseKeypresses()function inApp.tsxthat correctly handles:ESC [ <params> <letter>ESC O <char>Modified
handleReadable()to conditionally split input when option is enabledAdded
splitRapidInputproperty toOptionstype with JSDoc documentationUpdated README with usage examples and explanation
Usage
Testing
Verified with automation using
tmux send-keys:Use Cases
Backward Compatibility
false)