14d5e5f9add8417d622a8dbf7fcb17ec3c38f625
253 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
8a2efdfeef |
fix(runtime): make initHmrBridge idempotent (#176)
initHmrBridge registered three Vite HMR listeners (vite:error, vite:beforeUpdate, vite:beforeFullReload) with no idempotency guard and is called once per createApp() (dev block in render.ts). createApp() can run multiple times in one dev process — two apps, an app that unmounts and is re-created, a tool that restarts the UI, or a test run — and Vite's Node HMR runtime APPENDS listeners with no dedup, so N calls leaked N copies of every handler permanently. Every later HMR event then ran each handler N times. Add a module-level boolean guard so the listeners register at most once for the module's lifetime, regardless of how many times initHmrBridge is called. Also parameterize the hot context (defaulting to import.meta.hot) so the body is reachable under vitest, where import.meta.hot is undefined. HotContext is a local structural type and import.meta.hot is read via a structural cast so the module type-checks even when imported directly from runtime-tests, whose tsconfig doesn't pick up env.d.ts's ambient ImportMeta.hot augmentation. Out of scope: the setTimeout stale-timer in the vite:beforeUpdate handler is a separate bug left exactly as-is. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fb43b6af8f |
fix(runtime): disable bracketed paste synchronously on signal exit (#173)
On the signal-exit teardown path signal-exit re-raises the signal
immediately after the callback returns ({alwaysLast:false}), so a
buffered async stream.write can be lost before the process dies.
teardown(true) already flushes show-cursor, leave-alt-screen and
disable-kitty synchronously via fs.writeSync, but the bracketed-paste
-disable escape \x1b[?2004l was still written with an async
stdout.write on both teardown sub-paths (usePaste's onScopeDispose
-> detach during originalUnmount(), and the stdin controller dispose
backstop). When dropped, the user's shell stays in bracketed-paste
mode and wraps later pastes in \x1b[200~ ... \x1b[201~.
Thread a sync flag through the paste teardown, mirroring kitty:
disableBracketedPaste(sync) writes via fs.writeSync(fd, ...) when sync;
the stdin controller dispose(sync) forwards it; teardown passes sync at
the dispose() call site. Because Vue's unmount runs detach (async, lost
on signal) before dispose() and zeroes the live count, dispose(sync)
re-issues paste-OFF synchronously whenever paste was ever enabled --
paste-OFF is idempotent, so the redundant write is harmless. The normal
(non-signal) unmount path stays async, unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
df843b67fe |
fix(runtime): validate custom borderStyle object shape at render (#172)
A malformed custom borderStyle OBJECT (e.g. `{ topLeft, topRight }` missing
`top`) — or a truthy non-string non-object value (e.g. a number from a JS
caller) — bypassed assertBoxValid's render-time check, which only shape-checked
the STRING form. It reached drawBorder, passed the `if (!chars)` guard, and
threw `Cannot read properties of undefined (reading 'repeat')` deep in the
post-flush PAINT pass — wedging Vue's scheduler instead of surfacing a
recoverable error, exactly the failure mode box-validate.ts exists to prevent.
Resolve borderStyle to a BoxStyle the same way paint's drawBorder does (string
-> cliBoxes[name], object -> directly), then shape-check the result: every one
of the 8 glyphs paint reads (top/bottom/left/right + the four corners) must be
a string. Any invalid value now throws a clean error AT RENDER, caught by the
error boundary — like the existing unknown-string case. The string case keeps
its "Unknown borderStyle:" wording; the object/non-string case uses "Invalid
borderStyle:".
Test-first: borders.test.tsx now asserts a malformed object, a number, each
individually-missing glyph, and a present-but-non-string glyph all reject at
render (not the opaque paint TypeError), and a complete custom object still
paints a border.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c37ac910f4 |
fix(runtime): run teardown on synchronous mount() throw (#169)
mount() registers the app as the stdout owner (liveInstances.set) and then runs holdRawModeForLifetime(), kittyController.init(), and attachYoga()/ setWidth() — all of which can throw SYNCHRONOUSLY on a hostile terminal (setRawMode raises ERR_TTY_INIT_FAILED on some SSH/container PTYs that report isTTY=true; kitty enable's stdout.write can throw on a broken stream) — BEFORE the originalMount try/catch and before the exit/signal handlers are wired. A throw there skipped teardown(), leaving the liveInstances entry forever (poisoning the stdout: every later mount() hit the reuse guard and became an inert no-op), leaking the yoga root, and leaving raw mode / kitty on. Wrap those pre-mount steps in the same teardown-then-rethrow guard as originalMount. teardown() is idempotent and safe at this early stage (it derives all cleanup from the wired state set so far and guards on mountedAppContext). Also: assign mountedKittyController BEFORE init() so an auto-mode detection-query throw (after the stdin listener + timer are installed) is disposed, and record mountedRoot right after attachYoga (before setWidth) so the just-allocated yoga node is freed on a setWidth throw. The original error always survives and is rethrown to the caller. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a2d33d98fe |
fix(runtime): gate per-edge border width on borderStyle (align Ink) (#168)
Per-edge border props (borderTop/Bottom/Left/Right) reserved 1 yoga cell whenever truthy, regardless of borderStyle. Since these props default to `true`, toggling one on an UPDATE while borderStyle stays unset (Vue patches only the changed per-edge prop, not borderStyle) left a spurious 1-cell inset with no border ever drawn — content shifted to "\n HELLO" instead of "HELLO". Mirror Ink's applyBorderStyles: an edge's width is `borderStyle ? 1 : 0`, forced to 0 when that edge is explicitly `false`. A per-edge toggle can only SUBTRACT, never add. The per-edge yoga setters become no-ops; patchProp now recomputes all four edges from el.props on any border-prop change via the new reconcileBorderEdges helper, so borderStyle flipping in EITHER direction (set->unset zeroes, unset->set re-reserves) and per-edge toggles are all handled jointly — the computation a single (n, v) yoga setter cannot do. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
33da2e5073 |
docs(runtime): correct box.vue $el comment (fragment anchor, subtree-drilled)
Reviewer caught a stale comment: it claimed the root-`v-if` fragment's `$el` "resolves to the real host node". It doesn't — `$el` is the fragment boundary anchor; measureElement/useBoxMetrics resolve a Box ref by drilling the component subTree to its first host node. Also fixed a leftover `box.ts` reference (now box-validate.ts). Comment-only; code was correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3a029aa684 |
docs(runtime): record TuiNode-via-TuiApp as accepted incidental exposure
Review (Codex) flagged that `TuiApp extends Omit<App<TuiNode>, "mount">` surfaces the internal `TuiNode` host-node type in the published .d.ts (it rides out on Vue's internal `App._container`). Decision: KEEP it / don't fix. Rationale: `_container` is a Vue-internal field no consumer touches, so the exposure is purely cosmetic (zero functional impact), and type-only surface isn't held to strict SemVer, so it imposes no real contract. Hiding it (`App<unknown>` or a `Pick<App, …>` allowlist) is ceremony for a cosmetic gain on a pre-1.0 lib. Documented at the TuiApp definition and in api-contract.md so it isn't re-flagged. No behavior/type change — just a conscious-decision record. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3aadf55a3e |
docs(runtime): fix host-tag comment drift after tui- prefix rename
Adversarial review found stale bare-host-tag references the per-file sed couldn't reach (they live in comments/docs). Code was clean — no contamination, no public API leakage, root/text-leaf/comment asymmetry consistent. Updated: - vite.config.ts isCustomElement comment (<box>/<text> -> <tui-box>/<tui-text>) - component-authoring.md split-table Text row (virtual-text/text -> tui-*) - box.vue / useBoxMetrics.ts / use-box-metrics.test.tsx "the real `box` host node" comments -> `tui-box` Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ca63a5d04c |
refactor(runtime)!: prefix host primitive tags with tui- (align to Ink, drop *Impl)
The renderer's intrinsic elements were named with bare words (box/text/static/
transform/virtual-text), which collide with the same-named public components: a
template `<box>` PascalCase-resolves to `<Box>` under vue-tsc (no isCustomElement
at the type layer), forcing the BoxImpl/TextImpl/StaticImpl workaround.
Prefix the 5 host elements to `tui-*` (mirroring Ink's `ink-box`/`ink-text`):
the prefix + hyphen keeps them in their own namespace, so the components keep
their real names (Box/Text/Static) with no self-recursion — the *Impl rename is
removed. root/text-leaf/comment stay unprefixed (not template tags, not elements).
Mechanics: renamed the TuiNode discriminant literals + factories first, then let
vue-tsc enumerate all 145 stale `node.type === "box"` comparisons (the type-
driven finder also kept `position: "static"` and the ansi-tokenizer's separate
`type: "text"` union untouched). Updated createElement cases, HOST_TAGS,
the .vue templates, transform.ts h(), and raw `h("box")` host-op tests.
Two non-type-checked contaminations the sed caused were caught by tests and fixed:
- patchProp's `key === "transform"` (the PROP name, not the node type) must stay
"transform" — the sed wrongly prefixed it, dropping the transform fn (identity).
- text-measure's `token.type === "text"` is an AnsiToken, not a TuiNode — reverted.
BREAKING CHANGE: the internal host element names are now tui-box/tui-text/
tui-virtual-text/tui-static/tui-transform. Public components (Box/Text/Static/
Spacer/Newline/Transform) and their props/types are unchanged; only raw host-op
callers (h("box") -> h("tui-box")) are affected.
vp run ready green: fmt, lint 0/0, vue-tsc, tests (runtime 350, integration 1161,
PTY 129).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
9fe6d7cc44 |
refactor(runtime): author public components as template SFCs (+ integrate main)
Rewrites Box/Text/Spacer/Static/Newline from h()/render functions to Vue <script setup> template SFCs (Transform stays a render fn — it inspects its own child vnodes), with vue-tsc-verified consumer types (template + JSX fixtures), provide/inject text context, the always-validate Text divergence (color + backgroundColor), and three renderer fixes the SFCs surfaced (static anchor skip, transform line-index Ink-parity, useBoxMetrics subtree drill). Integrates the five main commits landed after the branch point: #163 public-API audit, generic Static scoped-slot typing, foreground color validation, useWindowSize/divergence docs. Squashed from the SFC sub-commits + the two main-integration merges to keep a linear, rebaseable history. See PR #165 for the full breakdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f847e17c81 |
docs(runtime): finish the useWindowSize rename in docs; tidy contract guards (#164)
Follow-up cleanup for the 7 confirmed findings from a review of #163. The dominant theme: #163 hard-renamed the public composable useTerminalSize -> useWindowSize (no alias) but left stale references to the dead name in user-facing docs. - README.md + packages/runtime/README.md: the composable tables named the removed `useTerminalSize()` (root README even framed the sole real export `useWindowSize` as an "Ink-compat alias" — now inverted). Point both at `useWindowSize()`. - .agents/docs/ink-divergences.md: two vue-tui-side references to `useTerminalSize` (the shallowRef "object of refs" example and the "composables throw outside a render tree" list) -> `useWindowSize`. The Ink-side `useWindowSize -> WindowSize` naming example is left unchanged. - .agents/docs/accessibility-api.md: the intro cited three "blessed entries" but only aria-camelCase is one; `renderToString` layout-only and the `useWindowSize` name are now Ink parity, not divergences. Reword. - .agents/docs/api-contract.md: tighten the `/internal` wording — the test does assert one tripwire on `/internal`, so "not covered by public-api.test.ts" was imprecise. - public-api.test.ts / render-to-string.test.tsx: the public renderToString dropped the `isScreenReaderEnabled` option but (unlike the sibling `ScreenReaderOptions` type) had no compile-time guard. Replace an obscure, fmt-fragile type-indexing guard with a readable call-site `@ts-expect-error` in render-to-string.test.tsx; re-adding the option to the public RenderToStringOptions makes the directive unused and fails `tsc --noEmit`. - Rename terminal-size.test.tsx / .sequential.test.tsx -> window-size.test.tsx / .sequential.test.tsx to match the migrated symbol. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0b61ff2bf4 |
refactor(runtime)!: public-API audit follow-ups — align to Ink, record decisions (#163)
* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions
Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.
Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.
BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle
Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.
- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.
- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).
- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).
- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.
BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: move renderScreenReaderOutput to /internal-only
The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.
`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.
public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.
Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.
BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(runtime-tests): snapshot the exact public value-export set
Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.
Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
dab5125c90 | fix(runtime): type Static scoped slots | ||
|
|
5d29240df9 | fix(runtime): validate invalid foreground color props | ||
|
|
d1fe39f96c |
fix(runtime): reject non-Error throws with the message ErrorOverview displays (#158)
A thrown non-Error whose .message is a string (throw {message:'x'})
displayed 'x' in the ErrorOverview but rejected waitUntilExit() with
new Error(String(value)) = '[object Object]' — display and reject
disagreed. Introduce one messageForNonError(value) helper (string
.message else String(value)) and feed it to BOTH the overview header
and the two non-Error reject-wrap sites, so the shown and rejected
messages can never drift. Overview output is byte-identical (the helper
is the prior inline logic extracted); real-Error, cross-realm, and
no-synthetic-stack paths are unchanged.
Blesses vue-tui's uniform show-the-error-and-reject behavior for any
thrown value (audit e17): Ink instead resolves waitUntilExit() with a
truthy thrown value and silently hangs on a falsy throw — abnormal, so
vue-tui deliberately diverges. Ledger entry rewritten to the full
run-verified scope with Maintainer decision (2026-06-12): KEEP.
Red-first: a consistency test asserting throw {message:'objmsg'} shows
AND rejects 'objmsg'.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
86b94b9fa5 |
fix(runtime): re-assert the declared cursor every commit (persistent declaration) (#157)
A focused input's caret zombied to the bottom-left corner whenever an
unrelated repaint (spinner tick, log line, progress bar) committed
without re-declaring the cursor: the active cursor was gated on a
per-commit dirty/reference change, so an unrelated commit dropped it.
Real terminal programs that own an edit point re-place the caret there
every frame (vim emits an absolute CUP after each repaint, readline
re-lands the buffer offset on SIGWINCH, nano homes to its edit cell).
Match that: the runtime now re-emits the last-declared caret at the end
of every commit until the declaration changes or is cleared, so the
caret survives unrelated repaints in all component topologies. The
position is clamped to the visible region (D5) and a cleared
declaration emits no caret, so teardown still hands the cursor back.
This is a deliberate divergence FROM Ink, which re-asserts only when
the cursor's React component re-renders and so zombies the caret in
sibling/leaf topology too (run-verified). Aligning to Ink reduces bugs
only when Ink is correct; here matching Ink would preserve abnormal
behavior. Overrides the prior 2026-06-01 KEEP, whose rationale (avoid
diverging from Ink in the sibling direction) was overturned by running
real terminal apps. The {x,y} setCursorPosition API is unchanged (it
remains the IME primitive); the fix is an internal per-commit re-emit.
Red-first: a real-TTY PTY test with sibling-topology spinner state
asserts the spinner-only frame ends with the caret-restore suffix.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
6a3537023a |
fix(runtime): re-arm trailing commit per deferred call to match Ink's throttle anchor (#154)
The commit scheduler armed one fixed trailing timer at the start of a throttle window, firing the trailing commit at windowStart+wait. Ink's es-toolkit throttle re-arms on every throttled call: trailing fires at lastCall+wait. Deterministic probe at maxFps=10 (updates t0/t0+43/ t0+86): Ink trailing median 192.5ms, vue-tui 103.6ms (audit e29). Mirror the observable timing of es-toolkit's throttle: leading commit when no window is active, per-call trailing re-arm (lastCall+wait), and the maxWait edge (a call a full window after the first deferral commits synchronously) so sustained updates keep the ~wait cadence instead of debounce-starving. Resize cancellation (a separate blessed divergence) is preserved: the post-fix cancel probe is byte-identical. Red test is the discriminating multi-deferred-call shape: a single- deferred-call test goes green under the wrong firstDeferredCall anchor. Post-fix probes land at 188.3-189.2ms, inside Ink's 188.4-195.0 band; CI=true vp run ci passes alongside vp run ready. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c66cddb676 |
fix(runtime): derive mount-guard skip from wired state, not a sticky flag (#153)
The instance-reuse guard set a per-app skippedMount flag that was never reset, so one guarded mount() call permanently disabled the app's own teardown. Three run-confirmed wedges (audit e18), all absent in Ink: - an owner double-firing mount() on its own live stdout kept painting after unmount() and leaked its registry entry - an app that once hit the guard could never unmount a later legitimate mount on a free stdout - an app live on stream A that merely targeted another app's busy stream B became unkillable on A Delete the flag; teardown()/resolveExit() now consult the actually wired state (mountedAppContext / mountedAsOwner), so a guarded call is inert for that call only. The blessed inert-no-op divergence from Ink's reuse-and-rerender is unchanged; the ledger entry is reworded to the call-scoped semantics. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
814c482d6d |
fix(runtime): install console patch before first mount so initial [Vue warn] is filtered (#151)
A [Vue warn] emitted during the initial mount (e.g. the missing-render- function warn from a root setup() throw) escaped the stderr filter because mount() installed the console patch only after originalMount. Ink patches in its constructor before the first React render (ink.tsx:435-436); move the install before originalMount to match. The mount-throw catch already restores the console via teardown(). Verified red-first against real Ink v7.0.4 (audit e10): Ink's stderr stays empty for a render-throwing component; vue-tui's initial-mount warn reached a real PTY before this fix. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1bd91dbbb9 |
test(runtime): pin absolute-child position to the padding box; fix wording
Addresses PR review: the containing block for an absolutely-positioned child is the **padding box** (inside the borders), not the "border-box". Verified by running yoga (an abs child at top:0/left:0 insets by the border only, never by padding — confirmed across border/padding combos) and the real Ink/vue-tui renderers (X lands at the inner-border edge, byte-identical in both). - Tighten the regression test: exact-frame assertions instead of `toContain`, including a border+padding case that distinguishes the padding box from the content box — the assertion that would have caught the original wording slip (presence-only assertions could not). - Correct "containing block (border-box)" -> "padding box (inside the borders)" in paint.ts, layout-guards.ts, and ink-divergences.md. (The unrelated "border-box-like" *sizing* notes are correct and left as-is.) No runtime behavior change; the code already used yoga's computed position. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0d50fb7e40 |
fix(runtime): paint position:absolute children in zero-content boxes
The zero-content-area guard (layout-guards + paint.ts) suppressed ALL children of a Box whose inner content rect collapsed to zero, including position:"absolute" children. An absolutely-positioned child is placed against the containing block (border-box), not the content rect, so Ink v7.0.4 paints it (verified by running real Ink: a w=2 h=2 single-border box with an absolute child renders "┌┐#\n└X"); vue-tui dropped it, rendering "┌┐#\n└┘". - layout-guards: exempt POSITION_TYPE_ABSOLUTE children from the hide loop so they keep their layout. - paint: move overflow-clip setup above the zero-content early-return and, in that branch, paint only absolute children (still clipped by overflow:hidden, matching Ink) while keeping in-flow children suppressed (the blessed degenerate-box divergence). Flow-child suppression and overflow:hidden clipping both stay Ink-aligned (verified byte-identical against real Ink). Known limitation: an absolute descendant nested under a suppressed in-flow child is still dropped (the flow ancestor is removed from layout) — scoped to direct absolute children. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ddd651b5e0 |
chore: bump all packages to 0.0.3 (#148)
Bump @vue-tui/runtime, @vue-tui/cli, and @vue-tui/testing from 0.0.2 to 0.0.3. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1fd832d297 |
fix(runtime): align Ink parity behavior
Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint. Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
1340c049b2 |
fix(runtime): call onRender before frame writes
Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
65ac088001 |
fix(runtime): align stdout writability guards
Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
941fff1845 |
fix(runtime): track useFocus autoFocus updates
Co-authored-by: Claude <noreply@anthropic.com> |