Commit Graph

253 Commits

Author SHA1 Message Date
Yunfei He de706817d0 chore(release): @vue-tui/runtime + @vue-tui/vite + @vue-tui/components 0.1.1 (#230)
- @vue-tui/runtime 0.1.0 -> 0.1.1 (ships #215's runtime changes).
- @vue-tui/vite 0.0.0 -> 0.1.1 — first publish (the #215 plugin replacing @vue-tui/cli).
- @vue-tui/components 0.0.0 -> 0.1.1 — first publish (#229; Spinner).

First-publish readiness (runtime already had everything):
- @vue-tui/vite: add LICENSE + README, list LICENSE in files.
- @vue-tui/components: was `private: true` (wouldn't publish at all) — remove it and
  add publishConfig.access=public; add prepublishOnly (was MISSING — publish would
  not have auto-built dist); add LICENSE + README; change the @vue-tui/runtime peer
  from workspace:* (would publish as an exact `0.1.1` peer) to workspace:^ (^0.1.1).

Publish flow verified for all three via `pnpm publish --dry-run` + `pnpm pack`:
prepublishOnly auto-builds dist; the tarball ships LICENSE + README + dist
(components also ships types: dist/index.d.mts); catalog:/workspace: are fully
resolved (no literal strings; vite & components peer @vue-tui/runtime -> ^0.1.1).

Publish with `pnpm publish` (NOT npm — npm ships literal catalog:). Order: runtime
first, then vite + components (their peer points at ^0.1.1).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:06:04 +08:00
Yunfei He 62543d9452 feat(vite): replace @vue-tui/cli with an in-process @vue-tui/vite plugin (#215)
* docs(runtime): tighten the README status banner

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(runtime): add connectDevtools/isDevConnected dev API (internal)

* refactor(runtime): gate dev overlay on isDevConnected(); drop __VUE_TUI_DEV__ define

The build-define approach required a bundler transform and couldn't be
tested without a real build. Replace the two __VUE_TUI_DEV__ gates in
render.ts with isDevConnected() (set by connectDevtools() at runtime)
so the overlay can be exercised in unit tests without a define injection.

Also removes the dead __VUE_TUI_DEV__: "true" define from the @vue-tui/cli
vite plugin and deletes the ambient env.d.ts declaration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(vite): scaffold @vue-tui/vite + forceClientCompile

Creates the new @vue-tui/vite package with forceClientCompile helper that
forces @vitejs/plugin-vue to emit client render functions (with HMR) even
when running in Vite's SSR runnable environment, by intercepting transform/load
hooks and flipping the ssr option to false.

* feat(vite): add bridgeHmrEventsToRunner (state-preserving HMR)

* test(vite): cover bridgeHmrEventsToRunner object-form + no-ssr branches

* feat(vite): add isExternalId build filter

* feat(vite): add virtual:vue-tui/dev module

Adds devVmodPlugin (apply:'serve') that resolves virtual:vue-tui/dev
to its \0-prefixed id and loads a snippet that imports connectDevtools
from @vue-tui/runtime/internal and calls it with import.meta.hot.
Re-exports DEV_VMOD_ID and RESOLVED_DEV_VMOD_ID from index.ts.

* feat(vite): in-process dev plugin + vueTui() factory with HMR integration test

Implements devPlugin (src/dev.ts) that injects the dev-vmod connector at the
entry point, bridges HMR events to the SSR runner, and boots the app via the
runnable SSR environment. Wires everything in vueTui() (src/index.ts).

Adds the basic fixture (test/fixtures/basic) and a sequential integration test
that verifies (1) the app boots in-process rendering LABEL-A, and (2) a
template-only edit hot-swaps to LABEL-B-HOT with counter state preserved (≥3,
proving bridgeHmrEventsToRunner prevents a state-resetting reload).

Note: test uses configFile:false to pass vueTui() plugins inline, bypassing a
rolldown v0.2.1 bug where combining transform.define with a plugin transform
returning {code, map:null} throws "Cannot convert undefined or null to object"
during bundleConfigFile. The actual plugin and HMR behaviour are fully exercised.

* fix(vite): inject dev module into the configured entry (not just conventions)

The transform inject condition matched a Set of root-relative ids against the
ABSOLUTE fs path Vite passes to the transform hook, so injectInto.has(path)
never matched. A custom entry (vueTui({ entry: "/src/app.ts" })) silently got
no virtual:vue-tui/dev import → no overlay, no HMR-connect; the default entry
only worked by accident via the endsWith fallback over ENTRY_CONVENTIONS.

Match on the absolute path with path.endsWith(entry) (entry is root-relative,
so the leading "/" anchors the match), injecting into exactly the entry that
configureServer's runner.import(entry) loads. Drop the dead injectInto Set and
ENTRY_CONVENTIONS list.

Adds src/dev.spec.ts pinning: custom entry injects, default entry injects, query
suffix is stripped, and non-entry modules are left untouched.

* fix(vite): forward build-error HMR payloads to the SSR runner so the dev overlay renders

This dev server runs the app in the SSR runnable environment with the browser
socket off, so Vite's typed { type: "error" } compile/build broadcast (sent over
the same object as server.ws) never reached the module runner. The runtime's
initHmrBridge listens for `vite:error` on the SSR hot channel, so the dev overlay
never learned of build errors. bridgeHmrEventsToRunner only forwarded
type:"custom" payloads; extend it to also forward type:"error" AS-IS — the runner
dispatches `vite:error` straight from that payload (whose .err the runtime reads).

Empirically verified (real ws/client.hot/ssr.hot taps): the error broadcasts
in-process, ws.send IS client.hot.send, and forwarding as-is fires the runner's
vite:error listener → devState becomes error → the overlay renders "Build Error"
plus the real [vue/compiler-sfc] diagnostic. Red/green confirms it is load-bearing.

- unit: error payloads are forwarded as-is onto the ssr hot channel
- integration (overlay.sequential): boot, inject a <script setup> syntax error,
  assert the overlay's "Build Error" header + "compiler-sfc" diagnostic in-process
- the integration test uses a dedicated fixtures/overlay copy so it can't race
  dev.sequential's edits to fixtures/basic/app.vue under file-level parallelism
- drop an unused `vi` import that was failing lint in the dev-overlay spec

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(vite): production build path (single Node entry)

vite build now emits a single self-contained Node entry via buildConfigPlugin
(apply: "build"): target esnext, modulePreload:false, rollupOptions.input=entry,
external=isExternalId, output entryFileNames "[name].js". Wired into vueTui()
alongside the apply:"serve" dev plugins so the two coexist per mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(vite): in-process full-reload restart + app-exit dev-server teardown

An entry-level edit Vite can't hot-accept (e.g. editing main.ts) emits a full
reload. Verified by a real run against the configFile:false harness: Vite's SSR
module runner already re-executes the entry on full reload, and the runtime's
existing `vite:beforeFullReload` handler fires BEFORE that re-import. So no
manual re-import is needed in dev.ts — but the OLD app was never torn down,
leaving a zombie: its renderer/timers keep writing while the new mount() either
hits the instance-reuse guard (reload no-ops) or interleaves frames.

Runtime: render.ts registers the active dev app's internal teardown() with the
HMR bridge on mount and clears it on unmount; hmr.ts's vite:beforeFullReload
handler runs that teardown just before the runner re-imports. teardown() (not
unmount()) is used so the reload does NOT settle the exit promise.

App-exit teardown: in dev the app runs in-process under the dev server, which
holds the event loop open, so a genuine app exit (useApp().exit() / drain /
error) would hang. The runtime snapshots a `__VUE_TUI_TEARDOWN__` hook at mount
and calls it when the exit promise settles; dev.ts sets it to close the server
so the process exits cleanly. A full reload never settles the exit promise, so
it can't trigger a server close.

Test: full-reload.sequential.test.ts (dedicated reload/exit fixtures to avoid a
file-parallelism race) proves a single clean monotonic counter after one and
two consecutive entry edits (no zombie), and that a genuine app exit closes the
dev server. Verified red→green by disabling each hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: remove @vue-tui/cli; migrate examples + README to @vue-tui/vite

The @vue-tui/vite plugin (vueTui()) replaces the @vue-tui/cli bundledDev +
child-process dev story with an in-process Vite dev server (HMR) plus a
production build, so the cli package is now obsolete.

- Delete packages/cli/ entirely (bundledDev, hmr-loader, process-manager,
  bundle-extractor).
- Migrate examples basic-template, basic-jsx, and coding-agent to the plugin
  form: vite.config.ts uses vueTui(); scripts become dev=vite, build=vite build,
  start=vite build && node dist/main.js; drop the @vue-tui/cli dependency.
- Add examples/basic-template/README.md: the example is a config reference for
  vanilla vite@8 (recommended, proven). In this monorepo `vite` is overridden to
  vite-plus-core: `vite build` works, but the in-process dev server cannot run
  (its ssr environment is not a runnable dev environment) — a vite-plus-core
  limitation, not a plugin bug.
- Update root README quick-start and package READMEs to the plugin form.
- CI task graph: replace ci:test:cli with ci:test:vite.
- build-output integration test: swap the cli package case for vite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): give DevOverlay Box slot functions to silence Non-function-slot warning

The dev overlay passed array children to the `Box` component in two places —
the ok-state wrapper render (fires on EVERY dev session) and ErrorDisplay.
Vue warns "Non-function value encountered for default slot" for array children
on a component, and the runtime routes console.warn through the frame writer, so
the warning was visible in a real terminal on every dev boot. Wrap the children
in slot functions (`() => [...]`); rendered output is unchanged.

Add a focused guard spec that mounts the dev overlay in both the ok and error
states with a console.warn spy and asserts no Non-function/default-slot warning
is emitted (verified RED against the unfixed code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): make isExternalId Windows-safe (port vue-tui#209/#210 to @vue-tui/vite)

@vitejs/plugin-vue resolves the SFC to an absolute path; the old POSIX-only
/^[./]/ check missed Windows drive-letter/UNC paths, so the .vue file was
externalized and the built bundle crashed with ERR_MODULE_NOT_FOUND on Windows.
Use posix.isAbsolute || win32.isAbsolute, mirroring the CLI fix being deleted.

* chore: align vitest to upstream per vite-plus#1588 (drop vitest override)

* chore: run repo on vanilla vite (repoint the vite override from core to vanilla)

- catalog vite -> vanilla 8.1.0; the original @voidzero-dev/vite-plus-core spec is
  preserved as a commented catalog line for easy revert
- KEEP the `vite: "catalog:"` override ACTIVE: it just tracks catalog.vite, so with
  catalog on vanilla it now pins vite's version spec (incl. third-party peer ranges)
  tree-wide to 8.1.0 -- vanilla, not core. The override was never inherently 'Vite+';
  repointing the catalog is enough to flip the whole tree to vanilla.
- keep the single-@types/node override for stable types across the workspace
- vp commands still work; vp run ready green (build incl. all examples on vanilla,
  lint, type, 1289 + 129 PTY tests); vitest already on upstream (prev commit)

* chore(examples): drop needless spread of vueTui() in basic-jsx config

vueTui() returns Plugin[] and Vite flattens nested plugin arrays, so
`[vueTui({ entry }), vueJsx()]` works without the `...` and matches how the
other examples consume it. Verified: basic-jsx still builds on vanilla vite
8.1.0 (5 modules -> dist/main.js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vite): trim dead exports, micro-opt transform, dedupe test helpers

/simplify cleanup pass:
- index.ts: export only `vueTui` (+ default). The re-exported forceClientCompile/
  bridgeHmrEventsToRunner/isExternalId/buildConfigPlugin/DEV_VMOD_ID/RESOLVED_DEV_VMOD_ID
  were consumed by nothing (specs import from their own modules; examples import only
  vueTui) and the package ships no types.
- dev.ts: strip the entry query with indexOf/slice instead of split('?')[0], dropping a
  throwaway array on every module transform.
- extract packages/vite/test/helpers.ts (capture/waitUntil/waitFor), replacing the
  byte-identical copies in the three *.sequential.test.ts files.

vp run ready green (1289 + 129 PTY); @vue-tui/vite 9 files / 18 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(examples): rename example "start" script to "preview"

Align basic-template/basic-jsx/coding-agent with flappy-bird and the Vite
dev/build/preview convention; the script is unchanged (vite build && node
dist/main.js), only its name. README scripts block updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): re-arm the HMR bridge per-hot so dev survives full reloads on published installs

initHmrBridge guarded registration with a process-lifetime `initialized` flag. On a real
npm install @vue-tui/runtime lives in node_modules, which Vite's SSR dev runner EXTERNALIZES
— so the runtime's module-globals persist across full reloads. After reload #1 the
re-imported dev module's connectDevtools() hit `if (initialized) return` and never
re-registered listeners on the new hot, so vite:beforeFullReload stopped firing: the dev
overlay + HMR status went dead and the next reload leaked a zombie app (the instance-reuse
guard no-ops the new mount, the old renderer keeps writing). The monorepo BUNDLES the runtime
(workspace real-path outside node_modules), re-executing it each reload so the flag reset —
which is why full-reload.sequential.test.ts passed and masked the regression.

Track the hot identity instead: re-arm each new hot, skip only a redundant re-call on the
same hot. Adds a failing-first test that forces ssr.external (the published path) and asserts
a SECOND full reload tears down cleanly.

Found by adversarial review; reproduced with the real built runtime under forced ssr.external
(reload #2 zombie counter climbing 130->264), now clean across 3 reloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): force-client-compile user-added plugin-vue-jsx so JSX renders in dev

vueTui() force-client-compiled only the @vitejs/plugin-vue it creates itself, never a
@vitejs/plugin-vue-jsx the user adds alongside it (basic-jsx does `plugins: [vueTui({entry}),
vueJsx()]`). So in the dev SSR module runner the .tsx compiled in SSR mode (ssrRegisterHelper,
no import.meta.hot) and the terminal CLIENT renderer got SSR-shaped output -> a BLANK frame,
silently (no error).

Move force-client-compile into devPlugin's configResolved and apply it to every vite:vue /
vite:vue-jsx plugin in the resolved set (idempotently), so both our own plugin-vue and any
user-added plugin-vue-jsx emit client render functions in the SSR dev environment. Verified
by run: basic-jsx went from 1 byte (blank) to a full render. Adds a JSX dev fixture + a
failing-first render test (and @vitejs/plugin-vue-jsx as a devDependency for it).

Note: JSX edits still full-reload rather than state-preserving hot-swap (import.meta.hot is
injected by Vite core only in the client env, not by the plugin) — a known limitation, not a
blank screen. Found by adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): normalize a './'-prefixed custom entry so dev injection matches build

dev injects the dev module when the absolute module id endsWith(entry); build feeds entry
to rollupOptions.input. A "./src/main.ts" entry slipped past dev's match (absolute ids never
end with "./...") -> no virtual:vue-tui/dev -> no HMR/overlay, while build's stripLeadingSlash
left "./" intact and still succeeded — a silent dev/build split. normalizeEntry() canonicalizes
"/x", "x", and "./x" to a bare form (dev re-adds the slash, build uses it as-is).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(vite): give build.sequential its own fixture to remove a cross-file flake

build.sequential and dev.sequential both targeted fixtures/basic; dev.sequential's hot-swap
test writes app.vue (LABEL-A -> LABEL-B-HOT), and with fileParallelism that edit could land in
build.sequential's output mid-run and break its toContain("LABEL-A"). Copy basic -> a private
`build` fixture (the pattern overlay/reload already use).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(runtime): caution against top-level await waitUntilExit() in dev entries

Under the @vue-tui/vite dev server a top-level `await app.waitUntilExit()` blocks the entry
module's evaluation, wedging Vite's serial HMR full-reload queue after the first reload (the
dev server already keeps the process alive). Prefer fire-and-forget mount() in dev; reserve
waitUntilExit() for standalone/production entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): clone the Vue hook options instead of mutating Vite's shared object

forceClientCompile flipped opt.ssr=false in place on the transform hook's options arg, but
Vite reuses that object for the transform hooks of plugins ordered after vue/vue-jsx — so they
saw ssr:false and compiled for the wrong environment. Pass a clone {...opt, ssr:false} to the
Vue hook instead; the shared object is untouched. Adds a no-mutation test. Regression from
a350609 (which widened force-client-compile to the JSX path); found by round-2 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): preserve Windows-absolute entries in normalizeEntry

normalizeEntry stripped/prefixed unconditionally, turning a "C:/proj/src/main.ts" entry into
"/C:/proj/src/main.ts" — which never matches Vite's drive-letter module id, so dev injection
(HMR/overlay) silently missed. Leave drive-letter absolute paths as-is; only root-relative
"/x"/"x"/"./x" get the canonical slash treatment. Adds a regression test. Regression from
b373fa2; found by round-2 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): neutralize Vite's CLI keyboard shortcuts so they don't hijack the TUI's stdin

