Commit Graph

328 Commits

Author SHA1 Message Date
Yunfei He 99839397c6 fix(runtime): a write op at the right clip edge still runs its transformers (Ink parity) (#109)
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>
2026-06-01 01:14:28 +08:00
Yunfei He e68e36f26b fix(deps): bump is-in-ci catalog ^1.0.0 → ^2.0.0, matching Ink (#108)
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>
2026-06-01 00:58:25 +08:00
Yunfei He 4de7f35b92 fix(runtime): focusNext/focusPrevious clear a stale activeId, matching Ink (#107)
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>
2026-06-01 00:47:05 +08:00
Yunfei He 819f22ca40 test(runtime): lock <Transform> P19 screen-reader behavior (follow-up to #105) (#106)
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>
2026-06-01 00:31:57 +08:00
Yunfei He 224afeb3a2 fix(runtime): <Transform> with no children renders no node, matching Ink (#105)
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>
2026-06-01 00:29:21 +08:00
Yunfei He 6078cb7a80 fix(runtime): useAnimation interval preserves fractional values, matching Ink (#104)
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>
2026-06-01 00:02:42 +08:00
Yunfei He b6dc9a0919 fix(runtime): a 0-width box wraps its text onto its own line, not drops it (Ink parity) (#103)
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>
2026-05-31 23:54:12 +08:00
Yunfei He 759af7fa5f fix(runtime): gate interactive cursor hide/show on isTTY, matching cli-cursor (#102)
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>
2026-05-31 22:44:40 +08:00
Yunfei He c76a0f09b2 fix(runtime): raw-mode teardown matches Ink's clearInputState + disableRawMode (#101)
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>
2026-05-31 22:25:42 +08:00
Yunfei He f99f23e346 fix(runtime): restoreLastOutput falls back on an empty frame, matching Ink (#100)
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>
2026-05-31 22:04:06 +08:00
Yunfei He 8841350491 fix(runtime): descendant Box backgroundColor="" keeps inheriting the ancestor bg (#99)
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>
2026-05-31 21:50:55 +08:00
Yunfei He 7ca592c503 docs: mark Dev toolkit as experimental in README (#98)
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>
2026-05-31 21:42:52 +08:00
Yunfei He 12efbe6900 fix(runtime): border edge SGR nests dim outermost, matching Ink (#97)
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>
2026-05-31 21:30:36 +08:00
Yunfei He 8091154785 fix(runtime): sanitize ANSI in screen-reader text, matching Ink (#96)
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>
2026-05-31 21:16:51 +08:00
Yunfei He eea9fb6b72 fix(runtime): tokenizeAnsi('') returns a single empty text token, matching Ink (#95)
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>
2026-05-31 20:59:55 +08:00
Yunfei He 9ed77fc38c fix(runtime): bare-string dimensions and position offsets are percentages (Ink parity) (#94)
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>
2026-05-31 20:50:27 +08:00
Yunfei He 8e14081a7c docs(runtime): document and test the kitty query-response filter as a load-bearing divergence (#93)
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>
2026-05-31 17:02:40 +08:00
Yunfei He 4c18ec0dd0 fix(runtime): useAnimation reset() while paused keeps the last frame, zeros on resume (Ink parity) (#92)
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>
2026-05-31 16:42:51 +08:00
Yunfei He f2397bfa00 fix(runtime): kitty key-release delivers input like Ink, not '' (parity) (#91)
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>
2026-05-31 16:26:13 +08:00
Yunfei He 860980de8a fix(runtime): write wide chars at the terminal edge instead of clipping them (Ink parity) (#90)
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>
2026-05-31 16:09:53 +08:00
Yunfei He 8c3e97ab47 fix(runtime): removing display resets to the default (visible), not persist (Ink divergence) (#89)
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>
2026-05-31 15:48:08 +08:00
Yunfei He c0ebf53e63 fix(runtime): string flexBasis is a percent, matching Ink (parity) (#88)
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>
2026-05-31 15:30:17 +08:00
Yunfei He ad067d9ae3 fix(runtime): sanitize text before measuring, matching Ink (parity gap #9) (#87)
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>
2026-05-31 11:38:07 +08:00
Yunfei He aa1c8bfb07 fix(runtime): treat cross-realm Error as an error on exit + thrown paths (Ink parity) (#86)
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>
2026-05-31 07:22:27 +08:00
Yunfei He 4a5594ec22 feat(runtime): useAnimation interval is reactive (Ink parity) (#85)
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>
2026-05-31 07:02:22 +08:00
Yunfei He 6f2877965c fix(runtime): nested <Text> inherits ancestor boolean styles (Ink parity) (#84)
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>
2026-05-31 06:45:58 +08:00
Yunfei He 9abc531dca fix(runtime): backgroundColor="" on a Text opts out of the inherited Box bg (Ink parity) (#83)
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>
2026-05-31 06:13:18 +08:00
Yunfei He 32b99da7a0 fix(runtime): don't re-wrap fitting text, so non-hyperlink OSC text survives (Ink parity) (#82)
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>
2026-05-31 05:48:49 +08:00
Yunfei He 9b5eac3ba4 feat(runtime): port ErrorOverview to Ink's full error frame (parity) (#81)
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>
2026-05-31 05:26:02 +08:00
Yunfei He e3202f345a fix(runtime): feed useCursor position to the interactive commit path (Ink parity) (#80)
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>
2026-05-31 04:57:37 +08:00
Yunfei He 26cf40987b feat(runtime)!: narrow useStdin to Ink's public surface; name composable returns UseXReturn (#79)
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>
2026-05-31 01:37:46 +08:00
Yunfei He 87254dd561 docs: add convention for reading Ink source from a fixed local clone (#78)
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>
2026-05-31 00:41:29 +08:00
Yunfei He cc44098a84 docs: resolve Vue-vs-React semantics placeholder (all 3 are idioms, not divergences) (#76)
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>
2026-05-31 00:28:31 +08:00
Yunfei He 369442d4b2 fix(runtime): exit on Ctrl+C under kitty for raw-mode-only apps (#75)
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.
2026-05-31 00:17:21 +08:00
Yunfei He 99eb50c8ff docs(parity): record the flexDirection/flexWrap divergence (concise) (#71)
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>
2026-05-30 23:46:56 +08:00
Yunfei He a9cfc8994e docs: record the concise-records convention in AGENTS.md (#74)
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>
2026-05-30 23:42:10 +08:00
Yunfei He bfd680490f refactor(runtime)!: rename useAppContext() to useApp() (full Ink alignment) (#73)
#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>
2026-05-30 23:32:22 +08:00
Yunfei He a8105edbf2 docs: drop accessibility from Ink-divergences (it's parity, not a divergence) (#72)
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>
2026-05-30 23:03:30 +08:00
Yunfei He 9d1e4d7805 feat(runtime)!: replace useExit() with Ink-aligned useAppContext() (#69)
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>
2026-05-30 19:57:52 +08:00
Yunfei He 6edc51b925 feat(runtime): re-export Ink-aligned named prop/data types (BoxProps, …) (#70)
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>
2026-05-30 19:18:19 +08:00
Yunfei He 688da13872 fix(runtime): keep text-measure helpers internal, matching Ink (#68)
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>
2026-05-30 18:53:34 +08:00
Yunfei He 936f2ee1ee docs: replace sprawling Ink-parity docs with one focused intentional-divergences doc (#67)
* 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>
2026-05-30 18:11:46 +08:00
Yunfei He 5b681aa677 chore(parity): close out the Ink-parity loop — reconcile ledger, record stop decision (#66)
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>
2026-05-30 16:56:49 +08:00
Yunfei He 6b09a55a6d fix(runtime): nest Text ANSI styles per-style in Ink's order (Ink parity, G68) (#65)
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>
2026-05-30 16:42:18 +08:00
Yunfei He e7992bb06b fix(runtime): content-size auto-width <Static> isolated paint (Ink parity, G64) (#64)
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>
2026-05-30 15:42:05 +08:00
Yunfei He 8cadcca95c fix(runtime): clip horizontally before applying transforms (Ink parity, G63) (#63)
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>
2026-05-30 15:04:12 +08:00
Yunfei He 8140c663ce fix(runtime): give screen-reader mode a dedicated write path (Ink parity, G59) (#62)
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>
2026-05-30 14:30:02 +08:00
Yunfei He c8cbacfbb8 fix(runtime): render direct text/Newline children of a standalone <Transform> (Ink parity, G58) (#61)
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>
2026-05-30 14:07:56 +08:00
Yunfei He a5a7cf124b fix(runtime): skip comment nodes when indexing <Transform> children (Ink parity, G52) (#60)
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>
2026-05-30 12:55:48 +08:00
Yunfei He f9435f94c9 fix(runtime): honor <Static> layout style props in isolated paint (Ink parity, G44) (#59)
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>
2026-05-30 12:19:34 +08:00