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>
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>
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>
Findings confirmed by running real Ink v7.0.4 + vue-tui harnesses (and
cross-checked against source), not source-reading:
- nonerror-message: reclassify Additive Supersets -> Intentional. Ink accepts
the same non-Error throw and renders a blank-message overview (same input,
different output), so it is not an additive superset.
- flexdir-reset: reframe as parity through the public <Box>. Ink's Box
re-injects flexDirection:'row'/flexWrap:'nowrap', so removing the prop
resets in BOTH engines (column -> row); the "Ink persists" framing held only
at the raw ink-box host layer. Kept as the explicit contrast to display.
- resize: retitle/reword — scheduler.cancel() is unconditional on every resize,
not narrowing-only; the dedup is driven by overflow + a pending commit.
- usecursor: correct the "Why" — a render-body-set cursor is still dropped on an
ancestor-only commit (Ink re-asserts); the gap is broader than "set-once".
- shallowref-state: useTerminalSize() returns { columns, rows } (object of
refs), not a single shallowRef read as .value.
- composable-naming: the XProps return-type convention holds only for the
stream/app hooks (StdinProps/AppProps); useInput/usePaste/useFocus don't fit.
- renderToString SR: note Ink's live render() does expose isScreenReaderEnabled,
so it's a string-API gap, not a missing SR capability.
- measureElement ref: $el is the primary path (Box is a defineComponent).
- trim two Non-Behavioral bullets that duplicated AGENTS.md house rules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
An explicit `display={undefined}` is applied as DISPLAY_NONE and hides on
mount (verified against real Ink v7.0.4) — only an omitted `display` stays
visible. The previous parenthetical conflated the two.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The composables table was missing four exported composables. Add them
and note that `useTerminalSize` is also exported under Ink's
`useWindowSize` name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified against real Ink v7.0.4 (react 19.2) instead of source-reading:
- display-reset: Ink does not "persist" a withdrawn `display`. Its diff
emits a removed key as `display: undefined`, and applyDisplayStyles
sets DISPLAY_NONE for any non-`'flex'` value, so clearing a previously
set `display` hides the box (verified: flex -> removed goes visible ->
hidden). Describe the real behavior and the common-toggle consequence.
- react-concurrent: drop "Suspense" from the React-only list. Vue ships a
built-in `<Suspense>`; the genuine gap is interruptible concurrent
rendering / `useTransition`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A source-reading audit of the Ink-divergence ledger was wrong on all
three of its highest-confidence calls; running the real Ink/vue-tui
harness overturned them. Add a guideline: behavior claims (parity
assertions, "what Ink does" lines) are hypotheses until a real run
against the pinned version confirms them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.
Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles.
Co-authored-by: Claude <noreply@anthropic.com>
Pausing (isActive→false) in the SAME synchronous batch as an interval change froze
the frame at 0 instead of the last live frame. vue-tui split Ink's single render-time
`shouldReset` into TWO `flush:"sync"` watchers; sync fires once-per-mutation, so
`interval.value = X; isActive.value = false` ran the interval watcher first (while
still active) → erroneous start() zeroed the frame, before the isActive watcher
stop()'d.
Replace them with ONE `flush:"post"` watcher on `[isActive, interval]` that coalesces
the batch and fires once with the final values, mirroring Ink's
`shouldReset = isActive && (intervalChanged || becameActive)` (use-animation.ts:77-96):
paused → stop() (freeze, no reset); active + (becameActive || intervalChanged) →
start(). `immediate:true` keeps the initial mount synchronous (one subscribe, no
double-subscribe).
flush:"post" was verified to fire in the BLESSED standalone (no-component) fallback:
Vue's post-flush queue flushes on any reactive mutation's microtask, independent of
component updates.
Adds tests: batched pause+interval (both orders) freezes; resume zeros then advances
at the new interval; same-interval rerender does not reset; and two standalone
(no-render-tree) cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
`<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>
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>
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>
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>
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>
The package.json `exports` shape — runtime/testing's bare-string target vs Ink's
explicit `types` condition — is build-toolchain plumbing, not a runtime or
user-facing-API behavior. TS resolves the declaration identically via the
`.d.mts`-next-to-`.mjs` adjacency tsdown emits, so nothing is observably
divergent. It fit none of the doc's categories (mis-filed under "Public API
surface") and was an AI-added defensive note guarding a non-event.
Alignment tracks runtime behavior + the user-facing API; packaging/internal
infra (exports shape, ./internal, dist vs build, .mjs vs .js) is out of scope
and isn't recorded as a divergence.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of the "byte-identical reconciler/runtime mechanics" subsection, each entry
verified against vue-tui + Ink v7.0.4 source.
Doc:
- Rewrite the TuiComment/Transform entry cause-first (Vue materializes a comment
placeholder where React renders nothing -> vue makes it inert) for clarity.
- Fix the commit-throttle figure: it is `ceil(1000/maxFps)` = 34ms at the default
maxFps=30, not "~32ms". The 32 was vue's own dead fallback constant, never the
production value; Ink has no 32 either.
- Drop the keyed-lists (LIS) entry: it restated the section header and guarded no
vue-authored code (patchKeyedChildren is upstream Vue).
- Drop the wrapText-truncate and animation-scheduler entries: both are vue-tui
implementation choices, not Vue-vs-React framework differences, and both are
already explained by their in-code comments.
Code (no behavior change; verified by `vp run ready`):
- Remove the dead `DEFAULT_THROTTLE_MS = 32` fallback in scheduler.ts. Production
always passes throttleMs (render.ts derives it from maxFps) and the immediate
path never reads it, so the 32 fallback never gated a frame. Make throttleMs
required; render.ts always passes it (0 when unthrottled).
- Tighten the animation-scheduler ceil comment (drop the "busy-loop" overstatement;
the fractional-delay truncation it describes is real and keeps the Math.ceil).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `useFocusManager().activeId` entry conflated two things and buried the
load-bearing one. Split and reframe:
- The real divergence is framework-semantic, not API-specific: a React hook
re-runs each render so it can return a plain snapshot, whereas a Vue
composable's setup() runs once and must wrap reactive state in a `shallowRef`.
Moved to the Vue != React section as a general rule; `activeId` is now just
one example of it.
- Folded the empty-value convention (`null` vs Ink's `undefined`) into that
entry as a Vue ecosystem idiom rather than a separate headline.
Doc-only.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tui validates invalid render input (a chalk-modifier backgroundColor like
"bold", an unknown borderStyle) at the component-render layer (Box.ts/Text.ts),
not sunk into the paint layer — so a bad value throws where the error boundary
catches it (ErrorOverview → reject waitUntilExit) instead of crashing.
Records the framework-semantic forcing function (vue-tui's paint runs in a Vue
post-flush callback, so a paint-layer throw escapes onErrorCaptured and wedges
the scheduler), notes the React/Vue symmetry (a paint-layer throw is uncatchable
by component boundaries in both engines, not a Vue weakness), and the honest
cost (eager render-time validation over-throws in a few degenerate cases Ink's
lazy paint check never reaches).
Verified against Ink v7.0.4 source; entry reviewed by Codex.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression tests for behaviors the audit found correct-but-unpinned, so the
suite is a strict superset of Ink:
- A07: two <Static> regions both render (the additive divergence)
- B04: Static render-prop index = absolute index across appends; container
vertical padding adds blank rows to the static frame
- B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the
'always' default masks)
- B19: child useCursor unmount emits the cursor-hide escape (stream-level)
- B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and
advances without busy-hang
- B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled
true-path (env tests isolated in a *.sequential file per the global-state rule)
- B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/
useBoxMetrics as inert no-ops (don't throw)
- B30: dedicated columnGap/rowGap props + their removal-reset
Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink
correctness, and process-global isolation.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>