Commit Graph

376 Commits

Author SHA1 Message Date
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 7451cd4cd6 docs: add usePaste/useBoxMetrics/useCursor/useAnimation to README composables
The composables table was missing four exported composables. Add them
and note that `useTerminalSize` is also exported under Ink's
`useWindowSize` name.

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 d7e6898745 docs: require verifying behavior by running, not source-reading
A source-reading audit of the Ink-divergence ledger was wrong on all
three of its highest-confidence calls; running the real Ink/vue-tui
harness overturned them. Add a guideline: behavior claims (parity
assertions, "what Ink does" lines) are hypotheses until a real run
against the pinned version confirms them.

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 ddd651b5e0 chore: bump all packages to 0.0.3 (#148)
Bump @vue-tui/runtime, @vue-tui/cli, and @vue-tui/testing from 0.0.2 to 0.0.3.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:59:34 +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 1340c049b2 fix(runtime): call onRender before frame writes
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 21:04:34 +08:00
Yunfei He 65ac088001 fix(runtime): align stdout writability guards
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:56:25 +08:00
Yunfei He 941fff1845 fix(runtime): track useFocus autoFocus updates
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:45:57 +08:00
Yunfei He 7bf033b009 fix(runtime): freeze useAnimation frame on batched pause+interval change (Ink parity) (#143)
Pausing (isActive→false) in the SAME synchronous batch as an interval change froze
the frame at 0 instead of the last live frame. vue-tui split Ink's single render-time
`shouldReset` into TWO `flush:"sync"` watchers; sync fires once-per-mutation, so
`interval.value = X; isActive.value = false` ran the interval watcher first (while
still active) → erroneous start() zeroed the frame, before the isActive watcher
stop()'d.

Replace them with ONE `flush:"post"` watcher on `[isActive, interval]` that coalesces
the batch and fires once with the final values, mirroring Ink's
`shouldReset = isActive && (intervalChanged || becameActive)` (use-animation.ts:77-96):
paused → stop() (freeze, no reset); active + (becameActive || intervalChanged) →
start(). `immediate:true` keeps the initial mount synchronous (one subscribe, no
double-subscribe).

flush:"post" was verified to fire in the BLESSED standalone (no-component) fallback:
Vue's post-flush queue flushes on any reactive mutation's microtask, independent of
component updates.

Adds tests: batched pause+interval (both orders) freezes; resume zeros then advances
at the new interval; same-interval rerender does not reset; and two standalone
(no-render-tree) cases.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:57 +08:00
Yunfei He 7707322382 fix(runtime): align debug-mode unmount byte stream to Ink (#142)
* fix(runtime): align debug-mode unmount byte stream to Ink

In DEBUG mode (non-interactive) vue-tui's teardown emitted neither a final-frame
re-emit nor a trailing newline, while Ink emits both (ink.tsx:749-762 settleThrottle
re-emit + ink.tsx:812-819 `debug ? '\n' : lastOutput + '\n'`). So for a debug app
that renders "Hello" once, Ink's byte stream is "HelloHello\n" but vue-tui's was
just "Hello" — a divergence that matters when porting Ink debug snapshots / CI logs.

- Fire the final-frame re-emit `mountedCommit()` for debug too (was interactive-only).
- In the non-interactive teardown write, emit a bare "\n" for debug (Ink parity),
  keeping the non-debug `lastFrame + "\n"` branch byte-identical.

Because the @vue-tui/testing render() helper captures debug commits via an internal
frame sink, gate the debug commit's two `frameSink?.(...)` forwards on
`!teardownStarted` so the teardown re-emit (a stdout byte-parity FLUSH, not a render)
does not append a spurious entry to the helper's live `frames[]`. `teardownStarted`
is set at the top of teardown() before the re-emit, so this covers EVERY teardown
route (unmount / cleanup / exit / Ctrl+C / signal / process.exit). Both `stdout.write`
calls stay unconditional, preserving byte parity.

Adds a PTY byte-parity test (asserts "HelloHello\r\n") and a testing-helper test
covering all teardown routes (frames.length stable, incl. <Static>).

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

* test: drop CI-fragile exitOnCtrlC frames-teardown case

The Ctrl+C case timed out in CI (waitUntilExit never resolved — stdin/raw-mode
timing is environment-fragile), while passing locally. Ctrl+C routes through the
SAME exit-driven teardown path as programmatic useApp().exit()
(emitInput → appContext.exit() → teardown()), which the remaining exit() cases
already cover, so removing it loses no teardown-route coverage of the
!teardownStarted frame-sink gate.

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-05 13:31:35 +08:00
Yunfei He ea5ef18325 fix(runtime): hard-wrap at width 0 must use wordWrap:false (Ink parity) (#141)
`<Text wrap="hard">` measured at width 0 dropped one blank row per interior word
boundary, so it measured a shorter height than Ink. Ink's wrap-text.ts uses
`{hard:true, wordWrap:false}` for `hard` mode and `{hard:true}` for `wrap` mode;
vue-tui's width-0 path (wrapZeroWidthAnsi) always used the `wrap` options
regardless of mode.

Thread the wrap mode into wrapZeroWidthAnsi and select
`{hard:true, trim:false, wordWrap:false}` for `hard` (vs `{hard:true, trim:false}`
for `wrap`) at width 0, matching Ink. The non-zero `hard` branch already used
wordWrap:false, so this makes the width-0 path consistent with it. The re-styling
loop is unchanged (extra blank rows pass through as empty strings).

width-0 hard "a b c" now measures 8 rows (['','a',' ','','b',' ','','c']) like
Ink, not 6. `wrap` mode and all non-zero widths are byte-identical.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:37:43 +08:00
Yunfei He f6a552d855 fix(runtime): don't hide the cursor for an empty interactive app (Ink parity) (#140)
vue-tui hid the terminal cursor EAGERLY at mount regardless of content, so an
interactive app whose root renders nothing emitted `\x1b[?25l` and hid the
user's cursor. Ink hides LAZILY (log-update, on the first render that writes)
and skips log-update entirely for an empty frame, so an empty app emits zero
cursor escapes.

Remove the eager mount-time hide and rely on log-update's lazy hide. That alone
was insufficient: an empty frame becomes "\n", and the old commit gate
`willRender(outputToRender) || isCursorDirty()` was true for "\n", so log-update
(and its lazy hide) was still reached. Align the outer commit gate to Ink's
exact condition (ink.tsx:1094) `output !== frameState.lastOutput || isCursorDirty()`,
comparing the RAW frame; on an empty first commit both are "" so log-update is
never reached. `willRender` is retained only for the inner BSU/ESU wrap gate.

Verified via PTY: empty app = 0 hides; non-empty = 1 lazy hide; useCursor =
hide-then-show within one render (SHOW last, cursor positioned). alt-screen,
screen-reader, and non-TTY cursor behavior unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:21:10 +08:00
Yunfei He c05cb5df36 fix(runtime): correct misleading measureElement() timing guidance (#139)
A bare `measureElement()` called inside `watchPostEffect` reads layout BEFORE
the commit scheduler's post-flush `calculateLayout` runs, so it returns an
uncomputed value (NaN for computed dimensions), not the current size. The JSDoc
previously recommended that exact broken call site.

Align the guidance to vue-tui's real post-flush timing: defer the read with
`nextTick(() => measureElement(ref.value))` — the pattern `useBoxMetrics` itself
uses — or read from an input/timer callback that fires after a flush; prefer
`useBoxMetrics` for reactive metrics. Also correct the stale claim that a
pre-layout read returns `{0,0}` (it returns NaN for an attached-but-uncomputed
node; `{0,0}` is only the detached case).

Adds a characterization test pinning bare-watchPostEffect = NaN vs
nextTick = real width (80), guarding against regressing to the old advice.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:43:52 +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 c8c2c4a32d test: backfill Ink-parity coverage gaps (suite as a superset of Ink) (#130)
Regression tests for behaviors the audit found correct-but-unpinned, so the
suite is a strict superset of Ink:
- A07: two <Static> regions both render (the additive divergence)
- B04: Static render-prop index = absolute index across appends; container
  vertical padding adds blank rows to the static frame
- B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the
  'always' default masks)
- B19: child useCursor unmount emits the cursor-hide escape (stream-level)
- B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and
  advances without busy-hang
- B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled
  true-path (env tests isolated in a *.sequential file per the global-state rule)
- B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/
  useBoxMetrics as inert no-ops (don't throw)
- B30: dedicated columnGap/rowGap props + their removal-reset

Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink
correctness, and process-global isolation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:42:33 +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 1bad1014da fix(runtime): guard teardown cursor/kitty restore writes against a destroyed stdout (Ink parity) (#127)
Two more teardown stdout writes (besides the paste-disable fixed in #126) were
gated only on isTTY, which stays cached-truthy after destroy()/end() — so a
teardown on an already-gone stdout threw ERR_STREAM_DESTROYED: log-update's
show/hide-cursor restore and kitty-keyboard's async disable-kitty write. Both
now also require !destroyed && !writableEnded, matching Ink's canWriteToStdout
(App.tsx:620, ink.tsx:792). The writeBestEffort-routed restores (alt-screen
exit, non-interactive last frame, final commit) were already safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:04:26 +08:00
Yunfei He d35d81b1a0 fix(runtime): guard bracketed-paste-disable write against a destroyed stdout (Ink parity) (#126)
The `\x1b[?2004l` paste-OFF write at teardown was gated only on `stdout.isTTY`,
which stays cached-truthy after a stream is destroy()ed/end()ed — so a teardown
where stdout is already gone threw `ERR_STREAM_DESTROYED`. Route both `?2004l`
sites (the setBracketedPasteMode disable branch and the dispose teardown
backstop) through a `disableBracketedPaste()` helper that gates on
`isTTY && !destroyed && !writableEnded`, matching Ink's `canWriteToStdout`
(App.tsx:620/633). The live-stdout happy path is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:51:30 +08:00
Yunfei He 7e7b255277 fix(runtime): coerce non-string text to String() at the host sink (Ink parity) (#125)
Ink's setTextNodeValue (dom.ts) coerces any non-string to String(text) before
storing it, on both the create and update paths; vue-tui stored the raw value.
Coerce at both host text sinks — createTextLeaf (create) and setText (update) —
matching Ink. Reachable only via a TS-bypass (Vue stringifies text/number
children before the host op), so it's a defensive safety-net; typeof-guarded so
string values are stored unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:36:22 +08:00
Yunfei He 6cb7875103 fix(runtime): unknown borderStyle string throws (Ink parity) (#124)
An unknown borderStyle name (reachable only via a TS-bypass — the prop type is
the cli-boxes keyof union) silently degraded to no border (and wrongly reserved
a 1-cell inset). Ink throws a TypeError on it. Align to that "throw on unknown"
contract by validating in the Box component's render, so the throw is caught by
vue-tui's error boundary (onErrorCaptured -> ErrorOverview), like any other
render error — rather than in paint, where a throw would unwind through Vue's
post-flush commit and wedge the scheduler.

The check resolves cliBoxes[borderStyle] and throws unless it's a genuine
BoxStyle (an object with a string `top` glyph), so unknown names, the cli-boxes
CJS-interop `default` self-key, and inherited prototype names (toString,
constructor) all throw; valid names, false/undefined (no border), and a custom
BoxStyle object are unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:27:13 +08:00
Yunfei He eaf05466b0 fix(runtime,testing): debug-mode Ink byte-parity + source-hook frame capture (#123)
Two coupled changes, both about debug mode (which @vue-tui/testing's render()
is built on):

1. Debug stdout is now byte-identical to Ink v7.0.4: the debug commit branch
   re-emits the FULL accumulated <Static> history every frame (not just the
   per-commit delta), writes every frame unconditionally (no FrameWriter
   dedup), and drops the synthetic trailing "\n" — matching Ink's
   `fullStaticOutput + output` (ink.tsx:558, output.ts has no trailing newline).

2. The test frame-capture no longer reverse-engineers frames out of stdout.
   The runtime exposes an internal, per-app frame sink (INTERNAL_FRAME_SINK,
   a Symbol from @vue-tui/runtime/internal; the public MountOptions type is
   untouched). The debug branch hands each committed frame to the sink,
   mirroring the stdout writes. @vue-tui/testing's render() builds
   frames[]/lastFrame() from the sink instead of sniffing stdout.

Why: an isTTY:true test stdout (which render() needs for the interactive resize
listener) lets isTTY-gated escapes — bracket-paste \x1b[?2004h/l from usePaste —
land in a stdout-sniffing capture and pollute frames[]. Capturing at the source
makes frames[] provably content-only regardless of which composables a test
mounts, while public debug stdout stays byte-exact to Ink (escapes still written,
not debug-gated). The test surface stays cleanly tiered (Ink's model): render()
= content; createApp+debug:false = in-process control sequences; PTY = real
terminal. '' floor, verbatim SGR/OSC8, frames[] multi-frame/static semantics,
and terminal.resize() are all preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:37:36 +08:00
Yunfei He 7ace8f735a fix(runtime): setRawMode(false) throws on unsupported stdin (Ink parity) (#122)
useStdin().setRawMode(false) on a non-TTY stdin silently no-opped while
setRawMode(true) threw — an asymmetry. Ink's handleSetRawMode throws before
the enable/disable split (App.tsx:315), so both directions throw on an
unsupported stdin, and its test asserts both mount-enable and unmount-disable
throw without ever calling stdin.setRawMode.

Move the isRawModeSupported guard into the public setRawMode wrapper (via a
shared throwRawModeUnsupported helper reusing the existing messages) so both
true/false throw. Internal acquireRawMode/releaseRawMode are unchanged —
composables (useInput/useFocus/usePaste) call those directly, so teardown
release stays a no-op and an unsupported-stdin app still unmounts cleanly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:04:43 +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
Yunfei He c6be1f5a7a fix(runtime): attach the stdin input listener per-controller so two apps share one stdin (#118)
The terminal raw-mode toggle is refcounted per-stdin (a WeakMap shared across
controllers) so one app's unmount can't drop raw mode while another still needs
it — vue's deliberate improvement over Ink, whose per-App counts let the first
unmount disable raw for everyone. But the "data" input listener was ALSO gated
on that shared refcount, so when two apps (separate createApp/stdout) shared one
stdin, only the first app's handleData ever attached and the second was
permanently deaf — and it didn't even self-heal when the first unmounted (Ink at
least does, via its per-App readable listeners).

Make the "data" listener (and its synchronous clearInputState cleanup: parser
reset, pending-escape flush, listener detach) PER-CONTROLLER, gated on this
controller's own localRefs, while keeping the raw-mode enable/disable on the
shared refcount. Because vue uses the "data" (push) event, every listener gets
every chunk, so both apps now receive input — strictly better than Ink's
"readable" (pull) model where the first-registered listener drains the buffer.

Single-app behavior is byte-identical: with one controller localRefs and the
shared refs move 1:1, so the same-tick swap (parser reset + listener re-attach)
fires at exactly the same moments as before.

Test: two apps sharing one stdin both receive a keystroke, and the second keeps
receiving after the first unmounts while raw mode stays enabled (shared ref);
raw mode disables only when the last app unmounts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:23:55 +08:00
Yunfei He 835e026336 fix(runtime): sync raw-mode disable at teardown + same-tick swap & focus-nav Ink parity (#117)
Three small raw-mode/focus corrections, each aligned to Ink v7.0.4 and
covered by a test (TDD red→green where reachable).

1. Raw mode left ON after a synchronous signal exit (Ctrl+C). The terminal
   raw-mode disable is deferred to a microtask (so it survives same-tick
   component swaps), but on the signal-exit path teardown(true) re-raises the
   signal synchronously without draining microtasks, so the disable never ran
   and the shell stopped echoing after Ctrl+C. dispose() now forces the disable
   SYNCHRONOUSLY when raw mode is no longer owned (state.refs === 0 and either
   this dispose released the last ref or a release left pendingDisable set),
   mirroring Ink's unmount-cleanup guard `rawModeEnabledCount > 0 ||
   pendingDisableRawModeRef.current` (App.tsx:626-631). The disable stays gated
   on the SHARED refcount, so a multi-app teardown can't disable while another
   app still holds raw mode.

2. Same-tick useInput swap re-issued setRawMode(true) + stdin.ref() and leaked
   a libuv ref (the deferred disable bailed on refs>0 and never unref'd). Added
   a pendingDisable flag to RawModeState mirroring Ink's pendingDisableRawModeRef
   (App.tsx:331-344): on re-acquire while a disable is pending, skip
   ref()/setRawMode(true) and cancel the queued disable.

3. focusNext/focusPrevious start-index logic factored into a shared
   startSearchIndex() helper so the two directions stay symmetric. Behavior is
   identical for all reachable states; it additionally folds the (unreachable
   while the activeId invariant holds) "activeId not in list" case into the
   same branch instead of diverging per-direction.

Tests: raw-mode-lifecycle.test.tsx (swap no-op, replacement still receives
input, synchronous teardown disable); programmatic-focus.test.tsx (no-active
first/last targeting + active-focus step/wrap via the manager API).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:29:14 +08:00
Yunfei He 48558c5af6 test(runtime): lock reconciler, measure, flex, overflow, build-output (Ink parity) (#116)
Final round-2 test-only batch (behaviors already at parity with Ink reconciler.tsx,
measure-text.tsx, flex-*.tsx, overflow.tsx, build-output.ts):
- build-output: every package.json export target resolves on disk (runtime/cli/testing)
  + the .d.mts declaration sibling for the typed libraries (runtime/testing, not cli).
- reconciler: keyed insert-between [a,c]→[a,b,c]; replace a colored <Text> child with a
  plain string; setElementText A→B + the text-context guard; marginLeft removal reset.
- measure: empty <Text> contributes height 0 in a column; non-zero left (marginLeft=5 →
  5,1); measureTextNatural trailing/only-newline heights.
- flex: alignSelf='auto' == default + alignSelf removal resets to AUTO; the two
  space-around known-yoga-bug cases converted from test.skip to test.fails (they assert
  the DESIRED output and flip to a real failure if yoga ever fixes the bug); the documented
  flexDirection/flexWrap removal-reset divergence (was comment-only) now has a visual lock.
- overflow: out-of-bounds writes produce Ink's exact clipped frame (sparse past-width cell,
  filtered hole) — tightened from toBeDefined().
- components: inline + top-level non-empty fragment in <Text>; the previously-skipped
  ST-terminated OSC-8 hyperlink hard-wrap now passes ('abcde\nfghij') — un-skipped as a lock.

Codex-reviewed GENUINE.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 03:28:35 +08:00
Yunfei He 7d6a92ea15 test(runtime): lock render lifecycle, exit, error-handling, alt-screen, use-stdout (Ink parity) (#115)
Round-2 test-only locks (behaviors already at parity with Ink test/render.tsx,
exit.tsx, errors.tsx, hooks.tsx):
- resize re-render reflows content to the new width (exact narrowed round-border bytes)
  + consecutive width decreases each clear; onRender fires exactly once per rerender
  (tightened from `>`); onMounted runs before the first frame write callback (#596);
  bsu/esu wraps a trailing throttled content change, and an unchanged trailing rerender
  emits neither; patchConsole puts a log above the live frame.
- exit-with-static #397 non-duplication (A/B/C each render once) — the fixture is fixed
  to function slots so no [Vue warn] pollutes stdout; exit-with-thrown-error via run()'s
  strict exit-0 gate; DEV is inert; the process stays alive ~500ms while raw mode is held.
- raw mode is disabled on the thrown-error cleanup path; the unhandledRejection test moved
  to a *.sequential file (it mutates a process-global listener).
- alternate-screen enter sequence hides the cursor; useStdout().write() preserves the
  frame with the external write ordered above it.

Codex-reviewed GENUINE.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 03:07:35 +08:00
Yunfei He a78fd67cea test(runtime): lock vt220 fn-keys, terminal-size fallbacks, animation offsets, arrow meta (#114)
Round-2 test-only locks (behaviors already at parity with Ink):
- parse-keypress (Ink parse-keypress.ts): Ctrl+F1–F4 (\x1b[1;5P/Q/R/S → f1–f4 ctrl),
  unmapped ctrl (\x1b[1;5I/X → name '' ctrl), Shift+F1 (\x1b[1;2P → f1 shift).
- terminal-size (Ink terminal-resize.tsx): 0-columns → positive fallback;
  resize-listener returns to baseline on unmount; env.LINES rows fallback (a .sequential
  file — mutates process.env/stdout; deletes absent env vars in teardown to avoid pollution).
- use-animation (Ink use-animation.tsx, a .sequential file with deterministic fake timers):
  newly mounted/activated same-interval animations don't inherit elapsed time
  (firstFrame - secondFrame === 1); a re-render with an unchanged interval doesn't reset
  the frame (forced via an unrelated reactive dep — a same-value assign is a Vue no-op);
  reset is a stable reference across re-renders (collected in the render fn).
- use-input: plain arrows assert key.meta === false (Ink's `&& !key.meta` gate).

Codex-reviewed; the same-value-no-reset (was vacuous) and env teardown (left "undefined")
were tightened per its notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:49:31 +08:00
Yunfei He eebbb37118 test(runtime): lock focus activeId + screen-reader coverage, mirroring Ink (#113)
Round-2 test-only locks (behaviors already at parity):
- focus (Ink test/focus.tsx): activeId resets to null on Esc, updates on programmatic
  focus(id), resets on unmount of the focused item, is null initially then Tab lands on
  first; Esc does NOT clear focus while focus management is disabled; focus(id) targets
  a deactivated (isActive=false) item (membership-only, ignoring isActive).
- screen-reader (Ink test/screen-reader.tsx) via the live renderToString component path:
  aria-label-only Text/Box, display:none subtree skipped, column>row space-join,
  single-box aria-states (checked/selected/multiselectable + multi-state ', '-join),
  role-only button, Transform accessibilityLabel replaces children.

Codex-reviewed GENUINE (expected strings match vue output + Ink intent).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:30:28 +08:00
Yunfei He d5c3f39871 test(runtime): lock exact-byte output (cursor-helpers, render-to-string, text), matching Ink (#112)
Round-2 test-only locks tightening lax `.toContain()` to exact equality, mirroring
Ink's `t.is` assertions (all behaviors already at parity):
- cursor-helpers: buildCursorSuffix/buildReturnToBottom/buildReturnToBottomPrefix/
  buildCursorOnlySequence exact full output + show/hide cursor constant literals.
- render-to-string: column "Line 1\nLine 2", paddingLeft "  Padded", a byte-exact
  single-border 20-wide frame, and byte-exact gap wrap ("A B\n\nC") + column ("A\n\nB")
  (the trimLines live tests can't catch trailing-space regressions).
- text: OSC-8 link exact bytes; a new RIS/ESC-c strip test (the existing test only
  covered the ESC#8 leg).
- box-in-text validation: the exact "Text string \"…\" must be rendered inside <Text>
  component" message via anchored regex (catches extra prefix/suffix, not just substring).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:20:06 +08:00
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 99839397c6 fix(runtime): a write op at the right clip edge still runs its transformers (Ink parity) (#109)
The whole-op horizontal clip skip used `x >= clipH.x2`, dropping a write op that
starts exactly AT the right clip edge. Ink's skip is strict `x > clip.x2`
(output.ts:188): at x === clip.x2 it proceeds to clip each line to empty
(sliceAnsi 0,0) and runs the transformers on the empty slice. A transformer that
produces output from empty input (e.g. `() => '中'`, `s => s + 'X'`) emits at the
clip edge in Ink but was short-circuited away in vue. Changed `>=` to `>`. Normal
and identity ops at x === clip.x2 still emit nothing (empty slice →
characters.length === 0 → skip); the inner per-line clip already used strict `>`.

Tests lock the transform-on-empty + append-on-empty cases (Ink "    中" / "    X")
and the identity/plain controls (""). Also drops a stale comment reference to the
deleted parity-ledger.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:14:28 +08:00
Yunfei He e68e36f26b fix(deps): bump is-in-ci catalog ^1.0.0 → ^2.0.0, matching Ink (#108)
is-in-ci feeds the `interactive` default (render.ts:503) and shouldSynchronize
(write-synchronized.ts:9). vue pinned ^1.0.0 vs Ink's ^2.0.0, whose CI-detection
formula differs: v1 scans for any `CI_*`-prefixed var and gates the whole expression
on CI not being falsy; v2 independently checks `CI` and `CONTINUOUS_INTEGRATION` and
drops the prefix scan. The common cases (local CI=false, GitHub CI=true) are identical
on both, so the suite and CI are unaffected; only edge env configs diverge. Bumping to
^2.0.0 makes vue's CI detection byte-identical to Ink's. Locked by a test
(CI=false + CONTINUOUS_INTEGRATION=true → true, which v1 would report false).

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