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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import process from "node:process";
|
||||
import { Box, Text, createApp, useCursor, useInput } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, onMounted, shallowRef } from "vue";
|
||||
|
||||
// Sibling-topology storyboard for the persistent-cursor-re-assertion divergence.
|
||||
//
|
||||
// The spinner state (`spin`) lives in a SIBLING of the `useCursor` input
|
||||
// component, so a spinner tick re-renders only the sibling — the input child's
|
||||
// own deps do not change. Under the old value/reference gate the input never
|
||||
// re-declared its caret on that commit, so the caret zombied to the bottom-left
|
||||
// corner. The fix re-emits the last-declared caret at the end of EVERY commit,
|
||||
// so the caret survives the unrelated spinner repaint.
|
||||
//
|
||||
// Flow: type "hi" (caret declared at x = 2 + 2 = 4, y = 1), then a spinner-only
|
||||
// repaint fires with NO further keystroke. The final frame must still end at the
|
||||
// declared column, not the corner.
|
||||
|
||||
const SPIN = ["|", "/", "-", "\\"];
|
||||
|
||||
const spin = shallowRef(0);
|
||||
const typed = shallowRef("");
|
||||
|
||||
// The spinner — a SIBLING of the input. Its repaint must not orphan the caret.
|
||||
const Spinner = defineComponent(
|
||||
() => () => h(Text, null, () => `${SPIN[spin.value % 4]} working...`),
|
||||
);
|
||||
|
||||
// The input owns the caret via useCursor; it re-declares on each keystroke.
|
||||
const Input = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl || key.meta || !input) return;
|
||||
typed.value = typed.value + input;
|
||||
});
|
||||
return () => {
|
||||
// Caret sits just after the typed text on row 1 (the input is line 2).
|
||||
setCursorPosition({ x: 2 + typed.value.length, y: 1 });
|
||||
return h(Text, null, () => `> ${typed.value}`);
|
||||
};
|
||||
});
|
||||
|
||||
const App = defineComponent(() => {
|
||||
onMounted(() => {
|
||||
process.stdout.write("__READY__");
|
||||
});
|
||||
return () => h(Box, { flexDirection: "column" }, [h(Spinner), h(Input)]);
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout: process.stdout, exitOnCtrlC: false });
|
||||
|
||||
// Drive the storyboard once the app is mounted: the test writes "hi" to stdin,
|
||||
// we wait for it to land, fire ONE spinner-only repaint (no keystroke), then
|
||||
// exit so the captured byte stream ends right after the unrelated repaint.
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
void (async () => {
|
||||
// Wait long enough for the two keystrokes to be processed and committed.
|
||||
await sleep(400);
|
||||
spin.value++; // spinner-only repaint — the caret must NOT zombie here
|
||||
await sleep(200);
|
||||
app.unmount();
|
||||
})();
|
||||
|
||||
await app.waitUntilExit();
|
||||
console.log("exited");
|
||||
@@ -14,7 +14,12 @@ export interface CursorPosition {
|
||||
*
|
||||
* Setting a position makes the cursor visible at the given coordinates
|
||||
* (relative to the output origin). Pass `undefined` to hide the cursor.
|
||||
* The cursor position is automatically cleared when the component unmounts.
|
||||
*
|
||||
* The declared position **persists**: the runtime re-asserts it at the end of
|
||||
* every frame, so the caret stays put across unrelated repaints (a spinner tick,
|
||||
* a log line) until you set a new position or clear it — you do not need to
|
||||
* re-set it on every render. The position is automatically cleared when the
|
||||
* component unmounts.
|
||||
*/
|
||||
export function useCursor() {
|
||||
const ctx = inject(AppContextKey);
|
||||
|
||||
@@ -23,19 +23,37 @@ export const cursorPositionChanged = (
|
||||
* position and show it.
|
||||
* Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the
|
||||
* last output line.
|
||||
*
|
||||
* The position is clamped to the visible region before emitting: under the
|
||||
* persistent-declaration re-emit (the caret is re-asserted every commit until
|
||||
* the declaration changes), a stale {x,y} left over from a larger frame —
|
||||
* after a resize, overflow, or content shrink — must not produce an
|
||||
* out-of-range move. `y` is clamped to `[0, visibleLineCount]` (so a y past the
|
||||
* shrunk content lands on the last visible line, never below it) and `x` to
|
||||
* `[0, width - 1]` when `width` is known (so a column past the terminal edge
|
||||
* lands at the rightmost cell, not beyond it). This is D5 in the cursor design
|
||||
* study; D1 (a stale-but-in-range coordinate that no longer tracks content) is
|
||||
* accepted residue, not corrected here.
|
||||
*/
|
||||
export const buildCursorSuffix = (
|
||||
visibleLineCount: number,
|
||||
cursorPosition: CursorPosition | undefined,
|
||||
width?: number,
|
||||
): string => {
|
||||
if (!cursorPosition) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const moveUp = visibleLineCount - cursorPosition.y;
|
||||
const clampedY = Math.max(0, Math.min(cursorPosition.y, visibleLineCount));
|
||||
const clampedX =
|
||||
width !== undefined && width > 0
|
||||
? Math.max(0, Math.min(cursorPosition.x, width - 1))
|
||||
: Math.max(0, cursorPosition.x);
|
||||
|
||||
const moveUp = visibleLineCount - clampedY;
|
||||
return (
|
||||
(moveUp > 0 ? ansiEscapes.cursorUp(moveUp) : "") +
|
||||
ansiEscapes.cursorTo(cursorPosition.x) +
|
||||
ansiEscapes.cursorTo(clampedX) +
|
||||
showCursorEscape
|
||||
);
|
||||
};
|
||||
@@ -56,8 +74,14 @@ export const buildReturnToBottom = (
|
||||
|
||||
// PreviousLineCount includes trailing newline, so visible lines =
|
||||
// previousLineCount - 1. Cursor is at previousCursorPosition.y, need to go
|
||||
// to line (previousLineCount - 1).
|
||||
const down = previousLineCount - 1 - previousCursorPosition.y;
|
||||
// to line (previousLineCount - 1). Clamp y to the same [0, previousLineCount-1]
|
||||
// range buildCursorSuffix used when it last placed the caret: the suffix
|
||||
// already clamped the move (D5), so return-to-bottom must measure from the
|
||||
// CLAMPED row the caret actually sits at — measuring from a raw out-of-range y
|
||||
// (e.g. a negative y, or a y left over from a taller frame) would over-move.
|
||||
const bottomLine = previousLineCount - 1;
|
||||
const clampedY = Math.max(0, Math.min(previousCursorPosition.y, bottomLine));
|
||||
const down = bottomLine - clampedY;
|
||||
return (down > 0 ? ansiEscapes.cursorDown(down) : "") + ansiEscapes.cursorTo(0);
|
||||
};
|
||||
|
||||
@@ -67,6 +91,7 @@ export type CursorOnlyInput = {
|
||||
previousCursorPosition: CursorPosition | undefined;
|
||||
visibleLineCount: number;
|
||||
cursorPosition: CursorPosition | undefined;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -77,7 +102,7 @@ export type CursorOnlyInput = {
|
||||
export const buildCursorOnlySequence = (input: CursorOnlyInput): string => {
|
||||
const hidePrefix = input.cursorWasShown ? hideCursorEscape : "";
|
||||
const returnToBottom = buildReturnToBottom(input.previousLineCount, input.previousCursorPosition);
|
||||
const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition);
|
||||
const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition, input.width);
|
||||
return hidePrefix + returnToBottom + cursorSuffix;
|
||||
};
|
||||
|
||||
|
||||
@@ -189,6 +189,65 @@ describe("standard rendering", () => {
|
||||
expect(secondCall.includes(showCursorEscape)).toBe(false);
|
||||
});
|
||||
|
||||
test("persistent declaration: a non-dirty changed-output re-render re-emits the declared cursor suffix", () => {
|
||||
// Persistent-declaration core. The caret is declared ONCE, then an unrelated
|
||||
// repaint (different output, NO setCursorPosition call this commit, so
|
||||
// cursorDirty is false) must STILL re-emit the caret-restore suffix at the
|
||||
// last-declared position — not drop it and zombie the caret to the corner.
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render("Line 1\nLine 2\n"); // declares + shows the cursor
|
||||
expect(render.isCursorDirty()).toBe(false); // consumed by the render
|
||||
|
||||
// Unrelated repaint: output changes, cursor is NOT re-declared.
|
||||
render("Changed\nLine 2\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
// The frame still ends at the declared column (the persistent re-emit), not
|
||||
// the bottom-left corner.
|
||||
expect(
|
||||
secondCall.endsWith(ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(5) + showCursorEscape),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("D5 clamp: a stale y past shrunk content lands on the last visible line, not below", () => {
|
||||
// The caret is declared at y=2 against a 3-line frame, then content shrinks
|
||||
// to 1 line WITHOUT re-declaring. The persistent re-emit must clamp y to the
|
||||
// visible line count so it never moves below the rendered block.
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 3, y: 2 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
// Shrink to a single line; no setCursorPosition — y=2 is now out of range.
|
||||
render("Only\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
// visibleLineCount is 1, y clamped to 1 -> moveUp 0, so the SUFFIX is just
|
||||
// cursorTo(3) + show with no leading cursorUp (an unclamped y=2 would have
|
||||
// emitted cursorUp(-1)-as-nothing here but a larger frame would move above
|
||||
// the block; the clamp guarantees the suffix never moves past the content).
|
||||
expect(secondCall.endsWith(ansiEscapes.cursorTo(3) + showCursorEscape)).toBe(true);
|
||||
});
|
||||
|
||||
test("D5 clamp: a stale x past terminal width lands at the rightmost cell, not beyond", () => {
|
||||
// stdout width is 100 (createStdout). Declare x past the edge; the re-emit
|
||||
// must clamp x to width-1 so the column move stays in range.
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 250, y: 0 });
|
||||
render("Hello\n");
|
||||
|
||||
const written = stdout.write.firstCall.args[0] as string;
|
||||
// x clamped to 99 -> cursorTo(99).
|
||||
expect(written.endsWith(ansiEscapes.cursorTo(99) + showCursorEscape)).toBe(true);
|
||||
expect(written.includes(ansiEscapes.cursorTo(250))).toBe(false);
|
||||
});
|
||||
|
||||
test("returns to bottom before erase when cursor was positioned", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
@@ -499,18 +558,23 @@ describe.each(modes)("$name mode - cursor positioning", ({ incremental }) => {
|
||||
expect(secondCall.endsWith(ansiEscapes.cursorTo(3) + showCursorEscape)).toBe(true);
|
||||
});
|
||||
|
||||
test("sync() resets cursor state", () => {
|
||||
test("sync() updates the frame baseline so the next render has no stale return", () => {
|
||||
// sync() to a SHORTER frame must update previousLineCount/Position so the
|
||||
// next render does not emit a stale return-to-bottom for the old 3-line
|
||||
// frame. Under persistent-declaration sync re-seats the declared cursor (it
|
||||
// no longer zeros it), so the next render legitimately hides-then-shows; the
|
||||
// invariant that survives is "no stale cursorDown(3) from the old height".
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 0 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
render.sync("Fresh output\n");
|
||||
render.sync("Fresh output\n"); // 1-line baseline now
|
||||
|
||||
render("Updated output\n");
|
||||
|
||||
const afterSync = stdout.get();
|
||||
expect(afterSync.includes(hideCursorEscape)).toBe(false);
|
||||
// The stale 3-line return must NOT appear (sync rebased the height to 1).
|
||||
expect(afterSync.includes(ansiEscapes.cursorDown(3))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -537,17 +601,25 @@ describe.each(modes)("$name mode - cursor positioning", ({ incremental }) => {
|
||||
expect(renderCall.startsWith(hideCursorEscape)).toBe(true);
|
||||
});
|
||||
|
||||
test("sync() hides cursor when previous render showed cursor", () => {
|
||||
test("sync() re-seats the still-declared cursor (persistent declaration)", () => {
|
||||
// Persistent-declaration: a sync() after a render that showed the cursor does
|
||||
// NOT drop it — the declaration is still live, so sync re-emits the caret
|
||||
// suffix at the declared position (it formerly emitted a bare hide because the
|
||||
// dirty-gate zeroed the active cursor on a non-dirty sync). The cursor stays
|
||||
// visible at its edit point across the out-of-band repaint.
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
|
||||
render.sync("Fresh output\n");
|
||||
render.sync("Fresh output\n"); // 1-line frame, cursor {5,1} still declared
|
||||
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
expect(stdout.write.secondCall.args[0] as string).toBe(hideCursorEscape);
|
||||
const synced = stdout.write.secondCall.args[0] as string;
|
||||
// y=1 clamped to visibleLineCount 1 -> moveUp 0; suffix is cursorTo(5)+show.
|
||||
expect(synced).toBe(ansiEscapes.cursorTo(5) + showCursorEscape);
|
||||
expect(synced.includes(hideCursorEscape)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ const visibleLineCount = (lines: string[], str: string): number =>
|
||||
// `isTTY`, so we read it off the runtime object (WriteStream sets it).
|
||||
const isTtyStream = (stream: Writable): boolean => Boolean((stream as { isTTY?: boolean }).isTTY);
|
||||
|
||||
// Terminal width for the D5 cursor-x clamp (see buildCursorSuffix). `stream` is
|
||||
// typed `Writable`, which has no `columns`; the runtime WriteStream sets it.
|
||||
// Returns undefined when unknown so the clamp falls back to the no-width path.
|
||||
const streamWidth = (stream: Writable): number | undefined =>
|
||||
(stream as { columns?: number }).columns;
|
||||
|
||||
// The show-cursor restore at done() runs on the teardown path, where stdout may
|
||||
// already be destroyed/ended. `isTTY` stays cached-truthy after destroy()/end(),
|
||||
// so gating cursor writes on isTTY alone throws ERR_STREAM_DESTROYED on a
|
||||
@@ -70,7 +76,19 @@ const createStandard = (
|
||||
let previousCursorPosition: CursorPosition | undefined;
|
||||
let cursorWasShown = false;
|
||||
|
||||
const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined);
|
||||
// Persistent-declaration: the active cursor is the LAST-declared position and
|
||||
// is re-emitted at the end of EVERY commit, so a focused input's caret stays
|
||||
// at its edit point across unrelated repaints (spinner/log/progress) in all
|
||||
// component topologies — matching real terminal apps (vim/readline/nano
|
||||
// re-place the caret each frame). It is NOT gated on cursorDirty: gating there
|
||||
// dropped the caret on any commit that did not re-declare, zombieing it to the
|
||||
// bottom-left corner (a deliberate divergence from Ink — see
|
||||
// .agents/docs/ink-divergences.md). A CLEARED declaration (setCursorPosition
|
||||
// undefined, e.g. useCursor's onScopeDispose on unmount) sets cursorPosition
|
||||
// to undefined, so the next re-emit places no caret — the clear is not
|
||||
// resurrected. cursorDirty still tracks "was re-declared this commit" purely
|
||||
// to gate the commit/dedup paths (render.ts outer gate + frame-writer skip).
|
||||
const getActiveCursor = () => cursorPosition;
|
||||
const hasChanges = (str: string, activeCursor: CursorPosition | undefined): boolean => {
|
||||
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
|
||||
return str !== previousOutput || cursorChanged;
|
||||
@@ -82,8 +100,6 @@ const createStandard = (
|
||||
hasHiddenCursor = true;
|
||||
}
|
||||
|
||||
// Only use cursor if setCursorPosition was called since last render.
|
||||
// This ensures stale positions don't persist after component unmount.
|
||||
const activeCursor = getActiveCursor();
|
||||
cursorDirty = false;
|
||||
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
|
||||
@@ -94,7 +110,7 @@ const createStandard = (
|
||||
|
||||
const lines = str.split("\n");
|
||||
const visibleCount = visibleLineCount(lines, str);
|
||||
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
|
||||
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, streamWidth(stream));
|
||||
|
||||
if (str === previousOutput && cursorChanged) {
|
||||
stream.write(
|
||||
@@ -104,6 +120,7 @@ const createStandard = (
|
||||
previousCursorPosition,
|
||||
visibleLineCount: visibleCount,
|
||||
cursorPosition: activeCursor,
|
||||
width: streamWidth(stream),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
@@ -155,7 +172,10 @@ const createStandard = (
|
||||
};
|
||||
|
||||
render.sync = (str: string) => {
|
||||
const activeCursor = cursorDirty ? cursorPosition : undefined;
|
||||
// Persistent-declaration: sync the LAST-declared position (not cursorDirty-
|
||||
// gated), so the clearTerminal / restoreLastOutput sync re-seats the caret
|
||||
// at the declared point too.
|
||||
const activeCursor = getActiveCursor();
|
||||
cursorDirty = false;
|
||||
|
||||
const lines = str.split("\n");
|
||||
@@ -170,7 +190,9 @@ const createStandard = (
|
||||
}
|
||||
|
||||
if (activeCursor) {
|
||||
stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor));
|
||||
stream.write(
|
||||
buildCursorSuffix(visibleLineCount(lines, str), activeCursor, streamWidth(stream)),
|
||||
);
|
||||
}
|
||||
|
||||
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
|
||||
@@ -200,7 +222,11 @@ const createIncremental = (
|
||||
let previousCursorPosition: CursorPosition | undefined;
|
||||
let cursorWasShown = false;
|
||||
|
||||
const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined);
|
||||
// Persistent-declaration (see createStandard for the full rationale): the
|
||||
// active cursor is the last-declared position, re-emitted at the end of every
|
||||
// commit so it survives unrelated repaints; a cleared declaration emits no
|
||||
// caret. cursorDirty only gates the commit/dedup paths now.
|
||||
const getActiveCursor = () => cursorPosition;
|
||||
const hasChanges = (str: string, activeCursor: CursorPosition | undefined): boolean => {
|
||||
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
|
||||
return str !== previousOutput || cursorChanged;
|
||||
@@ -212,8 +238,6 @@ const createIncremental = (
|
||||
hasHiddenCursor = true;
|
||||
}
|
||||
|
||||
// Only use cursor if setCursorPosition was called since last render.
|
||||
// This ensures stale positions don't persist after component unmount.
|
||||
const activeCursor = getActiveCursor();
|
||||
cursorDirty = false;
|
||||
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
|
||||
@@ -234,6 +258,7 @@ const createIncremental = (
|
||||
previousCursorPosition,
|
||||
visibleLineCount: visibleCount,
|
||||
cursorPosition: activeCursor,
|
||||
width: streamWidth(stream),
|
||||
}),
|
||||
);
|
||||
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
|
||||
@@ -248,7 +273,7 @@ const createIncremental = (
|
||||
);
|
||||
|
||||
if (str === "\n" || previousOutput.length === 0) {
|
||||
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
|
||||
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, streamWidth(stream));
|
||||
stream.write(
|
||||
returnPrefix + ansiEscapes.eraseLines(previousLines.length) + str + cursorSuffix,
|
||||
);
|
||||
@@ -303,7 +328,7 @@ const createIncremental = (
|
||||
);
|
||||
}
|
||||
|
||||
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
|
||||
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, streamWidth(stream));
|
||||
buffer.push(cursorSuffix);
|
||||
|
||||
stream.write(buffer.join(""));
|
||||
@@ -348,7 +373,10 @@ const createIncremental = (
|
||||
};
|
||||
|
||||
render.sync = (str: string) => {
|
||||
const activeCursor = cursorDirty ? cursorPosition : undefined;
|
||||
// Persistent-declaration: sync the LAST-declared position (not cursorDirty-
|
||||
// gated), so the clearTerminal / restoreLastOutput sync re-seats the caret
|
||||
// at the declared point too.
|
||||
const activeCursor = getActiveCursor();
|
||||
cursorDirty = false;
|
||||
|
||||
const lines = str.split("\n");
|
||||
@@ -363,7 +391,9 @@ const createIncremental = (
|
||||
}
|
||||
|
||||
if (activeCursor) {
|
||||
stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor));
|
||||
stream.write(
|
||||
buildCursorSuffix(visibleLineCount(lines, str), activeCursor, streamWidth(stream)),
|
||||
);
|
||||
}
|
||||
|
||||
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
|
||||
|
||||
@@ -732,9 +732,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
appContext.cursorPosition = pos;
|
||||
// Mirror Ink's single setCursorPosition (ink.tsx:494-497), which sets
|
||||
// BOTH the instance field AND this.log.setCursorPosition(position) on
|
||||
// every render. Forwarding to the frame writer marks log-update's
|
||||
// cursorDirty so getActiveCursor() returns the position and the commit
|
||||
// gate (output !== lastOutput || isCursorDirty) fires the cursor suffix.
|
||||
// every render. Forwarding to the frame writer updates log-update's
|
||||
// last-declared position (persistently re-emitted at every commit) and
|
||||
// marks cursorDirty so the commit gate (output !== lastOutput ||
|
||||
// isCursorDirty) fires even on a cursor-only move (same output, new pos).
|
||||
// Without this the cursor is never shown/moved on the interactive path.
|
||||
// `writer` is created below in mount() but is always initialized before
|
||||
// any render/setup can call this (originalMount runs after writer creation),
|
||||
|
||||
Reference in New Issue
Block a user