Ink's <Transform> returns null (no node) when children are undefined/null, and
that guard runs BEFORE the accessibilityLabel substitution (Transform.tsx:28-30).
vue always created a "transform" host node, so:
- an empty <Transform> in a flex `gap` row consumed a gap slot Ink never adds (P13); and
- a childless <Transform accessibilityLabel> emitted the label even though Ink's
null guard wins over it (P19).
Add the null-children guard at the top of the render fn. Vue materializes a bare
null/false/undefined/v-if=false child as a single Comment vnode and cannot tell them
apart, so the predicate treats the whole group as "no children" (slot undefined OR
every vnode is a Comment) — matching Ink for the common `{null}`/`{cond ? x : null}`
idioms and keeping <Transform> consistent with vue-tui's documented comment-anchor
model (every other component already omits a false/v-if child). An empty-string ({''},
a Text vnode) or JSX empty array ({[]}, a Fragment) still renders, matching Ink.
This deliberately diverges from Ink only for a literal {false} / {cond && x}-false
child (React's false !== null → Ink renders an empty gap-slot node); documented in
ink-divergences.md and locked by a test, since Vue physically cannot distinguish it
from null.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.8 KiB
vue-tui — Intentional Divergences from Ink
vue-tui started as a Vue 3 port of Ink, and it still tracks Ink closely — the aim is behavioral parity except where a difference is deliberate. But it is no longer just a port: it has grown its own design decisions, additive features, and Vue-native choices. This document records the places vue-tui intentionally differs from Ink — by design, not as a gap to fix. A difference that is not listed here is treated as a bug (or simply unverified), 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 read this
Each entry states what Ink does, what vue-tui does, and why the divergence is deliberate. Divergences fall into a few kinds:
- API surface — public API renamed/reshaped to fit Vue idioms.
- Additive — vue-tui supports something Ink doesn't (a strict superset).
- Framework semantics — a consequence of Vue ≠ React that cannot be papered over.
- N/A — a React-only concept with no Vue equivalent.
Public API surface
Entry point — createApp() instead of render()
- Ink:
render(<App/>, options?)—optionsisRenderOptions; returns anInstance. - vue-tui:
createApp(App)returns aTuiApp;app.mount(options?)takesMountOptions. - Why: mirrors Vue's own
createAppmental 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), notrender()-shaped (RenderOptions/Instance).
Host-node type — DOMElement → TuiNode
- Ink: exports
DOMElement, a DOM-emulation node (nodeName/attributes/childNodes). - vue-tui: the host tree is a different representation
(
TuiContainer | TuiTextLeaf | TuiComment), exported asTuiNodefrom@vue-tui/runtime/internal. - Why: vue-tui's renderer keeps a native host-node tree rather than a DOM emulation, so the exported node type names that tree, not a DOM node.
Additive features (vue-tui is a strict superset)
Multiple <Static> regions
- Ink: keeps a single
staticNode; only one<Static>is honored. - vue-tui:
findStatics(root)renders every<Static>in the tree. - Why: strictly more capable — a tree with two
<Static>regions both render. Maintainer decision (2026-05-30): KEEP.
Ctrl+C exits under the kitty protocol too
- Ink: exits only on the legacy
\x03byte (inApp), 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), viaparseKeypress— 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:
exitOnCtrlCis a contract that shouldn't depend on the wire encoding; keeping the lone exit at the single always-on layer avoids a two-place seam. Opt out withexitOnCtrlC: false. Maintainer decision (2026-05-30): KEEP. Tests:usePaste-only app exits on {legacy,kitty} Ctrl+Cininput-kitty.test.ts.
parseKeypress filters kitty query-responses (second safety net)
- Ink: filters kitty keyboard-protocol query-responses (
ESC[?Nu) in exactly one place — the auto-detection lifecycle inink.tsx(stripKittyQueryResponsesAndTrailingPartialon a privateonDatabuffer). Itsparse-keypress.tshas no query-response branch. - vue-tui: mirrors that detection layer (in
kitty-keyboard.ts) and adds a second net —parseKeypressreturns{ ignore: true }forESC[?Nu, whichuseInputthen drops. - Why: the detection layer does not cover the real input pipeline (
stdin 'data'→inputParser→emitInput→useInput→parseKeypress). Inenabledmode it never runs; inautomode itsonDatalistener and the stdin controller'shandleDataboth subscribe to the same'data'event, so stripping its private buffer can't stop the chunk reachinghandleData; and after detection settles the listener is gone. Empirically (Layer 2 removed, rebuilt) a stray query-response reaches auseInputhandler as spurious"[?1u"input in all of those cases — including a response split across two reads, whichinputParserreassembles before dispatch. So this is load-bearing, not redundant. Introduced 2026-05-31. Tests: "kitty query-response - end-to-end filtering" inkitty-lifecycle.test.ts(RED without it).
Non-Error thrown values keep their message in the error overview
- Ink:
ErrorOverviewrenderserror.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
ErrorOverviewshowsString(value)as the message, sothrow 'boom'rendersERROR boom, not a blankERROR. Like Ink, no stack block is rendered when the value carries no stack. - Why: strictly more informative for the (lint-discouraged) non-
Errorthrow, and it keeps the message vue-tui already surfaced before — when the boundary wrapped such throws innew Error(String(value)), which also produced a misleading synthetic stack pointing at the framework internals (that synthetic stack is now gone). Introduced 2026-05-31.
Framework-semantic divergences (Vue ≠ React)
Removing flexDirection / flexWrap resets to the default
- Ink: these two props have no reset branch in
applyFlexStyles(every other flex prop does), so an explicitflexDirection={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: the render is a function of the current props — with no value set you get the default, and (absent a special contract) dropping or changing a prop changes the output. Keeping a previous render's value, as Ink does for these two props, is the anomaly — and an inconsistent one, since every other flex prop resets. Maintainer decision (2026-05-30): KEEP.
Removing display resets to the default (visible)
- Ink:
applyDisplayStyles(styles.ts) setsDISPLAY_NONEwhenever an explicitdisplayis present and not'flex'— so a present-but-undefineddisplay={undefined}hides the box, and an omitteddisplaypersists the prior value. - vue-tui: a removed/undefined
displayresets to the Box defaultDISPLAY_FLEX(visible) — the same state as if the prop had never been set. - Why: same reasoning as the
flexDirection/flexWrapreset above — render = f(current props): nodisplayset → the default (visible). Persisting a withdrawn prop, or flipping it to hidden, is the anomaly. Maintainer decision (2026-05-31): KEEP.
Not applicable in Vue
React concurrent mode
- Ink: built on React; Suspense /
useTransitionare React features. - vue-tui: no equivalent — N/A, not a gap.
Framework idioms (noted, not behavioral divergences)
Surface conventions, listed so they aren't mistaken for gaps:
- Vue composables (
useFocus,useInput, …) instead of React hooks. - Composable return types follow VueUse's
UseXReturnconvention (UseStdinReturn,UseAppReturn, …) — Ink names the equivalent hook-return typesXProps(StdinProps,AppProps, …), but in vue-tuiXPropsis reserved for component props (BoxProps, derived viaExtractPublicPropTypes). The return shapes still mirror Ink field-for-field (e.g.useStdin()exposes only Ink's public{ stdin, setRawMode, isRawModeSupported }). <script setup>SFCs /defineComponentinstead of function components.- kebab-case filenames;
.tsover.tsxwhere there's no JSX. shallowRefby default for reactive state.
Reconciler/runtime mechanics that differ from React internally yet produce byte-identical
terminal output, because a commit always paints f(current host tree) — how the tree was
built never reaches the terminal:
- A
v-if=falsebranch (or anull/false/undefinedchild) leaves a comment anchor (TuiComment) where Ink emits no node, but it is inert: no yoga node, paints nothing, never shifts a sibling's yoga index, and is skipped for the positional<Transform>index in all three squash paths (G52). Output equals omitting the element. This also governs<Transform>'s own children guard: a childless<Transform>(or one whose only child is anull/false/v-if=falsecomment anchor) renders no node (matching Ink fornull, consistent with every other component). It diverges from Ink only for a literal{false}/{cond && x}-false child — React'sfalse !== null, so Ink renders an empty node (and a gap slot); Vue collapsesfalse/nullto the sameTuiCommentand cannot distinguish them, so it omits the node. Keeping<Transform>consistent with the comment-anchor model is the principled choice. - Commit timing is deliberately Ink-aligned — leading+trailing throttle at
ceil(1000/maxFps)≈ 32 ms (Ink'srenderThrottleMs), synchronous resize — even though re-renders are Vue's fine-grained reactivity, not a React subtree re-render. - Keyed lists use Vue core's
patchKeyedChildren(LIS), not React's fiber diff; output depends on the final tree, not the move order.
Maintainer additions
Space for divergences to add or refine. For each, capture: what Ink does, what vue-tui does, and why it's deliberate (the trade-off, not just the what).