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>
An inner <Box backgroundColor=""> inside an ancestor Box with a real bg clobbered
the inherited background (descendants rendered bare). vue used `?? inheritedBg`,
which only falls back for null/undefined — "" passed through. Ink keeps inheriting:
its provider uses a TRUTHY guard (Box.tsx:103 `if (backgroundColor)`), while the
fill uses the Box's OWN style (render-background.ts:11), so an empty-bg Box paints
no fill yet still passes the ancestor's bg down.
Split the single bg variable: the FILL uses the Box's own bg (falsy-guarded → empty
paints nothing), and the value THREADED to children is `ownBg ? ownBg : inheritedBg`
(truthy fallback → empty inherits the ancestor). A sized/bordered empty-bg inner box
therefore adds no own fill while its text still inherits — locked by a discriminator
test that fails the naive single-variable form.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dev toolkit (terminal HMR, build, preview) is still under active
development, so label it experimental to set expectations.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A border edge combining dim with a foreground and/or background color emitted a
different SGR nesting order than Ink (vue: dim innermost via the shared Text
applyChalk; Ink: dim OUTERMOST). Visually identical, but the byte stream diverged.
Give colorizeEdge its own stylePiece ordering matching render-border.ts (fg, then
bg, then chalk.dim last/outermost) instead of routing border edges through
applyChalk — whose dim-innermost order is correct for <Text> and is left unchanged.
colorizeEdge is the single shared path for all four edges.
Exact-byte tests lock the top edge, a side rail, and the fg+bg (no-dim) subset;
the previously-lax `.toContain('[31m')` tests (duplicated across borders and
background-color) are upgraded.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink squashes every ink-text via squashTextNodes, which always returns
sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI while
keeping SGR + OSC. vue's SR squash concatenated raw text-leaf values with no
sanitize, so an embedded control sequence (e.g. \x1b[2J) leaked into screen-reader
output. Wrap the squashed SR text in sanitizeAnsi at squashTextContent and the
standalone <Transform> branch — the SR twin of text-measure.ts:54. The double pass
on nested/transform text is idempotent (sanitizeAnsi is a fixed point), matching
Ink's recursive squashTextNodes.
Tests assert erase stripping in Text/Box/Transform AND that SGR is KEPT (so a
strip-everything regression is caught).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink's tokenizeAnsi has no empty-string early return — '' falls through to the
no-control-chars branch and yields [{type:'text', value:''}]. vue had an extra
`if (text.length === 0) return []` guard that diverged from Ink at the tokenizer
boundary. Removed it (the production caller sanitizeAnsi short-circuits '' before
tokenizing, so no behavior changes downstream) and flipped the test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink coerces ANY string dimension/position value to a percent before handing it
to yoga (applyDimensionStyles uses parseInt; applyPositionStyles uses parseFloat).
vue forwarded the raw string to native yoga, which only treats %-suffixed strings
as percent — so width="50" rendered as 50 absolute cells instead of 50%, top="2"
as 2 cells instead of 2%, and width="" crashed the render ("Invalid value").
width/height now also fall back to setWidthAuto()/setHeightAuto() on a non-number,
non-string junk value (matching Ink's else branch), so width={false} no longer
throws where Ink renders fine. min/max/position keep forwarding junk to the raw
cell setter, matching Ink (which has no auto fallback there and throws identically).
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>
reset() zeroed frame/time/delta unconditionally, so calling it while paused
(isActive=false) flipped the frozen frame to 0 immediately. Ink keeps the last frame
until resume: its reset only bumps a key consumed by the isActive-gated effect, which
early-returns while inactive (use-animation.ts:83-89). Now reset() zeros + restarts only
when active (via start()); while paused it's a no-op and the next resume's start() zeros
-- matching Ink. Active reset is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useInput had an undocumented guard that blanked `input` to '' on any kitty key-RELEASE
event, so a printable release (and a ctrl+letter release) delivered nothing. Ink has no
release special-case -- it classifies a kitty event purely by isPrintable/ctrl+letter, so
a printable release delivers `text ?? name` and a ctrl+letter release delivers the letter
name (use-input.ts:204-217). Removes the guard to match.
The kept "Ctrl+C exits under kitty" divergence is unaffected: the exit check in emitInput
is already scoped to `eventType !== "release"`, so a Ctrl+C release flows through as
input='c' without spuriously exiting (press still exits).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The paint Output write loop had two x-bounds guards Ink lacks, which dropped a whole wide
char -- including its in-bounds leading cell -- when only its trailing cell exceeded the
width, so an edge-aligned "aa你" rendered as "aa". Ink's Output write loop has no bounds
check: it writes both cells and lets the past-width placeholder be dropped as a sparse hole
by line.filter(undefined) + trimEnd. Removes the two guards to match (output.ts:272-308);
box-level overflow:hidden clipping is unchanged (the separate clipH sliceAnsi path).
Un-skips the non-hyperlink-OSC overflow-wrap test, which this also fixes (the now-visible
OSC bytes no longer push the trailing char off a clipped edge) -- verified "abcde\nfghij"
against the Ink reference. Adds a wide-char-at-edge test.
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>
A string flexBasis was forwarded to yoga's setFlexBasis, so a bare numeric string like
"3" became 3 absolute cells; Ink coerces ANY string to a percent via Number.parseInt ->
setFlexBasisPercent (styles.ts:547-555). "3" now means 3% (at width 6 the box collapses
to 0 and the sibling takes the row), matching Ink. The setter branch is now structurally
identical to Ink -- number -> absolute, string -> percent, anything else (incl. a
non-number/non-string value Vue's prop validation only warns about) -> setFlexBasisAuto()
instead of throwing.
A separate, pre-existing downstream divergence (zero/negative flexBasis% wraps the
sibling in Ink) is documented with a skipped test -- not caused or fixed here.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The layout measure path flattened a Text node to its RAW string while paint applied
sanitizeAnsi, so measure and paint used different strings. A control sequence that
sanitizeAnsi strips then either mis-measured the width (ESC#8/DECALN: string-width 2
vs the real 3 -> undersized cell -> trailing char clipped) or broke the wrap step
(\x1b[2K: wrap-ansi doesn't recognize the CSI -> text un-wrapped, overflowing a
too-short cell). Sanitizes the measure-path squash (flattenLeaves +
flattenTransformLeaves) so measure and wrap see the same string paint emits, matching
Ink's squashTextNodes -> sanitizeAnsi (dom.ts:227, render-node-to-output.ts:141-150).
Un-skips the ESC#8 test (corrected to the ESC#8-alone input) and adds a \x1b[2K
wrap-drop test (exact-byte, verified against Ink). The non-hyperlink-OSC overflow case
is a SEPARATE Output grid-clip gap (sanitize preserves OSC) -- its test stays skipped
with a corrected note.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.exit(crossRealmError) resolved waitUntilExit() with the error as a result value,
and a component-thrown cross-realm Error was re-wrapped (losing the original) -- both
because `instanceof Error` fails across VM realms. Adds Ink's isErrorInput check
(instanceof Error || Object.prototype.toString.call(v) === "[object Error]", the
realm-independent [object Error] brand) and uses it at the 3 exit() classification
sites and the 2 error-boundary normalization sites. A cross-realm Error now rejects
(exit) / is preserved (throw), matching Ink; genuine non-Error values still resolve /
still wrap.
Flips the previously-locked "cross-realm resolves" test to Ink's reject (the behavior
was never in ink-divergences.md, so per the repo rule it was an unverified gap, not a
sanctioned divergence). Adds component-throw cross-realm + a non-Error-still-wraps guard.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changing the interval option on a live useAnimation was a no-op (it was captured
once at setup). interval now accepts a MaybeRefOrGetter<number> (strict superset of
number); while active, a change resets frame/time/delta to 0 and re-subscribes at
the new interval; while inactive the new value is recorded and applies on the next
activation. Mirrors Ink's shouldReset gating (use-animation.ts), which recomputes
safeInterval every render and resets only when active.
Adds 3 tests (live change while active resets; while inactive doesn't; plain number
still works).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A nested <Text> inside a styled <Text> now inherits the ancestor's bold/italic/
underline/strikethrough/dim -- previously those closed at the nested boundary.
Rewrites inline-text composition from merge-down + per-leaf applyChalk to Ink's
wrap-the-concatenation model (squash-text-nodes.ts + render-node-to-output.ts:136):
each Text's own style wraps the concatenation of its already-styled children, so
<Text bold>A<Text green>B</Text></Text> = chalk.bold("A" + chalk.green("B")) --
bold stays open across the green child.
A bare text-leaf now contributes its RAW value (Ink's #text carries no transform);
all styling incl. the effective bg (ownBg ?? inheritedBg) is applied once by the
enclosing Text's wrap. This also makes the nested-inline backgroundColor="" case
match Ink, and is more Ink-faithful for bare text under a <Transform> (no Box bg on
its glyphs -- Ink's Transform doesn't consume backgroundContext), locked by a test.
G52 comment-anchor index handling preserved. Adds 11 exact-byte tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An explicit backgroundColor="" on a descendant Text now renders bare glyphs instead of
bleeding the inherited Box background. Mirrors Ink Text.tsx:103-106 (effectiveBg =
backgroundColor ?? inheritedBg; colorize only when truthy): undefined inherits, ""
opts out. The trailing padding still uses the inherited Box bg (Box fill), so glyphs
carry their effective bg while the Box fills the rest -- byte-identical to Ink.
Corrects the background-color.test.tsx mixed snapshot, which had encoded the buggy
green-bleed output. Adds the opt-out test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tui called wrapText unconditionally for every Text node; Ink only wraps when the
text overflows its cell (render-node-to-output.ts:144-150). wrap-ansi can't account for
the visible width of non-hyperlink OSC sequences (e.g. a set-title ESC]0;...BEL), so
re-wrapping fitting text that contained one consumed the following visible text. Adds
Ink's wrap-only-on-overflow guard to wrapText: when measureTextNatural(text).width <=
width, return the text verbatim (also matches Ink's literal-tab handling as a bonus).
Now "\x1b]0;My Title\x07Some text" renders "Some text" (was a single char).
The overflow case (a non-hyperlink OSC before an overflowing word) is a separate
remaining divergence tracked to gap #9 (vue wraps raw text; Ink wraps sanitized) -- its
test stays skipped with an honest note. Un-skips 2 OSC tests (BEL + ST terminated).
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>
On the interactive commit path the active cursor position was never forwarded to
the frame writer, so the cursor was never shown at the useCursor() position, never
followed input, and a cursor-only move on a byte-identical frame emitted nothing.
Aligns with Ink v7.0.4 by wiring three coupled defects together:
- render.ts setCursorPosition now forwards to writer.setCursorPosition, marking
log-update's cursorDirty (Ink ink.tsx:494-497).
- the synchronized-update commit gate is split into Ink's two levels: the write is
gated on willRender() || isCursorDirty(), but BSU/ESU wrap only when willRender()
(Ink ink.tsx:1094 outer, :372-382 inner) -- an idle cursor-dirty re-render emits
zero bytes, not an empty BSU/ESU pair.
- FrameWriter.write() bypasses its frame===lastFrame dedup when the cursor is dirty,
so a cursor-only move still reaches log-update's buildCursorOnlySequence.
The mount-time hide-cursor write moves before originalMount so the first commit's
show is the last visibility change (Ink hides before its first render); a synchronous
mount throw now runs best-effort teardown (cursor/alt-screen restore) before
rethrowing the ORIGINAL error, matching Ink's constructor-wired signalExit.
Adds 8 interactive-TTY tests; the prior use-cursor tests used the debug render()
helper where log-update never runs, so they passed for the wrong reason.
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>
When parity/divergence work needs Ink's source, clone it once to a fixed path
(/tmp/ink) and read locally instead of relying on node_modules (Ink isn't a
dependency). Always check out and confirm the pinned baseline first — the exact
version/commit lives in .agents/docs/ink-divergences.md — since a claim read
against the wrong Ink version is worse than none.
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>
Recorded notes / design docs should be concise and direct — the essential
what + why, led by the principle or intuition — without losing information
or sliding into essays / exhaustive mechanism dumps. Also: when a behavior
is the correct default, state the principle rather than framing it as a
framework limitation.
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>
13 mediums fixed+merged across sweeps 1-10 (G32, G33, G39, G44, G45, G46,
G52, G58, G59, G63, G64, G68) plus the ~22 earlier gaps. Maintainer paused
the open-ended loop at diminishing returns: latest sweeps surface byte-level/
edge-case divergences (G68 ANSI order is visually identical; G67 refuted as an
unavoidable Vue-vs-React semantic). Open mediums G69/G70 and the LOW tail are
recorded but deferred by decision.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's Text.tsx transform applies each enabled style as a SEPARATE nested
chalk wrap, in the exact order dim -> color -> backgroundColor -> bold ->
italic -> underline -> strikethrough -> inverse. vue-tui's applyChalk built
ONE chained ChalkInstance (color -> bg -> dim -> bold -> ...) and invoked it
once, producing a different, non-Ink byte sequence for any multi-style Text:
e.g. color+bold emitted [31m[1mX[22m[39m vs Ink's [1m[31mX[39m[22m, and
dim+bold dropped the bold re-open after dim's SGR-22 reset.
Rewrite applyChalk to mirror Ink: apply each style as its own nested chalk(...)
call in Ink's order, reusing the existing color/background resolution. Chalk
level handling (FORCE_COLOR / level 0 -> no codes) is preserved and ANSI codes
remain zero-width, so styled-text measurement is unchanged. Multi-style Text now
produces byte-identical ANSI to Ink.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink content-sizes the position:absolute, auto-width static box: Static.tsx
sets `{position:'absolute', flexDirection:'column', ...customStyle}` with no
width, ink.tsx calculateLayout never sets the static node's width, and
renderer.ts reads node.staticNode.yogaNode.getComputedWidth() — the computed
width of a yoga absolute, auto-width node, which shrinks to its CONTENT. So
flex-fill children (Spacer/flexGrow/justifyContent/percent) inside a Static
item collapse to content width instead of expanding to the terminal width.
The G44 fix over-forced the iso root to full terminal width (setWidth(columns)),
so a Spacer/flexGrow child expanded to fill the terminal. This refines G44: for
an AUTO-width static node we now leave the iso root width auto and cap it at the
available columns with setMaxWidth(columns), content-sizing exactly like Ink
while still wrapping anything wider than the terminal. The explicit-width
(POINT/PERCENT) copyStyle path from G44 is unchanged and still wins.
Verified against the built Ink reference (v7.0.4, 40b3a75): a Static item Box
row [LEFT][Spacer][RIGHT] at cols=80 renders "LEFTRIGHT" (Spacer collapses),
and [A][flexGrow][B] renders "AB".
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's output.ts maps sliceAnsi(line, from, to) (the horizontal clip) over the
lines BEFORE the lines.entries() loop that runs transformer(line, index), and it
never re-clips the transformer's output. vue-tui's paint write() did the reverse
(commit 2c99431 deliberately moved clip AFTER transform), so a width-sensitive
transform inside an overflowX:"hidden" box received the FULL line and had its
result sliced — corrupting gradients (wrong char count) and OSC-8 hyperlinks
(closing sequence sliced off).
Reorder so the per-line horizontal clip runs first, then the transformers apply
to the already-clipped span. The post-vertical-clip line index passed to each
transformer is unchanged, so transform index/nesting behavior (G21/G32/G52/G58)
is untouched. The pre-existing overflow test that asserted the old re-clip order
(transform-returns-wide-char dropped at the boundary) is rewritten to Ink's
clip-then-transform output (the widened glyph overflows), verified against the
pinned Ink reference (7.0.4 / 40b3a75) via renderToString.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's onRender screen-reader branch (ink.tsx:573-625) writes the wrapped SR
transcript with a RAW stdout.write using manual ansiEscapes.eraseLines(prev) +
(inline static, if any) + the wrapped output, sets lastOutput/lastOutputToRender/
lastOutputHeight, and RETURNS before the normal interactive frame path. It emits
NO clearTerminal, does NOT accumulate/replay fullStaticOutput, does NOT go
through log-update, and does NOT hide the cursor.
vue-tui routed SR frames through renderInteractiveFrame, so a tall/overflowing
SR transcript (outputHeight >= viewportRows, then previousOutputHeight >
viewportRows) hit the clearTerminal branch — wiping the SR user's scrollback,
replaying accumulated fullStaticOutput, and the mount-time hide left the cursor
hidden.
This adds a dedicated `if (isScreenReaderEnabled) { ... return; }` branch in
commit(), before fullStaticOutput accumulation (now gated off for SR) and before
renderInteractiveFrame, mirroring Ink's SR branch byte-for-byte (eraseLines +
inline static + wrapped output, lastOutputToRender = wrapped output with no
trailing "\n", height = split count). It never clears the terminal, never
replays static, never uses the log-update writer, and the mount-time cursor-hide
is now skipped for SR mode. The normal (non-SR) interactive path is unchanged —
clearTerminal-on-tall-frame still applies there. G17/G46 SR behavior preserved.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
In Ink, <Transform> IS an ink-text host node (Transform.tsx renders
<ink-text internal_transform={fn}>), and the reconciler sets isInsideText
for ink-text. So bare-string and <Newline> children of a standalone
<Transform> render inline within that ink-text, with the transform applied
per line by the Output, and a nested <Text> child is squashed inline too.
The canonical README pattern `<Transform transform={fn}>Hello World</Transform>`
(no inner <Text>) works standalone.
vue-tui's transform host was a non-text yoga carrier: its direct text-leaf
children hit the paint no-op leaf branch and were silently dropped, a
<Newline> inside it rendered as a standalone "text" node (name-based
isInsideText saw no <Text> ancestor), and the SR squash ignored bare
text-leaf children. So `<Transform>ab</Transform>` rendered nothing and
`<Transform>a<Newline/>b</Transform>` applied the transform to empty lines.
Fix (treat a standalone <Transform> as a text context, matching Ink):
- paint.ts: when a transform has no yoga-carrying child, squash its inline
children to a string and write it like a text node, pushing the transform
as a per-line Output transformer (applied at paint, never at squash —
matching Ink). Transforms wrapping a yoga child keep the recursion path.
- yoga.ts: bind a text-style measure func on a standalone transform (its
squashed children, WITHOUT its own fn — matching Ink measureTextNode), and
toggle it off/on as yoga children are inserted/removed.
- Newline.ts / Text.ts: treat a <Transform> ancestor as a text context, so a
<Newline>/<Text> directly inside a transform emits inline virtual-text.
- node-ops.ts: allow bare text-leaf children of a transform (text-context
check) and mark a standalone transform dirty on inline child change; the
<Box>-in-<Text> guard still excludes transform (Ink renders Box-in-Transform
empty, no throw).
- screen-reader.ts: include bare text-leaf/virtual-text children of a
transform in SR output (Ink squashTextNodes includes #text).
All transform-in-text (G21/G32/G52), <Transform><Text>…</Text></Transform>,
nested transforms, plain <Text>/<Newline>, and Box-in-Transform cases verified
against Ink 7.0.4. Expected outputs (columns=40) confirmed by running the Ink
reference: `<Transform transform={s=>`<${s}>`}>ab</Transform>` + after →
"<ab>\nafter"; `<Transform>a<Newline/>b</Transform>` → "<a>\n<b>" (per-line);
SR → "ab" / "a\nb".
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Vue materializes a null/v-if/false render as a COMMENT host node that occupies
a positional slot in node.children. React never produces a childNode for such
children, so Ink's squash loop (squash-text-nodes.ts:13) never advances `index`
past them — empirically <Text>A{null}<Transform>(s,i)=>`${i}:${s}`>B</Transform>
yields "A1:B". vue-tui's three squash loops used the raw positional loop counter,
so a preceding comment took a slot and shifted the Transform to "A2:B".
The fix maintains a separate transform index that advances only for children
React would have produced — i.e. skips comment nodes — applied IDENTICALLY in
the paint, measure, and screen-reader paths so all three agree and match Ink.
G21 (which switched these loops from a hardcoded 0 to the positional counter)
introduced the precondition; its real-sibling positional indexing and G32's
transform-in-transform recursion remain intact.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink lays the <Static> node out via its OWN yoga node: Static.tsx merges
`{position:'absolute', flexDirection:'column', ...customStyle}` onto the
internal_static <ink-box>, and renderer.ts:48-56 reads
node.staticNode.yogaNode's computed layout directly. So every caller-supplied
layout style prop on `<Static style={{...}}>` (flexDirection, padding, margin,
gap, justifyContent, alignItems, width) governs how the static children are
laid out and written.
vue-tui's isolated paint replayed only the STYLE_PROPS subset (visual
color/border/overflow) that node-ops stores in `el.props`, then hard-defaulted
the fresh iso root to FLEX_DIRECTION_COLUMN. Every other layout style on
<Static> was a silent no-op: `flexDirection:'row'` painted as stacked lines,
`paddingLeft` was dropped, etc.
Fix: copyStyle the static node's yoga — which already holds every resolved
layout prop (including the column default) via node-ops applyYogaProp — onto
the iso root, instead of iterating the incomplete props bag. The static node's
own yoga is display:none (so it occupies no main-tree space) and
position:absolute; both are reset to flex/relative on the iso root since it is
the standalone paint root. An explicit `<Static style={{width}}>` is honored;
otherwise the iso root is constrained to the available columns. The live static
node's own yoga children are never reparented, so the main-tree layout/measure
is untouched.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's screen-reader branch (ink.tsx:617-621) writes the wrapped output
verbatim — `stdout.write(erase + wrappedOutput)` with
`lastOutputToRender = wrappedOutput` (NO appended "\n" in ANY case) and
`lastOutputHeight = wrappedOutput === "" ? 0 : wrappedOutput.split("\n").length`.
The earlier G17 fix only suppressed the trailing newline for the EMPTY SR
frame (`output === ""`); a non-empty multi-line SR frame still fell through
to `output + "\n"`, which (1) parked the cursor on a spurious blank line
below the content and (2) made log-update count the previous frame as N+1
lines, emitting `eraseLines(N+1)` instead of `eraseLines(N)` on every
subsequent multi-line SR frame (off-by-one erase).
Broaden the suppression to ALL screen-reader frames so the written output
and its height match Ink's SR branch exactly. Non-SR interactive frames are
untouched — they still append "\n" as before.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
In Ink (App.tsx v7.0.4, 40b3a75) the isFocusEnabled guard lives ONLY in
handleTabNavigation, not in focusNext/focusPrevious. So after disableFocus()
pressing Tab/Shift-Tab is a no-op, but a programmatic
useFocusManager().focusNext()/focusPrevious() still moves focus.
vue-tui previously short-circuited focusNext/focusPrevious on the internal
`enabled` flag, making the programmatic API a no-op while focus was disabled —
divergent from Ink. Move the enabled-check out of focusNext/focusPrevious and
into the Tab/Shift-Tab input listener (mirroring handleTabNavigation), keeping
the focusables.length === 0 short-circuit so focusing an empty/unmounted tree
stays a harmless no-op. The now-redundant local `enabled` is dropped in favor of
ctx.enabled as the single source of truth.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A plain <Box> (no explicit flexDirection) lays out as a row — yoga's Box
default is FLEX_DIRECTION_ROW (host/yoga.ts) — so its screen-reader children
must be joined with a space, matching Ink (which hardcodes flexDirection:'row'
in Box.tsx and derives the SR separator from style).
Root cause: flexDirection is a pure yoga prop. node-ops applies it to the yoga
node but does NOT mirror it into node.props (it is not in STYLE_PROPS), so the
yoga row default was never reflected there. screen-reader.ts read
node.props["flexDirection"], which was undefined for every live-rendered box
(both default AND explicit column/row), wrongly defaulting the separator to
"\n" in all cases.
Fix: resolve the direction from the yoga node (preferring an explicit
props.flexDirection for the direct-built unit fixtures), mirroring
static-channel.ts's resolvedFlexDirection so both SR linearization paths derive
the separator identically. Root keeps undefined → "\n" (Ink's column-default
root). row-reverse/column-reverse reversal is unaffected.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The first exit() call now captures its value/error and initiates teardown
synchronously; subsequent exit() calls are complete no-ops, so waitUntilExit
resolves/rejects with the FIRST value rather than the last. This mirrors
Ink's handleAppExit guard (isUnmounted || isUnmounting → early return).
Previously each exit() queued a microtask that overwrote pendingExitResult/
pendingExitError before resolveExit ran, making it last-wins. An exitInitiated
flag set at the top of exit() now guards the value capture and re-resolve,
while the deferred microtask teardown (needed because exit() is called from
inside the Vue update cycle) is preserved.
Reverses the sweep-1 refutation: exit() was NOT already first-wins guarded.
Also guard unmount-in-progress (isUnmounting parity) + value→error test.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>