Commit Graph

16 Commits

Author SHA1 Message Date
Yunfei He 2838d0218e docs(parity): record the round-2 deliberate divergences from Ink (#111)
The round-2 parity audit surfaced ~15 places where vue-tui deliberately differs from
Ink v7.0.4 (verified against Ink source, not invented). Document them so they aren't
re-"fixed" back into Ink's behavior:

Public API surface: useFocusManager().activeId is null (reactive ShallowRef) not
undefined (+ lock test); second mount() on a live stdout is an inert no-op vs Ink
reusing the instance; bare-string package exports vs an explicit types condition.

Additive (strict superset): RGB [r,g,b] tuples on every color prop (Ink string-only,
throws on an array); backgroundColor=chalk-modifier-name degrades to bare text (Ink
throws); useAnimation outside a tree drives a real scheduler; measureElement/
useBoxMetrics also accept a Vue component ref via $el; renderToString accepts
isScreenReaderEnabled; narrowing resize cancels the redundant trailing clearTerminal.

Framework-semantic (Vue ≠ React): an off-spec display value stays visible (Ink hides
any non-'flex'); out-of-type flex/align values are forwarded not defensively coerced
(only flexShrink — flexGrow matches Ink; reachable only via TS-bypass); duplicate
explicit-id useFocus dedups to one entry; the terminal-bound composables fail fast
outside a tree (useBoxMetrics/useAnimation degrade); a setup()-throw emits a dev-only
[Vue warn]. Plus wrapText truncate's per-line short-circuit and the scheduler's
ceil'd delay under reconciler mechanics.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:03:57 +08:00
Yunfei He 9ed1833207 docs(parity): record the useCursor re-assertion framework-semantic divergence (#110)
Investigating P15 (a stable-reference cursor dropped on an unrelated commit) found
the premise was false: Ink does NOT re-assert the cursor on every commit. Ink's
useCursor uses a no-deps useInsertionEffect that re-runs only when the cursor
COMPONENT re-renders. React re-renders a whole subtree on an ancestor's commit, so
Ink re-asserts when the cursor is in that subtree — but when an unrelated SIBLING
owns the changing state, the cursor component does not re-render and Ink drops the
cursor too. vue (watch on positionRef) already matches Ink in that sibling case and
for the recommended reactive usage; the two differ only in the narrow edge of a
set-once cursor plus an ancestor-driven commit (Vue's fine-grained reactivity vs
React's render cascade). A global per-commit re-assert would diverge from Ink in the
opposite (sibling) direction. So this is an unavoidable Vue ≠ React consequence:
document it and keep the reactivity-tied behavior rather than "fix" it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:46:52 +08:00
Yunfei He 224afeb3a2 fix(runtime): <Transform> with no children renders no node, matching Ink (#105)
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>
2026-06-01 00:29:21 +08:00
Yunfei He 8e14081a7c docs(runtime): document and test the kitty query-response filter as a load-bearing divergence (#93)
The parseKeypress kitty query-response filter (ESC[?Nu -> {ignore:true}, dropped by useInput)
was assumed to be a redundant second net duplicating the upstream kitty-keyboard detection
strip. Verified it is NOT redundant: the upstream strip runs only in the one-shot
confirmKittySupport detection listener (a private buffer), not on the steady-state input path
(stdin 'data' -> inputParser -> emitInput -> useInput -> parseKeypress). Removing the filter
leaks a stray query-response to handlers as spurious "[?1u" input in enabled mode, auto mode,
and split delivery (inputParser reassembles, so the upstream partial-handling doesn't apply).
Ink has the same gap (its parse-keypress has no query filter; its steady-state input uses
'readable', not 'data').

Keeps the filter (adds a why-comment) and records it as an additive divergence in
ink-divergences.md. Adds 4 end-to-end regression tests proving the filter is load-bearing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:02:40 +08:00
Yunfei He 8c3e97ab47 fix(runtime): removing display resets to the default (visible), not persist (Ink divergence) (#89)
vue-tui left `display` out of RESETTABLE_PROPS, so a removed/undefined `display` persisted
its prior value (a removed display="none" stayed hidden). Adds `display` to RESETTABLE_PROPS
-- the setter already maps undefined -> DISPLAY_FLEX -- so a withdrawn `display` returns to
the Box default (visible), per render = f(current props), like flexDirection/flexWrap (G19).

Deliberate, documented divergence from Ink (which hides on a present-undefined `display` via
DISPLAY_NONE, and persists on omitted) -- recorded in ink-divergences.md. The reset is
consistent across the visual and screen-reader paths (both read yoga's display state).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:48:08 +08:00
Yunfei He 9b5eac3ba4 feat(runtime): port ErrorOverview to Ink's full error frame (parity) (#81)
ErrorOverview was a one-line `Name: message` stub; Ink v7.0.4 renders a full
ERROR overview. Ports it faithfully:

- white-on-red ` ERROR ` label + message, dim cwd-relative file:line:column origin
- a code excerpt around the throwing line (padded gutter, error line highlighted,
  `Line N` / `Line N, error` aria-labels)
- the parsed stack (`- fn (file:line:col)`, cwd-relative, StackUtils nodeInternals
  filtering, unparsable-line fallback, fs.existsSync guard)

Adds code-excerpt@4.0.0 + stack-utils@2.0.6 (the versions Ink uses). The error
boundary now keeps the raw thrown value for display (Ink stores the raw value;
ErrorOverview renders a stack only when one exists) -- so a non-Error throw no
longer shows a misleading synthetic framework stack. The exit/reject path still
receives a wrapped Error (semantics unchanged). The unparsable-stack fallback
emits a literal backslash-t to match Ink's JSX.

Showing String(value) for a non-Error message (vs Ink's blank) is a documented
additive divergence (ink-divergences.md).

Adds 4 error-overview tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:26:02 +08:00
Yunfei He 26cf40987b feat(runtime)!: narrow useStdin to Ink's public surface; name composable returns UseXReturn (#79)
useStdin() returned the full internal StdinContext (8 members incl. the raw-mode
ref-counting primitives acquireRawMode/releaseRawMode, setBracketedPasteMode, and
internal_eventEmitter/internal_exitOnCtrlC). Ink's useStdin() returns only its PublicProps
— { stdin, setRawMode, isRawModeSupported } — keeping the rest on the internal context,
reached via the internal useStdinContext()/inject. Verified against Ink 7.0.4
(src/hooks/use-stdin.ts:10, src/components/StdinContext.ts).

- Narrow useStdin(): UseStdinReturn (the 3 public fields). The full StdinContext stays
  internal, reached by useInput/useFocus/usePaste via inject(StdinContextKey) — the runtime
  object is unchanged, only the public type narrows (mirrors Ink's type-level narrowing).
- Name every stdio/app composable return type per VueUse's UseXReturn convention and export
  them: UseStdinReturn, UseStdoutReturn, UseStderrReturn, UseAppReturn (shapes byte-identical
  to Ink's StdinProps/StdoutProps/StderrProps/AppProps). vue-tui reserves XProps for component
  props (BoxProps, via ExtractPublicPropTypes), so composable returns use UseXReturn — the
  Vue-community-idiomatic name.
- Unify the two pre-existing return types onto the same convention:
  AnimationResult → UseAnimationReturn, UseBoxMetricsResult → UseBoxMetricsReturn.
- Type-level test (public-types.test-d.ts) locks the shapes and asserts useStdin()'s public
  return excludes the internal members.
- Docs: trim the type-reexport divergence area to genuine divergences (fold
  RenderOptions/Instance → MountOptions/TuiApp into createApp; keep DOMElement → TuiNode);
  drop the AppProps/StdinProps/StdoutProps/StderrProps "N/A" entry — those Ink names are the
  hook return types, now mirrored as UseXReturn (recorded under Framework idioms). Supersedes #77.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:37:46 +08:00
Yunfei He cc44098a84 docs: resolve Vue-vs-React semantics placeholder (all 3 are idioms, not divergences) (#76)
The placeholder listed three candidates to evaluate as by-design divergences:
v-if/null comment host nodes, reactivity-driven re-render timing, and keyed
reconciliation order. Grounding each in the runtime code shows all three produce
byte-identical terminal output vs Ink — a commit always paints f(current host
tree), so *how* the tree was built never reaches the terminal. None is a
divergence, so the placeholder is removed and the three are recorded as concise
one-liners under "Framework idioms (noted, not behavioral divergences)":

- v-if=false / null|false|undefined children become an inert Comment vnode
  (TuiComment) — no yoga node, paints nothing, doesn't shift a sibling's yoga
  index, skipped for the positional <Transform> index (G52); output equals
  omitting the element.
- Commit timing is deliberately Ink-aligned (~32ms ceil(1000/maxFps) throttle,
  sync resize), so Vue's fine-grained re-render granularity stays unobservable.
- Keyed lists use Vue core's patchKeyedChildren, not React's fiber diff; output
  depends on the final tree, not the move order.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:28:31 +08:00
Yunfei He 369442d4b2 fix(runtime): exit on Ctrl+C under kitty for raw-mode-only apps (#75)
Move the exitOnCtrlC guard into the always-on stdin controller (emitInput),
encoding-agnostic via parseKeypress, so Ctrl+C exits under both the legacy
\x03 byte and the kitty CSI-u form regardless of which composable holds raw
mode (useInput / useFocus / usePaste, or none). Single source of truth —
dropped from useInput. Excludes Ctrl+Shift+C; fast-paths \x03 and only parses
escape-prefixed sequences. Adds TDD PTY coverage and updates the divergence doc.

Also: stop tracking docs/superpowers/plans/2026-05-27-ink-test-parity.md —
docs/ must stay out of git (AGENTS.md); it was committed before .gitignore
covered it.
2026-05-31 00:17:21 +08:00
Yunfei He 99eb50c8ff docs(parity): record the flexDirection/flexWrap divergence (concise) (#71)
Ink has no reset branch for these two props (every other flex prop does),
so explicit `={undefined}` leaves a stale value. vue-tui resets to the
row/nowrap default. Why: the render is a function of the current props —
absent a special contract, dropping/changing a prop changes the output;
Ink keeping a previous render's value is the anomaly. KEEP.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:46:56 +08:00
Yunfei He bfd680490f refactor(runtime)!: rename useAppContext() to useApp() (full Ink alignment) (#73)
#69 added `useAppContext()` as a Vue-native rename of Ink's `useApp()`,
qualified to avoid reading as the Vue application instance. On reflection the
"Context" suffix borrowed the name of an internal grab-bag context and slightly
mislabels the hook — it returns app lifecycle controls, not that context. The
collision worry doesn't hold up: Vue has no `useApp()`, the returned
`{ exit, waitUntilRenderFlush }` is clearly not the Vue app instance, and "App"
in vue-tui already means the `TuiApp` from `createApp()`.

Rename to `useApp()` for full Ink fidelity (same name + same shape), and drop
the now-defunct "App composable" entry from ink-divergences.md — it ceases to
be a divergence.

Internal context cleanup (the grab-bag `AppContext` + the `StdinContext`
duplication) is intentionally out of scope here, tracked separately.

BREAKING CHANGE: `useAppContext()` is renamed to `useApp()`. Replace
`const { exit } = useAppContext()` with `const { exit } = useApp()`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:32:22 +08:00
Yunfei He a8105edbf2 docs: drop accessibility from Ink-divergences (it's parity, not a divergence) (#72)
The "Accessibility props — AriaRole / AriaState" entry claimed "Ink: no
equivalent" and filed aria/screen-reader support under additive features. That
is incorrect: Ink has full accessibility support at this doc's own baseline
(v7.0.4, commit 40b3a75) — aria-label/aria-hidden/aria-role/aria-state on
Box/Text, useIsScreenReaderEnabled, and screen-reader linearization in
render-node-to-output. vue-tui's implementation is a faithful port of it; the
parity commits G03–G59 align the linearization with Ink (e.g. G22's parent-role
dedup matches Ink's `role !== parentRole`).

Accessibility is parity-by-default, so it does not belong in a doc that records
only intentional divergences. Remove the entry; the feature itself is unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:03:30 +08:00
Yunfei He 9d1e4d7805 feat(runtime)!: replace useExit() with Ink-aligned useAppContext() (#69)
Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.

- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
  impl shared by the `TuiApp` handle and the composable, so both resolve
  identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
  fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
  React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
  (`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
  surface one — and fix the prior wrong claim that Ink's `useApp` returns
  stdin/stdout/stderr

The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.

BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:57:52 +08:00
Yunfei He 6edc51b925 feat(runtime): re-export Ink-aligned named prop/data types (BoxProps, …) (#70)
vue-tui withheld Ink's named prop types (BoxProps, TextProps, StaticProps,
TransformProps, NewlineProps) and the WindowSize/CursorPosition data shapes
under a blanket "avoid React-shaped type names" rule. That rule over-reached:
a <Box> has props in Vue exactly as in React, so those names carry no
React-vs-Vue content — there's no reason to rename them. Re-export them under
Ink's names so a consumer can name a component's props the same way as in Ink.

- Derive each XProps from the component's runtime `props` object via Vue's
  `ExtractPublicPropTypes`, so the public type can never drift from the real
  props. Pin `required: true as const` on Static.items / Transform.transform:
  a standalone `const` widens `true`→`boolean`, which would otherwise drop them
  from the required keys — in both the exported type AND the component's own
  `setup(props)` typing.
- Add `WindowSize { columns, rows }` and `CursorPosition { x, y }`, anchored to
  their real usage in useTerminalSize / useCursor. (The composables still return
  reactive refs of these — the data shape matches Ink; the ref wrapper is the
  framework difference.)
- Keep the genuinely-divergent names as-is: DOMElement→TuiNode (real
  DOM-emulation vs host-node difference), RenderOptions/Instance→MountOptions/
  TuiApp (downstream of createApp()), App/Stdin/Stdout/StderrProps = N/A. Rewrite
  the ink-divergences doc to record what's now aligned vs still divergent.
- Add a tsc-checked type-level test (public-types.test-d.ts) asserting the
  exported shapes; it is excluded from vitest's runtime run by naming.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:18:19 +08:00
Yunfei He 688da13872 fix(runtime): keep text-measure helpers internal, matching Ink (#68)
Ink keeps its `measure-text` module internal and never re-exports it. vue-tui
exported `measureText` / `measureTextNatural` from the public index under the
mistaken belief — stated verbatim in commit 0e7d775's own message — that doing
so "matched Ink's public API". It does not; Ink keeps that module internal. A
later design doc then rationalized the leak post-hoc as an intentional
divergence. It was neither intentional nor a divergence — it was a mistake.

Align with Ink:
- Drop both from the public index. `measureTextNatural` stays as an internal
  helper (yoga.ts uses it). `measureText` had zero production callers (yoga uses
  `wrapText` + `measureTextNatural`, never `measureText`) and is removed.
- Integration tests that used `measureText(stripAnsi(x), 9999).width` as a
  line-width helper now use `stringWidth(stripAnsi(x))` directly.
- public-api.test.ts gains a regression test asserting neither is exported.
- Remove the now-obsolete entry from .agents/docs/ink-divergences.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:53:34 +08:00
Yunfei He 936f2ee1ee docs: replace sprawling Ink-parity docs with one focused intentional-divergences doc (#67)
* chore: remove Ink-parity design docs (.agents/docs), keep the code

Removes the Ink-parity loop documentation — ink-parity-loop.md, ink-parity.md,
and the parity-ledger.md — and drops the now-dangling 'Current docs:' list from
AGENTS.md's Context Engineering section (the convention itself is kept). All the
merged parity CODE fixes remain on main; only the documentation/ledger artifacts
are cleared, to re-orchestrate the documentation with a different approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add focused Ink intentional-divergences design doc

Replaces the removed sprawling parity docs with one focused doc that records
ONLY where vue-tui deliberately differs from Ink (API surface, additive
features, unavoidable Vue-vs-React semantics, N/A React concepts, framework
idioms) — leaving placeholders for the maintainer to supplement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:11:46 +08:00