"use client"; import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; import type { SkillsResponse } from "@/lib/api-types"; import type { TextContent, UserMessage } from "@/lib/types"; import { clearDraft, getDraft, mergeRestoredSubmissionDraft, mergeRestoredSubmissionText, rekeyDraft as rekeyStoredDraft, setDraft, type ChatDraftImage, } from "@/lib/draft-store"; import { MAX_ATTACHED_IMAGE_BYTES, MAX_ATTACHED_IMAGES, 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"; import type { ToolPreset } from "@/lib/tool-presets"; export interface AttachedImage { data: string; // base64, no prefix mimeType: string; previewUrl: string; // object URL for display } interface ModelOption { provider: string; modelId: string; name: 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 }[]; modelError?: string | null; /** Diagnostics from resolving `enabledModels`, e.g. a pattern that matched nothing. */ modelScopeWarnings?: string[]; onModelChange?: (provider: string, modelId: string) => void; modelSwitching?: boolean; onCompact?: () => void; onAbortCompaction?: () => void; isCompacting?: boolean; compactError?: string | null; compactResult?: CompactResultInfo | null; toolPreset?: ToolPreset; onToolPresetChange?: (preset: ToolPreset) => 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; replaceMessage: (message: UserMessage) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; rekeyDraft: (previousKey: string, nextKey: string) => void; restoreSubmission: (text: string, images?: ChatDraftImage[], targetDraftKey?: string) => void; } const TOOL_PRESETS = ["off", "read-only", "default", "full"] as const; type ToolPresetLabel = typeof TOOL_PRESETS[number]; const TOOL_PRESET_MAP: Record = { off: "none", "read-only": "read-only", 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" }); const ANCHORED_MENU_GAP = 8; export function getUpwardMenuMaxHeight(menuBottom: number, visibleTop: number, gap = ANCHORED_MENU_GAP): number { return Math.max(0, Math.floor(menuBottom - visibleTop - gap)); } function getVisibleTopBoundary(element: HTMLElement): number { let visibleTop = window.visualViewport?.offsetTop ?? 0; for (let parent = element.parentElement; parent; parent = parent.parentElement) { const overflowY = window.getComputedStyle(parent).overflowY; if (overflowY === "auto" || overflowY === "scroll" || overflowY === "hidden" || overflowY === "clip") { visibleTop = Math.max(visibleTop, parent.getBoundingClientRect().top + parent.clientTop); } } return visibleTop; } 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 = { 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 ?? ""; } // Skill slash commands are named "skill:"; look the skill up in the // dormancy map fetched from /api/skills. Unknown skills are treated as active. function isDormantSkillCommand(command: SlashCommandPaletteItem, dormancy: Record): boolean { if (command.source !== "skill" || !command.name.startsWith("skill:")) return false; return dormancy[command.name.slice("skill:".length)] === true; } export function buildSlashCommandLayout( commands: SlashCommandPaletteItem[], dormancy: Record, ) { let index = 0; const groups = SLASH_SOURCES .map((source) => { const sourceCommands = commands.filter((command) => command.source === source); const orderedCommands = source === "skill" ? [ ...sourceCommands.filter((command) => !isDormantSkillCommand(command, dormancy)), ...sourceCommands.filter((command) => isDormantSkillCommand(command, dormancy)), ] : sourceCommands; return { source, items: orderedCommands.map((command) => ({ command, index: index++ })), }; }) .filter((group) => group.items.length > 0); return { commands: groups.flatMap((group) => group.items.map(({ command }) => command)), groups, }; } 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); } export function canRestoreUserMessage( value: string, attachedImageCount: number, pendingImageCount: number, ): boolean { return !value.trim() && attachedImageCount === 0 && pendingImageCount === 0; } export function getUserMessageText(message: UserMessage): string { if (typeof message.content === "string") return message.content; return message.content .filter((block): block is TextContent => block.type === "text") .map((block) => block.text) .join("\n"); } export function getUserMessageDraftImages(message: UserMessage): ChatDraftImage[] { if (typeof message.content === "string") return []; return message.content.flatMap((block) => { if (block.type !== "image") return []; // Support both the current nested image format and older flat pi-ai entries. const flat = block as unknown as { data?: unknown; mimeType?: unknown }; const data = block.source?.type === "base64" ? block.source.data : flat.data; const mimeType = block.source?.type === "base64" ? block.source.media_type : flat.mimeType; if (typeof data !== "string" || typeof mimeType !== "string") return []; const image = { data, mimeType }; return isBase64ImageWithinLimits(image) ? [image] : []; }); } 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}
); } function ModelNoticeBanner({ tone, title, body }: { tone: "error" | "warning"; title: string; body: string }) { const color = tone === "error" ? "239,68,68" : "234,179,8"; return (
{title}
{body}
); } export function ModelErrorBanner({ error }: { error?: string | null }) { if (!error) return null; return ; } /** Surfaces `enabledModels` patterns that matched nothing, so a typo is visible (#307). */ export function ModelScopeWarningBanner({ warnings }: { warnings?: string[] }) { if (!warnings || warnings.length === 0) return null; return ( 1 ? "Model scope warnings" : "Model scope warning"} body={warnings.join("\n")} /> ); } export const ChatInput = forwardRef(function ChatInput({ onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelScopeWarnings, onModelChange, modelSwitching, 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(""); 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 [slashMenuMaxHeight, setSlashMenuMaxHeight] = useState(null); 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 [skillDormancyState, setSkillDormancyState] = useState<{ cwd: string; values: Record; } | null>(null); const skillDormancy = cwd && skillDormancyState?.cwd === cwd ? skillDormancyState.values : {}; 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 slashMenuRef = useRef(null); 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; valueRef.current = text; setValue(text); setAtQuery(null); requestAnimationFrame(() => { if (!ta) return; ta.focus(); ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, replaceMessage(message: UserMessage) { const ta = textareaRef.current; const current = ta ? ta.value : value; if (!canRestoreUserMessage(current, attachedImagesRef.current.length, pendingImageCountRef.current)) return; const restoredText = getUserMessageText(message); const restoredImages = draftImagesToAttachedImages(getUserMessageDraftImages(message)); valueRef.current = restoredText; attachedImagesRef.current = restoredImages; setValue(restoredText); setAtQuery(null); setHistoryMenuOpen(false); setAttachedImages((prev) => { prev.forEach(revokeImagePreview); return restoredImages; }); 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"); valueRef.current = combined; 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`; }); }, rekeyDraft(previousKey: string, nextKey: string) { if (previousKey === nextKey) return; if (draftKeyRef.current !== previousKey) { rekeyStoredDraft(previousKey, nextKey); return; } const currentDraft = { value: valueRef.current, images: attachedImagesRef.current.map(imageToDraftImage), }; const moved = rekeyStoredDraft(previousKey, nextKey, currentDraft) ?? { value: "", images: [] }; const unchanged = moved.value === currentDraft.value && moved.images.length === currentDraft.images.length && moved.images.every((image, index) => ( image.data === currentDraft.images[index]?.data && image.mimeType === currentDraft.images[index]?.mimeType )); draftKeyRef.current = nextKey; if (unchanged) return; const movedImages = draftImagesToAttachedImages(moved.images); valueRef.current = moved.value; attachedImagesRef.current = movedImages; setValue(moved.value); setAttachedImages((current) => { current.forEach(revokeImagePreview); return movedImages; }); setAtQuery(null); setHistoryMenuOpen(false); }, restoreSubmission(text: string, images?: ChatDraftImage[], targetDraftKey?: string) { if (!text.trim() && !images?.length) return; // clearInput is queued before the submission handler runs. Compose with // that queued state so a fast rejection cannot observe stale DOM text and // then get overwritten by the clear. const currentDraftKey = draftKeyRef.current; const destinationDraftKey = targetDraftKey ?? currentDraftKey; const targetsCurrentComposer = destinationDraftKey === currentDraftKey; const storedDraft = !targetsCurrentComposer && destinationDraftKey ? getDraft(destinationDraftKey) : null; const restoredDraft = mergeRestoredSubmissionDraft( text, images, targetsCurrentComposer ? valueRef.current : (storedDraft?.value ?? ""), targetsCurrentComposer ? attachedImagesRef.current.map(imageToDraftImage) : (storedDraft?.images ?? []), ); // The first optimistic message switches ChatWindow out of its empty-state // layout and remounts this component. Persist synchronously so recovery is // not lost if this instance is the one being unmounted. if (destinationDraftKey) setDraft(destinationDraftKey, restoredDraft); if (!targetsCurrentComposer) return; const restoredImages = images?.length ? [ ...draftImagesToAttachedImages(images).slice( 0, Math.max(0, MAX_ATTACHED_IMAGES - attachedImagesRef.current.length), ), ...attachedImagesRef.current, ].slice(0, MAX_ATTACHED_IMAGES) : attachedImagesRef.current; // Session promotion can rekey this composer before React flushes the // functional updates below, so update the imperative snapshot first. valueRef.current = restoredDraft.value; attachedImagesRef.current = restoredImages; setValue((current) => { const restored = mergeRestoredSubmissionText(text, current); valueRef.current = restored; return restored; }); setAtQuery(null); setHistoryMenuOpen(false); if (images?.length) { setAttachedImages((current) => { const available = Math.max(0, MAX_ATTACHED_IMAGES - current.length); const restored = draftImagesToAttachedImages(images) .slice(0, available); const next = restored.length > 0 ? [...restored, ...current] : current; attachedImagesRef.current = next; return next; }); } requestAnimationFrame(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.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; valueRef.current = newVal; 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[]) => { 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( (file) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // result is "data:;base64," const base64 = result.split(",")[1]; resolve({ data: base64, mimeType: file.type, previewUrl: URL.createObjectURL(file) }); }; reader.onerror = reject; reader.readAsDataURL(file); }) ) ); setAttachedImages((prev) => { const accepted = newImages.slice(0, Math.max(0, MAX_ATTACHED_IMAGES - prev.length)); newImages.slice(accepted.length).forEach(revokeImagePreview); const next = [...prev, ...accepted]; attachedImagesRef.current = next; return next; }); } finally { pendingImageCountRef.current -= imageFiles.length; } }, []); const removeImage = useCallback((index: number) => { setAttachedImages((prev) => { const next = [...prev]; const [removed] = next.splice(index, 1); if (removed) revokeImagePreview(removed); attachedImagesRef.current = next; return next; }); }, []); const clearImages = useCallback(() => { attachedImagesRef.current = []; setAttachedImages((prev) => { prev.forEach(revokeImagePreview); return []; }); }, []); const clearInput = useCallback(() => { valueRef.current = ""; 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; const nextValue = draft?.value ?? ""; const nextImages = draftImagesToAttachedImages(draft?.images); valueRef.current = nextValue; attachedImagesRef.current = nextImages; setValue(nextValue); setAtQuery(null); setHistoryMenuOpen(false); setAttachedImages((prev) => { prev.forEach(revokeImagePreview); return nextImages; }); }, [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; } } clearInput(); onSend(msg, attachedImages.length ? attachedImages : undefined); }, [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 { commands: displayedSlashCommands, groups: groupedSlashCommands, } = buildSlashCommandLayout(filteredSlashCommands, skillDormancy); 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; onAudioUnlock?.(); const streamingBehavior = mode === "steer" ? "steer" : "followUp"; if (msg.startsWith("/") && onPromptWithStreamingBehavior) { clearInput(); onPromptWithStreamingBehavior(msg, streamingBehavior, attachedImages.length ? attachedImages : undefined); return; } clearInput(); if (mode === "steer" && onSteer) { onSteer(msg, attachedImages.length ? attachedImages : undefined); } else if (mode === "followup" && onFollowUp) { onFollowUp(msg, attachedImages.length ? attachedImages : undefined); } }, [value, attachedImages, onPromptWithStreamingBehavior, onSteer, onFollowUp, clearInput, onAudioUnlock]); const getNextSlashIndex = useCallback((direction: "up" | "down" | "left" | "right") => { const lastIndex = displayedSlashCommands.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); }, [displayedSlashCommands.length, slashActiveIndex]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { const nativeEvent = e.nativeEvent; const sendShortcut = e.key === "Enter" && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey); const recentlyComposed = Date.now() - lastCompositionEndAtRef.current < COMPOSITION_END_ENTER_GRACE_MS; const isComposing = isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229; if (sendShortcut && (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" || sendShortcut) && 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" || sendShortcut) && displayedSlashCommands[slashActiveIndex]) { e.preventDefault(); applySlashCommand(displayedSlashCommands[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" || sendShortcut) && 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 (sendShortcut) { e.preventDefault(); if (isStreaming && (onSteer || onFollowUp)) { // Default Enter sends as steer if available, else followup sendQueued(onSteer ? "steer" : "followup"); } else { handleSend(); } } }, [isMobile, isStreaming, onSteer, onFollowUp, onAbort, slashMenuOpen, slashQuery, displayedSlashCommands, 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]); // Lazy-load skill dormancy (disable-model-invocation) each time the slash // palette opens, so toggles made in the skills panel are reflected on the // next open. Failures degrade silently to the unannotated palette. useEffect(() => { if (!slashMenuOpen || !cwd) return; const requestCwd = cwd; let cancelled = false; setSkillDormancyState({ cwd: requestCwd, values: {} }); fetch(`/api/skills?cwd=${encodeURIComponent(requestCwd)}`) .then((res) => { if (!res.ok) throw new Error(`skills fetch failed: ${res.status}`); return res.json() as Promise>; }) .then((data) => { if (cancelled) return; const dormancy: Record = {}; for (const skill of data.skills ?? []) dormancy[skill.name] = skill.disableModelInvocation; setSkillDormancyState({ cwd: requestCwd, values: dormancy }); }) .catch(() => { if (!cancelled) setSkillDormancyState({ cwd: requestCwd, values: {} }); }); return () => { cancelled = true; }; }, [slashMenuOpen, cwd]); useEffect(() => { if (slashActiveIndex >= displayedSlashCommands.length) { setSlashActiveIndex(Math.max(0, displayedSlashCommands.length - 1)); } }, [displayedSlashCommands.length, slashActiveIndex]); useEffect(() => { slashItemRefs.current.length = displayedSlashCommands.length; }, [displayedSlashCommands.length]); useEffect(() => { if (!slashMenuOpen) return; slashItemRefs.current[slashActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [slashActiveIndex, slashMenuOpen]); useLayoutEffect(() => { if (!slashMenuOpen || slashQuery === null) { setSlashMenuMaxHeight(null); return; } const menu = slashMenuRef.current; if (!menu) return; let frameId: number | null = null; const update = () => { frameId = null; const nextHeight = getUpwardMenuMaxHeight( menu.getBoundingClientRect().bottom, getVisibleTopBoundary(menu), ); setSlashMenuMaxHeight((current) => current === nextHeight ? current : nextHeight); }; const scheduleUpdate = () => { if (frameId !== null) cancelAnimationFrame(frameId); frameId = requestAnimationFrame(update); }; update(); const anchorObserver = typeof ResizeObserver === "undefined" || !menu.parentElement ? null : new ResizeObserver(scheduleUpdate); if (menu.parentElement) anchorObserver?.observe(menu.parentElement); const viewport = window.visualViewport; viewport?.addEventListener("resize", scheduleUpdate); viewport?.addEventListener("scroll", scheduleUpdate); window.addEventListener("resize", scheduleUpdate); window.addEventListener("scroll", scheduleUpdate, true); return () => { anchorObserver?.disconnect(); viewport?.removeEventListener("resize", scheduleUpdate); viewport?.removeEventListener("scroll", scheduleUpdate); window.removeEventListener("resize", scheduleUpdate); window.removeEventListener("scroll", scheduleUpdate, true); if (frameId !== null) cancelAnimationFrame(frameId); }; }, [slashMenuOpen, slashQuery]); // 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 })).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"; // 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; const dormant = isDormantSkillCommand(command, skillDormancy); 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 ( ); }) )}
); })()}