diff --git a/src/cli/App.tsx b/src/cli/App.tsx index b12b91b..a4dd6e9 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1733,16 +1733,124 @@ export default function App({ null, ); const prevColumnsRef = useRef(rawColumns); + const lastResizeColumnsRef = useRef(rawColumns); + const lastResizeRowsRef = useRef(terminalRows); const lastClearedColumnsRef = useRef(rawColumns); const pendingResizeRef = useRef(false); const pendingResizeColumnsRef = useRef(null); const [staticRenderEpoch, setStaticRenderEpoch] = useState(0); const resizeClearTimeout = useRef | null>(null); const lastClearAtRef = useRef(0); + const resizeGestureTimeoutRef = useRef | null>( + null, + ); + const didImmediateShrinkClearRef = useRef(false); const isInitialResizeRef = useRef(true); const columns = stableColumns; + // Keep bottom chrome from ever exceeding the *actual* terminal width. + // When widening, we prefer the old behavior (wait until settle), so we use + // stableColumns. When shrinking, we must clamp to rawColumns to avoid Ink + // wrapping the footer/input chrome and "printing" divider rows into the + // transcript while dragging. + const chromeColumns = Math.min(rawColumns, stableColumns); const debugFlicker = process.env.LETTA_DEBUG_FLICKER === "1"; + // Terminal resize + Ink: + // When the terminal shrinks, the *previous* frame reflows (wraps to more + // lines) instantly at the emulator level. Ink's incremental redraw then tries + // to clear based on the old line count and can leave stale rows behind. + // + // Fix: on shrink events, clear the screen *synchronously* in the resize event + // handler (before React/Ink flushes the next frame) and remount Static output. + useEffect(() => { + if ( + typeof process === "undefined" || + !process.stdout || + !("on" in process.stdout) || + !process.stdout.isTTY + ) { + return; + } + + const stdout = process.stdout; + const onResize = () => { + const nextColumns = stdout.columns ?? lastResizeColumnsRef.current; + const nextRows = stdout.rows ?? lastResizeRowsRef.current; + + const prevColumns = lastResizeColumnsRef.current; + const prevRows = lastResizeRowsRef.current; + + lastResizeColumnsRef.current = nextColumns; + lastResizeRowsRef.current = nextRows; + + // Skip initial mount. + if (isInitialResizeRef.current) { + return; + } + + const shrunk = nextColumns < prevColumns || nextRows < prevRows; + if (!shrunk) { + // Reset shrink-clear guard once the gesture ends. + if (resizeGestureTimeoutRef.current) { + clearTimeout(resizeGestureTimeoutRef.current); + } + resizeGestureTimeoutRef.current = setTimeout(() => { + resizeGestureTimeoutRef.current = null; + didImmediateShrinkClearRef.current = false; + }, RESIZE_SETTLE_MS); + return; + } + + // During a shrink gesture, do an immediate clear only once. + // Clearing on every resize event causes extreme flicker. + if (didImmediateShrinkClearRef.current) { + if (resizeGestureTimeoutRef.current) { + clearTimeout(resizeGestureTimeoutRef.current); + } + resizeGestureTimeoutRef.current = setTimeout(() => { + resizeGestureTimeoutRef.current = null; + didImmediateShrinkClearRef.current = false; + }, RESIZE_SETTLE_MS); + return; + } + + if (debugFlicker) { + // eslint-disable-next-line no-console + console.error( + `[debug:flicker:resize-immediate-clear] next=${nextColumns}x${nextRows} prev=${prevColumns}x${prevRows} streaming=${streamingRef.current}`, + ); + } + + // Cancel any debounced clear; we're taking the immediate-clear path. + if (resizeClearTimeout.current) { + clearTimeout(resizeClearTimeout.current); + resizeClearTimeout.current = null; + } + + stdout.write(CLEAR_SCREEN_AND_HOME); + setStaticRenderEpoch((epoch) => epoch + 1); + lastClearedColumnsRef.current = nextColumns; + lastClearAtRef.current = Date.now(); + didImmediateShrinkClearRef.current = true; + if (resizeGestureTimeoutRef.current) { + clearTimeout(resizeGestureTimeoutRef.current); + } + resizeGestureTimeoutRef.current = setTimeout(() => { + resizeGestureTimeoutRef.current = null; + didImmediateShrinkClearRef.current = false; + }, RESIZE_SETTLE_MS); + }; + + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + if (resizeGestureTimeoutRef.current) { + clearTimeout(resizeGestureTimeoutRef.current); + resizeGestureTimeoutRef.current = null; + } + }; + }, [debugFlicker, streamingRef]); + useEffect(() => { if (rawColumns === stableColumns) { if (stableColumnsTimeoutRef.current) { @@ -1902,6 +2010,20 @@ export default function App({ prevColumnsRef.current = rawColumns; }, [rawColumns, streaming, scheduleResizeClear]); + // Reflow Static output for 1-col width changes too. + // rawColumns resize handling intentionally ignores 1-col "jitter" to reduce + // flicker, but that also means widening by small increments won't remount + // Static and existing output won't reflow. + // + // stableColumns only advances once the width has settled, so it's safe to use + // for a low-frequency remount trigger. + useEffect(() => { + if (isInitialResizeRef.current) return; + if (streaming) return; + if (stableColumns === lastClearedColumnsRef.current) return; + scheduleResizeClear(stableColumns); + }, [stableColumns, streaming, scheduleResizeClear]); + useEffect(() => { if (streaming) { if (resizeClearTimeout.current) { @@ -2135,6 +2257,9 @@ export default function App({ const statusLine = useConfigurableStatusLine({ modelId: llmConfigRef.current?.model ?? null, modelDisplayName: currentModelDisplay, + reasoningEffort: currentReasoningEffort, + systemPromptId: currentSystemPromptId, + toolset: currentToolset, currentDirectory: process.cwd(), projectDirectory, sessionId: conversationId, @@ -2147,7 +2272,7 @@ export default function App({ usedContextTokens: contextTrackerRef.current.lastContextTokens, permissionMode: uiPermissionMode, networkPhase, - terminalWidth: columns, + terminalWidth: chromeColumns, triggerVersion: statusLineTriggerVersion, }); @@ -2160,7 +2285,7 @@ export default function App({ previousStreamingForStatusLineRef.current = streaming; }, [streaming, triggerStatusLineRefresh]); - const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}`; + const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}|${currentReasoningEffort ?? ""}|${currentSystemPromptId ?? ""}|${currentToolset ?? ""}`; // Trigger status line when key session identity/display state changes. useEffect(() => { @@ -6650,6 +6775,9 @@ export default function App({ buildStatusLinePayload({ modelId: llmConfigRef.current?.model ?? null, modelDisplayName: currentModelDisplay, + reasoningEffort: currentReasoningEffort, + systemPromptId: currentSystemPromptId, + toolset: currentToolset, currentDirectory: wd, projectDirectory, sessionId: conversationIdRef.current, @@ -6663,7 +6791,7 @@ export default function App({ contextTrackerRef.current.lastContextTokens, permissionMode: uiPermissionMode, networkPhase, - terminalWidth: columns, + terminalWidth: chromeColumns, }), { timeout: config.timeout, workingDirectory: wd }, ); @@ -12030,6 +12158,8 @@ Plan file path: ${planFilePath}`; currentModel={currentModelDisplay} currentModelProvider={currentModelProvider} currentReasoningEffort={currentReasoningEffort} + currentSystemPromptId={currentSystemPromptId} + currentToolset={currentToolset} messageQueue={messageQueue} onEnterQueueEditMode={handleEnterQueueEditMode} onEscapeCancel={ @@ -12044,7 +12174,7 @@ Plan file path: ${planFilePath}`; restoredInput={restoredInput} onRestoredInputConsumed={() => setRestoredInput(null)} networkPhase={networkPhase} - terminalWidth={columns} + terminalWidth={chromeColumns} shouldAnimate={shouldAnimate} statusLineText={statusLine.text || undefined} statusLineRight={statusLine.rightText || undefined} diff --git a/src/cli/components/AgentInfoBar.tsx b/src/cli/components/AgentInfoBar.tsx index 543450a..257568f 100644 --- a/src/cli/components/AgentInfoBar.tsx +++ b/src/cli/components/AgentInfoBar.tsx @@ -1,13 +1,30 @@ import { Box } from "ink"; -import Link from "ink-link"; import { memo, useMemo } from "react"; +import stringWidth from "string-width"; import type { ModelReasoningEffort } from "../../agent/model"; import { DEFAULT_AGENT_NAME } from "../../constants"; import { settingsManager } from "../../settings-manager"; import { getVersion } from "../../version"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { Text } from "./Text"; +function truncateText(text: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + if (stringWidth(text) <= maxWidth) return text; + if (maxWidth <= 3) return ".".repeat(maxWidth); + + const suffix = "..."; + const budget = Math.max(0, maxWidth - stringWidth(suffix)); + let out = ""; + for (const ch of text) { + const next = out + ch; + if (stringWidth(next) > budget) break; + out = next; + } + return out + suffix; +} + interface AgentInfoBarProps { agentId?: string; agentName?: string | null; @@ -40,7 +57,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({ serverUrl, conversationId, }: AgentInfoBarProps) { - const isTmux = Boolean(process.env.TMUX); + const columns = useTerminalWidth(); // Check if current agent is pinned const isPinned = useMemo(() => { if (!agentId) return false; @@ -50,9 +67,12 @@ export const AgentInfoBar = memo(function AgentInfoBar({ }, [agentId]); const isCloudUser = serverUrl?.includes("api.letta.com"); - const adeUrl = - agentId && agentId !== "loading" - ? `https://app.letta.com/agents/${agentId}${conversationId && conversationId !== "default" ? `?conversation=${conversationId}` : ""}` + const adeConversationUrl = + agentId && + agentId !== "loading" && + conversationId && + conversationId !== "default" + ? `https://app.letta.com/agents/${agentId}?conversation=${conversationId}` : ""; const showBottomBar = agentId && agentId !== "loading"; const reasoningLabel = formatReasoningLabel(currentReasoningEffort); @@ -66,6 +86,16 @@ export const AgentInfoBar = memo(function AgentInfoBar({ // Alien ASCII art lines (4 lines tall, with 2-char indent + extra space before text) const alienLines = [" ▗▖▗▖ ", " ▙█▜▛█▟ ", " ▝▜▛▜▛▘ ", " "]; + const leftWidth = Math.max(...alienLines.map((l) => stringWidth(l))); + const rightWidth = Math.max(0, columns - leftWidth); + + const agentNameLabel = agentName || "Unnamed"; + const agentHint = isPinned + ? " (pinned)" + : agentName === DEFAULT_AGENT_NAME || !agentName + ? " (type /pin to give your agent a real name!)" + : " (type /pin to pin agent)"; + const agentNameLine = `${agentNameLabel}${agentHint}`; return ( @@ -74,11 +104,8 @@ export const AgentInfoBar = memo(function AgentInfoBar({ {/* Version and Discord/feedback info */} - - {" "}Letta Code v{getVersion()} · Report bugs with /feedback or{" "} - - on Discord ↗ - + + {" "}Letta Code v{getVersion()} · /feedback · discord.gg/letta @@ -88,61 +115,83 @@ export const AgentInfoBar = memo(function AgentInfoBar({ {/* Alien + Agent name */} {alienLines[0]} - - {agentName || "Unnamed"} - - {isPinned ? ( - (pinned ✓) - ) : agentName === DEFAULT_AGENT_NAME || !agentName ? ( - (type /pin to give your agent a real name!) - ) : ( - (type /pin to pin agent) - )} + + + {truncateText(agentNameLine, rightWidth)} + + {/* Alien + Links */} {alienLines[1]} - {isCloudUser && adeUrl && !isTmux && ( - <> - - Open in ADE ↗ - - · - + {!isCloudUser && ( + + + {truncateText(serverUrl ?? "", rightWidth)} + + )} - {isCloudUser && adeUrl && isTmux && ( - Open in ADE: {adeUrl} · - )} - {isCloudUser && ( - - View usage ↗ - - )} - {!isCloudUser && {serverUrl}} + {/* Keep usage on its own line to avoid breaking the alien art rows. */} + {isCloudUser && ( + + {alienLines[3]} + + + {truncateText( + "Usage: https://app.letta.com/settings/organization/usage", + rightWidth, + )} + + + + )} + {/* Model summary */} {alienLines[2]} - {modelLine ?? "model unknown"} + + + {truncateText(modelLine ?? "model unknown", rightWidth)} + + {/* Agent ID */} {alienLines[3]} - {agentId} + + + {truncateText(agentId, rightWidth)} + + {/* Phantom alien row + Conversation ID */} {alienLines[3]} {conversationId && conversationId !== "default" ? ( - {conversationId} + + + {truncateText(conversationId, rightWidth)} + + ) : ( - default conversation + + default conversation + )} + + {/* Full ADE conversation URL (may wrap; kept last so it can't break the art rows) */} + {isCloudUser && adeConversationUrl && ( + + {alienLines[3]} + {`ADE: ${adeConversationUrl}`} + + )} ); }); diff --git a/src/cli/components/Autocomplete.tsx b/src/cli/components/Autocomplete.tsx index b35d309..78a6b95 100644 --- a/src/cli/components/Autocomplete.tsx +++ b/src/cli/components/Autocomplete.tsx @@ -42,6 +42,8 @@ export function AutocompleteItem({ {" "} {children} diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 08db02e..2abd4b4 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -217,6 +217,8 @@ const InputFooter = memo(function InputFooter({ agentName, currentModel, currentReasoningEffort, + currentSystemPromptId, + currentToolset, isOpenAICodexProvider, isByokProvider, hideFooter, @@ -234,6 +236,8 @@ const InputFooter = memo(function InputFooter({ agentName: string | null | undefined; currentModel: string | null | undefined; currentReasoningEffort?: ModelReasoningEffort | null; + currentSystemPromptId?: string | null; + currentToolset?: string | null; isOpenAICodexProvider: boolean; isByokProvider: boolean; hideFooter: boolean; @@ -247,13 +251,50 @@ const InputFooter = memo(function InputFooter({ const displayAgentName = truncateEnd(agentName || "Unnamed", maxAgentChars); const reasoningTag = getReasoningEffortTag(currentReasoningEffort); const byokExtraChars = isByokProvider ? 2 : 0; // " ▲" - const reservedChars = displayAgentName.length + byokExtraChars + 4; - const maxModelChars = Math.max(8, rightColumnWidth - reservedChars); + + const baseReservedChars = displayAgentName.length + byokExtraChars + 4; const modelWithReasoning = (currentModel ?? "unknown") + (reasoningTag ? ` (${reasoningTag})` : ""); + + // Optional suffixes: system prompt id + toolset. + const suffixParts: string[] = []; + if (currentSystemPromptId) { + suffixParts.push(`s:${currentSystemPromptId}`); + } + if (currentToolset) { + suffixParts.push(`t:${currentToolset}`); + } + + // Reserve 4 chars per suffix part so the label is visible even on narrow terminals. + const minSuffixBudget = suffixParts.length * 4; + const maxModelChars = Math.max( + 8, + rightColumnWidth - baseReservedChars - minSuffixBudget, + ); const displayModel = truncateEnd(modelWithReasoning, maxModelChars); - const rightTextLength = + + const baseTextLength = displayAgentName.length + displayModel.length + byokExtraChars + 3; + const maxSuffixChars = Math.max(0, rightColumnWidth - baseTextLength); + + const displaySuffix = (() => { + if (suffixParts.length === 0 || maxSuffixChars <= 0) return ""; + + let remaining = maxSuffixChars; + const out: string[] = []; + for (const part of suffixParts) { + // Leading space before each part. + if (remaining <= 1) break; + const budget = remaining - 1; + const clipped = truncateEnd(part, budget); + if (!clipped) break; + out.push(` ${clipped}`); + remaining -= 1 + clipped.length; + } + return out.join(""); + })(); + + const rightTextLength = baseTextLength + displaySuffix.length; const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength); const rightLabel = useMemo(() => { const parts: string[] = []; @@ -268,11 +309,17 @@ const InputFooter = memo(function InputFooter({ ); } parts.push(chalk.dim("]")); + + if (displaySuffix) { + parts.push(chalk.dim(displaySuffix)); + } + return parts.join(""); }, [ rightPrefixSpaces, displayAgentName, displayModel, + displaySuffix, isByokProvider, isOpenAICodexProvider, ]); @@ -365,12 +412,44 @@ const StreamingStatus = memo(function StreamingStatus({ terminalWidth: number; shouldAnimate: boolean; }) { + // While the user is actively resizing the terminal, Ink can struggle to + // clear/redraw rapidly-changing animated output (spinner/shimmer). + // Freeze animations briefly during resize to keep output stable. + const [isResizing, setIsResizing] = useState(false); + const resizeTimerRef = useRef | null>(null); + const lastWidthRef = useRef(terminalWidth); + + useEffect(() => { + if (terminalWidth === lastWidthRef.current) return; + lastWidthRef.current = terminalWidth; + + setIsResizing(true); + if (resizeTimerRef.current) { + clearTimeout(resizeTimerRef.current); + } + resizeTimerRef.current = setTimeout(() => { + resizeTimerRef.current = null; + setIsResizing(false); + }, 750); + }, [terminalWidth]); + + useEffect(() => { + return () => { + if (resizeTimerRef.current) { + clearTimeout(resizeTimerRef.current); + resizeTimerRef.current = null; + } + }; + }, []); + + const animate = shouldAnimate && !isResizing; + const [shimmerOffset, setShimmerOffset] = useState(-3); const [elapsedMs, setElapsedMs] = useState(0); const streamStartRef = useRef(null); useEffect(() => { - if (!streaming || !visible || !shouldAnimate) return; + if (!streaming || !visible || !animate) return; const id = setInterval(() => { setShimmerOffset((prev) => { @@ -383,17 +462,17 @@ const StreamingStatus = memo(function StreamingStatus({ }, 120); // Speed of shimmer animation return () => clearInterval(id); - }, [streaming, thinkingMessage, visible, agentName, shouldAnimate]); + }, [streaming, thinkingMessage, visible, agentName, animate]); useEffect(() => { - if (!shouldAnimate) { + if (!animate) { setShimmerOffset(-3); } - }, [shouldAnimate]); + }, [animate]); // Elapsed time tracking useEffect(() => { - if (streaming && visible) { + if (streaming && visible && !isResizing) { // Start tracking when streaming begins if (streamStartRef.current === null) { streamStartRef.current = performance.now(); @@ -408,7 +487,7 @@ const StreamingStatus = memo(function StreamingStatus({ // Reset when streaming stops streamStartRef.current = null; setElapsedMs(0); - }, [streaming, visible]); + }, [streaming, visible, isResizing]); const estimatedTokens = charsToTokens(tokenCount); const totalElapsedMs = elapsedBaseMs + elapsedMs; @@ -425,7 +504,10 @@ const StreamingStatus = memo(function StreamingStatus({ return "↑\u0338"; }, [networkPhase]); const showErrorArrow = networkArrow === "↑\u0338"; - const statusContentWidth = Math.max(0, terminalWidth - 2); + // Avoid painting into the terminal's last column; some terminals will soft-wrap + // padded Ink rows at the edge which breaks Ink's line-clearing accounting and + // leaves duplicate status rows behind during streaming/resizes. + const statusContentWidth = Math.max(0, terminalWidth - 3); const minMessageWidth = 12; const statusHintParts = useMemo(() => { const parts: string[] = []; @@ -469,14 +551,16 @@ const StreamingStatus = memo(function StreamingStatus({ // Uses chalk.dim to match reasoning text styling // Memoized to prevent unnecessary re-renders during shimmer updates const statusHintText = useMemo(() => { - const hintColor = chalk.hex(colors.subagent.hint); - const hintBold = hintColor.bold; const suffix = `${statusHintSuffix})`; if (interruptRequested) { - return hintColor(` (interrupting${suffix}`); + return {` (interrupting${suffix}`}; } return ( - hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`) + + {" ("} + esc + {` to interrupt${suffix}`} + ); }, [interruptRequested, statusHintSuffix]); @@ -488,7 +572,7 @@ const StreamingStatus = memo(function StreamingStatus({ - {shouldAnimate ? : "●"} + {animate ? : "●"} @@ -496,7 +580,7 @@ const StreamingStatus = memo(function StreamingStatus({ @@ -541,6 +625,8 @@ export function Input({ currentModel, currentModelProvider, currentReasoningEffort, + currentSystemPromptId, + currentToolset, messageQueue, onEnterQueueEditMode, onEscapeCancel, @@ -582,6 +668,8 @@ export function Input({ currentModel?: string | null; currentModelProvider?: string | null; currentReasoningEffort?: ModelReasoningEffort | null; + currentSystemPromptId?: string | null; + currentToolset?: string | null; messageQueue?: QueuedMessage[]; onEnterQueueEditMode?: () => void; onEscapeCancel?: () => void; @@ -618,9 +706,49 @@ export function Input({ // Terminal width is sourced from App.tsx to avoid duplicate resize subscriptions. const columns = terminalWidth; + // During shrink drags, Ink's incremental clear can leave stale rows behind. + // The worst offender is the full-width divider line, which wraps as the + // terminal shrinks and appears to "spam" into the transcript. + // Hide dividers during shrink gestures; restore after the width settles. + const [suppressDividers, setSuppressDividers] = useState(false); + const resizeDividersTimerRef = useRef | null>( + null, + ); + const lastColumnsRef = useRef(columns); + // Bash mode state (declared early so prompt width can feed into contentWidth) const [isBashMode, setIsBashMode] = useState(false); + useEffect(() => { + const prev = lastColumnsRef.current; + if (columns === prev) return; + lastColumnsRef.current = columns; + + const isShrinking = columns < prev; + if (isShrinking) { + setSuppressDividers(true); + } + + if (resizeDividersTimerRef.current) { + clearTimeout(resizeDividersTimerRef.current); + } + resizeDividersTimerRef.current = setTimeout(() => { + resizeDividersTimerRef.current = null; + setSuppressDividers(false); + }, 250); + + return; + }, [columns]); + + useEffect(() => { + return () => { + if (resizeDividersTimerRef.current) { + clearTimeout(resizeDividersTimerRef.current); + resizeDividersTimerRef.current = null; + } + }; + }, []); + const promptChar = isBashMode ? "!" : statusLinePrompt || ">"; const promptVisualWidth = stringWidth(promptChar) + 1; // +1 for trailing space const contentWidth = Math.max(0, columns - promptVisualWidth); @@ -1266,9 +1394,14 @@ export function Input({ } }, [ralphPending, ralphPendingYolo, ralphActive, currentMode]); - // Create a horizontal line using box-drawing characters - // Memoized since it only changes when terminal width changes - const horizontalLine = useMemo(() => "─".repeat(columns), [columns]); + // Create a horizontal line using box-drawing characters. + // IMPORTANT: never draw into the terminal's last column; some terminals will + // soft-wrap at the edge which breaks Ink's clear/redraw accounting during + // resize and can leave stacks of stale divider rows behind. + const horizontalLine = useMemo( + () => "─".repeat(Math.max(0, columns - 1)), + [columns], + ); const lowerPane = useMemo(() => { return ( @@ -1281,12 +1414,14 @@ export function Input({ {interactionEnabled ? ( {/* Top horizontal divider */} - - {horizontalLine} - + {!suppressDividers && ( + + {horizontalLine} + + )} {/* Two-column layout for input, matching message components */} @@ -1314,52 +1449,65 @@ export function Input({ {/* Bottom horizontal divider */} - - {horizontalLine} - + {!suppressDividers && ( + + {horizontalLine} + + )} - + {/* + During shrink drags Ink's incremental clear is most fragile. + Hide the entire footer chrome (assist + footer) until the width + settles to avoid "printing" wrapped rows into the transcript. + */} + {!suppressDividers && ( + + )} - + {!suppressDividers && ( + + )} ) : reserveInputSpace ? ( @@ -1403,8 +1551,11 @@ export function Input({ statusLineText, statusLineRight, statusLinePadding, + currentSystemPromptId, + currentToolset, promptChar, promptVisualWidth, + suppressDividers, ]); // If not visible, render nothing but keep component mounted to preserve state diff --git a/src/cli/components/ShimmerText.tsx b/src/cli/components/ShimmerText.tsx index 7a202f9..9d26e3e 100644 --- a/src/cli/components/ShimmerText.tsx +++ b/src/cli/components/ShimmerText.tsx @@ -1,4 +1,3 @@ -import chalk from "chalk"; import { memo } from "react"; import { colors } from "./colors.js"; import { Text } from "./Text"; @@ -23,25 +22,74 @@ export const ShimmerText = memo(function ShimmerText({ shimmerOffset, wrap, }: ShimmerTextProps) { - const fullText = `${boldPrefix ? `${boldPrefix} ` : ""}${message}…`; - const prefixLength = boldPrefix ? boldPrefix.length + 1 : 0; // +1 for space + const prefix = boldPrefix ? `${boldPrefix} ` : ""; + const prefixLen = prefix.length; + const fullText = `${prefix}${message}…`; - // Create the shimmer effect - simple 3-char highlight - const shimmerText = fullText - .split("") - .map((char, i) => { - // Check if this character is within the 3-char shimmer window - const isInShimmer = i >= shimmerOffset && i < shimmerOffset + 3; - const isInPrefix = i < prefixLength; + // Avoid per-character ANSI styling. Rendering shimmer with a small number of + // spans keeps Ink's wrapping/truncation behavior stable during resize. + const start = Math.max(0, shimmerOffset); + const end = Math.max(start, shimmerOffset + 3); - if (isInShimmer) { - const styledChar = chalk.hex(colors.status.processingShimmer)(char); - return isInPrefix ? chalk.bold(styledChar) : styledChar; - } - const styledChar = chalk.hex(color)(char); - return isInPrefix ? chalk.bold(styledChar) : styledChar; - }) - .join(""); + type Segment = { key: string; text: string; color?: string; bold?: boolean }; + const segments: Segment[] = []; - return {shimmerText}; + const pushRegion = ( + text: string, + regionStart: number, + regionColor?: string, + ) => { + if (!text) return; + + const regionEnd = regionStart + text.length; + const crossesPrefix = regionStart < prefixLen && regionEnd > prefixLen; + + if (!crossesPrefix) { + const bold = regionStart < prefixLen; + segments.push({ + key: `${regionStart}:${regionColor ?? ""}:${bold ? "b" : "n"}`, + text, + color: regionColor, + bold, + }); + return; + } + + const cut = Math.max(0, prefixLen - regionStart); + const left = text.slice(0, cut); + const right = text.slice(cut); + + if (left) + segments.push({ + key: `${regionStart}:${regionColor ?? ""}:b`, + text: left, + color: regionColor, + bold: true, + }); + if (right) + segments.push({ + key: `${prefixLen}:${regionColor ?? ""}:n`, + text: right, + color: regionColor, + bold: false, + }); + }; + + const before = fullText.slice(0, start); + const shimmer = fullText.slice(start, end); + const after = fullText.slice(end); + + pushRegion(before, 0, color); + pushRegion(shimmer, start, colors.status.processingShimmer); + pushRegion(after, end, color); + + return ( + + {segments.map((seg) => ( + + {seg.text} + + ))} + + ); }); diff --git a/src/cli/components/SlashCommandAutocomplete.tsx b/src/cli/components/SlashCommandAutocomplete.tsx index cca987a..533f00b 100644 --- a/src/cli/components/SlashCommandAutocomplete.tsx +++ b/src/cli/components/SlashCommandAutocomplete.tsx @@ -2,11 +2,20 @@ import { useEffect, useLayoutEffect, useMemo, useState } from "react"; import { settingsManager } from "../../settings-manager"; import { commands } from "../commands/registry"; import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { AutocompleteBox, AutocompleteItem } from "./Autocomplete"; import { Text } from "./Text"; import type { AutocompleteProps, CommandMatch } from "./types/autocomplete"; const VISIBLE_COMMANDS = 7; // Number of commands visible at once +const CMD_COL_WIDTH = 14; + +function truncateText(text: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + if (text.length <= maxWidth) return text; + if (maxWidth <= 3) return text.slice(0, maxWidth); + return `${text.slice(0, maxWidth - 3)}...`; +} // Compute filtered command list (excluding hidden commands), sorted by order const _allCommands: CommandMatch[] = Object.entries(commands) @@ -50,6 +59,7 @@ export function SlashCommandAutocomplete({ agentId, workingDirectory = process.cwd(), }: AutocompleteProps) { + const columns = useTerminalWidth(); const [customCommands, setCustomCommands] = useState([]); // Load custom commands once on mount @@ -213,13 +223,23 @@ export function SlashCommandAutocomplete({ {visibleMatches.map((item, idx) => { const actualIndex = startIndex + idx; + + // Keep the footer height stable while navigating by forcing a single-line + // representation for each row. + const displayCmd = truncateText(item.cmd, CMD_COL_WIDTH).padEnd( + CMD_COL_WIDTH, + ); + // 2-char gutter comes from . + const maxDescWidth = Math.max(0, columns - 2 - CMD_COL_WIDTH - 1); + const displayDesc = truncateText(item.desc, maxDescWidth); + return ( - {item.cmd.padEnd(14)}{" "} - {item.desc} + {displayCmd}{" "} + {displayDesc} ); })} diff --git a/src/cli/helpers/statusLinePayload.ts b/src/cli/helpers/statusLinePayload.ts index e7c1aec..79d86f4 100644 --- a/src/cli/helpers/statusLinePayload.ts +++ b/src/cli/helpers/statusLinePayload.ts @@ -3,6 +3,9 @@ import { getVersion } from "../../version"; export interface StatusLinePayloadBuildInput { modelId?: string | null; modelDisplayName?: string | null; + reasoningEffort?: string | null; + systemPromptId?: string | null; + toolset?: string | null; currentDirectory: string; projectDirectory: string; sessionId?: string; @@ -32,6 +35,10 @@ export interface StatusLinePayload { session_id?: string; transcript_path: string | null; version: string; + // Back-compat fields used by custom statusline scripts. + reasoning_effort: string | null; + system_prompt_id: string | null; + toolset: string | null; model: { id: string | null; display_name: string | null; @@ -122,6 +129,9 @@ export function buildStatusLinePayload( ...(input.sessionId ? { session_id: input.sessionId } : {}), transcript_path: null, version: getVersion(), + reasoning_effort: input.reasoningEffort ?? null, + system_prompt_id: input.systemPromptId ?? null, + toolset: input.toolset ?? null, model: { id: input.modelId ?? null, display_name: input.modelDisplayName ?? null, diff --git a/src/cli/hooks/useConfigurableStatusLine.ts b/src/cli/hooks/useConfigurableStatusLine.ts index abd952e..391ebc7 100644 --- a/src/cli/hooks/useConfigurableStatusLine.ts +++ b/src/cli/hooks/useConfigurableStatusLine.ts @@ -21,6 +21,9 @@ import { executeStatusLineCommand } from "../helpers/statusLineRuntime"; export interface StatusLineInputs { modelId?: string | null; modelDisplayName?: string | null; + reasoningEffort?: string | null; + systemPromptId?: string | null; + toolset?: string | null; currentDirectory: string; projectDirectory: string; sessionId?: string; @@ -54,6 +57,9 @@ function toPayloadInput(inputs: StatusLineInputs): StatusLinePayloadBuildInput { return { modelId: inputs.modelId, modelDisplayName: inputs.modelDisplayName, + reasoningEffort: inputs.reasoningEffort, + systemPromptId: inputs.systemPromptId, + toolset: inputs.toolset, currentDirectory: inputs.currentDirectory, projectDirectory: inputs.projectDirectory, sessionId: inputs.sessionId,