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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user