main
453 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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). |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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) |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
1d26a23222 |
docs(runtime): tighten the README status banner (#206)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |