Files
vue-tui/.agents/docs/parity-ledger.md
T
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

126 KiB
Raw Blame History

Ink Parity — Working Ledger

Audit sweeps and confirmed gaps for the loop in ink-parity-loop. Reference pin and the intentional-divergence allowlist live in ink-parity. Update a gap's row in the same PR that fixes it.

Sweep history

sweep Ink SHA candidates → confirmed status
sweep-1 (2026-05-29) 40b3a75 16 verified → 14 confirmed (2 refuted) recorded
sweep-2 (2026-05-30) 40b3a75 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) recorded
sweep-3 (2026-05-30) 40b3a75 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted recorded
sweep-4 (2026-05-30) 40b3a75 confirmation re-audit → 7 new (2 MEDIUM: G32-G33; 5 low: G34-G38) + 2 refuted-reversed recorded
sweep-5 (2026-05-30) 40b3a75 final re-audit (G32+G33 merged) → 6 cand → 5 confirmed: 1 MEDIUM (G39 SR default-Box separator) + 4 LOW (G40-G43) recorded
sweep-6 (2026-05-30) 40b3a75 re-audit (G39 merged) → 9 cand → 8 confirmed: 3 MEDIUM (G44 Static layout-style dropped, G45 programmatic focus-while-disabled, G46 non-empty SR-frame trailing newline) + 5 LOW (G47-G51) recorded
sweep-7 (2026-05-30) 40b3a75 re-audit (G44/G45/G46 merged) → 6 confirmed: 1 MEDIUM (G52 Transform index shift from comment-node siblings) + 5 LOW (G53-G57) recorded
sweep-8 (2026-05-30) 40b3a75 re-audit (G52 merged) → 5 confirmed (3 refuted): 2 MEDIUM (G58 standalone-Transform bare-text dropped, G59 SR frame routes through interactive clearTerminal/static path) + 3 LOW (G60-G62) recorded
sweep-9 (2026-05-30) 40b3a75 re-audit (G58/G59 merged) → 8 cand → 4 confirmed (4 refuted): 2 MEDIUM (G63 transform-then-clip order, G64 Static forces terminal width vs Ink content-size) + 2 LOW (G65-G66) recorded
sweep-10 (2026-05-30) 40b3a75 re-audit (G63/G64 merged) → 8 cand → 7 confirmed (1 refuted): 3 actionable MEDIUM (G68 Text ANSI nesting order, G69 Static throttled vs immediate, G70 non-interactive SR hard-wrap) + G67 (refuted on codex review — unavoidable Vue-vs-React semantic) + 3 LOW (G71-G73) recorded

Refuted (NOT gaps): exit() second-wins — vue-tui is already guarded REVERSED by sweep-4: it IS last-wins vs Ink first-wins → now tracked as G33; kitty key-release printable-text suppression — Ink behaves the same.

