From 33055e429cba83dbf39a38ba02fa28bea4e98e90 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 03:07:32 +0800 Subject: [PATCH] fix(runtime): throw a descriptive error when raw mode is unsupported (Ink parity, G10) (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): throw a descriptive error when raw mode is unsupported (Ink parity, G10) Previously the raw-mode acquire path silently no-opped on a stdin where raw mode is unsupported (non-TTY / isRawModeSupported false), so using useInput on such a stdin did nothing with no diagnostic. Ink's handleSetRawMode (App.tsx:315-327) instead throws immediately when enabling raw mode is unsupported, with two distinct messages (default process.stdin vs a custom stdin) both pointing at the isRawModeSupported docs. StdinController.acquireRawMode now throws that two-message error on !isRawModeSupported. The unguarded useInput path surfaces it (matching Ink's use-input.ts, which calls setRawMode(true) ungated). useFocus now guards on isRawModeSupported before acquiring (matching Ink's use-focus.ts), so focus degrades to a safe no-op on a non-TTY rather than throwing. releaseRawMode keeps its no-op guard — Ink only throws when enabling. Co-Authored-By: Claude Opus 4.8 * chore(parity): ledger — G10 pr-open, reconcile G09 merged Co-Authored-By: Claude Opus 4.8 * test(runtime): assert full raw-mode error message for exact Ink parity (G10, codex) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .agents/docs/parity-ledger.md | 4 +- .../composables/raw-mode-unsupported.test.tsx | 89 +++++++++++++++++++ packages/runtime/src/composables/useFocus.ts | 6 +- packages/runtime/src/render.ts | 17 +++- 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index df6dda8..51bde6d 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -42,8 +42,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G06 | text-wrap-transform | Nested / transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — | | G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | candidate | — (see ink-parity.md) | — | | G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 | -| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | pr-open | `fix/parity-external-bsu` | #39 | -| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — | +| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | merged | `fix/parity-external-bsu` | #39 | +| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | pr-open | `fix/parity-setrawmode-throw` | #40 | | G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — | | G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 | | G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — | diff --git a/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx b/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx new file mode 100644 index 0000000..73414dd --- /dev/null +++ b/packages/runtime-tests/integration/composables/raw-mode-unsupported.test.tsx @@ -0,0 +1,89 @@ +import { PassThrough } from "node:stream"; +import { nextTick, defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { createApp, Text, useFocus, useInput } from "@vue-tui/runtime"; +import { makeFakeWritable } from "../lifecycle/test-streams.ts"; + +// Builds a stdin that is NOT a TTY, so isRawModeSupported is false. This mirrors +// piping input into a program (e.g. `echo x | node app.js`) where raw mode can't +// be enabled. Matches Ink's isRawModeSupported = stdin.isTTY check. +function makeNonTtyStdin(): NodeJS.ReadStream { + const s = new PassThrough() as unknown as NodeJS.ReadStream; + Object.assign(s, { + isTTY: false, + setEncoding(this: NodeJS.ReadStream) { + return this; + }, + }); + (s as { ref?: () => void }).ref = () => {}; + (s as { unref?: () => void }).unref = () => {}; + return s; +} + +// Mounts a component against a non-TTY stdin and resolves with any error that +// surfaces through the app's exit promise (the error-boundary → exit path the +// testing render() helper relies on), or undefined if it mounts cleanly. +async function mountNonTtyAndCaptureError(component: Parameters[0]): Promise<{ + error: Error | undefined; + unmount: () => void; +}> { + const stdout = makeFakeWritable(); + const stdin = makeNonTtyStdin(); + + const app = createApp(component); + + let error: Error | undefined; + app.waitUntilExit().catch((e) => { + error = e as Error; + }); + + try { + app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false }); + } catch (e) { + error = e as Error; + } + + // Flush the Vue queue so the error boundary → nextTick → exit → reject chain runs. + await nextTick(); + await nextTick(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((r) => setImmediate(r)); + await Promise.resolve(); + + return { error, unmount: () => app.unmount() }; +} + +// Test A: useInput on an unsupported stdin must surface Ink's descriptive error. +test("useInput on a non-TTY stdin throws a descriptive raw-mode error", async () => { + const App = defineComponent(() => { + useInput(() => {}); + return () => listening; + }); + + const { error, unmount } = await mountNonTtyAndCaptureError(App); + unmount(); + + expect(error).toBeInstanceOf(Error); + // Assert the FULL custom-stdin message (a non-process.stdin PassThrough hits the + // "provided to Vue TUI" branch), so exact two-line parity with Ink's wording + + // docs URL can't silently regress (mirrors Ink App.tsx:323-325). + expect(error?.message).toBe( + "Raw mode is not supported on the stdin provided to Vue TUI.\n" + + "Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", + ); +}); + +// Test B (regression): useFocus must guard like Ink's use-focus.ts and NOT throw +// on a non-TTY stdin. This guards against over-throwing in the acquire chokepoint. +test("useFocus on a non-TTY stdin does not throw (graceful no-op)", async () => { + const App = defineComponent(() => { + useFocus(); + return () => focusable; + }); + + const { error, unmount } = await mountNonTtyAndCaptureError(App); + unmount(); + + expect(error).toBeUndefined(); +}); diff --git a/packages/runtime/src/composables/useFocus.ts b/packages/runtime/src/composables/useFocus.ts index 2d524ae..b1dcd54 100644 --- a/packages/runtime/src/composables/useFocus.ts +++ b/packages/runtime/src/composables/useFocus.ts @@ -35,7 +35,11 @@ export function useFocus(options: UseFocusOptions = {}): { let rawModeAcquired = false; function acquireRaw() { - if (!rawModeAcquired && stdin) { + // Guard on isRawModeSupported before acquiring — mirrors Ink's use-focus.ts + // (`if (!isRawModeSupported || !isActive) return;`). acquireRawMode() throws + // on an unsupported stdin (see render.ts), so without this guard useFocus + // would throw on a non-TTY. Focus should degrade to a no-op there instead. + if (!rawModeAcquired && stdin?.isRawModeSupported) { stdin.acquireRawMode(); rawModeAcquired = true; } diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 1441797..37e74e4 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -1048,7 +1048,22 @@ function createStdinController( internal_eventEmitter: emitter, internal_exitOnCtrlC: opts.exitOnCtrlC, acquireRawMode() { - if (!appCtx.isRawModeSupported) return; + if (!appCtx.isRawModeSupported) { + // Match Ink's handleSetRawMode (App.tsx): enabling raw mode on an + // unsupported stdin throws a descriptive error rather than silently + // no-opping. Two messages — one for the default process.stdin, one for + // a custom stream — both pointing at the isRawModeSupported docs. The + // unguarded useInput path surfaces this; useFocus guards before calling + // (see composables/useFocus.ts), so it degrades to a no-op like Ink. + if (stdin === process.stdin) { + throw new Error( + "Raw mode is not supported on the current process.stdin, which Vue TUI uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", + ); + } + throw new Error( + "Raw mode is not supported on the stdin provided to Vue TUI.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported", + ); + } const state = getRawModeState(stdin); if (state.refs === 0) { state.prevRaw = (stdin as { isRaw?: boolean }).isRaw ?? false;