fix(tui): prevent reflow glitches (footer + resize) (#1098)

This commit is contained in:
paulbettner
2026-02-25 14:08:33 -05:00
committed by GitHub
parent 33509d9358
commit 0023b9c7e5
8 changed files with 550 additions and 134 deletions

View File

@@ -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<number | null>(null);
const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
const resizeClearTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastClearAtRef = useRef(0);
const resizeGestureTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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}

View File

@@ -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 (
<Box flexDirection="column">
@@ -74,11 +104,8 @@ export const AgentInfoBar = memo(function AgentInfoBar({
{/* Version and Discord/feedback info */}
<Box>
<Text>
{" "}Letta Code v{getVersion()} · Report bugs with /feedback or{" "}
<Link url="https://discord.gg/letta">
<Text>on Discord </Text>
</Link>
<Text wrap="truncate-end">
{" "}Letta Code v{getVersion()} · /feedback · discord.gg/letta
</Text>
</Box>
@@ -88,61 +115,83 @@ export const AgentInfoBar = memo(function AgentInfoBar({
{/* Alien + Agent name */}
<Box>
<Text color={colors.footer.agentName}>{alienLines[0]}</Text>
<Text bold color={colors.footer.agentName}>
{agentName || "Unnamed"}
</Text>
{isPinned ? (
<Text color="green"> (pinned )</Text>
) : agentName === DEFAULT_AGENT_NAME || !agentName ? (
<Text color="gray"> (type /pin to give your agent a real name!)</Text>
) : (
<Text color="gray"> (type /pin to pin agent)</Text>
)}
<Box width={rightWidth} flexShrink={1}>
<Text bold color={colors.footer.agentName} wrap="truncate-end">
{truncateText(agentNameLine, rightWidth)}
</Text>
</Box>
</Box>
{/* Alien + Links */}
<Box>
<Text color={colors.footer.agentName}>{alienLines[1]}</Text>
{isCloudUser && adeUrl && !isTmux && (
<>
<Link url={adeUrl}>
<Text>Open in ADE </Text>
</Link>
<Text dimColor>· </Text>
</>
{!isCloudUser && (
<Box width={rightWidth} flexShrink={1}>
<Text dimColor wrap="truncate-end">
{truncateText(serverUrl ?? "", rightWidth)}
</Text>
</Box>
)}
{isCloudUser && adeUrl && isTmux && (
<Text dimColor>Open in ADE: {adeUrl} · </Text>
)}
{isCloudUser && (
<Link url="https://app.letta.com/settings/organization/usage">
<Text>View usage </Text>
</Link>
)}
{!isCloudUser && <Text dimColor>{serverUrl}</Text>}
</Box>
{/* Keep usage on its own line to avoid breaking the alien art rows. */}
{isCloudUser && (
<Box>
<Text color={colors.footer.agentName}>{alienLines[3]}</Text>
<Box width={rightWidth} flexShrink={1}>
<Text dimColor wrap="truncate-end">
{truncateText(
"Usage: https://app.letta.com/settings/organization/usage",
rightWidth,
)}
</Text>
</Box>
</Box>
)}
{/* Model summary */}
<Box>
<Text color={colors.footer.agentName}>{alienLines[2]}</Text>
<Text dimColor>{modelLine ?? "model unknown"}</Text>
<Box width={rightWidth} flexShrink={1}>
<Text dimColor wrap="truncate-end">
{truncateText(modelLine ?? "model unknown", rightWidth)}
</Text>
</Box>
</Box>
{/* Agent ID */}
<Box>
<Text>{alienLines[3]}</Text>
<Text dimColor>{agentId}</Text>
<Box width={rightWidth} flexShrink={1}>
<Text dimColor wrap="truncate-end">
{truncateText(agentId, rightWidth)}
</Text>
</Box>
</Box>
{/* Phantom alien row + Conversation ID */}
<Box>
<Text>{alienLines[3]}</Text>
{conversationId && conversationId !== "default" ? (
<Text dimColor>{conversationId}</Text>
<Box width={rightWidth} flexShrink={1}>
<Text dimColor wrap="truncate-end">
{truncateText(conversationId, rightWidth)}
</Text>
</Box>
) : (
<Text dimColor>default conversation</Text>
<Box width={rightWidth} flexShrink={1}>
<Text dimColor>default conversation</Text>
</Box>
)}
</Box>
{/* Full ADE conversation URL (may wrap; kept last so it can't break the art rows) */}
{isCloudUser && adeConversationUrl && (
<Box>
<Text>{alienLines[3]}</Text>
<Text dimColor>{`ADE: ${adeConversationUrl}`}</Text>
</Box>
)}
</Box>
);
});

View File

@@ -42,6 +42,8 @@ export function AutocompleteItem({
<Text
color={selected ? colors.command.selected : undefined}
bold={selected}
// Keep each item one visual line tall so navigating doesn't reflow the footer.
wrap="truncate-end"
>
{" "}
{children}

View File

@@ -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<ReturnType<typeof setTimeout> | null>(null);
const lastWidthRef = useRef<number>(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<number | null>(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 <Text dimColor>{` (interrupting${suffix}`}</Text>;
}
return (
hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`)
<Text dimColor>
{" ("}
<Text bold>esc</Text>
{` to interrupt${suffix}`}
</Text>
);
}, [interruptRequested, statusHintSuffix]);
@@ -488,7 +572,7 @@ const StreamingStatus = memo(function StreamingStatus({
<Box flexDirection="row" marginBottom={1}>
<Box width={2} flexShrink={0}>
<Text color={colors.status.processing}>
{shouldAnimate ? <Spinner type="layer" /> : "●"}
{animate ? <Spinner type="layer" /> : "●"}
</Text>
</Box>
<Box width={statusContentWidth} flexShrink={0} flexDirection="row">
@@ -496,7 +580,7 @@ const StreamingStatus = memo(function StreamingStatus({
<ShimmerText
boldPrefix={agentName || undefined}
message={thinkingMessage}
shimmerOffset={shouldAnimate ? shimmerOffset : -3}
shimmerOffset={animate ? shimmerOffset : -3}
wrap="truncate-end"
/>
</Box>
@@ -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<ReturnType<typeof setTimeout> | 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 ? (
<Box flexDirection="column">
{/* Top horizontal divider */}
<Text
dimColor={!isBashMode}
color={isBashMode ? colors.bash.border : undefined}
>
{horizontalLine}
</Text>
{!suppressDividers && (
<Text
dimColor={!isBashMode}
color={isBashMode ? colors.bash.border : undefined}
>
{horizontalLine}
</Text>
)}
{/* Two-column layout for input, matching message components */}
<Box flexDirection="row">
@@ -1314,52 +1449,65 @@ export function Input({
</Box>
{/* Bottom horizontal divider */}
<Text
dimColor={!isBashMode}
color={isBashMode ? colors.bash.border : undefined}
>
{horizontalLine}
</Text>
{!suppressDividers && (
<Text
dimColor={!isBashMode}
color={isBashMode ? colors.bash.border : undefined}
>
{horizontalLine}
</Text>
)}
<InputAssist
currentInput={value}
cursorPosition={currentCursorPosition}
onFileSelect={handleFileSelect}
onCommandSelect={handleCommandSelect}
onCommandAutocomplete={handleCommandAutocomplete}
onAutocompleteActiveChange={setIsAutocompleteActive}
agentId={agentId}
agentName={agentName}
currentModel={currentModel}
currentReasoningEffort={currentReasoningEffort}
serverUrl={serverUrl}
workingDirectory={process.cwd()}
conversationId={conversationId}
/>
{/*
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 && (
<InputAssist
currentInput={value}
cursorPosition={currentCursorPosition}
onFileSelect={handleFileSelect}
onCommandSelect={handleCommandSelect}
onCommandAutocomplete={handleCommandAutocomplete}
onAutocompleteActiveChange={setIsAutocompleteActive}
agentId={agentId}
agentName={agentName}
currentModel={currentModel}
currentReasoningEffort={currentReasoningEffort}
serverUrl={serverUrl}
workingDirectory={process.cwd()}
conversationId={conversationId}
/>
)}
<InputFooter
ctrlCPressed={ctrlCPressed}
escapePressed={escapePressed}
isBashMode={isBashMode}
modeName={modeInfo?.name ?? null}
modeColor={modeInfo?.color ?? null}
showExitHint={ralphActive || ralphPending}
agentName={agentName}
currentModel={currentModel}
currentReasoningEffort={currentReasoningEffort}
isOpenAICodexProvider={
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
}
isByokProvider={
currentModelProvider?.startsWith("lc-") ||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
}
hideFooter={hideFooter}
rightColumnWidth={footerRightColumnWidth}
statusLineText={statusLineText}
statusLineRight={statusLineRight}
statusLinePadding={statusLinePadding}
/>
{!suppressDividers && (
<InputFooter
ctrlCPressed={ctrlCPressed}
escapePressed={escapePressed}
isBashMode={isBashMode}
modeName={modeInfo?.name ?? null}
modeColor={modeInfo?.color ?? null}
showExitHint={ralphActive || ralphPending}
agentName={agentName}
currentModel={currentModel}
currentReasoningEffort={currentReasoningEffort}
currentSystemPromptId={currentSystemPromptId}
currentToolset={currentToolset}
isOpenAICodexProvider={
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
}
isByokProvider={
currentModelProvider?.startsWith("lc-") ||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
}
hideFooter={hideFooter}
rightColumnWidth={footerRightColumnWidth}
statusLineText={statusLineText}
statusLineRight={statusLineRight}
statusLinePadding={statusLinePadding}
/>
)}
</Box>
) : reserveInputSpace ? (
<Box height={inputChromeHeight} />
@@ -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

View File

@@ -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
// <Text> 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 <Text wrap={wrap}>{shimmerText}</Text>;
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 (
<Text wrap={wrap}>
{segments.map((seg) => (
<Text key={seg.key} color={seg.color} bold={seg.bold}>
{seg.text}
</Text>
))}
</Text>
);
});

View File

@@ -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<CommandMatch[]>([]);
// Load custom commands once on mount
@@ -213,13 +223,23 @@ export function SlashCommandAutocomplete({
<AutocompleteBox>
{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 <AutocompleteItem />.
const maxDescWidth = Math.max(0, columns - 2 - CMD_COL_WIDTH - 1);
const displayDesc = truncateText(item.desc, maxDescWidth);
return (
<AutocompleteItem
key={item.cmd}
selected={actualIndex === selectedIndex}
>
{item.cmd.padEnd(14)}{" "}
<Text dimColor={actualIndex !== selectedIndex}>{item.desc}</Text>
{displayCmd}{" "}
<Text dimColor={actualIndex !== selectedIndex}>{displayDesc}</Text>
</AutocompleteItem>
);
})}

View File

@@ -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,

View File

@@ -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,