A literal tab in <Text> is measured as 0 columns by string-width but painted at
its tab-stop width (wrap-ansi / terminal), so the reserved yoga width disagrees
with what's drawn (ab\tcd measures 4, paints ~10). This is a shared upstream
quirk — Ink v7.0.4 does the same and likewise doesn't normalize tabs — so it's
aligned with Ink, not a divergence; recorded under Non-Behavioral Notes so it's
not rediscovered as a parity gap. KEEP (literal tabs in TUI text are vanishingly
rare). The note also captures the fix direction if ever needed (expand tabs to
spaces at the shared squash chokepoint, upstream of string-width) and the one
behavior change that would then become the actual divergence. [VOUCHED @hyf0]
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Encode the declarative invariant for the runtime `wrap` re-measure fix
(PR #193) as a full matrix: for all 6 wrap modes and all 30 ordered
transitions, toggling `wrap` at runtime produces the exact same frame as
a fresh mount with that wrap (measure == paint). Ground-truth fresh-mount
frames are derived at runtime, not hardcoded. Reverting the one-line fix
in node-ops.ts turns 16 of the 30 transitions red, so the matrix
genuinely guards the fix.
Vouch the divergence: add [VOUCHED @hyf0] to the ink-divergences.md entry
and reword it to lead with correctness (Ink v7.0.4 has the latent stale
measure bug; vue-tui keeps the correct invariant). Drop "pending a human
vouch" from the node-ops comment.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clear-terminal branch of renderInteractiveFrame writes raw `output` (no
trailing newline) via a direct stdout.write, but then called
`writer.sync(outputToRender)`. `outputToRender` appends "\n" for non-fullscreen
frames, so on a fullscreen→non-fullscreen transition (a fullscreen frame
shrinking below the viewport) sync recorded a state that didn't match the screen:
with a declared cursor (useCursor) it placed the persistent caret one row too
high (buildCursorSuffix with hasTrailingNewline=true, basing the caret on row
`visibleLineCount` instead of the real `visibleLineCount - 1`), and it recorded
previousLineCount off by one so the next frame's erase was eraseLines(N+1) (G46
residue). This is the fullscreen→non-fullscreen sibling of #198.
Fix: sync the SAME string just written (`output`). `outputToRender === output`
whenever the frame is fullscreen or screen-reader, so steady-state fullscreen and
SR are byte-for-byte unchanged (G17: an empty SR frame still syncs "" → zero
lines). Leaving-fullscreen, overflowing, and unmount-clear all write raw `output`,
so syncing `output` is consistent for every clear sub-case.
TDD: a new integration test mounts a fullscreen TTY frame with a declared cursor,
shrinks it below the viewport, and asserts the emitted caret row and the
following frame's erase count. Red before the fix (cursorUp(3)/eraseLines(4)),
green after (cursorUp(2)/eraseLines(3)).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`buildCursorSuffix` computed `moveUp = visibleLineCount - clampedY`, assuming the
cursor rests on the blank row just past the content (row `visibleLineCount`).
That holds only when the frame ends with a newline. Fullscreen frames are written
WITHOUT a trailing newline (render.ts:962 `isFullscreen ? output : output + "\n"`,
and fullscreen is automatic whenever content fills the viewport), so the cursor
stays on the LAST visible row (`visibleLineCount - 1`). The suffix therefore moved
up one row too many: the declared caret landed a row too high, and the next
frame's `buildReturnToBottom` (which already measures from `previousLineCount - 1`)
then undershot the true bottom — erasing/rewriting the wrong rows and leaving stale
content. Reachable by any full-height TUI that declares a cursor (e.g. useCursor).
Found by differential fuzzing the incremental renderer (apply emitted bytes to a
terminal emulator seeded with the previous frame; result must equal a full repaint
of the next frame): 5,666 content mismatches in the no-trailing-newline + caret
regime, 0 once trailing newlines were forced — pinning the cause exactly.
Fix: thread `hasTrailingNewline` to `buildCursorSuffix` (and via `CursorOnlyInput`)
and move up from the real cursor row — `visibleLineCount - 1` when there's no
trailing newline. Defaults to true, so trailing-newline frames (the common
non-fullscreen path) are byte-for-byte unchanged. All log-update call sites pass
the frame's actual trailing-newline state.
TDD: cursor-helpers unit tests for the no-trailing-newline suffix math, plus
frame-writer regression tests that drive a fullscreen frame with a declared caret
through both the first-render and diff paths (red before the fix, green after).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
assertBoxValid (Box) and text.vue's validate() run eager render-time validation
of paint-time VISUAL props (backgroundColor, border fg/bg colors, borderStyle
shape) and throw into the error boundary on an invalid value (e.g. a chalk
modifier name like "bold" used as a color). They were gated only by the per-node
ariaHidden skip (srHidden), not by GLOBAL screen-reader mode.
Under global SR mode (isScreenReaderEnabled; INK_SCREEN_READER=true) vue-tui,
like Ink, linearizes the whole tree to PLAIN TEXT and never colorizes / draws
borders for any node — Ink's colorize path is bypassed entirely, so it never
throws on an invalid color. vue-tui still ran the eager validation for non-
ariaHidden boxes under SR and threw, crashing a screen-reader user out of
accessible content over a paint-only prop value.
Skip the eager visual validation when global SR is on, in addition to the
existing per-node srHidden skip: box.vue gates `!srHidden && (srEnabled ||
assertBoxValid(props))`, text.vue gates `!srHidden && (srEnabled || validate())
&& hasContent`. The validation is all paint-time visual input (no structural
checks), so skipping it under SR is safe and matches Ink.
Verified against real Ink v7.0.4: with INK_SCREEN_READER=true a
<Box backgroundColor="bold"> renders plain text and does NOT throw; without it
Ink throws in colorize.js. This is an alignment fix (removes a vue-tui
over-throw), not a new divergence — the existing ink-divergences entry gets a
factual, unstamped note about the SR carve-out.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
createFocusController()'s subscribe() returned an unsubscribe that did
`set.delete(fn)` but never removed the now-empty Set from the `subs` Map, and
remove(id) never touched `subs` either. useFocus() with no explicit id mints a
fresh `__auto-N` id per mount, so every mount/unmount of a no-id focusable
permanently leaked one empty-Set Map entry — unbounded growth over a long
session (300 mount/unmount cycles leaked 300 empty Sets).
The unsubscribe closure now drops the Set once its last subscriber leaves,
guarded by `subs.get(id) === set` so a stale double-unsubscribe after a
re-subscribe can't delete the fresh subscriber's Set (idempotency preserved).
remove() is left untouched on purpose: useFocus unsubscribes before calling it,
and deleting a Set with live subscribers would silence duplicate-id focus
delivery.
createFocusController + a test-only `__subscriberMapSize()` probe are exposed via
the ./internal entry so a unit test can assert the Map stays flat across 300
cycles, focus delivery still works (notify + re-subscribe re-creates the Set),
stale double-unsubscribe is a no-op, and multi-subscriber Sets are retained until
the last unsubscribe.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`devState` is a module-global shallowRef that the HMR handlers drive to
{type:"error"} / {type:"update"}; nothing reset it on the create path. createApp()
can run multiple times in one dev process (two apps, unmount + re-create, a UI
restart tool, a test run), so a fresh app would inject the previous app's leftover
state and render its old "Build Error" / "[HMR] updated" overlay instead of its own
content — until the next HMR event happened to reset it.
Add resetDevState() (hmr.ts) and call it from render()'s `__VUE_TUI_DEV__` block,
right after initHmrBridge(), so every newly-mounted dev app starts from a clean
status — consistent with the very first app, which sees the module's initial
{type:"ok"}.
Dev-only (the block is gated behind the cli vite-plugin's `__VUE_TUI_DEV__` define).
TDD: the unit test drives a stale error/update via the real vite:error /
vite:beforeUpdate handlers, then asserts resetDevState() clears it (the per-mount
hook render() now invokes).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In non-interactive non-debug mode commits are throttled (renderThrottleMs =
ceil(1000/30) = 34ms). teardown() cancel()s the scheduler, which DISCARDS any
pending trailing-edge commit, and the final-commit gate excluded non-interactive
non-debug — so the non-interactive trailing write emitted frameState.lastOutput
(the last commit that actually ran), a STALE frame. A reactive change deferred to
the trailing edge whose app unmounts within the throttle window was lost on
piped/CI output.
Mirror Ink's settleThrottle: broaden the final-commit gate to run mountedCommit()
in every mode before the trailing write. The non-interactive commit() branch only
refreshes frameState.lastOutput/lastOutputToRender to the current tree and writes
write-once <Static> (it DEFERS the dynamic frame), so the refresh feeds the latest
frame into the trailing write without double-writing it. Verified against real Ink
v7.0.4: the same deferred-then-unmount scenario emits "C\n" (latest); vue-tui now
matches (was "A\n").
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onTick set `isDispatching = true`, ran the subscriber callbacks, then reset the
flag, flushed `pending`, and rescheduled with no try/finally. A throwing callback
skipped all three, leaving `isDispatching` stuck true forever: every later
subscribe/unsubscribe queued into `pending` and never ran, and no timer was ever
rescheduled. One bad tick permanently killed every `useAnimation` instance sharing
the (process-wide) scheduler — a non-recoverable wedge.
Wrap the dispatch loop in try/finally so the scheduler invariants are always
restored and the error still propagates (restore-then-rethrow, mirroring
scheduler.ts `doCommit`). Also advance each subscriber's `nextDueTime` BEFORE
invoking its callback, so a thrower can't leave it in the past and make the
post-throw schedule() re-arm a 0ms tight re-throw loop.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `wrap` prop changes a <Text> node's MEASURED height (the yoga measure
func reads el.props.wrap to pick wrap/truncate/hard layout) but is NOT a
yoga prop, so a runtime wrap-only change took the generic STYLE_PROPS
branch in patchProp: it stored the new value into el.props and called
onCommit() WITHOUT markTextDirty(el). Yoga kept the OLD wrap mode's cached
height while paint rendered with the NEW wrap, so layout and paint
disagreed -- stale blank rows on wrap->truncate, overflow / overwritten
siblings on truncate->wrap.
Mark the text node dirty when the changed STYLE_PROP is `wrap` on a
tui-text node so yoga re-measures. `wrap` is the only STYLE_PROP that
affects measured dimensions (the rest are paint-only), so it is the sole
case.
Verified Ink v7.0.4 has the identical latent bug -- its applyStyles
ignores textWrap and never markDirty()s, so a wrap-only change goes stale
there too. Recorded as a blessed divergence in ink-divergences.md; the fix
matches the layout Ink produces whenever its measure func is invalidated.
Tests (text-wrap-remeasure.test.tsx) reproduce both directions:
RED produced Ink's stale frame ("aaaa …\n\n\nZZZZ"), GREEN the correct
re-measured layout.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
A text-leaf that mounts EMPTY passes insert()'s "must be inside <Text>" guard as
a Vue fragment anchor. If it later becomes non-empty via setText() — e.g.
`<Box><Text>label</Text>{{ maybe }}</Box>` where `maybe` goes ''->'hi' — it was
never re-validated, so non-empty bare text ended up directly under a <Box> and
paint silently DROPPED it (paintNode renders a text-leaf only via a <Text>/
<Transform> parent). Identical content mounted non-empty throws at insert, so the
same content either errored or silently vanished depending on render history.
Fix: setText() now re-runs the SAME rejectsTextLeaf() check insert() and
setElementText() use (the shared helper added in #179), throwing the same error
on an empty->non-empty transition into an invalid context. Throwing in the
patch/render phase is consistent with the "validate at render, not paint"
invariant and routes through the error boundary (rejects) rather than wedging. A
leaf inside <Text>, cleared back to "", or detached is a no-op; the common path
(text inside <Text>) is not rejected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): margin/padding edge removal falls back to the surviving shorthand
Withdrawing a per-edge/axis margin or padding override from a box that still
has a broader shorthand collapsed the edge to 0 instead of falling back. E.g.
`margin={5} marginTop={8}` with marginTop later removed: the setter ran
setMargin(EDGE_TOP, 0), and per yoga edge precedence EDGE_TOP=0 overrides the
surviving EDGE_ALL=5, so the top margin became 0 (the box jumps 5 cells) when
the declarative model (render = f(current props), current = {margin:5}) says 5.
A single per-prop yoga setter can't reconcile an edge that depends on the
specific edge + axis + all-edges shorthand together.
Fix mirrors the existing reconcileBorderEdges pattern: the 14 margin/padding
setters become no-ops, and reconcileMarginEdges/reconcilePaddingEdges recompute
all four physical edges from the box's full el.props with most-specific-wins
precedence (top = marginTop ?? marginY ?? margin ?? 0, ...), zeroing the
composite edges so nothing layers on top. A present-but-non-finite value
(NaN/Infinity) or a withdrawn prop falls THROUGH to the next precedence level,
preserving yoga's prior setMargin(NaN)->fallback behavior; an explicit 0 is
finite and still overrides. margin keeps EDGE_START/END and padding keeps
EDGE_LEFT/RIGHT for left/right, matching the prior setters.
Verified against real yoga-layout@3.2.1 that the SET path produces identical
computed edges as the old per-setter code (no layout regression) across all
combinations and patch orders, and the correct fallback on removal.
This is NOT an Ink-parity item: run against Ink v7.0.4, Ink and pre-fix vue-tui
both collapse to 0 (the identical bug). The fix diverges from Ink by being
declaratively correct under the already-documented G19 reset principle;
recorded in ink-divergences.md alongside the display / flexDirection entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): pin margin/padding spacing to a finite-number contract (Codex review)
The family recompute resolves an edge from a prop only when it is a finite
number (matching the `number` prop type + Ink's number-only spacing); numeric
strings (`margin="5"`) are coerced for Vue static-template ergonomics, but other
non-numeric values (`"50%"`, junk, `""`) are treated as not-set and fall through
to the surviving shorthand instead of being forwarded to yoga.
This makes intentional the behavior change the final review flagged: the OLD
per-setter code incidentally forwarded off-contract strings to yoga (so
`marginTop="50%"` became a percent and `marginTop="foo"` threw). That was
undocumented and non-Ink. Also excludes "" from the present() check so all
non-numeric strings fall through uniformly (Number("")===0 would otherwise
resolve to 0). The numeric/numeric-string SET path is unchanged (re-verified
across all 5040 patch orders).
Tests pin the contract (numeric, numeric-string, "50%"/"foo"/"" fall-through,
NaN/withdrawn fall-through, explicit 0), and ink-divergences.md records it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): make error capture first-wins and crash-safe against a racing unmount
Two confirmed bugs in the InternalErrorBoundary's onErrorCaptured:
BUG #2 — a component error was silently swallowed when host code threw
during an update flush and then synchronously called app.unmount() in the
same task. The exit was routed entirely through `void nextTick(() =>
exitWithError(e))`, so pendingExitError was not recorded until that deferred
microtask ran; the racing unmount's resolveExit() read it as undefined and
RESOLVED the exit promise clean instead of REJECTING with the error.
Fix: record the error SYNCHRONOUSLY via a new recordExitError() bridge
(first-wins: only sets pendingExitError if no exit is already decided), while
keeping teardown DEFERRED via nextTick. Deferring teardown is load-bearing —
teardown()'s final mountedCommit() paints the ErrorOverview frame, and the
boundary's errored->true re-render must commit before it; a synchronous exit
would drop the overview frame on non-interactive/non-debug mounts. Frame/paint
timing is now byte-identical to before in every mode.
BUG #5 — two descendants throwing in the same synchronous flush left the
displayed overview (caught, last-wins) and the rejected error (pendingExitError,
first-wins) disagreeing. Fix: guard the capture body with `if (!errored.value)`
so the first thrown error drives both the display and the rejection (e17).
Tests: the racing-unmount swallow (interactive/debug AND non-interactive/
non-debug), the two-throw display/reject agreement, and frame-painting guards
that pin the overview behavior to main in each mode. Also corrected a stale
exit-chain comment in @vue-tui/testing's render().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): exit() must not clobber an error already recorded by the boundary (first-wins)
Final review found an asymmetry: recordExitError() first-wins-guards its write,
but appContext.exit() recorded the error unconditionally. So a captured throw
(Error1, shown in the overview, recorded via recordExitError) followed by a
racing exit(Error2) before the deferred teardown made waitUntilExit() reject
Error2 while the overview displayed Error1 — the BUG #5 display/reject
disagreement through a different door.
Fix: exit() uses `pendingExitError ??= errorOrResult`, so it keeps a
synchronously-recorded error. Identical to `=` in every other case
(pendingExitError is undefined on a normal first exit()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): stop pathological non-Error throws from wedging the error boundary
A thrown value with a throwing coercion/getter could make three sibling
throw sites in the error-exit/display path re-throw with NO surrounding
try/catch, wedging Vue's post-flush scheduler — the app hangs and
waitUntilExit() never settles:
- messageForNonError's two String(value) fallbacks (a throwing
Symbol.toPrimitive/toString/valueOf) — now routed through a throw-safe
safeString() returning "[unserializable value]".
- isErrorInput's Object.prototype.toString.call (a throwing
Symbol.toStringTag getter), which runs BEFORE messageForNonError on the
error-exit path — now guarded; on throw the value is treated as non-Error
and routed through messageForNonError.
- ErrorOverview's `.stack` read (a throwing `.stack` getter) during render
— now read exactly once under try/catch; on throw it renders header-only.
Tests: unit coverage of messageForNonError plus an end-to-end "does not
wedge" mount test for all three pathological shapes, and an overview-frame
test proving the .stack guard is load-bearing for correctness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): close two more pathological-throw paths in the error boundary (Codex review)
Final review of the wedge fix found two reachable throw sites it hadn't closed:
- isErrorInput: `value instanceof Error` ran OUTSIDE the try/catch, but
`instanceof` invokes the value's [[GetPrototypeOf]], which a Proxy with a
throwing getPrototypeOf trap re-throws — wedging the boundary exactly like the
Symbol.toStringTag case. Wrap the whole body (instanceof + brand check) in one
try/catch → false on throw. (The old "instanceof CANNOT throw" comment was wrong.)
- ErrorOverview source excerpt: a crafted/stale `.stack` can parse to an existing
DIRECTORY, so fs.existsSync passes and fs.readFileSync throws EISDIR during
render — repainting the overview for the EISDIR error while waitUntilExit()
rejects the original (a displayed-vs-rejected e17 disagreement). Guard the file
read; on failure render header-only (no excerpt).
Tests: a Proxy whose getPrototypeOf throws does not wedge; a directory-pointing
`.stack` renders header-only with display==reject; and an e2e assertion that
"[unserializable value]" is both displayed AND rejected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.clear() should wipe the rendered output and leave the terminal caret
HIDDEN, like Ink v7.0.4. Instead vue-tui repositioned and RE-SHOWED the
caret on the now-blank screen.
Same scenario both sides (useCursor {x:5,y:0}, "Hello", columns 40):
Ink clear() bytes: \x1b[?25l \x1b[1B \x1b[1G \x1b[2K \x1b[1A \x1b[2K \x1b[G
vue-tui clear() bytes: ...same... + \x1b[1A \x1b[6G \x1b[?25h (BUG)
Root cause: mountedClear() runs writer.clear() (hide + erase, correct) then
writer.sync(...). vue-tui's sync re-emits the PERSISTENT declared cursor (a
blessed divergence that is correct for repaints, which redraw the content),
so it wrote buildCursorSuffix = reposition + show. But clear() erases WITHOUT
redrawing, so re-asserting the caret floats it on a blank screen. Ink's own
clear()-time sync sees cursorDirty=false and emits no caret for the same
reason.
Fix: add an optional SyncOptions { cursor?: boolean } to log-update's sync
(both the standard and incremental variants) and thread it through
FrameWriter.sync. When cursor:false, sync treats the active cursor as
undefined for that call only: no reposition/show, and (since clear() already
set cursorWasShown=false) no hide either. It does NOT touch the persistent
cursorPosition, so the NEXT real commit re-shows the caret normally. Only
mountedClear() passes { cursor: false }; the clearTerminal/resize sync and
the external-write restoreLastOutput path (which redraw) keep the default
cursor:true, so they still re-assert the caret.
Verified byte-exact against real Ink v7.0.4 across a 10-scenario matrix
(active cursor, no cursor, clear-then-rerender, multiline y>0, {0,0}, two
clears, owner-unmounted, non-interactive/debug no-op, external-write restore,
clear-then-resize). New test: clear-cursor.test.tsx (raw interactive stdout
byte capture; testing lastFrame() is content-only and cannot see cursor
escapes).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the homegrown "Context Engineering" convention with the canonical
Project Context Records (PCR) block in AGENTS.md, and migrate the
.agents/docs/ records to match.
- cross-links: [[wiki-link]] -> relative markdown [name](./name.md)
- provenance: the old "Maintainer decision (DATE): KEEP" markers -> canonical
[VOUCHED @hyf0] stamps (dates dropped, KEEP/OVERRIDE verdicts kept), covering
every variant ((DATE, user-blessed), (maintainer decision DATE), and
"(Decision recorded after review surfaced it.)")
- methodology prose describing the mechanism reworded to the vouch vocabulary
(generic [VOUCHED @handle])
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A vue-tui:request-reload arriving during the Ctrl+C shutdown window
spawned an orphan child the parent never kills. The reload path and the
shutdown path did not compose: handleReloadRequest only consulted the 3s
startup gate, never isShuttingDown, so a reload accepted during the
pm.shutdown() teardown window ran extractBundle then pm.restart() — which
schedules a fresh 100ms restartTimer. pm.shutdown() had already cleared
its own restartTimer at entry, so nothing cancels the new one; it fires
doSpawn() and creates a brand-new child after the parent has exited.
Fix mirrors respawnTick's existing two-check guard:
- Fast gate: compose shouldAccept as
`() => acceptReloads && !shutdown.isShuttingDown()` so reloads are
ignored once Ctrl+C teardown begins.
- Post-await guard (load-bearing): thread isShuttingDown into
ReloadRequestDeps and re-check it after `await extractBundle` and
before pm.setBundlePath/pm.restart, covering the case where shutdown
starts mid-extraction.
The hot handler registration is moved after createShutdown() so the
forward reference resolves (closures read at event time regardless).
Scope is only this reload-vs-shutdown guard; respawnTick, createShutdown
internals, process-manager.ts, and bundle-extractor.ts are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dev-server shutdown path had two teardown bugs:
1. The crash-respawn setInterval was never cleared. During shutdown's
async window (await pm.shutdown(); await server.close()), the 500ms
interval kept firing; if the child had crashed it could call
pm.spawn() and start a BRAND-NEW child while the parent was tearing
down — orphaning that child when the parent exits.
2. shutdown was registered directly as the SIGINT/SIGTERM handler with
no re-entrancy guard. Pressing Ctrl+C twice (common when shutdown
feels slow) invoked shutdown() twice concurrently → double
pm.shutdown()/server.close() and two racing process.exit(0).
Fix: extract a testable createShutdown(deps) factory that owns a
shuttingDown flag (2nd+ invocation is a no-op) and clears the respawn
interval FIRST, before the async teardown window. The respawn-tick body
is extracted into respawnTick(deps), which re-checks isShuttingDown
both before and AFTER the extractBundle await — closing the in-flight
window where a tick already mid-extraction could still spawn after
clearInterval. Both helpers are module-scoped (not re-exported from the
package public index.ts).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setElementText(el, text) removed ALL existing children first, then inserted
a single text-leaf. When `el` is a non-text container (tui-box / tui-static /
root) and `text` is non-empty, the inserted leaf trips insert()'s text-context
guard and throws AFTER the removal loop has already run — leaving the node
half-cleared (original children gone, nothing inserted).
Validate the target context BEFORE the destructive remove so a rejected insert
never leaves the node half-cleared. Extract the text-leaf rejection check into a
shared rejectsTextLeaf() helper used by BOTH setElementText()'s new pre-check and
insert()'s existing guard, so the condition and error message cannot drift.
Empty-string clears, text on a tui-text / inside-text context, and non-container
no-ops all keep their existing behavior.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
calculateLayoutWithContentGuards hides zero-content nodes (setDisplay
DISPLAY_NONE, prior display recorded in `guarded`) inside its for(;;)
loop, but only returns the restore closure on the normal path. If a
later loop iteration's calculateLayout — or a measure func it invokes —
throws after an earlier iteration already hid one or more nodes, the
throw propagated before the closure was handed back, leaving those nodes
DISPLAY_NONE on the live yoga tree. On the next commit
applyZeroContentGuards short-circuits any already-DISPLAY_NONE node, so
they were never un-hidden and the subtree stayed permanently invisible
even after the offending input was removed. The callers wrap the
RETURNED closure in try/finally, which cannot help because the closure
was never returned.
Wrap the loop so any exception restores everything currently in
`guarded` (reverse order, same as the success closure) before
re-throwing, leaving the live yoga tree clean. The original error
propagates unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each vite:beforeUpdate scheduled an unconditional setTimeout to reset the dev
status from "update" back to "ok" after 2s, but never stored or cleared the
handle. Rapid successive updates stacked independent timers; an earlier
update's timer firing while a later update was still showing would reset the
newer status line early (its guard only checked type === "update", which is
still true for the newer update).
Track the pending timer in a module-level variable, clear it at the top of
vite:beforeUpdate before scheduling a new one (so only the latest update's
timer is ever live), and clear it on vite:error (an error supersedes a pending
update->ok reset). Also unref() the timer so it doesn't hold the event loop
open; .unref is optional since the DOM number handle lacks it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
initHmrBridge registered three Vite HMR listeners (vite:error,
vite:beforeUpdate, vite:beforeFullReload) with no idempotency guard and is
called once per createApp() (dev block in render.ts). createApp() can run
multiple times in one dev process — two apps, an app that unmounts and is
re-created, a tool that restarts the UI, or a test run — and Vite's Node HMR
runtime APPENDS listeners with no dedup, so N calls leaked N copies of every
handler permanently. Every later HMR event then ran each handler N times.
Add a module-level boolean guard so the listeners register at most once for
the module's lifetime, regardless of how many times initHmrBridge is called.
Also parameterize the hot context (defaulting to import.meta.hot) so the body
is reachable under vitest, where import.meta.hot is undefined. HotContext is a
local structural type and import.meta.hot is read via a structural cast so the
module type-checks even when imported directly from runtime-tests, whose
tsconfig doesn't pick up env.d.ts's ambient ImportMeta.hot augmentation.
Out of scope: the setTimeout stale-timer in the vite:beforeUpdate handler is a
separate bug left exactly as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vue-tui:request-reload hot handler awaited extractBundle with no
try/catch. extractBundle throws ("No JS bundle found in Vite memoryFiles")
on a transient/broken build, and Vite's hot event emitter does not catch
async handler rejections — so the rejection escaped as an unhandledRejection
that could take down the whole dev process. The crash-respawn interval right
below already guards extractBundle for the same reason.
Extract the reload logic into a testable, module-scoped handleReloadRequest()
(also folding in the acceptReloads startup guard via a shouldAccept predicate)
and wrap the extract/restart work in try/catch: on failure, log via
logger.error and KEEP the previous bundle (no setBundlePath/restart), so a
momentarily broken build no longer kills the dev server. The inline
server.hot.on handler now delegates to it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): serialize extractBundle to prevent concurrent outDir race
extractBundle() wipes (rm) then repopulates (mkdir + writeFile) the shared
outDir in place, and dev.ts calls it from two unsynchronized sources — the
vue-tui:request-reload hot handler and the 500ms crash-respawn interval. When
two calls overlap, a second call's rm() can delete the tree a first call is
mid-writeFile into (ENOENT/EINVAL/ENOTEMPTY), or leave a torn, partially
populated dir that the child then loads.
Serialize extractions through a single module-level in-flight promise chain so
concurrent callers queue instead of racing the shared outDir. The internal
chain swallows errors so one failed run can't permanently wedge the queue,
while each caller still receives the real result/rejection. dev.ts is left
untouched; the external contract (args, output layout) is unchanged.
Adds a test that reproduces the race with staggered concurrent calls: red on
origin/main (rejects with EINVAL/ENOENT/ENOTEMPTY), green with the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli): slim extractBundle race test to fit CI timeout
The race test timed out at 5042ms on the 4-core ubuntu CI runner (vitest's
default 5s testTimeout) — it wrote 200 files x ~8KB across 20 staggered
concurrent calls, ~4000 serial ~8KB writes once extractBundle is serialized.
The race window is driven by the NUMBER of files in the write loop and the
staggered starts (more chances for an overlapping rm to interleave a
writeFile), not by file size. So shrink each file's payload from 8KB to 32
bytes to slash write time, keep FILE_COUNT=200, and trim CALLS 20->16 (the
minimum that still reds reliably). Also add an explicit 30s per-test timeout
for comfortable CI headroom.
Verified: green 5x consecutively (~410ms test time locally, ~1.4% of the
timeout); still red 3x on origin/main with the race signature
(EINVAL/ENOENT/ENOTEMPTY). Assertions unchanged in spirit (none reject; final
dir holds the complete file set). Production code untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
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>
A malformed custom borderStyle OBJECT (e.g. `{ topLeft, topRight }` missing
`top`) — or a truthy non-string non-object value (e.g. a number from a JS
caller) — bypassed assertBoxValid's render-time check, which only shape-checked
the STRING form. It reached drawBorder, passed the `if (!chars)` guard, and
threw `Cannot read properties of undefined (reading 'repeat')` deep in the
post-flush PAINT pass — wedging Vue's scheduler instead of surfacing a
recoverable error, exactly the failure mode box-validate.ts exists to prevent.
Resolve borderStyle to a BoxStyle the same way paint's drawBorder does (string
-> cliBoxes[name], object -> directly), then shape-check the result: every one
of the 8 glyphs paint reads (top/bottom/left/right + the four corners) must be
a string. Any invalid value now throws a clean error AT RENDER, caught by the
error boundary — like the existing unknown-string case. The string case keeps
its "Unknown borderStyle:" wording; the object/non-string case uses "Invalid
borderStyle:".
Test-first: borders.test.tsx now asserts a malformed object, a number, each
individually-missing glyph, and a present-but-non-string glyph all reject at
render (not the opaque paint TypeError), and a complete custom object still
paints a border.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
render() never set `interactive`, so the runtime derived it as
`!isInCi && Boolean(stdout.isTTY)`. `is-in-ci` is evaluated once at module
import, so consumers running @vue-tui/testing in CI silently got a
non-interactive app: `terminal.resize()` emitted but never re-laid-out (the
resize handler is registered only when interactive), and the lifetime
raw-mode hold never engaged (`terminal.rawMode.current` stayed false) —
breaking both APIs the README advertises.
Pin `interactive: options.interactive ?? true` in the mount options so the
harness is deterministic and independent of ambient CI/TTY detection, and
expose `interactive?: boolean` on RenderOptions so non-interactive behavior
stays testable. Runtime behavior is unchanged.
Add a subprocess test (runtime-tests, sequential — depends on the
process-global CI env baked into the child at import time) that spawns the
BUILT dist with CI=true vs CI=false, renders a bordered Box that fills the
columns, resizes 40→12, and asserts the re-layout happened and raw mode is
held. It fails on origin/main (resize ignored under CI=true) and passes with
the fix.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shutdown() force-killed the dev child with SIGKILL after a 2000ms wait but
never asked it to stop first. The dev (parent) process receives SIGINT/SIGTERM
directly, but the child is a plain spawn with no shared signal, so it never saw
the parent's signal — waitForExit blocked the full 2000ms, then SIGKILL
(uncatchable) skipped the child runtime's teardown (restore cursor, leave the
alternate screen, disable kitty keyboard, restore raw mode). Result: a 2s hang
on every clean shutdown plus a corrupted terminal afterward.
Send child.kill("SIGTERM") before awaiting exit, mirroring restart(), so a
well-behaved child exits gracefully and SIGKILL only escalates on a hang.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mount() registers the app as the stdout owner (liveInstances.set) and then
runs holdRawModeForLifetime(), kittyController.init(), and attachYoga()/
setWidth() — all of which can throw SYNCHRONOUSLY on a hostile terminal
(setRawMode raises ERR_TTY_INIT_FAILED on some SSH/container PTYs that
report isTTY=true; kitty enable's stdout.write can throw on a broken
stream) — BEFORE the originalMount try/catch and before the exit/signal
handlers are wired. A throw there skipped teardown(), leaving the
liveInstances entry forever (poisoning the stdout: every later mount()
hit the reuse guard and became an inert no-op), leaking the yoga root,
and leaving raw mode / kitty on.
Wrap those pre-mount steps in the same teardown-then-rethrow guard as
originalMount. teardown() is idempotent and safe at this early stage (it
derives all cleanup from the wired state set so far and guards on
mountedAppContext). Also: assign mountedKittyController BEFORE init() so
an auto-mode detection-query throw (after the stdin listener + timer are
installed) is disposed, and record mountedRoot right after attachYoga
(before setWidth) so the just-allocated yoga node is freed on a setWidth
throw. The original error always survives and is rethrown to the caller.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-edge border props (borderTop/Bottom/Left/Right) reserved 1 yoga cell
whenever truthy, regardless of borderStyle. Since these props default to
`true`, toggling one on an UPDATE while borderStyle stays unset (Vue patches
only the changed per-edge prop, not borderStyle) left a spurious 1-cell inset
with no border ever drawn — content shifted to "\n HELLO" instead of "HELLO".
Mirror Ink's applyBorderStyles: an edge's width is `borderStyle ? 1 : 0`,
forced to 0 when that edge is explicitly `false`. A per-edge toggle can only
SUBTRACT, never add. The per-edge yoga setters become no-ops; patchProp now
recomputes all four edges from el.props on any border-prop change via the new
reconcileBorderEdges helper, so borderStyle flipping in EITHER direction
(set->unset zeroes, unset->set re-reserves) and per-edge toggles are all
handled jointly — the computation a single (n, v) yoga setter cannot do.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the README self-sufficient as minimum docs while preserving the
existing narrative and structure:
- Replace the blanket "not for production" notice with a stability line
that distinguishes the runtime (API stabilizing) from the still
experimental CLI / dev toolkit; mirror this in the Packages table.
- Tighten the dev-toolkit bullet to match the CLI's actual surface
(`vue-tui dev`), which only implements the dev command.
- Add an "Add to an existing project" install step (`@vue-tui/runtime`).
- Document the public runtime exports the README was missing, matching
the coverage of Ink v7.0.4's README: renderToString,
useIsScreenReaderEnabled, and measureElement (kittyFlags/kittyModifiers
stay undocumented, as in Ink's README). Correct the useBoxMetrics row
to its actual return shape.
Reviewer caught a stale comment: it claimed the root-`v-if` fragment's `$el`
"resolves to the real host node". It doesn't — `$el` is the fragment boundary
anchor; measureElement/useBoxMetrics resolve a Box ref by drilling the component
subTree to its first host node. Also fixed a leftover `box.ts` reference
(now box-validate.ts). Comment-only; code was correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review (Codex) flagged that `TuiApp extends Omit<App<TuiNode>, "mount">` surfaces
the internal `TuiNode` host-node type in the published .d.ts (it rides out on Vue's
internal `App._container`). Decision: KEEP it / don't fix.
Rationale: `_container` is a Vue-internal field no consumer touches, so the exposure
is purely cosmetic (zero functional impact), and type-only surface isn't held to
strict SemVer, so it imposes no real contract. Hiding it (`App<unknown>` or a
`Pick<App, …>` allowlist) is ceremony for a cosmetic gain on a pre-1.0 lib.
Documented at the TuiApp definition and in api-contract.md so it isn't re-flagged.
No behavior/type change — just a conscious-decision record.
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>
The renderer's intrinsic elements were named with bare words (box/text/static/
transform/virtual-text), which collide with the same-named public components: a
template `<box>` PascalCase-resolves to `<Box>` under vue-tsc (no isCustomElement
at the type layer), forcing the BoxImpl/TextImpl/StaticImpl workaround.
Prefix the 5 host elements to `tui-*` (mirroring Ink's `ink-box`/`ink-text`):
the prefix + hyphen keeps them in their own namespace, so the components keep
their real names (Box/Text/Static) with no self-recursion — the *Impl rename is
removed. root/text-leaf/comment stay unprefixed (not template tags, not elements).
Mechanics: renamed the TuiNode discriminant literals + factories first, then let
vue-tsc enumerate all 145 stale `node.type === "box"` comparisons (the type-
driven finder also kept `position: "static"` and the ansi-tokenizer's separate
`type: "text"` union untouched). Updated createElement cases, HOST_TAGS,
the .vue templates, transform.ts h(), and raw `h("box")` host-op tests.
Two non-type-checked contaminations the sed caused were caught by tests and fixed:
- patchProp's `key === "transform"` (the PROP name, not the node type) must stay
"transform" — the sed wrongly prefixed it, dropping the transform fn (identity).
- text-measure's `token.type === "text"` is an AnsiToken, not a TuiNode — reverted.
BREAKING CHANGE: the internal host element names are now tui-box/tui-text/
tui-virtual-text/tui-static/tui-transform. Public components (Box/Text/Static/
Spacer/Newline/Transform) and their props/types are unchanged; only raw host-op
callers (h("box") -> h("tui-box")) are affected.
vp run ready green: fmt, lint 0/0, vue-tsc, tests (runtime 350, integration 1161,
PTY 129).
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>
A thrown non-Error whose .message is a string (throw {message:'x'})
displayed 'x' in the ErrorOverview but rejected waitUntilExit() with
new Error(String(value)) = '[object Object]' — display and reject
disagreed. Introduce one messageForNonError(value) helper (string
.message else String(value)) and feed it to BOTH the overview header
and the two non-Error reject-wrap sites, so the shown and rejected
messages can never drift. Overview output is byte-identical (the helper
is the prior inline logic extracted); real-Error, cross-realm, and
no-synthetic-stack paths are unchanged.
Blesses vue-tui's uniform show-the-error-and-reject behavior for any
thrown value (audit e17): Ink instead resolves waitUntilExit() with a
truthy thrown value and silently hangs on a falsy throw — abnormal, so
vue-tui deliberately diverges. Ledger entry rewritten to the full
run-verified scope with Maintainer decision (2026-06-12): KEEP.
Red-first: a consistency test asserting throw {message:'objmsg'} shows
AND rejects 'objmsg'.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A focused input's caret zombied to the bottom-left corner whenever an
unrelated repaint (spinner tick, log line, progress bar) committed
without re-declaring the cursor: the active cursor was gated on a
per-commit dirty/reference change, so an unrelated commit dropped it.
Real terminal programs that own an edit point re-place the caret there
every frame (vim emits an absolute CUP after each repaint, readline
re-lands the buffer offset on SIGWINCH, nano homes to its edit cell).
Match that: the runtime now re-emits the last-declared caret at the end
of every commit until the declaration changes or is cleared, so the
caret survives unrelated repaints in all component topologies. The
position is clamped to the visible region (D5) and a cleared
declaration emits no caret, so teardown still hands the cursor back.
This is a deliberate divergence FROM Ink, which re-asserts only when
the cursor's React component re-renders and so zombies the caret in
sibling/leaf topology too (run-verified). Aligning to Ink reduces bugs
only when Ink is correct; here matching Ink would preserve abnormal
behavior. Overrides the prior 2026-06-01 KEEP, whose rationale (avoid
diverging from Ink in the sibling direction) was overturned by running
real terminal apps. The {x,y} setCursorPosition API is unchanged (it
remains the IME primitive); the fix is an internal per-commit re-emit.
Red-first: a real-TTY PTY test with sibling-topology spinner state
asserts the spinner-only frame ends with the caret-restore suffix.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Run-verified vs Ink v7.0.4 (audit e11 decisive experiment, 3/3 runs
byte-deterministic): the concurrent render option's root-tag half is
inert under react-reconciler 0.33.0 (every root becomes ConcurrentRoot;
hooks and preemption probes behave identically), but its dispatch half
is live — the default commits the first frame synchronously inside
render()/rerender(), concurrent:true schedules it on a later tick.
The old entry described the flag as having no observable surface at
all; vue-tui's mount() matches Ink's default dispatch.
Also append the audit provenance note to the paint-validation entry:
the scheduler-wedge rationale rests on the earlier paint-throw
investigation — the audit probe could not reach a paint throw from
public or raw-host input (paint's border fallback intercepts it).
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>
The commit scheduler armed one fixed trailing timer at the start of a
throttle window, firing the trailing commit at windowStart+wait. Ink's
es-toolkit throttle re-arms on every throttled call: trailing fires at
lastCall+wait. Deterministic probe at maxFps=10 (updates t0/t0+43/
t0+86): Ink trailing median 192.5ms, vue-tui 103.6ms (audit e29).
Mirror the observable timing of es-toolkit's throttle: leading commit
when no window is active, per-call trailing re-arm (lastCall+wait), and
the maxWait edge (a call a full window after the first deferral commits
synchronously) so sustained updates keep the ~wait cadence instead of
debounce-starving. Resize cancellation (a separate blessed divergence)
is preserved: the post-fix cancel probe is byte-identical.
Red test is the discriminating multi-deferred-call shape: a single-
deferred-call test goes green under the wrong firstDeferredCall anchor.
Post-fix probes land at 188.3-189.2ms, inside Ink's 188.4-195.0 band;
CI=true vp run ci passes alongside vp run ready.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The instance-reuse guard set a per-app skippedMount flag that was never
reset, so one guarded mount() call permanently disabled the app's own
teardown. Three run-confirmed wedges (audit e18), all absent in Ink:
- an owner double-firing mount() on its own live stdout kept painting
after unmount() and leaked its registry entry
- an app that once hit the guard could never unmount a later legitimate
mount on a free stdout
- an app live on stream A that merely targeted another app's busy
stream B became unkillable on A
Delete the flag; teardown()/resolveExit() now consult the actually
wired state (mountedAppContext / mountedAsOwner), so a guarded call is
inert for that call only. The blessed inert-no-op divergence from Ink's
reuse-and-rerender is unchanged; the ledger entry is reworded to the
call-scoped semantics.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Run-based audit against pinned Ink v7.0.4 (40b3a75) found two ledger
defects:
- The composable-naming entry claimed Ink names hook return types
XProps; real Ink 7.0.4 is mixed (XProps for stream/app hooks,
UseBoxMetricsResult/AnimationResult/WindowSize for newer ones, and
several hooks export nothing). Restate both sides accurately.
- The <Transform> all-comment-children entry sat under Intentional
Divergence Choices, but its forcing is the React-only false !== null
edge that Vue's comment-vnode materialization cannot see — a
model-implied difference per the doc's own classification flow. Move
it there, expand the run-verified boundary (empty slot array, ''/0
children, screen-reader label), and reword the Non-Behavioral Notes
cross-ref in the same change so it stays true.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>