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>
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>