The PR runs the TUI in-process with the `vite` CLI, which binds keyboard shortcuts (q=quit,
r=restart, …) via a readline 'line' listener on process.stdin — the same stdin the runtime owns
in raw mode. So a submitted "q"/"r"/… line ran a dev-server action out from under the app
(q = server.close(), killing the session). configureServer now stubs server.bindCLIShortcuts;
the terminal app, not the CLI, owns the keys. Adds a sequential test that forces the enable gate
(httpServer + isTTY + !CI) and asserts no _shortcutsState is bound. Found by round-2 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(runtime): pin the per-hot HMR re-arm + fix a stale guard comment

The idempotency test header still described the old "MODULE-LEVEL boolean" guard the per-hot
refactor (54c1f77) replaced, and no unit test distinguished the per-hot guard from the boolean.
Update the comment to the hot-identity guard and add a hot-A->hot-B re-arm test (the integration
test already guards it end-to-end; this pins it at unit speed). Found by round-2 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): preserve Windows UNC entries in normalizeEntry

The round-2 Windows-absolute fix normalized backslashes then stripped leading slashes, turning
a UNC entry "\\server\share\src\main.ts" -> "//server/share/src/main.ts" -> relative
"server/share/src/main.ts" — so build resolved the wrong file (and it diverged from external.ts's
UNC-aware contract). Detect UNC ("//host/share/…") alongside drive-letter and leave it absolute.
Adds a UNC dev+build regression test. Regression from ecc8690; found by round-3 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vite): pass POSIX-absolute (and any rooted) entries through normalizeEntry

