878 lines
29 KiB
TypeScript
878 lines
29 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, Text, useInput } from "ink";
|
|
import SpinnerLib from "ink-spinner";
|
|
import {
|
|
type ComponentType,
|
|
memo,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
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 { useTerminalWidth } from "../hooks/useTerminalWidth";
|
|
import { colors } from "./colors";
|
|
import { InputAssist } from "./InputAssist";
|
|
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
|
import { QueuedMessages } from "./QueuedMessages";
|
|
import { ShimmerText } from "./ShimmerText";
|
|
|
|
// 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;
|
|
|
|
/**
|
|
* 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,
|
|
isOpenAICodexProvider,
|
|
isAutocompleteActive,
|
|
}: {
|
|
ctrlCPressed: boolean;
|
|
escapePressed: boolean;
|
|
isBashMode: boolean;
|
|
modeName: string | null;
|
|
modeColor: string | null;
|
|
showExitHint: boolean;
|
|
agentName: string | null | undefined;
|
|
currentModel: string | null | undefined;
|
|
isOpenAICodexProvider: boolean;
|
|
isAutocompleteActive: boolean;
|
|
}) {
|
|
// Hide footer when autocomplete is showing
|
|
if (isAutocompleteActive) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Box justifyContent="space-between" marginBottom={1}>
|
|
{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>
|
|
) : 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>
|
|
)}
|
|
<Text>
|
|
<Text color={colors.footer.agentName}>{agentName || "Unnamed"}</Text>
|
|
<Text
|
|
dimColor={!isOpenAICodexProvider}
|
|
color={isOpenAICodexProvider ? "#74AA9C" : undefined}
|
|
>
|
|
{` [${currentModel ?? "unknown"}]`}
|
|
</Text>
|
|
</Text>
|
|
</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,
|
|
thinkingMessage,
|
|
onSubmit,
|
|
onBashSubmit,
|
|
permissionMode: externalMode,
|
|
onPermissionModeChange,
|
|
onExit,
|
|
onInterrupt,
|
|
interruptRequested = false,
|
|
agentId,
|
|
agentName,
|
|
currentModel,
|
|
currentModelProvider,
|
|
messageQueue,
|
|
onEnterQueueEditMode,
|
|
onEscapeCancel,
|
|
ralphActive = false,
|
|
ralphPending = false,
|
|
ralphPendingYolo = false,
|
|
onRalphExit,
|
|
conversationId,
|
|
onPasteError,
|
|
}: {
|
|
visible?: boolean;
|
|
streaming: boolean;
|
|
tokenCount: number;
|
|
thinkingMessage: string;
|
|
onSubmit: (message?: string) => Promise<{ submitted: boolean }>;
|
|
onBashSubmit?: (command: string) => Promise<void>;
|
|
permissionMode?: PermissionMode;
|
|
onPermissionModeChange?: (mode: PermissionMode) => void;
|
|
onExit?: () => void;
|
|
onInterrupt?: () => void;
|
|
interruptRequested?: boolean;
|
|
agentId?: string;
|
|
agentName?: string | null;
|
|
currentModel?: string | null;
|
|
currentModelProvider?: string | null;
|
|
messageQueue?: string[];
|
|
onEnterQueueEditMode?: () => void;
|
|
onEscapeCancel?: () => void;
|
|
ralphActive?: boolean;
|
|
ralphPending?: boolean;
|
|
ralphPendingYolo?: boolean;
|
|
onRalphExit?: () => void;
|
|
conversationId?: string;
|
|
onPasteError?: (message: string) => 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);
|
|
|
|
// 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);
|
|
|
|
// Bash mode state
|
|
const [isBashMode, setIsBashMode] = useState(false);
|
|
|
|
const handleBangAtEmpty = () => {
|
|
if (isBashMode) return false;
|
|
setIsBashMode(true);
|
|
return true;
|
|
};
|
|
|
|
const handleBackspaceAtEmpty = () => {
|
|
if (!isBashMode) return false;
|
|
setIsBashMode(false);
|
|
return true;
|
|
};
|
|
|
|
// 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 when cursor moves (via left/right arrows)
|
|
useEffect(() => {
|
|
if (currentCursorPosition !== 0) {
|
|
setAtStartBoundary(false);
|
|
}
|
|
if (currentCursorPosition !== value.length) {
|
|
setAtEndBoundary(false);
|
|
}
|
|
}, [currentCursorPosition, value.length]);
|
|
|
|
// Sync with external mode changes (from plan approval dialog)
|
|
useEffect(() => {
|
|
if (externalMode !== undefined) {
|
|
setCurrentMode(externalMode);
|
|
}
|
|
}, [externalMode]);
|
|
|
|
// Shimmer animation state
|
|
const [shimmerOffset, setShimmerOffset] = useState(-3);
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
const streamStartRef = useRef<number | null>(null);
|
|
|
|
// Terminal width (reactive to window resizing)
|
|
const columns = useTerminalWidth();
|
|
const contentWidth = Math.max(0, columns - 2);
|
|
|
|
// 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 (!visible) 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 (!visible) 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 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 (!visible) 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 (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 (!visible) 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 (!visible) 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 which wrapped line the cursor is on
|
|
const lineWidth = contentWidth; // Available width for text
|
|
|
|
// Calculate current wrapped line number and position within that line
|
|
const currentWrappedLine = Math.floor(currentCursorPosition / lineWidth);
|
|
const columnInCurrentLine = currentCursorPosition % lineWidth;
|
|
|
|
// Calculate total number of wrapped lines
|
|
const totalWrappedLines = Math.ceil(value.length / lineWidth) || 1;
|
|
|
|
if (key.upArrow) {
|
|
if (currentWrappedLine > 0) {
|
|
// Not on first wrapped line - move cursor up one wrapped line
|
|
// Try to maintain the same column position
|
|
const targetLine = currentWrappedLine - 1;
|
|
const targetLineStart = targetLine * lineWidth;
|
|
const targetLineEnd = Math.min(
|
|
targetLineStart + lineWidth,
|
|
value.length,
|
|
);
|
|
const targetLineLength = targetLineEnd - targetLineStart;
|
|
|
|
// Move to same column in previous line, or end of line if shorter
|
|
const newPosition =
|
|
targetLineStart + Math.min(columnInCurrentLine, targetLineLength);
|
|
setCursorPos(newPosition);
|
|
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.join("\n");
|
|
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) {
|
|
if (currentWrappedLine < totalWrappedLines - 1) {
|
|
// Not on last wrapped line - move cursor down one wrapped line
|
|
// Try to maintain the same column position
|
|
const targetLine = currentWrappedLine + 1;
|
|
const targetLineStart = targetLine * lineWidth;
|
|
const targetLineEnd = Math.min(
|
|
targetLineStart + lineWidth,
|
|
value.length,
|
|
);
|
|
const targetLineLength = targetLineEnd - targetLineStart;
|
|
|
|
// Move to same column in next line, or end of line if shorter
|
|
const newPosition =
|
|
targetLineStart + Math.min(columnInCurrentLine, targetLineLength);
|
|
setCursorPos(newPosition);
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
// Shimmer animation effect
|
|
useEffect(() => {
|
|
if (!streaming || !visible) 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]);
|
|
|
|
// Elapsed time tracking
|
|
useEffect(() => {
|
|
if (streaming && visible) {
|
|
// Start tracking when streaming begins
|
|
if (streamStartRef.current === null) {
|
|
streamStartRef.current = Date.now();
|
|
}
|
|
const id = setInterval(() => {
|
|
if (streamStartRef.current !== null) {
|
|
setElapsedMs(Date.now() - streamStartRef.current);
|
|
}
|
|
}, 1000);
|
|
return () => clearInterval(id);
|
|
}
|
|
// Reset when streaming stops
|
|
streamStartRef.current = null;
|
|
setElapsedMs(0);
|
|
}, [streaming, visible]);
|
|
|
|
const handleSubmit = 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;
|
|
|
|
// Add to history if not empty and not a duplicate of the last entry
|
|
if (previousValue.trim() !== history[history.length - 1]) {
|
|
setHistory([...history, 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() && previousValue !== history[history.length - 1]) {
|
|
setHistory([...history, 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);
|
|
}
|
|
};
|
|
|
|
// 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 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);
|
|
};
|
|
|
|
// 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();
|
|
|
|
// Add to history if not a duplicate of the last entry
|
|
if (commandToSubmit && commandToSubmit !== history[history.length - 1]) {
|
|
setHistory([...history, commandToSubmit]);
|
|
}
|
|
|
|
// Reset history navigation
|
|
setHistoryIndex(-1);
|
|
setTemporaryInput("");
|
|
|
|
setValue(""); // Clear immediately for responsiveness
|
|
await onSubmit(commandToSubmit);
|
|
};
|
|
|
|
// Handle slash command autocomplete (Tab key - fill text only)
|
|
const handleCommandAutocomplete = (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]);
|
|
|
|
const estimatedTokens = charsToTokens(tokenCount);
|
|
const shouldShowTokenCount =
|
|
streaming && estimatedTokens > TOKEN_DISPLAY_THRESHOLD;
|
|
const shouldShowElapsed =
|
|
streaming && elapsedMs > ELAPSED_DISPLAY_THRESHOLD_MS;
|
|
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
|
|
|
// 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 hintColor = chalk.hex(colors.subagent.hint);
|
|
const hintBold = hintColor.bold;
|
|
const suffix =
|
|
(shouldShowElapsed ? ` · ${elapsedMinutes}m` : "") +
|
|
(shouldShowTokenCount ? ` · ${formatCompact(estimatedTokens)} ↑` : "") +
|
|
")";
|
|
if (interruptRequested) {
|
|
return hintColor(` (interrupting${suffix}`);
|
|
}
|
|
return (
|
|
hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`)
|
|
);
|
|
}, [
|
|
shouldShowElapsed,
|
|
elapsedMinutes,
|
|
shouldShowTokenCount,
|
|
estimatedTokens,
|
|
interruptRequested,
|
|
]);
|
|
|
|
// Create a horizontal line using box-drawing characters
|
|
// Memoized since it only changes when terminal width changes
|
|
const horizontalLine = useMemo(() => "─".repeat(columns), [columns]);
|
|
|
|
// If not visible, render nothing but keep component mounted to preserve state
|
|
if (!visible) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
{/* Live status / token counter - only show when streaming */}
|
|
{streaming && (
|
|
<Box flexDirection="row" marginBottom={1}>
|
|
<Box width={2} flexShrink={0}>
|
|
<Text color={colors.status.processing}>
|
|
<Spinner type="layer" />
|
|
</Text>
|
|
</Box>
|
|
<Box flexGrow={1} flexDirection="row">
|
|
<ShimmerText
|
|
boldPrefix={agentName || undefined}
|
|
message={thinkingMessage}
|
|
shimmerOffset={shimmerOffset}
|
|
/>
|
|
<Text>{statusHintText}</Text>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Queue display - show whenever there are queued messages */}
|
|
{messageQueue && messageQueue.length > 0 && (
|
|
<QueuedMessages messages={messageQueue} />
|
|
)}
|
|
|
|
<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={!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
|
|
}
|
|
isAutocompleteActive={isAutocompleteActive}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|