2372b6b03b
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>
141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
// Sequential: these tests assert on the raw-mode controller's deferred teardown,
|
|
// which is driven by queueMicrotask + a real 20ms pending-escape flush timer.
|
|
// File-level parallelism can perturb the microtask/timer interleaving relative to
|
|
// other apps mounting/unmounting on the shared process, so we keep them serial.
|
|
|
|
import { PassThrough } from "node:stream";
|
|
import { defineComponent, h, nextTick, shallowRef } from "vue";
|
|
import { expect, test } from "vite-plus/test";
|
|
import { createApp, Text, useInput } from "@vue-tui/runtime";
|
|
import { makeFakeWritable } from "./test-streams.ts";
|
|
|
|
// A fake stdin that, like a real PTY, reflects the last setRawMode call in `isRaw`
|
|
// AND records every setRawMode call in `history`. The shared test-streams stdin
|
|
// does not track `isRaw`, which hides the prevRaw re-capture corruption (FIX B):
|
|
// on a real terminal, a deferred setRawMode(false) leaves isRaw=true, so a same-
|
|
// tick re-acquire snapshots prevRaw=true.
|
|
function makeRawTrackingStdin(): {
|
|
stream: NodeJS.ReadStream;
|
|
rawMode: { current: boolean; history: boolean[] };
|
|
} {
|
|
const rawMode = { current: false, history: [] as boolean[] };
|
|
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
Object.assign(s, {
|
|
isTTY: true,
|
|
isRaw: false,
|
|
setRawMode(this: NodeJS.ReadStream, mode: boolean) {
|
|
(this as { isRaw: boolean }).isRaw = mode;
|
|
rawMode.current = mode;
|
|
rawMode.history.push(mode);
|
|
return this;
|
|
},
|
|
setEncoding(this: NodeJS.ReadStream) {
|
|
return this;
|
|
},
|
|
});
|
|
(s as { ref?: () => void }).ref = () => {};
|
|
(s as { unref?: () => void }).unref = () => {};
|
|
return { stream: s, rawMode };
|
|
}
|
|
|
|
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
|
|
// FIX A (P6): a same-tick useInput swap must not leak buffered parser state into
|
|
// the replacement. Ink clears input state synchronously on the last release
|
|
// (clearInputState — App.tsx:212-216) and defers only the terminal toggle.
|
|
// Port of Ink test/components.tsx:855-909.
|
|
test.sequential("swapping useInput components clears pending parser state (no leaked partial escape)", async () => {
|
|
const receivedByB: string[] = [];
|
|
const showA = shallowRef(true);
|
|
|
|
const StepA = defineComponent(() => {
|
|
useInput(() => {});
|
|
return () => h(Text, null, () => "A");
|
|
});
|
|
|
|
const StepB = defineComponent(() => {
|
|
useInput((input) => {
|
|
receivedByB.push(input);
|
|
});
|
|
return () => h(Text, null, () => "B");
|
|
});
|
|
|
|
const Root = defineComponent(() => {
|
|
return () => (showA.value ? h(StepA, { key: "a" }) : h(StepB, { key: "b" }));
|
|
});
|
|
|
|
const stdout = makeFakeWritable();
|
|
const stderr = makeFakeWritable();
|
|
const { stream: stdin } = makeRawTrackingStdin();
|
|
|
|
const app = createApp(Root);
|
|
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
|
|
// holds "\x1b[" as pending and schedules a 20ms flush timer.
|
|
stdin.emit("data", "\x1b[");
|
|
|
|
// Swap StepA -> StepB in the same tick: A unmounts (refs -> 0, queues the
|
|
// deferred disable), B mounts (refs 0 -> 1, re-attaches the data listener).
|
|
showA.value = false;
|
|
await nextTick();
|
|
|
|
// Let the queued microtask AND the 20ms pending-escape flush run.
|
|
await Promise.resolve();
|
|
await wait(40);
|
|
|
|
// Ink: the replacement receives nothing — the stale "\x1b[" must not leak.
|
|
expect(receivedByB).toEqual([]);
|
|
|
|
app.unmount();
|
|
});
|
|
|
|
// FIX B (P7): after a sync setRawMode(false)->(true) swap then teardown, the
|
|
// terminal must be restored (final setRawMode call is false). The previous
|
|
// prevRaw-restore would re-capture prevRaw=true while raw mode was still active
|
|
// (deferred disable), leaving the terminal in raw mode on exit. Ink unconditionally
|
|
// setRawMode(false) on disable (App.tsx:218-222).
|
|
test.sequential("final raw-mode teardown restores the terminal (setRawMode(false)) after a sync re-acquire swap", async () => {
|
|
const active = shallowRef(true);
|
|
|
|
// Two useInput components: dropping one and adding another in the same tick is
|
|
// a release(false)+acquire(true) cycle while the deferred disable is pending.
|
|
const Listener = defineComponent(() => {
|
|
useInput(() => {});
|
|
return () => h(Text, null, () => "x");
|
|
});
|
|
|
|
const Root = defineComponent(() => {
|
|
return () => (active.value ? h(Listener, { key: "a" }) : h(Listener, { key: "b" }));
|
|
});
|
|
|
|
const stdout = makeFakeWritable();
|
|
const stderr = makeFakeWritable();
|
|
const { stream: stdin, rawMode } = makeRawTrackingStdin();
|
|
|
|
const app = createApp(Root);
|
|
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false, rawMode: "auto" });
|
|
await nextTick();
|
|
|
|
expect(rawMode.current).toBe(true);
|
|
|
|
// Sync swap: release -> acquire in the same tick. With the old code,
|
|
// acquireRawMode re-captures prevRaw from the still-true isRaw -> prevRaw=true.
|
|
active.value = false;
|
|
await nextTick();
|
|
await Promise.resolve();
|
|
|
|
// Raw mode must still be on across the swap (the deferred toggle is the whole
|
|
// reason for the microtask — never break this).
|
|
expect(rawMode.current).toBe(true);
|
|
|
|
// Now tear down. The final disable must leave the terminal NOT in raw mode.
|
|
app.unmount();
|
|
await Promise.resolve();
|
|
await wait(5);
|
|
|
|
expect(rawMode.current).toBe(false);
|
|
expect(rawMode.history.at(-1)).toBe(false);
|
|
});
|