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:
Yunfei He
2026-06-01 17:00:09 +08:00
committed by GitHub
parent 84d211ed10
commit 2372b6b03b
12 changed files with 328 additions and 23 deletions
+29
View File
@@ -80,6 +80,35 @@ deliberate. Divergences fall into a few kinds:
future reader does **not** "restore parity" by adding a `types` condition the toolchain
already satisfies. (The `cli` package needs no `types` at all — it has no public type surface.)
### Raw mode is owned for the interactive lifetime by default (`rawMode` option)
- **Ink:** raw mode is **lazy / reference-counted to input hooks** — `useInput` /
`useFocus` / `usePaste` enable it on mount and release it when the last one
unmounts, so a screen with no input handler falls back to cooked mode. There is
no option to hold it.
- **vue-tui:** the `rawMode` mount option defaults to **`'always'`** — raw mode is
enabled at mount and held for the whole interactive run (when `interactive` and
stdin is a TTY), regardless of which input composables are mounted. `rawMode:
'auto'` opts back into Ink's exact lazy behavior.
- **Why:** for a long-running interactive app (a full-screen TUI, a coding agent),
Ink's lazy model makes raw mode **oscillate** as the user moves between input and
no-input screens, with two real consequences: (1) keystrokes echo into a
half-drawn frame on a no-input / streaming screen; (2) Ctrl+C flips meaning — on a
no-input screen raw is off, so Ctrl+C becomes a kernel SIGINT and (e.g.) kills an
agent mid-generation instead of reaching the app's "interrupt this generation"
handler. Holding raw for the lifetime makes Ctrl+C and keystroke handling
identical on every screen and removes the echo. This matches the cross-framework
norm — Bubble Tea, Textual, Ratatui, and prompt_toolkit all own the terminal for
the program lifetime; Ink's hook-driven model is the outlier (its "cooked on a
no-input screen" is an emergent side-effect of refcounting input hooks, not a
relied-upon feature).
- **Consequence:** owning raw mode `ref()`s stdin, so an `'always'` app stays alive
until you explicitly `unmount()` / `exit()` — it does **not** auto-exit when idle
(the same way an Ink app holding a `useInput` already doesn't). The "render and
auto-exit" pattern (Ink's inline-output use) is `rawMode: 'auto'`. Tests:
`raw-mode-lifecycle.test.tsx` (`'always'` holds raw with no input hook; `'auto'`
stays cooked; no mid-session oscillation).
## Additive features (vue-tui is a strict superset)
### Multiple `<Static>` regions
@@ -71,7 +71,7 @@ test("a same-tick useInput swap does not re-issue setRawMode(true) or leak a ref
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
// Baseline: mounting the first useInput enables raw mode exactly once.
@@ -120,7 +120,7 @@ test("the replacement useInput after a same-tick swap still receives input", asy
const { stream: stdin } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
which.value = "b";
@@ -158,7 +158,7 @@ test("teardown disables raw mode synchronously so a signal exit can't leave the
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
expect(setRawModeCalls).toEqual([true]);
@@ -209,8 +209,8 @@ test("two apps sharing one stdin both receive input; the second keeps receiving
const appA = createApp(AppA);
const appB = createApp(AppB);
appA.mount({ stdout: stdout1, stdin, debug: true, exitOnCtrlC: false });
appB.mount({ stdout: stdout2, stdin, debug: true, exitOnCtrlC: false });
appA.mount({ stdout: stdout1, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
appB.mount({ stdout: stdout2, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
// Raw mode enabled exactly once (shared refcount); both apps hold the one ref.
@@ -241,3 +241,168 @@ test("two apps sharing one stdin both receive input; the second keeps receiving
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
// --- rawMode option: 'always' (default) vs 'auto' (Ink lazy) ---
// Default rawMode 'always': the app OWNS raw mode for its whole interactive
// lifetime — raw is enabled at mount even with NO input composable mounted, and
// held until unmount. This is the Bubble Tea / Textual / Ratatui norm and keeps
// keystrokes from echoing into the frame on a no-input screen.
test("rawMode 'always' (default): a no-input app holds raw mode for its whole lifetime", async () => {
const App = defineComponent(() => () => <Text>no input here</Text>);
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
// No rawMode option → default 'always'.
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
await settle();
// Raw mode is on at mount despite no useInput/useFocus/usePaste.
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
app.unmount();
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
// rawMode 'auto': raw mode is lazy — a no-input app stays in cooked mode (raw is
// never enabled), exactly Ink's behavior. Opting into 'auto' is the escape hatch
// back to the Ink lazy model.
test("rawMode 'auto': a no-input app never enables raw mode (Ink lazy behavior)", async () => {
const App = defineComponent(() => () => <Text>no input here</Text>);
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
// Cooked: raw mode never acquired without an input composable.
expect(setRawModeCalls).toEqual([]);
expect(refCount()).toBe(0);
app.unmount();
});
// rawMode 'always': raw mode must NOT drop when an input component unmounts
// mid-session — the app's lifetime ref holds the floor at 1, so there is no
// cooked-mode oscillation as the user navigates between input and no-input
// screens (the exact wart 'auto' has and 'always' fixes).
test("rawMode 'always': raw mode stays on when the only input component unmounts mid-session", async () => {
const showInput = shallowRef(true);
const Child = defineComponent(() => {
useInput(() => {});
return () => <Text>input</Text>;
});
const App = defineComponent(() => () => (showInput.value ? <Child /> : <Text>idle</Text>));
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
await settle();
// App ref + the child's useInput ref; raw enabled once (one 0→1 transition).
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Navigate to the no-input screen: the useInput unmounts, but the app's
// lifetime ref keeps raw mode ON — no setRawMode(false) here.
showInput.value = false;
await settle();
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Only unmount drops it.
app.unmount();
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
// rawMode 'always': a partial escape buffered before a same-tick useInput swap
// must NOT bleed into the replacement. The App's lifetime hold keeps raw mode and
// the data listener alive (no oscillation), but the per-consumer input-state
// cleanup is re-based to that floor, so it still fires when the last input
// composable unmounts — clearing the pending escape. Without it, the floor ref
// would suppress the cleanup and the stale escape would flush to the new useInput
// ~20ms later (e.g. a lone ESC during a screen transition leaking to the next
// screen). 'auto' is covered separately in raw-mode-teardown.sequential.test.tsx.
test("rawMode 'always': a pending partial escape does not bleed across a useInput swap", async () => {
const which = shallowRef<"a" | "b">("a");
const bKeys: string[] = [];
const A = defineComponent(() => {
useInput(() => {});
return () => <Text>a</Text>;
});
const B = defineComponent(() => {
useInput((input) => bKeys.push(input));
return () => <Text>b</Text>;
});
const App = defineComponent(() => () => (which.value === "a" ? <A /> : <B />));
const stdout = makeFakeWritable();
const { stream: stdin } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false }); // default 'always'
await settle();
// Buffer a partial CSI escape, then swap A → B in the same tick.
(stdin as unknown as PassThrough).emit("data", "\x1b[");
which.value = "b";
await settle();
// Let the parser's ~20ms pending-escape flush timer fire.
await new Promise<void>((r) => setTimeout(r, 40));
expect(bKeys).toEqual([]); // the replacement must not receive the stale escape
app.unmount();
});
// rawMode 'always': an escape typed WHILE ON a no-input screen must not bleed
// into the next useInput either. The App's lifetime listener keeps parsing on the
// idle screen, so a lone ESC buffers a pending flush; if an input screen mounts
// within the ~20ms window, the first-consumer-acquire clear must discard the
// stale escape so the new useInput never sees it.
test("rawMode 'always': an escape buffered on a no-input screen does not bleed into the next useInput", async () => {
const screen = shallowRef<"a" | "idle" | "b">("a");
const bKeys: string[] = [];
const A = defineComponent(() => {
useInput(() => {});
return () => <Text>a</Text>;
});
const B = defineComponent(() => {
useInput((input) => bKeys.push(input));
return () => <Text>b</Text>;
});
const App = defineComponent(
() => () => (screen.value === "a" ? <A /> : screen.value === "b" ? <B /> : <Text>idle</Text>),
);
const stdout = makeFakeWritable();
const { stream: stdin } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false }); // default 'always'
await settle();
// Navigate to the idle (no-input) screen; A's useInput unmounts.
screen.value = "idle";
await settle();
// Type a partial escape WHILE idle — the lifetime listener still parses it.
(stdin as unknown as PassThrough).emit("data", "\x1b[");
// Transition to the input screen B within the ~20ms flush window.
screen.value = "b";
await settle();
await new Promise<void>((r) => setTimeout(r, 40));
expect(bKeys).toEqual([]); // the idle-typed escape must not reach B
app.unmount();
});
@@ -69,7 +69,7 @@ test.sequential("swapping useInput components clears pending parser state (no le
const { stream: stdin } = makeRawTrackingStdin();
const app = createApp(Root);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await nextTick();
// Buffer a partial escape sequence (CSI start, no final byte). The parser
@@ -115,7 +115,7 @@ test.sequential("final raw-mode teardown restores the terminal (setRawMode(false
const { stream: stdin, rawMode } = makeRawTrackingStdin();
const app = createApp(Root);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await nextTick();
expect(rawMode.current).toBe(true);
@@ -86,6 +86,26 @@ it("don't exit while raw mode is active", async () => {
expect(ps.output).toContain("exited");
});
it("rawMode 'always' (default): a no-input app stays alive and exits on Ctrl+C", async () => {
// The default rawMode 'always' holds raw mode for the whole interactive run, so
// even a no-input app (no useInput, no stdin listener) does NOT auto-exit — the
// lifetime raw-mode ref keeps the loop alive. (Under 'auto' the same app exits
// immediately; cf. exit-normally, pinned to 'auto'.) Ctrl+C still exits it
// cleanly because raw mode is held and exitOnCtrlC defaults to true — the
// headline benefit that Ctrl+C works on a no-input screen.
const ps = term("exit-rawmode-always");
const exitedDuringWait = await Promise.race([
ps.waitForExitInfo().then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 500)),
]);
expect(exitedDuringWait).toBe(false);
ps.write("\x03"); // Ctrl+C
await ps.waitForExit();
expect(ps.output).toContain("exited");
});
it("exit when DEV is set", async () => {
// Port of Ink exit.tsx:133-142 ("exit when DEV is set"). DEV is inert in
// vue-tui (no React DevTools hookup), so exit-normally must still exit cleanly.
@@ -38,4 +38,4 @@ const App = defineComponent(() => {
});
const app = createApp(App);
app.mount();
app.mount({ rawMode: "auto" }); // relies on auto-exit (default "always" holds raw & never exits)
@@ -25,4 +25,4 @@ const Erase = defineComponent(() => {
process.stdout.rows = Number(process.argv[2]);
const app = createApp(Erase);
app.mount();
app.mount({ rawMode: "auto" }); // relies on auto-exit (default "always" holds raw & never exits)
@@ -20,4 +20,4 @@ const EraseWithStatic = defineComponent(() => {
process.stdout.rows = Number(process.argv[2]);
const app = createApp(EraseWithStatic);
app.mount();
app.mount({ rawMode: "auto" }); // relies on auto-exit (default "always" holds raw & never exits)
@@ -13,4 +13,4 @@ const Erase = defineComponent(() => {
process.stdout.rows = Number(process.argv[2]);
const app = createApp(Erase);
app.mount();
app.mount({ rawMode: "auto" }); // relies on auto-exit (default "always" holds raw & never exits)
@@ -3,6 +3,6 @@ import { defineComponent } from "vue";
const App = defineComponent(() => () => <Text>Hello World</Text>);
const app = createApp(App);
app.mount();
app.mount({ rawMode: "auto" }); // relies on auto-exit (default "always" holds raw & never exits)
await app.waitUntilExit();
console.log("exited");
@@ -28,5 +28,5 @@ const App = defineComponent(() => {
});
const app = createApp(App);
app.mount();
app.mount({ rawMode: "auto" }); // relies on auto-exit (default "always" holds raw & never exits)
await app.waitUntilExit();
@@ -0,0 +1,23 @@
import process from "node:process";
import { createApp, Text } from "@vue-tui/runtime";
import { defineComponent, h, onMounted } from "vue";
// A NO-input app under the DEFAULT rawMode 'always'. There is no useInput /
// useFocus / usePaste, no explicit setRawMode, and no stdin listener — so the
// App's lifetime raw-mode hold is the ONLY thing keeping the process alive. It
// must therefore NOT auto-exit (under rawMode 'auto' it would render and exit
// immediately, like exit-normally). Ctrl+C still exits it cleanly (exitOnCtrlC
// default + raw mode held), which is the headline benefit: Ctrl+C works on a
// no-input screen.
const App = defineComponent(() => {
onMounted(() => {
setTimeout(() => process.stdout.write("__READY__"), 100);
});
return () => h(Text, null, "Hello World");
});
const app = createApp(App);
app.mount(); // default rawMode 'always'
await app.waitUntilExit();
console.log("exited");
+78 -10
View File
@@ -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);
}