453 Commits

Author SHA1 Message Date
Yunfei He 3e44c9a266 feat(runtime): add mouse input API (#245)
CI / Check, build & test (push) Has been cancelled
* docs: record mouse input API design

* feat(runtime): add mouse input API

* fix(runtime): align mouse edge cases

* fix(runtime): suppress click after drag

* refactor(runtime): use template refs for dragging

* docs: record mouse API follow-ups

* fix(runtime): align useDraggable semantics

* fix(runtime): align mouse composable APIs

* fix(runtime): keep mouse input handler refs explicit

* fix(runtime): clean up mouse mode state

* fix(examples): use templates for mouse demo
2026-07-06 01:20:27 +08:00
Yunfei He 0a774ddc8c refactor(vite)!: make @vue-tui/vite dev-only; build apps with tsdown (#247)
`vite build` is a browser-first bundler, so producing a runnable Node program
from it meant constantly overriding its defaults (platform, externalization,
inlineDynamicImports, module-preload). Building a Node app is a Node bundler's
job — so split vue-tui's two jobs across two tools:

- @vue-tui/vite is now DEV-ONLY (in-terminal dev server + HMR). Removed
  buildConfigPlugin and the externalize predicate: deleted src/build.ts,
  src/external.ts (+ external.spec.ts) and test/build.sequential.test.ts.
  vueTui() returns just the dev + dev-vmod plugins, and entry normalization is
  dev-only; dev.spec.ts updated.
- Production builds move to tsdown + unplugin-vue: a plain tsdown.config.ts that
  bundles the whole app into one self-contained Node file (dist/*.mjs) runnable
  with no node_modules. platform:node keeps builtins external and emits a real
  createRequire for CJS deps; deps.alwaysBundle inlines everything else.
- Migrated all five examples to tsdown (including flappy-bird, which hand-rolled
  a vite lib-mode self-contained build), and rewrote the examples smoke suite to
  build via tsdown and launch each self-contained bundle from an empty sandbox.
- Added tsdown + unplugin-vue-jsx to the catalog; updated the root and
  @vue-tui/vite READMEs.

BREAKING CHANGE: @vue-tui/vite no longer configures `vite build`. Build the app
with tsdown instead (see the @vue-tui/vite README, examples/*, and the starter).
2026-07-06 00:29:28 +08:00
Yunfei He e5015b0a80 fix(vite): resolve @vue-tui/vite from CommonJS Vite configs (#243)
* fix(vite): resolve @vue-tui/vite from CommonJS Vite configs

When a consumer project is CommonJS — package.json "type": "commonjs", or
no "type" field at all (Node defaults to CommonJS) — Vite loads vite.config.ts
as CommonJS and resolves its imports under the `require` condition.
@vue-tui/vite's exports used an `import`-only conditions object with no
fallback, so nothing matched under `require` and Vite threw:

    [plugin externalize-deps] Failed to resolve "@vue-tui/vite".
    This package is ESM only but it was tried to load by `require`.

Switch the export to a bare string (plus "./package.json"), matching the
other @vue-tui/* packages and @vitejs/plugin-vue. A string export is
condition-agnostic, so it resolves under both `import` and `require`; Node
then loads the ESM file via require(ESM), which every supported Node
(>=22.18) provides. No CommonJS build is added — this mirrors how the Vite
plugin ecosystem ships today (all ESM-only).

Add a regression test (test/cjs-config.test.ts): a `type: commonjs` fixture
whose vite.config.ts imports @vue-tui/vite by name, loaded through the real
config loader via resolveConfig(). It fails with the exact #238 error on the
old exports and passes with this change.

* chore(lint): exclude test fixtures from linting

Test fixtures are test INPUTS, not shipped source, and some are deliberately
broken: the overlay / full-reload dev-server tests transiently overwrite a
fixture's app.vue with a syntax error (to exercise the error overlay / failed
HMR), then restore it. Because `vp run ci` runs lint concurrently with those
tests, a linter that read a fixture inside that broken window failed with a
spurious "Unexpected token" — a flaky race whose surfacing depended on test
timing. Add lint.ignorePatterns ["**/test/fixtures/**"] to remove the race
(and the wasted lint work on test data).
2026-07-05 18:30:31 +08:00
Yunfei He 8bc7976561 feat(components): scroll ScrollBox through an imperative handle (drop wheel/keyboard) (#242)
ScrollBox drops its `wheel`, `keyboard`, and `linesPerWheel` props and instead
exposes an imperative handle (`ScrollBoxExpose`): scrollToLine / scrollByLines /
scrollToTop / scrollToBottom. It listens to no mouse or keyboard input itself —
the consumer wires its own bindings to the handle.

Built-in input is deferred because best practice isn't settled: the mouse wheel
needs terminal mouse tracking (which suppresses native text selection
window-wide), and keyboard input is global (collides with a focused field).
Shipping only the scroll mechanism keeps the component honest and lets the app
own input policy. The core bounded, sticky-following viewport still works with
no props.
2026-07-05 16:09:09 +08:00
Yunfei He f9f9f319a6 docs: record package layers and dependency direction (#241)
* docs: record package layers and dependency direction

* docs(components): mouse input is no longer absent (landed in #237)
2026-07-04 12:01:39 +08:00
Doctor Wu d8d9296905 feat(components): add ScrollBox component (#237)
* feat(runtime): add ScrollBox component

* refactor(components): move ScrollBox into components package

* feat(runtime): add mouse input composable

* refactor(components): delegate ScrollBox mouse input

* docs: describe mouse input composable

* fix(runtime): consume unsupported SGR mouse input

* fix(components): gate ScrollBox input on raw mode support

* fix(runtime): require escape prefix for SGR mouse input

* refactor(components): rename ScrollBox input props to wheel/keyboard

Follow the components boolean-prop convention (bare noun, default false):
enableMouse/enableKeyboard/isActive -> wheel/keyboard, both opt-in. Mouse-
wheel is off by default because enabling terminal mouse tracking suppresses
the terminal's native text selection window-wide. Record the convention in
components-design-principles.md.

* refactor(components): rename ScrollBox linesPerWheel + input tests

- Rename the wheel-step prop wheelLines -> linesPerWheel.
- Drop Home/End keyboard scroll for now — keyboard is PageUp/PageDown only.
- Add tests: keyboard paging, and SGR mouse-mode disable on signal-exit
  (fs.writeSync path, mirroring the bracketed-paste test).

* feat(examples): add ScrollBox streaming demo

A streaming-log demo of <ScrollBox wheel keyboard>: new lines arrive on a
timer and stick to the bottom until you scroll up (wheel / PageUp / PageDown),
then hold position while output keeps arriving. Press q to quit.

---------

Co-authored-by: Yunfei He <i.heyunfei@gmail.com>
2026-07-04 11:25:50 +08:00
Yunfei He 2eb4c4e3c7 docs(readme): show npm version badges for each package (#239)
* docs(readme): show npm version badges for each package

Replace the centered text-link package row with shields.io npm version
badges, one per published package (runtime, components, vite, testing).
Each badge auto-updates from npm, is labeled with its package name, and
keeps its existing npmx.dev link target. The private @vue-tui/runtime-tests
package is omitted since it isn't published.

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

* docs(readme): color npm version badges with Vue brand green

Apply &color=42b883 (Vue's brand green) to each shields.io npm version
badge so the row matches the project's branding.

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-30 07:14:42 +08:00
Yunfei He d2af0c83b8 docs(readme): present both usage modes in Quick Start (#233)
* docs(readme): present both usage modes in Quick Start

Quick Start now covers the two ways to use vue-tui:

1. Scaffold a project (the @vue-tui/vite template) — Vue SFCs + terminal HMR
   dev server + vue-tsc.
2. Use @vue-tui/runtime standalone — no plugin, no build step; components as
   h() render functions, run with `node app.mjs`.

Both snippets are verified to run against the published packages. Folds in the
former "Add to an existing project" + "Example" sections and drops the now-stale
Example entry from the table of contents.

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

* docs(readme): tighten Quick Start prose

Drop the redundant build/type-check sentence from the template section, and trim
the standalone section's trailing explainers (the h()-vs-template note and the
Node TypeScript-runner paragraph) down to the renderToString one-liner.

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

* docs(readme): drop the renderToString aside from Quick Start

It's a separate (non-interactive) capability — tangential to the Quick Start's
goal of getting an interactive app running. Belongs in API docs, not here.

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

* docs(readme): reframe standalone mode (decoupled from plugin, not "no build")

"no plugin, no build step" was misleading — TS/SFC/JSX all need a compile step
(same as Ink's JSX), so "no build" isn't a real differentiator. The actual point
is that @vue-tui/runtime is a standalone renderer and the @vue-tui/vite plugin
only adds the SFC + HMR dev workflow on top, so the runtime can be used on its
own in any existing project.

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

* docs(readme): mode 1 code-free; mode 2 uses SFC (not h) + JSX note

- Scaffold (recommended): drop the component snippet — keep it to the commands.
- Standalone: author with an SFC and mount with createApp (not h() render
  functions), and note JSX via @vitejs/plugin-vue-jsx. Verified an SFC builds and
  renders with plain @vitejs/plugin-vue (no @vue-tui/vite).

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

* docs(readme): bullet the standalone tooling notes; clearer plugin line

Split the two trailing sentences into bullets, and rewrite the @vue-tui/vite
line to be direct: it does the SFC setup for you and adds terminal HMR (option 1).

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

* docs(readme): frame @vue-tui/vite around HMR, not "SFC setup"

The standalone tooling note implied @vue-tui/vite's job is the SFC compilation.
Its value is the terminal HMR dev server; reframe the bullet accordingly (it
bundles plugin-vue only as the mechanism that makes terminal HMR work).

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

* docs(readme): reword the HMR bullet to read naturally

Replace the clipped "— that's option 1" with a normal sentence.

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

* docs(readme): drop the awkward "option 1" callback in the standalone note

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

* docs(readme): show the concrete [vue(), vueTui()] config in the HMR note

Now that @vue-tui/vite no longer bundles plugin-vue, make the standalone-mode
HMR bullet concrete: you compose them, compiler first.

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

* docs(readme): "HMR support" wording in the standalone note

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-29 00:17:07 +08:00
Yunfei He a181462c37 refactor(vite): signal app-exit over the hot channel, not a process-global (#235)
Replace globalThis.__VUE_TUI_TEARDOWN__ with an in-process hot-channel event:
the runtime emits "vue-tui:exit" (notifyDevExit) when the app genuinely exits,
and the dev plugin closes the server via server.environments.ssr.hot.on(...).
Removes a cross-package process-global using public Vite APIs; the per-server
listener is GC'd with its server (no global slot for a sibling to clobber).

From the @vue-tui/vite "hack" adversarial review, this was the one item flagged
as a clean, avoidable improvement. The other reviewed mechanisms
(force-client-compile, the bindCLIShortcuts no-op, the entry-inject endsWith
match, and the bridge-hmr ws.send forward) are load-bearing and kept as-is. In
particular the bridge-hmr "file-changed -> hotUpdate" reduction was re-examined
adversarially and REJECTED: plugin-vue sends file-changed UNCONDITIONALLY as
metadata (rerender-vs-reload lives in the transformed module's _rerender_only); a
hand-rolled hotUpdate would have to re-derive normalizePath(filename) to match
the client's exact-string compare (any drift -> template edits silently fall back
to a state-resetting reload), and the error leg still needs the ws.send patch.
Net: more code + a platform-dependent regression risk for zero gain.

vp run ready green; the teardown change adversarially reviewed (ship-as-is).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:59:13 +08:00
Yunfei He 14d5e5f9ad feat(vite)!: don't bundle @vitejs/plugin-vue; compose it explicitly (0.2.0) (#234)
* feat(vite)!: don't bundle @vitejs/plugin-vue; compose it explicitly

BREAKING: vueTui() no longer includes @vitejs/plugin-vue. Add your own SFC/JSX
compiler alongside it — `[vueTui(), vue()]` for SFCs (JSX was already explicit:
`[vueTui(), vueJsx()]`). This makes SFC and JSX consistent, matches Vite
convention (your framework plugin is visible in the config), removes the "is
plugin-vue already bundled?" ambiguity, and keeps @vue-tui/vite focused on the
terminal dev server (HMR) + build wiring.

The dev plugin already force-client-compiles whichever plugin-vue/plugin-vue-jsx
is present (matched by name in configResolved), so user-provided plugins work
identically to the previously-bundled one.

- index.ts: drop the bundled vue() and the `vue` passthrough option.
- package.json: @vitejs/plugin-vue moves from dependencies to devDependencies +
  an OPTIONAL peerDependency (^6) — optional because JSX (plugin-vue-jsx) and
  h() users don't need it; the version is still validated when present.
  Bump 0.1.2 -> 0.2.0.
- Update all in-repo SFC configs (examples + fixtures) and the dev/build tests
  to `[vueTui(), vue()]`; update basic-template's README.

vp run ready green (vite suite 129 tests, runtime examples 6).

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

* test(vite): use the public [vueTui(), vue()] form in dev tests

The decouple migration left the tests on the spread `[...vueTui(), vue()]` while
configs/examples/docs use `[vueTui(), vue()]` (Vite flattens the nested plugin
array — verified it works in createServer too). Align the tests to the
documented consumer form for consistency. Vite suite green (12 files, 28 tests).

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

* refactor(vite): compiler plugin first in order — [vue(), vueTui()]

Convention: list the SFC/JSX compiler first and vueTui() last
([vue(), vueTui()] / [vueJsx(), vueTui()]). Order is functionally irrelevant
(devPlugin force-compiles whichever plugin-vue/vue-jsx is present by name in
configResolved), so this is purely stylistic consistency across configs,
examples, fixtures, tests, and docs.

Also refresh @vue-tui/vite's own README, which still showed the pre-decouple
[vueTui()] form and documented the now-removed `vue` passthrough option.

Vite suite green (28 tests, incl. the JSX path).

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 22:41:28 +08:00
Yunfei He 9f2529ba31 fix(vite): ship type declarations for @vue-tui/vite (#232)
The published package shipped only dist/index.mjs (no .d.mts), so every
TypeScript consumer got `any` for vueTui() — TS7016 under noImplicitAny — and a
vue-tsc-based project's type-check failed on `import { vueTui } from
"@vue-tui/vite"`. The pack config was missing `dts`, unlike @vue-tui/components.

- vite.config.ts: add `dts: true` so `vp pack` emits dist/index.d.mts
  (declaring vueTui + VueTuiOptions).
- package.json: add a `types`-first exports condition; bump 0.1.1 -> 0.1.2.

Verified: build emits dist/index.d.mts; a vue-tsc consumer importing vueTui
type-checks clean (was TS7016). `vp run ready` green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:32:19 +08:00
Yunfei He e343f0df44 docs(readme): point Quick Start at the new vue-tui-starter/vite template (#231)
The starter repo now hosts templates in per-template subdirectories
(vuejs-ai/vue-tui-starter#2), so scaffold from the `vite` subdirectory. Also
correct the edit hint to the real path (src/app.vue).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:08:48 +08:00
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 8aea881ffa feat(components): add @vue-tui/components + Spinner (first component) (#229)
* feat(components): scaffold @vue-tui/components with spinner preset data

New private package (0.0.0) for high-level components composed from runtime
primitives. Ships the dots/line preset data + a pure resolveSpinner() with
edge-guards (empty frames / unknown type → dots; interval threads both modes),
fully unit-tested incl. a width-safety guard (string-width === 1 per frame).

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

* feat(components): add the Spinner component

Spinner is a <Text> + useAnimation pure composition: `type` selects an inline
preset (dots/line), `frames`/`interval` is the escape hatch, and it always
animates (no interactivity gate — there is no public signal; matches Ink).
Renders a visible glyph non-interactively. Typed props via ExtractPublicPropTypes.

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

* feat(components): Spinner color + label

`color` tints the glyph only (label stays default, matching ora/@inkjs/ui);
`label` renders after the glyph with a separating space (interpolated so Vue
whitespace-condense keeps it). Two <Text> spans share an outer <Text> context so
they render inline on one line rather than stacking vertically.

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

* chore(components): add @vue-tui/components to the CI task graph

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

* docs(components): record Spinner decisions

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

* docs(components): refresh package status and clarify ink-spinner parity note

The design-principles status blockquote said the package was 'planned'
with no code yet; this branch ships Spinner, so mark it active. Also
reword the spinner Behavior note to name the third-party ink-spinner
explicitly and soften it to an unverified, un-run-checked observation.

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

* docs(readme): list @vue-tui/components + <Spinner>

Add the new package to the hero line + Packages table, and <Spinner> to the
Components table. Marked "New; early".

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

* docs(readme): give @vue-tui/components its own section

Move <Spinner> out of the runtime Components table into a separate
"High-level Components" section so the package's API surface stays decoupled
from the runtime primitives. Add a ToC entry.

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

* docs(readme): drop the "New; early" status tag for @vue-tui/components

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 16:42:01 +08:00
Yunfei He 7a8ccf3655 feat(examples): build flappy-bird as a self-contained single file (#223)
flappy-bird's end goal is a distributable binary; as a stepping stone it
builds a single dist/game.mjs that `node` runs on its own, with no
node_modules present. It's the example that demonstrates that ideal.

Bundle everything that CAN be bundled — vue, chalk, @vue-tui/runtime, yoga's
base64-inlined wasm, the SFC — and externalize ONLY Node builtins:

- The external predicate is `(id) => isBuiltin(id)`, inline in vite.config.ts
  (no separate file for a one-liner). It has no relative/absolute path
  heuristics, so the Windows path footgun behind vue-tui#209 can't exist here.
- rollupOptions.platform = "node" so rolldown emits a real
  createRequire(import.meta.url) for a CJS dep's require() instead of a stub
  that throws at startup (stack-utils does `require("module").builtinModules`
  at module load — the #212 fault class).

Guard the property that actually matters in examples-smoke: build flappy-bird,
copy ONLY game.mjs into a fresh temp dir with no node_modules, and launch it
there — it must paint. Verified red->green: dropping platform:"node" brings
back the throwing require shim (build-shim check fails) and re-externalizing a
dep is ERR_MODULE_NOT_FOUND in the empty sandbox.

Closes #209. Supersedes #210 (which targeted the now-removed @vue-tui/cli).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:47:27 +08:00
Yunfei He 830cd29355 fix(vite): yield the build external to the consumer (Vite 8 rolldownOptions) (#227)
The build plugin always set the external predicate, and Vite merges a plugin's
config() OVER the user config — so a consumer who set their own external (e.g. to
bundle everything into one self-contained file for a binary) was silently
clobbered and couldn't change the build's distribution shape at all.

Distribution shape — externalize bare deps vs bundle a self-contained file — is
the app author's call, not the plugin's. Keep the DEFAULT (externalize deps; the
library / app-shipped-with-node_modules shape), but YIELD: only set our external
when the consumer didn't provide one.

Vite 8 is Rolldown-powered, so the build field is `rolldownOptions` (`rollupOptions`
is the deprecated alias). Emit rolldownOptions, and detect the consumer's external
under EITHER name (the alias proxy isn't wired up yet inside the config() hook).

Now a consumer using vueTui() (for dev/HMR) can opt into a self-contained build in
their own vite.config.ts:
  rolldownOptions: { external: (id) => isBuiltin(id), platform: "node",
                     output: { inlineDynamicImports: true } }

Verified by running: the build runs from an empty dir with no node_modules (single
file). New test asserts the consumer's external takes effect; red->green confirmed.
Default-externalized test still holds; dev.spec reads the rolldownOptions field.
vp run ready green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:36:37 +08:00
Yunfei He 6609aac4d1 docs(components): add @vue-tui/components design principles & conventions (#225)
New PCR recording how components in the planned @vue-tui/components package
should be shaped and styled, and the bar for adding one. Design intent only —
no package code exists yet.

- governing idea: components are pure compositions of runtime primitives; the
  runtime owns the terminal-I/O and layout/commit boundary
- demand-driven inclusion bar ("Ink has it" is not a reason on its own)
- the runtime <-> component boundary litmus test
- Vue-idiomatic / Ink-inspired (borrow behavior, not React signatures)
- pure composition on the public barrel, never /internal
- type-friendliness: Volar / vue-tsc must catch misuse at compile time
- idiomatic patterns (defineModel, default-slot + JSX children, handler-prop
  forwarding) as reference, with the two correctness constraints marked
- deliberately omits an a11y requirement

Cross-links the existing PCRs (api-contract, ink-divergences, component-authoring,
accessibility-api) instead of restating them. Adversarially reviewed.

Relates to #218.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:29:57 +08:00
Yunfei He 16e49024da test(runtime-tests): guard the shipped examples launch+paint in CI (#212) (#220)
* fix(examples): restore flexDirection column on basic-template

A past restyle (#137) replaced the root Box's `flexDirection="column"
:paddingX="1"` with `backgroundColor/borderStyle/width="20"` and dropped
the column direction. With the Ink-aligned default (row) and a fixed
`width="20"`, the six children pack into ~3-char columns and the title
interleaves illegibly. Restore `flexDirection="column"` so the example
renders as the intended bordered card.

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

* test(runtime-tests): PTY smoke suite guarding the examples launch+paint (#212)

#212's `Calling \`require\` for "node:module"` crash came from the old
@vue-tui/cli bundledDev step. The @vue-tui/vite plugin (#215) removed it,
but nothing exercised the shipped examples end to end, so a future
regression could break "the examples run" silently.

Add a node-pty smoke suite that launches basic-template and basic-jsx
through both the dev server (`vite`) and the production build
(`node dist/main.js`) under a real TTY and waits for the frame to paint;
coding-agent (needs an LLM key to run) gets a key-free build guard. A
static `CJS_REQUIRE_SHIM` assertion on the built bundle locks the #212
invariant directly, and `[vue-tui] failed to launch` / process-exit
signals fail a broken launch fast instead of burning the render timeout.
Wired in as the `ci:test:examples` branch of the CI graph.

Verified RED->GREEN: injecting a bare `require()` into an entry
reproduces #212 exactly and the guard catches it (statically and at
runtime); a non-module throw is caught via the dev launch-failure signal.

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

* refactor(runtime-tests): simplify the examples smoke harness

Cleanup pass on the new #212 smoke suite (no behavior change):
- use the `strip-ansi` package instead of a hand-rolled CSI regex (already a
  devDep here and used across the suite; strips OSC too, drops the eslint
  no-control-regex suppression);
- drop the onExitWaiters wake-up set — the 100ms poll already observes the exit
  flag, so onExit only needs to record the code;
- fold the three reject sites into one `fail()` helper;
- give the #212 bundle-shim invariant a single home: move CJS_REQUIRE_SHIM next
  to the builder and factor `buildAndExpectNoCjsRequire`, shared by the runnable
  apps and the coding-agent build guard;
- drop dead exports (repoRoot, CRASH_SIGNATURE).

Re-verified RED->GREEN (bundled require still caught) and all 5 tests green.

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

* test(runtime-tests): serialize the examples suite files (match #222)

Adversarial review flagged the examples config's parallel-safety comment as
imprecise: PTY-process isolation does NOT isolate on-disk state. Each launched
example writes its optimizeDeps cache (examples/<name>/node_modules/.vite) and
bundle (examples/<name>/dist), so two test files launching the SAME example at
once would race that shared dir — the exact #222 failure the sibling
@vue-tui/vite suite just fixed with fileParallelism:false.

It can't happen today (one serial file, per-example caches), but set
fileParallelism:false to match #222 and keep it safe as the suite grows, and
correct the comment to state the real guarantee.

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 03:01:10 +08:00
Yunfei He 345f4a5428 test(vite): serialize dev-server test files to fix flaky main CI (#222)
The @vue-tui/vite suite broke main CI intermittently: full-reload.sequential's
"a genuine app exit closes the in-process dev server" failed with
"Test timed out in 5000ms" alongside "[vue-tui] failed to launch /src/main.ts"
("transport was disconnected, cannot call fetchModule").

Root cause: every *.sequential.test.ts boots a live Vite dev server, and the
fixtures have no local node_modules, so every server resolves the SAME
optimizeDeps cache dir (packages/vite/node_modules/.vite). Under the monorepo
default fileParallelism:true two dev-server files run concurrently — in SEPARATE
processes (verified: distinct pids, no shared globalThis), so the shared resource
is the FILESYSTEM, not a JS global — and their dep optimizers race on .vite/deps.
Locally this surfaces as "ENOTEMPTY: rmdir .vite/deps"; on the contended CI runner
a sibling's re-bundle invalidates a server's cache and restarts its module-runner
transport mid-import, so the exit fixture never launches and the test hits the 5s
timeout. Reproduced ~30% under CPU saturation; 0/25 after the fix.

- test.fileParallelism:false — keep exactly one dev server (and one bound port)
  alive at a time, which the per-file headers already state is required. Off the
  CI critical path (build->PTY dominates), so wall-clock is unaffected.
- test.testTimeout:15000 — defense-in-depth for the same failure message: the
  dev-server tests poll with waitUntil (8000ms budget), ABOVE Vitest's default
  5000ms test timeout, so a slow cold-optimize boot was killed by the framework
  before the helper's own diagnostic could fire. Raise the ceiling above the
  helper budget (mirrors overlay.sequential's 15000ms).

Test-config only; no production code changed. vp run ready green
(lint, type, 27 vite + 1290 runtime + 129 PTY, build).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:23:46 +08:00
Yunfei He c05e5a169a test(vite): add #214 regression test for dev-mode Text color loss (#219)
vue-tui#214 ("macos zsh 开发模式 Text 组件颜色丢失") — `vue-tui dev` rendered
<Text color>/<Box borderColor> with no ANSI color while the built bundle showed
color — was fixed by the @vue-tui/cli -> @vue-tui/vite cutover (#215): the new
dev path runs the app in Vite's SSR runnable environment (Node resolve
conditions + externalized chalk) instead of bundling it in the browser-
conditioned client env, so chalk's #supports-color resolves to its node
implementation and does real TTY/FORCE_COLOR detection.

But the fix had no regression test (the old @vue-tui/cli color tests were
deleted with the package), and @vue-tui/vite's test config set no FORCE_COLOR,
so chalk was level 0 there and no color assertion could fire.

- Add test/fixtures/color (a <Text color="green"> app) + color.sequential.test.ts:
  boots the live in-process dev server and asserts the green SGR
  (\x1b[32mCOLORTEST\x1b[39m). Under the old browser-shim bug the codes never
  appear, so this fails in exactly the #214 failure mode (verified by mutation).
- Add test.env { FORCE_COLOR:"3", CI:"false" } to packages/vite/vite.config.ts
  (chalk locks its level at import time, so it must be set at the process level;
  mirrors packages/runtime-tests/vite.config.ts).

vp run ready green (lint, type, 1290 + 129 PTY tests, build). No production code changed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 01:36:13 +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 e25a89528b docs(readme): align the homepage status banner with the public-beta message (#208)
The top-level README carried a terser "Status" banner, while
packages/runtime/README.md had the fuller "Public beta" banner that
actually conveys the call to action — seeking public feedback to lock
the @vue-tui/runtime API down before 1.0. Bring the homepage in sync so
both read identically.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:43:43 +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>
runtime-0.1.0
2026-06-20 00:32:56 +08:00
Yunfei He c4b001c12f docs(divergences): broaden into an Ink relationship record; strengthen the alignment-is-a-means principle (#204)
ink-divergences.md used to record only divergences from Ink. Broaden it into the
single record point for the *whole* Ink relationship: it now also records deliberate
alignments (intentional sameness) as first-class entries, not just differences.

- Reframe the title + intro: three relationship kinds (deliberate alignments,
  intentional divergences, non-behavioral notes), each a conscious decision.
- Elevate + strengthen the governing principle: aligning to Ink is only a means to
  reduce bugs, never the goal — correctness and Vue philosophy outrank parity, and
  "because Ink does it" is never on its own a justification.
- Add a first-class "Deliberate Alignments" section; promote the two deliberate-
  sameness records (commit-timing Ink-alignment, literal-tab measure-vs-paint, the
  latter keeping its existing [VOUCHED @hyf0]) out of Non-Behavioral Notes into it.
- Restructure the classification flow to split deliberate match vs deliberate
  divergence first ("How to Classify an Entry").
- Update AGENTS.md guidance and the two stale cross-references to the renamed
  headings (accessibility-api.md + the in-file ARIA entry).

No existing divergence entry's substance changed; no [VOUCHED] stamp was added or
removed (AI cannot self-bless). Reviewed by Codex + an independent reviewer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:08:32 +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 e9cd66041f docs(divergences): trim the tab measure-vs-paint note (#202)
The note was overlong. Keep the essentials: string-width counts a tab as 0 but
paint expands it to the tab stop (ab\tcd measures 4, paints ~10); Ink-shared so
KEEP aligned; fix-if-needed pointer (expand at squash, upstream of string-width).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:46:34 +08:00
Yunfei He c8be3f5672 docs(divergences): record the tab measure-vs-paint width quirk (shared with Ink, KEEP) (#201)
A literal tab in <Text> is measured as 0 columns by string-width but painted at
its tab-stop width (wrap-ansi / terminal), so the reserved yoga width disagrees
with what's drawn (ab\tcd measures 4, paints ~10). This is a shared upstream
quirk — Ink v7.0.4 does the same and likewise doesn't normalize tabs — so it's
aligned with Ink, not a divergence; recorded under Non-Behavioral Notes so it's
not rediscovered as a parity gap. KEEP (literal tabs in TUI text are vanishingly
rare). The note also captures the fix direction if ever needed (expand tabs to
spaces at the shared squash chokepoint, upstream of string-width) and the one
behavior change that would then become the actual divergence. [VOUCHED @hyf0]

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:43:22 +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 fd81656c3d docs: adopt Project Context Records (PCR) for agent docs (#188)
Replace the homegrown "Context Engineering" convention with the canonical
Project Context Records (PCR) block in AGENTS.md, and migrate the
.agents/docs/ records to match.

- cross-links: [[wiki-link]] -> relative markdown [name](./name.md)
- provenance: the old "Maintainer decision (DATE): KEEP" markers -> canonical
  [VOUCHED @hyf0] stamps (dates dropped, KEEP/OVERRIDE verdicts kept), covering
  every variant ((DATE, user-blessed), (maintainer decision DATE), and
  "(Decision recorded after review surfaced it.)")
- methodology prose describing the mechanism reworded to the vouch vocabulary
  (generic [VOUCHED @handle])

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:08:12 +08:00
Yunfei He 900e4176f9 fix(cli): guard reload against shutdown to prevent orphan child (#183)
A vue-tui:request-reload arriving during the Ctrl+C shutdown window
spawned an orphan child the parent never kills. The reload path and the
shutdown path did not compose: handleReloadRequest only consulted the 3s
startup gate, never isShuttingDown, so a reload accepted during the
pm.shutdown() teardown window ran extractBundle then pm.restart() — which
schedules a fresh 100ms restartTimer. pm.shutdown() had already cleared
its own restartTimer at entry, so nothing cancels the new one; it fires
doSpawn() and creates a brand-new child after the parent has exited.

Fix mirrors respawnTick's existing two-check guard:
- Fast gate: compose shouldAccept as
  `() => acceptReloads && !shutdown.isShuttingDown()` so reloads are
  ignored once Ctrl+C teardown begins.
- Post-await guard (load-bearing): thread isShuttingDown into
  ReloadRequestDeps and re-check it after `await extractBundle` and
  before pm.setBundlePath/pm.restart, covering the case where shutdown
  starts mid-extraction.

The hot handler registration is moved after createShutdown() so the
forward reference resolves (closures read at event time regardless).
Scope is only this reload-vs-shutdown guard; respawnTick, createShutdown
internals, process-manager.ts, and bundle-extractor.ts are unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:44:15 +08:00
Yunfei He 8b10770dc8 fix(cli): clear respawn interval and guard shutdown re-entrancy (#181)
The dev-server shutdown path had two teardown bugs:

1. The crash-respawn setInterval was never cleared. During shutdown's
   async window (await pm.shutdown(); await server.close()), the 500ms
   interval kept firing; if the child had crashed it could call
   pm.spawn() and start a BRAND-NEW child while the parent was tearing
   down — orphaning that child when the parent exits.

2. shutdown was registered directly as the SIGINT/SIGTERM handler with
   no re-entrancy guard. Pressing Ctrl+C twice (common when shutdown
   feels slow) invoked shutdown() twice concurrently → double
   pm.shutdown()/server.close() and two racing process.exit(0).

Fix: extract a testable createShutdown(deps) factory that owns a
shuttingDown flag (2nd+ invocation is a no-op) and clears the respawn
interval FIRST, before the async teardown window. The respawn-tick body
is extracted into respawnTick(deps), which re-checks isShuttingDown
both before and AFTER the extractBundle await — closing the in-flight
window where a tick already mid-extraction could still spawn after
clearInterval. Both helpers are module-scoped (not re-exported from the
package public index.ts).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:13:54 +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