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>
Ink's setTextNodeValue (dom.ts) coerces any non-string to String(text) before
storing it, on both the create and update paths; vue-tui stored the raw value.
Coerce at both host text sinks — createTextLeaf (create) and setText (update) —
matching Ink. Reachable only via a TS-bypass (Vue stringifies text/number
children before the host op), so it's a defensive safety-net; typeof-guarded so
string values are stored unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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 terminal raw-mode toggle is refcounted per-stdin (a WeakMap shared across
controllers) so one app's unmount can't drop raw mode while another still needs
it — vue's deliberate improvement over Ink, whose per-App counts let the first
unmount disable raw for everyone. But the "data" input listener was ALSO gated
on that shared refcount, so when two apps (separate createApp/stdout) shared one
stdin, only the first app's handleData ever attached and the second was
permanently deaf — and it didn't even self-heal when the first unmounted (Ink at
least does, via its per-App readable listeners).
Make the "data" listener (and its synchronous clearInputState cleanup: parser
reset, pending-escape flush, listener detach) PER-CONTROLLER, gated on this
controller's own localRefs, while keeping the raw-mode enable/disable on the
shared refcount. Because vue uses the "data" (push) event, every listener gets
every chunk, so both apps now receive input — strictly better than Ink's
"readable" (pull) model where the first-registered listener drains the buffer.
Single-app behavior is byte-identical: with one controller localRefs and the
shared refs move 1:1, so the same-tick swap (parser reset + listener re-attach)
fires at exactly the same moments as before.
Test: two apps sharing one stdin both receive a keystroke, and the second keeps
receiving after the first unmounts while raw mode stays enabled (shared ref);
raw mode disables only when the last app unmounts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three small raw-mode/focus corrections, each aligned to Ink v7.0.4 and
covered by a test (TDD red→green where reachable).
1. Raw mode left ON after a synchronous signal exit (Ctrl+C). The terminal
raw-mode disable is deferred to a microtask (so it survives same-tick
component swaps), but on the signal-exit path teardown(true) re-raises the
signal synchronously without draining microtasks, so the disable never ran
and the shell stopped echoing after Ctrl+C. dispose() now forces the disable
SYNCHRONOUSLY when raw mode is no longer owned (state.refs === 0 and either
this dispose released the last ref or a release left pendingDisable set),
mirroring Ink's unmount-cleanup guard `rawModeEnabledCount > 0 ||
pendingDisableRawModeRef.current` (App.tsx:626-631). The disable stays gated
on the SHARED refcount, so a multi-app teardown can't disable while another
app still holds raw mode.
2. Same-tick useInput swap re-issued setRawMode(true) + stdin.ref() and leaked
a libuv ref (the deferred disable bailed on refs>0 and never unref'd). Added
a pendingDisable flag to RawModeState mirroring Ink's pendingDisableRawModeRef
(App.tsx:331-344): on re-acquire while a disable is pending, skip
ref()/setRawMode(true) and cancel the queued disable.
3. focusNext/focusPrevious start-index logic factored into a shared
startSearchIndex() helper so the two directions stay symmetric. Behavior is
identical for all reachable states; it additionally folds the (unreachable
while the activeId invariant holds) "activeId not in list" case into the
same branch instead of diverging per-direction.
Tests: raw-mode-lifecycle.test.tsx (swap no-op, replacement still receives
input, synchronous teardown disable); programmatic-focus.test.tsx (no-active
first/last targeting + active-focus step/wrap via the manager API).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final round-2 test-only batch (behaviors already at parity with Ink reconciler.tsx,
measure-text.tsx, flex-*.tsx, overflow.tsx, build-output.ts):
- build-output: every package.json export target resolves on disk (runtime/cli/testing)
+ the .d.mts declaration sibling for the typed libraries (runtime/testing, not cli).
- reconciler: keyed insert-between [a,c]→[a,b,c]; replace a colored <Text> child with a
plain string; setElementText A→B + the text-context guard; marginLeft removal reset.
- measure: empty <Text> contributes height 0 in a column; non-zero left (marginLeft=5 →
5,1); measureTextNatural trailing/only-newline heights.
- flex: alignSelf='auto' == default + alignSelf removal resets to AUTO; the two
space-around known-yoga-bug cases converted from test.skip to test.fails (they assert
the DESIRED output and flip to a real failure if yoga ever fixes the bug); the documented
flexDirection/flexWrap removal-reset divergence (was comment-only) now has a visual lock.
- overflow: out-of-bounds writes produce Ink's exact clipped frame (sparse past-width cell,
filtered hole) — tightened from toBeDefined().
- components: inline + top-level non-empty fragment in <Text>; the previously-skipped
ST-terminated OSC-8 hyperlink hard-wrap now passes ('abcde\nfghij') — un-skipped as a lock.
Codex-reviewed GENUINE.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 test-only locks (behaviors already at parity with Ink test/render.tsx,
exit.tsx, errors.tsx, hooks.tsx):
- resize re-render reflows content to the new width (exact narrowed round-border bytes)
+ consecutive width decreases each clear; onRender fires exactly once per rerender
(tightened from `>`); onMounted runs before the first frame write callback (#596);
bsu/esu wraps a trailing throttled content change, and an unchanged trailing rerender
emits neither; patchConsole puts a log above the live frame.
- exit-with-static #397 non-duplication (A/B/C each render once) — the fixture is fixed
to function slots so no [Vue warn] pollutes stdout; exit-with-thrown-error via run()'s
strict exit-0 gate; DEV is inert; the process stays alive ~500ms while raw mode is held.
- raw mode is disabled on the thrown-error cleanup path; the unhandledRejection test moved
to a *.sequential file (it mutates a process-global listener).
- alternate-screen enter sequence hides the cursor; useStdout().write() preserves the
frame with the external write ordered above it.
Codex-reviewed GENUINE.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 test-only locks (behaviors already at parity with Ink):
- parse-keypress (Ink parse-keypress.ts): Ctrl+F1–F4 (\x1b[1;5P/Q/R/S → f1–f4 ctrl),
unmapped ctrl (\x1b[1;5I/X → name '' ctrl), Shift+F1 (\x1b[1;2P → f1 shift).
- terminal-size (Ink terminal-resize.tsx): 0-columns → positive fallback;
resize-listener returns to baseline on unmount; env.LINES rows fallback (a .sequential
file — mutates process.env/stdout; deletes absent env vars in teardown to avoid pollution).
- use-animation (Ink use-animation.tsx, a .sequential file with deterministic fake timers):
newly mounted/activated same-interval animations don't inherit elapsed time
(firstFrame - secondFrame === 1); a re-render with an unchanged interval doesn't reset
the frame (forced via an unrelated reactive dep — a same-value assign is a Vue no-op);
reset is a stable reference across re-renders (collected in the render fn).
- use-input: plain arrows assert key.meta === false (Ink's `&& !key.meta` gate).
Codex-reviewed; the same-value-no-reset (was vacuous) and env teardown (left "undefined")
were tightened per its notes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 test-only locks (behaviors already at parity):
- focus (Ink test/focus.tsx): activeId resets to null on Esc, updates on programmatic
focus(id), resets on unmount of the focused item, is null initially then Tab lands on
first; Esc does NOT clear focus while focus management is disabled; focus(id) targets
a deactivated (isActive=false) item (membership-only, ignoring isActive).
- screen-reader (Ink test/screen-reader.tsx) via the live renderToString component path:
aria-label-only Text/Box, display:none subtree skipped, column>row space-join,
single-box aria-states (checked/selected/multiselectable + multi-state ', '-join),
role-only button, Transform accessibilityLabel replaces children.
Codex-reviewed GENUINE (expected strings match vue output + Ink intent).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 test-only locks tightening lax `.toContain()` to exact equality, mirroring
Ink's `t.is` assertions (all behaviors already at parity):
- cursor-helpers: buildCursorSuffix/buildReturnToBottom/buildReturnToBottomPrefix/
buildCursorOnlySequence exact full output + show/hide cursor constant literals.
- render-to-string: column "Line 1\nLine 2", paddingLeft " Padded", a byte-exact
single-border 20-wide frame, and byte-exact gap wrap ("A B\n\nC") + column ("A\n\nB")
(the trimLines live tests can't catch trailing-space regressions).
- text: OSC-8 link exact bytes; a new RIS/ESC-c strip test (the existing test only
covered the ESC#8 leg).
- box-in-text validation: the exact "Text string \"…\" must be rendered inside <Text>
component" message via anchored regex (catches extra prefix/suffix, not just substring).
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>
The whole-op horizontal clip skip used `x >= clipH.x2`, dropping a write op that
starts exactly AT the right clip edge. Ink's skip is strict `x > clip.x2`
(output.ts:188): at x === clip.x2 it proceeds to clip each line to empty
(sliceAnsi 0,0) and runs the transformers on the empty slice. A transformer that
produces output from empty input (e.g. `() => '中'`, `s => s + 'X'`) emits at the
clip edge in Ink but was short-circuited away in vue. Changed `>=` to `>`. Normal
and identity ops at x === clip.x2 still emit nothing (empty slice →
characters.length === 0 → skip); the inner per-line clip already used strict `>`.
Tests lock the transform-on-empty + append-on-empty cases (Ink " 中" / " X")
and the identity/plain controls (""). Also drops a stale comment reference to the
deleted parity-ledger.md.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
is-in-ci feeds the `interactive` default (render.ts:503) and shouldSynchronize
(write-synchronized.ts:9). vue pinned ^1.0.0 vs Ink's ^2.0.0, whose CI-detection
formula differs: v1 scans for any `CI_*`-prefixed var and gates the whole expression
on CI not being falsy; v2 independently checks `CI` and `CONTINUOUS_INTEGRATION` and
drops the prefix scan. The common cases (local CI=false, GitHub CI=true) are identical
on both, so the suite and CI are unaffected; only edge env configs diverge. Bumping to
^2.0.0 makes vue's CI detection byte-identical to Ink's. Locked by a test
(CI=false + CONTINUOUS_INTEGRATION=true → true, which v1 would report false).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink's focusNext returns `findNextFocusable(...) ?? firstFocusableId` (App.tsx:455-487),
so it ALWAYS reassigns activeId — to the next active, the first active, or undefined
when NO focusable is active. vue's focusNext/focusPrevious only did `if (next)
setActive(next)`, so when no focusable was active (reachable via focus(id) pinning an
isActive=false item) the stale activeId was left in place.
Call setActive(findNextActive(...)) unconditionally — a null result now clears the
stale activeId (and fires the blur notification Ink also fires), matching Ink. Normal
Tab cycling among active items is unchanged (findNextActive already wraps to the
first/last active, equivalent to Ink's `next ?? first`). The focusables.length===0
guard is kept (activeId is already null at 0 focusables via remove(), and it avoids a
%0 in the wrap arithmetic).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the screen-reader locking tests omitted from #105 (the file was not staged):
a childless <Transform accessibilityLabel> emits nothing in SR mode (Ink's null
guard wins over the label), and a control proving the WITH-children label path is
unchanged. The behavior itself shipped in #105.
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>
normalizeInterval rounded the interval (Math.round), so a 60fps interval (16.67ms)
became 17ms and 8.4ms became 8ms — drifting frame=floor(elapsed/interval) and the
scheduler's nextDueTime over time. Ink's normalizeAnimationInterval
(use-animation.ts:147-151) does not round. Removed Math.round; the clamp
(>=1, <=MAX_TIMER_INTERVAL) is unchanged and the scheduler already ceil()s the
setTimeout delay so a fractional interval doesn't busy-loop.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 0-width text container (flexBasis=0, width=0, width="0%", a negative parsed
percent) DROPPED its text in vue where Ink wraps it onto its own row. Ink's wrapText
has no width<=0 guard: wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" (height 2),
so the glyph occupies a second row and a row-sibling renders "B\nA". vue collapsed it
to height 1 (wrapText's `width <= 0 -> [""]` guard), then the paint clamp re-collapsed
the wrap, so the sibling overwrote the text -> "B".
Fixes, all confined to the width<=0 branch:
- text-measure.ts: drop the `width <= 0 -> [""]` guard. A styled string can't go
through wrapAnsi at width 0 (wrap-ansi@10 byte-splits SGR codes -> garbage like
"B\n["), so the wrap/hard branch routes through a new wrapZeroWidthAnsi that
derives its line STRUCTURE from wrapAnsi on the PLAIN (stripped) text — which is
correct for zero-width graphemes (ZWSP/ZWNJ/ZWJ/combining/VS16/BOM, interior and
trailing) — then re-applies SGR per grapheme via slice-ansi's slot model, keeping
wide glyphs whole. Input is NFC-normalized first so combining sequences compose to
match wrap-ansi (and vue's own normal-width path), not the decomposed source bytes.
- paint.ts: pad the bg to the TRUE wrap width (0), not a >=1-clamped width — a 0-width
box pads nothing (Ink getMaxWidth=0); clamping bg-padded the empty leading wrap line
into a stray cell that collided with a row-sibling.
A comparison-battery test locks wrapZeroWidthAnsi's plain output to wrapAnsi's
width-0 layout for ~22 inputs (zero-width, wide, emoji, ZWJ, combining decomposed +
composed, multiline). The full layout suite is byte-unchanged for all width>=1 cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a forced interactive:true mount over a non-TTY stdout, vue emitted cursor
hide/show escapes where Ink emits none — Ink routes render()/done() cursor writes
through cli-cursor, which short-circuits `if (!stream.isTTY) return`, and its only
mount-time hide is alt-screen-only (alt-screen itself requires a TTY).
Gate the non-alt-screen cursor writes on stream.isTTY: log-update's hideCursor/
showCursor (used by render()/done() and the incremental writer) and render.ts's
bare mount-hide + teardown-show. The alternate-screen cursor writes are left as-is
(already gated behind alternateScreen, which requires isTTY). log-update's sync()
direct hide is deliberately NOT gated — Ink writes it directly, not via cli-cursor.
Locked by a non-TTY interactive mount test asserting no \x1b[?25l/\x1b[?25h; the
real-TTY hide-on-mount/show-on-teardown path stays covered by cursor.test.tsx.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related fixes to the raw-mode controller, mirroring Ink's split
(App.tsx:212-224,357):
- Sync input-state clear (P6): on the last useInput release (refs→0), reset the
input parser, clear the pending escape-flush timer, and detach the stdin
listeners SYNCHRONOUSLY — only the terminal raw-mode toggle stays deferred.
Previously everything was deferred in one microtask, so a same-tick useInput
SWAP (old unmounts → refs 0 → queued; new mounts → refs 0→1; the queued reset
then short-circuits on refs>0) left the parser un-reset and a partial escape
buffered before the swap leaked into the replacement handler. Ink's
clearInputState runs synchronously and unconditionally so this can't happen.
- Force raw-off (P7): the final disable now unconditionally setRawMode(false),
matching Ink's disableRawMode. The previous prevRaw-restore re-captured
stdin.isRaw at acquire while raw was still active on a sync false→true→false
swap, snapshotting `true` and leaving the terminal in RAW mode after exit. No
test locked the prevRaw-restore (an undocumented vue invention), so the field
is removed entirely — eliminating the corruption and aligning with Ink.
The swap-keeps-raw-on behavior (deferred toggle short-circuits on refs>0) is
preserved. Tests lock the partial-escape no-leak on swap and the terminal being
restored (final setRawMode is false) via an isRaw-tracking stdin.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frame-restore after an external stdout write (console.log / useStdout().write)
used `lastOutputToRender ?? lastOutput + "\n"`. `??` only falls back for
null/undefined, so an empty-string lastOutputToRender (the initial value, and the
value left by the screen-reader empty-frame path) restored "" — nothing — where Ink
restores lastOutput + "\n". Ink uses `||` (ink.tsx:507) and so does vue's own
mountedClear (render.ts:668); :518 was the lone inconsistent site. Changed `??` to
`||`. Locked by an SR-empty-frame + external-write test that re-emits "\n".
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>