Files
vue-tui/.agents/docs/ink-divergences.md
T
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

523 lines
34 KiB
Markdown

# vue-tui - Intentional Divergences from Ink
vue-tui started as a Vue 3 port of [Ink](https://github.com/vadimdemedes/ink), and it
still tracks Ink closely: the aim is behavioral parity except where a difference is
deliberate. It is no longer only a port, though. It has its own design decisions,
additive features, and Vue-native choices.
This document records the places vue-tui intentionally differs from Ink by design. A
difference that is not listed here is treated as a bug, or simply unverified behavior,
not a design choice.
Reference baseline: Ink **v7.0.4** (commit
`40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version,
re-validate every entry below against the new source.
## How to Classify a Divergence
Classify each divergence by the first rule that applies. The order matters: earlier
sections are narrower, while later sections are broader fallbacks.
1. If Ink's supported subset still behaves the same and vue-tui only accepts more inputs,
supports more contexts, or exposes an extra capability, put it in **Additive
Supersets**.
2. If the primary reason is alignment with Vue's framework model, philosophy, or user
expectations, put it in **Vue-Aligned Design**.
- Use **Model-Implied Differences** when the difference comes from the React/Vue
framework-model boundary. Matching Ink would require React-shaped machinery inside
Vue, changing a core Vue-facing contract, or dealing with a React-only concept that
has no Vue equivalent.
- Use **Vue-Idiomatic Choices** when Ink could be copied, but vue-tui chooses the
behavior or public surface that better fits Vue's reactivity, lifecycle, component
boundaries, current-props model, or API conventions.
3. If the divergence is intentional but is not additive and is not primarily Vue-aligned,
put it in **Intentional Divergence Choices**.
4. If the note is not a divergence, put it in **Non-Behavioral Notes**.
Each divergence entry states what Ink does, what vue-tui does, and why the difference is
deliberate. Some entries also record consequences, costs, tests, or maintainer decisions
where those details are needed to understand the decision.
---
## Additive Supersets
vue-tui supports more than Ink in these cases. Ink-supported inputs and common use cases
remain compatible; vue-tui only adds accepted inputs, contexts, or capabilities.
### Multiple `<Static>` regions
- **Ink:** keeps a single `staticNode`; only one `<Static>` is honored.
- **vue-tui:** `findStatics(root)` renders **every** `<Static>` in the tree.
- **Why:** a tree with two `<Static>` regions renders both. Maintainer decision
(2026-05-30): KEEP.
### Ctrl+C exits under the kitty protocol too
- **Ink:** exits only on the legacy `\x03` byte (in `App`), so a kitty-protocol Ctrl+C
(`\x1b[99;5u`) parses fine but never exits. Its guard is byte-specific, not
Ctrl+C-specific.
- **vue-tui:** one encoding-agnostic exit in the always-on stdin controller (`emitInput`),
via `parseKeypress`. It matches Ctrl+C in both the legacy and kitty forms (but not
Ctrl+Shift+C), so it fires no matter which composable holds raw mode (`useInput` /
`useFocus` / `usePaste`, or none).
- **Why:** `exitOnCtrlC` is defined in terms of Ctrl+C, not one byte encoding. Keeping the
exit at the single always-on layer avoids splitting the behavior across two places. Opt
out with `exitOnCtrlC: false`. Maintainer decision (2026-05-30): KEEP. Tests:
`usePaste-only app exits on {legacy,kitty} Ctrl+C` in `input-kitty.test.ts`.
### `parseKeypress` filters kitty query-responses
- **Ink:** filters kitty keyboard-protocol query-responses (`ESC[?Nu`) in exactly **one**
place: the auto-detection lifecycle in `ink.tsx`
(`stripKittyQueryResponsesAndTrailingPartial` on a private `onData` buffer). Its
`parse-keypress.ts` has no query-response branch.
- **vue-tui:** mirrors that detection layer (in `kitty-keyboard.ts`) **and** adds a
parser-level filter: `parseKeypress` returns `{ ignore: true }` for `ESC[?Nu`, which
`useInput` then drops.
- **Why:** the detection layer does **not** cover the runtime input pipeline (`stdin 'data'`
-> `inputParser` -> `emitInput` -> `useInput` -> `parseKeypress`). In `enabled` mode it
never runs; in `auto` mode its `onData` listener and the stdin controller's
`handleData` both subscribe to the same `'data'` event, so stripping its private buffer
cannot stop the chunk reaching `handleData`; and after detection settles the listener is
gone. Empirically (Layer 2 removed, rebuilt) a stray query-response reaches a `useInput`
handler as spurious `"[?1u"` input in all of those cases, including a response split
across two reads, which `inputParser` reassembles before dispatch. The parser-level
filter is therefore intentional, not redundant. Introduced 2026-05-31. Tests: "kitty
query-response - end-to-end filtering" in `kitty-lifecycle.test.ts` (RED without it).
### Non-`Error` thrown values keep their message in the error overview
- **Ink:** `ErrorOverview` renders `error.message`; a thrown non-`Error` (`throw 'boom'`)
has no `.message`, so the overview shows a blank message.
- **vue-tui:** the error boundary keeps the **raw** thrown value and `ErrorOverview` shows
`String(value)` as the message, so `throw 'boom'` renders ` ERROR boom`, not a blank
`ERROR`. Like Ink, no stack block is rendered when the value carries no stack.
- **Why:** this gives a useful message for the (lint-discouraged) non-`Error` throw, and it
keeps the message vue-tui already surfaced before: when the boundary wrapped such throws
in `new Error(String(value))`, which also produced a misleading synthetic stack pointing
at framework internals. That synthetic stack is now gone. Introduced 2026-05-31.
### `useAnimation()` outside a render tree drives a standalone animation
- **Ink:** the default `AnimationContext.subscribe()` is a no-op subscription with
`startTime: 0`, so a `useAnimation` rendered outside an Ink tree never ticks.
- **vue-tui:** `useAnimation` falls back to a freshly created standalone scheduler
(`inject(AnimationSchedulerKey, null) ?? createAnimationScheduler()`), so `frame`/`time`/
`delta` advance even with no surrounding app.
- **Why:** the composable still does useful work in isolation, such as a unit test or a
non-rendered driver. Additive; inside a tree the injected scheduler is used exactly as
Ink's. Contrast with the terminal-bound composables in the outside-render-tree entry,
which throw because they have no meaningful standalone mode.
### `measureElement` / `useBoxMetrics` also accept a Vue component-instance ref
- **Ink:** `measureElement(node: DOMElement)` and `useBoxMetrics(...)` read
`node.yogaNode` directly: a host `DOMElement` only.
- **vue-tui:** the ref is resolved through `$el` as well: a `ref` bound to a **Vue
component** (whose root host node is on `$el`), not just a host-node ref, resolves to the
underlying yoga node.
- **Why:** in Vue a template ref on a component yields the component instance, and its host
node is reached via `$el`. Supporting both shapes is a strict superset that matches how
Vue refs behave; a bare host-node ref still works identically to Ink.
### `renderToString` supports screen-reader mode
- **Ink:** `renderToString` has only a `columns` option; it always renders the non-SR
(ANSI) frame.
- **vue-tui:** `renderToString` accepts `isScreenReaderEnabled?: boolean`. In SR mode it
returns the linearized accessibility text (`renderScreenReaderOutput`) and prepends the
linearized `<Static>` output, just as the non-SR path prepends the painted static frame.
- **Why:** vue-tui already has a parity SR renderer for the live path. Surfacing it through
the string API is a strict superset (default `false` is byte-identical to Ink) and keeps
`<Static>` content in generated SR snapshots. Additive.
### Two apps sharing one stdin both receive input
- **Ink:** raw-mode count and the input listener are **per-`App`** (`useRef`), and Ink reads
via the `'readable'` event + `stdin.read()` (pull, `App.tsx:278-313`). Two `render()`s to
different stdout but one stdin each attach a `readable` listener, but the first-registered
listener's `read()` loop drains the buffer every tick, so the second app receives no
input until the first unmounts. And because counts are per-`App`, the first app's
unmount calls `stdin.setRawMode(false)`, dropping raw mode while the second still needs
it.
- **vue-tui:** the terminal raw-mode toggle is refcounted **per-stdin** (a shared
`WeakMap`), so one app's unmount cannot drop raw mode while another holds it; and the
`'data'` input listener is **per-controller**. Each app attaches its own `handleData` ->
own parser -> own emitter. Since `'data'` (push) broadcasts to **every** listener, both
apps receive every keystroke, and the second keeps receiving after the first unmounts.
- **Why:** this covers a combination vue-tui already allows: two `createApp`s to different
stdout. The same-stdout no-op is keyed on stdout, not stdin. The push model has no drain
race, and a shared raw-mode refcount matches the ownership model when several renderers
share one input. The common one-app-to-terminal flow is unchanged: one controller's
`localRefs` equals the shared `refs`. Test: `raw-mode-lifecycle.test.tsx` ("two apps
sharing one stdin both receive input..."). Maintainer decision (2026-06-07): KEEP.
## Vue-Aligned Design
These divergences come from choosing Vue's framework model and user expectations as the
source of truth while tracking Ink. Some are model-implied: matching Ink would require
React-shaped machinery inside Vue, changing a core Vue-facing contract, or handling a
React-only concept that has no Vue equivalent. Others are idiomatic choices: Ink could be
copied, but vue-tui chooses the behavior or public surface that better fits Vue's
reactivity, lifecycle, component boundaries, current-props model, or API conventions.
### Model-Implied Differences
#### Reactive composable state is a `shallowRef`, not a plain snapshot
- **Ink/React:** a hook re-runs on every render of its component, so it can return a plain
value and the caller always reads the latest one. `useFocusManager().activeId`, for
instance, is a bare `string | undefined`, re-read fresh each render.
- **vue-tui:** a composable's `setup()` runs **once**, so reactive state cannot be a plain
snapshot: it would freeze at setup time. vue-tui returns a **`shallowRef`** whose `.value`
updates and re-renders the template; read these as `.value`. Every stateful composable
follows this, including `useTerminalSize()` and `useFocusManager().activeId`. An empty
one holds `null` (Vue's convention for an empty ref: a template ref is
`ref<T | null>(null)`), where Ink's plain value is `undefined`.
- **Why:** the two frameworks track a changing value differently. React reads the newest
value by re-running the hook; Vue wraps it in a ref the template subscribes to. This is
the general rule, not a per-API choice. `useFocusManager().activeId` is just one
instance. This follows Vue's philosophy: changing state is exposed as a reactive source,
not as a one-time snapshot. Maintainer decision (2026-06-07): KEEP.
#### `useCursor()` re-assertion follows fine-grained reactivity, not React's render cascade
- **Ink:** `useCursor`'s no-deps `useInsertionEffect` (`use-cursor.ts:27-32`) re-runs on
every render **of the cursor component**, re-marking the cursor dirty
(`ink.tsx:494-497`); log-update resets `cursorDirty` each commit. React re-renders a
whole subtree when an ancestor commits, so if the cursor component is in that subtree it
re-renders and the cursor is re-asserted, even when only an ancestor's unrelated state
changed. If an unrelated sibling owns the changing state, the cursor component does
**not** re-render, so Ink does **not** re-assert and the cursor is dropped that commit.
- **vue-tui:** `useCursor` propagates via `watch(positionRef, ..., {flush:'sync'})`. It
re-asserts when the position **reference changes** (or the owning component re-renders
and re-sets it). Vue's fine-grained reactivity re-runs only components whose own deps
changed, so an ancestor-driven commit does **not** re-run a cursor child that did not
depend on the changed value, and a set-once cursor is dropped that commit.
- **Why:** the two agree for the **recommended** usage: set the position reactively (in the
render body / from a ref the component reads), as Ink's apps and vue-tui's parity tests
do. They also agree in the unrelated-sibling case (both drop). They differ only in the
narrow edge of a **set-once** cursor plus an **ancestor-driven** commit: React's render
cascade re-asserts it, Vue's fine-grained reactivity does not. This is a consequence of
React's cascade vs Vue's fine-grained re-render model. A global per-commit re-assert
would make vue-tui diverge from Ink in the opposite (unrelated-sibling) direction, where
Ink drops the cursor. Keep the reactivity-tied behavior. Maintainer decision
(2026-06-01): KEEP.
#### A `setup()`-throwing component emits a dev-only `[Vue warn]` on stderr
- **Ink:** a component that throws during render surfaces only through the error overview /
exit path; React emits no extra framework warning.
- **vue-tui:** in a **development** build, a component whose `setup()` throws additionally
produces Vue's own `[Vue warn]` lines on stderr (for example, the missing-render-function
warning) that Ink has no analog for. In interactive mode `patchConsole` filters
`[Vue warn]` out of the frame; outside that path (debug, non-patched stderr) it surfaces.
- **Why:** these warnings come from Vue itself and are **dev-only** (stripped in production
builds); they have no effect on stdout output or the exit code. Documented so the stray
warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics. While
console patching is active, vue-tui treats the `[Vue warn]` prefix as that framework
diagnostics channel and filters it. This may also filter user-authored stderr logs that
intentionally use the same reserved prefix; use a different application prefix when that
output must be preserved. Maintainer decision (2026-06-06): KEEP.
#### React concurrent mode
- **Ink:** built on React; Suspense / `useTransition` are React features.
- **vue-tui:** no equivalent.
- **Why:** this is a React-only concept with no Vue equivalent, so it is N/A rather than a
parity gap.
### Vue-Idiomatic Choices
#### Entry point - `createApp()` instead of `render()`
- **Ink:** `render(<App/>, options?)`: `options` is `RenderOptions`; returns an `Instance`.
- **vue-tui:** `createApp(App)` returns a `TuiApp`; `app.mount(options?)` takes
`MountOptions`.
- **Why:** mirrors Vue's own `createApp` mental model. A Vue developer expects an app
object (`TuiApp`) they mount, not a one-shot render call. The mount-options bag and the
app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped
(`RenderOptions` / `Instance`). Do not add Ink-compatible aliases here: aliases would
make the public API look render-shaped while the actual runtime contract is app-shaped.
Maintainer decision (2026-06-07): KEEP.
#### Removing `display` resets to the default (visible)
- **Ink:** `applyDisplayStyles` (`styles.ts`) sets `DISPLAY_NONE` whenever an explicit
`display` is present and not `'flex'`, so a present-but-undefined
`display={undefined}` **hides** the box, and an omitted `display` **persists** the prior
value.
- **vue-tui:** a removed/undefined `display` resets to the Box default `DISPLAY_FLEX`
(visible): the same state as if the prop had never been set.
- **Why:** render = f(current props): no `display` set means the default (visible).
Persisting a withdrawn prop, or flipping it to hidden, does not match that model. Maintainer
decision (2026-05-31): KEEP.
#### Public composable naming follows Vue conventions
- **Ink/React:** public APIs are hooks (`useFocus`, `useInput`, ...) and the equivalent
hook-return types are named `XProps` (`StdinProps`, `AppProps`, ...).
- **vue-tui:** public APIs are Vue **composables** (`useFocus`, `useInput`, ...), and
composable return types follow VueUse's `UseXReturn` convention (`UseStdinReturn`,
`UseAppReturn`, ...). In vue-tui, `XProps` is reserved for component props (`BoxProps`,
derived via `ExtractPublicPropTypes`).
- **Why:** the public surface should read like Vue code. The return shapes still mirror
Ink field-for-field where the same public state exists; reactive state is represented as
refs for the model-implied reason documented above. Do not export Ink-compatible alias
names for these types: Vue-first naming is more important than making type imports look
portable across React and Vue. `XProps` stays reserved for component props; composable
returns stay `UseXReturn`. Maintainer decision (2026-06-07): KEEP.
#### Function-valued composable inputs use `MaybeRef`, not getters
- **Ink/React:** `useInput` and `usePaste` use React's current-props model: a hook can keep
a stable event listener and still call the latest handler after a re-render.
- **vue-tui:** `setup()` runs once, so passing a function prop's current value directly
(`useInput(props.onInput)`) captures a one-time snapshot. When a composable should follow
a function-valued prop, pass a live prop ref instead:
`useInput(toRef(props, "onInput"))` / `usePaste(toRef(props, "onPaste"))`. A wrapper
closure that reads `props.onInput(...)` at event time is also correct.
- **Why:** this is Vue's standard reactive-source boundary: pass the source, not a value
read from it in setup. The handler parameter accepts `MaybeRef<Handler>` and resolves it
with `unref()` when input/paste occurs. It deliberately does **not** accept
`MaybeRefOrGetter<Handler>` because a handler is itself a function:
`useInput(() => {})` must remain an input handler, not be reinterpreted as a getter that
returns one. Maintainer decision (2026-06-06): KEEP.
#### `<Static>` uses a scoped slot object instead of positional render arguments
- **Ink/React:** `<Static>` receives a function-as-children render callback and calls it as
`render(item, absoluteIndex)`.
- **vue-tui:** `<Static>` exposes a Vue scoped slot with `{ item, index }`, so template
users write `v-slot="{ item, index }"` and TSX users pass a slot function that receives
one props object.
- **Why:** this is the framework-native match for React render children. Vue scoped slots
pass one props object, not multiple positional arguments, and that object form is what
Vue users expect for slot payloads. The rendered item/index values remain equivalent.
Maintainer decision (2026-06-06): KEEP.
## Intentional Divergence Choices
These divergences are deliberate, but they are not strict supersets and are not primarily
driven by Vue's framework model or API conventions. vue-tui intentionally chooses a
different runtime behavior, ownership rule, or out-of-contract handling.
### Second `mount()` on a live stdout is an inert no-op
- **Ink:** `render()` keeps one instance per stdout (`WeakMap<WriteStream, Ink>`); a second
`render(node, {stdout})` on a stream that already has a live instance warns on stderr but
**reuses** that instance and `rerender`s the new tree into it.
- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr and returns an
**inert handle**. It wires no second renderer and renders nothing; the first app's tree
stays on screen. `unmount()`/`teardown()` on that handle never touch the owner's stream or
registry entry (`unmount()` only settles the inert handle's own exit promise).
- **Why:** a second `mount()` on a live stdout is a misuse (forgot to `unmount()`, a
re-render glitch fired `mount()` twice, or expecting `mount()` to re-render — it doesn't;
update reactive state for that). Ink treats it as unsupported and warns too. vue-tui fails
safe: it ignores the second mount, keeps the live app rendering, and warns with the two
recovery paths. It deliberately doesn't copy Ink's reuse-and-rerender: there's no clean
public path to it (`createApp` binds the tree to the app, so an Ink-style rerender would
mean reaching into the live app's container or tearing it down first), and on a misuse path
keeping the running app stable beats auto-tearing it down (which would churn on a re-render
glitch). Maintainer decision (2026-06-04): KEEP. Test: `instance-reuse-guard.test.tsx`.
### Raw mode is owned for the interactive lifetime by default (`rawMode` option)
- **Ink:** raw mode is **lazy / reference-counted to input hooks**. `useInput` /
`useFocus` / `usePaste` enable it on mount and release it when the last one unmounts, so
a screen with no input handler falls back to cooked mode. There is no option to hold it.
- **vue-tui:** the `rawMode` mount option defaults to **`'always'`**. Raw mode is enabled at
mount and held for the whole interactive run (when `interactive` and stdin is a TTY),
regardless of which input composables are mounted. `rawMode: 'auto'` opts back into Ink's
exact lazy behavior.
- **Why:** for a long-running interactive app (a full-screen TUI, a coding agent), Ink's
lazy model makes raw mode **toggle** as the user moves between input and no-input
screens. The main consequence is **echo**: on a no-input / streaming screen the terminal
is back in cooked mode, so typed keys echo into the half-drawn frame (and line-buffer).
Ctrl+C also changes path: on a no-input screen it is a kernel SIGINT rather than the
app's own `\x03` intercept. Note `exitOnCtrlC` defaults to `true` in both Ink and
vue-tui, so by default Ctrl+C exits either way; the divergence is only the exit path/code
(a clean exit `0` vs a re-raised SIGINT `130`). It matters for an app that sets
`exitOnCtrlC: false` to handle Ctrl+C itself: under the lazy model its opt-out is
bypassed on a no-input screen (the SIGINT still exits). Holding raw for the
lifetime keeps echo and Ctrl+C handling identical on every screen. This matches the
cross-framework norm: Bubble Tea, Textual, Ratatui, and prompt_toolkit all own the
terminal for the program lifetime. Ink's hook-driven model differs: its "cooked on a
no-input screen" behavior follows from refcounting input hooks rather than from an
explicit no-input-screen contract.
- **Consequence:** owning raw mode `ref()`s stdin, so an `'always'` app stays alive until
you explicitly `unmount()` / `exit()`. It does **not** auto-exit when idle (the same way
an Ink app holding a `useInput` already does not). The "render and auto-exit" pattern
(Ink's inline-output use) is `rawMode: 'auto'`. Tests: `raw-mode-lifecycle.test.tsx`
(`'always'` holds raw with no input hook; `'auto'` stays cooked; no mid-session
oscillation).
### Narrowing resize cancels the redundant trailing `clearTerminal`
- **Ink:** `resized()` paints synchronously via `onRender()` but does **not** cancel a
pending throttled `onRender`; on a narrowing resize that trailing commit re-runs and,
because `shouldClearTerminalForFrame` clears whenever the previous frame overflowed, Ink
emits a **second** `clearTerminal`.
- **vue-tui:** `onResize` calls `scheduler.cancel()` before its synchronous commit,
dropping the now-redundant trailing commit. The screen is cleared **once** per narrowing
resize.
- **Why:** the synchronous resize commit already reflects the current tree, so the pending
commit repeats the same clear. Emitting one clear instead of two has no visible behavior
difference (issue #26).
### Degenerate boxes do not lay out or paint children when the content area is gone
- **Ink:** its size model is border-box-like: `width`/`height` are handed to Yoga as the
element's outer size, while border and padding consume space inside that size. Ink has no
`boxSizing` / `content-box` prop. During paint, though, Ink only clips children when
`overflow` is hidden. With default visible overflow, children can leak into border rows
or outside the box when border/padding squeeze the content area to zero, and a bare
`width={0}` Box can still let zero-width text wrapping create extra visible rows
(`B\nA` beside a sibling). Examples in Ink v7.0.4 include:
`width={3} height={2} borderStyle="single"` painting the child on the bottom border row;
`width={2} height={3}` leaking the child past the right border;
`width={4} height={3} paddingX={1} borderStyle="single"` leaking into the bottom
border; and bare `width={0}` text reserving rows through wrap-ansi's width-0 layout.
- **vue-tui:** layout computes each Box's inner content size by subtracting computed border
and padding from the outer box size, clamps it to `{width >= 0, height >= 0}`, and
temporarily removes that Box's yoga children from the layout when either dimension is
zero. Paint applies the same inner-content gate, so the child subtree neither reserves
invisible rows nor writes glyphs outside a nonexistent content area. Border and background
are still painted as far as the outer area permits. Positive-size content areas keep the
existing overflow behavior; this is not a blanket `overflow:hidden`.
- **Layout model guidance:** primitive `Box` should preserve the Yoga/flexbox model rather
than paper over it with ad-hoc layout corrections. Defaults such as `flexShrink: 1` are
part of that model, and a child resolving to zero width or height can be a valid layout
result. Higher-level components are where stronger user intent belongs: scroll, list, and
viewport abstractions should keep their content at natural size (`flexShrink: 0`, or an
equivalent encapsulated default) and let a bounded viewport clip or offset what is visible.
Paint containment is the renderer invariant underneath both cases: whatever Yoga resolves,
children may only paint inside their owning Box's content rectangle; if that rectangle has
no positive width or height, the child subtree does not paint.
- **Why:** children need a real content rectangle to lay out and paint into. If the
resolved content width or height is zero, rendering child text or nested borders on top
of the frame, outside the box, or on later rows is an implementation artifact, not useful
output. This follows the common TUI box model: Ratatui renders child widgets into an
inner `Rect`, Textual reduces content space from the assigned box, and Rich panels render
children with child width/height after subtracting the border. Bubble Tea's viewport
follows the same separation at the component level: content keeps its natural size while
the viewport exposes a bounded visible window and offsets. The behavior also prevents
negative repeat/count math and paint crashes in tiny legal boxes. Maintainer decision
(2026-06-07): KEEP. Tests: `text-wrap-width.test.tsx`, `flex.test.tsx`,
`text.test.tsx`.
- **Future `content-box`:** this does not block adding an explicit content-box option
later. That option would change how a requested size is expanded into an outer box size
before layout. Once an outer box exists, the paint invariant remains the same: a child
subtree only lays out and paints when the resolved inner content rectangle has positive
width and height. The default remains border-box-like, matching Ink's current public
sizing model.
### Out-of-type style values are forwarded, not defensively coerced
- **Ink:** several flex/align setters coerce an invalid runtime value to a default:
`flexShrink` non-number -> `1`; `alignItems`/`alignSelf`/`alignContent`/
`justifyContent` falsy (`""`) -> their default (STRETCH / AUTO / FLEX_START); and an
out-of-set value matches none of Ink's `if`-chain branches, so no setter runs and the
previous/default value persists.
- **vue-tui:** these setters trust the typed prop surface and forward the raw value to
yoga: a non-number `flexShrink` is passed through; `toAlign("")`/`toJustify("")` look up
`""` and pass `undefined` to the setter; and out-of-set values that yoga happens to
accept (`space-*`/`baseline`/`auto` on `alignItems`) reach yoga rather than being ignored.
- **Why:** every one of these is reachable **only** via a TS-bypass. The public prop types
forbid them. Within the typed contract Ink and vue-tui are identical. Ink's per-value
coercion is defensive code for runtime values vue-tui's types already exclude. Duplicating
those `typeof`/falsy guards would add checks for inputs the public types reject.
(`flexGrow` is not in this set: both only coerce null/undefined -> `0`.) If a reviewer
shows any case is reachable in-type, it becomes a bug to fix, not a divergence.
### Removing `flexDirection` / `flexWrap` resets to the default
- **Ink:** neither prop has a reset branch in `applyFlexStyles` (every _other_ flex prop
does), so an explicit `flexDirection={undefined}` leaves the previous value in place.
- **vue-tui:** resets to the Box default (`row` / `nowrap`): the same state as if the prop
had never been set.
- **Why:** render is a function of the current props. With no value set you get the default,
and dropping a prop changes the output; keeping a previous render's value does not match
that, and Ink resets every other flex prop. Maintainer decision (2026-05-30): KEEP.
### Composables throw outside a render tree
- **Ink:** the hooks read a React context whose **default** value is a no-op object, so
calling e.g. `useStdin()` outside an Ink tree returns inert defaults without an error.
- **vue-tui:** `useApp`, `useStdout`, `useStderr`, `useStdin`, `useTerminalSize`,
`useFocus`, `useFocusManager`, `useInput`, `usePaste`, `useCursor`, and
`useIsScreenReaderEnabled` **throw** when their context is absent ("... must be called
inside a vue-tui render tree"). `useBoxMetrics` and `useAnimation` do **not** throw:
they fall back. `useBoxMetrics` reports zero metrics, and `useAnimation` drives a
standalone scheduler. See the additive entry.
- **Why:** a composable used in the wrong place is a bug, and a thrown error names it at
the call site instead of returning a context that quietly does nothing. The two
exceptions fall back because they have a meaningful standalone behavior (zero metrics / a
working animation), so throwing would remove a useful capability. Required app,
terminal, focus, and input context should fail fast when absent; no-op defaults hide bugs.
Maintainer decision (2026-06-07): KEEP.
### Invalid input is validated at the component layer, not the paint layer
- **Principle:** 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 down at the **paint layer**. A bad
value therefore throws where the **error boundary** catches it -> `ErrorOverview` -> a
clean `reject` of `waitUntilExit()`, exactly like any other component error. The app
reports the error instead of crashing.
- **Ink:** validates the same inputs **lazily at paint** (`colorize` / `render-border`, run
from the reconciler's commit hook): **outside** React's ErrorBoundary, so a bad value is
an uncaught crash, not a recoverable error.
- **Why:** the key constraint is where paint runs. vue-tui's paint runs in a Vue
**post-flush callback** (`queuePostFlushCb`, decoupled from render), so a throw there
escapes `onErrorCaptured` and wedges the scheduler. Unlike a component error, it cannot
be made recoverable. The escape itself is symmetric, not a Vue weakness: a component
error boundary (React `ErrorBoundary`; vue-tui's `onErrorCaptured` wrapper) covers
framework-managed component work, never the renderer's paint callbacks, so a paint-layer
throw is uncatchable in **both** engines. Validating in `Box` / `Text` keeps a bad value
on that boundary-driven recoverable path; Ink's paint-time check can only crash. This is
a deliberately chosen fail-safe given a constraint that is symmetric across React and Vue
(recover-vs-crash), not a Vue-model-forced difference — hence an intentional choice rather
than a model-implied one.
- **Cost:** the component-layer check is eager (no paint-time layout/squash info), so it
over-throws in a few degenerate, invalid-input-only cases Ink never reaches. Realistic
inputs match Ink; both error on bad input. Only the channel (recoverable reject vs crash)
differs. Maintainer decision (2026-06-07): KEEP. vue-tui makes the more reliable
library choice here: reject the same invalid input with a recoverable, prop-specific
error instead of preserving Ink's lower-level paint crash and chalk implementation
message. Tests: `background-color.test.tsx`, plus the `borderStyle` validation tests.
## Non-Behavioral Notes
These notes are not divergence entries. They document Vue-facing conventions or internal
mechanics so they are not mistaken for parity gaps.
- Vue SFCs use `<script setup>`, and component definitions use `defineComponent()`.
- `shallowRef` is the default for reactive state. Use `ref` only when deep reactivity is
intentional and documented.
- The exported host-node type is **`TuiNode`** (`TuiContainer | TuiTextLeaf | TuiComment`,
from `@vue-tui/runtime/internal`), not Ink's DOM-emulation `DOMElement`
(`nodeName` / `attributes` / `childNodes`). vue-tui keeps a native host tree, so the
exported type names that tree; `measureElement` and template refs accept it. No runtime
behavior differs from Ink's DOM-emulation node.
- `null` / `false` / `undefined` / `v-if="false"` children are materialized by Vue as
comment vnodes, which vue-tui's host renderer turns into an inert `TuiComment`: no yoga
node, paints nothing, never shifts a sibling, and skipped when counting the positional
`<Transform>` index (the `child.type !== "comment"` guards in `paint.ts`,
`text-measure.ts`, and `screen-reader.ts`; `G52`). The result matches Ink, which drops
these children outright — React never renders `null` / `false` / `undefined` (verified
against Ink v7.0.4: `{false}`, `{null}`, `{undefined}` each produce no node; only a real
empty `<Box/>` occupies a flex-gap slot). `<Transform>` over an empty or all-comment slot
renders no node (`return null`), matching Ink's `children == null` guard for nullish
children and intentionally diverging for a literal `false` child. Ink's React
`children` check sees `false !== null` and creates an empty `ink-text` layout item that
can consume a flex-gap slot; Vue materializes `false`, `null`, `undefined`, and
`v-if=false` as the same comment-anchor shape, so vue-tui treats the all-comment slot as
absent. Maintainer decision (2026-06-07): KEEP. This is the correct Vue behavior:
conditional false children should behave like no node, not like an empty layout item.
- Commit timing is deliberately Ink-aligned: leading+trailing throttle at
`ceil(1000/maxFps)` ms (34ms at the default `maxFps=30`, matching Ink's
`renderThrottleMs`), synchronous resize. This remains true even though re-renders come
from Vue's fine-grained reactivity, not a React subtree re-render.