fix(runtime): hide the caret on app.clear() instead of re-showing it (Ink parity) (#190)

app.clear() should wipe the rendered output and leave the terminal caret
HIDDEN, like Ink v7.0.4. Instead vue-tui repositioned and RE-SHOWED the
caret on the now-blank screen.

Same scenario both sides (useCursor {x:5,y:0}, "Hello", columns 40):
  Ink     clear() bytes: \x1b[?25l \x1b[1B \x1b[1G \x1b[2K \x1b[1A \x1b[2K \x1b[G
  vue-tui clear() bytes: ...same... + \x1b[1A \x1b[6G \x1b[?25h   (BUG)

Root cause: mountedClear() runs writer.clear() (hide + erase, correct) then
writer.sync(...). vue-tui's sync re-emits the PERSISTENT declared cursor (a
blessed divergence that is correct for repaints, which redraw the content),
so it wrote buildCursorSuffix = reposition + show. But clear() erases WITHOUT
redrawing, so re-asserting the caret floats it on a blank screen. Ink's own
clear()-time sync sees cursorDirty=false and emits no caret for the same
reason.

Fix: add an optional SyncOptions { cursor?: boolean } to log-update's sync
(both the standard and incremental variants) and thread it through
FrameWriter.sync. When cursor:false, sync treats the active cursor as
undefined for that call only: no reposition/show, and (since clear() already
set cursorWasShown=false) no hide either. It does NOT touch the persistent
cursorPosition, so the NEXT real commit re-shows the caret normally. Only
mountedClear() passes { cursor: false }; the clearTerminal/resize sync and
the external-write restoreLastOutput path (which redraw) keep the default
cursor:true, so they still re-assert the caret.

Verified byte-exact against real Ink v7.0.4 across a 10-scenario matrix
(active cursor, no cursor, clear-then-rerender, multiline y>0, {0,0}, two
clears, owner-unmounted, non-interactive/debug no-op, external-write restore,
clear-then-resize). New test: clear-cursor.test.tsx (raw interactive stdout
byte capture; testing lastFrame() is content-only and cannot see cursor
escapes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 01:01:34 +08:00
committed by GitHub
parent fd81656c3d
commit be045cfaff
4 changed files with 492 additions and 10 deletions
+6 -4
View File
@@ -1,11 +1,11 @@
import logUpdate, { type LogUpdate } from "./log-update.ts";
import logUpdate, { type LogUpdate, type SyncOptions } from "./log-update.ts";
import type { CursorPosition } from "./cursor-helpers.ts";
export interface FrameWriter {
write: (frame: string) => void;
done: () => void;
clear: () => void;
sync: (frame: string) => void;
sync: (frame: string, options?: SyncOptions) => void;
setCursorPosition: (pos: CursorPosition | undefined) => void;
isCursorDirty: () => boolean;
willRender: (frame: string) => boolean;
@@ -46,14 +46,16 @@ export function createFrameWriter(
lastFrame = null;
if (log) log.clear();
},
sync(frame: string) {
sync(frame: string, options?: SyncOptions) {
// Keep this writer's dedup baseline aligned with log-update's internal
// previousOutput. Without this, a later write() of `frame` is skipped by
// log-update (state synced) while a write() of the *pre-sync* lastFrame
// passes this layer's dedup but is dropped by log-update — desyncing the
// two dedup layers and dropping a legitimately-changed frame.
// `options` (e.g. { cursor: false } from app.clear()) is forwarded so the
// caller can suppress the cursor emit on this sync — see log-update.sync.
lastFrame = frame;
if (log) log.sync(frame);
if (log) log.sync(frame, options);
},
setCursorPosition(pos) {
if (log) log.setCursorPosition(pos);
+37 -5
View File
@@ -12,11 +12,21 @@ import {
export type { CursorPosition } from "./cursor-helpers.ts";
export type SyncOptions = {
// When false, sync re-seats only the OUTPUT bookkeeping and emits NO cursor
// escape (no reposition, no show, and — because clear() has already set
// cursorWasShown=false — no hide either). Used by app.clear(): clear() erases
// the lines WITHOUT redrawing them, so re-asserting the persistent caret would
// float it on a blank screen. Defaults to true (the restoreLastOutput path,
// which DOES redraw, still re-shows the caret). See render.ts mountedClear.
cursor?: boolean;
};
export type LogUpdate = {
clear: () => void;
done: () => void;
reset: () => void;
sync: (str: string) => void;
sync: (str: string, options?: SyncOptions) => void;
setCursorPosition: (position: CursorPosition | undefined) => void;
isCursorDirty: () => boolean;
willRender: (str: string) => boolean;
@@ -171,11 +181,20 @@ const createStandard = (
cursorWasShown = false;
};
render.sync = (str: string) => {
render.sync = (str: string, options?: SyncOptions) => {
// 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();
//
// options.cursor === false suppresses the cursor emit for THIS sync (the
// app.clear() path). clear() erased the lines WITHOUT redrawing them, so
// re-asserting the persistent caret would float it on a blank screen — Ink
// leaves it hidden (its clear()-time sync sees cursorDirty=false, so it
// emits no caret either). We do NOT touch cursorPosition (the declaration
// persists), so the NEXT real render re-shows the caret normally. Treating
// the active cursor as undefined here also drives previousCursorPosition/
// cursorWasShown to the true post-clear blank state.
const activeCursor = options?.cursor === false ? undefined : getActiveCursor();
cursorDirty = false;
const lines = str.split("\n");
@@ -185,6 +204,8 @@ const createStandard = (
// NOT isTTY-gated: Ink's sync() writes the hide directly (Ink
// log-update.ts:149-151), NOT via cli-cursor, so it has no isTTY guard —
// unlike render()/done()'s hide/show which DO route through cli-cursor.
// After clear() cursorWasShown is already false, so the clear() path (which
// passes cursor:false → activeCursor undefined) writes no hide here either.
if (!activeCursor && cursorWasShown) {
stream.write(hideCursorEscape);
}
@@ -372,11 +393,20 @@ const createIncremental = (
cursorWasShown = false;
};
render.sync = (str: string) => {
render.sync = (str: string, options?: SyncOptions) => {
// 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();
//
// options.cursor === false suppresses the cursor emit for THIS sync (the
// app.clear() path). clear() erased the lines WITHOUT redrawing them, so
// re-asserting the persistent caret would float it on a blank screen — Ink
// leaves it hidden (its clear()-time sync sees cursorDirty=false, so it
// emits no caret either). We do NOT touch cursorPosition (the declaration
// persists), so the NEXT real render re-shows the caret normally. Treating
// the active cursor as undefined here also drives previousCursorPosition/
// cursorWasShown to the true post-clear blank state.
const activeCursor = options?.cursor === false ? undefined : getActiveCursor();
cursorDirty = false;
const lines = str.split("\n");
@@ -386,6 +416,8 @@ const createIncremental = (
// NOT isTTY-gated: Ink's sync() writes the hide directly (Ink
// log-update.ts:149-151), NOT via cli-cursor, so it has no isTTY guard —
// unlike render()/done()'s hide/show which DO route through cli-cursor.
// After clear() cursorWasShown is already false, so the clear() path (which
// passes cursor:false → activeCursor undefined) writes no hide here either.
if (!activeCursor && cursorWasShown) {
stream.write(hideCursorEscape);
}
+12 -1
View File
@@ -841,7 +841,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
mountedClear = () => {
if (!interactive || debug) return;
writer.clear();
writer.sync(frameState.lastOutputToRender || frameState.lastOutput + "\n");
// cursor:false — leave the caret HIDDEN after clear() (Ink parity:
// ink.js clear() -> log.clear() then log.sync(...) where cursorDirty is
// already false, so its sync emits no caret). clear() erased the lines
// WITHOUT redrawing them, so re-asserting the persistent caret would float
// it on a now-blank screen. The declared position is NOT discarded (sync
// doesn't touch it), so the next real commit re-shows the caret normally.
// This is scoped to clear() ONLY: restoreLastOutput()'s writer.write()
// (the external-write path) still REDRAWS the content and re-shows the
// caret, so it must keep the default cursor:true behavior.
writer.sync(frameState.lastOutputToRender || frameState.lastOutput + "\n", {
cursor: false,
});
};
const synchronize = shouldSynchronize(stdout, interactive);