normalizeEntry special-cased only Windows drive-letter + UNC absolutes; a plain POSIX-absolute
entry (the standard fileURLToPath(new URL('./src/main.ts', import.meta.url)) idiom) fell through
and had its leading slash stripped, so vite build got a project-relative path and failed with
UNRESOLVED_ENTRY — while dev's endsWith still matched, hiding it until build/CI. Generalize the
guard: pass through anything already rooted (a leading '/' — covering root-relative,
POSIX-absolute, and UNC — or a drive-letter), normalizing only the relative forms. Adds a
POSIX-absolute dev+build regression test. Found by round-3 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(vite): table-drive the entry-form tests + fix stale entry comments

Consolidate the four near-identical vueTui entry tests ('./', drive-letter, UNC, POSIX) into a
single test.each — shorter, and now every form asserts BOTH dev injection and the build input
(previously './' and drive-letter only checked dev). Also correct two comments the entry-handling
evolution left stale: index.ts no longer claims build 'must have no leading slash' (rooted entries
pass through), and dev.ts's transform note reflects that entry can be a drive-letter path, not only
'/'-rooted. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: rewrite the beta banner — scope experimental note to dev-mode HMR, tighten wording

The Vite plugin's build path is solid; it's dev-mode HMR that's still experimental. Both READMEs:
'Public beta — the @vue-tui/runtime API is stabilizing toward 1.0; dev-mode HMR is still
experimental. Bug reports welcome.'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(ci): rename the ci:test:vite task to ci:test:vite-plugin

