* fix(runtime): restore terminal on signal exit via signal-exit (Ink parity, G18)
Previously nothing routed a process signal to teardown(): SIGINT-as-signal,
SIGTERM or SIGHUP killed the process with the cursor hidden, the alternate
screen active and raw mode on, leaving the terminal corrupted.
Mirror Ink (ink.tsx:426): register signal-exit's onExit(teardown,
{alwaysLast:false}) at interactive mount, storing the unsubscribe fn, and
call it first thing in teardown() (ink.tsx:765) so the handler is removed on
unmount()/exit() and can't leak or double-run. teardown() stays idempotent
(teardownStarted guard) so a signal-triggered teardown plus a later unmount
won't double-run, and we don't prevent the process from exiting. Only the
live interactive, non-debug mount registers — render-to-string /
non-interactive paths never touch process signal handlers; registration is
guarded against double-registration.
Uses signal-exit v4 (named onExit export; ships ESM + types, so no
@types/signal-exit needed). PTY test sends SIGINT/SIGTERM/SIGHUP to a mounted
alt-screen app and asserts the captured output ends with show-cursor
(\x1b[?25h) + leave-alt-screen (\x1b[?1049l).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review follow-ups (3 fixes): register signal-exit whenever interactive
(drop the !debug gate so debug-but-interactive apps, which still enter the
alt-screen/hide the cursor, restore on signal — Ink ink.tsx:426); add
!teardownStarted to the registration so a spent app instance does not
re-register on a same-instance remount (the next unmount() returns early at
the teardownStarted guard before it could unsubscribe — a leak); and make
the PTY test prove the SIGNAL drove teardown (fixture never self-unmounts, so
restore bytes can only come from the signal path) with a debug-mode signal
test, an exit-anchored waitForOutput drain, and a bounded retry for the
async-flush race under saturated runners.
Review follow-ups (2 fixes): synchronous restore flush on signal — the
signal-exit teardown path now writes the restore escapes (show-cursor,
leave-alt-screen, disable-kitty) via fs.writeSync to the stdout fd so they
reach the terminal before signal-exit re-raises the signal (a buffered async
stream.write could be lost on abrupt exit); the normal unmount path keeps async
writes. Removed the config-wide retry:3 from vitest.pty.config.ts (it masked
the whole PTY suite) and scoped a retry:2 to the signal-teardown describe only,
for the residual parent-side node-pty onData read-race under a saturated runner.
* chore(parity): ledger — G18 pr-open
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Re-audit of main (all 16 sweep-1 fixes merged) vs Ink 40b3a75 found 8 confirmed
divergences the first sweep missed:
- G18 (HIGH): no signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP.
- G19 (med): dynamic yoga prop removal doesn't reset to default (stale layout).
- G20: writeToStdout/stderr lack an isUnmounted guard.
- G21: nested <Transform> in <Text> gets hardcoded index 0 (squash path; refines the
earlier G06 refutation — the inline/nested case IS a gap).
- G22: SR role dedup inherits grandparent role.
- G23: <Transform> under <Box> SR-joins children with newline (should concatenate).
- G24 (CANDIDATE): vue-tui supports multiple <Static>; Ink has one staticNode — awaiting
maintainer decision (appended to ink-parity.md candidates, like G07).
Also reconciles G17 -> merged (#45).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): linearize screen-reader static output + drop empty-SR-frame newline (Ink parity, G17)
(a) The live static channel flushed <Static> via the 2D grid painter
(paintIsolated) even in screen-reader mode, so bordered static items leaked
box glyphs. paintStaticNode now takes an isScreenReaderEnabled flag and
linearizes fresh static children via renderScreenReaderOutput
(skipStaticElements:false) instead — matching Ink's renderer.ts:24, which
renders node.staticNode through renderNodeToScreenReaderOutput. Non-SR static
is unchanged. render.ts commit() and render-to-string.ts thread the flag.
(b) Interactive SR frames went through renderInteractiveFrame, which appends
"\n" even for empty output, leaking a spurious blank line. Ink's SR path writes
the wrapped output directly with lastOutputToRender = wrappedOutput (no appended
newline), so an empty SR frame emits zero lines. We now suppress the trailing
newline for EMPTY SR output only — matching ink.tsx:573-626 — leaving non-SR
and non-empty SR frames untouched.
Follow-up fixes (two review findings):
- renderToString in SR mode no longer DROPS <Static> output: the SR return
branch now prepends the captured/linearized static output like the non-SR
path (Ink's SR renderer returns staticOutput when node.staticNode exists,
renderer.ts:24-33).
- The SR static linearization now honors the <Static>'s resolved flexDirection
for separator + child order (read from yoga via getFlexDirection), matching
screen-reader.ts:73-82 (row/row-reverse → space, *-reverse reverses order);
the default column case is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G17 pr-open, reconcile G16 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): let per-edge borderDimColor=false override general dim (Ink parity, G16)
edgeDim now uses `?? generalDim` (nullish) instead of `|| dimAll`, so an explicit per-edge false wins — matching Ink render-border.ts:54. The five borderDimColor prop declarations in Box.ts are changed from bare `Boolean` to `{ type: Boolean, default: undefined }` so Vue does not boolean-coerce absent per-edge dim props to false (Vue only applies absent→false casting when no explicit default is provided), preserving the undefined sentinel needed for the nullish fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G16 pr-open, reconcile G14 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): guard against two renderers on the same stdout (Ink parity, G14)
Make skipped-mount unmount() a pure no-op: add `skippedMount` flag set in
the instance-reuse guard branch; teardown() and resolveExit() return early
when set, so no write-barrier or stdout touch reaches the owner's stream.
Strengthen test: assert app2.unmount() writes nothing to process.stdout
(catches the pre-fix empty write-barrier) and that app1 still owns the
WeakMap entry after app2.unmount() (third mount on same stdout still warns).
* chore(parity): ledger — G14 pr-open, reconcile G13 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): clear+reset on terminal-width decrease during resize (Ink parity, G11)
Track lastTerminalWidth (initialized at mount via resolveSize); onResize clears
writer + resets frameState.lastOutput/lastOutputToRender when the new width is
narrower than the previous width, mirroring the logic in ink.tsx:459-474 that
prevents duplicate overlapping re-renders on terminal narrow. Width increases and
pure height changes are unaffected.
Preserve outputHeight on narrowing (matches Ink ink.tsx:462-466 which leaves
lastOutputHeight intact): zeroing it suppressed the clearTerminal path in
shouldClearTerminalForFrame for overflowing frames (hadPreviousFrame=false).
Adds overflow-path test that was RED with outputHeight=0 and GREEN with the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G11 pr-open, reconcile G10 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): throw a descriptive error when raw mode is unsupported (Ink parity, G10)
Previously the raw-mode acquire path silently no-opped on a stdin where raw mode
is unsupported (non-TTY / isRawModeSupported false), so using useInput on such a
stdin did nothing with no diagnostic. Ink's handleSetRawMode (App.tsx:315-327)
instead throws immediately when enabling raw mode is unsupported, with two
distinct messages (default process.stdin vs a custom stdin) both pointing at the
isRawModeSupported docs.
StdinController.acquireRawMode now throws that two-message error on
!isRawModeSupported. The unguarded useInput path surfaces it (matching Ink's
use-input.ts, which calls setRawMode(true) ungated). useFocus now guards on
isRawModeSupported before acquiring (matching Ink's use-focus.ts), so focus
degrades to a safe no-op on a non-TTY rather than throwing. releaseRawMode keeps
its no-op guard — Ink only throws when enabling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G10 pr-open, reconcile G09 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(runtime): assert full raw-mode error message for exact Ink parity (G10, codex)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): wrap external stdout/stderr writes in synchronized-update markers (Ink parity, G09)
writeToStdout/writeToStderr now emit bsu/esu around clear+write+restore when
shouldSynchronize, matching the render path and Ink ink.tsx:687-728. The sync
variable was already computed at mount time (render.ts:489); the external-write
functions simply lacked the wrapping. For writeToStderr, BSU/ESU go to stdout
(not stderr) because synchronized-update mode is a stdout capability — exactly
mirroring Ink's ink.tsx:717-728 behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G09 pr-open
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): draw box borders per-edge without the min-size guard (Ink parity, G05+G15)
Removed blanket `w<2||h<2` return from drawBorder; replaced it with a
`w<1||h<1` degenerate guard. Vertical sides now start at
`offsetY = top ? 1 : 0` and run for `Math.max(0, h - topRows - bottomRows)`,
matching Ink render-border.ts:133. Fixes: (G05) a 1-cell-tall box with only
side rails rendered nothing; (G15) with borderTop=false the left/right rails
were shifted one row down. Updated 4 existing snapshots that encoded the
old buggy behavior and added 3 new tests that verified red before green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G05+G15 pr-open, reconcile G03 merged, log snapshot decision
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): render linear screen-reader output in the live commit path (Ink parity, G03)
commit() previously called paint(tuiRoot) (the 2D grid painter) unconditionally
for both the non-interactive and interactive branches, so an app mounted with
isScreenReaderEnabled emitted the visual frame (box-drawing borders, padded
grid) into the live stream instead of flat linearized text. isScreenReaderEnabled
was only consulted to disable commit throttling.
Add a renderFrame(width) helper that branches on isScreenReaderEnabled: when SR
is enabled it linearizes the tree via renderScreenReaderOutput(tuiRoot,
{ skipStaticElements: true }) and wraps it with wrapAnsi(out, width,
{ trim: false, hard: true }), mirroring Ink's onRender SR branch
(ink.tsx:598-603). Both commit branches now call renderFrame() instead of
paint() directly. The non-SR path is byte-for-byte unchanged (renderFrame
returns paint(tuiRoot)). Static output continues to flush through the existing
paintStaticNode path; full SR-static linearization parity is deferred.
render-to-string.ts already used renderScreenReaderOutput and is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G03 pr-open, reconcile G02 merged, track G17 (SR edges)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): coalesce useAnimation ticks within the render-throttle window (Ink parity, G02)
useAnimation now coalesces scheduler ticks that fall inside the current
render-throttle window and reports delta as the time since the last
RENDERED tick, so velocity-driven motion (position += speed * delta)
advances at correct wall-clock speed even when the commit throttle is
coarser than the animation interval. Previously delta was ~one scheduler
interval per committed tick, under-integrating velocity at render time.
- animation-scheduler: createAnimationScheduler(renderThrottleMs = 0)
exposes renderThrottleMs on the AnimationScheduler (no-op variant = 0).
- render.ts: derive animationRenderThrottleMs from maxFps using Ink's
Math.max(1, ceil(1000/maxFps)); 0 on debug/screen-reader/unthrottled
paths, mirroring the commit-throttle gate.
- useAnimation: tick() skips while now < nextRenderTime; on an allowed
tick delta = now - lastRenderedTime, then nextRenderTime = now + window.
Also default maxFps to 30 (Ink parity: options.maxFps ?? 30) and derive a
single renderThrottleMs that drives BOTH the commit scheduler and the
animation scheduler, so the coalescing engages on the default non-debug
path (previously it only engaged when maxFps was passed explicitly).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G02 pr-open, reconcile G01 merged, log G02 decisions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): unmount written <Static> items to match Ink (G01)
Ink's <Static> renders `items.slice(index)` and advances `index` to
`items.length` in a post-commit `useLayoutEffect`, so once an item has been
painted it is removed from the tree and its component unmounts. vue-tui kept
every Static item mounted forever: the component always mapped the full
`props.items`, and write-once was enforced only at flush time via a positional
`writtenCount` slice — the item components never tore down.
Now the <Static> component owns a `cursor` (Ink's `index`) and renders only
`items.slice(cursor)`. The renderer advances the cursor AFTER a commit has
painted the fresh items, via an `onWritten` callback registered on the host
static node — the vue-tui analogue of Ink's post-commit layout effect. This
ordering guarantees items are written before they are sliced out and unmounted,
so no item is ever lost or re-painted.
Write-once bookkeeping moved from a positional `writtenCount` to a
`writtenNodes` Set keyed by host-node identity. A single logical item expands to
several host nodes (the <Text>/<Box> plus empty text-leaf fragment anchors Vue
inserts), so a positional count mis-sliced once the cursor advanced; identity
tracking is anchor-agnostic. The shared `paintStaticNode` helper paints children
not yet in the set, records them, prunes unmounted entries, then fires
`onWritten`; render.ts, render-to-string.ts and flushStatic all use it.
Make the cursor mirror Ink fully so it can DECREASE, not just increase.
`onWritten` now SETS the cursor to items.length (was max-with-current), and a
length watch lowers it on shrink — needed because a shrink that leaves the
already-sliced children empty produces no host mutation, hence no commit/
onWritten to re-sync. Without this, [A,B] (cursor→2) → [A] → [A,C] sliced(2)=[]
and silently dropped C. paintStaticNode now always prunes and calls onWritten
(even on empty commits), painting only when there are fresh children.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G01 pr-open, reconcile G12 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Replace three `stdout.columns ?? 80` / `stdout.rows ?? 24` spots in render.ts with
`resolveSize(stdout).columns/rows`. The `??` guard only falls back on null/undefined,
not on 0 — so non-TTY environments where stdout reports 0 columns would collapse Yoga
layout to width 0. Ink's `getWindowSize` (utils.ts:8-23) uses a truthy guard
(`if (columns && rows)`) and a fallback chain through terminal-size → 80/24 defaults.
`resolveSize()` in useTerminalSize.ts already implements this chain; now exported and
used by the renderer. The non-TTY viewportRows → 24 branch is preserved (Ink-aligned).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
G06 re-verification: the audit claimed <Transform>'s fn gets a hardcoded
index 0 "instead of the childNode index". Ink's index (output.ts:230-239)
is the LINE index, applied per output line — not a child index; the audit
misread it. vue-tui already produces correct per-line line indices for
multi-line transforms (existing tests "transform with multiple lines" and
transform-yoga pass unmodified). paint.ts:314's transform(innerText, 0) is
only the inline <Transform>-in-<Text> path (single logical line, 0 matches
Ink). No observable gap — marked refuted, not fixed.
Also reconciles G08 -> merged (landed in #31).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink derives the focus id via useMemo(() => customId ?? random, [customId])
and keys its add/remove effect on [id], so changing the id prop re-registers
the component under the new id. vue-tui captured `const id = options.id ?? …`
once at setup (and typed id as a plain string), so it never reacted.
- Widen id to MaybeRefOrGetter<string>.
- Track the current registration and re-register (unsubscribe/remove old,
subscribe/add new, re-apply active state) in a watcher keyed on the resolved
id, mirroring Ink's [id] effect. isActive handling unchanged.
Test (test-first, verified red before the fix): focus is driven purely by
focus(id) (no Tab, which would focus by position and mask the bug); changing
the id re-registers under the new value and the old id goes dead.
Also reconciles G04 -> merged (landed in #30).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's render-border.ts computes each border edge's background from
border<Edge>BackgroundColor ?? borderBackgroundColor only — it never falls
back to the Box's own backgroundColor. vue-tui's colorizeEdge had an extra
`?? bgColor` fallback, so a Box with backgroundColor but no explicit border
background painted its background onto the border glyphs too.
Drop the fallback. Background still fills the inner content area; border
glyphs are now uncolored unless an explicit border background is set.
Tests rewritten to match Ink (per maintainer's align-to-Ink policy; see
.agents/docs/parity-ledger.md Decisions log):
- add failing-first repro "Box backgroundColor does not bleed onto border
glyphs (Ink parity)"
- "wrapped text preserves backgroundColor on every content line": assert
inner rows carry bg, border rows don't (height 4->5 so text fits)
- "Box background with border fills content area": snapshot updated so
border rows have no bg
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink-parity audit against v7.0.4 (commit 40b3a75): 10 areas reviewed in
parallel, candidates adversarially verified by 26 agents total.
16 candidates verified → 14 confirmed gaps (3 medium, 11 low), 2 refuted:
- exit() second-wins: vue-tui is already guarded (not last-wins).
- kitty key-release printable-text suppression: Ink behaves the same.
Each gap recorded in the ledger with Ink/vue-tui evidence and a fix sketch;
fixes ship as individual PRs per the loop in ink-parity-loop.md.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>