86b94b9fa5
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>
50 lines
2.5 KiB
TypeScript
50 lines
2.5 KiB
TypeScript
// Persistent cursor re-assertion across an UNRELATED repaint (real-TTY PTY).
|
|
//
|
|
// The caret-restore bytes only reach a live TTY (frame-writer's `log` is null in
|
|
// debug/test mode), so this divergence is invisible to byte-exact non-TTY tests
|
|
// — per CLAUDE.md that is a testing gap, not a non-issue, hence a PTY repro.
|
|
//
|
|
// Storyboard (sibling topology): an input declares its caret via useCursor after
|
|
// typing "hi" (x = 2 + 2 = 4, y = 1). A spinner whose state lives in a SIBLING
|
|
// component then repaints WITHOUT any further keystroke. The input child's own
|
|
// deps did not change, so the old value/reference gate dropped the caret and it
|
|
// zombied to the bottom-left corner. The fix re-emits the last-declared caret at
|
|
// the END of every commit, so the spinner-only frame must still end with the
|
|
// caret-restore suffix at the declared column.
|
|
//
|
|
// ansiEscapes.cursorTo(x) === `\x1b[${x + 1}G`. With x = 4 and a two-line frame
|
|
// (visibleLineCount 2, y 1 -> moveUp 1) the restore suffix is:
|
|
// `\x1b[1A` (up 1) + `\x1b[5G` (to col 4) + `\x1b[?25h` (show).
|
|
import { test as it, expect } from "vite-plus/test";
|
|
import term from "./helpers/term.ts";
|
|
|
|
const SHOW = "\x1b[?25h";
|
|
// The full caret-restore suffix the spinner-only frame must end with.
|
|
const CARET_RESTORE = "\x1b[1A\x1b[5G\x1b[?25h";
|
|
// Synchronized-update end — the byte that closes each interactive frame.
|
|
const ESU = "\x1b[?2026l";
|
|
|
|
it("a spinner-only repaint (sibling topology) re-asserts the declared caret, not the corner", async () => {
|
|
const ps = term("cursor-sibling-repaint");
|
|
ps.write("hi");
|
|
// Wait for the spinner-only repaint frame (spin index 1 -> "/ working") to
|
|
// commit; the fixture fires it ~400ms after mount with no further keystroke.
|
|
await ps.waitForOutput((o) => o.includes("/ working"));
|
|
await ps.waitForExit();
|
|
expect(ps.output).toContain("exited");
|
|
|
|
// Isolate the spinner-tick frame: from the "/ working" content up to its
|
|
// synchronized-update end. That frame must END with the caret-restore suffix,
|
|
// i.e. the caret is put back at the declared column, not left at the corner.
|
|
const tickStart = ps.output.indexOf("/ working");
|
|
expect(tickStart).toBeGreaterThan(-1);
|
|
const tickEnd = ps.output.indexOf(ESU, tickStart);
|
|
expect(tickEnd).toBeGreaterThan(-1);
|
|
const tickFrame = ps.output.slice(tickStart, tickEnd);
|
|
|
|
// The spinner-only frame ends with the caret-restore suffix (up, to col, show).
|
|
expect(tickFrame.endsWith(CARET_RESTORE)).toBe(true);
|
|
// And the last visibility change in that frame is a SHOW (caret visible).
|
|
expect(tickFrame).toContain(SHOW);
|
|
});
|