🛑 LOOP STOPPED by maintainer (2026-05-30) — diminishing returns. 13 mediums fixed+merged (G32, G33, G39, G44, G45, G46, G52, G58, G59, G63, G64, G68 — plus the ~22 earlier gaps G01–G05, G08–G23). The high-value, user-visible parity is done. The maintainer paused the open-ended "run until a sweep is medium-clean" loop after observing that the latest sweeps surface byte-level / edge-case divergences (e.g. G68 ANSI nesting order is visually identical; G67 was refuted as an unavoidable Vue-vs-React semantic; G64/G67 were corrections of vue-tui's own earlier fixes), and that a byte-level audit between a Vue and a React runtime may never return "clean." Open mediums deferred by decision (NOT done): G69 (Static immediate render), G70 (non-interactive SR hard-wrap). LOW tail (G25–G31, G34–G43, G47–G51, G53–G57, G60–G62, G65–G66, G71–G73 + the SR squashTextContent transform-drop note) remains recorded and unfixed by maintainer decision. To resume: pick up G69/G70 (sketches in their rows) or re-triage under a stricter "observable user-facing impact only" bar.

Decisions log

Non-obvious calls made while fixing gaps, recorded for review in the final report.

  • 2026-05-29 — Conflict policy (set by maintainer): when an audit gap conflicts with an existing vue-tui test that deliberately asserts non-Ink behavior, align to Ink and rewrite the conflicting test — and record the decision here so it can be reviewed against expectations later.
  • 2026-05-30 — G68 (Text ANSI nesting order): Rewrote applyChalk (packages/runtime/src/paint/text-style.ts) to mirror Ink's Text.tsx transform — each enabled style applied as its OWN nested chalk call in Ink's exact order (dim→color→bg→bold→italic→underline→strikethrough→inverse) instead of one chained ChalkInstance. Byte sequences verified against /tmp/ink-40b3a75 @ chalk level 1 (color+bold [1m[31mX[39m[22m, dim+bold [1m[2mX[22m[1m[22m, color+bg [44m[31mX[39m[49m). Conflict-policy rewrites (align to Ink): the text-style.test.ts "multiple modifiers chain" case asserted the old chained order (chalk.bold.underline) → rewritten to nested chalk.underline(chalk.bold("x")); and the borders.test.tsx "borderDimColor does not dim styled child Text touching left edge" inline snapshot asserted old chained bytes [34m[1m…[22m[39m for bold color="blue" → updated to Ink's nested [1m[34m…[39m[22m (verified vs Ink). Single-style Text, level-0 (no codes), and styled-text width (ANSI is zero-width) all unchanged.
  • 2026-05-30 — G67 REFUTED (FLAG for maintainer): sweep-10 flagged that vue-tui resets flexDirection/flexWrap to row/nowrap on dynamic prop removal while Ink's host-level applyFlexStyles keeps the stale value. On codex review this was REFUTED: Ink's <Box> injects flexDirection:'row'/flexWrap:'nowrap' defaults BEFORE the style spread (Box.tsx), so an OMITTED prop resolves to row/nowrap (the common case) — which vue-tui's G19 reset already matches. Ink only keeps the stale value for an explicit flexDirection={undefined} (rare). Vue cannot distinguish an omitted prop from an explicit undefined (both collapse to the prop default), so vue-tui must pick ONE behavior: it keeps G19's reset-to-default, which matches Ink's COMMON omitted case and is Vue-idiomatic. The residual divergence (explicit ={undefined} → vue resets, Ink keeps stale) is an UNAVOIDABLE Vue-vs-React semantic, left as-is. Decision: do NOT "fix" G67; keep G19. Surfaced here for maintainer review.
  • 2026-05-29 — Sequencing: the P1 gaps G01 (Static unmount), G02 (useAnimation throttle coalescing), G03 (screen-reader render path) are architecturally invasive — they hinge on timing between Vue's reactivity and the scheduler's commit, where a mistake silently drops output. Deferred until the end-to-end pipeline (PR → codex → CI → auto-merge) is validated on a low-risk fix. Started with G04.
  • 2026-05-29 — G04 (border background): maintainer confirmed Ink behavior — border glyphs carry NO background unless borderBackgroundColor (or a per-edge variant) is set. Removed the ?? bgColor fallback in paint.ts colorizeEdge. Tests rewritten to match Ink:
    • "wrapped text preserves backgroundColor on every line" → "…on every content line": now asserts inner content rows carry the bg while the pure-border first/last rows do not (box height 4→5 so the wrapped text fits without overflowing onto the bottom border).
    • "Box background with border fills content area": inline snapshot updated — border rows (╭───╮/╰───╯) no longer carry bg; inner rows keep it.
    • "Box backgroundColor survives border rendering": unchanged, still passes (the inner fill keeps the bg).
    • Added "Box backgroundColor does not bleed onto border glyphs (Ink parity)" as the failing-first reproduction.
  • 2026-05-30 — G02 (useAnimation throttle): to actually fix the gap by default (not just when a caller passes maxFps), maxFps now defaults to 30 (Ink: options.maxFps ?? 30) and a single renderThrottleMs = ceil(1000/maxFps) feeds BOTH the commit scheduler and createAnimationScheduler, mirroring Ink's one-value architecture. Behavior change: the default commit-throttle shifts 32ms→34ms (no test depended on 32ms; explicit maxFps already drove commit cadence on main). Test rewrite: the pre-existing "delta accounts for throttled ticks" test was weak/self-contradictory (asserted only delta>0, passed trivially against the bug) — replaced with "delta accumulates across coalesced ticks…" (maxFps:5, 200ms window) plus a default-path test using a count-based assertion (≈31 rendered ticks unfixed vs <15 fixed) so it deterministically discriminates. Both verified red on unfixed.
  • 2026-05-30 — G05+G15 (border geometry): rewrote paint.ts drawBorder to draw each edge independently (mirroring Ink render-border.ts): removed the blanket w<2||h<2 early-return (→ w<1||h<1) and changed the vertical-side loop to start at offsetY = top?1:0 with run length max(0, h - visibleTop - visibleBottom). Per conflict policy, 4 existing snapshot tests that encoded the buggy hide-top/bottom output were updated to Ink-correct values (rails on the content row, not shifted/missing) — codex traced each against Ink render-border.ts and confirmed they match (not just code-blessed). Normal (top+bottom) boxes are byte-identical to before.
  • 2026-05-30 — G07 → CANDIDATE (needs human decision, not auto-fixed): Ink genuinely does NOT exit on kitty-protocol Ctrl+C (use-input.ts:245-247 only returns; the \x03 exit path never fires under kitty). vue-tui DOES exit on kitty Ctrl+C (useInput.ts:100-105 calls app.exit()). But vue-tui's behavior is arguably BETTER UX (Ctrl+C exits under all protocols) and Ink's non-exit looks like a kitty-era oversight, not a deliberate design. Rather than auto-remove a working Ctrl+C-exits behavior to match an Ink limitation, appended it to ink-parity's 'Candidate intentional divergences' section for the maintainer to decide (keep & allowlist, or match Ink). No code change.
  • 2026-05-30 — G33 (exit() first-wins) — REVERSES sweep-1 refutation + test rewrite: sweep-1 wrongly refuted exit()-second-wins ('already guarded'); sweep-4 + a red test confirmed vue-tui was LAST-wins. Added an exitInitiated || teardownStarted guard (Ink's isUnmounted || isUnmounting first-wins). Per conflict policy, 3 existing exit tests that ASSERTED the last-wins bug (resolving 'second', with comments noting Ink does first-wins) were rewritten to assert first-wins ('first') — they had documented the divergence; now aligned to Ink.
  • 2026-05-30 — G23 (SR ) — spec corrected via empirical Ink check: the sweep-2 finding claimed Ink applies the Transform's fn to its squashed SR children. Building Ink from source and tracing squash-text-nodes.ts showed Ink only applies internal_transform of CHILD nodes, never the top-level node handed to squashTextNodes — so a <Transform> directly under a <Box> outputs its children CONCATENATED with no transform. Only the \n→"" join was a real bug. Fixed to match Ink (concat, no top-level transform); applying it would have DIVERGED. (Per align-with-Ink; codex independently confirmed.)
  • 2026-05-29 — G06 REFUTED (false positive from the audit): the audit claimed <Transform>'s fn gets a hardcoded index 0 "instead of the childNode index". Re-verification against Ink output.ts:230-239 shows Ink's index is the line index (transformers apply per output line: transformer(line, index)), not a child index — the audit misread it. vue-tui already applies per-line line indices for multi-line (block) transforms via the yoga-carrier path: the existing tests transform with multiple lines → [0: hello world]\n[1: goodbye world] and transform-yoga [0: hello]\n[1: world] pass on unmodified code. paint.ts:314's transform(innerText, 0) is only the inline <Transform>-inside-<Text> path, whose content is a single logical line where 0 matches Ink (all inline tests assert [0: …]). No observable gap; not fixed.
  • 2026-05-30 — G63 left-edge wide-glyph origin → ALIGNED TO INK (x = clip.x1) + 2 test rewrites — FLAG FOR MAINTAINER: the G63 follow-up codex review found one must-fix in paint.ts write(). When a wide glyph (e.g. 中) straddles the LEFT clip edge it is dropped whole; vue-tui then advanced the write origin by the dropped glyph's EXTRA column (lineX += droppedWidth), emitting a leading space (" x"). Ink instead flatly sets the per-line write origin to the clipped left edge (output.ts:210-212 if (x < clip.x1) x = clip.x1) and writes the kept content from there with NO leading offset. Verified empirically against the pinned Ink build (40b3a75) via renderToString on the SAME tree (<Box width=4 overflow=hidden><Box marginLeft=-1 flexShrink=0>…</Box></Box>): plain 中x → "x"; <Transform>=>"z" → "z"; <Transform>=>[l] → "[x]" (vue-tui previously emitted " x"/" z"/" [x]"). Fix: replaced the droppedWidth origin advance with if (lineX < clipH.x1) lineX = clipH.x1; the G63 clip-before-transform order is unchanged, right-edge clipping and the no-clip path are untouched. Per conflict policy, 2 wide-char tests that LOCKED the old leading-space padding were rewritten to Ink's clipped-origin output: overflow.test.tsx "text after clipped left-edge wide char …" (" x"→"x") and grapheme-clip.test.tsx "left-edge wide grapheme straddle …" (startsWith(" x")→=== "x"). FLAG: if the maintainer prefers vue-tui's leading-space padding (keeps the dropped wide cell visually reserved) over Ink's flush clip origin, this is the single change to reverse (restore the droppedWidth advance + revert those 2 test assertions).
  • 2026-05-30 — G63 (clip-then-transform) — test rewrite per conflict policy: paint.ts write() applied the line transformer to the FULL line then horizontally clipped (added by commit 2c99431 "reorder clip/transform pipeline … so transforms are clipped correctly", which deliberately put clip AFTER transform to stop Transform-widened text escaping the clip). That reverses Ink's output.ts order — Ink maps sliceAnsi(line, from, to) (horizontal clip) over lines BEFORE the lines.entries() loop that runs transformer(line, index), and never re-clips the transformer's output. Verified empirically with renderToString against the pinned Ink build (7.0.4 / 40b3a75): <Box width=5 overflowX=hidden><Box width=16 flexShrink=0><Transform l=>'['+l+']'>hello world</Transform></Box></Box> → "[hello]" (clip "hello" first, then bracket), not "[hell" (bracket then slice). Reordered the two blocks in paint.ts; the post-vertical-clip line index passed to the transformer is unchanged (still the lines.entries() counter), so the G21/G32/G52/G58 transform-index/nesting tests are untouched. The pre-existing overflow test "transform returning wide char in clipped box is clipped" asserted the OLD (commit-2c99431) order — that the transform's wide-中 output is re-clipped. Ink renders "abc中" there (transform output overflows, never re-clipped), confirmed against the reference. Per conflict policy the test was renamed to "… overflows (Ink clip-then-transform)" and now asserts "abc中". No-clip transform paths and plain (no-transform) overflow clipping are unchanged.

Confirmed gaps

status ∈ todo · in-progress · pr-open · merged · blocked · refuted · candidate. Priority: correctness/behavior first, omissions next.

id area summary priority status branch PR
G01 static-newline-spacer Static keeps every already-written item permanently mounted instead of unmounting it P1 merged fix/parity-static-unmount #34
G02 app-exit-instances-animation-sr useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' P1 merged fix/parity-useanimation-throttle #35
G03 render-lifecycle-reconciler Live screen-reader render path is missing; commit() always paints the visual grid P1 merged fix/parity-sr-render #36
G04 box-layout-border Border edges incorrectly inherit the Box backgroundColor P2 merged fix/parity-border-bg #30
G05 box-layout-border Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) P2 merged fix/parity-border-1cell #37
G06 text-wrap-transform Nested / transform fn receives hardcoded index 0 instead of childNode index P2 refuted — —
G07 input-keypress-kitty-paste Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink P2 kept — (see ink-parity.md) —
G08 focus useFocus does not react to changes in the id prop P2 merged fix/parity-usefocus-id #31
G09 stdout-stderr-stdin-size-cursor External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers P2 merged fix/parity-external-bsu #39
G10 stdout-stderr-stdin-size-cursor setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error P2 merged fix/parity-setrawmode-throw #40
G11 render-lifecycle-reconciler Resize handler does not clear+reset on terminal-width decrease P2 merged fix/parity-resize-clear #41
G12 render-lifecycle-reconciler Renderer frame width/rows lack terminal-size fallback (only ?? defaults) P2 merged fix/parity-renderer-size #33
G13 box-layout-border Custom border style objects (BoxStyle) not supported P3 merged fix/parity-custom-border #42
G14 app-exit-instances-animation-sr No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout P3 merged fix/parity-instance-reuse #43
G15 box-layout-border Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned P2 merged fix/parity-border-1cell #37
G16 box-layout-border Per-edge borderDimColor=false cannot override general borderDimColor (|| dimAll vs Ink's ??) P3 merged fix/parity-border-dim #44
G17 render-lifecycle-reconciler Screen-reader live-path edges: still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) P3 merged fix/parity-sr-edges #45
G18 render-lifecycle-reconciler No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) P1 merged fix/parity-signal-teardown #47
G19 box-layout-border Dynamic removal of most yoga style props does not reset to default (stale layout) P2 merged fix/parity-yoga-reset #48
G20 stdout-stderr-stdin-size-cursor writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) P2 merged fix/parity-write-after-unmount #49
G21 text-wrap-transform Nested in gets hardcoded index 0 vs child sibling position (squash path) P3 merged fix/parity-transform-index #50
G22 app-exit-instances-animation-sr SR role dedup inherits grandparent role; Ink dedups only vs immediate parent P3 merged fix/parity-sr-role-dedup #51
G23 app-exit-instances-animation-sr under SR-joins children with newline; Ink concatenates P3 pr-open fix/parity-sr-transform-concat #52
G24 render-lifecycle-reconciler Renders ALL nodes; Ink renders only the most-recent staticNode (multi-Static support — kept ) P3 kept — (see ink-parity.md) —
G25 text-wrap-transform truncate wrap keeps multi-line when each line fits but whole string overflows (Ink collapses via cliTruncate on whole string) P3 todo — —
G26 input-keypress-kitty-paste useInput suppresses input text on kitty key-RELEASE events; Ink delivers the char on release (REVERSES the sweep-1 refutation) P3 todo — —
G27 stdout-stderr-stdin-size-cursor releaseRawMode defers input detach + parser reset; Ink stops input synchronously (clearInputState) P3 todo — —
G28 stdout-stderr-stdin-size-cursor useStdin() leaks internal members; Ink narrows the public return to PublicProps {stdin,setRawMode,isRawModeSupported} P3 todo — —
G29 render-lifecycle-reconciler useCursor()/setCursorPosition never applied during normal render commits (only after console writes) P3 todo — —
G30 app-exit-instances-animation-sr SR: nested inside a box-level drops the INNER transform fn (refines G23) P3 todo — —
G31 app-exit-instances-animation-sr useAnimation interval option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity) P3 todo — —
G32 text-wrap-transform nested directly inside another (in ) is silently DROPPED — paint+measure lose all content P1 merged fix/parity-transform-nesting #54
G33 app-exit-instances-animation-sr exit() resolves last-call-wins; Ink is first-call-wins (REVERSES the sweep-1 exit()-second-wins refutation) P1 merged fix/parity-exit-first-wins #55
G34 box-layout-border Box border glyphs + bg fill are subject to ancestor transformers; Ink renders chrome with empty transformer list P3 todo — —
G35 static-newline-spacer container's own borderStyle/backgroundColor (non-yoga visual style) not painted P3 todo — —
G36 focus useFocus autoFocus prop not reactive (captured once); Ink re-registers on autoFocus change P3 todo — —
G37 render-lifecycle-reconciler onRender renderTime metric includes stdout write + static capture, not just paint P3 todo — —
G38 app-exit-instances-animation-sr useAnimation/scheduler quantizes interval via Math.round; Ink preserves fractional intervals P3 todo — —
G39 app-exit-instances-animation-sr SR linearization: a default <Box> (no flexDirection prop) joins children with \n; Ink defaults flexDirection to row so it joins with a space (MEDIUM, found sweep-5) P1 merged fix/parity-sr-default-box-separator #56
G40 render-lifecycle-reconciler Output write CLAMPS chars at grid width (paint.ts offsetX>=width break/continue); Ink writes overflow past the grid (unbounded output.ts loop) — sweep-5 LOW P3 todo — —
G41 render-lifecycle-reconciler onRender renderTime INCLUDES synchronous stdout-write time; Ink calls onRender before the write — sweep-5 LOW (overlaps G37) P3 todo — —
G42 render-lifecycle-reconciler Debug mode does NOT re-emit accumulated <Static> history each frame — sweep-5 LOW P3 todo — —
G43 render-lifecycle-reconciler Non-interactive unmount SKIPS the trailing newline when the final frame is empty — sweep-5 LOW P3 todo — —
G44 static-newline-spacer <Static style> layout props (flexDirection/padding/justify/align/gap/margin/width) dropped from isolated paint — only column-default + visual border/color survive (MEDIUM, sweep-6) P1 pr-open fix/parity-static-layout-style #59
G45 focus Programmatic focusNext()/focusPrevious() are no-ops while focus is disabled; Ink keeps them live (guard belongs only in Tab listener) (MEDIUM, sweep-6) P1 pr-open fix/parity-programmatic-focus-while-disabled #57
G46 render-lifecycle-reconciler Non-empty multi-line screen-reader frames append a spurious trailing \n + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) P1 pr-open fix/parity-nonempty-sr-frame-newline #58
G47 box-layout-border Per-edge border toggles (borderTop/Bottom/Left/Right) reserve yoga border space even with no borderStyle set (only on reactive patch) — sweep-6 LOW P3 todo — —
G48 text-wrap-transform Measure path omits sanitizeAnsi that Ink applies in squashTextNodes (diverges only for private-param/intermediate-byte CSI) — sweep-6 LOW P3 todo — —
G49 stdout-stderr-stdin-size-cursor restoreLastOutput() uses nullish-coalescing instead of Ink's falsy-OR fallback, dropping the restored trailing newline before the first frame — sweep-6 LOW P3 todo — —
G50 app-exit-instances-animation-sr useApp's waitUntilRenderFlush() not reachable from inside the render tree (intentional minimal surface — cf. allowlist) — sweep-6 LOW P3 todo — —
G51 app-exit-instances-animation-sr Screen-reader text squash does not sanitize ANSI (transform-injected control sequences leak) — sweep-6 LOW (same family as the squashTextContent note) P3 todo — —
G52 text-wrap-transform <Transform> index arg shifts when null/false/v-if comment-node siblings precede it inside <Text> — Vue comment nodes occupy positional slots; Ink (React) skips null children (MEDIUM, sweep-7; G21's positional-index fix introduced the precondition) P1 merged fix/parity-transform-index-comment-skip #60
G53 box-layout-border Removing an explicit display='flex' keeps the Box visible; Ink resolves a removed display to DISPLAY_NONE — sweep-7 LOW P3 todo — —
G54 focus Duplicate explicit focus id: Ink keeps duplicate focusable entries; vue-tui dedups on add and removes only the first on remove — sweep-7 LOW P3 todo — —
G55 render-lifecycle-reconciler Debug-mode frame writer suppresses identical consecutive frames; Ink re-emits every debug frame unconditionally — sweep-7 LOW P3 todo — —
G56 app-exit-instances-animation-sr exit() misclassifies cross-realm Error objects (no [object Error] fallback) → resolves waitUntilExit() instead of rejecting — sweep-7 LOW P3 todo — —
G57 app-exit-instances-animation-sr useAnimation reset() while isActive=false zeros frame/time/delta; Ink keeps the frozen (paused) values — sweep-7 LOW P3 todo — —
G58 static-newline-spacer Standalone <Transform> with DIRECT text/Newline children (not wrapped in <Text>) renders nothing — text silently dropped; Ink's Transform IS an ink-text host so bare-string children render inline (MEDIUM, sweep-8; canonical README pattern) P1 pr-open fix/parity-standalone-transform-text #61
G59 render-lifecycle-reconciler Interactive SR frames route through renderInteractiveFrame → a tall/overflowing SR frame emits clearTerminal + replays fullStaticOutput + hides cursor; Ink's dedicated SR branch never clears/accumulates/hides (MEDIUM, sweep-8) P1 pr-open fix/parity-sr-dedicated-write-path #62
G60 box-layout-border String-typed dimension/position values (bare numeric string e.g. width="50") treated as POINT not PERCENT; Ink applies all string dims as percent — sweep-8 LOW P3 todo — —
G61 stdout-stderr-stdin-size-cursor Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW P3 todo — —
G62 render-lifecycle-reconciler resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW P3 todo — —
G63 text-wrap-transform Transform applied to the FULL line THEN horizontally clipped; Ink clips THEN transforms (output.ts), so a width-sensitive transform (gradient / OSC-8 hyperlink) inside an overflowX:hidden box gets the wrong char span (MEDIUM, sweep-9) P1 merged fix/parity-clip-then-transform #63
G64 static-newline-spacer <Static> isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) P1 merged fix/parity-static-content-size #64
G65 app-exit-instances-animation-sr SR unchanged-frame skip compares WRAPPED output, not Ink's UNWRAPPED linearization — diverges on resize with identical content — sweep-9 LOW P3 todo — —
G66 app-exit-instances-animation-sr app.waitUntilRenderFlush() lacks Ink's unmount/unmounting short-circuit to awaitExit() — sweep-9 LOW P3 todo — —
G67 box-layout-border flexDirection/flexWrap reset on dynamic removal vs Ink keep-stale REFUTED (codex, sweep-10): Ink's <Box> injects row/nowrap defaults BEFORE the style spread, so OMITTING the prop → row (common case, which vue-tui's G19 reset already matches); Ink keeps stale ONLY for explicit flexDirection={undefined} (rare), which Vue CANNOT distinguish from omission. Keeping stale would break the common case. UNAVOIDABLE Vue-vs-React semantic — KEEP G19's reset-to-default. FLAG for maintainer. P1 refuted — —
G68 text-wrap-transform Text style ANSI nesting/order: vue-tui builds ONE chained chalk (color→bg→dim→bold…); Ink applies each style as a SEPARATE nested wrap in a different order (dim→color→bg→bold…) — bytes diverge for multi-style Text (MEDIUM, sweep-10) P1 merged fix/parity-text-ansi-order #65
G69 render-lifecycle-reconciler <Static> output is throttled, not immediate; Ink has an isStaticDirty/onImmediateRender escape hatch that paints freshly-appended Static children synchronously at commit before they can be sliced out (MEDIUM, sweep-10) P1 todo — —
G70 app-exit-instances-animation-sr Non-interactive (and debug) screen-reader output is hard-wrapped at terminal width; Ink keeps it unwrapped (wrap only in the interactive SR branch) — diverges from vue-tui's own renderToString (MEDIUM, sweep-10) P1 todo — —
G71 focus useFocusManager().activeId is null (not undefined) when nothing is focused — sweep-10 LOW P3 todo — —
G72 render-lifecycle-reconciler Layout computation + layout-listener (measureElement/useBoxMetrics) callbacks are throttled, not immediate — sweep-10 LOW P3 todo — —
G73 render-lifecycle-reconciler Show-cursor escape (\x1b[?25h) written twice on normal interactive unmount — sweep-10 LOW P3 todo — —

Gap details

Full evidence captured at audit time (Ink commit 40b3a75). Re-verify against source before fixing.

G01 — Static keeps every already-written item permanently mounted instead of unmounting it

area: static-newline-spacer · kind: behavior · severity: medium · priority: P1

  • Ink: /tmp/ink-40b3a75/src/components/Static.tsx:30-42 — Ink holds an internal cursor const [index, setIndex] = useState(0), renders only items.slice(index) (line 32-34), then in a layout effect advances setIndex(items.length) (line 36-38). After the effect settles, itemsToRender is items.slice(items.length) = [] (line 40), so the <ink-box internal_static> renders ZERO children (line 53-57). The React elements created for previously-emitted items are therefore removed from the tree and unmounted — Ink only ever keeps not-yet-written items mounted (the canonical 'things that don't change after they're rendered' contract, lines 21-24).
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/components/Static.ts:16-23 — the render function ALWAYS maps over the full props.items ((props.items as unknown[]).map((item, index) => slots.default?.({ item, index })), line 21). There is no internal index/slice, so every item's component stays mounted for the life of the . One-time-WRITE is enforced separately at flush time in /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/paint/static-channel.ts:15-20 via stat.children.slice(stat.writtenCount), which prevents re-PAINTING but does not unmount the components.
  • Why it's a real gap: Verified against actual source. Ink's Static (/tmp/ink-40b3a75/src/components/Static.tsx:30-42) holds an internal cursor index, renders only items.slice(index) (line 32-34), and advances setIndex(items.length) in useLayoutEffect (line 36-38). After the effect settles itemsToRender = items.slice(items.length) = [], so the internal_static ink-box renders ZERO children (line 53-57). React therefore reconciles away and UNMOUNTS every already-emitted item, tearing down its effects/timers/hooks.

vue-tui's Static (packages/runtime/src/components/Static.ts:16-23) ALWAYS maps over the full props.items in its render function with no index/slice, so every item component stays mounted for the life of the . One-time-WRITE is enforced separately at flush via stat.children.slice(stat.writtenCount) in packages/runtime/src/paint/static-channel.ts:15-20 (and the inline copy in render.ts:550-555). I confirmed nodes.ts:86/174 only tracks writtenCount; there is no unmount/slice path in Static.ts. So writtenCount prevents re-PAINTING but does NOT unmount the components — exactly as claimed.

Observable consequence: in Ink an emitted Static item's watchers/intervals/effects are destroyed right after first paint; in vue-tui they keep running and the item keeps re-rendering on reactive changes (output discarded by the writtenCount slice). The live DOM + yoga tree accumulate one Text+yoga node per historical item, and render.ts:579-580 calls calculateLayout over the whole tree (including the never-shrinking static subtree) on every commit, whereas Ink's static subtree is empty after settle.

Refutation checks all failed: (1) Not in the intentional-divergences list, and it is a lifecycle/perf behavior, not a naming/idiom difference. (2) vue-tui does NOT handle it elsewhere — searched components/Static.ts, host/nodes.ts, paint/static-channel.ts, render.ts; the only mechanism is writtenCount which is paint-only. (3) Not covered by tests — packages/runtime-tests/integration/components/static.test.tsx (incl. the ported Ink tests) only asserts painted frame contents and unmount-of-the-whole-; nothing asserts per-item component unmounting or that an item's effects/timers stop after it is written. (4) Ink does behave as claimed at this commit (slice + layout-effect index advance verified). No comment in Static.ts or static-channel.ts acknowledges the divergence.

Severity is medium rather than high: the canonical Static use case is a growing log of completed-task items which usually have no effects, so the typical user impact is the unbounded yoga/DOM accumulation (a perf regression on long logs) plus a correctness gap only for items that actually run effects/timers/watchers.

  • Fix sketch: In Static.ts, mirror Ink: keep a shallowRef cursor, render only items.slice(cursor) and advance cursor to items.length in onUpdated/watch (post-flush) so already-written item components unmount; adjust static-channel/render flush to account for the now-relative children. Add a test asserting an emitted Static item's onUnmounted fires / its interval stops.

G02 — useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders'

area: app-exit-instances-animation-sr · kind: behavior · severity: medium · priority: P1

  • Ink: /tmp/ink-40b3a75/src/hooks/use-animation.ts:70 reads renderThrottleMs from AnimationContext and lines 102-121 skip ticks while currentTime < nextRenderTimeRef.current (throttle window), then on the next allowed tick jump to the latest elapsed values and set nextRenderTimeRef = currentTime + renderThrottleMs. delta is computed as currentTime - lastRenderTimeRef so it accumulates across coalesced ticks (delta = time since last rendered tick). AnimationContext (/tmp/ink-40b3a75/src/components/AnimationContext.ts:4) carries renderThrottleMs.
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/composables/useAnimation.ts:90-95 tick() sets frame/time/delta on EVERY scheduler callback with delta = now - lastTickTime (time since last scheduler tick). There is no renderThrottleMs concept: createAnimationScheduler() takes no throttle arg (/Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/animation-scheduler.ts:25) and render.ts:625 calls it with none. The commit-scheduler throttleMs (render.ts:608-612) only throttles render commits, not animation state.
  • Why it's a real gap: Verified against actual source. Ink: use-animation.ts:70 pulls renderThrottleMs from AnimationContext (AnimationContext.ts:4, wired in ink.tsx:342-344 and App.tsx:724 as Math.max(1, ceil(1000/maxFps))). The subscriber (lines 102-121) skips ticks while currentTime < nextRenderTimeRef (the throttle window), then on the next allowed tick jumps to the latest elapsed values and sets nextRenderTimeRef = currentTime + renderThrottleMs. delta = currentTime - lastRenderTimeRef, i.e. time since last rendered tick, so it accumulates across coalesced ticks. The doc at use-animation.ts:42 documents this: "Time in milliseconds since the previous rendered tick. Accounts for throttled renders."

vue-tui: composables/useAnimation.ts:90-95 tick() runs on EVERY scheduler callback and sets delta = now - lastTickTime (time since previous scheduler tick, ~one interval). There is no renderThrottleMs concept anywhere in the animation path: createAnimationScheduler() (animation-scheduler.ts:25) takes no throttle arg, render.ts:625 calls it with none, AnimationSchedulerKey (context.ts:57) injects a plain AnimationScheduler with no throttle field. The commit-scheduler throttleMs (render.ts:608-612) throttles only terminal commits, not animation state. The vue-tui doc (useAnimation.ts:47-49) copies Ink's "Accounts for throttled renders" promise verbatim but the behavior is not implemented.

Observable divergence: with maxFps set so commit-throttle > interval (e.g. interval 16ms vs ~32ms throttle), Ink reports the accumulated delta (~throttle window) on each rendered/committed frame, so render-time physics integration position += speed * delta advances at correct wall-clock speed. vue-tui commits only some frames but each committed delta still reports ~one interval, under-integrating velocity at render time. frame/time stay monotonic, so impact is bounded to the documented velocity-driven-at-render-time pattern under active throttling — hence medium, not high.

Not intentional/idiom: it is a missing mechanism, not a naming/framework difference, and is not in the intentional-divergence list. Not already covered: the existing test "delta accounts for throttled ticks" (use-animation.test.tsx:915-943) does NOT validate it — it asserts only lastRenderedDelta > 0, its watchEffect fires on every 16ms ref mutation (not on committed frames), and its own comment is self-contradictory ("should reflect time since last rendered tick" yet "~20ms per tick"). It passes trivially against the buggy per-tick delta and cannot detect the coalescing absence.

  • Fix sketch: Thread a renderThrottleMs into createAnimationScheduler (from maxFps in render.ts:611-625) and gate tick() in useAnimation.ts to skip while now < nextRenderTime, then set delta = now - lastRenderedTime and nextRenderTime = now + renderThrottleMs (mirror Ink use-animation.ts:102-121); add a test asserting committed delta ~= throttle window, not ~interval.

G03 — Live screen-reader render path is missing; commit() always paints the visual grid

area: render-lifecycle-reconciler · kind: omission · severity: medium · priority: P1

  • Ink: ink.tsx:573-626 — when isScreenReaderEnabled, onRender() takes a dedicated branch that calls renderer with screen-reader mode (renderer.ts:15-34 -> renderNodeToScreenReaderOutput), then wrapAnsi(output, terminalWidth, {trim:false, hard:true}) and writes via eraseLines, producing flat linear screen-reader text (no borders/spatial grid). It also has screen-reader-specific static-output erasing.
  • vue-tui: render.ts:532-606 commit() never references isScreenReaderEnabled; it always calls paint(tuiRoot) (the 2D grid renderer in paint/paint.ts:398). isScreenReaderEnabled is only used at render.ts:609,611 to disable commit throttling. The dedicated renderScreenReaderOutput() (paint/screen-reader.ts) is wired only into render-to-string.ts:120-121, never into the live commit path.
  • Why it's a real gap: Verified against source. Ink (ink.tsx:540-543) calls render(rootNode, isScreenReaderEnabled), and renderer.ts:13-35 takes the renderNodeToScreenReaderOutput branch when screen reader is on, producing flat linearized plain text (no borders/grid). The live onRender interactive path (ink.tsx:573-626) then has a dedicated screen-reader branch: wrapAnsi(output, terminalWidth, {trim:false, hard:true}) + eraseLines-based writes, plus screen-reader-specific static erasing.

vue-tui's live commit() (render.ts:532-606) never references isScreenReaderEnabled; it unconditionally calls paint(tuiRoot) (the 2D grid painter, render.ts:571 and 582) for both the non-interactive and interactive branches. A grep of the entire runtime src confirms isScreenReaderEnabled is used in the live render path ONLY at render.ts:609 and 611 to disable commit throttling — never to switch the renderer. The dedicated renderScreenReaderOutput() (paint/screen-reader.ts:50) is imported and used only in render-to-string.ts:120-121, never in the live commit path. The code itself confirms intent: render.ts:361 comment says outputHeight is used for "screen-reader mode in future tasks" — i.e. the live path is explicitly not yet implemented.

No test covers this: grep for screen-reader/screenReader/INK_SCREEN_READER across *.test.ts in the runtime package returned zero files.

Not an intentional divergence: it is not in the divergence allowlist, and Box.ts:147-157, Text.ts:33-41, Transform.ts:17-21 already swap in ariaLabel content and honor ariaHidden when isScreenReaderEnabled — that aria swapping only makes sense if the final output is meant to be linearized for a screen reader. So the half-built accessibility wiring corroborates that the live screen-reader render branch is a genuine missing piece, not a deliberate design choice.

Severity medium rather than high: vue-tui's components do partially adapt (aria labels, ariaHidden) so output isn't identical to the visual app, but it still flows through the grid painter (borders, padding, columns, 2D spatial layout intact) instead of Ink's flat wrapAnsi linear text. A screen-reader user therefore observes materially different output than Ink, but it is a niche, opt-in path (INK_SCREEN_READER=true / isScreenReaderEnabled:true).

  • Fix sketch: In render.ts commit(), branch on isScreenReaderEnabled to use renderScreenReaderOutput(tuiRoot, {skipStaticElements:true}) + wrapAnsi(out, w, {trim:false, hard:true}) and an eraseLines-based write path, mirroring ink.tsx:573-626, instead of paint(tuiRoot).

G04 — Border edges incorrectly inherit the Box backgroundColor

area: box-layout-border · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/render-border.ts:44-52 computes each edge's border background from borderTopBackgroundColor ?? borderBackgroundColor (etc.) only. It never falls back to the box's node.style.backgroundColor (grep for backgroundColor in render-border.ts returns nothing besides the dedicated border-bg props). So a Box with backgroundColor but no borderBackgroundColor draws plain (uncolored-bg) border glyphs.
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/paint/paint.ts:353-356 colorizeEdge computes edgeBg = border<Edge>BackgroundColor ?? borderBackgroundColor ?? bgColor, where bgColor = props.backgroundColor (paint.ts:346). The border glyphs therefore get painted with the Box's backgroundColor when no explicit border background is set.
  • Why it's a real gap: Verified against source on both sides.

Ink (/tmp/ink-40b3a75/src/render-border.ts:44-52): each edge's border background = borderBackgroundColor ?? borderBackgroundColor, with NO fallback to node.style.backgroundColor. stylePiece(segment, fg, bg) with bg=undefined runs colorize(...) which returns the segment unchanged, so an uncolored-bg border glyph is emitted. render-background.ts:28-29,42-49 fills ONLY the inner content area (x+leftBorderWidth .. contentWidth), never the border cells. render-node-to-output.ts:163-164 calls renderBackground then renderBorder, so the border line is colorized independently of the box bg. Net: draws border glyphs with default (no) background in Ink.

vue-tui (paint.ts:349-362): colorizeEdge computes edgeBg = borderBackgroundColor ?? borderBackgroundColor ?? bgColor, where bgColor = props.backgroundColor (paint.ts:346). drawBorder is called (paint.ts:434-435) and fillBackground only fills the inner area (paint.ts:443), so the border glyphs themselves are painted with the box backgroundColor. This is a real, user-observable color divergence.

Not intentional/idiom, not a naming difference. NOT already correctly covered: the existing snapshot test in packages/runtime-tests/integration/components/background-color.test.tsx:294-310 ("Box background with border fills content area") actually FROZE the buggy behavior — its inline snapshot shows [46m╭────────╮[49m, i.e. cyan bg applied to the border corners/edges. The mirrored Ink test (/tmp/ink-40b3a75/test/background.tsx:275-294) only uses loose includes(ansi.bgCyan) assertions (satisfied by the inner fill) and never asserts the border glyphs lack a bg, so it does not pin Ink to the cyan-bordered output. There is no test asserting Ink-parity (uncolored border bg) — so the gap is real and not caught.

Severity low: cosmetic ANSI background on border glyphs only when a Box sets backgroundColor without an explicit border background; functionally minor but a genuine visual divergence and the snapshot enshrines the wrong behavior.

  • Fix sketch: In paint.ts colorizeEdge (~line 353-356), drop the ?? bgColor fallback so edgeBg = borderBackgroundColor ?? borderBackgroundColor only; then regenerate the now-incorrect snapshot in background-color.test.tsx ("Box background with border fills content area", ~line 304) to drop [46m around the border glyphs.

G05 — Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard)

area: box-layout-border · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/render-border.ts has no minimum-size guard. For a box with only left/right borders (borderTop:false, borderBottom:false) and height 1, verticalBorderHeight stays 1 (render-border.ts:87-95) and the left/right glyphs are written (render-border.ts:97-119, 139-147). Likewise a top/bottom-only border with width 1 still repeats box.top once (render-border.ts:71-78).
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/paint/paint.ts:338 if (w < 2 || h < 2) return; aborts the whole border draw, so a 1-row box with side borders, or a 1-column box with top/bottom borders, renders no border.
  • Why it's a real gap: Verified on both sides and reproduced live. Ink's render-border.ts (/tmp/ink-40b3a75/src/render-border.ts) has NO minimum-size guard: for a height-1 box with only left/right borders, verticalBorderHeight = height = 1 (lines 87-95, top/bottom not subtracted) and leftBorder/rightBorder each write one glyph (lines 99-147); for a width-1 box with only top/bottom, contentWidth=1 and box.top.repeat(1) writes one glyph (lines 71-78). vue-tui's drawBorder at packages/runtime/src/paint/paint.ts:338 has if (w < 2 || h < 2) return; which aborts the ENTIRE border draw.

Reachable and observable: I added a temporary repro test (column-flex, child Box borderStyle=round borderTop={false} borderBottom={false} wrapping single-cell content). Total box is w=3,h=1, so h<2 fires and vue-tui renders the frame "Above\n X\nBelow" — the middle row has NO side rails. Ink would render "│X│". So the side rails vanish entirely.

Refutation paths all fail: (1) Not an intentional divergence — guard dates to initial commit ff82f6e with no explanatory comment, not on the divergence allowlist, and CLAUDE.md requires a comment for non-idiomatic deviations. (2) vue-tui does NOT handle it elsewhere — the early-return in drawBorder is the only path. (3) Not covered by tests — borders.test.tsx has hide-top/bottom and hide-left/right cases, but all use multi-cell content where the surviving dimension is >=2, so the w<2||h<2 cutoff is never asserted. (4) Ink does behave as claimed at this commit per source trace.

Severity low: requires a degenerate 1-cell-thin box with partial borders, an edge case unlikely in typical layouts, but it is a real, observable, deterministic divergence.

  • Fix sketch: In drawBorder (paint.ts:338) replace the blanket if (w < 2 || h < 2) return; with Ink-style per-edge handling: clamp fill/verticalBorderHeight to >=0 and draw each visible edge by its own run length instead of aborting the whole border.

G06 — Nested / transform fn receives hardcoded index 0 instead of childNode index

area: text-wrap-transform · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/squash-text-nodes.ts:13-39 — squashTextNodes loops for (let index = 0; index < node.childNodes.length; index++) over ALL child nodes and calls childNode.internal_transform(nodeText, index) (line 38) with that childNode index. /tmp/ink-40b3a75/src/components/Transform.tsx:13 documents the public signature transform: (children: string, index: number). Confirmed by reproduction: for <Text><Transform A><Text>a</Text></Transform><Transform B><Text>b</Text></Transform></Text>, Ink's squash yields [A0:a][B1:b] (second sibling gets index 1).
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/paint/paint.ts:313-314 hardcodes the index: innerText = child.transform(innerText, 0) inside the per-child loop of renderTextWithInlineStyles. Same hardcoded 0 in the measurement path at /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/host/text-measure.ts:26 (child.transform(innerText, 0)). Empirically verified via a render test: vue-tui outputs [A0:a][B0:b] where Ink outputs [A0:a][B1:b].
  • Why it's a real gap: Confirmed real gap, verified empirically. Ink's squash-text-nodes.ts:13-38 loops for (let index = 0; index < node.childNodes.length; index++) and calls childNode.internal_transform(nodeText, index) (line 38), passing each child's array position. Transform.tsx:13 documents the public signature transform: (children: string, index: number), and Transform wraps children in <ink-text internal_transform={transform}>, so two sibling s inside one become childNodes at indices 0 and 1 → Ink yields [A0:a][B1:b].

vue-tui hardcodes index 0 in BOTH paths the reviewer cited: paint.ts:314 (innerText = child.transform(innerText, 0)) and text-measure.ts:26 (child.transform(innerText, 0)). I wrote a reproduction test (two sibling Transforms inside a Text via renderToString) and vue-tui produced [A0:a][B0:b] — the second sibling gets 0 instead of 1, exactly as claimed.

Refutation criteria all fail: (1) Not intentional — the (line, index) signature is a faithful Ink port, createTransform even names the param lineIndex, and the standalone per-line path (paint.ts Output loop) correctly uses the real index, showing the 0 is an oversight not a design choice. (2) Not handled elsewhere — both squash paths hardcode 0; no sibling index is threaded into the per-child loop. (3) Not covered — every existing nested-Transform test in transform.test.tsx uses a single child, so index 0 is coincidentally correct; none uses 2+ siblings. (4) Ink does behave as claimed at this commit.

Severity low: the index argument is rarely used and the divergence only surfaces with 2+ sibling Transform/Text children inside one Text that read index — an uncommon pattern — but it is a genuine, observable correctness gap in both render and measure paths.

  • Fix sketch: Thread the loop position into the transform call: in paint.ts renderTextWithInlineStyles and text-measure.ts flattenLeaves, iterate with an index (e.g. node.children.forEach or a counter) and pass it as the second arg to child.transform instead of 0 (matching Ink's childNodes index, including any non-element siblings in the count).

G07 — Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink

area: input-keypress-kitty-paste · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/hooks/use-input.ts:245-247: when input==='c' && key.ctrl && internal_exitOnCtrlC, Ink only returns (skips the user handler) and does NOT exit. Ink's actual exit path is the legacy \x03 byte caught in /tmp/ink-40b3a75/src/components/App.tsx:244-246 emitInput. Under kitty protocol Ctrl+C arrives as \x1b[99;5u (input='c', key.ctrl), never as \x03, so Ink does not exit on kitty Ctrl+C.
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/composables/useInput.ts:100-105: when input==='c' && key.ctrl && stdin.internal_exitOnCtrlC, vue-tui calls app.exit() directly and returns. Legacy \x03 is separately caught in render.ts:914.
  • Why it's a real gap: Verified the claim against actual source at the cited locations and the surrounding data-flow.

Ink side (REFUTE attempt failed — Ink genuinely does NOT exit on kitty Ctrl+C):

  • /tmp/ink-40b3a75/src/hooks/use-input.ts:245-247: when input==='c' && key.ctrl && internal_exitOnCtrlC, Ink only returns (skips the user handler). No exit() call.
  • Ink's real exit path is /tmp/ink-40b3a75/src/components/App.tsx:244-246 handleInput: if (input === '\x03' && exitOnCtrlC) handleExit(). This fires only for the literal \x03 byte.
  • Critically, App.tsx feeds handleInput via emitInput, which receives PARSED events from inputParserRef.current.push(chunk) (App.tsx:283-291). The parser in /tmp/ink-40b3a75/src/input-parser.ts:239 pushes the RAW escape sequence (events.push(parsedEscapeSequence.sequence)) — it does not rewrite kitty \x1b[99;5u into \x03. So under kitty protocol, input at App.tsx:244 is the raw CSI-u sequence, the \x03 check is false, and Ink does not call handleExit. The kitty sequence then reaches use-input.ts where parseKeypress resolves it to input='c'/key.ctrl and line 245 merely returns. Net: Ink swallows kitty Ctrl+C without exiting.

vue-tui side (does NOT match Ink):

  • packages/runtime/src/composables/useInput.ts:102-105: when input==='c' && key.ctrl && stdin?.internal_exitOnCtrlC, vue-tui calls app!.exit() directly, then returns.
  • Legacy \x03 is separately intercepted in packages/runtime/src/render.ts:912-917 emitInput BEFORE dispatch to useInput, mirroring Ink's App.tsx \x03 path. But the kitty Ctrl+C exit lives ONLY inside useInput, so a kitty-enabled app with no useInput hook would not exit either — confirming the reviewer's nuance.

Refutation checks: (1) Not in the intentional-divergence list and not a naming/idiom difference — it's an unintended behavioral asymmetry. (2) vue-tui does not handle it the Ink way anywhere else in the tree (render.ts only catches \x03). (3) No existing test covers it — parse-keypress-kitty.test.ts and input-parser.test.ts only assert keypress parsing, none assert app.exit() on kitty Ctrl+C. Ink behaves as claimed at this commit.

Real, observable divergence. Severity low: the practical effect (kitty Ctrl+C exits) is the conventionally-expected behavior and arguably more correct than Ink's swallow; impact is limited and edge-case (kitty active + useInput mounted).

  • Fix sketch: If strict Ink parity is desired: in useInput.ts:102-105 replace app!.exit() with a bare return (swallow only). Optionally move kitty-Ctrl+C exit into render.ts emitInput (next to the \x03 check) so behavior is independent of useInput being mounted.

G08 — useFocus does not react to changes in the id prop

area: focus · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/hooks/use-focus.ts:47-57 — id is memoized via useMemo on [customId], and the add/remove effect deps include id. If the consumer passes a changing customId, Ink removes the focusable under the old id and re-registers it under the new id (re-running add/remove).
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/composables/useFocus.ts:28 — const id = options.id ?? "__auto-..." reads options.id exactly once at setup and never watches it. ctx.add/subscribe/remove (lines 31-35, 68-72) are all keyed to that one captured id, so a later change to options.id is ignored: the focusable stays registered under the original id and isFocused never tracks the new id.
  • Why it's a real gap: Verified both sources. Ink (/tmp/ink-40b3a75/src/hooks/use-focus.ts:47-65): id = useMemo(() => customId ?? random, [customId]), the add/remove effect deps are [id, autoFocus, add, remove], and the activate effect deps are [isActive, id, ...]. When customId changes, id recomputes, the cleanup runs remove(oldId), add(newId) re-runs, and isFocused = activeId === id tracks the new id. Ink behaves exactly as claimed.

vue-tui (packages/runtime/src/composables/useFocus.ts:28): const id = options.id ?? "__auto-..." reads options.id exactly once at setup. id is a plain const consumed by ctx.subscribe (31), ctx.add (35), ctx.activate/deactivate (58/61), and ctx.remove (70). The only watch (54) is on isActive. There is no reactive tracking of id; a later change is silently ignored — the focusable stays registered under the original id and isFocused never tracks a new id. The vue-tui status in the claim is accurate.

Notably, the type signature treats isActive as MaybeRefOrGetter (deliberately reactive, watched) but id as plain string — so this is not symmetric framework idiom; the author wired reactivity for isActive but not id.

Not covered by tests: the focus tests (packages/runtime-tests/integration/focus/{tab-cycling,programmatic-focus,focus-manager}.test.tsx) all pass props.id into useFocus but never mutate the id prop after mount, so the change-id path is untested.

Not on the intentional-divergence list. Real and observable (a test could mount id='a', switch to id='b', and observe vue-tui remains registered under 'a' while Ink re-registers). Severity is low because id is virtually always static per component instance and the plain-string typing discourages dynamic binding — but the behaviors genuinely diverge.

  • Fix sketch: Make id reactive: type id?: MaybeRefOrGetter<string>, and in useFocus.ts watch () => toValue(options.id) to remove/unsubscribe the old id and re-add/subscribe under the new id (mirroring Ink's add/remove effect keyed on [id]).

G09 — External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers

area: stdout-stderr-stdin-size-cursor · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/ink.tsx:687-698 (writeToStdout) and 717-728 (writeToStderr): each computes const sync = this.shouldSync() and, when true, writes bsu before this.log.clear(); stdout.write(data); this.restoreLastOutput() and esu after, so the clear+external-write+frame-restore is one atomic synchronized update. shouldSync = shouldSynchronize(stdout, interactive) (ink.tsx:994-995).
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/render.ts:380-407: writeToStdout/writeToStderr do writer.clear(); stdout.write(data); restoreLastOutput() with NO bsu/esu wrapping. vue-tui already imports and uses bsu/esu/shouldSynchronize for the normal render path (render.ts:27, 481, 505-522) but omits it from the external-write path. The composables surface this write via useStdout/useStderr (useStdout.ts:7, useStderr.ts:7).
  • Why it's a real gap: Verified against actual source. Ink (ink.tsx:687-698 writeToStdout, 717-728 writeToStderr) computes const sync = this.shouldSync() (shouldSync -> shouldSynchronize(stdout, interactive), 994-995) and wraps clear()+external-write+restoreLastOutput() with bsu/esu when sync is true, making it one atomic synchronized update. vue-tui (render.ts:380-407) does writer.clear(); stdout.write(data); restoreLastOutput() with NO bsu/esu wrapping in both writeToStdout and writeToStderr. The file already imports bsu/esu/shouldSynchronize (line 27) and uses them in the normal render path (line 481, 505-522), so this is an internal inconsistency, not an intentional divergence or framework-idiom difference. Test coverage: use-stdout.test.tsx only asserts the write routes through writeToStdout (no \x1b[?2026h/l check); use-stderr has no such file; write-synchronized.test.ts only unit-tests the bsu/esu constants and shouldSynchronize() helper, never the external-write path. So no existing test covers it. Real, observable gap: on mode-2026 terminals, useStdout/useStderr write() during render can tear/flicker where Ink does not. Severity low: cosmetic flicker on sync-capable terminals during concurrent external writes; helpers already present; fix is mechanical (wrap the clear/write/restore with shouldSynchronize-gated bsu/esu, matching the render path).
  • Fix sketch: In render.ts writeToStdout/writeToStderr, compute const sync = shouldSynchronize(stdout, interactive) and emit stdout.write(bsu) before writer.clear() and stdout.write(esu) after restoreLastOutput(), mirroring Ink ink.tsx:687-698/717-728.

G10 — setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error

area: stdout-stderr-stdin-size-cursor · kind: behavior · severity: low · priority: P2

  • Ink: /tmp/ink-40b3a75/src/components/App.tsx:315-327: handleSetRawMode throws immediately when !isRawModeSupported, with distinct messages for process.stdin vs a custom stdin, pointing users to the isRawModeSupported docs. useInput calls setRawMode(true) on mount (/tmp/ink-40b3a75/src/hooks/use-input.ts:169), so using useInput without TTY raw-mode support surfaces this error.
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/render.ts: AppContext.setRawMode (431-437) only checks typeof and no-ops otherwise; StdinController.acquireRawMode (1004-1005) and releaseRawMode (1031-1032) both if (!appCtx.isRawModeSupported) return; silently. useInput.attach() calls stdin.acquireRawMode() (composables/useInput.ts:113) with no guard, so it silently does nothing. There is no throw anywhere (no "Raw mode is not supported" string in src).
  • Why it's a real gap: Verified against source. Ink (/tmp/ink-40b3a75/src/components/App.tsx:315-327): handleSetRawMode throws an Error immediately when !isRawModeSupported, with two distinct messages (process.stdin default vs custom stdin) both linking to the isRawModeSupported docs. useInput (use-input.ts:169) calls setRawMode(true) on mount, so using useInput in a non-TTY/unsupported environment surfaces a descriptive error. vue-tui: render.ts AppContext.setRawMode (431-437) only checks typeof setRawMode and no-ops; StdinController.acquireRawMode (1005) and releaseRawMode (1032) both if (!appCtx.isRawModeSupported) return; silently. useInput.attach() (useInput.ts:113) calls stdin.acquireRawMode() unguarded, so it silently does nothing. A repo-wide grep confirms no "Raw mode is not supported" string and no throw anywhere in src. No test references acquireRawMode/isRawModeSupported/the throw path, so it is not already covered. This is not a naming/idiom difference nor on the intentional-divergence list: it is a genuine, user-observable failure-mode difference (silent input-never-works vs an explicit, documented error). Severity is low because the no-op is functionally degraded-but-safe and isRawModeSupported is exposed for gating, but the gap is real.
  • Fix sketch: In render.ts AppContext.setRawMode (or acquireRawMode), throw a descriptive Error (matching Ink's two-message form + docs link) when !isRawModeSupported instead of silently returning; add a failing test first that mounts useInput with a non-TTY stdin and asserts the throw.

G11 — Resize handler does not clear+reset on terminal-width decrease

area: render-lifecycle-reconciler · kind: behavior · severity: low · priority: P2

  • Ink: ink.tsx:459-474 resized(): compares getWindowSize(stdout).columns to this.lastTerminalWidth; when currentWidth < lastTerminalWidth it runs this.log.clear() and resets lastOutput/lastOutputToRender to '' before recomputing layout and rendering, then updates lastTerminalWidth. Comment: 'We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.'
  • vue-tui: render.ts:667-680 onResize() only does scheduler.cancel() then commit(). There is no stored previous terminal width, no width-decrease comparison, and no writer.clear()/lastOutput reset on narrowing. shouldClearTerminalForFrame (render.ts:127-147) only clears based on height/overflow/fullscreen, never on width change.
  • Why it's a real gap: Verified against actual source on both sides.

Ink (real behavior at this commit): ink.tsx:302 declares lastTerminalWidth, line 402 initializes it to the initial getWindowSize(stdout).columns in the constructor, and resized() (459-474) compares currentWidth < this.lastTerminalWidth; on a narrowing it runs this.log.clear() and resets lastOutput/lastOutputToRender to '' (with the explicit comment "We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders"), then recomputes layout, renders, and updates lastTerminalWidth. This is active, not a no-op.

vue-tui: the resize handler (render.ts:667-680) only does scheduler.cancel(); commit();. A repo-wide search of packages/runtime/src found no lastTerminalWidth/previous-width tracking and no width-decrease comparison anywhere. The only force-clear path is shouldClearTerminalForFrame (render.ts:127-147), which is purely height/overflow/fullscreen/unmount based — none of its four conditions read width, and nextOutputHeight/previousOutputHeight come from the logical painted frame's split("\n").length, not physical wrapped rows.

Why the gap is observable: the interactive frame writer (io/log-update.ts) erases the prior frame with ansiEscapes.eraseLines(previousLineCount), where previousLineCount is the logical line count of the previously written string. When the terminal narrows, previously-rendered wide lines wrap to MORE physical rows than previousLineCount, so eraseLines under-erases and leaves stale overlapping leftovers — precisely the artifact Ink's narrowing-clear prevents.

Not an intentional divergence: this is not on the divergence list and is a behavior (not naming/idiom) difference. The existing issue #26 work referenced in render.ts comments concerns suppressing a DUPLICATE clearTerminal on overflow during resize — a different case; it does not add the narrowing-clear.

Not already covered: no test emits a stdout 'resize' that narrows width and asserts clearing. The testing helper has terminal.resize() (packages/testing/src/render.ts:103-110) but a grep shows zero .resize( call sites in any *.test.ts; no lastTerminalWidth/narrowing/clearTerminal-on-width test exists.

Severity low: it only manifests on an interactive TTY when the user actively shrinks terminal width AND the previous frame had lines wider than the new width; transient artifact, typically cleared by the next full repaint or scroll. Real but cosmetic and narrow in scope.

  • Fix sketch: In render.ts onResize, track a lastTerminalWidth (init at mount from stdout.columns); when current columns < lastTerminalWidth, call writer.clear() and reset frameState.lastOutput/lastOutputToRender to '' before commit(), then update lastTerminalWidth (mirrors ink.tsx:459-474). Add a test that narrows via terminal.resize() and asserts a clear is emitted.

G12 — Renderer frame width/rows lack terminal-size fallback (only ?? defaults)

area: render-lifecycle-reconciler · kind: behavior · severity: low · priority: P2

  • Ink: ink.tsx uses getWindowSize(stdout) throughout for layout width and viewport rows (e.g. calculateLayout ink.tsx:510-520, fullscreen detection ink.tsx:1047). utils.ts getWindowSize falls back to terminal-size() then 80x24 when stdout.columns/rows are 0 or undefined (handles piped processes where columns is 0).
  • vue-tui: render.ts uses bare nullish defaults for the live frame: tuiRoot.yoga.setWidth(stdout.columns ?? 80) at lines 462,568,579, w = stdout.columns ?? 80 at line 546, and viewportRows = stdout.rows ?? 24 at line 486. terminal-size is available and is used by the useTerminalSize composable's resolveSize (composables/useTerminalSize.ts:11-22, mirroring Ink), but the renderer does not use that fallback chain.
  • Why it's a real gap: CONFIRMED, but narrower than the claim states. Verified facts:

Ink (commit 40b3a75) uses getWindowSize(stdout) for every layout-width and viewport-rows site: ink.tsx:402,460,511,598 (width) and 1047 (rows). utils.ts getWindowSize returns {columns,rows} when both truthy, else falls back with columns || terminalSize().columns || 80 and rows || terminalSize().rows || 24 — using || so a 0 value triggers the terminal-size fallback. The header comment explicitly says it handles "piped processes where stdout.columns is 0".

vue-tui render.ts uses bare nullish defaults at every equivalent site: setWidth(stdout.columns ?? 80) at lines 462, 568, 579; w = stdout.columns ?? 80 at line 546; viewportRows = isTty ? (stdout.rows ?? 24) : 24 at line 486. I grepped the whole runtime/src tree: terminal-size / resolveSize / getWindowSize appear ONLY in composables/useTerminalSize.ts. The fallback chain exists (resolveSize, lines 11-22, deliberately mirroring Ink per its own comment) but is NOT wired into the renderer. So this is an oversight, not an intentional divergence, and is not on the divergence allowlist.

No test covers it. The composables/terminal-size.test.tsx only tests useTerminalSize with normal dims (80x24, 100x40). The PTY ci.test.ts passes cols:0, but I empirically verified node-pty clamps cols:0 so the child reports stdout.columns===80, isTTY:true — it never delivers 0 to the userland code path.

REFUTATION OF THE CLAIM'S MECHANISM (why severity is low): The claim emphasizes "in a redirected/piped process stdout.columns is often 0 (not undefined)". I tested this: node -e ... | cat yields stdout.columns === undefined (typeof undefined), NOT 0. With undefined, undefined ?? 80 === 80 — identical to Ink, NO divergence in the common piped case. The ?? vs || difference only manifests when columns/rows is literally 0 while terminal-size() can still resolve a real value (e.g. an unusual TTY/CI/container reporting winsize 0 with a usable COLUMNS env). That edge case is real and Ink handles it (0-width -> paint clamps to 1 in vue-tui, vs correctly-sized in Ink), so the gap survives, but the claim overstates how often it triggers.

Net: a genuine renderer-vs-composable inconsistency and a real Ink-parity gap, observable only in the columns===0 / rows===0 edge case rather than the common undefined case the claim describes.

  • Fix sketch: Wire the existing resolveSize() (useTerminalSize.ts) into render.ts: replace stdout.columns ?? 80 (lines 462,546,568,579) and stdout.rows ?? 24 (line 486) with the terminal-size fallback chain (use || 80 / || 80 || terminalSize so 0 triggers fallback), and add a render-level test with a mock stdout reporting columns:0 to assert an 80-wide layout.

G13 — Custom border style objects (BoxStyle) not supported

area: box-layout-border · kind: omission · severity: low · priority: P3

  • Ink: /tmp/ink-40b3a75/src/styles.ts:255 types borderStyle?: keyof Boxes | BoxStyle, and /tmp/ink-40b3a75/src/render-border.ts:31-34 handles the object case: typeof node.style.borderStyle === 'string' ? cliBoxes[node.style.borderStyle] : node.style.borderStyle. A user can pass a fully custom {topLeft, top, topRight, right, ...} object as the border.
  • vue-tui: /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/components/Box.ts:111 declares borderStyle: String as PropType<BorderStyle> (string union only), and /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/paint/paint.ts:334-336 only does cliBoxes[style] lookup with style typed as string | undefined. A passed object would fail the string lookup and if (!chars) return (paint.ts:337) skips drawing.
  • Why it's a real gap: Verified in source. Ink styles.ts:255 types borderStyle as keyof Boxes | BoxStyle, and render-border.ts:31-34 explicitly handles the object case (typeof node.style.borderStyle === 'string' ? cliBoxes[...] : node.style.borderStyle), so a fully custom {topLeft,top,...} object is supported. vue-tui restricts borderStyle to a string union of 8 named presets: Box.ts:25-33 defines type BorderStyle = "single"|...|"arrow" and Box.ts:111 declares the prop as String as PropType<BorderStyle>. paint.ts drawBorder (lines 334-337) types style as string | undefined, does cliBoxes[style], and bails with if (!chars) return when the lookup fails. A passed object is not a valid cliBoxes key, so no border is drawn. Searched the whole runtime tree: BoxStyle only appears as an internal alias in paint.ts:323 (= cliBoxes value type), with no code path accepting a user-supplied object. No *.test.ts references borderStyle at all, so this is not test-covered. This is not in the intentional-divergences list. Real, observable parity gap. Severity low because custom border-character objects are a niche Ink feature and the 8 named presets cover the common cases; impact is limited to users porting Ink code that supplies a literal BoxStyle object.
  • Fix sketch: Widen borderStyle prop type to accept an object (PropType<BorderStyle | BoxStyle>) in Box.ts and in paint.ts drawBorder use typeof style === 'string' ? cliBoxes[style] : style before the !chars guard.

G14 — No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout

area: app-exit-instances-animation-sr · kind: omission · severity: low · priority: P3

  • Ink: /tmp/ink-40b3a75/src/instances.ts:9 keeps a WeakMap<WriteStream, Ink> and /tmp/ink-40b3a75/src/render.ts:252-274 getInstance() returns the EXISTING instance for a stdout and writes a stderr warning ('render() was called again for the same stdout before the previous Ink instance was unmounted ... Reusing stdout across multiple render() calls is unsupported. Call unmount() first.') instead of creating a second renderer that competes for the same output.
  • vue-tui: vue-tui has no instances map (no instances.ts; grep for instances/WeakMap in render.ts finds only the unrelated rawModeRegistry at /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/render.ts:876). createApp()/mount() (render.ts:149, 338) bind options.stdout (default process.stdout) per call with no dedup or warning, so calling createApp(...).mount() twice against the same stdout creates two live renderers writing to the same stream.
  • Why it's a real gap: Verified all four facts against source. Ink: instances.ts:9 keeps const instances = new WeakMap<NodeJS.WriteStream, Ink>(); render.ts:252-274 getInstance() returns the EXISTING instance for a stdout that already has a live renderer and writes the exact warning to process.stderr ("render() was called again for the same stdout before the previous Ink instance was unmounted ... Reusing stdout across multiple render() calls is unsupported. Call unmount() first.\n") rather than creating a second competing renderer. The claim describes Ink accurately at this commit.

vue-tui: grep across the entire packages/runtime/src tree finds no instances map keyed on a WriteStream — the only WeakMap is rawModeRegistry (render.ts:876, keyed on ReadStream, unrelated). mount() (render.ts:338-339) computes const stdout = options.stdout ?? process.stdout per call with no lookup, dedup, or warning. So createApp(...).mount() twice against the same stdout yields two live renderers writing the same stream. Reviewer did not miss it elsewhere.

Not an intentional divergence: the listed intentional items cover the render()->createApp() entry-API rename and the minimal useExit() surface, but the competing-renderer guard is a separate protective behavior, not naming. It is observable (interleaved output, double cursor/alt-screen control) and not framework-idiom-driven.

Not already covered: only public-api.test.ts references createApp, and merely to assert it is exported (line 7); no test exercises double-mount-on-same-stdout reuse or the warning.

Severity low: this is a real gap that survives refutation, but it requires a user to deliberately mount two trees on one stdout, an uncommon path; the original claim also self-rated lower confidence. The guard is genuinely absent, so confirmed=true.

  • Fix sketch: Add an instances Map<NodeJS.WriteStream, TuiApp> in render.ts; in mount() check for an existing live instance on the resolved stdout and, if found, write Ink's warning to process.stderr and return/reuse rather than wiring a second renderer; delete the entry on unmount/exit.

G15 — Vertical border sides not shifted up when borderTop=false

area: box-layout-border · kind: behavior · severity: low · priority: P2

Surfaced by codex during the G04 review (2026-05-29), not the sweep-1 audit.

  • Ink: /tmp/ink-40b3a75/src/render-border.ts:133 — vertical sides start at y + offsetY where offsetY = showTopBorder ? 1 : 0. With borderTop={false} the left/right rails begin on the first row.
  • vue-tui: packages/runtime/src/paint/paint.ts:378 — vertical sides always start at i = 1, so when the top border is hidden the left/right rails are shifted down by one row (and the first content row has no rails).
  • Fix sketch: compute offsetY = top ? 1 : 0 and start the vertical-side loop at offsetY, clamping the run length to the visible inner height; mirror Ink's per-edge offset for the bottom too.

G16 — Per-edge borderDimColor=false cannot override general borderDimColor

area: box-layout-border · kind: behavior · severity: low · priority: P3

Surfaced by codex during the G04 review (2026-05-29), not the sweep-1 audit.

  • Ink: /tmp/ink-40b3a75/src/render-border.ts:54 — borderTopDimColor ?? borderDimColor, so an explicit per-edge false overrides the general borderDimColor.
  • vue-tui: packages/runtime/src/paint/paint.ts:351 — (props[\border${capEdge}DimColor`] as boolean | undefined) || dimAll, so a per-edge falsecannot turn dim off onceborderDimColor` is set.
  • Fix sketch: use a nullish fallback ?? dimAll (treating only undefined as "inherit"), matching Ink.

G17 — Screen-reader live-path edges (deferred from G03)

area: render-lifecycle-reconciler · kind: behavior · severity: low · priority: P3

Surfaced by codex during the G03 review (2026-05-30). G03 fixed the core live SR render (dynamic frame now linear, not the 2D grid); these two SR-mode edges remain:

  • SR <Static> still grid-painted: the SR dynamic frame uses skipStaticElements:true, but commit() still flushes <Static> via paintStaticNode→paintIsolated (the 2D grid painter), so bordered static content can still emit box glyphs in SR mode. Ink linearizes SR static too (/tmp/ink-40b3a75/src/renderer.ts:24 uses skipStaticElements:false for the static pass). Fix: linearize the SR static channel (renderScreenReaderOutput over the fresh static children) when isScreenReaderEnabled.
  • Empty SR frame trailing newline: interactive SR frames go through the normal frame writer which appends "\n" for non-fullscreen/empty frames (render.ts write path), so an empty SR frame renders a blank line. Ink writes the wrappedOutput directly with height from that string and no added newline (/tmp/ink-40b3a75/src/ink.tsx:600). Fix: in SR mode, don't append the trailing newline for empty output.

G18 — No signal-based teardown (terminal corrupted on SIGINT/SIGTERM/SIGHUP)

area: render-lifecycle-reconciler · kind: behavior · severity: HIGH · priority: P1 · (sweep-2)

  • Ink: ink.tsx:426 registers signal-exit at mount so teardown() (show cursor, leave alt-screen, disable kitty, final flush) runs before the process dies on a signal.
  • vue-tui: no SIGINT/SIGTERM/SIGHUP handlers route through teardown — a signal kills the process with the cursor hidden / alt-screen / raw-mode left on, corrupting the terminal.
  • Fix sketch: add signal-exit (or SIGINT/SIGTERM/SIGHUP handlers) at mount that invoke teardown() before exit, mirroring Ink ink.tsx:426. Test via a PTY fixture or by asserting the handler is registered + runs teardown.

G19 — Dynamic yoga prop removal doesn't reset to default

area: box-layout-border · kind: behavior · severity: medium · priority: P2 · (sweep-2)

  • Ink: reconciler diff emits key: undefined on removal; styles.ts applies the yoga default (margin/padding/minWidth/flexGrow→0, flexShrink→1, flexBasis→auto, position→RELATIVE, align/justify→defaults).
  • vue-tui: host/yoga.ts:312 early-returns on value===undefined except RESETTABLE_PROPS (width/height/max*/aspectRatio/alignContent/top/right/bottom/left). All margin/padding/minWidth/minHeight/gap/flex*/justify/align/flexDirection/flexWrap/position keep their STALE value when the prop is removed across renders.
  • Fix sketch: when value===undefined for these props, apply the documented yoga default instead of early-returning (extend RESETTABLE_PROPS + a per-prop default map). Tests: remove marginTop/paddingTop/minWidth/gap/flexGrow/justifyContent/position across renders.
  • CORRECTION (G67, sweep-10): flexDirection/flexWrap are SPECIAL — Ink's applyFlexStyles has NO undefined branch for them, so at the host level a key: undefined keeps the STALE value (unlike the other flex props which reset). The G19 fix listed flexDirection→ROW/flexWrap→NO_WRAP as resets — technically the wrong host-level mechanism, BUT the net behavior still matches Ink for the COMMON case: Ink's <Box> injects flexDirection:'row'/flexWrap:'nowrap' defaults before the style spread, so an OMITTED prop yields row/nowrap (which vue-tui's reset also yields). Ink only keeps-stale for an explicit flexDirection={undefined} (rare), which Vue CANNOT distinguish from omission. So G67 ("make them keep-stale") was REFUTED — it would break the common omitted-prop case. vue-tui keeps G19's reset-to-default for flexDirection/flexWrap; the explicit-={undefined} divergence is an unavoidable Vue-vs-React semantic. See G67 row + Decisions log. FLAG for maintainer.

G20 — writeToStdout/writeToStderr lack an isUnmounted guard

area: stdout-stderr-stdin-size-cursor · kind: behavior · severity: low · priority: P2 · (sweep-2)

  • Ink: ink.tsx:673/702 — writeToStdout/writeToStderr return early if unmounted.
  • vue-tui: render.ts writeToStdout/writeToStderr have no if (teardownStarted) return; guard, so a write after teardown can clear/restore and corrupt the terminal.
  • Fix sketch: add if (teardownStarted) return; as the first line of both (teardownStarted is already a closure var). Test: call useStdout().write after unmount → no write.

G21 — Nested gets hardcoded index 0 (squash path)

area: text-wrap-transform · kind: behavior · severity: low · priority: P3 · (sweep-2; refines the earlier G06 refutation — the INLINE/nested squash path IS a gap, distinct from the per-line output path)

  • Ink: squash-text-nodes.ts:13,38 — iterates childNodes by index and calls childNode.internal_transform(nodeText, index) with the child's sibling position.
  • vue-tui: paint.ts:314 + host/text-measure.ts:26 — for (const child of node.children) with child.transform(innerText, 0) (hardcoded 0). A Transform that is the Nth child of a Text gets 0 instead of N.
  • Fix sketch: index-tracking loops in both spots; pass the child's position. (First/sole-child case already correct at 0.)

G22 — Screen-reader role dedup inherits grandparent role

area: app-exit-instances-animation-sr · kind: correctness · severity: low · priority: P3 · (sweep-2)

  • Ink: dedups a node's role only against its IMMEDIATE parent.
  • vue-tui: screen-reader.ts:90 parentRole: parentRole ?? options.parentRole passes a grandparent role down, so dedup can wrongly suppress against a grandparent.
  • Fix sketch: pass only the current node's own role (parentRole: parentRole), matching Ink. Test SR output for nested same-role-grandparent structures.

G23 — under SR-linearizes with newline join

area: app-exit-instances-animation-sr · kind: behavior · severity: low · priority: P3 · (sweep-2)

  • Ink: a Transform concatenates its children (squashTextNodes) and applies the child transform.
  • vue-tui: screen-reader.ts:96-107 transform branch joins children with "\n" (should concatenate with "").
  • Fix sketch: concatenate (join "") in the transform branch; for full fidelity apply the transform fn. Test SR output of <Box><Transform>multi-child</Transform></Box>.

G24 — vue-tui renders ALL nodes (Ink: single staticNode) — CANDIDATE

area: render-lifecycle-reconciler · kind: behavior · severity: low · priority: P3 · (sweep-2 — flagged candidate, likely intentional)

  • Ink: maintains ONE staticNode (only the most-recent <Static> is rendered to the static channel).
  • vue-tui: findStatics(root) collects and renders ALL <Static> nodes — i.e. vue-tui SUPPORTS MULTIPLE <Static>, which Ink does not. Matching Ink would REMOVE that capability.
  • Decision needed (like G07): keep vue-tui's multi-Static support (allowlist) or restrict to Ink's single-staticNode. Appended to ink-parity.md candidate section; awaiting maintainer.

G25 — truncate wrap keeps multi-line when each line fits but whole string overflows (Ink collapses via cliTruncate on whole string)

area: text-wrap-transform · kind: behavior · severity: low · (sweep-3)

  • Ink: /tmp/ink-40b3a75/src/wrap-text.ts:36-48 — for any wrapType starting with 'truncate', Ink unconditionally calls cliTruncate(text, maxWidth, {position}) on the ENTIRE string (newlines included). cli-truncate budgets against the whole joined string, so when total content exceeds maxWidth it truncates and drops trailing lines, e.g. cliTruncate('abc\nxy', 4, {position:'end'}) => 'abc…' (one line). dom.
  • vue-tui: GAP present. /Users/yunfeihe/.warp/worktrees/vue-tui/palo-verde-wildfire/packages/runtime/src/host/text-measure.ts:83-87 adds a per-line optimization: const lines = text.split('\n'); if (lines.every((l) => stringWidth(l) <= width)) return lines; — it returns the original multi-line array whenever EACH line individually fits, only falling through to cliTruncate when some single line overflows. cl
  • Fix sketch: In text-measure.ts wrapText truncate branch, drop the per-line lines.every(...) short-circuit (lines 83-84) so truncate modes always defer to cliTruncate(text, width, {position}).split('\n') on the full string, matching Ink wrap-text.ts:47. Optionally gate any short-circuit on the WHOLE-string width (stringWidth(text) <= width) rather than per-line, which would preserve correctness. Add a failin

G26 — useInput suppresses input text on kitty key-RELEASE events; Ink delivers the char on release (REVERSES the sweep-1 refutation)

area: input-keypress-kitty-paste · kind: behavior · severity: low · (sweep-3)

  • Ink: /tmp/ink-40b3a75/src/hooks/use-input.ts:205-217 — for kitty-protocol keys Ink branches only on isPrintable / ctrl, with NO eventType check: a printable key sets input = keypress.text ?? keypress.name regardless of press/repeat/release. eventType is merely surfaced on the Key object (use-input.ts:201) and parse-keypress.ts:307-311 maps value 3 -> 'release'. So with the reportEventTypes kitty fl
  • vue-tui: DIVERGENT — vue-tui adds an extra leading branch that Ink does not have.
  • Fix sketch: Remove the leading if (keypress.eventType === "release") { input = ""; } branch (useInput.ts:73-74) so release events fall through to the isPrintable / ctrl branches exactly as Ink does — yielding keypress.text ?? keypress.name for printable keys and keypress.name for Ctrl+letter on release. Add a test-first failing test in a new useInput composable test exercising a printable 'a' release an

G27 — releaseRawMode defers input detach + parser reset; Ink stops input synchronously (clearInputState)

area: stdout-stderr-stdin-size-cursor · kind: behavior · severity: low · (sweep-3)

  • Ink: /tmp/ink-40b3a75/src/components/App.tsx:350-368 — when rawModeEnabledCount drops to 0, Ink calls clearInputState() SYNCHRONOUSLY (detachReadableListener + inputParserRef.current.reset() + clearPendingInputFlush, per App.tsx:212-216), then sets pendingDisableRawModeRef=true and defers ONLY the terminal teardown (stdin.setRawMode(false)/unref) via queueMicrotask. Comment at 355-357: 'Stop owning inp
  • vue-tui: DIVERGENT
  • Fix sketch: In releaseRawMode(), when state.refs reaches 0, synchronously detach input and reset parser before deferring the terminal flip — mirror Ink's clearInputState(): call stdin.off('readable', handleReadable); stdin.off('data', handleData); clearPendingFlush(); inputParser.reset() immediately, and keep only appCtx.setRawMode(state.prevRaw) + unref() inside the queueMicrotask (guarded by state.refs stil

G28 — useStdin() leaks internal members; Ink narrows the public return to PublicProps {stdin,setRawMode,isRawModeSupported}

area: stdout-stderr-stdin-size-cursor · kind: behavior · severity: low · (sweep-3)

  • Ink: /tmp/ink-40b3a75/src/hooks/use-stdin.ts:10 — public hook is typed const useStdin = (): PublicProps => useContext(StdinContext), where PublicProps (StdinContext.ts:5-20) is ONLY {stdin, setRawMode, isRawModeSupported}. Internal members (setBracketedPasteMode, internal_exitOnCtrlC, internal_eventEmitter — StdinContext.ts:22-31) are reachable solely via a separate non-public `useStdinContext(): Pro
  • vue-tui: DIVERGENT
  • Fix sketch: Mirror Ink's split. Define a PublicStdinContext type ({stdin, setRawMode, isRawModeSupported}) and narrow the public composable: export function useStdin(): PublicStdinContext. Add a separate non-exported (or @internal) useStdinContext(): StdinContext for internal consumers (useInput, usePaste, useFocus, render). Internally the injected object can still carry the full shape; only the public co

G29 — useCursor()/setCursorPosition never applied during normal render commits (only after console writes)

area: render-lifecycle-reconciler · kind: correctness · severity: low · (sweep-3)

  • Ink: ink.tsx:494-497 — Ink's setCursorPosition immediately forwards to log-update: setCursorPosition = (position) => { this.cursorPosition = position; this.log.setCursorPosition(position); }. The render path then honors a cursor-only change even when frame text is unchanged: ink.tsx:1094 } else if (output !== this.lastOutput || this.log.isCursorDirty()) { this.throttledLog(outputToRender); }, and l
  • vue-tui: GAP — present in current src
  • Fix sketch: In render.ts, make appContext.setCursorPosition forward to the frame writer (mirroring ink.tsx:496): setCursorPosition(pos) { cursorPosition = pos; appContext.cursorPosition = pos; writer.setCursorPosition(pos); } (note: writer is declared after appContext today, so either move the writer creation earlier or capture it via a mutable ref / call through mountedWriter).

Then make renderInteracti

G30 — SR: nested inside a box-level drops the INNER transform fn (refines G23)

area: app-exit-instances-animation-sr · kind: correctness · severity: low · (sweep-3)

  • Ink: /tmp/ink-40b3a75/src/render-node-to-output.ts:49-50 routes a box-level (an ink-text node) through output = squashTextNodes(node). squash-text-nodes.ts:25-39 iterates children: an inner child is also ink-text, so nodeText = squashTextNodes(child) then, because childNode.internal_transform is a function, nodeText = childNode.internal_transform(nodeText, index) (li
  • vue-tui: GAP — produces "b" (inner transform fn dropped).
  • Fix sketch: In screen-reader.ts, the transform node branch (lines 105-129) should apply the node's OWN transform fn ONLY when the transform node is reached as a CHILD via recursion (mirroring squashTextNodes' child-transform application), not when it is the top-level box-level node. Cleanest approach: align with Ink by handling transform nodes the way squashTextContent already does for the text path — when

G31 — useAnimation interval option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity)

area: app-exit-instances-animation-sr · kind: behavior · severity: low · (sweep-3)

  • Ink: /tmp/ink-40b3a75/src/hooks/use-animation.ts:68 reads interval from props every render; line 79 safeInterval !== previousOptions.safeInterval makes shouldReset true on an interval change (line 138 returns zeros synchronously), and the layout effect lists safeInterval in its dep array (line 132) so it unsubscribes and re-subscribes with the new interval, restarting timing.
  • vue-tui: PARTIAL — interval is captured once and never reacts.
  • Fix sketch: Type interval as MaybeRefOrGetter<number> (mirroring isActive). Move normalizeInterval inside a watcher: watch(() => normalizeInterval(toValue(options.interval ?? defaultInterval)), (newInterval) => { interval = newInterval; if (handle) start(); /* re-subscribe at new cadence, which resets values per Ink */ }). The interval closure variable used by tick()/start() must be reassignable (