fix: stabilize streaming footer and status layout (#882)

This commit is contained in:
Charles Packer
2026-02-09 19:49:44 -08:00
committed by GitHub
parent fe8a4042d2
commit baa28ede88
5 changed files with 476 additions and 213 deletions

View File

@@ -100,6 +100,7 @@ await copyToResolved(
"ink/build/hooks/use-input.js",
);
await copyToResolved("vendor/ink/build/devtools.js", "ink/build/devtools.js");
await copyToResolved("vendor/ink/build/log-update.js", "ink/build/log-update.js");
// ink-text-input (optional vendor with externalCursorOffset support)
await copyToResolved(

View File

@@ -248,6 +248,7 @@ const RESIZE_SETTLE_MS = 250;
const MIN_CLEAR_INTERVAL_MS = 750;
const STABLE_WIDTH_SETTLE_MS = 180;
const TOOL_CALL_COMMIT_DEFER_MS = 50;
const ANIMATION_RESUME_HYSTERESIS_ROWS = 2;
// Eager approval checking is now CONDITIONAL (LET-7101):
// - Enabled when resuming a session (--resume, --continue, or startupApprovals exist)
@@ -1556,6 +1557,7 @@ export default function App({
const lastClearAtRef = useRef(0);
const isInitialResizeRef = useRef(true);
const columns = stableColumns;
const debugFlicker = process.env.LETTA_DEBUG_FLICKER === "1";
useEffect(() => {
if (rawColumns === stableColumns) {
@@ -1585,7 +1587,15 @@ export default function App({
}, STABLE_WIDTH_SETTLE_MS);
}, [rawColumns, stableColumns]);
const clearAndRemount = useCallback((targetColumns: number) => {
const clearAndRemount = useCallback(
(targetColumns: number) => {
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:clear-remount] target=${targetColumns} previousCleared=${lastClearedColumnsRef.current} raw=${prevColumnsRef.current}`,
);
}
if (
typeof process !== "undefined" &&
process.stdout &&
@@ -1597,7 +1607,9 @@ export default function App({
setStaticRenderEpoch((epoch) => epoch + 1);
lastClearedColumnsRef.current = targetColumns;
lastClearAtRef.current = Date.now();
}, []);
},
[debugFlicker],
);
const scheduleResizeClear = useCallback(
(targetColumns: number) => {
@@ -1616,23 +1628,47 @@ export default function App({
? 0
: MIN_CLEAR_INTERVAL_MS - elapsedSinceClear;
const delay = Math.max(RESIZE_SETTLE_MS, rateLimitDelay);
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:resize-schedule] target=${targetColumns} delay=${delay}ms elapsedSinceClear=${elapsedSinceClear}ms`,
);
}
resizeClearTimeout.current = setTimeout(() => {
resizeClearTimeout.current = null;
// If resize changed again while waiting, let the latest schedule win.
if (prevColumnsRef.current !== targetColumns) {
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:resize-skip] stale target=${targetColumns} currentRaw=${prevColumnsRef.current}`,
);
}
return;
}
if (targetColumns === lastClearedColumnsRef.current) {
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:resize-skip] already-cleared target=${targetColumns}`,
);
}
return;
}
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:resize-fire] clear target=${targetColumns}`,
);
}
clearAndRemount(targetColumns);
}, delay);
},
[clearAndRemount],
[clearAndRemount, debugFlicker],
);
useEffect(() => {
@@ -10020,9 +10056,8 @@ Plan file path: ${planFilePath}`;
getSubagentSnapshot,
);
// Overflow detection: disable animations when live content exceeds viewport
// This prevents Ink's clearTerminal flicker on every re-render cycle
const shouldAnimate = useMemo(() => {
// Estimate live area height for overflow detection.
const estimatedLiveHeight = useMemo(() => {
// Count actual lines in live content by counting newlines
const countLines = (text: string | undefined): number => {
if (!text) return 0;
@@ -10064,8 +10099,33 @@ Plan file path: ${planFilePath}`;
const estimatedHeight = liveItemsHeight + subagentsHeight + FIXED_BUFFER;
return estimatedHeight < terminalRows;
}, [liveItems, terminalRows, subagents.length]);
return estimatedHeight;
}, [liveItems, subagents.length]);
// Overflow detection with hysteresis: disable quickly on overflow, re-enable
// only after we've recovered extra headroom to avoid flap near the boundary.
const [shouldAnimate, setShouldAnimate] = useState(
() => estimatedLiveHeight < terminalRows,
);
useEffect(() => {
if (terminalRows <= 0) {
setShouldAnimate(false);
return;
}
const disableThreshold = terminalRows;
const resumeThreshold = Math.max(
0,
terminalRows - ANIMATION_RESUME_HYSTERESIS_ROWS,
);
setShouldAnimate((prev) => {
if (prev) {
return estimatedLiveHeight < disableThreshold;
}
return estimatedLiveHeight < resumeThreshold;
});
}, [estimatedLiveHeight, terminalRows]);
// Commit welcome snapshot once when ready for fresh sessions (no history)
// Wait for agentProvenance to be available for new agents (continueSession=false)
@@ -10518,6 +10578,7 @@ Plan file path: ${planFilePath}`;
onRestoredInputConsumed={() => setRestoredInput(null)}
networkPhase={networkPhase}
terminalWidth={columns}
shouldAnimate={shouldAnimate}
/>
</Box>

View File

@@ -8,6 +8,7 @@ import SpinnerLib from "ink-spinner";
import {
type ComponentType,
memo,
useCallback,
useEffect,
useMemo,
useRef,
@@ -37,6 +38,7 @@ const Spinner = SpinnerLib as ComponentType<{ type?: string }>;
// Window for double-escape to clear input
const ESC_CLEAR_WINDOW_MS = 2500;
const FOOTER_WIDTH_STREAMING_DELTA = 2;
function truncateEnd(value: string, maxChars: number): string {
if (maxChars <= 0) return "";
@@ -122,7 +124,7 @@ const InputFooter = memo(function InputFooter({
isOpenAICodexProvider,
isByokProvider,
hideFooter,
terminalWidth,
rightColumnWidth,
}: {
ctrlCPressed: boolean;
escapePressed: boolean;
@@ -135,19 +137,39 @@ const InputFooter = memo(function InputFooter({
isOpenAICodexProvider: boolean;
isByokProvider: boolean;
hideFooter: boolean;
terminalWidth: number;
rightColumnWidth: number;
}) {
const hideFooterContent = hideFooter;
const rightColumnWidth = Math.max(
28,
Math.min(72, Math.floor(terminalWidth * 0.45)),
);
const maxAgentChars = Math.max(10, Math.floor(rightColumnWidth * 0.45));
const displayAgentName = truncateEnd(agentName || "Unnamed", maxAgentChars);
const byokExtraChars = isByokProvider ? 2 : 0; // " ▲"
const reservedChars = displayAgentName.length + byokExtraChars + 4;
const maxModelChars = Math.max(8, rightColumnWidth - reservedChars);
const displayModel = truncateEnd(currentModel ?? "unknown", maxModelChars);
const rightTextLength =
displayAgentName.length + displayModel.length + byokExtraChars + 3;
const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength);
const rightLabel = useMemo(() => {
const parts: string[] = [];
parts.push(" ".repeat(rightPrefixSpaces));
parts.push(chalk.hex(colors.footer.agentName)(displayAgentName));
parts.push(chalk.dim(" ["));
parts.push(chalk.dim(displayModel));
if (isByokProvider) {
parts.push(chalk.dim(" "));
parts.push(
isOpenAICodexProvider ? chalk.hex("#74AA9C")("▲") : chalk.yellow("▲"),
);
}
parts.push(chalk.dim("]"));
return parts.join("");
}, [
rightPrefixSpaces,
displayAgentName,
displayModel,
isByokProvider,
isOpenAICodexProvider,
]);
return (
<Box flexDirection="row" marginBottom={1}>
@@ -178,24 +200,11 @@ const InputFooter = memo(function InputFooter({
<Text dimColor>Press / for commands</Text>
)}
</Box>
<Box width={rightColumnWidth} flexShrink={0} justifyContent="flex-end">
<Box width={rightColumnWidth} flexShrink={0}>
{hideFooterContent ? (
<Text> </Text>
<Text>{" ".repeat(rightColumnWidth)}</Text>
) : (
<Text wrap="truncate-end">
<Text color={colors.footer.agentName}>{displayAgentName}</Text>
<Text dimColor>{" ["}</Text>
<Text dimColor>{displayModel}</Text>
{isByokProvider ? (
<>
<Text dimColor> </Text>
<Text color={isOpenAICodexProvider ? "#74AA9C" : "yellow"}>
</Text>
</>
) : null}
<Text dimColor>{"]"}</Text>
</Text>
<Text>{rightLabel}</Text>
)}
</Box>
</Box>
@@ -212,6 +221,7 @@ const StreamingStatus = memo(function StreamingStatus({
interruptRequested,
networkPhase,
terminalWidth,
shouldAnimate,
}: {
streaming: boolean;
visible: boolean;
@@ -222,13 +232,14 @@ const StreamingStatus = memo(function StreamingStatus({
interruptRequested: boolean;
networkPhase: "upload" | "download" | "error" | null;
terminalWidth: number;
shouldAnimate: boolean;
}) {
const [shimmerOffset, setShimmerOffset] = useState(-3);
const [elapsedMs, setElapsedMs] = useState(0);
const streamStartRef = useRef<number | null>(null);
useEffect(() => {
if (!streaming || !visible) return;
if (!streaming || !visible || !shouldAnimate) return;
const id = setInterval(() => {
setShimmerOffset((prev) => {
@@ -241,7 +252,13 @@ const StreamingStatus = memo(function StreamingStatus({
}, 120); // Speed of shimmer animation
return () => clearInterval(id);
}, [streaming, thinkingMessage, visible, agentName]);
}, [streaming, thinkingMessage, visible, agentName, shouldAnimate]);
useEffect(() => {
if (!shouldAnimate) {
setShimmerOffset(-3);
}
}, [shouldAnimate]);
// Elapsed time tracking
useEffect(() => {
@@ -279,15 +296,36 @@ const StreamingStatus = memo(function StreamingStatus({
const showErrorArrow = networkArrow === "↑\u0338";
const statusContentWidth = Math.max(0, terminalWidth - 2);
const minMessageWidth = 12;
const desiredHintWidth = Math.max(18, Math.floor(statusContentWidth * 0.34));
const cappedHintWidth = Math.min(44, desiredHintWidth);
const hintColumnWidth = Math.max(
0,
Math.min(
cappedHintWidth,
Math.max(0, statusContentWidth - minMessageWidth),
),
const statusHintParts = useMemo(() => {
const parts: string[] = [];
if (shouldShowElapsed) {
parts.push(elapsedLabel);
}
if (shouldShowTokenCount) {
parts.push(
`${formatCompact(estimatedTokens)}${networkArrow ? ` ${networkArrow}` : ""}`,
);
} else if (showErrorArrow) {
parts.push(networkArrow);
}
return parts;
}, [
shouldShowElapsed,
elapsedLabel,
shouldShowTokenCount,
estimatedTokens,
networkArrow,
showErrorArrow,
]);
const statusHintSuffix = statusHintParts.length
? ` · ${statusHintParts.join(" · ")}`
: "";
const statusHintPlain = interruptRequested
? ` (interrupting${statusHintSuffix})`
: ` (esc to interrupt${statusHintSuffix})`;
const statusHintWidth = Array.from(statusHintPlain).length;
const maxHintWidth = Math.max(0, statusContentWidth - minMessageWidth);
const hintColumnWidth = Math.max(0, Math.min(statusHintWidth, maxHintWidth));
const maxMessageWidth = Math.max(0, statusContentWidth - hintColumnWidth);
const statusLabel = `${agentName ? `${agentName} ` : ""}${thinkingMessage}`;
const statusLabelWidth = Array.from(statusLabel).length;
@@ -302,33 +340,14 @@ const StreamingStatus = memo(function StreamingStatus({
const statusHintText = useMemo(() => {
const hintColor = chalk.hex(colors.subagent.hint);
const hintBold = hintColor.bold;
const parts: string[] = [];
if (shouldShowElapsed) {
parts.push(elapsedLabel);
}
if (shouldShowTokenCount) {
parts.push(
`${formatCompact(estimatedTokens)}${networkArrow ? ` ${networkArrow}` : ""}`,
);
} else if (showErrorArrow) {
parts.push(networkArrow);
}
const suffix = `${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""})`;
const suffix = `${statusHintSuffix})`;
if (interruptRequested) {
return hintColor(` (interrupting${suffix}`);
}
return (
hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`)
);
}, [
shouldShowElapsed,
elapsedLabel,
shouldShowTokenCount,
estimatedTokens,
interruptRequested,
networkArrow,
showErrorArrow,
]);
}, [interruptRequested, statusHintSuffix]);
if (!streaming || !visible) {
return null;
@@ -338,7 +357,7 @@ const StreamingStatus = memo(function StreamingStatus({
<Box flexDirection="row" marginBottom={1}>
<Box width={2} flexShrink={0}>
<Text color={colors.status.processing}>
<Spinner type="layer" />
{shouldAnimate ? <Spinner type="layer" /> : "●"}
</Text>
</Box>
<Box width={statusContentWidth} flexShrink={0} flexDirection="row">
@@ -346,7 +365,7 @@ const StreamingStatus = memo(function StreamingStatus({
<ShimmerText
boldPrefix={agentName || undefined}
message={thinkingMessage}
shimmerOffset={shimmerOffset}
shimmerOffset={shouldAnimate ? shimmerOffset : -3}
wrap="truncate-end"
/>
</Box>
@@ -403,6 +422,7 @@ export function Input({
onRestoredInputConsumed,
networkPhase = null,
terminalWidth,
shouldAnimate = true,
}: {
visible?: boolean;
streaming: boolean;
@@ -437,6 +457,7 @@ export function Input({
onRestoredInputConsumed?: () => void;
networkPhase?: "upload" | "download" | "error" | null;
terminalWidth: number;
shouldAnimate?: boolean;
}) {
const [value, setValue] = useState("");
const [escapePressed, setEscapePressed] = useState(false);
@@ -462,6 +483,60 @@ export function Input({
return Math.max(1, getVisualLines(value, contentWidth).length);
}, [value, contentWidth]);
const inputChromeHeight = inputRowLines + 3; // top divider + input rows + bottom divider + footer
const computedFooterRightColumnWidth = useMemo(
() => Math.max(28, Math.min(72, Math.floor(columns * 0.45))),
[columns],
);
const [footerRightColumnWidth, setFooterRightColumnWidth] = useState(
computedFooterRightColumnWidth,
);
const debugFlicker = process.env.LETTA_DEBUG_FLICKER === "1";
useEffect(() => {
if (!streaming) {
setFooterRightColumnWidth(computedFooterRightColumnWidth);
return;
}
// While streaming, keep the right column width stable to avoid occasional
// right-edge jitter. Allow significant shrink (terminal got smaller),
// defer growth until streaming ends.
if (computedFooterRightColumnWidth >= footerRightColumnWidth) {
const growthDelta =
computedFooterRightColumnWidth - footerRightColumnWidth;
if (debugFlicker && growthDelta >= FOOTER_WIDTH_STREAMING_DELTA) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:footer-width] defer growth ${footerRightColumnWidth} -> ${computedFooterRightColumnWidth} (delta=${growthDelta})`,
);
}
return;
}
const shrinkDelta = footerRightColumnWidth - computedFooterRightColumnWidth;
if (shrinkDelta < FOOTER_WIDTH_STREAMING_DELTA) {
if (debugFlicker && shrinkDelta > 0) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:footer-width] ignore minor shrink ${footerRightColumnWidth} -> ${computedFooterRightColumnWidth} (delta=${shrinkDelta})`,
);
}
return;
}
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:footer-width] shrink ${footerRightColumnWidth} -> ${computedFooterRightColumnWidth} (delta=${shrinkDelta})`,
);
}
setFooterRightColumnWidth(computedFooterRightColumnWidth);
}, [
streaming,
computedFooterRightColumnWidth,
footerRightColumnWidth,
debugFlicker,
]);
// Command history
const [history, setHistory] = useState<string[]>([]);
@@ -489,17 +564,17 @@ export function Input({
}
}, [restoredInput, value, onRestoredInputConsumed]);
const handleBangAtEmpty = () => {
const handleBangAtEmpty = useCallback(() => {
if (isBashMode) return false;
setIsBashMode(true);
return true;
};
}, [isBashMode]);
const handleBackspaceAtEmpty = () => {
const handleBackspaceAtEmpty = useCallback(() => {
if (!isBashMode) return false;
setIsBashMode(false);
return true;
};
}, [isBashMode]);
// Reset cursor position after it's been applied
useEffect(() => {
@@ -852,7 +927,7 @@ export function Input({
};
}, []);
const handleSubmit = async () => {
const handleSubmit = useCallback(async () => {
// Don't submit if autocomplete is active with matches
if (isAutocompleteActive) {
return;
@@ -868,9 +943,10 @@ export function Input({
if (bashRunning) return;
// Add to history if not empty and not a duplicate of the last entry
if (previousValue.trim() !== history[history.length - 1]) {
setHistory([...history, previousValue]);
}
setHistory((prev) => {
if (previousValue.trim() === prev[prev.length - 1]) return prev;
return [...prev, previousValue];
});
// Reset history navigation
setHistoryIndex(-1);
@@ -885,8 +961,11 @@ export function Input({
}
// Add to history if not empty and not a duplicate of the last entry
if (previousValue.trim() && previousValue !== history[history.length - 1]) {
setHistory([...history, previousValue]);
if (previousValue.trim()) {
setHistory((prev) => {
if (previousValue === prev[prev.length - 1]) return prev;
return [...prev, previousValue];
});
}
// Reset history navigation
@@ -899,10 +978,18 @@ export function Input({
if (!result.submitted) {
setValue(previousValue);
}
};
}, [
isAutocompleteActive,
value,
isBashMode,
bashRunning,
onBashSubmit,
onSubmit,
]);
// Handle file selection from autocomplete
const handleFileSelect = (selectedPath: string) => {
const handleFileSelect = useCallback(
(selectedPath: string) => {
// Find the last "@" and replace everything after it with the selected path
const atIndex = value.lastIndexOf("@");
if (atIndex === -1) return;
@@ -928,17 +1015,23 @@ export function Input({
setValue(newValue);
setCursorPos(newCursorPos);
};
},
[value],
);
// Handle slash command selection from autocomplete (Enter key - execute)
const handleCommandSelect = async (selectedCommand: string) => {
const handleCommandSelect = useCallback(
async (selectedCommand: string) => {
// For slash commands, submit immediately when selected via Enter
// This provides a better UX - pressing Enter on /model should open the model selector
const commandToSubmit = selectedCommand.trim();
// Add to history if not a duplicate of the last entry
if (commandToSubmit && commandToSubmit !== history[history.length - 1]) {
setHistory([...history, commandToSubmit]);
if (commandToSubmit) {
setHistory((prev) => {
if (commandToSubmit === prev[prev.length - 1]) return prev;
return [...prev, commandToSubmit];
});
}
// Reset history navigation
@@ -947,15 +1040,17 @@ export function Input({
setValue(""); // Clear immediately for responsiveness
await onSubmit(commandToSubmit);
};
},
[onSubmit],
);
// Handle slash command autocomplete (Tab key - fill text only)
const handleCommandAutocomplete = (selectedCommand: string) => {
const handleCommandAutocomplete = useCallback((selectedCommand: string) => {
// Just fill in the command text without executing
// User can then press Enter to execute or continue typing arguments
setValue(selectedCommand);
setCursorPos(selectedCommand.length);
};
}, []);
// Get display name and color for permission mode (ralph modes take precedence)
// Memoized to prevent unnecessary footer re-renders
@@ -1014,25 +1109,9 @@ export function Input({
// Memoized since it only changes when terminal width changes
const horizontalLine = useMemo(() => "─".repeat(columns), [columns]);
// If not visible, render nothing but keep component mounted to preserve state
if (!visible) {
return null;
}
const lowerPane = useMemo(() => {
return (
<Box flexDirection="column">
<StreamingStatus
streaming={streaming}
visible={visible}
tokenCount={tokenCount}
elapsedBaseMs={elapsedBaseMs}
thinkingMessage={thinkingMessage}
agentName={agentName}
interruptRequested={interruptRequested}
networkPhase={networkPhase}
terminalWidth={columns}
/>
<>
{/* Queue display - show whenever there are queued messages */}
{messageQueue && messageQueue.length > 0 && (
<QueuedMessages messages={messageQueue} />
@@ -1112,12 +1191,69 @@ export function Input({
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
}
hideFooter={hideFooter}
terminalWidth={columns}
rightColumnWidth={footerRightColumnWidth}
/>
</Box>
) : reserveInputSpace ? (
<Box height={inputChromeHeight} />
) : null}
</>
);
}, [
messageQueue,
interactionEnabled,
isBashMode,
horizontalLine,
contentWidth,
value,
handleSubmit,
cursorPos,
onEscapeCancel,
handleBangAtEmpty,
handleBackspaceAtEmpty,
onPasteError,
currentCursorPosition,
handleFileSelect,
handleCommandSelect,
handleCommandAutocomplete,
agentId,
agentName,
serverUrl,
conversationId,
ctrlCPressed,
escapePressed,
modeInfo?.name,
modeInfo?.color,
ralphActive,
ralphPending,
currentModel,
currentModelProvider,
hideFooter,
footerRightColumnWidth,
reserveInputSpace,
inputChromeHeight,
]);
// If not visible, render nothing but keep component mounted to preserve state
if (!visible) {
return null;
}
return (
<Box flexDirection="column">
<StreamingStatus
streaming={streaming}
visible={visible}
tokenCount={tokenCount}
elapsedBaseMs={elapsedBaseMs}
thinkingMessage={thinkingMessage}
agentName={agentName}
interruptRequested={interruptRequested}
networkPhase={networkPhase}
terminalWidth={columns}
shouldAnimate={shouldAnimate}
/>
{lowerPane}
</Box>
);
}

View File

@@ -10,7 +10,7 @@
* if animations should be disabled, then provides this via context.
*/
import { createContext, type ReactNode, useContext } from "react";
import { createContext, type ReactNode, useContext, useMemo } from "react";
interface AnimationContextValue {
/**
@@ -46,8 +46,10 @@ export function AnimationProvider({
children,
shouldAnimate,
}: AnimationProviderProps) {
const contextValue = useMemo(() => ({ shouldAnimate }), [shouldAnimate]);
return (
<AnimationContext.Provider value={{ shouldAnimate }}>
<AnimationContext.Provider value={contextValue}>
{children}
</AnimationContext.Provider>
);

63
vendor/ink/build/log-update.js vendored Normal file
View File

@@ -0,0 +1,63 @@
import ansiEscapes from 'ansi-escapes';
import cliCursor from 'cli-cursor';
const create = (stream, { showCursor = false } = {}) => {
let previousLineCount = 0;
let previousOutput = '';
let hasHiddenCursor = false;
const renderWithClearedLineEnds = (output) => {
const lines = output.split('\n');
return lines.map((line) => line + ansiEscapes.eraseEndLine).join('\n');
};
const render = (str) => {
if (!showCursor && !hasHiddenCursor) {
cliCursor.hide();
hasHiddenCursor = true;
}
const output = str + '\n';
if (output === previousOutput) {
return;
}
// Keep existing line-count semantics used by Ink's bundled log-update.
const nextLineCount = output.split('\n').length;
// Avoid eraseLines() pre-clear flashes by repainting in place:
// move to start of previous frame, rewrite each line while erasing EOL,
// then clear any trailing old lines if the frame got shorter.
if (previousLineCount > 1) {
stream.write(ansiEscapes.cursorUp(previousLineCount - 1));
}
stream.write(renderWithClearedLineEnds(output));
if (nextLineCount < previousLineCount) {
stream.write(ansiEscapes.eraseDown);
}
previousOutput = output;
previousLineCount = nextLineCount;
};
render.clear = () => {
stream.write(ansiEscapes.eraseLines(previousLineCount));
previousOutput = '';
previousLineCount = 0;
};
render.done = () => {
previousOutput = '';
previousLineCount = 0;
if (!showCursor) {
cliCursor.show();
hasHiddenCursor = false;
}
};
return render;
};
const logUpdate = { create };
export default logUpdate;