fix(runtime): re-assert the declared cursor every commit (persistent declaration) (#157)

A focused input's caret zombied to the bottom-left corner whenever an
unrelated repaint (spinner tick, log line, progress bar) committed
without re-declaring the cursor: the active cursor was gated on a
per-commit dirty/reference change, so an unrelated commit dropped it.

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

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

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-12 17:08:05 +08:00
committed by GitHub
parent 751f61eec5
commit 86b94b9fa5
8 changed files with 320 additions and 55 deletions
+45 -27
View File
@@ -176,33 +176,6 @@ current-props model, or API conventions.
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:** they agree whenever the cursor child itself re-renders (its own deps change) and
in the unrelated-sibling case (both drop). They differ whenever a commit happens **without
the cursor child's own deps changing** — most commonly an **ancestor-driven** commit:
React's render cascade re-renders the child and re-asserts the cursor; Vue's fine-grained
reactivity does not re-run the child, so the cursor is dropped that commit. Setting the
position reactively in the render body does **not** by itself close this gap (verified by
running: an ancestor-only commit drops a render-body-set cursor in vue-tui while Ink
re-asserts) — it helps only when that ancestor change also flows into a ref the child reads. 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 /
@@ -432,6 +405,51 @@ different runtime behavior, ownership rule, or out-of-contract handling.
(`'always'` holds raw with no input hook; `'auto'` stays cooked; no mid-session
oscillation).
### `useCursor()` re-asserts the declared caret every commit (persistent declaration)
- **Ink:** `useCursor`'s no-deps `useInsertionEffect` (`use-cursor.ts:27-32`) re-marks the
cursor dirty only when **the cursor's React component re-renders**, and log-update emits
the caret only on a dirty commit. React's render cascade re-renders the child on an
**ancestor**-driven commit (so Ink re-asserts there), but on an unrelated **sibling/leaf**
repaint the cursor component does not re-render — Ink does **not** re-assert and the caret
is dropped, **zombieing** to the bottom-left corner (run-verified vs v7.0.4: a sibling
spinner tick ends `…> hello\n` with no caret suffix, leaving the caret at row 2 col 0).
- **vue-tui:** the runtime re-emits the **last-declared** caret at the **end of every
commit** (until the declaration changes or is cleared), so a focused input's caret stays
at its edit point across unrelated repaints (spinner / log line / progress bar) in **all**
component topologies. The hide-before-erase / show-at-resolved-position flicker discipline
is preserved (no corner streak), and the re-emitted position is clamped to the visible
region (y to the line count, x to the width) so a post-resize/shrink stale coordinate
never moves out of range.
- **Why:** this is a **deliberate divergence FROM Ink toward correct terminal-app
behavior**, not Ink-alignment. Real terminal programs that own an edit point re-place the
caret at that point **every frame** (vim emits `\e[<row>;<col>H` after each repaint;
readline re-lands the buffer offset on SIGWINCH; nano homes to its edit cell) — they never
leave the caret where the repaint dragged it. Aligning to Ink exists to reduce bugs, not to
preserve abnormal behavior; matching Ink's topology-conditional zombie would preserve
abnormal behavior, so vue-tui diverges. Per the classification flow this is **not** a
Model-Implied difference (Vue is not _forced_ here — fine-grained reactivity could also be
made to re-run the child; the runtime simply chooses to be more correct than Ink at the
commit level), and it is not Vue-API-shaped, so it lands in **Intentional Divergence
Choices**. The `{x,y}` `setCursorPosition` surface is **unchanged** — it remains the right
low-level IME primitive (a composing glyph offset deliberately decoupled from the buffer
point); the fix is an internal per-commit re-emit, so the public API stays compatible. A
cleared declaration (`setCursorPosition(undefined)`, e.g. `useCursor`'s `onScopeDispose`
on unmount) re-emits no caret, so teardown still ends with the cursor shown and handed back
(`\x1b[?25h`); the persistent re-emit runs **before** the unmount clear, so it cannot
resurrect a torn-down caret. The one accepted residue is a stale-but-in-range absolute
position if an app declares a fixed `{x,y}` and then shrinks content without re-declaring
(it parks at a plausible spot, strictly better than a corner-zombie); a future **Stage 2**
focus-owned, content-tracking caret (recomputed from the focused widget's layout each
frame) would dissolve that residue. Tests: PTY `cursor-sibling-repaint.test.ts` (a
sibling-topology spinner tick re-asserts the caret, not the corner) and unit
`frame-writer.test.ts` (a non-dirty changed-output re-render re-emits the declared suffix;
D5 clamp). **Maintainer decision (2026-06-12): OVERRIDE prior KEEP — adopt per-commit
re-assert.** The prior KEEP (2026-06-01) had kept the reactivity-tied behavior to avoid
diverging from Ink in the sibling direction; that rationale was overturned when running
real terminal apps showed Ink itself zombies the caret there, so matching Ink was matching
a defect, not parity.
### Resize unconditionally cancels the pending trailing commit
- **Ink:** `resized()` paints synchronously via `onRender()` but does **not** cancel a