fix: stabilize streaming footer and status layout (#882)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import SpinnerLib from "ink-spinner";
|
||||
import {
|
||||
type ComponentType,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -37,6 +38,7 @@ const Spinner = SpinnerLib as ComponentType<{ type?: string }>;
|
||||
|
||||
// Window for double-escape to clear input
|
||||
const ESC_CLEAR_WINDOW_MS = 2500;
|
||||
const FOOTER_WIDTH_STREAMING_DELTA = 2;
|
||||
|
||||
function truncateEnd(value: string, maxChars: number): string {
|
||||
if (maxChars <= 0) return "";
|
||||
@@ -122,7 +124,7 @@ const InputFooter = memo(function InputFooter({
|
||||
isOpenAICodexProvider,
|
||||
isByokProvider,
|
||||
hideFooter,
|
||||
terminalWidth,
|
||||
rightColumnWidth,
|
||||
}: {
|
||||
ctrlCPressed: boolean;
|
||||
escapePressed: boolean;
|
||||
@@ -135,19 +137,39 @@ const InputFooter = memo(function InputFooter({
|
||||
isOpenAICodexProvider: boolean;
|
||||
isByokProvider: boolean;
|
||||
hideFooter: boolean;
|
||||
terminalWidth: number;
|
||||
rightColumnWidth: number;
|
||||
}) {
|
||||
const hideFooterContent = hideFooter;
|
||||
const rightColumnWidth = Math.max(
|
||||
28,
|
||||
Math.min(72, Math.floor(terminalWidth * 0.45)),
|
||||
);
|
||||
const maxAgentChars = Math.max(10, Math.floor(rightColumnWidth * 0.45));
|
||||
const displayAgentName = truncateEnd(agentName || "Unnamed", maxAgentChars);
|
||||
const byokExtraChars = isByokProvider ? 2 : 0; // " ▲"
|
||||
const reservedChars = displayAgentName.length + byokExtraChars + 4;
|
||||
const maxModelChars = Math.max(8, rightColumnWidth - reservedChars);
|
||||
const displayModel = truncateEnd(currentModel ?? "unknown", maxModelChars);
|
||||
const rightTextLength =
|
||||
displayAgentName.length + displayModel.length + byokExtraChars + 3;
|
||||
const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength);
|
||||
const rightLabel = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
parts.push(" ".repeat(rightPrefixSpaces));
|
||||
parts.push(chalk.hex(colors.footer.agentName)(displayAgentName));
|
||||
parts.push(chalk.dim(" ["));
|
||||
parts.push(chalk.dim(displayModel));
|
||||
if (isByokProvider) {
|
||||
parts.push(chalk.dim(" "));
|
||||
parts.push(
|
||||
isOpenAICodexProvider ? chalk.hex("#74AA9C")("▲") : chalk.yellow("▲"),
|
||||
);
|
||||
}
|
||||
parts.push(chalk.dim("]"));
|
||||
return parts.join("");
|
||||
}, [
|
||||
rightPrefixSpaces,
|
||||
displayAgentName,
|
||||
displayModel,
|
||||
isByokProvider,
|
||||
isOpenAICodexProvider,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -178,24 +200,11 @@ const InputFooter = memo(function InputFooter({
|
||||
<Text dimColor>Press / for commands</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box width={rightColumnWidth} flexShrink={0} justifyContent="flex-end">
|
||||
<Box width={rightColumnWidth} flexShrink={0}>
|
||||
{hideFooterContent ? (
|
||||
<Text> </Text>
|
||||
<Text>{" ".repeat(rightColumnWidth)}</Text>
|
||||
) : (
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={colors.footer.agentName}>{displayAgentName}</Text>
|
||||
<Text dimColor>{" ["}</Text>
|
||||
<Text dimColor>{displayModel}</Text>
|
||||
{isByokProvider ? (
|
||||
<>
|
||||
<Text dimColor> </Text>
|
||||
<Text color={isOpenAICodexProvider ? "#74AA9C" : "yellow"}>
|
||||
▲
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
<Text dimColor>{"]"}</Text>
|
||||
</Text>
|
||||
<Text>{rightLabel}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -212,6 +221,7 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
interruptRequested,
|
||||
networkPhase,
|
||||
terminalWidth,
|
||||
shouldAnimate,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
visible: boolean;
|
||||
@@ -222,13 +232,14 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
interruptRequested: boolean;
|
||||
networkPhase: "upload" | "download" | "error" | null;
|
||||
terminalWidth: number;
|
||||
shouldAnimate: boolean;
|
||||
}) {
|
||||
const [shimmerOffset, setShimmerOffset] = useState(-3);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const streamStartRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streaming || !visible) return;
|
||||
if (!streaming || !visible || !shouldAnimate) return;
|
||||
|
||||
const id = setInterval(() => {
|
||||
setShimmerOffset((prev) => {
|
||||
@@ -241,7 +252,13 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
}, 120); // Speed of shimmer animation
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [streaming, thinkingMessage, visible, agentName]);
|
||||
}, [streaming, thinkingMessage, visible, agentName, shouldAnimate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
setShimmerOffset(-3);
|
||||
}
|
||||
}, [shouldAnimate]);
|
||||
|
||||
// Elapsed time tracking
|
||||
useEffect(() => {
|
||||
@@ -279,15 +296,36 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
const showErrorArrow = networkArrow === "↑\u0338";
|
||||
const statusContentWidth = Math.max(0, terminalWidth - 2);
|
||||
const minMessageWidth = 12;
|
||||
const desiredHintWidth = Math.max(18, Math.floor(statusContentWidth * 0.34));
|
||||
const cappedHintWidth = Math.min(44, desiredHintWidth);
|
||||
const hintColumnWidth = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
cappedHintWidth,
|
||||
Math.max(0, statusContentWidth - minMessageWidth),
|
||||
),
|
||||
);
|
||||
const statusHintParts = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (shouldShowElapsed) {
|
||||
parts.push(elapsedLabel);
|
||||
}
|
||||
if (shouldShowTokenCount) {
|
||||
parts.push(
|
||||
`${formatCompact(estimatedTokens)}${networkArrow ? ` ${networkArrow}` : ""}`,
|
||||
);
|
||||
} else if (showErrorArrow) {
|
||||
parts.push(networkArrow);
|
||||
}
|
||||
return parts;
|
||||
}, [
|
||||
shouldShowElapsed,
|
||||
elapsedLabel,
|
||||
shouldShowTokenCount,
|
||||
estimatedTokens,
|
||||
networkArrow,
|
||||
showErrorArrow,
|
||||
]);
|
||||
const statusHintSuffix = statusHintParts.length
|
||||
? ` · ${statusHintParts.join(" · ")}`
|
||||
: "";
|
||||
const statusHintPlain = interruptRequested
|
||||
? ` (interrupting${statusHintSuffix})`
|
||||
: ` (esc to interrupt${statusHintSuffix})`;
|
||||
const statusHintWidth = Array.from(statusHintPlain).length;
|
||||
const maxHintWidth = Math.max(0, statusContentWidth - minMessageWidth);
|
||||
const hintColumnWidth = Math.max(0, Math.min(statusHintWidth, maxHintWidth));
|
||||
const maxMessageWidth = Math.max(0, statusContentWidth - hintColumnWidth);
|
||||
const statusLabel = `${agentName ? `${agentName} ` : ""}${thinkingMessage}…`;
|
||||
const statusLabelWidth = Array.from(statusLabel).length;
|
||||
@@ -302,33 +340,14 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
const statusHintText = useMemo(() => {
|
||||
const hintColor = chalk.hex(colors.subagent.hint);
|
||||
const hintBold = hintColor.bold;
|
||||
const parts: string[] = [];
|
||||
if (shouldShowElapsed) {
|
||||
parts.push(elapsedLabel);
|
||||
}
|
||||
if (shouldShowTokenCount) {
|
||||
parts.push(
|
||||
`${formatCompact(estimatedTokens)}${networkArrow ? ` ${networkArrow}` : ""}`,
|
||||
);
|
||||
} else if (showErrorArrow) {
|
||||
parts.push(networkArrow);
|
||||
}
|
||||
const suffix = `${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""})`;
|
||||
const suffix = `${statusHintSuffix})`;
|
||||
if (interruptRequested) {
|
||||
return hintColor(` (interrupting${suffix}`);
|
||||
}
|
||||
return (
|
||||
hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`)
|
||||
);
|
||||
}, [
|
||||
shouldShowElapsed,
|
||||
elapsedLabel,
|
||||
shouldShowTokenCount,
|
||||
estimatedTokens,
|
||||
interruptRequested,
|
||||
networkArrow,
|
||||
showErrorArrow,
|
||||
]);
|
||||
}, [interruptRequested, statusHintSuffix]);
|
||||
|
||||
if (!streaming || !visible) {
|
||||
return null;
|
||||
@@ -338,7 +357,7 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={colors.status.processing}>
|
||||
<Spinner type="layer" />
|
||||
{shouldAnimate ? <Spinner type="layer" /> : "●"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={statusContentWidth} flexShrink={0} flexDirection="row">
|
||||
@@ -346,7 +365,7 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
<ShimmerText
|
||||
boldPrefix={agentName || undefined}
|
||||
message={thinkingMessage}
|
||||
shimmerOffset={shimmerOffset}
|
||||
shimmerOffset={shouldAnimate ? shimmerOffset : -3}
|
||||
wrap="truncate-end"
|
||||
/>
|
||||
</Box>
|
||||
@@ -403,6 +422,7 @@ export function Input({
|
||||
onRestoredInputConsumed,
|
||||
networkPhase = null,
|
||||
terminalWidth,
|
||||
shouldAnimate = true,
|
||||
}: {
|
||||
visible?: boolean;
|
||||
streaming: boolean;
|
||||
@@ -437,6 +457,7 @@ export function Input({
|
||||
onRestoredInputConsumed?: () => void;
|
||||
networkPhase?: "upload" | "download" | "error" | null;
|
||||
terminalWidth: number;
|
||||
shouldAnimate?: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [escapePressed, setEscapePressed] = useState(false);
|
||||
@@ -462,6 +483,60 @@ export function Input({
|
||||
return Math.max(1, getVisualLines(value, contentWidth).length);
|
||||
}, [value, contentWidth]);
|
||||
const inputChromeHeight = inputRowLines + 3; // top divider + input rows + bottom divider + footer
|
||||
const computedFooterRightColumnWidth = useMemo(
|
||||
() => Math.max(28, Math.min(72, Math.floor(columns * 0.45))),
|
||||
[columns],
|
||||
);
|
||||
const [footerRightColumnWidth, setFooterRightColumnWidth] = useState(
|
||||
computedFooterRightColumnWidth,
|
||||
);
|
||||
const debugFlicker = process.env.LETTA_DEBUG_FLICKER === "1";
|
||||
|
||||
useEffect(() => {
|
||||
if (!streaming) {
|
||||
setFooterRightColumnWidth(computedFooterRightColumnWidth);
|
||||
return;
|
||||
}
|
||||
|
||||
// While streaming, keep the right column width stable to avoid occasional
|
||||
// right-edge jitter. Allow significant shrink (terminal got smaller),
|
||||
// defer growth until streaming ends.
|
||||
if (computedFooterRightColumnWidth >= footerRightColumnWidth) {
|
||||
const growthDelta =
|
||||
computedFooterRightColumnWidth - footerRightColumnWidth;
|
||||
if (debugFlicker && growthDelta >= FOOTER_WIDTH_STREAMING_DELTA) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[debug:flicker:footer-width] defer growth ${footerRightColumnWidth} -> ${computedFooterRightColumnWidth} (delta=${growthDelta})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const shrinkDelta = footerRightColumnWidth - computedFooterRightColumnWidth;
|
||||
if (shrinkDelta < FOOTER_WIDTH_STREAMING_DELTA) {
|
||||
if (debugFlicker && shrinkDelta > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[debug:flicker:footer-width] ignore minor shrink ${footerRightColumnWidth} -> ${computedFooterRightColumnWidth} (delta=${shrinkDelta})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (debugFlicker) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[debug:flicker:footer-width] shrink ${footerRightColumnWidth} -> ${computedFooterRightColumnWidth} (delta=${shrinkDelta})`,
|
||||
);
|
||||
}
|
||||
setFooterRightColumnWidth(computedFooterRightColumnWidth);
|
||||
}, [
|
||||
streaming,
|
||||
computedFooterRightColumnWidth,
|
||||
footerRightColumnWidth,
|
||||
debugFlicker,
|
||||
]);
|
||||
|
||||
// Command history
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
@@ -489,17 +564,17 @@ export function Input({
|
||||
}
|
||||
}, [restoredInput, value, onRestoredInputConsumed]);
|
||||
|
||||
const handleBangAtEmpty = () => {
|
||||
const handleBangAtEmpty = useCallback(() => {
|
||||
if (isBashMode) return false;
|
||||
setIsBashMode(true);
|
||||
return true;
|
||||
};
|
||||
}, [isBashMode]);
|
||||
|
||||
const handleBackspaceAtEmpty = () => {
|
||||
const handleBackspaceAtEmpty = useCallback(() => {
|
||||
if (!isBashMode) return false;
|
||||
setIsBashMode(false);
|
||||
return true;
|
||||
};
|
||||
}, [isBashMode]);
|
||||
|
||||
// Reset cursor position after it's been applied
|
||||
useEffect(() => {
|
||||
@@ -852,7 +927,7 @@ export function Input({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// Don't submit if autocomplete is active with matches
|
||||
if (isAutocompleteActive) {
|
||||
return;
|
||||
@@ -868,9 +943,10 @@ export function Input({
|
||||
if (bashRunning) return;
|
||||
|
||||
// Add to history if not empty and not a duplicate of the last entry
|
||||
if (previousValue.trim() !== history[history.length - 1]) {
|
||||
setHistory([...history, previousValue]);
|
||||
}
|
||||
setHistory((prev) => {
|
||||
if (previousValue.trim() === prev[prev.length - 1]) return prev;
|
||||
return [...prev, previousValue];
|
||||
});
|
||||
|
||||
// Reset history navigation
|
||||
setHistoryIndex(-1);
|
||||
@@ -885,8 +961,11 @@ export function Input({
|
||||
}
|
||||
|
||||
// Add to history if not empty and not a duplicate of the last entry
|
||||
if (previousValue.trim() && previousValue !== history[history.length - 1]) {
|
||||
setHistory([...history, previousValue]);
|
||||
if (previousValue.trim()) {
|
||||
setHistory((prev) => {
|
||||
if (previousValue === prev[prev.length - 1]) return prev;
|
||||
return [...prev, previousValue];
|
||||
});
|
||||
}
|
||||
|
||||
// Reset history navigation
|
||||
@@ -899,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 && (
|
||||
<QueuedMessages messages={messageQueue} />
|
||||
)}
|
||||
|
||||
{interactionEnabled ? (
|
||||
<Box flexDirection="column">
|
||||
{/* Top horizontal divider */}
|
||||
<Text
|
||||
dimColor={!isBashMode}
|
||||
color={isBashMode ? colors.bash.border : undefined}
|
||||
>
|
||||
{horizontalLine}
|
||||
</Text>
|
||||
|
||||
{/* Two-column layout for input, matching message components */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isBashMode ? colors.bash.prompt : colors.input.prompt}
|
||||
>
|
||||
{isBashMode ? "!" : ">"}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<PasteAwareTextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
cursorPosition={cursorPos}
|
||||
onCursorMove={setCurrentCursorPosition}
|
||||
focus={interactionEnabled && !onEscapeCancel}
|
||||
onBangAtEmpty={handleBangAtEmpty}
|
||||
onBackspaceAtEmpty={handleBackspaceAtEmpty}
|
||||
onPasteError={onPasteError}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom horizontal divider */}
|
||||
<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}
|
||||
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}
|
||||
isOpenAICodexProvider={
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
isByokProvider={
|
||||
currentModelProvider?.startsWith("lc-") ||
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
hideFooter={hideFooter}
|
||||
rightColumnWidth={footerRightColumnWidth}
|
||||
/>
|
||||
</Box>
|
||||
) : reserveInputSpace ? (
|
||||
<Box height={inputChromeHeight} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
messageQueue,
|
||||
interactionEnabled,
|
||||
isBashMode,
|
||||
horizontalLine,
|
||||
contentWidth,
|
||||
value,
|
||||
handleSubmit,
|
||||
cursorPos,
|
||||
onEscapeCancel,
|
||||
handleBangAtEmpty,
|
||||
handleBackspaceAtEmpty,
|
||||
onPasteError,
|
||||
currentCursorPosition,
|
||||
handleFileSelect,
|
||||
handleCommandSelect,
|
||||
handleCommandAutocomplete,
|
||||
agentId,
|
||||
agentName,
|
||||
serverUrl,
|
||||
conversationId,
|
||||
ctrlCPressed,
|
||||
escapePressed,
|
||||
modeInfo?.name,
|
||||
modeInfo?.color,
|
||||
ralphActive,
|
||||
ralphPending,
|
||||
currentModel,
|
||||
currentModelProvider,
|
||||
hideFooter,
|
||||
footerRightColumnWidth,
|
||||
reserveInputSpace,
|
||||
inputChromeHeight,
|
||||
]);
|
||||
|
||||
// If not visible, render nothing but keep component mounted to preserve state
|
||||
if (!visible) {
|
||||
return null;
|
||||
@@ -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 && (
|
||||
<QueuedMessages messages={messageQueue} />
|
||||
)}
|
||||
|
||||
{interactionEnabled ? (
|
||||
<Box flexDirection="column">
|
||||
{/* Top horizontal divider */}
|
||||
<Text
|
||||
dimColor={!isBashMode}
|
||||
color={isBashMode ? colors.bash.border : undefined}
|
||||
>
|
||||
{horizontalLine}
|
||||
</Text>
|
||||
|
||||
{/* Two-column layout for input, matching message components */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isBashMode ? colors.bash.prompt : colors.input.prompt}
|
||||
>
|
||||
{isBashMode ? "!" : ">"}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<PasteAwareTextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
cursorPosition={cursorPos}
|
||||
onCursorMove={setCurrentCursorPosition}
|
||||
focus={interactionEnabled && !onEscapeCancel}
|
||||
onBangAtEmpty={handleBangAtEmpty}
|
||||
onBackspaceAtEmpty={handleBackspaceAtEmpty}
|
||||
onPasteError={onPasteError}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom horizontal divider */}
|
||||
<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}
|
||||
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}
|
||||
isOpenAICodexProvider={
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
isByokProvider={
|
||||
currentModelProvider?.startsWith("lc-") ||
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
hideFooter={hideFooter}
|
||||
terminalWidth={columns}
|
||||
/>
|
||||
</Box>
|
||||
) : reserveInputSpace ? (
|
||||
<Box height={inputChromeHeight} />
|
||||
) : null}
|
||||
{lowerPane}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* if animations should be disabled, then provides this via context.
|
||||
*/
|
||||
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
interface AnimationContextValue {
|
||||
/**
|
||||
@@ -46,8 +46,10 @@ export function AnimationProvider({
|
||||
children,
|
||||
shouldAnimate,
|
||||
}: AnimationProviderProps) {
|
||||
const contextValue = useMemo(() => ({ shouldAnimate }), [shouldAnimate]);
|
||||
|
||||
return (
|
||||
<AnimationContext.Provider value={{ shouldAnimate }}>
|
||||
<AnimationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AnimationContext.Provider>
|
||||
);
|
||||
|
||||
63
vendor/ink/build/log-update.js
vendored
Normal file
63
vendor/ink/build/log-update.js
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import cliCursor from 'cli-cursor';
|
||||
|
||||
const create = (stream, { showCursor = false } = {}) => {
|
||||
let previousLineCount = 0;
|
||||
let previousOutput = '';
|
||||
let hasHiddenCursor = false;
|
||||
|
||||
const renderWithClearedLineEnds = (output) => {
|
||||
const lines = output.split('\n');
|
||||
return lines.map((line) => line + ansiEscapes.eraseEndLine).join('\n');
|
||||
};
|
||||
|
||||
const render = (str) => {
|
||||
if (!showCursor && !hasHiddenCursor) {
|
||||
cliCursor.hide();
|
||||
hasHiddenCursor = true;
|
||||
}
|
||||
|
||||
const output = str + '\n';
|
||||
if (output === previousOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep existing line-count semantics used by Ink's bundled log-update.
|
||||
const nextLineCount = output.split('\n').length;
|
||||
|
||||
// Avoid eraseLines() pre-clear flashes by repainting in place:
|
||||
// move to start of previous frame, rewrite each line while erasing EOL,
|
||||
// then clear any trailing old lines if the frame got shorter.
|
||||
if (previousLineCount > 1) {
|
||||
stream.write(ansiEscapes.cursorUp(previousLineCount - 1));
|
||||
}
|
||||
stream.write(renderWithClearedLineEnds(output));
|
||||
if (nextLineCount < previousLineCount) {
|
||||
stream.write(ansiEscapes.eraseDown);
|
||||
}
|
||||
|
||||
previousOutput = output;
|
||||
previousLineCount = nextLineCount;
|
||||
};
|
||||
|
||||
render.clear = () => {
|
||||
stream.write(ansiEscapes.eraseLines(previousLineCount));
|
||||
previousOutput = '';
|
||||
previousLineCount = 0;
|
||||
};
|
||||
|
||||
render.done = () => {
|
||||
previousOutput = '';
|
||||
previousLineCount = 0;
|
||||
if (!showCursor) {
|
||||
cliCursor.show();
|
||||
hasHiddenCursor = false;
|
||||
}
|
||||
};
|
||||
|
||||
return render;
|
||||
};
|
||||
|
||||
const logUpdate = { create };
|
||||
export default logUpdate;
|
||||
|
||||
Reference in New Issue
Block a user