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