Two related fixes to the raw-mode controller, mirroring Ink's split
(App.tsx:212-224,357):
- Sync input-state clear (P6): on the last useInput release (refs→0), reset the
input parser, clear the pending escape-flush timer, and detach the stdin
listeners SYNCHRONOUSLY — only the terminal raw-mode toggle stays deferred.
Previously everything was deferred in one microtask, so a same-tick useInput
SWAP (old unmounts → refs 0 → queued; new mounts → refs 0→1; the queued reset
then short-circuits on refs>0) left the parser un-reset and a partial escape
buffered before the swap leaked into the replacement handler. Ink's
clearInputState runs synchronously and unconditionally so this can't happen.
- Force raw-off (P7): the final disable now unconditionally setRawMode(false),
matching Ink's disableRawMode. The previous prevRaw-restore re-captured
stdin.isRaw at acquire while raw was still active on a sync false→true→false
swap, snapshotting `true` and leaving the terminal in RAW mode after exit. No
test locked the prevRaw-restore (an undocumented vue invention), so the field
is removed entirely — eliminating the corruption and aligning with Ink.
The swap-keeps-raw-on behavior (deferred toggle short-circuits on refs>0) is
preserved. Tests lock the partial-escape no-leak on swap and the terminal being
restored (final setRawMode is false) via an isRaw-tracking stdin.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frame-restore after an external stdout write (console.log / useStdout().write)
used `lastOutputToRender ?? lastOutput + "\n"`. `??` only falls back for
null/undefined, so an empty-string lastOutputToRender (the initial value, and the
value left by the screen-reader empty-frame path) restored "" — nothing — where Ink
restores lastOutput + "\n". Ink uses `||` (ink.tsx:507) and so does vue's own
mountedClear (render.ts:668); :518 was the lone inconsistent site. Changed `??` to
`||`. Locked by an SR-empty-frame + external-write test that re-emits "\n".
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.exit(crossRealmError) resolved waitUntilExit() with the error as a result value,
and a component-thrown cross-realm Error was re-wrapped (losing the original) -- both
because `instanceof Error` fails across VM realms. Adds Ink's isErrorInput check
(instanceof Error || Object.prototype.toString.call(v) === "[object Error]", the
realm-independent [object Error] brand) and uses it at the 3 exit() classification
sites and the 2 error-boundary normalization sites. A cross-realm Error now rejects
(exit) / is preserved (throw), matching Ink; genuine non-Error values still resolve /
still wrap.
Flips the previously-locked "cross-realm resolves" test to Ink's reject (the behavior
was never in ink-divergences.md, so per the repo rule it was an unverified gap, not a
sanctioned divergence). Adds component-throw cross-realm + a non-Error-still-wraps guard.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ErrorOverview was a one-line `Name: message` stub; Ink v7.0.4 renders a full
ERROR overview. Ports it faithfully:
- white-on-red ` ERROR ` label + message, dim cwd-relative file:line:column origin
- a code excerpt around the throwing line (padded gutter, error line highlighted,
`Line N` / `Line N, error` aria-labels)
- the parsed stack (`- fn (file:line:col)`, cwd-relative, StackUtils nodeInternals
filtering, unparsable-line fallback, fs.existsSync guard)
Adds code-excerpt@4.0.0 + stack-utils@2.0.6 (the versions Ink uses). The error
boundary now keeps the raw thrown value for display (Ink stores the raw value;
ErrorOverview renders a stack only when one exists) -- so a non-Error throw no
longer shows a misleading synthetic framework stack. The exit/reject path still
receives a wrapped Error (semantics unchanged). The unparsable-stack fallback
emits a literal backslash-t to match Ink's JSX.
Showing String(value) for a non-Error message (vs Ink's blank) is a documented
additive divergence (ink-divergences.md).
Adds 4 error-overview tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the interactive commit path the active cursor position was never forwarded to
the frame writer, so the cursor was never shown at the useCursor() position, never
followed input, and a cursor-only move on a byte-identical frame emitted nothing.
Aligns with Ink v7.0.4 by wiring three coupled defects together:
- render.ts setCursorPosition now forwards to writer.setCursorPosition, marking
log-update's cursorDirty (Ink ink.tsx:494-497).
- the synchronized-update commit gate is split into Ink's two levels: the write is
gated on willRender() || isCursorDirty(), but BSU/ESU wrap only when willRender()
(Ink ink.tsx:1094 outer, :372-382 inner) -- an idle cursor-dirty re-render emits
zero bytes, not an empty BSU/ESU pair.
- FrameWriter.write() bypasses its frame===lastFrame dedup when the cursor is dirty,
so a cursor-only move still reaches log-update's buildCursorOnlySequence.
The mount-time hide-cursor write moves before originalMount so the first commit's
show is the last visibility change (Ink hides before its first render); a synchronous
mount throw now runs best-effort teardown (cursor/alt-screen restore) before
rethrowing the ORIGINAL error, matching Ink's constructor-wired signalExit.
Adds 8 interactive-TTY tests; the prior use-cursor tests used the debug render()
helper where log-update never runs, so they passed for the wrong reason.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#69 added `useAppContext()` as a Vue-native rename of Ink's `useApp()`,
qualified to avoid reading as the Vue application instance. On reflection the
"Context" suffix borrowed the name of an internal grab-bag context and slightly
mislabels the hook — it returns app lifecycle controls, not that context. The
collision worry doesn't hold up: Vue has no `useApp()`, the returned
`{ exit, waitUntilRenderFlush }` is clearly not the Vue app instance, and "App"
in vue-tui already means the `TuiApp` from `createApp()`.
Rename to `useApp()` for full Ink fidelity (same name + same shape), and drop
the now-defunct "App composable" entry from ink-divergences.md — it ceases to
be a divergence.
Internal context cleanup (the grab-bag `AppContext` + the `StdinContext`
duplication) is intentionally out of scope here, tracked separately.
BREAKING CHANGE: `useAppContext()` is renamed to `useApp()`. Replace
`const { exit } = useAppContext()` with `const { exit } = useApp()`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.
- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
impl shared by the `TuiApp` handle and the composable, so both resolve
identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
(`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
surface one — and fix the prior wrong claim that Ink's `useApp` returns
stdin/stdout/stderr
The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.
BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The first exit() call now captures its value/error and initiates teardown
synchronously; subsequent exit() calls are complete no-ops, so waitUntilExit
resolves/rejects with the FIRST value rather than the last. This mirrors
Ink's handleAppExit guard (isUnmounted || isUnmounting → early return).
Previously each exit() queued a microtask that overwrote pendingExitResult/
pendingExitError before resolveExit ran, making it last-wins. An exitInitiated
flag set at the top of exit() now guards the value capture and re-resolve,
while the deferred microtask teardown (needed because exit() is called from
inside the Vue update cycle) is preserved.
Reverses the sweep-1 refutation: exit() was NOT already first-wins guarded.
Also guard unmount-in-progress (isUnmounting parity) + value→error test.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): guard against two renderers on the same stdout (Ink parity, G14)
Make skipped-mount unmount() a pure no-op: add `skippedMount` flag set in
the instance-reuse guard branch; teardown() and resolveExit() return early
when set, so no write-barrier or stdout touch reaches the owner's stream.
Strengthen test: assert app2.unmount() writes nothing to process.stdout
(catches the pre-fix empty write-barrier) and that app1 still owns the
WeakMap entry after app2.unmount() (third mount on same stdout still warns).
* chore(parity): ledger — G14 pr-open, reconcile G13 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): clear+reset on terminal-width decrease during resize (Ink parity, G11)
Track lastTerminalWidth (initialized at mount via resolveSize); onResize clears
writer + resets frameState.lastOutput/lastOutputToRender when the new width is
narrower than the previous width, mirroring the logic in ink.tsx:459-474 that
prevents duplicate overlapping re-renders on terminal narrow. Width increases and
pure height changes are unaffected.
Preserve outputHeight on narrowing (matches Ink ink.tsx:462-466 which leaves
lastOutputHeight intact): zeroing it suppressed the clearTerminal path in
shouldClearTerminalForFrame for overflowing frames (hadPreviousFrame=false).
Adds overflow-path test that was RED with outputHeight=0 and GREEN with the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G11 pr-open, reconcile G10 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The test asserting process.listenerCount("beforeExit") lived in the
parallel wait-flush.test.tsx, conflicting with the branch's rule that
process-global-state tests live in *.sequential.test.* files. Move it into
leak.sequential.test.tsx (already the home for process exit/SIGINT listener-
count assertions) and extend that file's header comment. #26 (item 2).
The resize handler painted synchronously via commit() but left the commit
scheduler's pending trailing throttle timer armed. If an update was sitting
in that timer, it fired a second doCommit() right after the resize paint —
and because shouldClearTerminalForFrame clears whenever the previous frame
overflowed the viewport, the second commit emitted a duplicate clearTerminal.
Cancel the pending trailing commit before the synchronous paint; the paint
already reflects the current tree, so the pending commit is redundant.
Regression test (test-first) in throttle.sequential.test.tsx reproduces the
double-clear (2 clears) and verifies the fix (1 clear). Closes#26 (item 1).
Enable sequence.concurrent: true in vite.config.ts so the non-PTY suite runs
concurrently like the PTY suite. Stress-verified stable (8/8 at maxForks=4);
the suite drops from ~13s to ~4-5s.
Three test patterns were incompatible with concurrency; handled per cause:
- Inline snapshots (background-color, borders): the module-level `expect`
loses snapshot test context under concurrency. Fixed in place by using the
context-local `expect` (async ({ expect }) => ...), so they stay concurrent.
- Process-global state (throttle/animation-scheduler use fake timers; leak
asserts on process exit/SIGINT listener counts and live yoga nodes): a
concurrent sibling clobbers the shared global mid-test. These genuinely
require serial execution, so they move to *.sequential.test.* files with
it.sequential / describe.sequential and a header explaining why.
`vp run ready` passes.
Correctness fixes found by max-effort review of the branch diff:
- frame-writer.sync() now updates lastFrame alongside log-update's
previousOutput. Previously the two dedup layers desynced after a sync()
(the clearTerminal path), silently dropping a legitimately-changed frame
and emitting an empty BSU/ESU pair. Adds a regression test.
- scheduler: the queuePostFlushCb callback now bails if scheduled was reset
by cancel(), so a stale callback can't commit on a torn-down tree or
re-arm an uncancellable trailing timer.
- scheduler.flush() now collects multiple concurrent waiters instead of
overwriting a single resolver — fixes a hang when two waitUntilRenderFlush()
calls await the same pending commit.
- render teardown nulls mountedClear so a post-unmount app.clear() can't
write to a torn-down stream.
- test-streams getContentWrites imports bsu/esu instead of hardcoding the
escape literals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port missing render lifecycle tests from Ink's test/render.tsx:
- onRender fires on input-triggered state update
- throttle renders to maxFps (leading+trailing pattern)
- immediate scheduler in debug mode commits every mutation
- screen reader mode bypasses throttle (immediate commits)
- exit(error) followed by exit(value) still rejects
- exit(value) resolves even when called rapidly twice
- unmount does not write to ended stdout stream
- non-interactive mode writes only last frame at unmount
- non-interactive mode does not emit erase or cursor sequences
- non-interactive unmount does not crash on ended stdout
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- stdin.ref() on raw mode enable, stdin.unref() on disable
- setEncoding("utf8") on raw mode enable
- Raw mode default changed from true to false (hooks acquire it)
- useStdin().setRawMode routed through ref-counted controller
- Stdin listeners attached only when raw mode is active
- EventEmitter maxListeners set to Infinity
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add four behavioral surfaces to the runtime:
- patchConsole (default: true): intercepts console.* methods to route
output through writeToStdout/writeToStderr, preventing frame corruption.
Disabled in debug mode. Restored before Vue cleanup on teardown.
- waitUntilRenderFlush(): flushes pending throttled renders and waits
for the stdout write barrier, exposed on TuiApp and testing RenderResult.
- onRender callback: fires after each commit with { renderTime } in ms.
- maxFps option: overrides the scheduler's default 32ms throttle interval
with 1000/maxFps. The scheduler now accepts a throttleMs option and
exposes hasPending() for flush coordination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace data event with readable + input-parser state machine
- Intercept \x03 in input pipeline (not process SIGINT)
- useInput now uses parse-keypress for semantic key mapping
- 20ms pending escape flush timer matching Ink
- Esc focus reset in input pipeline
- Delete old key-parser.ts (replaced by input-parser + parse-keypress)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap user root component in an InternalErrorBoundary that catches all
descendant errors (setup, render, lifecycle) via onErrorCaptured and
routes them through exit(error). This prevents yoga WASM state corruption
that occurred when errors propagated uncaught during Vue's render phase.
- Add ErrorOverview component for rendering error frames
- Replace try-catch around mount with onErrorCaptured boundary
- Update testing render() to detect early exit errors
- Convert 3 todo tests to active tests (nested setup, unhandledRejection, post-mount)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Change Box-in-Text validation from dev-only warn+skip to unconditional
throw, matching Ink's reconciler behavior. Add text-outside-Text
validation that throws when non-empty text-leaf nodes are inserted into
box/root/static containers outside a Text context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detect invalid <Box> inside <Text> nesting at the DOM insert level and
emit a dev warning instead of crashing the WASM yoga engine. The box
insertion is skipped to prevent layout corruption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vue's errorHandler (installed after mount) already catches render-time
errors from nested components and routes them through exit(err),
providing error boundary behavior matching Ink's design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Write ESC[?25l to stdout after mount and ESC[?25h during teardown,
matching Ink's cursor management. Only active in production mode
(debug: false) to avoid interfering with test streams.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Change exit(error?: Error) to exit(errorOrResult?: unknown). When an
Error is passed, waitUntilExit rejects; otherwise it resolves with the
value. This matches Ink's behavior where exit("hello") resolves the
promise with "hello".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AGENTS.md requires shallowRef by default. All test state uses
reassignment (not mutation), so shallowRef is correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Distributes across text, static, transform, newline-spacer,
error-handling, and prop-reset test files.
Tests requiring features not yet in vue-tui are marked test.todo:
- <Transform> inside <Text> (paint pass limitation)
- multi-line transform (transform nodes not yoga carriers)
- text/box nesting validation (no runtime validation yet)
- complex Static rerender patterns (Ink-specific API)
Also fixes wrap="hard" to use wordWrap:false so text breaks at exact
character boundaries rather than word boundaries, matching Ink behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 test files covering components, composables, focus, lifecycle,
and scheduler through the public render() API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>