yoga's getComputedWidth()/getComputedHeight() return NaN for a node not yet
through a layout pass, and `?? 0` does NOT catch NaN (NaN ?? 0 === NaN), so a
pre-layout / mis-timed measureElement() read returned { width: NaN, height: NaN }
— poisoning user layout math (terminalWidth - measured.width → NaN → a NaN width
prop). Coerce non-finite computed dims to 0 (Number.isFinite(v) ? v : 0).
0 is a safe sentinel ("not yet computed"), not the box's true size — the correct
usage is to read AFTER layout (the JSDoc already steers callers to defer via
nextTick). It's chosen because it is Ink's clear intent (`?? 0`) and matches the
DOM precedent (getBoundingClientRect on display:none / img.naturalWidth pre-load
return 0, not NaN). Deliberate, low-risk robustness divergence from Ink v7.0.4's
NaN-leaking `?? 0`; recorded in ink-divergences.md.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderToString mounts the Vue tree, then lays out and paints, then unmounts.
The app.unmount() sat inside the try AFTER paint, so when layout/paint threw
(e.g. a <Transform> whose transformer throws during the paint phase) control
jumped to the outer finally, which only freed yoga — Vue never tore down, so
onScopeDispose never ran. Any composable that registered an external listener
then leaked it: useWindowSize attaches a `resize` listener to the shared
process.stdout (the no-op AppContext's stdout) and only removes it via
onScopeDispose, so each failed renderToString leaked one listener, accumulating
toward Node's MaxListenersExceededWarning.
Fix: track that mount succeeded and, in the outer finally, run app.unmount()
when `mounted && !teardownSucceeded` (best-effort, in try/catch, before the yoga
free). The happy path is unaffected (it already unmounted; teardownSucceeded
short-circuits the fallback). The error-path unmount frees child yoga nodes and
runs onScopeDispose cleanups; freeRecursive then frees the root. The original
paint error still propagates (the fallback teardown can't mask it). useWindowSize
is intentionally unchanged — the unmount-in-finally is the general fix and also
covers any other external listener a tree registers.
Test (sequential — asserts on the process-global process.stdout resize listener
count): three renderToString calls whose paint throws leak zero `resize`
listeners after the fix (3 before).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the signal-exit teardown path signal-exit re-raises the signal
immediately after the callback returns ({alwaysLast:false}), so a
buffered async stream.write can be lost before the process dies.
teardown(true) already flushes show-cursor, leave-alt-screen and
disable-kitty synchronously via fs.writeSync, but the bracketed-paste
-disable escape \x1b[?2004l was still written with an async
stdout.write on both teardown sub-paths (usePaste's onScopeDispose
-> detach during originalUnmount(), and the stdin controller dispose
backstop). When dropped, the user's shell stays in bracketed-paste
mode and wraps later pastes in \x1b[200~ ... \x1b[201~.
Thread a sync flag through the paste teardown, mirroring kitty:
disableBracketedPaste(sync) writes via fs.writeSync(fd, ...) when sync;
the stdin controller dispose(sync) forwards it; teardown passes sync at
the dispose() call site. Because Vue's unmount runs detach (async, lost
on signal) before dispose() and zeroes the live count, dispose(sync)
re-issues paste-OFF synchronously whenever paste was ever enabled --
paste-OFF is idempotent, so the redundant write is harmless. The normal
(non-signal) unmount path stays async, unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review found stale bare-host-tag references the per-file sed couldn't
reach (they live in comments/docs). Code was clean — no contamination, no public
API leakage, root/text-leaf/comment asymmetry consistent. Updated:
- vite.config.ts isCustomElement comment (<box>/<text> -> <tui-box>/<tui-text>)
- component-authoring.md split-table Text row (virtual-text/text -> tui-*)
- box.vue / useBoxMetrics.ts / use-box-metrics.test.tsx "the real `box` host node"
comments -> `tui-box`
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrites Box/Text/Spacer/Static/Newline from h()/render functions to Vue
<script setup> template SFCs (Transform stays a render fn — it inspects its own
child vnodes), with vue-tsc-verified consumer types (template + JSX fixtures),
provide/inject text context, the always-validate Text divergence (color +
backgroundColor), and three renderer fixes the SFCs surfaced (static anchor skip,
transform line-index Ink-parity, useBoxMetrics subtree drill). Integrates the five
main commits landed after the branch point: #163 public-API audit, generic Static
scoped-slot typing, foreground color validation, useWindowSize/divergence docs.
Squashed from the SFC sub-commits + the two main-integration merges to keep a
linear, rebaseable history. See PR #165 for the full breakdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up cleanup for the 7 confirmed findings from a review of #163. The
dominant theme: #163 hard-renamed the public composable useTerminalSize ->
useWindowSize (no alias) but left stale references to the dead name in
user-facing docs.
- README.md + packages/runtime/README.md: the composable tables named the
removed `useTerminalSize()` (root README even framed the sole real export
`useWindowSize` as an "Ink-compat alias" — now inverted). Point both at
`useWindowSize()`.
- .agents/docs/ink-divergences.md: two vue-tui-side references to
`useTerminalSize` (the shallowRef "object of refs" example and the
"composables throw outside a render tree" list) -> `useWindowSize`. The
Ink-side `useWindowSize -> WindowSize` naming example is left unchanged.
- .agents/docs/accessibility-api.md: the intro cited three "blessed entries"
but only aria-camelCase is one; `renderToString` layout-only and the
`useWindowSize` name are now Ink parity, not divergences. Reword.
- .agents/docs/api-contract.md: tighten the `/internal` wording — the test
does assert one tripwire on `/internal`, so "not covered by
public-api.test.ts" was imprecise.
- public-api.test.ts / render-to-string.test.tsx: the public renderToString
dropped the `isScreenReaderEnabled` option but (unlike the sibling
`ScreenReaderOptions` type) had no compile-time guard. Replace an obscure,
fmt-fragile type-indexing guard with a readable call-site `@ts-expect-error`
in render-to-string.test.tsx; re-adding the option to the public
RenderToStringOptions makes the directive unused and fails `tsc --noEmit`.
- Rename terminal-size.test.tsx / .sequential.test.tsx ->
window-size.test.tsx / .sequential.test.tsx to match the migrated symbol.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions
Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.
Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.
BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle
Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.
- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.
- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).
- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).
- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.
BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: move renderScreenReaderOutput to /internal-only
The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.
`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.
public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.
Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.
BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(runtime-tests): snapshot the exact public value-export set
Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.
Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The test asserted a new committed frame within a fixed 1200ms sleep.
Since the trailing commit re-arms per deferred call (lastCall+wait,
Ink-aligned, #154), the margin races the ~1s cadence on a starved
4-core CI runner — it failed at the boundary (expected 4 to be greater
than 4) on an unrelated docs PR. Poll for the next commit under a
generous deadline instead: the contract is that commits keep flowing,
not that they land inside a hand-tuned sleep.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.
Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles.
Co-authored-by: Claude <noreply@anthropic.com>
Pausing (isActive→false) in the SAME synchronous batch as an interval change froze
the frame at 0 instead of the last live frame. vue-tui split Ink's single render-time
`shouldReset` into TWO `flush:"sync"` watchers; sync fires once-per-mutation, so
`interval.value = X; isActive.value = false` ran the interval watcher first (while
still active) → erroneous start() zeroed the frame, before the isActive watcher
stop()'d.
Replace them with ONE `flush:"post"` watcher on `[isActive, interval]` that coalesces
the batch and fires once with the final values, mirroring Ink's
`shouldReset = isActive && (intervalChanged || becameActive)` (use-animation.ts:77-96):
paused → stop() (freeze, no reset); active + (becameActive || intervalChanged) →
start(). `immediate:true` keeps the initial mount synchronous (one subscribe, no
double-subscribe).
flush:"post" was verified to fire in the BLESSED standalone (no-component) fallback:
Vue's post-flush queue flushes on any reactive mutation's microtask, independent of
component updates.
Adds tests: batched pause+interval (both orders) freezes; resume zeros then advances
at the new interval; same-interval rerender does not reset; and two standalone
(no-render-tree) cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare `measureElement()` called inside `watchPostEffect` reads layout BEFORE
the commit scheduler's post-flush `calculateLayout` runs, so it returns an
uncomputed value (NaN for computed dimensions), not the current size. The JSDoc
previously recommended that exact broken call site.
Align the guidance to vue-tui's real post-flush timing: defer the read with
`nextTick(() => measureElement(ref.value))` — the pattern `useBoxMetrics` itself
uses — or read from an input/timer callback that fires after a flush; prefer
`useBoxMetrics` for reactive metrics. Also correct the stale claim that a
pre-layout read returns `{0,0}` (it returns NaN for an attached-but-uncomputed
node; `{0,0}` is only the detached case).
Adds a characterization test pinning bare-watchPostEffect = NaN vs
nextTick = real width (80), guarding against regressing to the old advice.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression tests for behaviors the audit found correct-but-unpinned, so the
suite is a strict superset of Ink:
- A07: two <Static> regions both render (the additive divergence)
- B04: Static render-prop index = absolute index across appends; container
vertical padding adds blank rows to the static frame
- B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the
'always' default masks)
- B19: child useCursor unmount emits the cursor-hide escape (stream-level)
- B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and
advances without busy-hang
- B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled
true-path (env tests isolated in a *.sequential file per the global-state rule)
- B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/
useBoxMetrics as inert no-ops (don't throw)
- B30: dedicated columnGap/rowGap props + their removal-reset
Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink
correctness, and process-global isolation.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `\x1b[?2004l` paste-OFF write at teardown was gated only on `stdout.isTTY`,
which stays cached-truthy after a stream is destroy()ed/end()ed — so a teardown
where stdout is already gone threw `ERR_STREAM_DESTROYED`. Route both `?2004l`
sites (the setBracketedPasteMode disable branch and the dispose teardown
backstop) through a `disableBracketedPaste()` helper that gates on
`isTTY && !destroyed && !writableEnded`, matching Ink's `canWriteToStdout`
(App.tsx:620/633). The live-stdout happy path is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useStdin().setRawMode(false) on a non-TTY stdin silently no-opped while
setRawMode(true) threw — an asymmetry. Ink's handleSetRawMode throws before
the enable/disable split (App.tsx:315), so both directions throw on an
unsupported stdin, and its test asserts both mount-enable and unmount-disable
throw without ever calling stdin.setRawMode.
Move the isRawModeSupported guard into the public setRawMode wrapper (via a
shared throwRawModeUnsupported helper reusing the existing messages) so both
true/false throw. Internal acquireRawMode/releaseRawMode are unchanged —
composables (useInput/useFocus/usePaste) call those directly, so teardown
release stays a no-op and an unsupported-stdin app still unmounts cleanly.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The terminal raw-mode toggle is refcounted per-stdin (a WeakMap shared across
controllers) so one app's unmount can't drop raw mode while another still needs
it — vue's deliberate improvement over Ink, whose per-App counts let the first
unmount disable raw for everyone. But the "data" input listener was ALSO gated
on that shared refcount, so when two apps (separate createApp/stdout) shared one
stdin, only the first app's handleData ever attached and the second was
permanently deaf — and it didn't even self-heal when the first unmounted (Ink at
least does, via its per-App readable listeners).
Make the "data" listener (and its synchronous clearInputState cleanup: parser
reset, pending-escape flush, listener detach) PER-CONTROLLER, gated on this
controller's own localRefs, while keeping the raw-mode enable/disable on the
shared refcount. Because vue uses the "data" (push) event, every listener gets
every chunk, so both apps now receive input — strictly better than Ink's
"readable" (pull) model where the first-registered listener drains the buffer.
Single-app behavior is byte-identical: with one controller localRefs and the
shared refs move 1:1, so the same-tick swap (parser reset + listener re-attach)
fires at exactly the same moments as before.
Test: two apps sharing one stdin both receive a keystroke, and the second keeps
receiving after the first unmounts while raw mode stays enabled (shared ref);
raw mode disables only when the last app unmounts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three small raw-mode/focus corrections, each aligned to Ink v7.0.4 and
covered by a test (TDD red→green where reachable).
1. Raw mode left ON after a synchronous signal exit (Ctrl+C). The terminal
raw-mode disable is deferred to a microtask (so it survives same-tick
component swaps), but on the signal-exit path teardown(true) re-raises the
signal synchronously without draining microtasks, so the disable never ran
and the shell stopped echoing after Ctrl+C. dispose() now forces the disable
SYNCHRONOUSLY when raw mode is no longer owned (state.refs === 0 and either
this dispose released the last ref or a release left pendingDisable set),
mirroring Ink's unmount-cleanup guard `rawModeEnabledCount > 0 ||
pendingDisableRawModeRef.current` (App.tsx:626-631). The disable stays gated
on the SHARED refcount, so a multi-app teardown can't disable while another
app still holds raw mode.
2. Same-tick useInput swap re-issued setRawMode(true) + stdin.ref() and leaked
a libuv ref (the deferred disable bailed on refs>0 and never unref'd). Added
a pendingDisable flag to RawModeState mirroring Ink's pendingDisableRawModeRef
(App.tsx:331-344): on re-acquire while a disable is pending, skip
ref()/setRawMode(true) and cancel the queued disable.
3. focusNext/focusPrevious start-index logic factored into a shared
startSearchIndex() helper so the two directions stay symmetric. Behavior is
identical for all reachable states; it additionally folds the (unreachable
while the activeId invariant holds) "activeId not in list" case into the
same branch instead of diverging per-direction.
Tests: raw-mode-lifecycle.test.tsx (swap no-op, replacement still receives
input, synchronous teardown disable); programmatic-focus.test.tsx (no-active
first/last targeting + active-focus step/wrap via the manager API).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final round-2 test-only batch (behaviors already at parity with Ink reconciler.tsx,
measure-text.tsx, flex-*.tsx, overflow.tsx, build-output.ts):
- build-output: every package.json export target resolves on disk (runtime/cli/testing)
+ the .d.mts declaration sibling for the typed libraries (runtime/testing, not cli).
- reconciler: keyed insert-between [a,c]→[a,b,c]; replace a colored <Text> child with a
plain string; setElementText A→B + the text-context guard; marginLeft removal reset.
- measure: empty <Text> contributes height 0 in a column; non-zero left (marginLeft=5 →
5,1); measureTextNatural trailing/only-newline heights.
- flex: alignSelf='auto' == default + alignSelf removal resets to AUTO; the two
space-around known-yoga-bug cases converted from test.skip to test.fails (they assert
the DESIRED output and flip to a real failure if yoga ever fixes the bug); the documented
flexDirection/flexWrap removal-reset divergence (was comment-only) now has a visual lock.
- overflow: out-of-bounds writes produce Ink's exact clipped frame (sparse past-width cell,
filtered hole) — tightened from toBeDefined().
- components: inline + top-level non-empty fragment in <Text>; the previously-skipped
ST-terminated OSC-8 hyperlink hard-wrap now passes ('abcde\nfghij') — un-skipped as a lock.
Codex-reviewed GENUINE.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 test-only locks (behaviors already at parity with Ink):
- parse-keypress (Ink parse-keypress.ts): Ctrl+F1–F4 (\x1b[1;5P/Q/R/S → f1–f4 ctrl),
unmapped ctrl (\x1b[1;5I/X → name '' ctrl), Shift+F1 (\x1b[1;2P → f1 shift).
- terminal-size (Ink terminal-resize.tsx): 0-columns → positive fallback;
resize-listener returns to baseline on unmount; env.LINES rows fallback (a .sequential
file — mutates process.env/stdout; deletes absent env vars in teardown to avoid pollution).
- use-animation (Ink use-animation.tsx, a .sequential file with deterministic fake timers):
newly mounted/activated same-interval animations don't inherit elapsed time
(firstFrame - secondFrame === 1); a re-render with an unchanged interval doesn't reset
the frame (forced via an unrelated reactive dep — a same-value assign is a Vue no-op);
reset is a stable reference across re-renders (collected in the render fn).
- use-input: plain arrows assert key.meta === false (Ink's `&& !key.meta` gate).
Codex-reviewed; the same-value-no-reset (was vacuous) and env teardown (left "undefined")
were tightened per its notes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reset() zeroed frame/time/delta unconditionally, so calling it while paused
(isActive=false) flipped the frozen frame to 0 immediately. Ink keeps the last frame
until resume: its reset only bumps a key consumed by the isActive-gated effect, which
early-returns while inactive (use-animation.ts:83-89). Now reset() zeros + restarts only
when active (via start()); while paused it's a no-op and the next resume's start() zeros
-- matching Ink. Active reset is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useInput had an undocumented guard that blanked `input` to '' on any kitty key-RELEASE
event, so a printable release (and a ctrl+letter release) delivered nothing. Ink has no
release special-case -- it classifies a kitty event purely by isPrintable/ctrl+letter, so
a printable release delivers `text ?? name` and a ctrl+letter release delivers the letter
name (use-input.ts:204-217). Removes the guard to match.
The kept "Ctrl+C exits under kitty" divergence is unaffected: the exit check in emitInput
is already scoped to `eventType !== "release"`, so a Ctrl+C release flows through as
input='c' without spuriously exiting (press still exits).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changing the interval option on a live useAnimation was a no-op (it was captured
once at setup). interval now accepts a MaybeRefOrGetter<number> (strict superset of
number); while active, a change resets frame/time/delta to 0 and re-subscribes at
the new interval; while inactive the new value is recorded and applies on the next
activation. Mirrors Ink's shouldReset gating (use-animation.ts), which recomputes
safeInterval every render and resets only when active.
Adds 3 tests (live change while active resets; while inactive doesn't; plain number
still works).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): guard writeToStdout/writeToStderr against post-teardown writes (Ink parity, G20)
Return early if teardownStarted, mirroring Ink ink.tsx:673/702, so a write
after unmount (e.g. a stray useStdout().write or console.log routed through
writeToStdout after teardown) cannot run clear()/write/restore on an
already-torn-down renderer and corrupt the restored terminal state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G20 pr-open, reconcile G19 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* chore(parity): ledger — G10 pr-open, reconcile G09 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(runtime): assert full raw-mode error message for exact Ink parity (G10, codex)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): wrap external stdout/stderr writes in synchronized-update markers (Ink parity, G09)
writeToStdout/writeToStderr now emit bsu/esu around clear+write+restore when
shouldSynchronize, matching the render path and Ink ink.tsx:687-728. The sync
variable was already computed at mount time (render.ts:489); the external-write
functions simply lacked the wrapping. For writeToStderr, BSU/ESU go to stdout
(not stderr) because synchronized-update mode is a stdout capability — exactly
mirroring Ink's ink.tsx:717-728 behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G09 pr-open
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): coalesce useAnimation ticks within the render-throttle window (Ink parity, G02)
useAnimation now coalesces scheduler ticks that fall inside the current
render-throttle window and reports delta as the time since the last
RENDERED tick, so velocity-driven motion (position += speed * delta)
advances at correct wall-clock speed even when the commit throttle is
coarser than the animation interval. Previously delta was ~one scheduler
interval per committed tick, under-integrating velocity at render time.
- animation-scheduler: createAnimationScheduler(renderThrottleMs = 0)
exposes renderThrottleMs on the AnimationScheduler (no-op variant = 0).
- render.ts: derive animationRenderThrottleMs from maxFps using Ink's
Math.max(1, ceil(1000/maxFps)); 0 on debug/screen-reader/unthrottled
paths, mirroring the commit-throttle gate.
- useAnimation: tick() skips while now < nextRenderTime; on an allowed
tick delta = now - lastRenderedTime, then nextRenderTime = now + window.
Also default maxFps to 30 (Ink parity: options.maxFps ?? 30) and derive a
single renderThrottleMs that drives BOTH the commit scheduler and the
animation scheduler, so the coalescing engages on the default non-debug
path (previously it only engaged when maxFps was passed explicitly).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G02 pr-open, reconcile G01 merged, log G02 decisions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* test: add text ANSI sanitization parity tests from Ink (+15)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add use-animation parity tests from Ink (+43)
Port 43 new tests from Ink's use-animation test suite covering:
- Multiple animations in sync, different rates, sibling unmount
- Timer cleanup/recreation on unmount and remount
- Inactive animations, timer leak prevention
- Edge intervals (NaN, Infinity, -Infinity, oversized, zero, negative)
- isActive toggle resets, pause/resume cycles
- Frame catch-up, time/delta tracking, reset() behavior
- Newly mounted/activated animations don't inherit elapsed time
- Wall clock monotonicity, getter function isActive support
Uses selective fake timers (setInterval + performance only) so that
render()'s internal setImmediate still works on real clocks. Fake timer
tests read refs directly to avoid Vue scheduler flush timing issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add use-box-metrics/measure parity tests from Ink (+16)
Port 16 missing tests from Ink's use-box-metrics, measure-element, and
measure-text test suites. Fix useBoxMetrics to reset metrics to zeros
when the tracked ref detaches (element unmounts or ref switches to null).
3 tests are skipped because vue-tui's useBoxMetrics uses watchPostEffect
(re-runs only when ref.value changes) rather than Ink's layout-commit
listener pattern, so sibling-content and resize-driven re-measurement
is not yet supported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add screen-reader parity tests from Ink (+11)
Add 11 screen-reader integration tests covering aria-label substitution on
Text/Box, ANSI styling omission, multiple/nested components, null component,
aria-state variants (busy, disabled, expanded), multi-line roles, and
multiselectable listbox.
Also fix component prop bug: Vue normalizes kebab-case prop names to camelCase
at runtime, so props["aria-label"] was always undefined. Switch Box/Text prop
declarations and access to camelCase (ariaLabel, ariaHidden, ariaRole, ariaState).
Add isScreenReaderEnabled option to renderToString() so tests can exercise
screen-reader output through the component pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add render-to-string parity tests from Ink (+18)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add cursor composable parity tests from Ink (+7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add 6 missing screen-reader Ink parity tests
Add tests for aria-hidden, select input (list with roles/states/labels),
aria-state.multiline, aria-state.readonly, aria-state.required, and
nested multi-line text rendering in screen-reader mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix render-to-string missing Ink parity tests (+10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix cursor composable missing Ink parity tests (+6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix use-box-metrics missing Ink parity tests (+4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add layout listener so useBoxMetrics updates on resize and sibling changes
Adds a layout listener mechanism to TuiRoot matching Ink's architecture:
- TuiRoot.layoutListeners Set with addLayoutListener/emitLayoutListeners
- emitLayoutListeners called after every yoga.calculateLayout in commit()
- useBoxMetrics subscribes to layout listeners, diffs values before updating
Enables 4 previously-skipped tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Default: true when INK_SCREEN_READER=true env var set
- Screen reader mode bypasses render throttle
- Composable exposes flag via provide/inject
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drives animations with a frame counter, elapsed time, delta, and reset
function. Supports configurable interval and reactive isActive toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- setCursorPosition propagates to app context for frame restoration
- Cursor cleared on component unmount via onScopeDispose
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Ref-counted bracketed paste mode (\x1b[?2004h/l)
- usePaste acquires raw mode + paste mode, listens on paste channel
- Paste falls through to useInput when no paste listeners exist
- isActive option for conditional paste listening
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>
Add parsing for Home (ESC[H, ESC[1~, ESC[7~), End (ESC[F, ESC[4~,
ESC[8~), Ctrl+arrow (CSI 1;5 A/B/C/D), and Meta+arrow (double-ESC
prefix) key sequences. Add home/end fields to the Key interface.
Implement 12 new useInput tests for these key combinations.
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>
Covers basic character input, arrow keys, return, escape,
backspace, tab, and isActive option.
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>