diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 981a1b0..ce5a0db 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -244,6 +244,8 @@ import { useTerminalRows, useTerminalWidth } from "./hooks/useTerminalWidth"; // Used only for terminal resize, not for dialog dismissal (see PR for details) const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H"; const MIN_RESIZE_DELTA = 2; +const RESIZE_SETTLE_MS = 250; +const MIN_CLEAR_INTERVAL_MS = 750; const TOOL_CALL_COMMIT_DEFER_MS = 50; // Eager approval checking is now CONDITIONAL (LET-7101): @@ -1546,7 +1548,59 @@ export default function App({ const pendingResizeColumnsRef = useRef(null); const [staticRenderEpoch, setStaticRenderEpoch] = useState(0); const resizeClearTimeout = useRef | null>(null); + const lastClearAtRef = useRef(0); const isInitialResizeRef = useRef(true); + + 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 scheduleResizeClear = useCallback( + (targetColumns: number) => { + if (targetColumns === lastClearedColumnsRef.current) { + return; + } + + if (resizeClearTimeout.current) { + clearTimeout(resizeClearTimeout.current); + resizeClearTimeout.current = null; + } + + const elapsedSinceClear = Date.now() - lastClearAtRef.current; + const rateLimitDelay = + elapsedSinceClear >= MIN_CLEAR_INTERVAL_MS + ? 0 + : MIN_CLEAR_INTERVAL_MS - elapsedSinceClear; + const delay = Math.max(RESIZE_SETTLE_MS, rateLimitDelay); + + resizeClearTimeout.current = setTimeout(() => { + resizeClearTimeout.current = null; + + // If resize changed again while waiting, let the latest schedule win. + if (prevColumnsRef.current !== targetColumns) { + return; + } + + if (targetColumns === lastClearedColumnsRef.current) { + return; + } + + clearAndRemount(targetColumns); + }, delay); + }, + [clearAndRemount], + ); + useEffect(() => { const prev = prevColumnsRef.current; if (columns === prev) return; @@ -1567,12 +1621,12 @@ export default function App({ const delta = Math.abs(columns - prev); const isMinorJitter = delta > 0 && delta < MIN_RESIZE_DELTA; - if (streaming) { - if (isMinorJitter) { - prevColumnsRef.current = columns; - return; - } + if (isMinorJitter) { + prevColumnsRef.current = columns; + return; + } + if (streaming) { // Defer clear/remount until streaming ends to avoid Ghostty flicker. pendingResizeRef.current = true; pendingResizeColumnsRef.current = columns; @@ -1588,32 +1642,11 @@ export default function App({ } // Debounce to avoid flicker from rapid resize events (e.g., drag resize, Ghostty focus) - // Clear and remount must happen together - otherwise Static re-renders on top of existing content - const scheduledColumns = columns; - resizeClearTimeout.current = setTimeout(() => { - resizeClearTimeout.current = null; - 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 = scheduledColumns; - }, 150); + // and keep clear frequency bounded to prevent flash storms. + scheduleResizeClear(columns); prevColumnsRef.current = columns; - - // Cleanup on unmount - return () => { - if (resizeClearTimeout.current) { - clearTimeout(resizeClearTimeout.current); - resizeClearTimeout.current = null; - } - }; - }, [columns, streaming]); + }, [columns, streaming, scheduleResizeClear]); useEffect(() => { if (streaming) { @@ -1635,17 +1668,17 @@ export default function App({ if (pendingColumns === null) return; if (pendingColumns === lastClearedColumnsRef.current) return; - 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 = pendingColumns; - }, [columns, streaming]); + scheduleResizeClear(pendingColumns); + }, [columns, streaming, scheduleResizeClear]); + + useEffect(() => { + return () => { + if (resizeClearTimeout.current) { + clearTimeout(resizeClearTimeout.current); + resizeClearTimeout.current = null; + } + }; + }, []); const deferredToolCallCommitsRef = useRef>(new Map()); const [deferredCommitAt, setDeferredCommitAt] = useState(null); @@ -10437,6 +10470,7 @@ Plan file path: ${planFilePath}`; restoredInput={restoredInput} onRestoredInputConsumed={() => setRestoredInput(null)} networkPhase={networkPhase} + terminalWidth={columns} /> diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 208a90c..09b0964 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -25,7 +25,6 @@ import { ralphMode } from "../../ralph/mode"; import { settingsManager } from "../../settings-manager"; import { charsToTokens, formatCompact } from "../helpers/format"; import type { QueuedMessage } from "../helpers/messageQueueBridge"; -import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { InputAssist } from "./InputAssist"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; @@ -39,6 +38,13 @@ const Spinner = SpinnerLib as ComponentType<{ type?: string }>; // Window for double-escape to clear input const ESC_CLEAR_WINDOW_MS = 2500; +function truncateEnd(value: string, maxChars: number): string { + if (maxChars <= 0) return ""; + if (value.length <= maxChars) return value; + if (maxChars <= 3) return value.slice(0, maxChars); + return `${value.slice(0, maxChars - 3)}...`; +} + /** * Represents a visual line segment in the text. * A visual line ends at either a newline character or when it reaches lineWidth. @@ -117,6 +123,7 @@ const InputFooter = memo(function InputFooter({ isByokProvider, isAutocompleteActive, hideFooter, + terminalWidth, }: { ctrlCPressed: boolean; escapePressed: boolean; @@ -130,47 +137,70 @@ const InputFooter = memo(function InputFooter({ isByokProvider: boolean; isAutocompleteActive: boolean; hideFooter: boolean; + terminalWidth: number; }) { - // Hide footer when autocomplete is showing - if (hideFooter || isAutocompleteActive) { - return null; - } + const hideFooterContent = hideFooter || isAutocompleteActive; + 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 byokFlag = isByokProvider ? " ▲" : ""; + const reservedChars = displayAgentName.length + byokFlag.length + 4; + const maxModelChars = Math.max(8, rightColumnWidth - reservedChars); + const displayModel = truncateEnd(currentModel ?? "unknown", maxModelChars); return ( - - {ctrlCPressed ? ( - Press CTRL-C again to exit - ) : escapePressed ? ( - Press Esc again to clear - ) : isBashMode ? ( - - ⏵⏵ bash mode - - {" "} - (backspace to exit) + + + {hideFooterContent ? ( + + ) : ctrlCPressed ? ( + Press CTRL-C again to exit + ) : escapePressed ? ( + Press Esc again to clear + ) : isBashMode ? ( + + ⏵⏵ bash mode + + {" "} + (backspace to exit) + - - ) : modeName && modeColor ? ( - - ⏵⏵ {modeName} - - {" "} - (shift+tab to {showExitHint ? "exit" : "cycle"}) + ) : modeName && modeColor ? ( + + ⏵⏵ {modeName} + + {" "} + (shift+tab to {showExitHint ? "exit" : "cycle"}) + - - ) : ( - Press / for commands - )} - - {agentName || "Unnamed"} - - {` [${currentModel ?? "unknown"}`} - {isByokProvider && ( - - )} - {"]"} - - + ) : ( + Press / for commands + )} + + + {hideFooterContent ? ( + + ) : ( + + {displayAgentName} + + {` [${displayModel}${byokFlag}]`} + + + )} + ); }); @@ -216,6 +246,7 @@ export function Input({ restoredInput, onRestoredInputConsumed, networkPhase = null, + terminalWidth, }: { visible?: boolean; streaming: boolean; @@ -249,6 +280,7 @@ export function Input({ restoredInput?: string | null; onRestoredInputConsumed?: () => void; networkPhase?: "upload" | "download" | "error" | null; + terminalWidth: number; }) { const [value, setValue] = useState(""); const [escapePressed, setEscapePressed] = useState(false); @@ -263,8 +295,8 @@ export function Input({ const [cursorPos, setCursorPos] = useState(undefined); const [currentCursorPosition, setCurrentCursorPosition] = useState(0); - // Terminal width (reactive to window resizing) - const columns = useTerminalWidth(); + // Terminal width is sourced from App.tsx to avoid duplicate resize subscriptions. + const columns = terminalWidth; const contentWidth = Math.max(0, columns - 2); const interactionEnabled = visible && inputEnabled; @@ -1023,6 +1055,7 @@ export function Input({ } isAutocompleteActive={isAutocompleteActive} hideFooter={hideFooter} + terminalWidth={columns} /> ) : reserveInputSpace ? (