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:
@@ -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");
|
||||
@@ -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