* 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>
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>
The resize handler painted synchronously via commit() but left the commit
scheduler's pending trailing throttle timer armed. If an update was sitting
in that timer, it fired a second doCommit() right after the resize paint —
and because shouldClearTerminalForFrame clears whenever the previous frame
overflowed the viewport, the second commit emitted a duplicate clearTerminal.
Cancel the pending trailing commit before the synchronous paint; the paint
already reflects the current tree, so the pending commit is redundant.
Regression test (test-first) in throttle.sequential.test.tsx reproduces the
double-clear (2 clears) and verifies the fix (1 clear). Closes#26 (item 1).
The ci graph's test:integration task runs `vp run -r test:integration`, but
that script only existed in runtime-tests, so `vp run -r` silently skipped
packages/runtime (11 files, 309 tests) and packages/testing (2 files, 7 tests)
— their unit tests never ran in CI.
Give both packages a test:integration script (= vp test) and make their `test`
compose it, mirroring runtime-tests. `vp run -r test:integration` now covers
all three packages. PTY stays its own branch (no double-run).
Verified: `CI=true vp run ci` on a fresh checkout runs runtime (309) + testing
(7) + runtime-tests (756), exit 0.
Reported-by: @reviewer on PR #25
vp run check:lint reported 7 warnings, all false positives for a terminal UI
library:
- no-control-regex (×6): parsing keyboard escape sequences and stripping ANSI
inherently requires regexes matching control chars (ESC, BEL). Disable the
rule library-wide in vite.config.ts — it flags every such pattern and we
already use \x1b/\u escapes (its own suggested form). Also drop the now-
redundant (and misplaced — it sat one line above the regex) inline disable in
sanitize-ansi.test.ts.
- no-misused-spread (×1) at parse-keypress.ts:500: `[...s]` on a terminal
escape sequence (ASCII control chars + digits, never user text), so the
emoji-splitting concern doesn't apply. Suppressed in place with a comment
explaining why, keeping the rule active elsewhere.
check:lint now reports 0 warnings, 0 errors.
The resize handler routed through scheduler.schedule(), deferring the repaint
through the ~32ms commit throttle. Ink's resized() calls onRender() directly,
and a resize is a discrete viewport change that should repaint immediately —
deferring it can leave stale/overlapping content on screen for a frame.
It also made the clearTerminal-on-overflow behavior depend on wall-clock
timing: the #450 "shrink into overflow" test passed only because the throttled
resize emitted ZERO clears (its trailing timer never fired within the test's
nextTicks) and the single clear came entirely from unmount. The test asserted
the right number for the wrong reason, and the dependency on real elapsed time
made it flaky under CPU contention.
Change the resize handler to commit() directly. Now the resize itself emits the
overflow clear deterministically. Update the test to assert the clear happens
ON the resize (clearsAfterResize - clearsBeforeResize === 1) after a single
nextTick — no longer dependent on throttle timing.
Each package gets check:type = `tsc --noEmit` (under its own tsconfig); root
check:type = `vp run -r check:type` fans out across the workspace. Unlike vp's
tsgolint-based type-aware path, this is the standard TypeScript compiler, so it
honors each tsconfig's real project semantics.
Additive only here — vp lint still carries typeCheck; the switch-over and ready
rewiring land in the next commit. Note: testing and runtime-tests resolve
@vue-tui/runtime types from its built dist, so check:type requires a prior
build.
The committed file had unformatted line wrapping that vp fmt --check flags,
which would fail CI the moment check:fmt runs. Reflow to the formatter's
output; no behavior change.
Replace the opaque `vp check` bundling and the ad-hoc `ready` chain (which
shelled out via `cd ... && pnpm ...`) with explicit, composable named scripts.
Root delegates; category splits live in the package that owns them:
root: check:fmt / check:lint / check:fixtures / test / build / ready
runtime-tests: test = test:integration + test:pty; check:fixtures
`ready` now composes the named scripts in dependency order (fmt, lint, build,
fixtures, test) — build runs before check:fixtures and test:pty, which need
the built dist. Drop the broken root `dev` (referenced a non-existent website
package) and the redundant per-package `check` scripts (fmt/lint already run
workspace-wide from root). Renames: pty-test -> test:pty, typecheck:fixtures
-> check:fixtures.
The root vite.config.ts lint/fmt settings cascade into every workspace
package, and a package-level lint block merges with — rather than replaces —
the root options. So the `lint.options { typeAware, typeCheck }` and the empty
`fmt: {}` repeated in runtime, testing, and runtime-tests only restated what
each package already inherits.
Remove them. runtime-tests keeps just its `ignorePatterns` (the one genuine
per-package override); typeAware/typeCheck now come from root via the merge.
Verified with `vp check`: no lint or typecheck regression, and the PTY
fixtures stay excluded from lint.
Text/Box/Static/Transform read children via slots but never surfaced
`children` on their JSX `$props`. Under the automatic JSX runtime
(jsx: react-jsx + jsxImportSource: vue), children are passed as a
`children` prop, so `<Text>x</Text>` failed to type-check. Add a
type-only WithChildren cast that declares optional `children` on $props;
Vue routes that prop to the default slot at runtime, so there is no
runtime change.
Addresses PR #23 review: move the mid-file text-measure import to the
top-level import block, and declare engines.node>=22 to match the
upgraded text stack (cli-truncate@6, slice-ansi@9 require node>=22).
Bump slice-ansi@9, string-width@8, wrap-ansi@10 and add cli-truncate@6
(both the runtime dep and the pnpm catalog entry for string-width). Rewrite
wrapText truncate variants to delegate to cli-truncate, matching Ink's
wrap-text.ts: grapheme clusters (ZWJ emoji, combining marks) stay whole and
newlines are preserved. Adjust the horizontal-clip left-edge compensation in
paint.ts because slice-ansi@9 drops a straddling wide grapheme whole rather
than splitting it, so lineX must advance by the actually-dropped width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an early-return guard in paintNode so nodes with DISPLAY_NONE
(already set on their Yoga node) are entirely skipped during paint,
matching Ink's renderNodeToOutput behavior. Without the guard, hidden
text/borders leaked onto visible siblings at x=0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codex review found waitUntilRenderFlush gated the scheduler flush on
hasPending() alone, missing the window after schedule() queues a commit
but before the post-flush callback sets hasPendingFlag — letting the
promise resolve before the pending render flushed. Now delegates to
flush() unconditionally; flush() already short-circuits when nothing is
scheduled or pending, so this only adds the missing wait.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Correctness fixes found by max-effort review of the branch diff:
- frame-writer.sync() now updates lastFrame alongside log-update's
previousOutput. Previously the two dedup layers desynced after a sync()
(the clearTerminal path), silently dropping a legitimately-changed frame
and emitting an empty BSU/ESU pair. Adds a regression test.
- scheduler: the queuePostFlushCb callback now bails if scheduled was reset
by cancel(), so a stale callback can't commit on a torn-down tree or
re-arm an uncancellable trailing timer.
- scheduler.flush() now collects multiple concurrent waiters instead of
overwriting a single resolver — fixes a hang when two waitUntilRenderFlush()
calls await the same pending commit.
- render teardown nulls mountedClear so a post-unmount app.clear() can't
write to a torn-down stream.
- test-streams getContentWrites imports bsu/esu instead of hardcoding the
escape literals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scheduler.cancel() now resolves a pending flush() waiter, preventing
waitUntilRenderFlush() from hanging when the app unmounts mid-flush
- BSU/ESU now wrap the actual stream writes instead of being embedded in
the frame string, so synchronization survives log-update's incremental
line diffing (matches Ink ink.tsx:1059-1097). Normal branch guarded by
willRender() to avoid empty synchronized-update pairs on unchanged frames
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Horizontal clipping now runs per-line AFTER transforms instead of before,
preventing Transform-widened text from escaping clip boundaries. Left-edge
clipping also uses the actual removed width to position subsequent text
correctly when a wide char straddles the boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Absolute-positioned wide characters (CJK, emoji) could paint past the
right edge of a clipped box or the terminal grid, producing output wider
than the column limit. Three fixes:
- Safe-slice after sliceAnsi in clip logic to handle wide char overshoot
- Bounds check in grid write loop to skip chars exceeding grid width
- Width-aware border fill to account for measured corner char widths
* test: add text ANSI sanitization parity tests from Ink (+15)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add use-animation parity tests from Ink (+43)
Port 43 new tests from Ink's use-animation test suite covering:
- Multiple animations in sync, different rates, sibling unmount
- Timer cleanup/recreation on unmount and remount
- Inactive animations, timer leak prevention
- Edge intervals (NaN, Infinity, -Infinity, oversized, zero, negative)
- isActive toggle resets, pause/resume cycles
- Frame catch-up, time/delta tracking, reset() behavior
- Newly mounted/activated animations don't inherit elapsed time
- Wall clock monotonicity, getter function isActive support
Uses selective fake timers (setInterval + performance only) so that
render()'s internal setImmediate still works on real clocks. Fake timer
tests read refs directly to avoid Vue scheduler flush timing issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add use-box-metrics/measure parity tests from Ink (+16)
Port 16 missing tests from Ink's use-box-metrics, measure-element, and
measure-text test suites. Fix useBoxMetrics to reset metrics to zeros
when the tracked ref detaches (element unmounts or ref switches to null).
3 tests are skipped because vue-tui's useBoxMetrics uses watchPostEffect
(re-runs only when ref.value changes) rather than Ink's layout-commit
listener pattern, so sibling-content and resize-driven re-measurement
is not yet supported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add screen-reader parity tests from Ink (+11)
Add 11 screen-reader integration tests covering aria-label substitution on
Text/Box, ANSI styling omission, multiple/nested components, null component,
aria-state variants (busy, disabled, expanded), multi-line roles, and
multiselectable listbox.
Also fix component prop bug: Vue normalizes kebab-case prop names to camelCase
at runtime, so props["aria-label"] was always undefined. Switch Box/Text prop
declarations and access to camelCase (ariaLabel, ariaHidden, ariaRole, ariaState).
Add isScreenReaderEnabled option to renderToString() so tests can exercise
screen-reader output through the component pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add render-to-string parity tests from Ink (+18)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add cursor composable parity tests from Ink (+7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add 6 missing screen-reader Ink parity tests
Add tests for aria-hidden, select input (list with roles/states/labels),
aria-state.multiline, aria-state.readonly, aria-state.required, and
nested multi-line text rendering in screen-reader mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix render-to-string missing Ink parity tests (+10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix cursor composable missing Ink parity tests (+6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix use-box-metrics missing Ink parity tests (+4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add layout listener so useBoxMetrics updates on resize and sibling changes
Adds a layout listener mechanism to TuiRoot matching Ink's architecture:
- TuiRoot.layoutListeners Set with addLayoutListener/emitLayoutListeners
- emitLayoutListeners called after every yoga.calculateLayout in commit()
- useBoxMetrics subscribes to layout listeners, diffs values before updating
Enables 4 previously-skipped tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sanitize-ansi.test.ts: convert raw ESC/C1 control chars to \u escapes
- frame-writer.test.ts: import cursor escapes from cursor-helpers instead
of duplicating, add 7 createFrameWriter integration tests
slice-ansi can't split within a ZWJ grapheme cluster, so the retry
loop may never find a result that fits. Return empty string instead
of the oversized result.
Port 9 text-width tests from Ink's test suite covering wide characters
in fixed-width Boxes, CJK width calculation, mixed ASCII+wide chars,
ANSI styled text layout, empty Text siblings, and CJK truncation
(end/middle/start/box-width). Also fix CJK truncation bug where
slice-ansi would overshoot on wide character boundaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port all 35 test declarations from Ink's log-update test suite,
producing 41 new runtime tests (6 cursor tests run in both standard
and incremental modes via describe.each). Covers standard rendering,
incremental rendering (surgical updates, shrink, grow), clear/done
reset, sync+update, cursor positioning, no-trailing-newline fullscreen
mode, and render-to-empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port all 57 tests from Ink's input-parser test suite (43 named + 14
backspace/delete splitting via test.each). Replaces the previous 12
skeleton tests with full Ink parity covering: CSI parameters, SS3
sequences, meta+CSI/SS3 double escape, escaped printable/supplementary
codepoints, legacy ESC[[ sequences, incomplete holding states, flush
pending, empty chunks, text + incomplete escape, 14 backspace/delete
splitting cases, and 10 bracketed paste tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port all 26 tests from Ink's ansi-tokenizer test suite, covering CSI,
OSC, DCS, SOS, ESC, C1 control tokens and invalid/incomplete sequences.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port 24 missing tests from Ink's sanitize-ansi test suite covering:
- C1 OSC hyperlinks with various terminators (C1 ST, ESC ST, BEL)
- C1 CSI sequences (both SGR and non-SGR)
- DCS passthrough (tmux wrappers, incomplete, BEL in payload)
- SOS control strings (ESC and C1 variants, various terminators)
- Standalone C1 control characters and ST bytes
- ESC ST sequences, malformed ESC sequences, incomplete CSI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Release events now produce empty input to prevent character duplication
when reportEventTypes flag is enabled. key.eventType is still passed
through so handlers can detect release events.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>