Commit Graph

20 Commits

Author SHA1 Message Date
Yunfei He 216a7021a0 fix(runtime): restore terminal on signal exit via signal-exit (Ink parity, G18, HIGH) (#47)
* 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>
2026-05-30 06:04:51 +08:00
Yunfei He 5cc94ed2ff chore(parity): record sweep-2 — 6 new gaps (G18-G23) + 1 candidate (G24) (#46)
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>
2026-05-30 04:56:41 +08:00
Yunfei He 97c61363e5 fix(runtime): linearize screen-reader static + drop empty-SR-frame newline (Ink parity, G17) (#45)
* 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>
2026-05-30 04:43:25 +08:00
Yunfei He 067826c756 fix(runtime): let per-edge borderDimColor=false override general dim (Ink parity, G16) (#44)
* 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>
2026-05-30 04:19:57 +08:00
Yunfei He 2e9c938f5f fix(runtime): guard against two renderers on the same stdout (Ink parity, G14) (#43)
* 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>
2026-05-30 03:59:56 +08:00
Yunfei He 95b287afa1 feat(runtime): support custom BoxStyle border objects (Ink parity, G13) (#42)
* feat(runtime): support custom BoxStyle border objects (Ink parity, G13)

Widen borderStyle prop to BorderStyle | BoxStyle; drawBorder resolves
typeof style === 'string' ? cliBoxes[style] : style, matching Ink
render-border.ts:31-34. Export BoxStyle type from the package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(parity): ledger — G13 pr-open, reconcile G11 merged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:35:13 +08:00
Yunfei He f49a995d03 fix(runtime): clear+reset on terminal-width decrease during resize (Ink parity, G11) (#41)
* 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>
2026-05-30 03:24:35 +08:00
Yunfei He 33055e429c fix(runtime): throw a descriptive error when raw mode is unsupported (Ink parity, G10) (#40)
* 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>
2026-05-30 03:07:32 +08:00
Yunfei He 61e4e09a1e fix(runtime): wrap external stdout/stderr writes in synchronized-update markers (Ink parity, G09) (#39)
* 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>
2026-05-30 02:12:27 +08:00
Yunfei He 2d47bad9ab chore(parity): flag G07 (kitty Ctrl+C exit) as candidate divergence; reconcile G05/G15 (#38)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 01:55:42 +08:00
Yunfei He 63a1fb2046 fix(runtime): draw box borders per-edge without the min-size guard (Ink parity, G05+G15) (#37)
* 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>
2026-05-30 01:51:49 +08:00
Yunfei He 34aeb8ec2e fix(runtime): render linear screen-reader output in the live commit path (Ink parity, G03) (#36)
* 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>
2026-05-30 01:40:59 +08:00
Yunfei He 3f92240ca9 fix(runtime): coalesce useAnimation ticks within the render-throttle window (Ink parity, G02) (#35)
* 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>
2026-05-30 01:29:03 +08:00
Yunfei He eaf333a5ea fix(runtime): unmount written <Static> items to match Ink (G01) (#34)
* 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>
2026-05-30 01:01:08 +08:00
Yunfei He 144db33d0b fix(runtime): use terminal-size fallback when stdout reports 0 cols/rows (Ink parity, G12) (#33)
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>
2026-05-30 00:24:02 +08:00
Yunfei He 78ec54977e chore(parity): refute G06 (false positive) + reconcile G08 merged (#32)
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>
2026-05-29 23:46:45 +08:00
Yunfei He f9f72f7f68 fix(runtime): make useFocus react to id prop changes (Ink parity, G08) (#31)
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>
2026-05-29 23:39:46 +08:00
Yunfei He 0c113a3063 fix(runtime): don't paint Box backgroundColor onto border glyphs (Ink parity, G04) (#30)
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>
2026-05-29 23:14:25 +08:00
Yunfei He 1a38474cbe chore(parity): record audit sweep #1 — 14 confirmed gaps (#29)
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>
2026-05-29 22:51:17 +08:00
Yunfei He 7af3ff3e00 docs(agents): add .agents/docs context-engineering home + Ink-parity loop (#28)
Establishes the committed .agents/docs/ convention (distinct from the
uncommitted docs/ working-notes folder) and seeds the Ink-parity
verification loop:

- ink-parity-loop.md: design spec + reusable /loop prompt (audit →
  test-first fix → codex review → PR → CI → auto-merge, hard codex gate).
- ink-parity.md: pinned Ink reference (v7.0.4, commit 40b3a75) + the
  intentional-divergence allowlist the audit skips.
- parity-ledger.md: working ledger of audit sweeps and confirmed gaps.
- AGENTS.md: "Context Engineering" section documenting the convention.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:35:14 +08:00