"use client"; import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent, } from "react"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo, } from "@/hooks/useAgentSession"; import { clearDraft, getDraft, setDraft, type ChatDraftImage, } from "@/lib/draft-store"; import { MAX_ATTACHED_IMAGE_BYTES, MAX_ATTACHED_IMAGES, compressImageFile, isBase64ImageWithinLimits, } from "@/lib/image-attachments"; import { buildEntriesFromFiles, buildAtInsertText, extractAtQuery, filterFileEntries, type AtQueryMatch, type FileIndexEntry, } from "@/lib/file-fuzzy"; import { FolderIcon, getFileIcon } from "./FileIcons"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; export interface AttachedImage { data: string; // base64, no prefix mimeType: string; previewUrl: string; // object URL for display } interface ModelOption { provider: string; modelId: string; name: string; serviceTier?: string; } interface Props { onSend: (message: string, images?: AttachedImage[]) => void; onAbort: () => void; onSteer?: (message: string, images?: AttachedImage[]) => void; onFollowUp?: (message: string, images?: AttachedImage[]) => void; onPromptWithStreamingBehavior?: ( message: string, behavior: "steer" | "followUp", images?: AttachedImage[], ) => void; isStreaming: boolean; model?: { provider: string; modelId: string } | null; isAutoModelSelection?: boolean; modelNames?: Record; modelList?: { id: string; name: string; provider: string; serviceTier?: string; }[]; modelError?: string | null; onModelChange?: (provider: string, modelId: string) => void; onCompact?: () => void; onAbortCompaction?: () => void; isCompacting?: boolean; compactError?: string | null; compactResult?: CompactResultInfo | null; toolPreset?: "none" | "default" | "full"; onToolPresetChange?: (preset: "none" | "default" | "full") => void; thinkingLevel?: | "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; onThinkingLevelChange?: ( level: | "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", ) => void; availableThinkingLevels?: string[] | null; thinkingLevelMap?: Record | null; retryInfo?: { attempt: number; maxAttempts: number; errorMessage?: string; } | null; queuedMessages?: QueuedMessages | null; inputHistory?: string[]; onRecallQueue?: () => void; slashCommands?: SlashCommandInfo[]; slashCommandsLoading?: boolean; onLoadSlashCommands?: () => Promise | SlashCommandInfo[]; onBuiltinCommand?: (message: string) => Promise; soundEnabled?: boolean; onSoundToggle?: () => void; onAudioUnlock?: () => void; draftKey?: string; /** Session working directory — enables the @ file autocomplete menu */ cwd?: string | null; } export interface ChatInputHandle { insertText: (text: string) => void; insertIfEmpty: (text: string) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; } const TOOL_PRESETS = ["off", "default", "full"] as const; const TOOL_PRESET_MAP: Record< "off" | "default" | "full", "none" | "default" | "full" > = { off: "none", default: "default", full: "full" }; const COMPOSITION_END_ENTER_GRACE_MS = 100; const MODEL_FILTER_THRESHOLD = 8; const MODEL_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base", }); function compareModelOptions(a: ModelOption, b: ModelOption): number { return ( MODEL_OPTION_COLLATOR.compare(a.name || a.modelId, b.name || b.modelId) || MODEL_OPTION_COLLATOR.compare(a.provider, b.provider) || MODEL_OPTION_COLLATOR.compare(a.modelId, b.modelId) ); } export function filterModelOptions( options: ModelOption[], query: string, ): ModelOption[] { const normalizedQuery = query.trim().toLocaleLowerCase(); if (!normalizedQuery) return options; return options.filter((option) => `${option.name} ${option.modelId}` .toLocaleLowerCase() .includes(normalizedQuery), ); } const THINKING_LEVELS = [ "auto", "off", "minimal", "low", "medium", "high", "xhigh", "max", ] as const; const THINKING_LEVEL_DESC_KEYS: Record< (typeof THINKING_LEVELS)[number], string > = { auto: "chat.thinkingUseDefault", off: "chat.thinkingOff", minimal: "chat.thinkingMinimal", low: "chat.thinkingLow", medium: "chat.thinkingMedium", high: "chat.thinkingHigh", xhigh: "chat.thinkingXhigh", max: "chat.thinkingMax", }; function formatTokenCount(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; return tokens.toLocaleString(); } type SlashCommandPaletteItem = | SlashCommandInfo | { name: string; description: string; source: "builtin"; }; type SlashCommandSource = SlashCommandPaletteItem["source"]; const BUILTIN_SLASH_COMMANDS: SlashCommandPaletteItem[] = [ { name: "compact", description: "chat.commandCompact", source: "builtin" }, { name: "reload", description: "chat.commandReload", source: "builtin" }, { name: "name", description: "chat.commandName", source: "builtin" }, { name: "session", description: "chat.commandSession", source: "builtin" }, { name: "copy", description: "chat.commandCopy", source: "builtin" }, ]; const SLASH_SOURCES: SlashCommandSource[] = [ "builtin", "extension", "prompt", "skill", ]; const SLASH_SOURCE_GROUP_LABEL_KEYS: Record = { builtin: "chat.builtIn", extension: "chat.extensions", prompt: "chat.prompts", skill: "chat.skills", }; const SLASH_SOURCE_ORDER: Record = { builtin: 0, extension: 1, prompt: 2, skill: 3, }; function slashMatchRank( command: SlashCommandPaletteItem, query: string, t: (key: string) => string, ): number { const name = command.name.toLowerCase(); const description = getSlashDescription(command, t).toLowerCase(); if (name === query) return 0; if (name.startsWith(query)) return 1; if (name.includes(query)) return 2; if (description.includes(query)) return 3; return 4; } function getSlashDescription( command: SlashCommandPaletteItem, t: (key: string) => string, ): string { return command.source === "builtin" ? t(command.description) : (command.description ?? ""); } function imageToDraftImage(image: AttachedImage): ChatDraftImage { return { data: image.data, mimeType: image.mimeType }; } function draftImageToAttachedImage(image: ChatDraftImage): AttachedImage { return { ...image, previewUrl: `data:${image.mimeType};base64,${image.data}`, }; } function draftImagesToAttachedImages( images: ChatDraftImage[] | undefined, ): AttachedImage[] { return (images ?? []) .filter(isBase64ImageWithinLimits) .slice(0, MAX_ATTACHED_IMAGES) .map(draftImageToAttachedImage); } function revokeImagePreview(image: AttachedImage): void { if (image.previewUrl.startsWith("blob:")) { URL.revokeObjectURL(image.previewUrl); } } function QueuedMessageRow({ kind, text, }: { kind: "steer" | "follow-up"; text: string; }) { return (
{kind} {text}
); } export function ModelErrorBanner({ error }: { error?: string | null }) { if (!error) return null; return (
Model error
{error}
); } export const ChatInput = forwardRef(function ChatInput( { onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, onModelChange, onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, thinkingLevel, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap, retryInfo, queuedMessages, inputHistory = [], onRecallQueue, slashCommands, slashCommandsLoading, onLoadSlashCommands, onBuiltinCommand, soundEnabled, onSoundToggle, onAudioUnlock, onPromptWithStreamingBehavior, draftKey, cwd, }: Props, ref, ) { const { t } = useI18n(); const isMobile = useIsMobile(); const [value, setValue] = useState(() => draftKey ? (getDraft(draftKey)?.value ?? "") : "", ); const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [modelDropdownRect, setModelDropdownRect] = useState<{ top: number; left: number; width: number; } | null>(null); const [modelFilter, setModelFilter] = useState(""); // Codex fast mode toggle state; null = follow the model's configured serviceTier. const [fastModeOverride, setFastModeOverride] = useState( null, ); const [toolDropdownOpen, setToolDropdownOpen] = useState(false); const [thinkingDropdownOpen, setThinkingDropdownOpen] = useState(false); const [controlsMenuOpen, setControlsMenuOpen] = useState(false); const [attachedImages, setAttachedImages] = useState(() => draftKey ? draftImagesToAttachedImages(getDraft(draftKey)?.images) : [], ); const trimmedValue = value.trimStart(); const bashMode = attachedImages.length === 0 && trimmedValue.startsWith("!"); const bashExcluded = bashMode && trimmedValue.startsWith("!!"); const [slashMenuOpen, setSlashMenuOpen] = useState(false); const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [atQuery, setAtQuery] = useState(null); const [atMenuOpen, setAtMenuOpen] = useState(false); const [atActiveIndex, setAtActiveIndex] = useState(0); const [historyMenuOpen, setHistoryMenuOpen] = useState(false); const [historyActiveIndex, setHistoryActiveIndex] = useState(0); const [fileIndex, setFileIndex] = useState<{ cwd: string; entries: FileIndexEntry[]; truncated: boolean; } | null>(null); const [fileIndexLoading, setFileIndexLoading] = useState(false); const [atServerResult, setAtServerResult] = useState<{ cwd: string; query: string; matches: FileIndexEntry[]; } | null>(null); const textareaRef = useRef(null); const dropdownRef = useRef(null); const modelDropdownPanelRef = useRef(null); const toolDropdownRef = useRef(null); const thinkingDropdownRef = useRef(null); const controlsMenuRef = useRef(null); const historyMenuRef = useRef(null); const fileInputRef = useRef(null); const isComposingRef = useRef(false); const lastCompositionEndAtRef = useRef(0); const slashCommandsRequestedRef = useRef(false); const slashItemRefs = useRef>([]); const atItemRefs = useRef>([]); const historyItemRefs = useRef>([]); const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>( null, ); const fileIndexFetchingRef = useRef(null); const draftKeyRef = useRef(draftKey); const valueRef = useRef(value); const attachedImagesRef = useRef(attachedImages); const pendingImageCountRef = useRef(0); valueRef.current = value; attachedImagesRef.current = attachedImages; useImperativeHandle(ref, () => ({ insertIfEmpty(text: string) { const ta = textareaRef.current; const current = ta ? ta.value : value; if (current.trim()) return; setValue(text); setAtQuery(null); requestAnimationFrame(() => { if (!ta) return; ta.focus(); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, prependText(text: string) { if (!text.trim()) return; const ta = textareaRef.current; const current = ta ? ta.value : value; // Mirrors the TUI's queue restore: queued text first, then whatever // the user already typed, separated by a blank line. const combined = [text, current].filter((t) => t.trim()).join("\n\n"); setValue(combined); setAtQuery(null); requestAnimationFrame(() => { if (!ta) return; ta.focus(); ta.setSelectionRange(combined.length, combined.length); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, insertText(text: string) { const ta = textareaRef.current; if (!ta) { setValue((v) => v + (v ? " " : "") + text); return; } const start = ta.selectionStart ?? ta.value.length; const end = ta.selectionEnd ?? ta.value.length; const before = ta.value.slice(0, start); const after = ta.value.slice(end); const sep = before.length > 0 && !before.endsWith(" ") ? " " : ""; const newVal = before + sep + text + after; setValue(newVal); setAtQuery(null); requestAnimationFrame(() => { if (!ta) return; const pos = start + sep.length + text.length; ta.setSelectionRange(pos, pos); ta.focus(); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, addImages(files: File[]) { processImageFiles(files); }, })); const processImageFiles = useCallback( async (files: File[]) => { if (isStreaming) return; const remaining = Math.max( 0, MAX_ATTACHED_IMAGES - attachedImagesRef.current.length - pendingImageCountRef.current, ); const imageFiles = files .filter( (f) => f.type.startsWith("image/") && f.size <= MAX_ATTACHED_IMAGE_BYTES, ) .slice(0, remaining); if (!imageFiles.length) return; pendingImageCountRef.current += imageFiles.length; try { const newImages = await Promise.all( imageFiles.map(async (file) => { // 大 JPEG 先压缩(长边 1600/q0.85),会话不再内嵌数 MB base64,加载/传输/转录都更快 const compressed = await compressImageFile(file); const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve((reader.result as string).split(",")[1]); reader.onerror = reject; reader.readAsDataURL(compressed); }); return { data: base64, mimeType: compressed.type, previewUrl: URL.createObjectURL(compressed), }; }), ); setAttachedImages((prev) => { const accepted = newImages.slice( 0, Math.max(0, MAX_ATTACHED_IMAGES - prev.length), ); newImages.slice(accepted.length).forEach(revokeImagePreview); return [...prev, ...accepted]; }); } finally { pendingImageCountRef.current -= imageFiles.length; } }, [isStreaming], ); const removeImage = useCallback((index: number) => { setAttachedImages((prev) => { const next = [...prev]; const [removed] = next.splice(index, 1); if (removed) revokeImagePreview(removed); return next; }); }, []); const clearImages = useCallback(() => { setAttachedImages((prev) => { prev.forEach(revokeImagePreview); return []; }); }, []); const clearInput = useCallback(() => { setValue(""); setAtQuery(null); setHistoryMenuOpen(false); if (draftKey) clearDraft(draftKey); if (draftKeyRef.current && draftKeyRef.current !== draftKey) clearDraft(draftKeyRef.current); clearImages(); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } }, [clearImages, draftKey]); useEffect(() => { if (!draftKey || draftKeyRef.current !== draftKey) return; setDraft(draftKey, { value, images: attachedImages.map(imageToDraftImage), }); }, [attachedImages, draftKey, value]); useEffect(() => { const previousDraftKey = draftKeyRef.current; if (previousDraftKey === draftKey) return; if (previousDraftKey) { setDraft(previousDraftKey, { value: valueRef.current, images: attachedImagesRef.current.map(imageToDraftImage), }); } const draft = draftKey ? getDraft(draftKey) : null; draftKeyRef.current = draftKey; setValue(draft?.value ?? ""); setAtQuery(null); setHistoryMenuOpen(false); setAttachedImages((prev) => { prev.forEach(revokeImagePreview); return draftImagesToAttachedImages(draft?.images); }); }, [draftKey]); useEffect(() => { const ta = textareaRef.current; if (!ta) return; ta.style.height = "auto"; if (value) ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, [value]); useEffect(() => { return () => { attachedImagesRef.current.forEach(revokeImagePreview); }; }, []); const handleSend = useCallback(async () => { const msg = value.trim(); if (!msg && !attachedImages.length) return; if (isStreaming) return; onAudioUnlock?.(); if (!attachedImages.length && msg.startsWith("/") && onBuiltinCommand) { const result = await onBuiltinCommand(msg); if (result.handled) { if (!result.error) clearInput(); return; } } onSend(msg, attachedImages.length ? attachedImages : undefined); clearInput(); }, [ value, attachedImages, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock, ]); const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1)) ? value.slice(1).toLowerCase() : null; const filteredSlashCommands = (() => { if (slashQuery === null) return []; const commands = [ ...(isStreaming ? [] : BUILTIN_SLASH_COMMANDS), ...(slashCommands ?? []), ]; return [...commands] .filter((command) => { const name = command.name.toLowerCase(); const description = getSlashDescription(command, t).toLowerCase(); return name.includes(slashQuery) || description.includes(slashQuery); }) .sort((a, b) => { const rankDelta = slashMatchRank(a, slashQuery, t) - slashMatchRank(b, slashQuery, t); if (rankDelta !== 0) return rankDelta; return ( SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] || MODEL_OPTION_COLLATOR.compare(a.name, b.name) ); }); })(); const groupedSlashCommands = (() => { const groups = new Map< SlashCommandSource, { source: SlashCommandSource; items: { command: SlashCommandPaletteItem; index: number }[]; } >(); for (const source of SLASH_SOURCES) { groups.set(source, { source, items: [] }); } filteredSlashCommands.forEach((command, index) => { groups.get(command.source)?.items.push({ command, index }); }); return SLASH_SOURCES.map((source) => groups.get(source)!).filter( (group) => group.items.length > 0, ); })(); const slashCommandCountLabel = filteredSlashCommands.length === 1 ? t(slashQuery ? "chat.match" : "chat.command") : t(slashQuery ? "chat.matches" : "chat.commands", { count: filteredSlashCommands.length, }); const hasInputText = Boolean(value.trim()); const canQueueStreamingMessage = hasInputText && attachedImages.length === 0; // ── @ file autocomplete ────────────────────────────────────────────────── // Recomputed from the text before the caret on every change/caret move. // Disabled entirely when there is no cwd (new session without a directory). const updateAtQuery = useCallback( (text: string, cursor: number | null) => { if (!cwd) { setAtQuery(null); return; } const pos = cursor ?? text.length; setAtQuery(extractAtQuery(text.slice(0, pos))); }, [cwd], ); const atQueryText = atQuery?.query ?? null; const atLocalMatches: FileIndexEntry[] = React.useMemo( () => atQueryText !== null && fileIndex && fileIndex.cwd === cwd ? filterFileEntries(fileIndex.entries, atQueryText) : [], [atQueryText, fileIndex, cwd], ); // When the client index is truncated (repo larger than the index cap), // local filtering cannot see deep files, so queries are also ranked // server-side against the full listing. Local matches render immediately // and are replaced when the (debounced) server result for the current // query arrives; stale responses are ignored via the query/cwd tag. const needsServerSearch = Boolean( atQueryText && fileIndex?.truncated && fileIndex.cwd === cwd, ); useEffect(() => { if (!needsServerSearch || !cwd || !atQueryText) return; const fetchCwd = cwd; const query = atQueryText; const timer = setTimeout(() => { fetch( `/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`, ) .then((res) => { if (!res.ok) throw new Error(`file search failed: ${res.status}`); return res.json() as Promise<{ matches?: FileIndexEntry[] }>; }) .then((data) => setAtServerResult({ cwd: fetchCwd, query, matches: data.matches ?? [], }), ) .catch(() => { // Keep showing local matches; the next keystroke retries. }); }, 150); return () => clearTimeout(timer); }, [needsServerSearch, atQueryText, cwd]); const serverResultInUse = needsServerSearch && atServerResult !== null && atServerResult.cwd === cwd && atServerResult.query === atQueryText; const atMatches: FileIndexEntry[] = serverResultInUse ? atServerResult.matches : atLocalMatches; // Open/reset the menu whenever the @token appears or changes (mirrors the // slash menu: Escape closes it, the next keystroke re-opens it). const atTokenKey = atQuery === null ? null : `${atQuery.start}:${atQuery.quoted ? 1 : 0}:${atQuery.query}`; useEffect(() => { if (atTokenKey === null) { setAtMenuOpen(false); setAtActiveIndex(0); return; } setAtMenuOpen(true); setAtActiveIndex(0); }, [atTokenKey]); // Fetch the file index when the menu opens. The server caches per cwd for // ~10s, so re-opening refreshes cheaply; while typing nothing refetches. const atTokenActive = atQuery !== null; useEffect(() => { if (!atTokenActive || !cwd) return; const meta = fileIndexMetaRef.current; if (meta && meta.cwd === cwd && Date.now() - meta.fetchedAt < 10_000) return; if (fileIndexFetchingRef.current === cwd) return; fileIndexFetchingRef.current = cwd; const fetchCwd = cwd; setFileIndexLoading(true); fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`) .then((res) => { if (!res.ok) throw new Error(`file index failed: ${res.status}`); return res.json() as Promise<{ files?: string[]; truncated?: boolean }>; }) .then((data) => { setFileIndex({ cwd: fetchCwd, entries: buildEntriesFromFiles(data.files ?? []), truncated: !!data.truncated, }); fileIndexMetaRef.current = { cwd: fetchCwd, fetchedAt: Date.now() }; }) .catch(() => { // Leave any previous index in place; next open retries. fileIndexMetaRef.current = null; }) .finally(() => { fileIndexFetchingRef.current = null; setFileIndexLoading(false); }); }, [atTokenActive, cwd]); const applyAtCompletion = useCallback( (entry: FileIndexEntry) => { if (!atQuery) return; const ta = textareaRef.current; const cursor = ta?.selectionStart ?? value.length; const before = value.slice(0, atQuery.start); let after = value.slice(cursor); // Completing inside a quoted token (@"my dir/… with the caret before the // closing quote): the replacement carries its own closing quote, so drop // the old one right after the caret (mirrors the TUI's applyCompletion). if (atQuery.quoted && after.startsWith('"')) { after = after.slice(1); } const insert = buildAtInsertText(entry.path, entry.isDir, atQuery.quoted); const newValue = before + insert.text + after; const newPos = before.length + insert.cursorOffset; setValue(newValue); // setValue alone does not fire onChange — re-derive the token here. Files // end with a space (token closes, menu hides); directories end with "/" // before the caret (token stays open for drill-down into the directory). setAtQuery(extractAtQuery(newValue.slice(0, newPos))); requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; el.focus(); el.setSelectionRange(newPos, newPos); el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 200)}px`; }); }, [atQuery, value], ); useEffect(() => { if (atActiveIndex >= atMatches.length) { setAtActiveIndex(Math.max(0, atMatches.length - 1)); } }, [atMatches.length, atActiveIndex]); useEffect(() => { atItemRefs.current.length = atMatches.length; }, [atMatches.length]); useEffect(() => { if (!atMenuOpen) return; atItemRefs.current[atActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest", }); }, [atActiveIndex, atMenuOpen]); useEffect(() => { if (historyActiveIndex >= inputHistory.length) { setHistoryActiveIndex(Math.max(0, inputHistory.length - 1)); } }, [inputHistory.length, historyActiveIndex]); useEffect(() => { historyItemRefs.current.length = inputHistory.length; }, [inputHistory.length]); useEffect(() => { if (!historyMenuOpen) return; historyItemRefs.current[historyActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest", }); }, [historyActiveIndex, historyMenuOpen]); const applyHistoryInput = useCallback((text: string) => { setValue(text); setHistoryMenuOpen(false); setHistoryActiveIndex(0); setAtQuery(null); requestAnimationFrame(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); ta.setSelectionRange(text.length, text.length); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, []); const applySlashCommand = useCallback((command: SlashCommandPaletteItem) => { const nextValue = `/${command.name} `; setValue(nextValue); setSlashMenuOpen(false); setSlashActiveIndex(0); requestAnimationFrame(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); ta.setSelectionRange(nextValue.length, nextValue.length); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, []); const sendQueued = useCallback( (mode: "steer" | "followup") => { const msg = value.trim(); if (!msg && !attachedImages.length) return; if (attachedImages.length) return; onAudioUnlock?.(); const streamingBehavior = mode === "steer" ? "steer" : "followUp"; if (msg.startsWith("/") && onPromptWithStreamingBehavior) { onPromptWithStreamingBehavior( msg, streamingBehavior, attachedImages.length ? attachedImages : undefined, ); clearInput(); return; } if (mode === "steer" && onSteer) { onSteer(msg, attachedImages.length ? attachedImages : undefined); } else if (mode === "followup" && onFollowUp) { onFollowUp(msg, attachedImages.length ? attachedImages : undefined); } clearInput(); }, [ value, attachedImages, onPromptWithStreamingBehavior, onSteer, onFollowUp, clearInput, onAudioUnlock, ], ); const getNextSlashIndex = useCallback( (direction: "up" | "down" | "left" | "right") => { const lastIndex = filteredSlashCommands.length - 1; if (lastIndex < 0) return 0; if (direction === "left") return Math.max(0, slashActiveIndex - 1); if (direction === "right") return Math.min(lastIndex, slashActiveIndex + 1); const currentNode = slashItemRefs.current[slashActiveIndex]; if (!currentNode) { return direction === "down" ? Math.min(lastIndex, slashActiveIndex + 1) : Math.max(0, slashActiveIndex - 1); } const currentRect = currentNode.getBoundingClientRect(); const currentX = currentRect.left + currentRect.width / 2; const currentY = currentRect.top + currentRect.height / 2; let bestIndex = -1; let bestScore = Number.POSITIVE_INFINITY; for (let index = 0; index <= lastIndex; index += 1) { if (index === slashActiveIndex) continue; const node = slashItemRefs.current[index]; if (!node) continue; const rect = node.getBoundingClientRect(); const candidateY = rect.top + rect.height / 2; const verticalDelta = candidateY - currentY; if (direction === "down" ? verticalDelta <= 4 : verticalDelta >= -4) continue; const candidateX = rect.left + rect.width / 2; const score = Math.abs(verticalDelta) * 1000 + Math.abs(candidateX - currentX); if (score < bestScore) { bestIndex = index; bestScore = score; } } if (bestIndex >= 0) return bestIndex; return direction === "down" ? Math.min(lastIndex, slashActiveIndex + 1) : Math.max(0, slashActiveIndex - 1); }, [filteredSlashCommands.length, slashActiveIndex], ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { const nativeEvent = e.nativeEvent; const recentlyComposed = Date.now() - lastCompositionEndAtRef.current < COMPOSITION_END_ENTER_GRACE_MS; const isComposing = isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229; if ( e.key === "Enter" && !e.shiftKey && (isComposing || recentlyComposed) ) { if (recentlyComposed) e.preventDefault(); return; } if (historyMenuOpen && !isComposing) { if (e.key === "ArrowDown") { e.preventDefault(); setHistoryActiveIndex((i) => Math.min(Math.max(0, inputHistory.length - 1), i + 1), ); return; } if (e.key === "ArrowUp") { e.preventDefault(); setHistoryActiveIndex((i) => Math.max(0, i - 1)); return; } if (e.key === "Escape") { e.preventDefault(); setHistoryMenuOpen(false); return; } if ( (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && inputHistory[historyActiveIndex] ) { e.preventDefault(); applyHistoryInput(inputHistory[historyActiveIndex]); return; } } if (slashMenuOpen && slashQuery !== null) { if (e.key === "ArrowDown") { e.preventDefault(); setSlashActiveIndex(getNextSlashIndex("down")); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSlashActiveIndex(getNextSlashIndex("up")); return; } if (e.key === "ArrowRight") { e.preventDefault(); setSlashActiveIndex(getNextSlashIndex("right")); return; } if (e.key === "ArrowLeft") { e.preventDefault(); setSlashActiveIndex(getNextSlashIndex("left")); return; } if (e.key === "Escape") { e.preventDefault(); setSlashMenuOpen(false); return; } if ( (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && filteredSlashCommands[slashActiveIndex] ) { e.preventDefault(); applySlashCommand(filteredSlashCommands[slashActiveIndex]); return; } } // @ file menu — skip while composing so IME candidate navigation // (arrows/Enter/Tab) is never intercepted. if (atMenuOpen && atQuery !== null && !isComposing) { if (e.key === "ArrowDown") { e.preventDefault(); setAtActiveIndex((i) => Math.min(Math.max(0, atMatches.length - 1), i + 1), ); return; } if (e.key === "ArrowUp") { e.preventDefault(); setAtActiveIndex((i) => Math.max(0, i - 1)); return; } if (e.key === "Escape") { e.preventDefault(); setAtMenuOpen(false); return; } if ( (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && atMatches[atActiveIndex] ) { e.preventDefault(); applyAtCompletion(atMatches[atActiveIndex]); return; } } if ( e.key === "ArrowUp" && !isComposing && !isStreaming && inputHistory.length > 0 && value.trim().length === 0 ) { e.preventDefault(); setSlashMenuOpen(false); setAtMenuOpen(false); setHistoryActiveIndex(inputHistory.length - 1); setHistoryMenuOpen(true); return; } // Esc stops the agent when no slash/@/history menu or IME composition is active. if (e.key === "Escape" && !isComposing && isStreaming && onAbort) { e.preventDefault(); onAbort(); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (isStreaming && (onSteer || onFollowUp)) { // Default Enter sends as steer if available, else followup sendQueued(onSteer ? "steer" : "followup"); } else { handleSend(); } } }, [ isStreaming, onSteer, onFollowUp, onAbort, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value, ], ); const handleInput = useCallback(() => { const ta = textareaRef.current; if (!ta) return; ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, []); const handlePaste = useCallback( (e: React.ClipboardEvent) => { const items = Array.from(e.clipboardData?.items ?? []); const imageItems = items.filter((item) => item.type.startsWith("image/")); if (!imageItems.length) return; e.preventDefault(); const files = imageItems .map((item) => item.getAsFile()) .filter((f): f is File => f !== null); processImageFiles(files); }, [processImageFiles], ); useEffect(() => { if (slashQuery === null) { setSlashMenuOpen(false); setSlashActiveIndex(0); slashCommandsRequestedRef.current = false; return; } setSlashMenuOpen(true); setSlashActiveIndex(0); if (!slashCommandsRequestedRef.current && onLoadSlashCommands) { slashCommandsRequestedRef.current = true; Promise.resolve(onLoadSlashCommands()).catch(() => { slashCommandsRequestedRef.current = false; }); } }, [slashQuery, onLoadSlashCommands]); useEffect(() => { if (slashActiveIndex >= filteredSlashCommands.length) { setSlashActiveIndex(Math.max(0, filteredSlashCommands.length - 1)); } }, [filteredSlashCommands.length, slashActiveIndex]); useEffect(() => { slashItemRefs.current.length = filteredSlashCommands.length; }, [filteredSlashCommands.length]); useEffect(() => { if (!slashMenuOpen) return; slashItemRefs.current[slashActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest", }); }, [slashActiveIndex, slashMenuOpen]); // Build model options: prefer modelList (has provider info), fallback to modelNames const modelOptions: ModelOption[] = (() => { if (modelList && modelList.length > 0) { return modelList .map((m) => ({ provider: m.provider, modelId: m.id, name: m.name, serviceTier: m.serviceTier, })) .sort(compareModelOptions); } return Object.entries(modelNames ?? {}) .map(([modelId, name]) => ({ provider: model?.provider ?? "unknown", modelId, name, })) .sort(compareModelOptions); })(); const filteredModelOptions = filterModelOptions(modelOptions, modelFilter); const showModelFilter = modelOptions.length > MODEL_FILTER_THRESHOLD; // Group options by provider, preserving insertion order const modelsByProvider: { provider: string; options: ModelOption[] }[] = []; for (const opt of filteredModelOptions) { const group = modelsByProvider.find((g) => g.provider === opt.provider); if (group) group.options.push(opt); else modelsByProvider.push({ provider: opt.provider, options: [opt] }); } const displayModelName = model ? (modelOptions.find( (o) => o.modelId === model.modelId && o.provider === model.provider, )?.name ?? model.modelId) : null; const currentName = displayModelName; const compactSavedTokens = compactResult ? Math.max( 0, compactResult.tokensBefore - compactResult.estimatedTokensAfter, ) : 0; const compactResultText = compactResult ? `${compactResult.reason && compactResult.reason !== "manual" ? `${compactResult.reason[0].toUpperCase()}${compactResult.reason.slice(1)} ` : t("chat.compacted")} ${formatTokenCount(compactResult.tokensBefore)} -> ${formatTokenCount(compactResult.estimatedTokensAfter)} tokens (${t("chat.tokensSaved", { saved: formatTokenCount(compactSavedTokens) })})` : null; const thinkingDisplayLabel = (() => { const lvl = thinkingLevel ?? "auto"; if (lvl === "auto" || !thinkingLevelMap) return lvl; return thinkingLevelMap[lvl] ?? lvl; })(); const toolPresetLabel = Object.entries(TOOL_PRESET_MAP).find( ([, v]) => v === (toolPreset ?? "default"), )?.[0] ?? "default"; // Codex fast mode: derived from the selected model's configured serviceTier, // unless the user has flipped the toggle this session (fastModeOverride). const currentModelOption = model ? modelOptions.find( (o) => o.provider === model.provider && o.modelId === model.modelId, ) : undefined; const fastModeActive = fastModeOverride ?? currentModelOption?.serviceTier === "priority"; const showFastModeToggle = model?.provider === "openai-codex" && !!currentModelOption; // Reset the local override when the selected model changes. useEffect(() => { setFastModeOverride(null); }, [model?.provider, model?.modelId]); const toggleFastMode = useCallback(async () => { if (!model || !onModelChange) return; const next = !fastModeActive; setFastModeOverride(next); try { const res = await fetch("/api/models-config"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const cfg = (await res.json()) as { providers?: Record>; }; const providers = cfg.providers ?? {}; const codex = providers["openai-codex"] ?? {}; const overrides = (codex.modelOverrides ?? {}) as Record< string, Record >; const modelOverride = { ...(overrides[model.modelId] ?? {}) }; if (next) modelOverride.serviceTier = "priority"; else delete modelOverride.serviceTier; if (Object.keys(modelOverride).length === 0) delete overrides[model.modelId]; else overrides[model.modelId] = modelOverride; codex.modelOverrides = overrides; providers["openai-codex"] = codex; const put = await fetch("/api/models-config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...cfg, providers }), }); if (!put.ok) throw new Error(`HTTP ${put.status}`); // Re-select the current model so the session re-resolves it with the new serviceTier. await onModelChange(model.provider, model.modelId); } catch (e) { setFastModeOverride(null); console.error("Failed to toggle fast mode:", e); } }, [fastModeActive, model, onModelChange]); // Close dropdowns on outside click useEffect(() => { const handler = (e: MouseEvent) => { if ( dropdownRef.current && !dropdownRef.current.contains(e.target as Node) && modelDropdownPanelRef.current && !modelDropdownPanelRef.current.contains(e.target as Node) ) { setModelDropdownOpen(false); setModelFilter(""); } if ( toolDropdownRef.current && !toolDropdownRef.current.contains(e.target as Node) ) { setToolDropdownOpen(false); } if ( thinkingDropdownRef.current && !thinkingDropdownRef.current.contains(e.target as Node) ) { setThinkingDropdownOpen(false); } if ( controlsMenuRef.current && !controlsMenuRef.current.contains(e.target as Node) ) { setControlsMenuOpen(false); } if ( historyMenuRef.current && !historyMenuRef.current.contains(e.target as Node) && !textareaRef.current?.contains(e.target as Node) ) { setHistoryMenuOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); useEffect(() => { if (!isMobile) setControlsMenuOpen(false); }, [isMobile]); return (
{/* Hidden file input */} { const files = Array.from(e.target.files ?? []); processImageFiles(files); e.target.value = ""; }} />
{/* Queued steering / follow-up messages (delivered by pi on upcoming turns) */} {(queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0) > 0 && (
{t("chat.queued", { count: (queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0), })} {onRecallQueue && ( )}
{queuedMessages?.steering.map((text, i) => ( ))} {queuedMessages?.followUp.map((text, i) => ( ))}
)} {/* Retry banner */} {retryInfo && (
{t("chat.retrying", { attempt: retryInfo.attempt, max: retryInfo.maxAttempts, })} {retryInfo.errorMessage && ( — {retryInfo.errorMessage} )}
)} {compactResultText && (
{compactResultText}
)} {compactError && (
{compactError}
)} {/* Image previews */} {attachedImages.length > 0 && (
{attachedImages.map((img, i) => (
{/* eslint-disable-next-line @next/next/no-img-element */}
))}
)} {/* Main input */}
{historyMenuOpen && inputHistory.length > 0 && (
{inputHistory.map((item, index) => { const active = index === historyActiveIndex; return ( ); })}
)} {slashMenuOpen && slashQuery !== null && (
{slashCommandsLoading ? t("chat.loadingCommands") : t("chat.slashCommands", { label: slashCommandCountLabel, })} {t("chat.tabEnter")}
{!slashCommandsLoading && filteredSlashCommands.length === 0 ? (
{t("chat.noCommands")}
) : ( groupedSlashCommands.map((group) => (
{t(SLASH_SOURCE_GROUP_LABEL_KEYS[group.source])} {group.items.length}
{group.items.map(({ command, index }) => { const active = index === slashActiveIndex; return ( ); })}
)) )}
)} {atMenuOpen && atQuery !== null && (() => { const indexLoading = fileIndexLoading && (!fileIndex || fileIndex.cwd !== cwd); const matchCountLabel = atMatches.length === 1 ? t("chat.match") : t("chat.matches", { count: atMatches.length }); // With a truncated index, local results are provisional — the // debounced server search over the full listing replaces them. const truncatedHint = fileIndex?.truncated && !serverResultInUse ? atQuery.query ? t("chat.searchingAll") : t("chat.indexTruncated") : ""; return (
{indexLoading ? t("chat.loadingFiles") : t("chat.files", { label: matchCountLabel, hint: truncatedHint, })} {t("chat.tabEnter")}
{!indexLoading && atMatches.length === 0 ? (
{needsServerSearch && !serverResultInUse ? t("chat.searching") : t("chat.noMatchingFiles")}
) : ( atMatches.map((entry, index) => { const active = index === atActiveIndex; const name = entry.path.split("/").pop() ?? entry.path; const dirPrefix = entry.path.slice( 0, entry.path.length - name.length, ); return ( ); }) )}
); })()}