Files
letta-code/src/cli/components/InputRich.tsx
Shubham Naik 948684dfac Feat add autocomplete (#18)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
2025-10-28 14:24:56 -07:00

335 lines
10 KiB
TypeScript

// Import useInput from vendored Ink for bracketed paste support
import { Box, Text, useInput } from "ink";
import SpinnerLib from "ink-spinner";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import type { PermissionMode } from "../../permissions/mode";
import { permissionMode } from "../../permissions/mode";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { InputAssist } from "./InputAssist";
import { PasteAwareTextInput } from "./PasteAwareTextInput";
import { ShimmerText } from "./ShimmerText";
// Type assertion for ink-spinner compatibility
const Spinner = SpinnerLib as ComponentType<{ type?: string }>;
// Only show token count when it exceeds this threshold
const COUNTER_VISIBLE_THRESHOLD = 1000;
export function Input({
visible = true,
streaming,
commandRunning = false,
tokenCount,
thinkingMessage,
onSubmit,
permissionMode: externalMode,
onPermissionModeChange,
onExit,
onInterrupt,
interruptRequested = false,
}: {
visible?: boolean;
streaming: boolean;
commandRunning?: boolean;
tokenCount: number;
thinkingMessage: string;
onSubmit: (message?: string) => Promise<{ submitted: boolean }>;
permissionMode?: PermissionMode;
onPermissionModeChange?: (mode: PermissionMode) => void;
onExit?: () => void;
onInterrupt?: () => void;
interruptRequested?: boolean;
}) {
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);
// Sync with external mode changes (from plan approval dialog)
useEffect(() => {
if (externalMode !== undefined) {
setCurrentMode(externalMode);
}
}, [externalMode]);
// Shimmer animation state
const [shimmerOffset, setShimmerOffset] = useState(-3);
// Terminal width (reactive to window resizing)
const columns = useTerminalWidth();
const contentWidth = Math.max(0, columns - 2);
// Handle escape key for interrupt (when streaming) or double-escape-to-clear (when not)
useInput((_input, key) => {
if (key.escape) {
// When streaming, use Esc to interrupt
if (streaming && onInterrupt && !interruptRequested) {
onInterrupt();
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 1-second timer
setEscapePressed(true);
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = setTimeout(() => {
setEscapePressed(false);
}, 1000);
}
}
}
});
// Handle CTRL-C for double-ctrl-c-to-exit
useInput((input, key) => {
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
setValue("");
setCtrlCPressed(true);
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressed(false);
}, 1000);
}
}
});
// Handle Shift+Tab for permission mode cycling
useInput((_input, key) => {
if (key.shift && key.tab) {
// 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);
}
}
});
// 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);
}
previousValueRef.current = value;
}, [value]);
// 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) => {
const len = thinkingMessage.length;
const next = prev + 1;
return next > len + 3 ? -3 : next;
});
}, 120); // Speed of shimmer animation
return () => clearInterval(id);
}, [streaming, thinkingMessage, visible]);
const handleSubmit = async () => {
// Don't submit if autocomplete is active with matches
if (isAutocompleteActive) {
return;
}
if (streaming || commandRunning) {
return;
}
const previousValue = value;
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);
// Reset cursor position after a short delay so it only applies once
setTimeout(() => setCursorPos(undefined), 50);
};
// Get display name and color for permission mode
const getModeInfo = () => {
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;
}
};
const modeInfo = getModeInfo();
const shouldShowTokenCount =
streaming && tokenCount > COUNTER_VISIBLE_THRESHOLD;
// Create a horizontal line using box-drawing characters
const horizontalLine = "─".repeat(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}>
<ShimmerText
message={thinkingMessage}
shimmerOffset={shimmerOffset}
/>
<Text dimColor>
{" ("}
{interruptRequested ? "interrupting" : "esc to interrupt"}
{shouldShowTokenCount && ` · ${tokenCount}`}
{")"}
</Text>
</Box>
</Box>
)}
<Box flexDirection="column">
{/* Top horizontal divider */}
<Text dimColor>{horizontalLine}</Text>
{/* Two-column layout for input, matching message components */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={colors.input.prompt}>{">"}</Text>
<Text> </Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<PasteAwareTextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
cursorPosition={cursorPos}
onCursorMove={setCurrentCursorPosition}
/>
</Box>
</Box>
{/* Bottom horizontal divider */}
<Text dimColor>{horizontalLine}</Text>
<InputAssist
currentInput={value}
cursorPosition={currentCursorPosition}
onFileSelect={handleFileSelect}
onAutocompleteActiveChange={setIsAutocompleteActive}
/>
<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>
) : modeInfo ? (
<Text>
<Text color={modeInfo.color}> {modeInfo.name}</Text>
<Text color={modeInfo.color} dimColor>
{" "}
(shift+tab to cycle)
</Text>
</Text>
) : (
<Text dimColor>Press / for commands or @ for files</Text>
)}
<Text dimColor>https://discord.gg/letta</Text>
</Box>
</Box>
</Box>
);
}