104 Commits

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

* feat(runtime): add mouse input API

* fix(runtime): align mouse edge cases

* fix(runtime): suppress click after drag

* refactor(runtime): use template refs for dragging

* docs: record mouse API follow-ups

* fix(runtime): align useDraggable semantics

* fix(runtime): align mouse composable APIs

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

* fix(runtime): clean up mouse mode state

* fix(examples): use templates for mouse demo
2026-07-06 01:20:27 +08:00
Yunfei He 8bc7976561 feat(components): scroll ScrollBox through an imperative handle (drop wheel/keyboard) (#242)
ScrollBox drops its `wheel`, `keyboard`, and `linesPerWheel` props and instead
exposes an imperative handle (`ScrollBoxExpose`): scrollToLine / scrollByLines /
scrollToTop / scrollToBottom. It listens to no mouse or keyboard input itself —
the consumer wires its own bindings to the handle.

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

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

* refactor(components): move ScrollBox into components package

* feat(runtime): add mouse input composable

* refactor(components): delegate ScrollBox mouse input

* docs: describe mouse input composable

* fix(runtime): consume unsupported SGR mouse input

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

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

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

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

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

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

* feat(examples): add ScrollBox streaming demo

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

---------

Co-authored-by: Yunfei He <i.heyunfei@gmail.com>
2026-07-04 11:25:50 +08:00
Yunfei He 8aea881ffa feat(components): add @vue-tui/components + Spinner (first component) (#229)
* feat(components): scaffold @vue-tui/components with spinner preset data

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

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

* feat(components): add the Spinner component

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

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

* feat(components): Spinner color + label

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

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

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

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

* docs(components): record Spinner decisions

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Relates to #218.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:18:54 +08:00
Yunfei He fd81656c3d docs: adopt Project Context Records (PCR) for agent docs (#188)
Replace the homegrown "Context Engineering" convention with the canonical
Project Context Records (PCR) block in AGENTS.md, and migrate the
.agents/docs/ records to match.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:08:05 +08:00
Yunfei He 751f61eec5 docs(divergences): record both halves of Ink's concurrent flag; add e26 wedge provenance note (#155)
Run-verified vs Ink v7.0.4 (audit e11 decisive experiment, 3/3 runs
byte-deterministic): the concurrent render option's root-tag half is
inert under react-reconciler 0.33.0 (every root becomes ConcurrentRoot;
hooks and preemption probes behave identically), but its dispatch half
is live — the default commits the first frame synchronously inside
render()/rerender(), concurrent:true schedules it on a later tick.
The old entry described the flag as having no observable surface at
all; vue-tui's mount() matches Ink's default dispatch.

Also append the audit provenance note to the paint-validation entry:
the scheduler-wedge rationale rests on the earlier paint-throw
investigation — the audit probe could not reach a paint throw from
public or raw-host input (paint's border fallback intercepts it).

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

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

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

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

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

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:51:19 +08:00
Yunfei He 25114ff52a docs(divergences): fix Ink hook-naming claim and reclassify Transform all-comment entry as model-implied (#152)
Run-based audit against pinned Ink v7.0.4 (40b3a75) found two ledger
defects:

- The composable-naming entry claimed Ink names hook return types
  XProps; real Ink 7.0.4 is mixed (XProps for stream/app hooks,
  UseBoxMetricsResult/AnimationResult/WindowSize for newer ones, and
  several hooks export nothing). Restate both sides accurately.
- The <Transform> all-comment-children entry sat under Intentional
  Divergence Choices, but its forcing is the React-only false !== null
  edge that Vue's comment-vnode materialization cannot see — a
  model-implied difference per the doc's own classification flow. Move
  it there, expand the run-verified boundary (empty slot array, ''/0
  children, screen-reader label), and reword the Non-Behavioral Notes
  cross-ref in the same change so it stays true.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 54017cef0d docs(divergences): correct & reclassify ledger entries per run-based verification
Findings confirmed by running real Ink v7.0.4 + vue-tui harnesses (and
cross-checked against source), not source-reading:

- nonerror-message: reclassify Additive Supersets -> Intentional. Ink accepts
  the same non-Error throw and renders a blank-message overview (same input,
  different output), so it is not an additive superset.
- flexdir-reset: reframe as parity through the public <Box>. Ink's Box
  re-injects flexDirection:'row'/flexWrap:'nowrap', so removing the prop
  resets in BOTH engines (column -> row); the "Ink persists" framing held only
  at the raw ink-box host layer. Kept as the explicit contrast to display.
- resize: retitle/reword — scheduler.cancel() is unconditional on every resize,
  not narrowing-only; the dedup is driven by overflow + a pending commit.
- usecursor: correct the "Why" — a render-body-set cursor is still dropped on an
  ancestor-only commit (Ink re-asserts); the gap is broader than "set-once".
- shallowref-state: useTerminalSize() returns { columns, rows } (object of
  refs), not a single shallowRef read as .value.
- composable-naming: the XProps return-type convention holds only for the
  stream/app hooks (StdinProps/AppProps); useInput/usePaste/useFocus don't fit.
- renderToString SR: note Ink's live render() does expose isScreenReaderEnabled,
  so it's a string-API gap, not a missing SR capability.
- measureElement ref: $el is the primary path (Box is a defineComponent).
- trim two Non-Behavioral bullets that duplicated AGENTS.md house rules.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 9b7e9dfb26 docs(divergences): correct display={undefined} note (Codex review)
An explicit `display={undefined}` is applied as DISPLAY_NONE and hides on
mount (verified against real Ink v7.0.4) — only an omitted `display` stays
visible. The previous parenthetical conflated the two.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He c75c6497d4 docs(divergences): correct Ink behavior in display-reset and react-concurrent
Verified against real Ink v7.0.4 (react 19.2) instead of source-reading:

- display-reset: Ink does not "persist" a withdrawn `display`. Its diff
  emits a removed key as `display: undefined`, and applyDisplayStyles
  sets DISPLAY_NONE for any non-`'flex'` value, so clearing a previously
  set `display` hides the box (verified: flex -> removed goes visible ->
  hidden). Describe the real behavior and the common-toggle consequence.
- react-concurrent: drop "Suspense" from the React-only list. Vue ships a
  built-in `<Suspense>`; the genuine gap is interruptible concurrent
  rendering / `useTransition`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 6752c9c41c docs: clarify Ink divergence classifications 2026-06-08 17:01:38 +08:00
Yunfei He 1fd832d297 fix(runtime): align Ink parity behavior
Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 12:51:20 +08:00
Yunfei He a31e8fa335 docs(divergences): correct ledger after adversarial re-audit (#138)
Re-verified every ledger entry against Ink v7.0.4. Fixes: MI5 (remove false
"residual false-child divergence", verified by running Ink), MI3 (Model-Implied
-> Intentional), VI2 (-> Non-Behavioral), VI3 (-> Intentional), and an
error-overview test comment (Ink renders blank error.message, not String(value)).
ID4 verified accurate and left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:49:53 +08:00
Yunfei He 7f3b0ca40e chore: align Vue file conventions (#137)
- document Vue file authoring conventions
- rename runtime/example component files to kebab-case
- update imports and docs references
2026-06-05 08:28:59 +08:00
Yunfei He 5d071d44ab docs(divergences): reframe second-mount no-op as an intentional, blessed divergence (#136)
A second mount() on a live stdout stays a warn + no-op (no behavior change).
This reframes it as a deliberate, maintainer-blessed choice instead of the
vaguely-justified entry it was.

- warning (render.ts): rewrite the stderr message to state the situation plus
  the two recovery paths (update reactive state, or unmount() the existing app
  first), replacing the old "unsupported / call unmount() first" phrasing.
- divergence doc: rewrite the Why with the real rationale (it is a misuse path;
  Ink warns it is unsupported too; vue-tui fails safe by keeping the live app
  and warning; there is no clean public path to Ink's reuse-and-rerender under
  the createApp model). Move the entry from "Vue-Idiomatic Choices" to
  "Intentional Divergence Choices"; record "Maintainer decision: KEEP"; tighten
  the inert-handle wording (unmount() only settles its own exit promise).
- tests: retarget the instance-reuse-guard assertions to the new warning text,
  including a negative assertion that previously matched a stale substring.

Verified: vp run ready (integration 1104 + PTY 123 passing, lint/type/build).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:17:53 +08:00
Yunfei He 929f3952bb docs(divergences): reorganize Ink divergence notes 2026-06-04 16:29:49 +08:00
Yunfei He b03d6f59cb docs(divergences): drop the exports bare-string vs types-condition entry (#134)
The package.json `exports` shape — runtime/testing's bare-string target vs Ink's
explicit `types` condition — is build-toolchain plumbing, not a runtime or
user-facing-API behavior. TS resolves the declaration identically via the
`.d.mts`-next-to-`.mjs` adjacency tsdown emits, so nothing is observably
divergent. It fit none of the doc's categories (mis-filed under "Public API
surface") and was an AI-added defensive note guarding a non-event.

Alignment tracks runtime behavior + the user-facing API; packaging/internal
infra (exports shape, ./internal, dist vs build, .mjs vs .js) is out of scope
and isn't recorded as a divergence.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:12:19 +08:00
Yunfei He 51cca1cf05 docs(divergences)+refactor(scheduler): prune mis-scoped idioms, fix throttle figure, drop dead constant (#133)
Audit of the "byte-identical reconciler/runtime mechanics" subsection, each entry
verified against vue-tui + Ink v7.0.4 source.

Doc:
- Rewrite the TuiComment/Transform entry cause-first (Vue materializes a comment
  placeholder where React renders nothing -> vue makes it inert) for clarity.
- Fix the commit-throttle figure: it is `ceil(1000/maxFps)` = 34ms at the default
  maxFps=30, not "~32ms". The 32 was vue's own dead fallback constant, never the
  production value; Ink has no 32 either.
- Drop the keyed-lists (LIS) entry: it restated the section header and guarded no
  vue-authored code (patchKeyedChildren is upstream Vue).
- Drop the wrapText-truncate and animation-scheduler entries: both are vue-tui
  implementation choices, not Vue-vs-React framework differences, and both are
  already explained by their in-code comments.

Code (no behavior change; verified by `vp run ready`):
- Remove the dead `DEFAULT_THROTTLE_MS = 32` fallback in scheduler.ts. Production
  always passes throttleMs (render.ts derives it from maxFps) and the immediate
  path never reads it, so the 32 fallback never gated a frame. Make throttleMs
  required; render.ts always passes it (0 when unthrottled).
- Tighten the animation-scheduler ceil comment (drop the "busy-loop" overstatement;
  the fractional-delay truncation it describes is real and keeps the Math.ceil).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:58:52 +08:00
Yunfei He 11e9a2e684 docs(divergences): reframe activeId as the general composable-ref divergence (#132)
The `useFocusManager().activeId` entry conflated two things and buried the
load-bearing one. Split and reframe:

- The real divergence is framework-semantic, not API-specific: a React hook
  re-runs each render so it can return a plain snapshot, whereas a Vue
  composable's setup() runs once and must wrap reactive state in a `shallowRef`.
  Moved to the Vue != React section as a general rule; `activeId` is now just
  one example of it.
- Folded the empty-value convention (`null` vs Ink's `undefined`) into that
  entry as a Vue ecosystem idiom rather than a separate headline.

Doc-only.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:45:57 +08:00
Yunfei He b7690050fa docs(divergences): record validate-at-component-layer (not paint) principle (#131)
vue-tui validates invalid render input (a chalk-modifier backgroundColor like
"bold", an unknown borderStyle) at the component-render layer (Box.ts/Text.ts),
not sunk into the paint layer — so a bad value throws where the error boundary
catches it (ErrorOverview → reject waitUntilExit) instead of crashing.

Records the framework-semantic forcing function (vue-tui's paint runs in a Vue
post-flush callback, so a paint-layer throw escapes onErrorCaptured and wedges
the scheduler), notes the React/Vue symmetry (a paint-layer throw is uncatchable
by component boundaries in both engines, not a Vue weakness), and the honest
cost (eager render-time validation over-throws in a few degenerate cases Ink's
lazy paint check never reaches).

Verified against Ink v7.0.4 source; entry reviewed by Codex.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:24:04 +08:00
Yunfei He 5f55b59b70 fix(runtime): backgroundColor of a chalk-modifier name throws (Ink parity, drop A12 divergence) (#129)
Ink's colorize throws on a chalk-modifier-name backgroundColor (e.g. "bold":
'bold' in chalk but chalk.bgBold is undefined -> "chalk.bgBold is not a
function"); vue-tui degraded to bare text. Align: validate backgroundColor at
component render (Text + Box own bg + drawn border edges) so the throw is caught
by the error boundary, not the post-flush paint pass (a throw there wedges Vue's
scheduler — cf. borderStyle #124). Detection mirrors Ink exactly: only the
in-chalk-but-no-bg-method case throws; valid colors / hex / ansi256 / rgb /
[r,g,b] / non-chalk strings and foreground modifiers (color="bold" still bolds)
are unaffected. Border bgs are gated to Ink's render-border conditions
(borderStyle + drawn edge + perEdge ?? general); empty/hidden elements don't throw.

Since Ink throws lazily at paint (with layout/squash info) while vue must validate
eagerly at render, a few degenerate cases (content-area<=0 box, degenerate
top/bottom border, nested-empty text) over-throw on the invalid modifier input —
documented in code as architecturally irreducible. Removes the A12 entry from
ink-divergences.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:09:09 +08:00
Yunfei He d7c62f9b3a fix(runtime): off-spec display value hides, aligning Ink (drop A21 divergence) (#128)
Ink's applyDisplayStyles hides any present `display` that isn't 'flex'
(DISPLAY_NONE); vue-tui hid only on exact 'none', leaving off-spec values
(reachable via TS-bypass — the prop type is 'flex'|'none') visible. Align: the
yoga display setter now hides any present (non-null) value except 'flex',
matching Ink even for non-string junk (display={5}). The blessed A19 divergence
is preserved — a removed/undefined display (null) still resets to the visible
default (Vue can't distinguish display={undefined} from an omitted prop).

Removes the now-obsolete A21 entry from .agents/docs/ink-divergences.md (the
A19 "removed display resets to visible" entry remains).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:19:48 +08:00
Yunfei He 27307910a2 docs(parity): tighten the rawMode Ctrl+C wording (lead with echo; note exitOnCtrlC default) (#121)
The rawMode 'always' divergence entry overstated the Ctrl+C benefit — it framed
"reaching the app's interrupt handler" as the headline, which only applies under
the non-default `exitOnCtrlC: false`. Both Ink and vue-tui default exitOnCtrlC to
true, so by default Ctrl+C exits either way; the lazy-vs-always difference there is
only the exit path/code (graceful 0 vs re-raised SIGINT 130). Reword to lead with
the real default consequence (echo into the frame on no-input screens) and state
the Ctrl+C difference accurately, noting it only bites an app that sets
exitOnCtrlC:false. No behavior change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:11:53 +08:00
Yunfei He 2372b6b03b feat(runtime): own raw mode for the interactive lifetime by default (rawMode option) (#120)
Add a `rawMode?: 'always' | 'auto'` mount option (replacing the dead, unwired
`rawMode?: boolean`), defaulting to 'always'.

- 'always' (default): the App takes a lifetime raw-mode hold at mount (gated on
  interactive + a TTY stdin), so raw mode is held for the whole run regardless of
  which input composables are mounted. Keystrokes never echo into the rendered
  frame on a no-input/streaming screen, and Ctrl+C is handled consistently on
  every screen (e.g. it reaches an agent's "interrupt generation" handler instead
  of becoming a kernel SIGINT). Because owning raw mode ref()s stdin, the app
  stays alive until an explicit unmount()/exit() — it does NOT auto-exit when idle.
- 'auto': Ink's original lazy model — raw mode is acquired only while a useInput /
  useFocus / usePaste is mounted, so a no-input screen returns to cooked mode and a
  no-input app auto-exits. The opt-out for inline / render-and-exit tools.

This is a deliberate divergence from Ink (the cross-framework norm — Bubble Tea,
Textual, Ratatui, prompt_toolkit all own the terminal for the program lifetime;
Ink's hook-driven model is the outlier). Documented in
.agents/docs/ink-divergences.md.

Implementation: the App holds a `lifetimeFloor` ref via holdRawModeForLifetime();
input composables stack above it. The per-consumer clearInputState is re-based to
the floor so a buffered partial escape (e.g. a lone ESC at a screen transition)
can't bleed into the next consumer — cleared both when the last consumer releases
and when the first consumer re-acquires above the floor (covers same-tick swaps
AND a delayed idle→input transition). The data listener and raw toggle stay on
until teardown, where dispose() releases the floor ref (raw disabled + stdin
unref'd exactly once).

Tests: rawMode-lifecycle ('always' holds raw with no input; 'auto' stays cooked;
no mid-session oscillation; no partial-escape bleed across a swap or an idle gap);
PTY exit-rawmode-always (a no-input 'always' app stays alive and exits on Ctrl+C).
The 6 auto-exit PTY fixtures are pinned to 'auto' (they model render-and-exit).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:00:09 +08:00
Yunfei He 84d211ed10 docs(parity): record the shared-stdin multi-app divergence (vue is strictly better than Ink) (#119)
Add an Additive-features entry documenting that two apps sharing one stdin
(different stdout) both receive input in vue-tui, where Ink's first-registered
'readable' listener drains the buffer (second app deaf until the first
unmounts) and its per-App raw-mode count drops raw mode on the first unmount.
vue refcounts the raw-mode toggle per-stdin (shared) and attaches the 'data'
listener per-controller, so the push event broadcasts to both. Implemented in
#118; single-app behavior is byte-identical.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:27:46 +08:00