diff --git a/scripts/postinstall-patches.js b/scripts/postinstall-patches.js
index cc94729..1684d7a 100644
--- a/scripts/postinstall-patches.js
+++ b/scripts/postinstall-patches.js
@@ -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(
diff --git a/src/cli/App.tsx b/src/cli/App.tsx
index 7b9971f..a47f006 100644
--- a/src/cli/App.tsx
+++ b/src/cli/App.tsx
@@ -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,19 +1587,29 @@ export default function App({
}, STABLE_WIDTH_SETTLE_MS);
}, [rawColumns, stableColumns]);
- const clearAndRemount = useCallback((targetColumns: number) => {
- if (
- typeof process !== "undefined" &&
- process.stdout &&
- "write" in process.stdout &&
- process.stdout.isTTY
- ) {
- process.stdout.write(CLEAR_SCREEN_AND_HOME);
- }
- setStaticRenderEpoch((epoch) => epoch + 1);
- lastClearedColumnsRef.current = targetColumns;
- lastClearAtRef.current = Date.now();
- }, []);
+ 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 &&
+ "write" in process.stdout &&
+ process.stdout.isTTY
+ ) {
+ process.stdout.write(CLEAR_SCREEN_AND_HOME);
+ }
+ 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}
/>
diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx
index 1a81b4f..129e35a 100644
--- a/src/cli/components/InputRich.tsx
+++ b/src/cli/components/InputRich.tsx
@@ -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 (
@@ -178,24 +200,11 @@ const InputFooter = memo(function InputFooter({
Press / for commands
)}
-
+
{hideFooterContent ? (
-
+ {" ".repeat(rightColumnWidth)}
) : (
-
- {displayAgentName}
- {" ["}
- {displayModel}
- {isByokProvider ? (
- <>
-
-
- ▲
-
- >
- ) : null}
- {"]"}
-
+ {rightLabel}
)}
@@ -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(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({
-
+ {shouldAnimate ? : "●"}
@@ -346,7 +365,7 @@ const StreamingStatus = memo(function StreamingStatus({
@@ -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([]);
@@ -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,63 +978,79 @@ export function Input({
if (!result.submitted) {
setValue(previousValue);
}
- };
+ }, [
+ isAutocompleteActive,
+ value,
+ isBashMode,
+ bashRunning,
+ onBashSubmit,
+ onSubmit,
+ ]);
// Handle file selection from autocomplete
- const handleFileSelect = (selectedPath: string) => {
- // Find the last "@" and replace everything after it with the selected path
- const atIndex = value.lastIndexOf("@");
- if (atIndex === -1) return;
+ 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;
- const beforeAt = value.slice(0, atIndex);
- const afterAt = value.slice(atIndex + 1);
- const spaceIndex = afterAt.indexOf(" ");
+ const beforeAt = value.slice(0, atIndex);
+ const afterAt = value.slice(atIndex + 1);
+ const spaceIndex = afterAt.indexOf(" ");
- let newValue: string;
- let newCursorPos: number;
+ let newValue: string;
+ let newCursorPos: number;
- // Replace the query part with the selected path
- if (spaceIndex === -1) {
- // No space after @query, replace to end
- newValue = `${beforeAt}@${selectedPath} `;
- newCursorPos = newValue.length;
- } else {
- // Space exists, replace only the query part
- const afterQuery = afterAt.slice(spaceIndex);
- newValue = `${beforeAt}@${selectedPath}${afterQuery}`;
- newCursorPos = beforeAt.length + selectedPath.length + 1; // After the path
- }
+ // Replace the query part with the selected path
+ if (spaceIndex === -1) {
+ // No space after @query, replace to end
+ newValue = `${beforeAt}@${selectedPath} `;
+ newCursorPos = newValue.length;
+ } else {
+ // Space exists, replace only the query part
+ const afterQuery = afterAt.slice(spaceIndex);
+ newValue = `${beforeAt}@${selectedPath}${afterQuery}`;
+ newCursorPos = beforeAt.length + selectedPath.length + 1; // After the path
+ }
- setValue(newValue);
- setCursorPos(newCursorPos);
- };
+ setValue(newValue);
+ setCursorPos(newCursorPos);
+ },
+ [value],
+ );
// Handle slash command selection from autocomplete (Enter key - execute)
- const handleCommandSelect = 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();
+ 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]);
- }
+ // Add to history if not a duplicate of the last entry
+ if (commandToSubmit) {
+ setHistory((prev) => {
+ if (commandToSubmit === prev[prev.length - 1]) return prev;
+ return [...prev, commandToSubmit];
+ });
+ }
- // Reset history navigation
- setHistoryIndex(-1);
- setTemporaryInput("");
+ // Reset history navigation
+ setHistoryIndex(-1);
+ setTemporaryInput("");
- setValue(""); // Clear immediately for responsiveness
- await onSubmit(commandToSubmit);
- };
+ 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,6 +1109,131 @@ export function Input({
// Memoized since it only changes when terminal width changes
const horizontalLine = useMemo(() => "─".repeat(columns), [columns]);
+ const lowerPane = useMemo(() => {
+ return (
+ <>
+ {/* Queue display - show whenever there are queued messages */}
+ {messageQueue && messageQueue.length > 0 && (
+
+ )}
+
+ {interactionEnabled ? (
+
+ {/* Top horizontal divider */}
+
+ {horizontalLine}
+
+
+ {/* Two-column layout for input, matching message components */}
+
+
+
+ {isBashMode ? "!" : ">"}
+
+
+
+
+
+
+
+
+ {/* Bottom horizontal divider */}
+
+ {horizontalLine}
+
+
+
+
+
+
+ ) : reserveInputSpace ? (
+
+ ) : 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;
@@ -1031,93 +1251,9 @@ export function Input({
interruptRequested={interruptRequested}
networkPhase={networkPhase}
terminalWidth={columns}
+ shouldAnimate={shouldAnimate}
/>
-
- {/* Queue display - show whenever there are queued messages */}
- {messageQueue && messageQueue.length > 0 && (
-
- )}
-
- {interactionEnabled ? (
-
- {/* Top horizontal divider */}
-
- {horizontalLine}
-
-
- {/* Two-column layout for input, matching message components */}
-
-
-
- {isBashMode ? "!" : ">"}
-
-
-
-
-
-
-
-
- {/* Bottom horizontal divider */}
-
- {horizontalLine}
-
-
-
-
-
-
- ) : reserveInputSpace ? (
-
- ) : null}
+ {lowerPane}
);
}
diff --git a/src/cli/contexts/AnimationContext.tsx b/src/cli/contexts/AnimationContext.tsx
index c870f08..ce76b56 100644
--- a/src/cli/contexts/AnimationContext.tsx
+++ b/src/cli/contexts/AnimationContext.tsx
@@ -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 (
-
+
{children}
);
diff --git a/vendor/ink/build/log-update.js b/vendor/ink/build/log-update.js
new file mode 100644
index 0000000..78d9ab5
--- /dev/null
+++ b/vendor/ink/build/log-update.js
@@ -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;
+