Commit Graph

219 Commits

Author SHA1 Message Date
Yunfei He e4f181f888 fix(runtime): measureElement coerces a non-finite (pre-layout) dimension to 0, not NaN (#191)
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>
2026-06-15 01:25:23 +08:00
Yunfei He 7cab51cc28 fix(runtime): always unmount the tree in renderToString so a paint throw can't leak listeners (#186)
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>
2026-06-15 01:23:23 +08:00
Yunfei He daf5e76dfd fix(runtime): re-validate text-leaf context in setText (empty anchor can't smuggle bare text into a Box) (#185)
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>
2026-06-15 01:21:02 +08:00
Yunfei He 4243cff937 fix(runtime): margin/padding edge removal falls back to the surviving shorthand (#184)
* 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>
2026-06-15 01:18:54 +08:00
Yunfei He 889e0cdb00 fix(runtime): make error capture first-wins and crash-safe against a racing unmount (#182)
* 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>
2026-06-15 01:16:51 +08:00
Yunfei He 670cca402a fix(runtime): stop pathological non-Error throws from wedging the error boundary (#180)
* 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>
2026-06-15 01:14:23 +08:00
Yunfei He be045cfaff fix(runtime): hide the caret on app.clear() instead of re-showing it (Ink parity) (#190)
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>
2026-06-15 01:01:34 +08:00
Yunfei He fb43b6af8f fix(runtime): disable bracketed paste synchronously on signal exit (#173)
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>
2026-06-14 20:13:26 +08:00
Yunfei He df843b67fe fix(runtime): validate custom borderStyle object shape at render (#172)
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>
2026-06-14 19:44:27 +08:00
Yunfei He 71e7d7e2f0 fix(testing): pin interactive:true in render() so resize/rawMode are deterministic (#171)
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>
2026-06-14 19:25:20 +08:00
Yunfei He c37ac910f4 fix(runtime): run teardown on synchronous mount() throw (#169)
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>
2026-06-14 18:56:40 +08:00
Yunfei He a2d33d98fe fix(runtime): gate per-edge border width on borderStyle (align Ink) (#168)
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>
2026-06-14 18:25:28 +08:00
Yunfei He 3aadf55a3e docs(runtime): fix host-tag comment drift after tui- prefix rename
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>
2026-06-14 15:49:08 +08:00
Yunfei He ca63a5d04c refactor(runtime)!: prefix host primitive tags with tui- (align to Ink, drop *Impl)
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>
2026-06-14 15:49:08 +08:00
Yunfei He 9fe6d7cc44 refactor(runtime): author public components as template SFCs (+ integrate main)
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>
2026-06-14 15:49:08 +08:00
Yunfei He f847e17c81 docs(runtime): finish the useWindowSize rename in docs; tidy contract guards (#164)
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>
2026-06-14 02:04:16 +08:00
Yunfei He 0b61ff2bf4 refactor(runtime)!: public-API audit follow-ups — align to Ink, record decisions (#163)
* 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>
2026-06-14 01:40:57 +08:00
Yunfei He dab5125c90 fix(runtime): type Static scoped slots 2026-06-14 01:12:13 +08:00
Yunfei He 5d29240df9 fix(runtime): validate invalid foreground color props 2026-06-13 03:27:48 +08:00
Yunfei He d1fe39f96c fix(runtime): reject non-Error throws with the message ErrorOverview displays (#158)
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>
2026-06-12 21:15:48 +08:00
Yunfei He 86b94b9fa5 fix(runtime): re-assert the declared cursor every commit (persistent declaration) (#157)
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>
2026-06-12 17:08:05 +08:00
Yunfei He e33e1cd7d1 test(runtime-tests): de-flake low-maxFps animation test with event-based wait (#156)
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>
2026-06-12 04:07:33 +08:00
Yunfei He 6a3537023a fix(runtime): re-arm trailing commit per deferred call to match Ink's throttle anchor (#154)
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>
2026-06-12 03:21:35 +08:00
Yunfei He c66cddb676 fix(runtime): derive mount-guard skip from wired state, not a sticky flag (#153)
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>
2026-06-12 02:51:19 +08:00
Yunfei He 814c482d6d fix(runtime): install console patch before first mount so initial [Vue warn] is filtered (#151)
A [Vue warn] emitted during the initial mount (e.g. the missing-render-
function warn from a root setup() throw) escaped the stderr filter
because mount() installed the console patch only after originalMount.
Ink patches in its constructor before the first React render
(ink.tsx:435-436); move the install before originalMount to match.
The mount-throw catch already restores the console via teardown().

Verified red-first against real Ink v7.0.4 (audit e10): Ink's stderr
stays empty for a render-throwing component; vue-tui's initial-mount
warn reached a real PTY before this fix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:23:32 +08:00
Yunfei He 1bd91dbbb9 test(runtime): pin absolute-child position to the padding box; fix wording
Addresses PR review: the containing block for an absolutely-positioned child
is the **padding box** (inside the borders), not the "border-box". Verified by
running yoga (an abs child at top:0/left:0 insets by the border only, never by
padding — confirmed across border/padding combos) and the real Ink/vue-tui
renderers (X lands at the inner-border edge, byte-identical in both).

- Tighten the regression test: exact-frame assertions instead of `toContain`,
  including a border+padding case that distinguishes the padding box from the
  content box — the assertion that would have caught the original wording slip
  (presence-only assertions could not).
- Correct "containing block (border-box)" -> "padding box (inside the borders)"
  in paint.ts, layout-guards.ts, and ink-divergences.md. (The unrelated
  "border-box-like" *sizing* notes are correct and left as-is.)

No runtime behavior change; the code already used yoga's computed position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 0d50fb7e40 fix(runtime): paint position:absolute children in zero-content boxes
The zero-content-area guard (layout-guards + paint.ts) suppressed ALL
children of a Box whose inner content rect collapsed to zero, including
position:"absolute" children. An absolutely-positioned child is placed
against the containing block (border-box), not the content rect, so Ink
v7.0.4 paints it (verified by running real Ink: a w=2 h=2 single-border
box with an absolute child renders "┌┐#\n└X"); vue-tui dropped it,
rendering "┌┐#\n└┘".

- layout-guards: exempt POSITION_TYPE_ABSOLUTE children from the hide loop
  so they keep their layout.
- paint: move overflow-clip setup above the zero-content early-return and,
  in that branch, paint only absolute children (still clipped by
  overflow:hidden, matching Ink) while keeping in-flow children suppressed
  (the blessed degenerate-box divergence).

Flow-child suppression and overflow:hidden clipping both stay Ink-aligned
(verified byte-identical against real Ink). Known limitation: an absolute
descendant nested under a suppressed in-flow child is still dropped (the
flow ancestor is removed from layout) — scoped to direct absolute children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 1fd832d297 fix(runtime): align Ink parity behavior
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>
2026-06-08 12:51:20 +08:00
Yunfei He 1340c049b2 fix(runtime): call onRender before frame writes
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 21:04:34 +08:00
Yunfei He 65ac088001 fix(runtime): align stdout writability guards
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:56:25 +08:00
Yunfei He 941fff1845 fix(runtime): track useFocus autoFocus updates
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:45:57 +08:00
Yunfei He 7bf033b009 fix(runtime): freeze useAnimation frame on batched pause+interval change (Ink parity) (#143)
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>
2026-06-05 13:51:57 +08:00
Yunfei He 7707322382 fix(runtime): align debug-mode unmount byte stream to Ink (#142)
* fix(runtime): align debug-mode unmount byte stream to Ink

In DEBUG mode (non-interactive) vue-tui's teardown emitted neither a final-frame
re-emit nor a trailing newline, while Ink emits both (ink.tsx:749-762 settleThrottle
re-emit + ink.tsx:812-819 `debug ? '\n' : lastOutput + '\n'`). So for a debug app
that renders "Hello" once, Ink's byte stream is "HelloHello\n" but vue-tui's was
just "Hello" — a divergence that matters when porting Ink debug snapshots / CI logs.

- Fire the final-frame re-emit `mountedCommit()` for debug too (was interactive-only).
- In the non-interactive teardown write, emit a bare "\n" for debug (Ink parity),
  keeping the non-debug `lastFrame + "\n"` branch byte-identical.

Because the @vue-tui/testing render() helper captures debug commits via an internal
frame sink, gate the debug commit's two `frameSink?.(...)` forwards on
`!teardownStarted` so the teardown re-emit (a stdout byte-parity FLUSH, not a render)
does not append a spurious entry to the helper's live `frames[]`. `teardownStarted`
is set at the top of teardown() before the re-emit, so this covers EVERY teardown
route (unmount / cleanup / exit / Ctrl+C / signal / process.exit). Both `stdout.write`
calls stay unconditional, preserving byte parity.

Adds a PTY byte-parity test (asserts "HelloHello\r\n") and a testing-helper test
covering all teardown routes (frames.length stable, incl. <Static>).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop CI-fragile exitOnCtrlC frames-teardown case

The Ctrl+C case timed out in CI (waitUntilExit never resolved — stdin/raw-mode
timing is environment-fragile), while passing locally. Ctrl+C routes through the
SAME exit-driven teardown path as programmatic useApp().exit()
(emitInput → appContext.exit() → teardown()), which the remaining exit() cases
already cover, so removing it loses no teardown-route coverage of the
!teardownStarted frame-sink gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:31:35 +08:00
Yunfei He ea5ef18325 fix(runtime): hard-wrap at width 0 must use wordWrap:false (Ink parity) (#141)
`<Text wrap="hard">` measured at width 0 dropped one blank row per interior word
boundary, so it measured a shorter height than Ink. Ink's wrap-text.ts uses
`{hard:true, wordWrap:false}` for `hard` mode and `{hard:true}` for `wrap` mode;
vue-tui's width-0 path (wrapZeroWidthAnsi) always used the `wrap` options
regardless of mode.

Thread the wrap mode into wrapZeroWidthAnsi and select
`{hard:true, trim:false, wordWrap:false}` for `hard` (vs `{hard:true, trim:false}`
for `wrap`) at width 0, matching Ink. The non-zero `hard` branch already used
wordWrap:false, so this makes the width-0 path consistent with it. The re-styling
loop is unchanged (extra blank rows pass through as empty strings).

width-0 hard "a b c" now measures 8 rows (['','a',' ','','b',' ','','c']) like
Ink, not 6. `wrap` mode and all non-zero widths are byte-identical.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:37:43 +08:00
Yunfei He f6a552d855 fix(runtime): don't hide the cursor for an empty interactive app (Ink parity) (#140)
vue-tui hid the terminal cursor EAGERLY at mount regardless of content, so an
interactive app whose root renders nothing emitted `\x1b[?25l` and hid the
user's cursor. Ink hides LAZILY (log-update, on the first render that writes)
and skips log-update entirely for an empty frame, so an empty app emits zero
cursor escapes.

Remove the eager mount-time hide and rely on log-update's lazy hide. That alone
was insufficient: an empty frame becomes "\n", and the old commit gate
`willRender(outputToRender) || isCursorDirty()` was true for "\n", so log-update
(and its lazy hide) was still reached. Align the outer commit gate to Ink's
exact condition (ink.tsx:1094) `output !== frameState.lastOutput || isCursorDirty()`,
comparing the RAW frame; on an empty first commit both are "" so log-update is
never reached. `willRender` is retained only for the inner BSU/ESU wrap gate.

Verified via PTY: empty app = 0 hides; non-empty = 1 lazy hide; useCursor =
hide-then-show within one render (SHOW last, cursor positioned). alt-screen,
screen-reader, and non-TTY cursor behavior unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:21:10 +08:00
Yunfei He c05cb5df36 fix(runtime): correct misleading measureElement() timing guidance (#139)
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>
2026-06-05 10:43:52 +08:00
Yunfei He a31e8fa335 docs(divergences): correct ledger after adversarial re-audit (#138)
Re-verified every ledger entry against Ink v7.0.4. Fixes: MI5 (remove false
"residual false-child divergence", verified by running Ink), MI3 (Model-Implied
-> Intentional), VI2 (-> Non-Behavioral), VI3 (-> Intentional), and an
error-overview test comment (Ink renders blank error.message, not String(value)).
ID4 verified accurate and left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:49:53 +08:00
Yunfei He 7f3b0ca40e chore: align Vue file conventions (#137)
- document Vue file authoring conventions
- rename runtime/example component files to kebab-case
- update imports and docs references
2026-06-05 08:28:59 +08:00
Yunfei He 5d071d44ab docs(divergences): reframe second-mount no-op as an intentional, blessed divergence (#136)
A second mount() on a live stdout stays a warn + no-op (no behavior change).
This reframes it as a deliberate, maintainer-blessed choice instead of the
vaguely-justified entry it was.

- warning (render.ts): rewrite the stderr message to state the situation plus
  the two recovery paths (update reactive state, or unmount() the existing app
  first), replacing the old "unsupported / call unmount() first" phrasing.
- divergence doc: rewrite the Why with the real rationale (it is a misuse path;
  Ink warns it is unsupported too; vue-tui fails safe by keeping the live app
  and warning; there is no clean public path to Ink's reuse-and-rerender under
  the createApp model). Move the entry from "Vue-Idiomatic Choices" to
  "Intentional Divergence Choices"; record "Maintainer decision: KEEP"; tighten
  the inert-handle wording (unmount() only settles its own exit promise).
- tests: retarget the instance-reuse-guard assertions to the new warning text,
  including a negative assertion that previously matched a stale substring.

Verified: vp run ready (integration 1104 + PTY 123 passing, lint/type/build).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:17:53 +08:00
Yunfei He c8c2c4a32d test: backfill Ink-parity coverage gaps (suite as a superset of Ink) (#130)
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>
2026-06-03 03:42:33 +08:00
Yunfei He 5f55b59b70 fix(runtime): backgroundColor of a chalk-modifier name throws (Ink parity, drop A12 divergence) (#129)
Ink's colorize throws on a chalk-modifier-name backgroundColor (e.g. "bold":
'bold' in chalk but chalk.bgBold is undefined -> "chalk.bgBold is not a
function"); vue-tui degraded to bare text. Align: validate backgroundColor at
component render (Text + Box own bg + drawn border edges) so the throw is caught
by the error boundary, not the post-flush paint pass (a throw there wedges Vue's
scheduler — cf. borderStyle #124). Detection mirrors Ink exactly: only the
in-chalk-but-no-bg-method case throws; valid colors / hex / ansi256 / rgb /
[r,g,b] / non-chalk strings and foreground modifiers (color="bold" still bolds)
are unaffected. Border bgs are gated to Ink's render-border conditions
(borderStyle + drawn edge + perEdge ?? general); empty/hidden elements don't throw.

Since Ink throws lazily at paint (with layout/squash info) while vue must validate
eagerly at render, a few degenerate cases (content-area<=0 box, degenerate
top/bottom border, nested-empty text) over-throw on the invalid modifier input —
documented in code as architecturally irreducible. Removes the A12 entry from
ink-divergences.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:09:09 +08:00
Yunfei He d7c62f9b3a fix(runtime): off-spec display value hides, aligning Ink (drop A21 divergence) (#128)
Ink's applyDisplayStyles hides any present `display` that isn't 'flex'
(DISPLAY_NONE); vue-tui hid only on exact 'none', leaving off-spec values
(reachable via TS-bypass — the prop type is 'flex'|'none') visible. Align: the
yoga display setter now hides any present (non-null) value except 'flex',
matching Ink even for non-string junk (display={5}). The blessed A19 divergence
is preserved — a removed/undefined display (null) still resets to the visible
default (Vue can't distinguish display={undefined} from an omitted prop).

Removes the now-obsolete A21 entry from .agents/docs/ink-divergences.md (the
A19 "removed display resets to visible" entry remains).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:19:48 +08:00
Yunfei He 1bad1014da fix(runtime): guard teardown cursor/kitty restore writes against a destroyed stdout (Ink parity) (#127)
Two more teardown stdout writes (besides the paste-disable fixed in #126) were
gated only on isTTY, which stays cached-truthy after destroy()/end() — so a
teardown on an already-gone stdout threw ERR_STREAM_DESTROYED: log-update's
show/hide-cursor restore and kitty-keyboard's async disable-kitty write. Both
now also require !destroyed && !writableEnded, matching Ink's canWriteToStdout
(App.tsx:620, ink.tsx:792). The writeBestEffort-routed restores (alt-screen
exit, non-interactive last frame, final commit) were already safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:04:26 +08:00
Yunfei He d35d81b1a0 fix(runtime): guard bracketed-paste-disable write against a destroyed stdout (Ink parity) (#126)
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>
2026-06-03 01:51:30 +08:00
Yunfei He 6cb7875103 fix(runtime): unknown borderStyle string throws (Ink parity) (#124)
An unknown borderStyle name (reachable only via a TS-bypass — the prop type is
the cli-boxes keyof union) silently degraded to no border (and wrongly reserved
a 1-cell inset). Ink throws a TypeError on it. Align to that "throw on unknown"
contract by validating in the Box component's render, so the throw is caught by
vue-tui's error boundary (onErrorCaptured -> ErrorOverview), like any other
render error — rather than in paint, where a throw would unwind through Vue's
post-flush commit and wedge the scheduler.

The check resolves cliBoxes[borderStyle] and throws unless it's a genuine
BoxStyle (an object with a string `top` glyph), so unknown names, the cli-boxes
CJS-interop `default` self-key, and inherited prototype names (toString,
constructor) all throw; valid names, false/undefined (no border), and a custom
BoxStyle object are unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:27:13 +08:00
Yunfei He eaf05466b0 fix(runtime,testing): debug-mode Ink byte-parity + source-hook frame capture (#123)
Two coupled changes, both about debug mode (which @vue-tui/testing's render()
is built on):

1. Debug stdout is now byte-identical to Ink v7.0.4: the debug commit branch
   re-emits the FULL accumulated <Static> history every frame (not just the
   per-commit delta), writes every frame unconditionally (no FrameWriter
   dedup), and drops the synthetic trailing "\n" — matching Ink's
   `fullStaticOutput + output` (ink.tsx:558, output.ts has no trailing newline).

2. The test frame-capture no longer reverse-engineers frames out of stdout.
   The runtime exposes an internal, per-app frame sink (INTERNAL_FRAME_SINK,
   a Symbol from @vue-tui/runtime/internal; the public MountOptions type is
   untouched). The debug branch hands each committed frame to the sink,
   mirroring the stdout writes. @vue-tui/testing's render() builds
   frames[]/lastFrame() from the sink instead of sniffing stdout.

Why: an isTTY:true test stdout (which render() needs for the interactive resize
listener) lets isTTY-gated escapes — bracket-paste \x1b[?2004h/l from usePaste —
land in a stdout-sniffing capture and pollute frames[]. Capturing at the source
makes frames[] provably content-only regardless of which composables a test
mounts, while public debug stdout stays byte-exact to Ink (escapes still written,
not debug-gated). The test surface stays cleanly tiered (Ink's model): render()
= content; createApp+debug:false = in-process control sequences; PTY = real
terminal. '' floor, verbatim SGR/OSC8, frames[] multi-frame/static semantics,
and terminal.resize() are all preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:37:36 +08:00
Yunfei He 7ace8f735a fix(runtime): setRawMode(false) throws on unsupported stdin (Ink parity) (#122)
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>
2026-06-02 01:04:43 +08:00
Yunfei He 2372b6b03b feat(runtime): own raw mode for the interactive lifetime by default (rawMode option) (#120)
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>
2026-06-01 17:00:09 +08:00
Yunfei He c6be1f5a7a fix(runtime): attach the stdin input listener per-controller so two apps share one stdin (#118)
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>
2026-06-01 11:23:55 +08:00
Yunfei He 835e026336 fix(runtime): sync raw-mode disable at teardown + same-tick swap & focus-nav Ink parity (#117)
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>
2026-06-01 10:29:14 +08:00