1610 lines
51 KiB
TypeScript
1610 lines
51 KiB
TypeScript
// Import useInput from vendored Ink for bracketed paste support
|
|
|
|
import { EventEmitter } from "node:events";
|
|
import { stdin } from "node:process";
|
|
import chalk from "chalk";
|
|
import { Box, useInput } from "ink";
|
|
import Link from "ink-link";
|
|
import SpinnerLib from "ink-spinner";
|
|
import {
|
|
type ComponentType,
|
|
memo,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import stringWidth from "string-width";
|
|
import type { ModelReasoningEffort } from "../../agent/model";
|
|
import { LETTA_CLOUD_API_URL } from "../../auth/oauth";
|
|
import {
|
|
ELAPSED_DISPLAY_THRESHOLD_MS,
|
|
TOKEN_DISPLAY_THRESHOLD,
|
|
} from "../../constants";
|
|
import type { PermissionMode } from "../../permissions/mode";
|
|
import { permissionMode } from "../../permissions/mode";
|
|
import { OPENAI_CODEX_PROVIDER_NAME } from "../../providers/openai-codex-provider";
|
|
import { ralphMode } from "../../ralph/mode";
|
|
import { settingsManager } from "../../settings-manager";
|
|
import { charsToTokens, formatCompact } from "../helpers/format";
|
|
import type { QueuedMessage } from "../helpers/messageQueueBridge";
|
|
import { colors } from "./colors";
|
|
import { InputAssist } from "./InputAssist";
|
|
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
|
import { QueuedMessages } from "./QueuedMessages";
|
|
import { ShimmerText } from "./ShimmerText";
|
|
import { Text } from "./Text";
|
|
|
|
// Type assertion for ink-spinner compatibility
|
|
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 "";
|
|
if (value.length <= maxChars) return value;
|
|
if (maxChars <= 3) return value.slice(0, maxChars);
|
|
return `${value.slice(0, maxChars - 3)}...`;
|
|
}
|
|
|
|
function getReasoningEffortTag(
|
|
effort: ModelReasoningEffort | null | undefined,
|
|
): string | null {
|
|
if (effort === "none") return null;
|
|
if (effort === "xhigh") return "max";
|
|
if (effort === "minimal") return "minimal";
|
|
if (effort === "low") return "low";
|
|
if (effort === "medium") return "medium";
|
|
if (effort === "high") return "high";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Represents a visual line segment in the text.
|
|
* A visual line ends at either a newline character or when it reaches lineWidth.
|
|
*/
|
|
interface VisualLine {
|
|
start: number; // Start index in text
|
|
end: number; // End index (exclusive, not including \n)
|
|
}
|
|
|
|
/**
|
|
* Computes visual lines from text, accounting for both hard breaks (\n)
|
|
* and soft wrapping at lineWidth.
|
|
*/
|
|
function getVisualLines(text: string, lineWidth: number): VisualLine[] {
|
|
const lines: VisualLine[] = [];
|
|
let lineStart = 0;
|
|
|
|
for (let i = 0; i <= text.length; i++) {
|
|
const char = text[i];
|
|
const lineLength = i - lineStart;
|
|
|
|
if (char === "\n" || i === text.length) {
|
|
// Hard break or end of text
|
|
lines.push({ start: lineStart, end: i });
|
|
lineStart = i + 1;
|
|
} else if (lineLength >= lineWidth && lineWidth > 0) {
|
|
// Soft wrap - line is full
|
|
lines.push({ start: lineStart, end: i });
|
|
lineStart = i;
|
|
}
|
|
}
|
|
|
|
// Ensure at least one line for empty text
|
|
if (lines.length === 0) {
|
|
lines.push({ start: 0, end: 0 });
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Finds which visual line the cursor is on and the column within that line.
|
|
*/
|
|
function findCursorLine(
|
|
cursorPos: number,
|
|
visualLines: VisualLine[],
|
|
): { lineIndex: number; column: number } {
|
|
for (let i = 0; i < visualLines.length; i++) {
|
|
const line = visualLines[i];
|
|
if (line && cursorPos >= line.start && cursorPos <= line.end) {
|
|
return { lineIndex: i, column: cursorPos - line.start };
|
|
}
|
|
}
|
|
// Fallback to last line
|
|
const lastLine = visualLines[visualLines.length - 1];
|
|
return {
|
|
lineIndex: visualLines.length - 1,
|
|
column: Math.max(0, cursorPos - (lastLine?.start ?? 0)),
|
|
};
|
|
}
|
|
|
|
// Matches OSC 8 hyperlink sequences: \x1b]8;;URL\x1b\DISPLAY\x1b]8;;\x1b\
|
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 escape sequences require \x1b
|
|
const OSC8_REGEX = /\x1b\]8;;([^\x1b]*)\x1b\\([^\x1b]*)\x1b\]8;;\x1b\\/g;
|
|
|
|
function parseOsc8Line(line: string, keyPrefix: string): ReactNode[] {
|
|
const parts: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
const regex = new RegExp(OSC8_REGEX.source, "g");
|
|
|
|
for (let match = regex.exec(line); match !== null; match = regex.exec(line)) {
|
|
if (match.index > lastIndex) {
|
|
parts.push(
|
|
<Text key={`${keyPrefix}-${lastIndex}`}>
|
|
{line.slice(lastIndex, match.index)}
|
|
</Text>,
|
|
);
|
|
}
|
|
const url = match[1] ?? "";
|
|
const display = match[2] ?? "";
|
|
parts.push(
|
|
<Link key={`${keyPrefix}-${match.index}`} url={url}>
|
|
<Text>{display}</Text>
|
|
</Link>,
|
|
);
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
if (lastIndex < line.length) {
|
|
parts.push(
|
|
<Text key={`${keyPrefix}-${lastIndex}`}>{line.slice(lastIndex)}</Text>,
|
|
);
|
|
}
|
|
if (parts.length === 0) {
|
|
parts.push(<Text key={keyPrefix}>{line}</Text>);
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function StatusLineContent({
|
|
text,
|
|
padding,
|
|
modeName,
|
|
modeColor,
|
|
showExitHint,
|
|
}: {
|
|
text: string;
|
|
padding: number;
|
|
modeName: string | null;
|
|
modeColor: string | null;
|
|
showExitHint: boolean;
|
|
}) {
|
|
const lines = text.split("\n");
|
|
const paddingStr = padding > 0 ? " ".repeat(padding) : "";
|
|
const parts: ReactNode[] = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (i > 0) {
|
|
parts.push("\n");
|
|
}
|
|
if (paddingStr) {
|
|
parts.push(paddingStr);
|
|
}
|
|
parts.push(...parseOsc8Line(lines[i] ?? "", `l${i}`));
|
|
}
|
|
return (
|
|
<Text wrap="wrap">
|
|
<Text>{parts}</Text>
|
|
{modeName && modeColor && (
|
|
<>
|
|
{"\n"}
|
|
<Text color={modeColor}>⏵⏵ {modeName}</Text>
|
|
<Text color={modeColor} dimColor>
|
|
{" "}
|
|
(shift+tab to {showExitHint ? "exit" : "cycle"})
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Memoized footer component to prevent re-renders during high-frequency
|
|
* shimmer/timer updates. Only updates when its specific props change.
|
|
*/
|
|
const InputFooter = memo(function InputFooter({
|
|
ctrlCPressed,
|
|
escapePressed,
|
|
isBashMode,
|
|
modeName,
|
|
modeColor,
|
|
showExitHint,
|
|
agentName,
|
|
currentModel,
|
|
currentReasoningEffort,
|
|
currentSystemPromptId,
|
|
currentToolset,
|
|
isOpenAICodexProvider,
|
|
isByokProvider,
|
|
hideFooter,
|
|
rightColumnWidth,
|
|
statusLineText,
|
|
statusLineRight,
|
|
statusLinePadding,
|
|
}: {
|
|
ctrlCPressed: boolean;
|
|
escapePressed: boolean;
|
|
isBashMode: boolean;
|
|
modeName: string | null;
|
|
modeColor: string | null;
|
|
showExitHint: boolean;
|
|
agentName: string | null | undefined;
|
|
currentModel: string | null | undefined;
|
|
currentReasoningEffort?: ModelReasoningEffort | null;
|
|
currentSystemPromptId?: string | null;
|
|
currentToolset?: string | null;
|
|
isOpenAICodexProvider: boolean;
|
|
isByokProvider: boolean;
|
|
hideFooter: boolean;
|
|
rightColumnWidth: number;
|
|
statusLineText?: string;
|
|
statusLineRight?: string;
|
|
statusLinePadding?: number;
|
|
}) {
|
|
const hideFooterContent = hideFooter;
|
|
const maxAgentChars = Math.max(10, Math.floor(rightColumnWidth * 0.45));
|
|
const displayAgentName = truncateEnd(agentName || "Unnamed", maxAgentChars);
|
|
const reasoningTag = getReasoningEffortTag(currentReasoningEffort);
|
|
const byokExtraChars = isByokProvider ? 2 : 0; // " ▲"
|
|
|
|
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 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[] = [];
|
|
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("]"));
|
|
|
|
if (displaySuffix) {
|
|
parts.push(chalk.dim(displaySuffix));
|
|
}
|
|
|
|
return parts.join("");
|
|
}, [
|
|
rightPrefixSpaces,
|
|
displayAgentName,
|
|
displayModel,
|
|
displaySuffix,
|
|
isByokProvider,
|
|
isOpenAICodexProvider,
|
|
]);
|
|
|
|
return (
|
|
<Box flexDirection="row" marginBottom={1}>
|
|
<Box flexGrow={1} paddingRight={1}>
|
|
{hideFooterContent ? (
|
|
<Text> </Text>
|
|
) : ctrlCPressed ? (
|
|
<Text dimColor>Press CTRL-C again to exit</Text>
|
|
) : escapePressed ? (
|
|
<Text dimColor>Press Esc again to clear</Text>
|
|
) : isBashMode ? (
|
|
<Text>
|
|
<Text color={colors.bash.prompt}>⏵⏵ bash mode</Text>
|
|
<Text color={colors.bash.prompt} dimColor>
|
|
{" "}
|
|
(backspace to exit)
|
|
</Text>
|
|
</Text>
|
|
) : statusLineText ? (
|
|
<StatusLineContent
|
|
text={statusLineText}
|
|
padding={statusLinePadding ?? 0}
|
|
modeName={modeName}
|
|
modeColor={modeColor}
|
|
showExitHint={showExitHint}
|
|
/>
|
|
) : modeName && modeColor ? (
|
|
<Text>
|
|
<Text color={modeColor}>⏵⏵ {modeName}</Text>
|
|
<Text color={modeColor} dimColor>
|
|
{" "}
|
|
(shift+tab to {showExitHint ? "exit" : "cycle"})
|
|
</Text>
|
|
</Text>
|
|
) : (
|
|
<Text dimColor>Press / for commands</Text>
|
|
)}
|
|
</Box>
|
|
<Box
|
|
flexDirection={
|
|
statusLineRight && !hideFooterContent ? "column" : undefined
|
|
}
|
|
alignItems={
|
|
statusLineRight && !hideFooterContent ? "flex-end" : undefined
|
|
}
|
|
width={
|
|
statusLineRight && !hideFooterContent ? undefined : rightColumnWidth
|
|
}
|
|
flexShrink={0}
|
|
>
|
|
{hideFooterContent ? (
|
|
<Text>{" ".repeat(rightColumnWidth)}</Text>
|
|
) : statusLineRight ? (
|
|
statusLineRight.split("\n").map((line, i) => (
|
|
<Text key={`${i}-${line}`} wrap="truncate-end">
|
|
{parseOsc8Line(line, `r${i}`)}
|
|
</Text>
|
|
))
|
|
) : (
|
|
<Text>{rightLabel}</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
const StreamingStatus = memo(function StreamingStatus({
|
|
streaming,
|
|
visible,
|
|
tokenCount,
|
|
elapsedBaseMs,
|
|
thinkingMessage,
|
|
agentName,
|
|
interruptRequested,
|
|
networkPhase,
|
|
terminalWidth,
|
|
shouldAnimate,
|
|
}: {
|
|
streaming: boolean;
|
|
visible: boolean;
|
|
tokenCount: number;
|
|
elapsedBaseMs: number;
|
|
thinkingMessage: string;
|
|
agentName: string | null | undefined;
|
|
interruptRequested: boolean;
|
|
networkPhase: "upload" | "download" | "error" | null;
|
|
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 || !animate) return;
|
|
|
|
const id = setInterval(() => {
|
|
setShimmerOffset((prev) => {
|
|
// Include agent name length (+1 for space) in shimmer cycle
|
|
const prefixLen = agentName ? agentName.length + 1 : 0;
|
|
const len = prefixLen + thinkingMessage.length;
|
|
const next = prev + 1;
|
|
return next > len + 3 ? -3 : next;
|
|
});
|
|
}, 120); // Speed of shimmer animation
|
|
|
|
return () => clearInterval(id);
|
|
}, [streaming, thinkingMessage, visible, agentName, animate]);
|
|
|
|
useEffect(() => {
|
|
if (!animate) {
|
|
setShimmerOffset(-3);
|
|
}
|
|
}, [animate]);
|
|
|
|
// Elapsed time tracking: pause updates during resize, but do not reset.
|
|
useEffect(() => {
|
|
if (!streaming || !visible || isResizing) {
|
|
return;
|
|
}
|
|
|
|
if (streamStartRef.current === null) {
|
|
streamStartRef.current = performance.now();
|
|
}
|
|
|
|
const id = setInterval(() => {
|
|
if (streamStartRef.current !== null) {
|
|
setElapsedMs(performance.now() - streamStartRef.current);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(id);
|
|
}, [streaming, visible, isResizing]);
|
|
|
|
useEffect(() => {
|
|
if (streaming && visible) {
|
|
return;
|
|
}
|
|
streamStartRef.current = null;
|
|
setElapsedMs(0);
|
|
}, [streaming, visible]);
|
|
|
|
const estimatedTokens = charsToTokens(tokenCount);
|
|
const totalElapsedMs = elapsedBaseMs + elapsedMs;
|
|
const shouldShowTokenCount =
|
|
streaming && estimatedTokens > TOKEN_DISPLAY_THRESHOLD;
|
|
const shouldShowElapsed =
|
|
streaming && totalElapsedMs > ELAPSED_DISPLAY_THRESHOLD_MS;
|
|
const elapsedLabel = formatElapsedLabel(totalElapsedMs);
|
|
|
|
const networkArrow = useMemo(() => {
|
|
if (!networkPhase) return "";
|
|
if (networkPhase === "upload") return "↑";
|
|
if (networkPhase === "download") return "↑"; // Use ↑ for both to avoid distracting flip (change to ↓ to restore)
|
|
return "↑\u0338";
|
|
}, [networkPhase]);
|
|
const showErrorArrow = networkArrow === "↑\u0338";
|
|
// 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[] = [];
|
|
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;
|
|
const messageColumnWidth = Math.max(
|
|
0,
|
|
Math.min(maxMessageWidth, Math.max(minMessageWidth, statusLabelWidth)),
|
|
);
|
|
|
|
// Build the status hint text (esc to interrupt · 2m · 1.2k ↑)
|
|
// Uses chalk.dim to match reasoning text styling
|
|
// Memoized to prevent unnecessary re-renders during shimmer updates
|
|
const statusHintText = useMemo(() => {
|
|
const suffix = `${statusHintSuffix})`;
|
|
if (interruptRequested) {
|
|
return <Text dimColor>{` (interrupting${suffix}`}</Text>;
|
|
}
|
|
return (
|
|
<Text dimColor>
|
|
{" ("}
|
|
<Text bold>esc</Text>
|
|
{` to interrupt${suffix}`}
|
|
</Text>
|
|
);
|
|
}, [interruptRequested, statusHintSuffix]);
|
|
|
|
if (!streaming || !visible) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="row" marginBottom={1}>
|
|
<Box width={2} flexShrink={0}>
|
|
<Text color={colors.status.processing}>
|
|
{animate ? <Spinner type="layer" /> : "●"}
|
|
</Text>
|
|
</Box>
|
|
<Box width={statusContentWidth} flexShrink={0} flexDirection="row">
|
|
<Box width={messageColumnWidth} flexShrink={0}>
|
|
<ShimmerText
|
|
boldPrefix={agentName || undefined}
|
|
message={thinkingMessage}
|
|
shimmerOffset={animate ? shimmerOffset : -3}
|
|
wrap="truncate-end"
|
|
/>
|
|
</Box>
|
|
{hintColumnWidth > 0 && (
|
|
<Box width={hintColumnWidth} flexShrink={0}>
|
|
<Text wrap="truncate-end">{statusHintText}</Text>
|
|
</Box>
|
|
)}
|
|
<Box flexGrow={1} />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
// Increase max listeners to accommodate multiple useInput hooks
|
|
// (5 in this component + autocomplete components)
|
|
stdin.setMaxListeners(20);
|
|
|
|
// Also set default max listeners on EventEmitter prototype to prevent warnings
|
|
// from any EventEmitters that might not have their limit set properly
|
|
EventEmitter.defaultMaxListeners = 20;
|
|
|
|
export function Input({
|
|
visible = true,
|
|
streaming,
|
|
tokenCount,
|
|
elapsedBaseMs = 0,
|
|
thinkingMessage,
|
|
onSubmit,
|
|
onBashSubmit,
|
|
bashRunning = false,
|
|
onBashInterrupt,
|
|
inputEnabled = true,
|
|
collapseInputWhenDisabled = false,
|
|
permissionMode: externalMode,
|
|
onPermissionModeChange,
|
|
onExit,
|
|
onInterrupt,
|
|
interruptRequested = false,
|
|
agentId,
|
|
agentName,
|
|
currentModel,
|
|
currentModelProvider,
|
|
currentReasoningEffort,
|
|
currentSystemPromptId,
|
|
currentToolset,
|
|
messageQueue,
|
|
onEnterQueueEditMode,
|
|
onEscapeCancel,
|
|
ralphActive = false,
|
|
ralphPending = false,
|
|
ralphPendingYolo = false,
|
|
onRalphExit,
|
|
conversationId,
|
|
onPasteError,
|
|
restoredInput,
|
|
onRestoredInputConsumed,
|
|
networkPhase = null,
|
|
terminalWidth,
|
|
shouldAnimate = true,
|
|
statusLineText,
|
|
statusLineRight,
|
|
statusLinePadding = 0,
|
|
statusLinePrompt,
|
|
onCycleReasoningEffort,
|
|
}: {
|
|
visible?: boolean;
|
|
streaming: boolean;
|
|
tokenCount: number;
|
|
elapsedBaseMs?: number;
|
|
thinkingMessage: string;
|
|
onSubmit: (message?: string) => Promise<{ submitted: boolean }>;
|
|
onBashSubmit?: (command: string) => Promise<void>;
|
|
bashRunning?: boolean;
|
|
onBashInterrupt?: () => void;
|
|
inputEnabled?: boolean;
|
|
collapseInputWhenDisabled?: boolean;
|
|
permissionMode?: PermissionMode;
|
|
onPermissionModeChange?: (mode: PermissionMode) => void;
|
|
onExit?: () => void;
|
|
onInterrupt?: () => void;
|
|
interruptRequested?: boolean;
|
|
agentId?: string;
|
|
agentName?: string | null;
|
|
currentModel?: string | null;
|
|
currentModelProvider?: string | null;
|
|
currentReasoningEffort?: ModelReasoningEffort | null;
|
|
currentSystemPromptId?: string | null;
|
|
currentToolset?: string | null;
|
|
messageQueue?: QueuedMessage[];
|
|
onEnterQueueEditMode?: () => void;
|
|
onEscapeCancel?: () => void;
|
|
ralphActive?: boolean;
|
|
ralphPending?: boolean;
|
|
ralphPendingYolo?: boolean;
|
|
onRalphExit?: () => void;
|
|
conversationId?: string;
|
|
onPasteError?: (message: string) => void;
|
|
restoredInput?: string | null;
|
|
onRestoredInputConsumed?: () => void;
|
|
networkPhase?: "upload" | "download" | "error" | null;
|
|
terminalWidth: number;
|
|
shouldAnimate?: boolean;
|
|
statusLineText?: string;
|
|
statusLineRight?: string;
|
|
statusLinePadding?: number;
|
|
statusLinePrompt?: string;
|
|
onCycleReasoningEffort?: () => void;
|
|
}) {
|
|
const [value, setValue] = useState("");
|
|
const [escapePressed, setEscapePressed] = useState(false);
|
|
const escapeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [ctrlCPressed, setCtrlCPressed] = useState(false);
|
|
const ctrlCTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const previousValueRef = useRef(value);
|
|
const [currentMode, setCurrentMode] = useState<PermissionMode>(
|
|
externalMode || permissionMode.getMode(),
|
|
);
|
|
const [isAutocompleteActive, setIsAutocompleteActive] = useState(false);
|
|
const [cursorPos, setCursorPos] = useState<number | undefined>(undefined);
|
|
const [currentCursorPosition, setCurrentCursorPosition] = useState(0);
|
|
|
|
// 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);
|
|
|
|
const interactionEnabled = visible && inputEnabled;
|
|
const reserveInputSpace = !collapseInputWhenDisabled;
|
|
const hideFooter = !interactionEnabled || value.startsWith("/");
|
|
const inputRowLines = useMemo(() => {
|
|
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[]>([]);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const [temporaryInput, setTemporaryInput] = useState("");
|
|
|
|
// Track if we just moved to a boundary (for two-step history navigation)
|
|
const [atStartBoundary, setAtStartBoundary] = useState(false);
|
|
const [atEndBoundary, setAtEndBoundary] = useState(false);
|
|
|
|
// Track preferred column for vertical navigation (sticky column behavior)
|
|
const [preferredColumn, setPreferredColumn] = useState<number | null>(null);
|
|
|
|
// Restore input from error (only if current value is empty)
|
|
useEffect(() => {
|
|
if (restoredInput && value === "") {
|
|
setValue(restoredInput);
|
|
onRestoredInputConsumed?.();
|
|
} else if (restoredInput && value !== "") {
|
|
// Input has content, don't clobber - just consume the restored value
|
|
onRestoredInputConsumed?.();
|
|
}
|
|
}, [restoredInput, value, onRestoredInputConsumed]);
|
|
|
|
const handleBangAtEmpty = useCallback(() => {
|
|
if (isBashMode) return false;
|
|
setIsBashMode(true);
|
|
return true;
|
|
}, [isBashMode]);
|
|
|
|
const handleBackspaceAtEmpty = useCallback(() => {
|
|
if (!isBashMode) return false;
|
|
setIsBashMode(false);
|
|
return true;
|
|
}, [isBashMode]);
|
|
|
|
// Reset cursor position after it's been applied
|
|
useEffect(() => {
|
|
if (cursorPos !== undefined) {
|
|
const timer = setTimeout(() => setCursorPos(undefined), 0);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [cursorPos]);
|
|
|
|
// Reset boundary flags and preferred column when cursor moves or value changes
|
|
useEffect(() => {
|
|
if (currentCursorPosition !== 0) {
|
|
setAtStartBoundary(false);
|
|
}
|
|
if (currentCursorPosition !== value.length) {
|
|
setAtEndBoundary(false);
|
|
}
|
|
// Reset preferred column - it will be set again when vertical navigation starts
|
|
setPreferredColumn(null);
|
|
}, [currentCursorPosition, value.length]);
|
|
|
|
// Sync with external mode changes (from plan approval dialog)
|
|
useEffect(() => {
|
|
if (externalMode !== undefined) {
|
|
setCurrentMode(externalMode);
|
|
}
|
|
}, [externalMode]);
|
|
|
|
useEffect(() => {
|
|
if (!interactionEnabled) {
|
|
setIsAutocompleteActive(false);
|
|
}
|
|
}, [interactionEnabled]);
|
|
|
|
// Get server URL (same logic as client.ts)
|
|
const settings = settingsManager.getSettings();
|
|
const serverUrl =
|
|
process.env.LETTA_BASE_URL ||
|
|
settings.env?.LETTA_BASE_URL ||
|
|
LETTA_CLOUD_API_URL;
|
|
|
|
// Handle profile confirmation: Enter confirms, any other key cancels
|
|
// When onEscapeCancel is provided, TextInput is unfocused so we handle all keys here
|
|
useInput((_input, key) => {
|
|
if (!interactionEnabled) return;
|
|
if (!onEscapeCancel) return;
|
|
|
|
// Enter key confirms the action - trigger submit with empty input
|
|
if (key.return) {
|
|
onSubmit("");
|
|
return;
|
|
}
|
|
|
|
// Any other key cancels
|
|
onEscapeCancel();
|
|
});
|
|
|
|
// Handle escape key for interrupt (when streaming) or double-escape-to-clear (when not)
|
|
useInput((_input, key) => {
|
|
if (!interactionEnabled) return;
|
|
// Debug logging for escape key detection
|
|
if (process.env.LETTA_DEBUG_KEYS === "1" && key.escape) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(
|
|
`[debug:InputRich:escape] escape=${key.escape} visible=${visible} onEscapeCancel=${!!onEscapeCancel} streaming=${streaming}`,
|
|
);
|
|
}
|
|
// Skip if onEscapeCancel is provided - handled by the confirmation handler above
|
|
if (onEscapeCancel) return;
|
|
|
|
if (key.escape) {
|
|
// When bash command running, use Esc to interrupt (LET-7199)
|
|
if (bashRunning && onBashInterrupt) {
|
|
onBashInterrupt();
|
|
return;
|
|
}
|
|
|
|
// When agent streaming, use Esc to interrupt
|
|
if (streaming && onInterrupt && !interruptRequested) {
|
|
onInterrupt();
|
|
// Don't load queued messages into input - let the dequeue effect
|
|
// in App.tsx process them automatically after the interrupt completes.
|
|
return;
|
|
}
|
|
|
|
// When input is non-empty, use double-escape to clear
|
|
if (value) {
|
|
if (escapePressed) {
|
|
// Second escape - clear input
|
|
setValue("");
|
|
setEscapePressed(false);
|
|
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
|
} else {
|
|
// First escape - start timer to allow double-escape to clear
|
|
setEscapePressed(true);
|
|
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
|
escapeTimerRef.current = setTimeout(() => {
|
|
setEscapePressed(false);
|
|
}, ESC_CLEAR_WINDOW_MS);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
useInput((input, key) => {
|
|
if (!interactionEnabled) return;
|
|
|
|
// Handle CTRL-C for double-ctrl-c-to-exit
|
|
// In bash mode, CTRL-C wipes input but doesn't exit bash mode
|
|
if (input === "c" && key.ctrl) {
|
|
// If a bash command is running, Ctrl+C interrupts it (same as Esc)
|
|
if (bashRunning && onBashInterrupt) {
|
|
onBashInterrupt();
|
|
return;
|
|
}
|
|
|
|
if (ctrlCPressed) {
|
|
// Second CTRL-C - call onExit callback which handles stats and exit
|
|
if (onExit) onExit();
|
|
} else {
|
|
// First CTRL-C - wipe input and start 1-second timer
|
|
// Note: In bash mode, this clears input but keeps bash mode active
|
|
setValue("");
|
|
setCtrlCPressed(true);
|
|
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
|
ctrlCTimerRef.current = setTimeout(() => {
|
|
setCtrlCPressed(false);
|
|
}, 1000);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Note: bash mode entry/exit is implemented inside PasteAwareTextInput so we can
|
|
// consume the keystroke before it renders (no flicker).
|
|
|
|
// Handle Shift+Tab for permission mode cycling (or ralph mode exit)
|
|
useInput((_input, key) => {
|
|
if (!interactionEnabled) return;
|
|
|
|
// Tab (no shift): cycle reasoning effort tiers for the current model (when idle).
|
|
// Only trigger when autocomplete is NOT active.
|
|
if (
|
|
key.tab &&
|
|
!key.shift &&
|
|
!isAutocompleteActive &&
|
|
!streaming &&
|
|
onCycleReasoningEffort
|
|
) {
|
|
onCycleReasoningEffort();
|
|
return;
|
|
}
|
|
|
|
// Debug logging for shift+tab detection
|
|
if (process.env.LETTA_DEBUG_KEYS === "1" && (key.shift || key.tab)) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(
|
|
`[debug:InputRich] shift=${key.shift} tab=${key.tab} visible=${visible}`,
|
|
);
|
|
}
|
|
|
|
if (key.shift && key.tab) {
|
|
// If ralph mode is active, exit it first (goes to default mode)
|
|
if (ralphActive && onRalphExit) {
|
|
onRalphExit();
|
|
return;
|
|
}
|
|
|
|
// Cycle through permission modes
|
|
const modes: PermissionMode[] = [
|
|
"default",
|
|
"acceptEdits",
|
|
"plan",
|
|
"bypassPermissions",
|
|
];
|
|
const currentIndex = modes.indexOf(currentMode);
|
|
const nextIndex = (currentIndex + 1) % modes.length;
|
|
const nextMode = modes[nextIndex] ?? "default";
|
|
|
|
// Update both singleton and local state
|
|
permissionMode.setMode(nextMode);
|
|
setCurrentMode(nextMode);
|
|
|
|
// Notify parent of mode change
|
|
if (onPermissionModeChange) {
|
|
onPermissionModeChange(nextMode);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle up/down arrow keys for wrapped text navigation and command history
|
|
useInput((_input, key) => {
|
|
if (!interactionEnabled) return;
|
|
// Don't interfere with autocomplete navigation, BUT allow history navigation
|
|
// when we're already browsing history (historyIndex !== -1)
|
|
if (isAutocompleteActive && historyIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
if (key.upArrow || key.downArrow) {
|
|
// Calculate visual lines accounting for both soft wrapping and hard newlines
|
|
const visualLines = getVisualLines(value, contentWidth);
|
|
const { lineIndex, column } = findCursorLine(
|
|
currentCursorPosition,
|
|
visualLines,
|
|
);
|
|
|
|
// Use preferred column if set (for sticky column behavior), otherwise current column
|
|
const targetColumn = preferredColumn ?? column;
|
|
|
|
if (key.upArrow) {
|
|
const targetLine = visualLines[lineIndex - 1];
|
|
if (lineIndex > 0 && targetLine) {
|
|
// Not on first visual line - move cursor up one visual line
|
|
// Set preferred column if not already set
|
|
if (preferredColumn === null) {
|
|
setPreferredColumn(column);
|
|
}
|
|
const targetLineLength = targetLine.end - targetLine.start;
|
|
const newColumn = Math.min(targetColumn, targetLineLength);
|
|
setCursorPos(targetLine.start + newColumn);
|
|
setAtStartBoundary(false); // Reset boundary flag
|
|
return; // Don't trigger history
|
|
}
|
|
|
|
// On first wrapped line
|
|
// First press: move to start, second press: queue edit or history
|
|
// Skip the two-step behavior if already browsing history - go straight to navigation
|
|
if (
|
|
currentCursorPosition > 0 &&
|
|
!atStartBoundary &&
|
|
historyIndex === -1
|
|
) {
|
|
// First press - move cursor to start
|
|
setCursorPos(0);
|
|
setAtStartBoundary(true);
|
|
return;
|
|
}
|
|
|
|
// Check if we should load queue (streaming with queued messages)
|
|
if (
|
|
streaming &&
|
|
messageQueue &&
|
|
messageQueue.length > 0 &&
|
|
atStartBoundary
|
|
) {
|
|
setAtStartBoundary(false);
|
|
// Clear the queue and load into input as one multi-line message
|
|
const queueText = messageQueue
|
|
.filter((item) => item.kind === "user")
|
|
.map((item) => item.text.trim())
|
|
.filter((msg) => msg.length > 0)
|
|
.join("\n");
|
|
if (!queueText) {
|
|
return;
|
|
}
|
|
setValue(queueText);
|
|
// Signal to App.tsx to clear the queue
|
|
if (onEnterQueueEditMode) {
|
|
onEnterQueueEditMode();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, trigger history navigation
|
|
if (history.length === 0) return;
|
|
|
|
setAtStartBoundary(false); // Reset for next time
|
|
|
|
if (historyIndex === -1) {
|
|
// Starting to navigate history - save current input
|
|
setTemporaryInput(value);
|
|
// Go to most recent command
|
|
setHistoryIndex(history.length - 1);
|
|
const historyEntry = history[history.length - 1] ?? "";
|
|
setValue(historyEntry);
|
|
setCursorPos(historyEntry.length); // Cursor at end (traditional terminal behavior)
|
|
} else if (historyIndex > 0) {
|
|
// Go to older command
|
|
setHistoryIndex(historyIndex - 1);
|
|
const olderEntry = history[historyIndex - 1] ?? "";
|
|
setValue(olderEntry);
|
|
setCursorPos(olderEntry.length); // Cursor at end (traditional terminal behavior)
|
|
}
|
|
} else if (key.downArrow) {
|
|
const targetLine = visualLines[lineIndex + 1];
|
|
if (lineIndex < visualLines.length - 1 && targetLine) {
|
|
// Not on last visual line - move cursor down one visual line
|
|
// Set preferred column if not already set
|
|
if (preferredColumn === null) {
|
|
setPreferredColumn(column);
|
|
}
|
|
const targetLineLength = targetLine.end - targetLine.start;
|
|
const newColumn = Math.min(targetColumn, targetLineLength);
|
|
setCursorPos(targetLine.start + newColumn);
|
|
setAtEndBoundary(false); // Reset boundary flag
|
|
return; // Don't trigger history
|
|
}
|
|
|
|
// On last wrapped line
|
|
// First press: move to end, second press: navigate history
|
|
// Skip the two-step behavior if already browsing history - go straight to navigation
|
|
if (
|
|
currentCursorPosition < value.length &&
|
|
!atEndBoundary &&
|
|
historyIndex === -1
|
|
) {
|
|
// First press - move cursor to end
|
|
setCursorPos(value.length);
|
|
setAtEndBoundary(true);
|
|
return;
|
|
}
|
|
|
|
// Second press or already at end - trigger history navigation
|
|
setAtEndBoundary(false); // Reset for next time
|
|
|
|
if (historyIndex === -1) return; // Not in history mode
|
|
|
|
if (historyIndex < history.length - 1) {
|
|
// Go to newer command
|
|
setHistoryIndex(historyIndex + 1);
|
|
const newerEntry = history[historyIndex + 1] ?? "";
|
|
setValue(newerEntry);
|
|
setCursorPos(newerEntry.length); // Cursor at end (traditional terminal behavior)
|
|
} else {
|
|
// At the end of history - restore temporary input
|
|
setHistoryIndex(-1);
|
|
setValue(temporaryInput);
|
|
setCursorPos(temporaryInput.length); // Cursor at end for user's draft
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Reset escape and ctrl-c state when user types (value changes)
|
|
useEffect(() => {
|
|
if (value !== previousValueRef.current && value !== "") {
|
|
setEscapePressed(false);
|
|
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
|
setCtrlCPressed(false);
|
|
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
|
}
|
|
// Reset boundary flags when value changes (user is typing)
|
|
if (value !== previousValueRef.current) {
|
|
setAtStartBoundary(false);
|
|
setAtEndBoundary(false);
|
|
}
|
|
previousValueRef.current = value;
|
|
}, [value]);
|
|
|
|
// Exit history mode when user starts typing
|
|
useEffect(() => {
|
|
// If user is in history mode and the value changes (they're typing)
|
|
// Exit history mode but keep the modified text
|
|
if (historyIndex !== -1 && value !== history[historyIndex]) {
|
|
setHistoryIndex(-1);
|
|
setTemporaryInput("");
|
|
}
|
|
}, [value, historyIndex, history]);
|
|
|
|
// Clean up timers on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
|
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
// Don't submit if autocomplete is active with matches
|
|
if (isAutocompleteActive) {
|
|
return;
|
|
}
|
|
|
|
const previousValue = value;
|
|
|
|
// Handle bash mode submission
|
|
if (isBashMode) {
|
|
if (!previousValue.trim()) return;
|
|
|
|
// Input locking - don't accept new commands while one is running (LET-7199)
|
|
if (bashRunning) return;
|
|
|
|
// Add to history if not empty and not a duplicate of the last entry
|
|
setHistory((prev) => {
|
|
if (previousValue.trim() === prev[prev.length - 1]) return prev;
|
|
return [...prev, previousValue];
|
|
});
|
|
|
|
// Reset history navigation
|
|
setHistoryIndex(-1);
|
|
setTemporaryInput("");
|
|
|
|
setValue(""); // Clear immediately for responsiveness
|
|
// Stay in bash mode - user exits with backspace on empty input
|
|
if (onBashSubmit) {
|
|
await onBashSubmit(previousValue);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Add to history if not empty and not a duplicate of the last entry
|
|
if (previousValue.trim()) {
|
|
setHistory((prev) => {
|
|
if (previousValue === prev[prev.length - 1]) return prev;
|
|
return [...prev, previousValue];
|
|
});
|
|
}
|
|
|
|
// Reset history navigation
|
|
setHistoryIndex(-1);
|
|
setTemporaryInput("");
|
|
|
|
setValue(""); // Clear immediately for responsiveness
|
|
const result = await onSubmit(previousValue);
|
|
// If message was NOT submitted (e.g. pending approval), restore it
|
|
if (!result.submitted) {
|
|
setValue(previousValue);
|
|
}
|
|
}, [
|
|
isAutocompleteActive,
|
|
value,
|
|
isBashMode,
|
|
bashRunning,
|
|
onBashSubmit,
|
|
onSubmit,
|
|
]);
|
|
|
|
// Handle file selection from autocomplete
|
|
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(" ");
|
|
|
|
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
|
|
}
|
|
|
|
setValue(newValue);
|
|
setCursorPos(newCursorPos);
|
|
},
|
|
[value],
|
|
);
|
|
|
|
// Handle slash command selection from autocomplete (Enter key - execute)
|
|
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) {
|
|
setHistory((prev) => {
|
|
if (commandToSubmit === prev[prev.length - 1]) return prev;
|
|
return [...prev, commandToSubmit];
|
|
});
|
|
}
|
|
|
|
// Reset history navigation
|
|
setHistoryIndex(-1);
|
|
setTemporaryInput("");
|
|
|
|
setValue(""); // Clear immediately for responsiveness
|
|
await onSubmit(commandToSubmit);
|
|
},
|
|
[onSubmit],
|
|
);
|
|
|
|
// Handle slash command autocomplete (Tab key - fill text only)
|
|
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
|
|
const modeInfo = useMemo(() => {
|
|
// Check ralph pending first (waiting for task input)
|
|
if (ralphPending) {
|
|
if (ralphPendingYolo) {
|
|
return {
|
|
name: "yolo-ralph (waiting)",
|
|
color: "#FF8C00", // dark orange
|
|
};
|
|
}
|
|
return {
|
|
name: "ralph (waiting)",
|
|
color: "#FEE19C", // yellow (brandColors.statusWarning)
|
|
};
|
|
}
|
|
|
|
// Check ralph mode active (using prop for reactivity)
|
|
if (ralphActive) {
|
|
const ralph = ralphMode.getState();
|
|
const iterDisplay =
|
|
ralph.maxIterations > 0
|
|
? `${ralph.currentIteration}/${ralph.maxIterations}`
|
|
: `${ralph.currentIteration}`;
|
|
|
|
if (ralph.isYolo) {
|
|
return {
|
|
name: `yolo-ralph (iter ${iterDisplay})`,
|
|
color: "#FF8C00", // dark orange
|
|
};
|
|
}
|
|
return {
|
|
name: `ralph (iter ${iterDisplay})`,
|
|
color: "#FEE19C", // yellow (brandColors.statusWarning)
|
|
};
|
|
}
|
|
|
|
// Fall through to permission modes
|
|
switch (currentMode) {
|
|
case "acceptEdits":
|
|
return { name: "accept edits", color: colors.status.processing };
|
|
case "plan":
|
|
return { name: "plan (read-only) mode", color: colors.status.success };
|
|
case "bypassPermissions":
|
|
return {
|
|
name: "yolo (allow all) mode",
|
|
color: colors.status.error,
|
|
};
|
|
default:
|
|
return null;
|
|
}
|
|
}, [ralphPending, ralphPendingYolo, ralphActive, currentMode]);
|
|
|
|
// 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 (
|
|
<>
|
|
{/* Queue display - show whenever there are queued messages */}
|
|
{messageQueue && messageQueue.length > 0 && (
|
|
<QueuedMessages messages={messageQueue} />
|
|
)}
|
|
|
|
{interactionEnabled ? (
|
|
<Box flexDirection="column">
|
|
{/* Top horizontal divider */}
|
|
{!suppressDividers && (
|
|
<Text
|
|
dimColor={!isBashMode}
|
|
color={isBashMode ? colors.bash.border : undefined}
|
|
>
|
|
{horizontalLine}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Two-column layout for input, matching message components */}
|
|
<Box flexDirection="row">
|
|
<Box width={promptVisualWidth} flexShrink={0}>
|
|
<Text
|
|
color={isBashMode ? colors.bash.prompt : colors.input.prompt}
|
|
>
|
|
{promptChar}
|
|
</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 */}
|
|
{!suppressDividers && (
|
|
<Text
|
|
dimColor={!isBashMode}
|
|
color={isBashMode ? colors.bash.border : undefined}
|
|
>
|
|
{horizontalLine}
|
|
</Text>
|
|
)}
|
|
|
|
{/*
|
|
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}
|
|
/>
|
|
)}
|
|
|
|
{!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} />
|
|
) : 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,
|
|
currentReasoningEffort,
|
|
currentModelProvider,
|
|
hideFooter,
|
|
footerRightColumnWidth,
|
|
reserveInputSpace,
|
|
inputChromeHeight,
|
|
statusLineText,
|
|
statusLineRight,
|
|
statusLinePadding,
|
|
currentSystemPromptId,
|
|
currentToolset,
|
|
promptChar,
|
|
promptVisualWidth,
|
|
suppressDividers,
|
|
]);
|
|
|
|
// If not visible, render nothing but keep component mounted to preserve state
|
|
if (!visible) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<StreamingStatus
|
|
streaming={streaming}
|
|
visible={visible}
|
|
tokenCount={tokenCount}
|
|
elapsedBaseMs={elapsedBaseMs}
|
|
thinkingMessage={thinkingMessage}
|
|
agentName={agentName}
|
|
interruptRequested={interruptRequested}
|
|
networkPhase={networkPhase}
|
|
terminalWidth={columns}
|
|
shouldAnimate={shouldAnimate}
|
|
/>
|
|
{lowerPane}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function formatElapsedLabel(ms: number): string {
|
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
const seconds = totalSeconds % 60;
|
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
if (totalMinutes === 0) {
|
|
return `${seconds}s`;
|
|
}
|
|
const minutes = totalMinutes % 60;
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
if (hours > 0) {
|
|
const parts: string[] = [`${hours}hr`];
|
|
if (minutes > 0) parts.push(`${minutes}m`);
|
|
if (seconds > 0) parts.push(`${seconds}s`);
|
|
return parts.join(" ");
|
|
}
|
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
}
|