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>
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>
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>
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>
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>
The rawMode 'always' divergence entry overstated the Ctrl+C benefit — it framed
"reaching the app's interrupt handler" as the headline, which only applies under
the non-default `exitOnCtrlC: false`. Both Ink and vue-tui default exitOnCtrlC to
true, so by default Ctrl+C exits either way; the lazy-vs-always difference there is
only the exit path/code (graceful 0 vs re-raised SIGINT 130). Reword to lead with
the real default consequence (echo into the frame on no-input screens) and state
the Ctrl+C difference accurately, noting it only bites an app that sets
exitOnCtrlC:false. No behavior change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `rawMode?: 'always' | 'auto'` mount option (replacing the dead, unwired
`rawMode?: boolean`), defaulting to 'always'.
- 'always' (default): the App takes a lifetime raw-mode hold at mount (gated on
interactive + a TTY stdin), so raw mode is held for the whole run regardless of
which input composables are mounted. Keystrokes never echo into the rendered
frame on a no-input/streaming screen, and Ctrl+C is handled consistently on
every screen (e.g. it reaches an agent's "interrupt generation" handler instead
of becoming a kernel SIGINT). Because owning raw mode ref()s stdin, the app
stays alive until an explicit unmount()/exit() — it does NOT auto-exit when idle.
- 'auto': Ink's original lazy model — raw mode is acquired only while a useInput /
useFocus / usePaste is mounted, so a no-input screen returns to cooked mode and a
no-input app auto-exits. The opt-out for inline / render-and-exit tools.
This is a deliberate divergence from Ink (the cross-framework norm — Bubble Tea,
Textual, Ratatui, prompt_toolkit all own the terminal for the program lifetime;
Ink's hook-driven model is the outlier). Documented in
.agents/docs/ink-divergences.md.
Implementation: the App holds a `lifetimeFloor` ref via holdRawModeForLifetime();
input composables stack above it. The per-consumer clearInputState is re-based to
the floor so a buffered partial escape (e.g. a lone ESC at a screen transition)
can't bleed into the next consumer — cleared both when the last consumer releases
and when the first consumer re-acquires above the floor (covers same-tick swaps
AND a delayed idle→input transition). The data listener and raw toggle stay on
until teardown, where dispose() releases the floor ref (raw disabled + stdin
unref'd exactly once).
Tests: rawMode-lifecycle ('always' holds raw with no input; 'auto' stays cooked;
no mid-session oscillation; no partial-escape bleed across a swap or an idle gap);
PTY exit-rawmode-always (a no-input 'always' app stays alive and exits on Ctrl+C).
The 6 auto-exit PTY fixtures are pinned to 'auto' (they model render-and-exit).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an Additive-features entry documenting that two apps sharing one stdin
(different stdout) both receive input in vue-tui, where Ink's first-registered
'readable' listener drains the buffer (second app deaf until the first
unmounts) and its per-App raw-mode count drops raw mode on the first unmount.
vue refcounts the raw-mode toggle per-stdin (shared) and attaches the 'data'
listener per-controller, so the push event broadcasts to both. Implemented in
#118; single-app behavior is byte-identical.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The round-2 parity audit surfaced ~15 places where vue-tui deliberately differs from
Ink v7.0.4 (verified against Ink source, not invented). Document them so they aren't
re-"fixed" back into Ink's behavior:
Public API surface: useFocusManager().activeId is null (reactive ShallowRef) not
undefined (+ lock test); second mount() on a live stdout is an inert no-op vs Ink
reusing the instance; bare-string package exports vs an explicit types condition.
Additive (strict superset): RGB [r,g,b] tuples on every color prop (Ink string-only,
throws on an array); backgroundColor=chalk-modifier-name degrades to bare text (Ink
throws); useAnimation outside a tree drives a real scheduler; measureElement/
useBoxMetrics also accept a Vue component ref via $el; renderToString accepts
isScreenReaderEnabled; narrowing resize cancels the redundant trailing clearTerminal.
Framework-semantic (Vue ≠ React): an off-spec display value stays visible (Ink hides
any non-'flex'); out-of-type flex/align values are forwarded not defensively coerced
(only flexShrink — flexGrow matches Ink; reachable only via TS-bypass); duplicate
explicit-id useFocus dedups to one entry; the terminal-bound composables fail fast
outside a tree (useBoxMetrics/useAnimation degrade); a setup()-throw emits a dev-only
[Vue warn]. Plus wrapText truncate's per-line short-circuit and the scheduler's
ceil'd delay under reconciler mechanics.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigating P15 (a stable-reference cursor dropped on an unrelated commit) found
the premise was false: Ink does NOT re-assert the cursor on every commit. Ink's
useCursor uses a no-deps useInsertionEffect that re-runs only when the cursor
COMPONENT re-renders. React re-renders a whole subtree on an ancestor's commit, so
Ink re-asserts when the cursor is in that subtree — but when an unrelated SIBLING
owns the changing state, the cursor component does not re-render and Ink drops the
cursor too. vue (watch on positionRef) already matches Ink in that sibling case and
for the recommended reactive usage; the two differ only in the narrow edge of a
set-once cursor plus an ancestor-driven commit (Vue's fine-grained reactivity vs
React's render cascade). A global per-commit re-assert would diverge from Ink in the
opposite (sibling) direction. So this is an unavoidable Vue ≠ React consequence:
document it and keep the reactivity-tied behavior rather than "fix" it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink's <Transform> returns null (no node) when children are undefined/null, and
that guard runs BEFORE the accessibilityLabel substitution (Transform.tsx:28-30).
vue always created a "transform" host node, so:
- an empty <Transform> in a flex `gap` row consumed a gap slot Ink never adds (P13); and
- a childless <Transform accessibilityLabel> emitted the label even though Ink's
null guard wins over it (P19).
Add the null-children guard at the top of the render fn. Vue materializes a bare
null/false/undefined/v-if=false child as a single Comment vnode and cannot tell them
apart, so the predicate treats the whole group as "no children" (slot undefined OR
every vnode is a Comment) — matching Ink for the common `{null}`/`{cond ? x : null}`
idioms and keeping <Transform> consistent with vue-tui's documented comment-anchor
model (every other component already omits a false/v-if child). An empty-string ({''},
a Text vnode) or JSX empty array ({[]}, a Fragment) still renders, matching Ink.
This deliberately diverges from Ink only for a literal {false} / {cond && x}-false
child (React's false !== null → Ink renders an empty gap-slot node); documented in
ink-divergences.md and locked by a test, since Vue physically cannot distinguish it
from null.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The parseKeypress kitty query-response filter (ESC[?Nu -> {ignore:true}, dropped by useInput)
was assumed to be a redundant second net duplicating the upstream kitty-keyboard detection
strip. Verified it is NOT redundant: the upstream strip runs only in the one-shot
confirmKittySupport detection listener (a private buffer), not on the steady-state input path
(stdin 'data' -> inputParser -> emitInput -> useInput -> parseKeypress). Removing the filter
leaks a stray query-response to handlers as spurious "[?1u" input in enabled mode, auto mode,
and split delivery (inputParser reassembles, so the upstream partial-handling doesn't apply).
Ink has the same gap (its parse-keypress has no query filter; its steady-state input uses
'readable', not 'data').
Keeps the filter (adds a why-comment) and records it as an additive divergence in
ink-divergences.md. Adds 4 end-to-end regression tests proving the filter is load-bearing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tui left `display` out of RESETTABLE_PROPS, so a removed/undefined `display` persisted
its prior value (a removed display="none" stayed hidden). Adds `display` to RESETTABLE_PROPS
-- the setter already maps undefined -> DISPLAY_FLEX -- so a withdrawn `display` returns to
the Box default (visible), per render = f(current props), like flexDirection/flexWrap (G19).
Deliberate, documented divergence from Ink (which hides on a present-undefined `display` via
DISPLAY_NONE, and persists on omitted) -- recorded in ink-divergences.md. The reset is
consistent across the visual and screen-reader paths (both read yoga's display state).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ErrorOverview was a one-line `Name: message` stub; Ink v7.0.4 renders a full
ERROR overview. Ports it faithfully:
- white-on-red ` ERROR ` label + message, dim cwd-relative file:line:column origin
- a code excerpt around the throwing line (padded gutter, error line highlighted,
`Line N` / `Line N, error` aria-labels)
- the parsed stack (`- fn (file:line:col)`, cwd-relative, StackUtils nodeInternals
filtering, unparsable-line fallback, fs.existsSync guard)
Adds code-excerpt@4.0.0 + stack-utils@2.0.6 (the versions Ink uses). The error
boundary now keeps the raw thrown value for display (Ink stores the raw value;
ErrorOverview renders a stack only when one exists) -- so a non-Error throw no
longer shows a misleading synthetic framework stack. The exit/reject path still
receives a wrapped Error (semantics unchanged). The unparsable-stack fallback
emits a literal backslash-t to match Ink's JSX.
Showing String(value) for a non-Error message (vs Ink's blank) is a documented
additive divergence (ink-divergences.md).
Adds 4 error-overview tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useStdin() returned the full internal StdinContext (8 members incl. the raw-mode
ref-counting primitives acquireRawMode/releaseRawMode, setBracketedPasteMode, and
internal_eventEmitter/internal_exitOnCtrlC). Ink's useStdin() returns only its PublicProps
— { stdin, setRawMode, isRawModeSupported } — keeping the rest on the internal context,
reached via the internal useStdinContext()/inject. Verified against Ink 7.0.4
(src/hooks/use-stdin.ts:10, src/components/StdinContext.ts).
- Narrow useStdin(): UseStdinReturn (the 3 public fields). The full StdinContext stays
internal, reached by useInput/useFocus/usePaste via inject(StdinContextKey) — the runtime
object is unchanged, only the public type narrows (mirrors Ink's type-level narrowing).
- Name every stdio/app composable return type per VueUse's UseXReturn convention and export
them: UseStdinReturn, UseStdoutReturn, UseStderrReturn, UseAppReturn (shapes byte-identical
to Ink's StdinProps/StdoutProps/StderrProps/AppProps). vue-tui reserves XProps for component
props (BoxProps, via ExtractPublicPropTypes), so composable returns use UseXReturn — the
Vue-community-idiomatic name.
- Unify the two pre-existing return types onto the same convention:
AnimationResult → UseAnimationReturn, UseBoxMetricsResult → UseBoxMetricsReturn.
- Type-level test (public-types.test-d.ts) locks the shapes and asserts useStdin()'s public
return excludes the internal members.
- Docs: trim the type-reexport divergence area to genuine divergences (fold
RenderOptions/Instance → MountOptions/TuiApp into createApp; keep DOMElement → TuiNode);
drop the AppProps/StdinProps/StdoutProps/StderrProps "N/A" entry — those Ink names are the
hook return types, now mirrored as UseXReturn (recorded under Framework idioms). Supersedes #77.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The placeholder listed three candidates to evaluate as by-design divergences:
v-if/null comment host nodes, reactivity-driven re-render timing, and keyed
reconciliation order. Grounding each in the runtime code shows all three produce
byte-identical terminal output vs Ink — a commit always paints f(current host
tree), so *how* the tree was built never reaches the terminal. None is a
divergence, so the placeholder is removed and the three are recorded as concise
one-liners under "Framework idioms (noted, not behavioral divergences)":
- v-if=false / null|false|undefined children become an inert Comment vnode
(TuiComment) — no yoga node, paints nothing, doesn't shift a sibling's yoga
index, skipped for the positional <Transform> index (G52); output equals
omitting the element.
- Commit timing is deliberately Ink-aligned (~32ms ceil(1000/maxFps) throttle,
sync resize), so Vue's fine-grained re-render granularity stays unobservable.
- Keyed lists use Vue core's patchKeyedChildren, not React's fiber diff; output
depends on the final tree, not the move order.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the exitOnCtrlC guard into the always-on stdin controller (emitInput),
encoding-agnostic via parseKeypress, so Ctrl+C exits under both the legacy
\x03 byte and the kitty CSI-u form regardless of which composable holds raw
mode (useInput / useFocus / usePaste, or none). Single source of truth —
dropped from useInput. Excludes Ctrl+Shift+C; fast-paths \x03 and only parses
escape-prefixed sequences. Adds TDD PTY coverage and updates the divergence doc.
Also: stop tracking docs/superpowers/plans/2026-05-27-ink-test-parity.md —
docs/ must stay out of git (AGENTS.md); it was committed before .gitignore
covered it.
Ink has no reset branch for these two props (every other flex prop does),
so explicit `={undefined}` leaves a stale value. vue-tui resets to the
row/nowrap default. Why: the render is a function of the current props —
absent a special contract, dropping/changing a prop changes the output;
Ink keeping a previous render's value is the anomaly. KEEP.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#69 added `useAppContext()` as a Vue-native rename of Ink's `useApp()`,
qualified to avoid reading as the Vue application instance. On reflection the
"Context" suffix borrowed the name of an internal grab-bag context and slightly
mislabels the hook — it returns app lifecycle controls, not that context. The
collision worry doesn't hold up: Vue has no `useApp()`, the returned
`{ exit, waitUntilRenderFlush }` is clearly not the Vue app instance, and "App"
in vue-tui already means the `TuiApp` from `createApp()`.
Rename to `useApp()` for full Ink fidelity (same name + same shape), and drop
the now-defunct "App composable" entry from ink-divergences.md — it ceases to
be a divergence.
Internal context cleanup (the grab-bag `AppContext` + the `StdinContext`
duplication) is intentionally out of scope here, tracked separately.
BREAKING CHANGE: `useAppContext()` is renamed to `useApp()`. Replace
`const { exit } = useAppContext()` with `const { exit } = useApp()`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The "Accessibility props — AriaRole / AriaState" entry claimed "Ink: no
equivalent" and filed aria/screen-reader support under additive features. That
is incorrect: Ink has full accessibility support at this doc's own baseline
(v7.0.4, commit 40b3a75) — aria-label/aria-hidden/aria-role/aria-state on
Box/Text, useIsScreenReaderEnabled, and screen-reader linearization in
render-node-to-output. vue-tui's implementation is a faithful port of it; the
parity commits G03–G59 align the linearization with Ink (e.g. G22's parent-role
dedup matches Ink's `role !== parentRole`).
Accessibility is parity-by-default, so it does not belong in a doc that records
only intentional divergences. Remove the entry; the feature itself is unchanged.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.
- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
impl shared by the `TuiApp` handle and the composable, so both resolve
identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
(`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
surface one — and fix the prior wrong claim that Ink's `useApp` returns
stdin/stdout/stderr
The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.
BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
vue-tui withheld Ink's named prop types (BoxProps, TextProps, StaticProps,
TransformProps, NewlineProps) and the WindowSize/CursorPosition data shapes
under a blanket "avoid React-shaped type names" rule. That rule over-reached:
a <Box> has props in Vue exactly as in React, so those names carry no
React-vs-Vue content — there's no reason to rename them. Re-export them under
Ink's names so a consumer can name a component's props the same way as in Ink.
- Derive each XProps from the component's runtime `props` object via Vue's
`ExtractPublicPropTypes`, so the public type can never drift from the real
props. Pin `required: true as const` on Static.items / Transform.transform:
a standalone `const` widens `true`→`boolean`, which would otherwise drop them
from the required keys — in both the exported type AND the component's own
`setup(props)` typing.
- Add `WindowSize { columns, rows }` and `CursorPosition { x, y }`, anchored to
their real usage in useTerminalSize / useCursor. (The composables still return
reactive refs of these — the data shape matches Ink; the ref wrapper is the
framework difference.)
- Keep the genuinely-divergent names as-is: DOMElement→TuiNode (real
DOM-emulation vs host-node difference), RenderOptions/Instance→MountOptions/
TuiApp (downstream of createApp()), App/Stdin/Stdout/StderrProps = N/A. Rewrite
the ink-divergences doc to record what's now aligned vs still divergent.
- Add a tsc-checked type-level test (public-types.test-d.ts) asserting the
exported shapes; it is excluded from vitest's runtime run by naming.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink keeps its `measure-text` module internal and never re-exports it. vue-tui
exported `measureText` / `measureTextNatural` from the public index under the
mistaken belief — stated verbatim in commit 0e7d775's own message — that doing
so "matched Ink's public API". It does not; Ink keeps that module internal. A
later design doc then rationalized the leak post-hoc as an intentional
divergence. It was neither intentional nor a divergence — it was a mistake.
Align with Ink:
- Drop both from the public index. `measureTextNatural` stays as an internal
helper (yoga.ts uses it). `measureText` had zero production callers (yoga uses
`wrapText` + `measureTextNatural`, never `measureText`) and is removed.
- Integration tests that used `measureText(stripAnsi(x), 9999).width` as a
line-width helper now use `stringWidth(stripAnsi(x))` directly.
- public-api.test.ts gains a regression test asserting neither is exported.
- Remove the now-obsolete entry from .agents/docs/ink-divergences.md.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: remove Ink-parity design docs (.agents/docs), keep the code
Removes the Ink-parity loop documentation — ink-parity-loop.md, ink-parity.md,
and the parity-ledger.md — and drops the now-dangling 'Current docs:' list from
AGENTS.md's Context Engineering section (the convention itself is kept). All the
merged parity CODE fixes remain on main; only the documentation/ledger artifacts
are cleared, to re-orchestrate the documentation with a different approach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: add focused Ink intentional-divergences design doc
Replaces the removed sprawling parity docs with one focused doc that records
ONLY where vue-tui deliberately differs from Ink (API surface, additive
features, unavoidable Vue-vs-React semantics, N/A React concepts, framework
idioms) — leaving placeholders for the maintainer to supplement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>