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:
@@ -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