Clearer name for the task running @vue-tui/vite's (the Vite plugin's) suite. Renamed the
definition + its reference in the 'ci' aggregate's dependsOn; command (vp run @vue-tui/vite#test)
unchanged. Verified 'vp run ci:test:vite-plugin' resolves and passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:42:22 +08:00
Yunfei He 1d26a23222 docs(runtime): tighten the README status banner (#206)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:48:47 +08:00
Yunfei He e4f756def2 chore(runtime): prepare 0.1.0 public beta release (#205)
Re-applies the 0.1.0 release prep on top of current main. PR #167's branch
(release/runtime-0.1.0) was 35 commits behind main and predated the #173..#204
fix batch (incl. the severe renderer fixes #198/#199), so publishing from it
would have shipped a 0.1.0 missing those fixes.

- version 0.0.3 -> 0.1.0 (runtime only; testing/cli stay 0.0.x)
- add root LICENSE + packages/runtime/LICENSE (MIT)
- add packages/runtime/CHANGELOG.md (0.1.0 public API; ./internal is non-semver)
- npm metadata: author, repository(+directory), homepage, bugs, keywords
- engines.node >=22 -> >=22.18.0 (match the real toolchain floor)
- files: ship LICENSE explicitly alongside dist + CHANGELOG
- README: reframe to public-beta status; fix useCursor (position-based, not
  visibility); add useIsScreenReaderEnabled + renderToString to the API docs

Verified on this branch: build, type-check, lint (0 warnings), and the full test
suite (runtime 1289, cli 364, testing 12, PTY 129) all green. pnpm pack ships
LICENSE + CHANGELOG + dist with 0 literal `catalog:` deps; attw resolves types
green under node16(ESM) + bundler for `.` and `./internal`.

Note: `exports` is auto-generated by `vp pack` (pack.exports: true) as bare
strings; attw confirms types resolve via the sibling .d.mts, so no manual
types condition is added (it would be wiped by the next build anyway).

Supersedes #167.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:32:56 +08:00
Yunfei He 19c475ae33 fix(runtime): propagate thrown-undefined + non-Error messages from renderToString; don't leak stdin.ref on setRawMode throw (#203)
Three small confirmed fixes:

- renderToString swallowed a component that threw literal `undefined`: the
  `uncaughtError !== undefined` sentinel could not tell "threw undefined" from
  "no error", so it returned the normal frame instead of propagating. Track
  occurrence with a separate `errored` boolean (mirrors the live renderer's
  onErrorCaptured `errored` flag).

- renderToString wrapped a non-Error throw as `new Error(String(value))`, so
  `{ message: "detail" }` became "[object Object]". Re-throw a genuine Error
  (incl. cross-realm, via the `[object Error]` brand check) as-is, and wrap a
  true non-Error with `messageForNonError` so its message survives. Relocated
  `isErrorInput` from render.ts into error-overview.ts (next to
  messageForNonError) so both renderers share one source of truth.

- acquireRawMode called `stdin.ref()` before `setRawMode(true)`. On a hostile
  PTY setRawMode throws ERR_TTY_INIT_FAILED after the ref but before the refcount
  increments, so dispose's gated unref never ran and the ref'd stdin kept the
  event loop alive. Reorder setRawMode(true) before stdin.ref() so a throw
  leaves nothing ref'd.

Test-first: added reproducing tests for each (renderToString throw-undefined and
non-Error-message in render-to-string.test.tsx; ref/unref balance on a throwing
setRawMode in raw-mode-ref-leak.sequential.test.tsx), confirmed red, then green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:17:05 +08:00
Yunfei He 33cc9c3dcd test(runtime): full wrap-mode transition matrix; vouch the wrap re-measure divergence (#200)
Encode the declarative invariant for the runtime `wrap` re-measure fix
(PR #193) as a full matrix: for all 6 wrap modes and all 30 ordered
transitions, toggling `wrap` at runtime produces the exact same frame as
a fresh mount with that wrap (measure == paint). Ground-truth fresh-mount
frames are derived at runtime, not hardcoded. Reverting the one-line fix
in node-ops.ts turns 16 of the 30 transitions red, so the matrix
genuinely guards the fix.

Vouch the divergence: add [VOUCHED @hyf0] to the ink-divergences.md entry
and reword it to lead with correctness (Ink v7.0.4 has the latent stale
measure bug; vue-tui keeps the correct invariant). Drop "pending a human
vouch" from the node-ops comment.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:39:40 +08:00
Yunfei He 0ba8a5ff0e fix(runtime): sync log-update with the bytes actually written on the clear path (#199)
The clear-terminal branch of renderInteractiveFrame writes raw `output` (no
trailing newline) via a direct stdout.write, but then called
`writer.sync(outputToRender)`. `outputToRender` appends "\n" for non-fullscreen
frames, so on a fullscreen→non-fullscreen transition (a fullscreen frame
shrinking below the viewport) sync recorded a state that didn't match the screen:
with a declared cursor (useCursor) it placed the persistent caret one row too
high (buildCursorSuffix with hasTrailingNewline=true, basing the caret on row
`visibleLineCount` instead of the real `visibleLineCount - 1`), and it recorded
previousLineCount off by one so the next frame's erase was eraseLines(N+1) (G46
residue). This is the fullscreen→non-fullscreen sibling of #198.

Fix: sync the SAME string just written (`output`). `outputToRender === output`
whenever the frame is fullscreen or screen-reader, so steady-state fullscreen and
SR are byte-for-byte unchanged (G17: an empty SR frame still syncs "" → zero
lines). Leaving-fullscreen, overflowing, and unmount-clear all write raw `output`,
so syncing `output` is consistent for every clear sub-case.

TDD: a new integration test mounts a fullscreen TTY frame with a declared cursor,
shrinks it below the viewport, and asserts the emitted caret row and the
following frame's erase count. Red before the fix (cursorUp(3)/eraseLines(4)),
green after (cursorUp(2)/eraseLines(3)).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 04:45:07 +08:00
Yunfei He 8afc82fcd3 fix(runtime): place the declared caret on the right row in fullscreen (no trailing newline) (#198)
`buildCursorSuffix` computed `moveUp = visibleLineCount - clampedY`, assuming the
cursor rests on the blank row just past the content (row `visibleLineCount`).
That holds only when the frame ends with a newline. Fullscreen frames are written
WITHOUT a trailing newline (render.ts:962 `isFullscreen ? output : output + "\n"`,
and fullscreen is automatic whenever content fills the viewport), so the cursor
stays on the LAST visible row (`visibleLineCount - 1`). The suffix therefore moved
up one row too many: the declared caret landed a row too high, and the next
frame's `buildReturnToBottom` (which already measures from `previousLineCount - 1`)
then undershot the true bottom — erasing/rewriting the wrong rows and leaving stale
content. Reachable by any full-height TUI that declares a cursor (e.g. useCursor).

Found by differential fuzzing the incremental renderer (apply emitted bytes to a
terminal emulator seeded with the previous frame; result must equal a full repaint
of the next frame): 5,666 content mismatches in the no-trailing-newline + caret
regime, 0 once trailing newlines were forced — pinning the cause exactly.

Fix: thread `hasTrailingNewline` to `buildCursorSuffix` (and via `CursorOnlyInput`)
and move up from the real cursor row — `visibleLineCount - 1` when there's no
trailing newline. Defaults to true, so trailing-newline frames (the common
non-fullscreen path) are byte-for-byte unchanged. All log-update call sites pass
the frame's actual trailing-newline state.

TDD: cursor-helpers unit tests for the no-trailing-newline suffix math, plus
frame-writer regression tests that drive a fullscreen frame with a declared caret
through both the first-render and diff paths (red before the fix, green after).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 04:28:17 +08:00
Yunfei He 6469b08c46 fix(runtime): skip eager visual validation under screen-reader mode (Ink parity) (#197)
assertBoxValid (Box) and text.vue's validate() run eager render-time validation
of paint-time VISUAL props (backgroundColor, border fg/bg colors, borderStyle
shape) and throw into the error boundary on an invalid value (e.g. a chalk
modifier name like "bold" used as a color). They were gated only by the per-node
ariaHidden skip (srHidden), not by GLOBAL screen-reader mode.

Under global SR mode (isScreenReaderEnabled; INK_SCREEN_READER=true) vue-tui,
like Ink, linearizes the whole tree to PLAIN TEXT and never colorizes / draws
borders for any node — Ink's colorize path is bypassed entirely, so it never
throws on an invalid color. vue-tui still ran the eager validation for non-
ariaHidden boxes under SR and threw, crashing a screen-reader user out of
accessible content over a paint-only prop value.

Skip the eager visual validation when global SR is on, in addition to the
existing per-node srHidden skip: box.vue gates `!srHidden && (srEnabled ||
assertBoxValid(props))`, text.vue gates `!srHidden && (srEnabled || validate())
&& hasContent`. The validation is all paint-time visual input (no structural
checks), so skipping it under SR is safe and matches Ink.

Verified against real Ink v7.0.4: with INK_SCREEN_READER=true a
<Box backgroundColor="bold"> renders plain text and does NOT throw; without it
Ink throws in colorize.js. This is an alignment fix (removes a vue-tui
over-throw), not a new divergence — the existing ink-divergences entry gets a
factual, unstamped note about the SR carve-out.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:04:54 +08:00
Yunfei He 90713177bb fix(runtime): drop empty focus-subscriber sets so auto-id focusables don't leak (#196)
createFocusController()'s subscribe() returned an unsubscribe that did
`set.delete(fn)` but never removed the now-empty Set from the `subs` Map, and
remove(id) never touched `subs` either. useFocus() with no explicit id mints a
fresh `__auto-N` id per mount, so every mount/unmount of a no-id focusable
permanently leaked one empty-Set Map entry — unbounded growth over a long
session (300 mount/unmount cycles leaked 300 empty Sets).

The unsubscribe closure now drops the Set once its last subscriber leaves,
guarded by `subs.get(id) === set` so a stale double-unsubscribe after a
re-subscribe can't delete the fresh subscriber's Set (idempotency preserved).
remove() is left untouched on purpose: useFocus unsubscribes before calling it,
and deleting a Set with live subscribers would silence duplicate-id focus
delivery.

createFocusController + a test-only `__subscriberMapSize()` probe are exposed via
the ./internal entry so a unit test can assert the Map stays flat across 300
cycles, focus delivery still works (notify + re-subscribe re-creates the Set),
stale double-unsubscribe is a no-op, and multi-subscriber Sets are retained until
the last unsubscribe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:40:42 +08:00
Yunfei He fa1ab61470 fix(runtime): reset dev status on app mount so a remount can't show a stale overlay (#195)
`devState` is a module-global shallowRef that the HMR handlers drive to
{type:"error"} / {type:"update"}; nothing reset it on the create path. createApp()
can run multiple times in one dev process (two apps, unmount + re-create, a UI
restart tool, a test run), so a fresh app would inject the previous app's leftover
state and render its old "Build Error" / "[HMR] updated" overlay instead of its own
content — until the next HMR event happened to reset it.

Add resetDevState() (hmr.ts) and call it from render()'s `__VUE_TUI_DEV__` block,
right after initHmrBridge(), so every newly-mounted dev app starts from a clean
status — consistent with the very first app, which sees the module's initial
{type:"ok"}.

Dev-only (the block is gated behind the cli vite-plugin's `__VUE_TUI_DEV__` define).
TDD: the unit test drives a stale error/update via the real vite:error /
vite:beforeUpdate handlers, then asserts resetDevState() clears it (the per-mount
hook render() now invokes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:28:08 +08:00
Yunfei He 871f348f2f fix(runtime): flush the deferred trailing commit on non-interactive teardown (Ink parity) (#192)
In non-interactive non-debug mode commits are throttled (renderThrottleMs =
ceil(1000/30) = 34ms). teardown() cancel()s the scheduler, which DISCARDS any
pending trailing-edge commit, and the final-commit gate excluded non-interactive
non-debug — so the non-interactive trailing write emitted frameState.lastOutput
(the last commit that actually ran), a STALE frame. A reactive change deferred to
the trailing edge whose app unmounts within the throttle window was lost on
piped/CI output.

Mirror Ink's settleThrottle: broaden the final-commit gate to run mountedCommit()
in every mode before the trailing write. The non-interactive commit() branch only
refreshes frameState.lastOutput/lastOutputToRender to the current tree and writes
write-once <Static> (it DEFERS the dynamic frame), so the refresh feeds the latest
frame into the trailing write without double-writing it. Verified against real Ink
v7.0.4: the same deferred-then-unmount scenario emits "C\n" (latest); vue-tui now
matches (was "A\n").

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:18:07 +08:00
Yunfei He 0431870bb2 fix(runtime): keep the animation scheduler alive when a tick callback throws (#194)
onTick set `isDispatching = true`, ran the subscriber callbacks, then reset the
flag, flushed `pending`, and rescheduled with no try/finally. A throwing callback
skipped all three, leaving `isDispatching` stuck true forever: every later
subscribe/unsubscribe queued into `pending` and never ran, and no timer was ever
rescheduled. One bad tick permanently killed every `useAnimation` instance sharing
the (process-wide) scheduler — a non-recoverable wedge.

Wrap the dispatch loop in try/finally so the scheduler invariants are always
restored and the error still propagates (restore-then-rethrow, mirroring
scheduler.ts `doCommit`). Also advance each subscriber's `nextDueTime` BEFORE
invoking its callback, so a thrower can't leave it in the past and make the
post-throw schedule() re-arm a 0ms tight re-throw loop.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:12:25 +08:00
Yunfei He bf9d9d4a4a fix(runtime): re-measure text when wrap changes at runtime (#193)
The `wrap` prop changes a <Text> node's MEASURED height (the yoga measure
func reads el.props.wrap to pick wrap/truncate/hard layout) but is NOT a
yoga prop, so a runtime wrap-only change took the generic STYLE_PROPS
branch in patchProp: it stored the new value into el.props and called
onCommit() WITHOUT markTextDirty(el). Yoga kept the OLD wrap mode's cached
height while paint rendered with the NEW wrap, so layout and paint
disagreed -- stale blank rows on wrap->truncate, overflow / overwritten
siblings on truncate->wrap.

Mark the text node dirty when the changed STYLE_PROP is `wrap` on a
tui-text node so yoga re-measures. `wrap` is the only STYLE_PROP that
affects measured dimensions (the rest are paint-only), so it is the sole
case.

Verified Ink v7.0.4 has the identical latent bug -- its applyStyles
ignores textWrap and never markDirty()s, so a wrap-only change goes stale
there too. Recorded as a blessed divergence in ink-divergences.md; the fix
matches the layout Ink produces whenever its measure func is invalidated.

Tests (text-wrap-remeasure.test.tsx) reproduce both directions:
RED produced Ink's stale frame ("aaaa …\n\n\nZZZZ"), GREEN the correct
re-measured layout.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:57:52 +08:00
Yunfei He e4f181f888 fix(runtime): measureElement coerces a non-finite (pre-layout) dimension to 0, not NaN (#191)
yoga's getComputedWidth()/getComputedHeight() return NaN for a node not yet
through a layout pass, and `?? 0` does NOT catch NaN (NaN ?? 0 === NaN), so a
pre-layout / mis-timed measureElement() read returned { width: NaN, height: NaN }
— poisoning user layout math (terminalWidth - measured.width → NaN → a NaN width
prop). Coerce non-finite computed dims to 0 (Number.isFinite(v) ? v : 0).

0 is a safe sentinel ("not yet computed"), not the box's true size — the correct
usage is to read AFTER layout (the JSDoc already steers callers to defer via
nextTick). It's chosen because it is Ink's clear intent (`?? 0`) and matches the
DOM precedent (getBoundingClientRect on display:none / img.naturalWidth pre-load
return 0, not NaN). Deliberate, low-risk robustness divergence from Ink v7.0.4's
NaN-leaking `?? 0`; recorded in ink-divergences.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:25:23 +08:00
Yunfei He 7cab51cc28 fix(runtime): always unmount the tree in renderToString so a paint throw can't leak listeners (#186)
renderToString mounts the Vue tree, then lays out and paints, then unmounts.
The app.unmount() sat inside the try AFTER paint, so when layout/paint threw
(e.g. a <Transform> whose transformer throws during the paint phase) control
jumped to the outer finally, which only freed yoga — Vue never tore down, so
onScopeDispose never ran. Any composable that registered an external listener
then leaked it: useWindowSize attaches a `resize` listener to the shared
process.stdout (the no-op AppContext's stdout) and only removes it via
onScopeDispose, so each failed renderToString leaked one listener, accumulating
toward Node's MaxListenersExceededWarning.

Fix: track that mount succeeded and, in the outer finally, run app.unmount()
when `mounted && !teardownSucceeded` (best-effort, in try/catch, before the yoga
free). The happy path is unaffected (it already unmounted; teardownSucceeded
short-circuits the fallback). The error-path unmount frees child yoga nodes and
runs onScopeDispose cleanups; freeRecursive then frees the root. The original
paint error still propagates (the fallback teardown can't mask it). useWindowSize
is intentionally unchanged — the unmount-in-finally is the general fix and also
covers any other external listener a tree registers.

Test (sequential — asserts on the process-global process.stdout resize listener
count): three renderToString calls whose paint throws leak zero `resize`
listeners after the fix (3 before).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:23:23 +08:00
Yunfei He daf5e76dfd fix(runtime): re-validate text-leaf context in setText (empty anchor can't smuggle bare text into a Box) (#185)
A text-leaf that mounts EMPTY passes insert()'s "must be inside <Text>" guard as
a Vue fragment anchor. If it later becomes non-empty via setText() — e.g.
`<Box><Text>label</Text>{{ maybe }}</Box>` where `maybe` goes ''->'hi' — it was
never re-validated, so non-empty bare text ended up directly under a <Box> and
paint silently DROPPED it (paintNode renders a text-leaf only via a <Text>/
<Transform> parent). Identical content mounted non-empty throws at insert, so the
same content either errored or silently vanished depending on render history.

Fix: setText() now re-runs the SAME rejectsTextLeaf() check insert() and
setElementText() use (the shared helper added in #179), throwing the same error
on an empty->non-empty transition into an invalid context. Throwing in the
patch/render phase is consistent with the "validate at render, not paint"
invariant and routes through the error boundary (rejects) rather than wedging. A
leaf inside <Text>, cleared back to "", or detached is a no-op; the common path
(text inside <Text>) is not rejected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:21:02 +08:00
Yunfei He 4243cff937 fix(runtime): margin/padding edge removal falls back to the surviving shorthand (#184)
* fix(runtime): margin/padding edge removal falls back to the surviving shorthand

Withdrawing a per-edge/axis margin or padding override from a box that still
has a broader shorthand collapsed the edge to 0 instead of falling back. E.g.
`margin={5} marginTop={8}` with marginTop later removed: the setter ran
setMargin(EDGE_TOP, 0), and per yoga edge precedence EDGE_TOP=0 overrides the
surviving EDGE_ALL=5, so the top margin became 0 (the box jumps 5 cells) when
the declarative model (render = f(current props), current = {margin:5}) says 5.
A single per-prop yoga setter can't reconcile an edge that depends on the
specific edge + axis + all-edges shorthand together.

Fix mirrors the existing reconcileBorderEdges pattern: the 14 margin/padding
setters become no-ops, and reconcileMarginEdges/reconcilePaddingEdges recompute
all four physical edges from the box's full el.props with most-specific-wins
precedence (top = marginTop ?? marginY ?? margin ?? 0, ...), zeroing the
composite edges so nothing layers on top. A present-but-non-finite value
(NaN/Infinity) or a withdrawn prop falls THROUGH to the next precedence level,
preserving yoga's prior setMargin(NaN)->fallback behavior; an explicit 0 is
finite and still overrides. margin keeps EDGE_START/END and padding keeps
EDGE_LEFT/RIGHT for left/right, matching the prior setters.

Verified against real yoga-layout@3.2.1 that the SET path produces identical
computed edges as the old per-setter code (no layout regression) across all
combinations and patch orders, and the correct fallback on removal.

This is NOT an Ink-parity item: run against Ink v7.0.4, Ink and pre-fix vue-tui
both collapse to 0 (the identical bug). The fix diverges from Ink by being
declaratively correct under the already-documented G19 reset principle;
recorded in ink-divergences.md alongside the display / flexDirection entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): pin margin/padding spacing to a finite-number contract (Codex review)

The family recompute resolves an edge from a prop only when it is a finite
number (matching the `number` prop type + Ink's number-only spacing); numeric
strings (`margin="5"`) are coerced for Vue static-template ergonomics, but other
non-numeric values (`"50%"`, junk, `""`) are treated as not-set and fall through
to the surviving shorthand instead of being forwarded to yoga.

This makes intentional the behavior change the final review flagged: the OLD
per-setter code incidentally forwarded off-contract strings to yoga (so
`marginTop="50%"` became a percent and `marginTop="foo"` threw). That was
undocumented and non-Ink. Also excludes "" from the present() check so all
non-numeric strings fall through uniformly (Number("")===0 would otherwise
resolve to 0). The numeric/numeric-string SET path is unchanged (re-verified
across all 5040 patch orders).

Tests pin the contract (numeric, numeric-string, "50%"/"foo"/"" fall-through,
NaN/withdrawn fall-through, explicit 0), and ink-divergences.md records it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:18:54 +08:00
Yunfei He 889e0cdb00 fix(runtime): make error capture first-wins and crash-safe against a racing unmount (#182)
* fix(runtime): make error capture first-wins and crash-safe against a racing unmount

Two confirmed bugs in the InternalErrorBoundary's onErrorCaptured:

BUG #2 — a component error was silently swallowed when host code threw
during an update flush and then synchronously called app.unmount() in the
same task. The exit was routed entirely through `void nextTick(() =>
exitWithError(e))`, so pendingExitError was not recorded until that deferred
microtask ran; the racing unmount's resolveExit() read it as undefined and
RESOLVED the exit promise clean instead of REJECTING with the error.

Fix: record the error SYNCHRONOUSLY via a new recordExitError() bridge
(first-wins: only sets pendingExitError if no exit is already decided), while
keeping teardown DEFERRED via nextTick. Deferring teardown is load-bearing —
teardown()'s final mountedCommit() paints the ErrorOverview frame, and the
boundary's errored->true re-render must commit before it; a synchronous exit
would drop the overview frame on non-interactive/non-debug mounts. Frame/paint
timing is now byte-identical to before in every mode.

BUG #5 — two descendants throwing in the same synchronous flush left the
displayed overview (caught, last-wins) and the rejected error (pendingExitError,
first-wins) disagreeing. Fix: guard the capture body with `if (!errored.value)`
so the first thrown error drives both the display and the rejection (e17).

Tests: the racing-unmount swallow (interactive/debug AND non-interactive/
non-debug), the two-throw display/reject agreement, and frame-painting guards
that pin the overview behavior to main in each mode. Also corrected a stale
exit-chain comment in @vue-tui/testing's render().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): exit() must not clobber an error already recorded by the boundary (first-wins)

Final review found an asymmetry: recordExitError() first-wins-guards its write,
but appContext.exit() recorded the error unconditionally. So a captured throw
(Error1, shown in the overview, recorded via recordExitError) followed by a
racing exit(Error2) before the deferred teardown made waitUntilExit() reject
Error2 while the overview displayed Error1 — the BUG #5 display/reject
disagreement through a different door.

Fix: exit() uses `pendingExitError ??= errorOrResult`, so it keeps a
synchronously-recorded error. Identical to `=` in every other case
(pendingExitError is undefined on a normal first exit()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:16:51 +08:00
Yunfei He 670cca402a fix(runtime): stop pathological non-Error throws from wedging the error boundary (#180)
* fix(runtime): stop pathological non-Error throws from wedging the error boundary

A thrown value with a throwing coercion/getter could make three sibling
throw sites in the error-exit/display path re-throw with NO surrounding
try/catch, wedging Vue's post-flush scheduler — the app hangs and
waitUntilExit() never settles:

- messageForNonError's two String(value) fallbacks (a throwing
  Symbol.toPrimitive/toString/valueOf) — now routed through a throw-safe
  safeString() returning "[unserializable value]".
- isErrorInput's Object.prototype.toString.call (a throwing
  Symbol.toStringTag getter), which runs BEFORE messageForNonError on the
  error-exit path — now guarded; on throw the value is treated as non-Error
  and routed through messageForNonError.
- ErrorOverview's `.stack` read (a throwing `.stack` getter) during render
  — now read exactly once under try/catch; on throw it renders header-only.

Tests: unit coverage of messageForNonError plus an end-to-end "does not
wedge" mount test for all three pathological shapes, and an overview-frame
test proving the .stack guard is load-bearing for correctness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): close two more pathological-throw paths in the error boundary (Codex review)

Final review of the wedge fix found two reachable throw sites it hadn't closed:

- isErrorInput: `value instanceof Error` ran OUTSIDE the try/catch, but
  `instanceof` invokes the value's [[GetPrototypeOf]], which a Proxy with a
  throwing getPrototypeOf trap re-throws — wedging the boundary exactly like the
  Symbol.toStringTag case. Wrap the whole body (instanceof + brand check) in one
  try/catch → false on throw. (The old "instanceof CANNOT throw" comment was wrong.)

- ErrorOverview source excerpt: a crafted/stale `.stack` can parse to an existing
  DIRECTORY, so fs.existsSync passes and fs.readFileSync throws EISDIR during
  render — repainting the overview for the EISDIR error while waitUntilExit()
  rejects the original (a displayed-vs-rejected e17 disagreement). Guard the file
  read; on failure render header-only (no excerpt).

Tests: a Proxy whose getPrototypeOf throws does not wedge; a directory-pointing
`.stack` renders header-only with display==reject; and an e2e assertion that
"[unserializable value]" is both displayed AND rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:14:23 +08:00
Yunfei He be045cfaff fix(runtime): hide the caret on app.clear() instead of re-showing it (Ink parity) (#190)
app.clear() should wipe the rendered output and leave the terminal caret
HIDDEN, like Ink v7.0.4. Instead vue-tui repositioned and RE-SHOWED the
caret on the now-blank screen.

Same scenario both sides (useCursor {x:5,y:0}, "Hello", columns 40):
  Ink     clear() bytes: \x1b[?25l \x1b[1B \x1b[1G \x1b[2K \x1b[1A \x1b[2K \x1b[G
  vue-tui clear() bytes: ...same... + \x1b[1A \x1b[6G \x1b[?25h   (BUG)

Root cause: mountedClear() runs writer.clear() (hide + erase, correct) then
writer.sync(...). vue-tui's sync re-emits the PERSISTENT declared cursor (a
blessed divergence that is correct for repaints, which redraw the content),
so it wrote buildCursorSuffix = reposition + show. But clear() erases WITHOUT
redrawing, so re-asserting the caret floats it on a blank screen. Ink's own
clear()-time sync sees cursorDirty=false and emits no caret for the same
reason.

Fix: add an optional SyncOptions { cursor?: boolean } to log-update's sync
(both the standard and incremental variants) and thread it through
FrameWriter.sync. When cursor:false, sync treats the active cursor as
undefined for that call only: no reposition/show, and (since clear() already
set cursorWasShown=false) no hide either. It does NOT touch the persistent
cursorPosition, so the NEXT real commit re-shows the caret normally. Only
mountedClear() passes { cursor: false }; the clearTerminal/resize sync and
the external-write restoreLastOutput path (which redraw) keep the default
cursor:true, so they still re-assert the caret.

Verified byte-exact against real Ink v7.0.4 across a 10-scenario matrix
(active cursor, no cursor, clear-then-rerender, multiline y>0, {0,0}, two
clears, owner-unmounted, non-interactive/debug no-op, external-write restore,
clear-then-resize). New test: clear-cursor.test.tsx (raw interactive stdout
byte capture; testing lastFrame() is content-only and cannot see cursor
escapes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:01:34 +08:00
Yunfei He 85088f7863 fix(runtime): validate setElementText context before clearing children (#179)
setElementText(el, text) removed ALL existing children first, then inserted
a single text-leaf. When `el` is a non-text container (tui-box / tui-static /
root) and `text` is non-empty, the inserted leaf trips insert()'s text-context
guard and throws AFTER the removal loop has already run — leaving the node
half-cleared (original children gone, nothing inserted).

Validate the target context BEFORE the destructive remove so a rejected insert
never leaves the node half-cleared. Extract the text-leaf rejection check into a
shared rejectsTextLeaf() helper used by BOTH setElementText()'s new pre-check and
insert()'s existing guard, so the condition and error message cannot drift.

Empty-string clears, text on a tui-text / inside-text context, and non-container
no-ops all keep their existing behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:58:23 +08:00
Yunfei He a9a8c65a30 fix(runtime): restore content-guard display state when layout throws (#178)
calculateLayoutWithContentGuards hides zero-content nodes (setDisplay
DISPLAY_NONE, prior display recorded in `guarded`) inside its for(;;)
loop, but only returns the restore closure on the normal path. If a
later loop iteration's calculateLayout — or a measure func it invokes —
throws after an earlier iteration already hid one or more nodes, the
throw propagated before the closure was handed back, leaving those nodes
DISPLAY_NONE on the live yoga tree. On the next commit
applyZeroContentGuards short-circuits any already-DISPLAY_NONE node, so
they were never un-hidden and the subtree stayed permanently invisible
even after the offending input was removed. The callers wrap the
RETURNED closure in try/finally, which cannot help because the closure
was never returned.

Wrap the loop so any exception restores everything currently in
`guarded` (reverse order, same as the success closure) before
re-throwing, leaving the live yoga tree clean. The original error
propagates unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:45:58 +08:00
Yunfei He 27a63b3b2b fix(runtime): clear stale HMR update timer so newer updates aren't reset early (#177)
Each vite:beforeUpdate scheduled an unconditional setTimeout to reset the dev
status from "update" back to "ok" after 2s, but never stored or cleared the
handle. Rapid successive updates stacked independent timers; an earlier
update's timer firing while a later update was still showing would reset the
newer status line early (its guard only checked type === "update", which is
still true for the newer update).

Track the pending timer in a module-level variable, clear it at the top of
vite:beforeUpdate before scheduling a new one (so only the latest update's
timer is ever live), and clear it on vite:error (an error supersedes a pending
update->ok reset). Also unref() the timer so it doesn't hold the event loop
open; .unref is optional since the DOM number handle lacks it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:22:30 +08:00
Yunfei He 8a2efdfeef fix(runtime): make initHmrBridge idempotent (#176)
initHmrBridge registered three Vite HMR listeners (vite:error,
vite:beforeUpdate, vite:beforeFullReload) with no idempotency guard and is
called once per createApp() (dev block in render.ts). createApp() can run
multiple times in one dev process — two apps, an app that unmounts and is
re-created, a tool that restarts the UI, or a test run — and Vite's Node HMR
runtime APPENDS listeners with no dedup, so N calls leaked N copies of every
handler permanently. Every later HMR event then ran each handler N times.

Add a module-level boolean guard so the listeners register at most once for
the module's lifetime, regardless of how many times initHmrBridge is called.

Also parameterize the hot context (defaulting to import.meta.hot) so the body
is reachable under vitest, where import.meta.hot is undefined. HotContext is a
local structural type and import.meta.hot is read via a structural cast so the
module type-checks even when imported directly from runtime-tests, whose
tsconfig doesn't pick up env.d.ts's ambient ImportMeta.hot augmentation.

Out of scope: the setTimeout stale-timer in the vite:beforeUpdate handler is a
separate bug left exactly as-is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:09:54 +08:00
Yunfei He fb43b6af8f fix(runtime): disable bracketed paste synchronously on signal exit (#173)
On the signal-exit teardown path signal-exit re-raises the signal
immediately after the callback returns ({alwaysLast:false}), so a
buffered async stream.write can be lost before the process dies.
teardown(true) already flushes show-cursor, leave-alt-screen and
disable-kitty synchronously via fs.writeSync, but the bracketed-paste
-disable escape \x1b[?2004l was still written with an async
stdout.write on both teardown sub-paths (usePaste's onScopeDispose
-> detach during originalUnmount(), and the stdin controller dispose
backstop). When dropped, the user's shell stays in bracketed-paste
mode and wraps later pastes in \x1b[200~ ... \x1b[201~.

Thread a sync flag through the paste teardown, mirroring kitty:
disableBracketedPaste(sync) writes via fs.writeSync(fd, ...) when sync;
the stdin controller dispose(sync) forwards it; teardown passes sync at
the dispose() call site. Because Vue's unmount runs detach (async, lost
on signal) before dispose() and zeroes the live count, dispose(sync)
re-issues paste-OFF synchronously whenever paste was ever enabled --
paste-OFF is idempotent, so the redundant write is harmless. The normal
(non-signal) unmount path stays async, unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:13:26 +08:00
Yunfei He df843b67fe fix(runtime): validate custom borderStyle object shape at render (#172)
A malformed custom borderStyle OBJECT (e.g. `{ topLeft, topRight }` missing
`top`) — or a truthy non-string non-object value (e.g. a number from a JS
caller) — bypassed assertBoxValid's render-time check, which only shape-checked
the STRING form. It reached drawBorder, passed the `if (!chars)` guard, and
threw `Cannot read properties of undefined (reading 'repeat')` deep in the
post-flush PAINT pass — wedging Vue's scheduler instead of surfacing a
recoverable error, exactly the failure mode box-validate.ts exists to prevent.

Resolve borderStyle to a BoxStyle the same way paint's drawBorder does (string
-> cliBoxes[name], object -> directly), then shape-check the result: every one
of the 8 glyphs paint reads (top/bottom/left/right + the four corners) must be
a string. Any invalid value now throws a clean error AT RENDER, caught by the
error boundary — like the existing unknown-string case. The string case keeps
its "Unknown borderStyle:" wording; the object/non-string case uses "Invalid
borderStyle:".

Test-first: borders.test.tsx now asserts a malformed object, a number, each
individually-missing glyph, and a present-but-non-string glyph all reject at
render (not the opaque paint TypeError), and a complete custom object still
paints a border.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:44:27 +08:00
Yunfei He c37ac910f4 fix(runtime): run teardown on synchronous mount() throw (#169)
mount() registers the app as the stdout owner (liveInstances.set) and then
runs holdRawModeForLifetime(), kittyController.init(), and attachYoga()/
setWidth() — all of which can throw SYNCHRONOUSLY on a hostile terminal
(setRawMode raises ERR_TTY_INIT_FAILED on some SSH/container PTYs that
report isTTY=true; kitty enable's stdout.write can throw on a broken
stream) — BEFORE the originalMount try/catch and before the exit/signal
handlers are wired. A throw there skipped teardown(), leaving the
liveInstances entry forever (poisoning the stdout: every later mount()
hit the reuse guard and became an inert no-op), leaking the yoga root,
and leaving raw mode / kitty on.

Wrap those pre-mount steps in the same teardown-then-rethrow guard as
originalMount. teardown() is idempotent and safe at this early stage (it
derives all cleanup from the wired state set so far and guards on
mountedAppContext). Also: assign mountedKittyController BEFORE init() so
an auto-mode detection-query throw (after the stdin listener + timer are
installed) is disposed, and record mountedRoot right after attachYoga
(before setWidth) so the just-allocated yoga node is freed on a setWidth
throw. The original error always survives and is rethrown to the caller.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:56:40 +08:00
Yunfei He a2d33d98fe fix(runtime): gate per-edge border width on borderStyle (align Ink) (#168)
Per-edge border props (borderTop/Bottom/Left/Right) reserved 1 yoga cell
whenever truthy, regardless of borderStyle. Since these props default to
`true`, toggling one on an UPDATE while borderStyle stays unset (Vue patches
only the changed per-edge prop, not borderStyle) left a spurious 1-cell inset
with no border ever drawn — content shifted to "\n HELLO" instead of "HELLO".

Mirror Ink's applyBorderStyles: an edge's width is `borderStyle ? 1 : 0`,
forced to 0 when that edge is explicitly `false`. A per-edge toggle can only
SUBTRACT, never add. The per-edge yoga setters become no-ops; patchProp now
recomputes all four edges from el.props on any border-prop change via the new
reconcileBorderEdges helper, so borderStyle flipping in EITHER direction
(set->unset zeroes, unset->set re-reserves) and per-edge toggles are all
handled jointly — the computation a single (n, v) yoga setter cannot do.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:25:28 +08:00
Yunfei He 33da2e5073 docs(runtime): correct box.vue $el comment (fragment anchor, subtree-drilled)
Reviewer caught a stale comment: it claimed the root-`v-if` fragment's `$el`
"resolves to the real host node". It doesn't — `$el` is the fragment boundary
anchor; measureElement/useBoxMetrics resolve a Box ref by drilling the component
subTree to its first host node. Also fixed a leftover `box.ts` reference
(now box-validate.ts). Comment-only; code was correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:49:08 +08:00
Yunfei He 3a029aa684 docs(runtime): record TuiNode-via-TuiApp as accepted incidental exposure
Review (Codex) flagged that `TuiApp extends Omit<App<TuiNode>, "mount">` surfaces
the internal `TuiNode` host-node type in the published .d.ts (it rides out on Vue's
internal `App._container`). Decision: KEEP it / don't fix.

Rationale: `_container` is a Vue-internal field no consumer touches, so the exposure
is purely cosmetic (zero functional impact), and type-only surface isn't held to
strict SemVer, so it imposes no real contract. Hiding it (`App<unknown>` or a
`Pick<App, …>` allowlist) is ceremony for a cosmetic gain on a pre-1.0 lib.

Documented at the TuiApp definition and in api-contract.md so it isn't re-flagged.
No behavior/type change — just a conscious-decision record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:49:08 +08:00
Yunfei He 3aadf55a3e docs(runtime): fix host-tag comment drift after tui- prefix rename
Adversarial review found stale bare-host-tag references the per-file sed couldn't
reach (they live in comments/docs). Code was clean — no contamination, no public
API leakage, root/text-leaf/comment asymmetry consistent. Updated:
- vite.config.ts isCustomElement comment (<box>/<text> -> <tui-box>/<tui-text>)
- component-authoring.md split-table Text row (virtual-text/text -> tui-*)
- box.vue / useBoxMetrics.ts / use-box-metrics.test.tsx "the real `box` host node"
  comments -> `tui-box`

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:49:08 +08:00
Yunfei He ca63a5d04c refactor(runtime)!: prefix host primitive tags with tui- (align to Ink, drop *Impl)
The renderer's intrinsic elements were named with bare words (box/text/static/
transform/virtual-text), which collide with the same-named public components: a
template `<box>` PascalCase-resolves to `<Box>` under vue-tsc (no isCustomElement
at the type layer), forcing the BoxImpl/TextImpl/StaticImpl workaround.

Prefix the 5 host elements to `tui-*` (mirroring Ink's `ink-box`/`ink-text`):
the prefix + hyphen keeps them in their own namespace, so the components keep
their real names (Box/Text/Static) with no self-recursion — the *Impl rename is
removed. root/text-leaf/comment stay unprefixed (not template tags, not elements).

Mechanics: renamed the TuiNode discriminant literals + factories first, then let
vue-tsc enumerate all 145 stale `node.type === "box"` comparisons (the type-
driven finder also kept `position: "static"` and the ansi-tokenizer's separate
`type: "text"` union untouched). Updated createElement cases, HOST_TAGS,
the .vue templates, transform.ts h(), and raw `h("box")` host-op tests.

Two non-type-checked contaminations the sed caused were caught by tests and fixed:
- patchProp's `key === "transform"` (the PROP name, not the node type) must stay
  "transform" — the sed wrongly prefixed it, dropping the transform fn (identity).
- text-measure's `token.type === "text"` is an AnsiToken, not a TuiNode — reverted.

BREAKING CHANGE: the internal host element names are now tui-box/tui-text/
tui-virtual-text/tui-static/tui-transform. Public components (Box/Text/Static/
Spacer/Newline/Transform) and their props/types are unchanged; only raw host-op
callers (h("box") -> h("tui-box")) are affected.

vp run ready green: fmt, lint 0/0, vue-tsc, tests (runtime 350, integration 1161,
PTY 129).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:49:08 +08:00
Yunfei He 9fe6d7cc44 refactor(runtime): author public components as template SFCs (+ integrate main)
Rewrites Box/Text/Spacer/Static/Newline from h()/render functions to Vue
<script setup> template SFCs (Transform stays a render fn — it inspects its own
child vnodes), with vue-tsc-verified consumer types (template + JSX fixtures),
provide/inject text context, the always-validate Text divergence (color +
backgroundColor), and three renderer fixes the SFCs surfaced (static anchor skip,
transform line-index Ink-parity, useBoxMetrics subtree drill). Integrates the five
main commits landed after the branch point: #163 public-API audit, generic Static
scoped-slot typing, foreground color validation, useWindowSize/divergence docs.

Squashed from the SFC sub-commits + the two main-integration merges to keep a
linear, rebaseable history. See PR #165 for the full breakdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:49:08 +08:00
Yunfei He f847e17c81 docs(runtime): finish the useWindowSize rename in docs; tidy contract guards (#164)
Follow-up cleanup for the 7 confirmed findings from a review of #163. The
dominant theme: #163 hard-renamed the public composable useTerminalSize ->
useWindowSize (no alias) but left stale references to the dead name in
user-facing docs.

- README.md + packages/runtime/README.md: the composable tables named the
  removed `useTerminalSize()` (root README even framed the sole real export
  `useWindowSize` as an "Ink-compat alias" — now inverted). Point both at
  `useWindowSize()`.
- .agents/docs/ink-divergences.md: two vue-tui-side references to
  `useTerminalSize` (the shallowRef "object of refs" example and the
  "composables throw outside a render tree" list) -> `useWindowSize`. The
  Ink-side `useWindowSize -> WindowSize` naming example is left unchanged.
- .agents/docs/accessibility-api.md: the intro cited three "blessed entries"
  but only aria-camelCase is one; `renderToString` layout-only and the
  `useWindowSize` name are now Ink parity, not divergences. Reword.
- .agents/docs/api-contract.md: tighten the `/internal` wording — the test
  does assert one tripwire on `/internal`, so "not covered by
  public-api.test.ts" was imprecise.
- public-api.test.ts / render-to-string.test.tsx: the public renderToString
  dropped the `isScreenReaderEnabled` option but (unlike the sibling
  `ScreenReaderOptions` type) had no compile-time guard. Replace an obscure,
  fmt-fragile type-indexing guard with a readable call-site `@ts-expect-error`
  in render-to-string.test.tsx; re-adding the option to the public
  RenderToStringOptions makes the directive unused and fails `tsc --noEmit`.
- Rename terminal-size.test.tsx / .sequential.test.tsx ->
  window-size.test.tsx / .sequential.test.tsx to match the migrated symbol.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:04:16 +08:00
Yunfei He 0b61ff2bf4 refactor(runtime)!: public-API audit follow-ups — align to Ink, record decisions (#163)
* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions

Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.

Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.

BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle

Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.

- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.

- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).

- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).

- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.

BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(runtime)!: move renderScreenReaderOutput to /internal-only

The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.

`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.

public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.

Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.

BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(runtime-tests): snapshot the exact public value-export set

Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.

Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 01:40:57 +08:00
Yunfei He dab5125c90 fix(runtime): type Static scoped slots 2026-06-14 01:12:13 +08:00
Yunfei He 5d29240df9 fix(runtime): validate invalid foreground color props 2026-06-13 03:27:48 +08:00
Yunfei He d1fe39f96c fix(runtime): reject non-Error throws with the message ErrorOverview displays (#158)
A thrown non-Error whose .message is a string (throw {message:'x'})
displayed 'x' in the ErrorOverview but rejected waitUntilExit() with
new Error(String(value)) = '[object Object]' — display and reject
disagreed. Introduce one messageForNonError(value) helper (string
.message else String(value)) and feed it to BOTH the overview header
and the two non-Error reject-wrap sites, so the shown and rejected
messages can never drift. Overview output is byte-identical (the helper
is the prior inline logic extracted); real-Error, cross-realm, and
no-synthetic-stack paths are unchanged.

Blesses vue-tui's uniform show-the-error-and-reject behavior for any
thrown value (audit e17): Ink instead resolves waitUntilExit() with a
truthy thrown value and silently hangs on a falsy throw — abnormal, so
vue-tui deliberately diverges. Ledger entry rewritten to the full
run-verified scope with Maintainer decision (2026-06-12): KEEP.

Red-first: a consistency test asserting throw {message:'objmsg'} shows
AND rejects 'objmsg'.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:15:48 +08:00
Yunfei He 86b94b9fa5 fix(runtime): re-assert the declared cursor every commit (persistent declaration) (#157)
A focused input's caret zombied to the bottom-left corner whenever an
unrelated repaint (spinner tick, log line, progress bar) committed
without re-declaring the cursor: the active cursor was gated on a
per-commit dirty/reference change, so an unrelated commit dropped it.

Real terminal programs that own an edit point re-place the caret there
every frame (vim emits an absolute CUP after each repaint, readline
re-lands the buffer offset on SIGWINCH, nano homes to its edit cell).
Match that: the runtime now re-emits the last-declared caret at the end
of every commit until the declaration changes or is cleared, so the
caret survives unrelated repaints in all component topologies. The
position is clamped to the visible region (D5) and a cleared
declaration emits no caret, so teardown still hands the cursor back.

This is a deliberate divergence FROM Ink, which re-asserts only when
the cursor's React component re-renders and so zombies the caret in
sibling/leaf topology too (run-verified). Aligning to Ink reduces bugs
only when Ink is correct; here matching Ink would preserve abnormal
behavior. Overrides the prior 2026-06-01 KEEP, whose rationale (avoid
diverging from Ink in the sibling direction) was overturned by running
real terminal apps. The {x,y} setCursorPosition API is unchanged (it
remains the IME primitive); the fix is an internal per-commit re-emit.

Red-first: a real-TTY PTY test with sibling-topology spinner state
asserts the spinner-only frame ends with the caret-restore suffix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:08:05 +08:00
Yunfei He 6a3537023a fix(runtime): re-arm trailing commit per deferred call to match Ink's throttle anchor (#154)
The commit scheduler armed one fixed trailing timer at the start of a
throttle window, firing the trailing commit at windowStart+wait. Ink's
es-toolkit throttle re-arms on every throttled call: trailing fires at
lastCall+wait. Deterministic probe at maxFps=10 (updates t0/t0+43/
t0+86): Ink trailing median 192.5ms, vue-tui 103.6ms (audit e29).

Mirror the observable timing of es-toolkit's throttle: leading commit
when no window is active, per-call trailing re-arm (lastCall+wait), and
the maxWait edge (a call a full window after the first deferral commits
synchronously) so sustained updates keep the ~wait cadence instead of
debounce-starving. Resize cancellation (a separate blessed divergence)
is preserved: the post-fix cancel probe is byte-identical.

Red test is the discriminating multi-deferred-call shape: a single-
deferred-call test goes green under the wrong firstDeferredCall anchor.
Post-fix probes land at 188.3-189.2ms, inside Ink's 188.4-195.0 band;
CI=true vp run ci passes alongside vp run ready.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:21:35 +08:00
Yunfei He c66cddb676 fix(runtime): derive mount-guard skip from wired state, not a sticky flag (#153)
The instance-reuse guard set a per-app skippedMount flag that was never
reset, so one guarded mount() call permanently disabled the app's own
teardown. Three run-confirmed wedges (audit e18), all absent in Ink:

- an owner double-firing mount() on its own live stdout kept painting
  after unmount() and leaked its registry entry
- an app that once hit the guard could never unmount a later legitimate
  mount on a free stdout
- an app live on stream A that merely targeted another app's busy
  stream B became unkillable on A

Delete the flag; teardown()/resolveExit() now consult the actually
wired state (mountedAppContext / mountedAsOwner), so a guarded call is
inert for that call only. The blessed inert-no-op divergence from Ink's
reuse-and-rerender is unchanged; the ledger entry is reworded to the
call-scoped semantics.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:51:19 +08:00
Yunfei He 814c482d6d fix(runtime): install console patch before first mount so initial [Vue warn] is filtered (#151)
A [Vue warn] emitted during the initial mount (e.g. the missing-render-
function warn from a root setup() throw) escaped the stderr filter
because mount() installed the console patch only after originalMount.
Ink patches in its constructor before the first React render
(ink.tsx:435-436); move the install before originalMount to match.
The mount-throw catch already restores the console via teardown().

Verified red-first against real Ink v7.0.4 (audit e10): Ink's stderr
stays empty for a render-throwing component; vue-tui's initial-mount
warn reached a real PTY before this fix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:23:32 +08:00
Yunfei He 1bd91dbbb9 test(runtime): pin absolute-child position to the padding box; fix wording
Addresses PR review: the containing block for an absolutely-positioned child
is the **padding box** (inside the borders), not the "border-box". Verified by
running yoga (an abs child at top:0/left:0 insets by the border only, never by
padding — confirmed across border/padding combos) and the real Ink/vue-tui
renderers (X lands at the inner-border edge, byte-identical in both).

- Tighten the regression test: exact-frame assertions instead of `toContain`,
  including a border+padding case that distinguishes the padding box from the
  content box — the assertion that would have caught the original wording slip
  (presence-only assertions could not).
- Correct "containing block (border-box)" -> "padding box (inside the borders)"
  in paint.ts, layout-guards.ts, and ink-divergences.md. (The unrelated
  "border-box-like" *sizing* notes are correct and left as-is.)

No runtime behavior change; the code already used yoga's computed position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 0d50fb7e40 fix(runtime): paint position:absolute children in zero-content boxes
The zero-content-area guard (layout-guards + paint.ts) suppressed ALL
children of a Box whose inner content rect collapsed to zero, including
position:"absolute" children. An absolutely-positioned child is placed
against the containing block (border-box), not the content rect, so Ink
v7.0.4 paints it (verified by running real Ink: a w=2 h=2 single-border
box with an absolute child renders "┌┐#\n└X"); vue-tui dropped it,
rendering "┌┐#\n└┘".

- layout-guards: exempt POSITION_TYPE_ABSOLUTE children from the hide loop
  so they keep their layout.
- paint: move overflow-clip setup above the zero-content early-return and,
  in that branch, paint only absolute children (still clipped by
  overflow:hidden, matching Ink) while keeping in-flow children suppressed
  (the blessed degenerate-box divergence).

Flow-child suppression and overflow:hidden clipping both stay Ink-aligned
(verified byte-identical against real Ink). Known limitation: an absolute
descendant nested under a suppressed in-flow child is still dropped (the
flow ancestor is removed from layout) — scoped to direct absolute children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He ddd651b5e0 chore: bump all packages to 0.0.3 (#148)
Bump @vue-tui/runtime, @vue-tui/cli, and @vue-tui/testing from 0.0.2 to 0.0.3.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:59:34 +08:00
Yunfei He 1fd832d297 fix(runtime): align Ink parity behavior
Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.

Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 12:51:20 +08:00
Yunfei He 1340c049b2 fix(runtime): call onRender before frame writes
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 21:04:34 +08:00
Yunfei He 65ac088001 fix(runtime): align stdout writability guards
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:56:25 +08:00
Yunfei He 941fff1845 fix(runtime): track useFocus autoFocus updates
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:45:57 +08:00