feat(runtime): own raw mode for the interactive lifetime by default (rawMode option) (#120)
Add a `rawMode?: 'always' | 'auto'` mount option (replacing the dead, unwired
`rawMode?: boolean`), defaulting to 'always'.
- 'always' (default): the App takes a lifetime raw-mode hold at mount (gated on
interactive + a TTY stdin), so raw mode is held for the whole run regardless of
which input composables are mounted. Keystrokes never echo into the rendered
frame on a no-input/streaming screen, and Ctrl+C is handled consistently on
every screen (e.g. it reaches an agent's "interrupt generation" handler instead
of becoming a kernel SIGINT). Because owning raw mode ref()s stdin, the app
stays alive until an explicit unmount()/exit() — it does NOT auto-exit when idle.
- 'auto': Ink's original lazy model — raw mode is acquired only while a useInput /
useFocus / usePaste is mounted, so a no-input screen returns to cooked mode and a
no-input app auto-exits. The opt-out for inline / render-and-exit tools.
This is a deliberate divergence from Ink (the cross-framework norm — Bubble Tea,
Textual, Ratatui, prompt_toolkit all own the terminal for the program lifetime;
Ink's hook-driven model is the outlier). Documented in
.agents/docs/ink-divergences.md.
Implementation: the App holds a `lifetimeFloor` ref via holdRawModeForLifetime();
input composables stack above it. The per-consumer clearInputState is re-based to
the floor so a buffered partial escape (e.g. a lone ESC at a screen transition)
can't bleed into the next consumer — cleared both when the last consumer releases
and when the first consumer re-acquires above the floor (covers same-tick swaps
AND a delayed idle→input transition). The data listener and raw toggle stay on
until teardown, where dispose() releases the floor ref (raw disabled + stdin
unref'd exactly once).
Tests: rawMode-lifecycle ('always' holds raw with no input; 'auto' stays cooked;
no mid-session oscillation; no partial-escape bleed across a swap or an idle gap);
PTY exit-rawmode-always (a no-input 'always' app stays alive and exits on Ctrl+C).
The 6 auto-exit PTY fixtures are pinned to 'auto' (they model render-and-exit).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,24 @@ export interface MountOptions {
|
||||
stderr?: NodeJS.WriteStream;
|
||||
debug?: boolean;
|
||||
exitOnCtrlC?: boolean;
|
||||
rawMode?: boolean;
|
||||
/**
|
||||
* Controls when the app holds the terminal's raw mode, which suppresses the
|
||||
* terminal's own echo and line-editing.
|
||||
*
|
||||
* - `'always'` (default): raw mode is enabled at mount and held for the whole
|
||||
* run, even when no input composable is mounted, so typed keys never echo
|
||||
* into the rendered frame and Ctrl+C behaves the same on every screen.
|
||||
* - `'auto'`: raw mode is enabled only while a `useInput`, `useFocus`, or
|
||||
* `usePaste` is mounted, and released when the last one unmounts — so a
|
||||
* screen with no input handler returns to the terminal's normal cooked mode
|
||||
* (native echo, line-editing, Ctrl+C/Ctrl+Z). This is Ink's original behavior.
|
||||
*
|
||||
* Has no effect when non-interactive or when stdin is not a TTY (raw mode is
|
||||
* unsupported there).
|
||||
*
|
||||
* @default 'always'
|
||||
*/
|
||||
rawMode?: "always" | "auto";
|
||||
/**
|
||||
* Override automatic interactive mode detection.
|
||||
*
|
||||
@@ -487,6 +504,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
liveInstances.set(stdout, app);
|
||||
mountedAsOwner = true;
|
||||
const exitOnCtrlC = options.exitOnCtrlC ?? true;
|
||||
// 'always' (default): own raw mode for the whole interactive run; 'auto':
|
||||
// Ink's lazy model where input composables acquire it on demand. See the
|
||||
// MountOptions.rawMode docs and .agents/docs/ink-divergences.md.
|
||||
const rawMode = options.rawMode ?? "always";
|
||||
const onRender = options.onRender;
|
||||
// Default maxFps to 30 to match Ink (ink.tsx: `options.maxFps ?? 30`), so
|
||||
// the render throttle engages by default — without this the animation
|
||||
@@ -656,6 +677,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
});
|
||||
mountedStdinController = stdinController;
|
||||
|
||||
// rawMode 'always': the App itself acquires a lifetime raw-mode ref now, so
|
||||
// the refcount floor never drops to 0 while the app runs — raw mode is held
|
||||
// continuously regardless of which input composables come and go, and there
|
||||
// is no cooked-mode oscillation between input and no-input screens. Gated on
|
||||
// interactive + isRawModeSupported (a TTY stdin): a non-interactive/piped run
|
||||
// must not seize raw mode. The matching release happens in the controller's
|
||||
// dispose() at teardown. (Diverges from Ink's lazy default — see
|
||||
// .agents/docs/ink-divergences.md.)
|
||||
if (rawMode === "always" && interactive && stdinController.isRawModeSupported) {
|
||||
stdinController.holdRawModeForLifetime();
|
||||
}
|
||||
|
||||
const kittyController = createKittyKeyboardController(stdin, stdout);
|
||||
kittyController.init(options.kittyKeyboard, interactive);
|
||||
mountedKittyController = kittyController;
|
||||
@@ -1316,6 +1349,10 @@ function createFocusController(): FocusContext {
|
||||
|
||||
interface StdinController extends StdinContext {
|
||||
dispose: () => void;
|
||||
// rawMode 'always': take a lifetime raw-mode hold (raw on + keep-alive + input
|
||||
// listener) that input composables stack on top of, with the per-consumer
|
||||
// input-state cleanup re-based to this floor.
|
||||
holdRawModeForLifetime: () => void;
|
||||
}
|
||||
|
||||
interface RawModeState {
|
||||
@@ -1467,6 +1504,12 @@ function createStdinController(
|
||||
emitter.on("input", focusInputListener);
|
||||
|
||||
let localRefs = 0;
|
||||
// 0 normally; 1 once the App takes a lifetime raw-mode hold (rawMode 'always').
|
||||
// The hold keeps raw mode + the data listener alive for the whole run, so the
|
||||
// per-consumer "clear input state" must fire when localRefs returns to THIS
|
||||
// floor (last input composable gone), not 0 — otherwise a buffered partial
|
||||
// escape would survive into the next composable.
|
||||
let lifetimeFloor = 0;
|
||||
|
||||
const controller: StdinController = {
|
||||
stdin,
|
||||
@@ -1526,9 +1569,30 @@ function createStdinController(
|
||||
// one app's unmount can't drop raw mode while another still needs it.
|
||||
stdin.on("data", handleData);
|
||||
}
|
||||
if (localRefs === lifetimeFloor) {
|
||||
// The FIRST input consumer joining above the App's lifetime floor (and
|
||||
// the very first acquire in 'auto', where the floor is 0). Under rawMode
|
||||
// 'always' the lifetime listener keeps parsing on no-input screens, so an
|
||||
// escape typed while idle leaves a buffered partial + pending-flush timer;
|
||||
// discard it here so it can't bleed into this consumer ~20ms later. (The
|
||||
// mirror clear on the last consumer's release handles a same-tick swap;
|
||||
// this handles a delayed idle→input transition.)
|
||||
inputParser.reset();
|
||||
clearPendingFlush();
|
||||
}
|
||||
state.refs++;
|
||||
localRefs++;
|
||||
},
|
||||
holdRawModeForLifetime() {
|
||||
// Same as acquireRawMode (raw on + ref + data listener), but marks the
|
||||
// resulting ref as the App's lifetime floor: input composables stack above
|
||||
// it, and releaseRawMode's input-state cleanup fires when the last consumer
|
||||
// returns localRefs to this floor (1) rather than 0. So raw mode and the
|
||||
// listener stay alive across no-input screens, but a buffered partial escape
|
||||
// is still cleared when an input composable unmounts — no bleed into the next.
|
||||
controller.acquireRawMode();
|
||||
lifetimeFloor = 1;
|
||||
},
|
||||
setBracketedPasteMode(enabled: boolean) {
|
||||
if (enabled) {
|
||||
if (bracketedPasteModeCount === 0 && appCtx.stdout.isTTY) {
|
||||
@@ -1549,17 +1613,21 @@ function createStdinController(
|
||||
const state = getRawModeState(stdin);
|
||||
state.refs = Math.max(0, state.refs - 1);
|
||||
localRefs = Math.max(0, localRefs - 1);
|
||||
if (localRefs === 0) {
|
||||
// PER-CONTROLLER: stop THIS controller owning input SYNCHRONOUSLY when its
|
||||
// own last useInput releases, matching Ink's clearInputState
|
||||
// (App.tsx:212-216,357): reset its parser, cancel its pending-escape flush,
|
||||
// and detach its data/readable listeners NOW — so a partial escape buffered
|
||||
// before a same-render useInput swap cannot leak into the replacement. (A
|
||||
// same-tick re-acquire re-attaches the listener with a fresh parser.)
|
||||
// Gated on localRefs, not the shared refcount: another app on the same
|
||||
// stdin keeps its own listener and parser intact.
|
||||
if (localRefs === lifetimeFloor) {
|
||||
// PER-CONSUMER: the last input composable on THIS controller released.
|
||||
// Clear pending parser state SYNCHRONOUSLY (Ink's clearInputState,
|
||||
// App.tsx:212-216,357): reset the parser and cancel the pending-escape
|
||||
// flush, so a partial escape (e.g. a lone ESC during a screen swap) can't
|
||||
// bleed into the next composable. Re-based to `lifetimeFloor`: under rawMode
|
||||
// 'always' the App holds a floor ref (and keeps the listener), so this fires
|
||||
// when consumers return to 1, not 0.
|
||||
inputParser.reset();
|
||||
clearPendingFlush();
|
||||
}
|
||||
if (localRefs === 0) {
|
||||
// CONTROLLER fully released (no App hold, no consumers): detach the input
|
||||
// listeners too. Gated on localRefs, not the shared refcount, so another
|
||||
// app on the same stdin keeps its own listener intact.
|
||||
stdin.off("readable", handleReadable);
|
||||
stdin.off("data", handleData);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user