Files
Netcatty/components/ai/ChatInput.tsx
T
2026-06-26 00:26:30 +08:00

1085 lines
49 KiB
TypeScript

/**
* ChatInput - Zed-style bottom input area for the AI chat panel
*
* Thin wrapper around the AI Elements prompt-input components.
* Bordered textarea with monospace placeholder, expand toggle,
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { filterQuickMessages, buildSlashCommandItems, filterUserSkillsForSlash, getSlashCommandItemKey, isSystemStopSlashCommand, type AIQuickMessage, type SlashCommandItem, type UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
import { SlashCommandPicker } from './SlashCommandPicker';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
// Slightly wider for the provider picker so the per-row default-model
// caption doesn't truncate.
const PROVIDER_PICKER_MAX_WIDTH = 320;
/**
* Provider picker payload used by Catty Agent. When set, the model chip
* switches to a flat provider list (provider icon + name + the provider's
* configured default model as caption) in place of the generic Cpu glyph
* + model-preset dropdown. Each provider exposes a single model — its
* `defaultModel` — so a two-level menu would be empty noise; picking a
* provider implicitly picks its model.
*/
export interface ProviderSwitcherConfig {
/** Every configured provider — Settings-level visibility, not the
* `enabled` toggle, since the user expects to swap between everything
* they've set up. */
providers: ProviderConfig[];
/** Currently bound provider id (falls back to providers[0] when missing). */
selectedProviderId?: string;
/** Currently bound model id under the selected provider. */
selectedModelId?: string;
/** Fires when the user picks a (providerId, modelId) pair. */
onSelect: (providerId: string, modelId: string) => void;
}
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
providerName?: string;
modelName?: string;
agentName?: string;
placeholder?: string;
/** Available model presets for the current agent */
modelPresets?: AgentModelPreset[];
/** Currently selected model ID */
selectedModelId?: string;
/** Callback when user selects a model */
onModelSelect?: (modelId: string) => void;
/** Attached files (images, PDFs, etc.) */
files?: UploadedFile[];
/** Callback to add files (paste/drop) */
onAddFiles?: (files: File[]) => void;
/** Callback to remove a file */
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Custom slash prompts configured in Settings → AI */
quickMessages?: AIQuickMessage[];
/** Callback to add a selected user skill */
onAddUserSkill?: (slug: string) => void;
/** Callback to remove a selected user skill */
onRemoveUserSkill?: (slug: string) => void;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
onPermissionModeChange?: (mode: AIPermissionMode) => void;
/**
* Provider→model two-level picker payload. When provided, replaces the
* single-list model dropdown with a provider-aware picker. Used for the
* Catty Agent only — external SDK agents (Claude/Codex) keep the
* `modelPresets` dropdown because their provider is wired inside the CLI.
*/
providerSwitcher?: ProviderSwitcherConfig;
}
const ChatInput: React.FC<ChatInputProps> = ({
value,
onChange,
onSend,
onStop,
isStreaming = false,
disabled = false,
providerName,
modelName,
agentName,
placeholder,
modelPresets = [],
selectedModelId,
onModelSelect,
files = [],
onAddFiles,
onRemoveFile,
hosts = [],
selectedUserSkills = [],
userSkills = [],
quickMessages = [],
onAddUserSkill,
onRemoveUserSkill,
permissionMode,
onPermissionModeChange,
providerSwitcher,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashCommandPicker = activeMenu === 'slashCommand';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setInputPanelPos(null);
setHoveredModelId(null);
setSlashQuery('');
setSlashRange(null);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputShellRef = useRef<HTMLDivElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const slashPickerListRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
const beforeCaret = text.slice(0, caretPosition);
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
if (!match) return null;
const start = beforeCaret.length - match[0].length + match[1].length;
return {
start,
end: beforeCaret.length,
query: String(match[2] || '').toLowerCase(),
};
}, []);
const getInputPanelMenuPos = useCallback(() => {
const rect = inputShellRef.current?.getBoundingClientRect();
if (!rect) return null;
const horizontalMargin = 12;
const safeRight = window.innerWidth - horizontalMargin;
const width = Math.min(rect.width, safeRight - rect.left);
return {
left: rect.left,
bottom: window.innerHeight - rect.top + 8,
width,
};
}, []);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
// Detect if user just typed @
if (
hosts.length > 0 &&
newValue.length > value.length &&
newValue.endsWith('@')
) {
// Position the popover near the textarea
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setActiveMenu('atMention');
return;
}
const slashTrigger = findSlashTrigger(newValue, caretPosition);
if (slashTrigger) {
const pos = getInputPanelMenuPos();
if (pos) {
setMenuPos(null);
setInputPanelPos(pos);
}
setSlashQuery(slashTrigger.query);
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
setActiveMenu('slashCommand');
return;
}
if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
} else if (showSlashCommandPicker) {
closeAllMenus();
}
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, showSlashCommandPicker, closeAllMenus, getInputPanelMenuPos]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
const name = host.label || host.hostname;
const lastAt = value.lastIndexOf('@');
const newValue = lastAt >= 0
? value.slice(0, lastAt) + `@${name} `
: value + `@${name} `;
onChange(newValue);
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setMenuPos(null);
setInputPanelPos(pos);
if (menu === 'slashCommand') {
const caret = textareaRef.current?.selectionStart ?? value.length;
const trigger = findSlashTrigger(value, caret);
if (trigger) {
setSlashQuery(trigger.query);
setSlashRange({ start: trigger.start, end: trigger.end });
} else {
setSlashQuery('');
setSlashRange(null);
}
}
setActiveMenu(menu);
}, [findSlashTrigger, getInputPanelMenuPos, value]);
const userSkillOptions = useMemo<UserSkillSlashOption[]>(
() => userSkills.map((skill) => ({
id: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
})),
[userSkills],
);
const quickMessageSlugSet = useMemo(
() => new Set(quickMessages.map((message) => message.slug)),
[quickMessages],
);
const filteredQuickMessages = useMemo(
() => filterQuickMessages(quickMessages, slashQuery),
[quickMessages, slashQuery],
);
const filteredUserSkills = useMemo(
() => filterUserSkillsForSlash(userSkillOptions, slashQuery)
.filter((skill) => !quickMessageSlugSet.has(skill.slug)),
[userSkillOptions, slashQuery, quickMessageSlugSet],
);
const slashCommandItems = useMemo(
() => buildSlashCommandItems(quickMessages, userSkillOptions, slashQuery),
[quickMessages, userSkillOptions, slashQuery],
);
const isSlashCatalogEmpty = quickMessages.length === 0 && userSkills.length === 0;
const slashPickerNoResultsLabel = isSlashCatalogEmpty
? t('ai.chat.slashEmptyHint')
: t('ai.chat.slashNoResults');
const slashPickerListboxId = menuPos ? 'slash-command-toolbar' : 'slash-command-input';
const showSlashPickerUI = showSlashCommandPicker && (inputPanelPos != null || menuPos != null);
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
if (/\s$/.test(before) && /^\s/.test(after)) {
return `${before}${after.slice(1)}`;
}
return `${before}${after}`;
}, [slashRange, value]);
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
onAddUserSkill?.(skill.slug);
if (slashRange) {
onChange(removeSlashQueryFromInput());
}
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
const insertQuickMessage = useCallback((message: AIQuickMessage) => {
if (slashRange) {
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
const spacerBefore = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
const spacerAfter = after.length > 0 && !/^\s/.test(after) ? ' ' : '';
onChange(`${before}${spacerBefore}${message.content}${spacerAfter}${after}`);
} else {
const spacer = value.length > 0 && !/\s$/.test(value) ? ' ' : '';
onChange(`${value}${spacer}${message.content}`);
}
closeAllMenus();
}, [closeAllMenus, onChange, slashRange, value]);
const handleSelectSlashCommandItem = useCallback((item: SlashCommandItem) => {
if (item.kind === 'quickMessage') {
insertQuickMessage(item.message);
return;
}
insertUserSkillToken(item.skill);
}, [insertQuickMessage, insertUserSkillToken]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
// filter produces a different set with the same count (e.g. user types
// another character into the slash query) — Enter would then commit an
// unexpected item. Derive a stable key from the visible ids instead.
const atMentionKey = useMemo(
() => hosts.map((h) => h.sessionId).join('|'),
[hosts],
);
const slashCommandKey = useMemo(
() => slashCommandItems.map(getSlashCommandItemKey).join('|'),
[slashCommandItems],
);
useEffect(() => {
if (showAtMention) setActiveMenuIndex(0);
}, [showAtMention, atMentionKey]);
useEffect(() => {
if (showSlashCommandPicker) setActiveMenuIndex(0);
}, [showSlashCommandPicker, slashCommandKey]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
slashPickerListRef.current?.focus();
}, [showSlashCommandPicker, menuPos, slashCommandKey, slashCommandItems.length]);
const handleSlashCommandKeyDown = useCallback((e: KeyboardEvent | React.KeyboardEvent) => {
if ('nativeEvent' in e && e.nativeEvent.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
if (e.key === 'Enter') {
if ('shiftKey' in e && e.shiftKey) {
return;
}
if (slashCommandItems.length > 0) {
e.preventDefault();
const item = slashCommandItems[Math.min(activeMenuIndex, slashCommandItems.length - 1)];
if (item) handleSelectSlashCommandItem(item);
return;
}
// Mid-slash token with no matches: block accidental send of "/query" text.
if (slashRange) {
e.preventDefault();
}
return;
}
if (slashCommandItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % slashCommandItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + slashCommandItems.length) % slashCommandItems.length);
return;
}
}, [activeMenuIndex, closeAllMenus, handleSelectSlashCommandItem, slashCommandItems, slashRange]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos) return;
const onKeyDown = (event: KeyboardEvent) => handleSlashCommandKeyDown(event);
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [handleSlashCommandKeyDown, menuPos, showSlashCommandPicker]);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
// @ mention popover keyboard navigation
if (showAtMention && hosts.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % hosts.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
if (host) handleSelectAtMention(host);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
}
// / command popover keyboard navigation (input-anchored picker)
if (showSlashCommandPicker && !menuPos) {
handleSlashCommandKeyDown(e);
return;
}
}, [showAtMention, hosts, showSlashCommandPicker, menuPos, activeMenuIndex, handleSelectAtMention, handleSlashCommandKeyDown, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item: DataTransferItem) => item.getAsFile())
.filter((f): f is File => !!f);
if (pastedFiles.length > 0) {
e.preventDefault();
onAddFiles?.(pastedFiles);
}
}, [onAddFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
onAddFiles?.(droppedFiles);
}
}, [onAddFiles]);
const defaultPlaceholder = agentName
? t('ai.chat.placeholder').replace('{agent}', agentName)
: t('ai.chat.placeholderDefault');
const handleSubmit = useCallback(
(_text: string, _event: FormEvent<HTMLFormElement>) => {
if (isSystemStopSlashCommand(value)) {
onStop?.();
onChange('');
return;
}
onSend();
},
[onSend, onStop, onChange, value],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
// Permission mode chip removed — agents run in auto mode
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
// split on the first '/'. Match against the full id first; only treat the
// trailing segment as a thinking level when we find a preset whose
// declared thinkingLevels make the combined form equal to selectedModelId.
const { selectedPreset, selectedThinking } = (() => {
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
const direct = modelPresets.find(m => m.id === selectedModelId);
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
const viaThinking = modelPresets.find(
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
);
if (viaThinking) {
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
return { selectedPreset: viaThinking, selectedThinking: thinking };
}
return { selectedPreset: undefined, selectedThinking: undefined };
})();
const selectedBaseModelId = selectedPreset?.id;
// Provider switcher mode (Catty Agent): two-column popover, chip carries
// the provider's icon + name + model name. Falls back to the existing
// single-list model dropdown for external SDK agents.
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
// Resolve to the actually-bound provider only — no `?? providers[0]`
// fallback, since a provider that isn't really bound will still hit the
// `!sendActiveProvider` guard at send time. Faking a selection in the
// chip would lie about a state the rest of the system treats as empty.
const selectedSwitcherProvider = hasProviderSwitcher
? providerSwitcher!.providers.find((p) => p.id === providerSwitcher!.selectedProviderId)
: undefined;
const providerSwitcherChipLabel = hasProviderSwitcher
? (selectedSwitcherProvider
? (providerSwitcher!.selectedModelId
? `${selectedSwitcherProvider.name} · ${providerSwitcher!.selectedModelId}`
: selectedSwitcherProvider.name)
: t('ai.chat.selectProvider'))
: '';
const modelLabel = hasProviderSwitcher
? providerSwitcherChipLabel
: (selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel'));
const modelChipMaxWidth = hasProviderSwitcher
? 'max-w-[180px]'
: (selectedThinking ? 'max-w-[148px]' : 'max-w-[82px]');
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
const iconButtonClassName =
'h-6 w-6 shrink-0 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<div ref={inputShellRef} className="relative">
<PromptInput onSubmit={handleSubmit}>
{/* File attachment chips */}
{files.length > 0 && (
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
{files.map((file) => (
<div
key={file.id}
className={[
"inline-flex items-center gap-1 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group",
file.terminalSelection ? "h-6 max-w-[260px]" : "h-6",
].join(" ")}
>
{file.terminalSelection ? (
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
) : file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
)}
{file.terminalSelection ? (
<span className="min-w-0">
<span className="block truncate max-w-[210px] text-foreground/82">
{t('ai.chat.terminalSelectionAttachment')}
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
</span>
</span>
) : (
<span className="truncate max-w-[80px]">{file.filename}</span>
)}
<button
type="button"
onClick={() => onRemoveFile?.(file.id)}
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer shrink-0"
>
<X size={8} />
</button>
</div>
))}
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onAddFiles?.(Array.from(e.target.files));
e.target.value = '';
}
}}
/>
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{selectedUserSkills.length > 0 && (
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<Tooltip key={skill.id}>
<TooltipTrigger asChild>
<div
className={selectedSkillChipClassName}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
</Tooltip>
))}
</div>
</div>
)}
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleTextareaKeyDown}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled}
className={[
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
expanded ? 'max-h-[220px]' : undefined,
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
>
<Expand size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
</Tooltip>
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{hosts.map((host, idx) => {
const isActive = idx === activeMenuIndex;
const showHostnameLine = host.label
&& host.hostname !== host.label
&& !host.label.includes(host.hostname);
return (
<button
id={`at-mention-${host.sessionId}`}
key={host.sessionId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectAtMention(host)}
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
{showHostnameLine ? (
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
</div>
) : null}
</button>
);
})}
</div>
</ScrollArea>
</div>
</>,
document.body,
)}
{/* / command popover */}
{showSlashPickerUI && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<SlashCommandPicker
listRef={slashPickerListRef}
listboxId={slashPickerListboxId}
ariaLabel={t('ai.chat.slashCommands')}
quickMessages={filteredQuickMessages}
userSkills={filteredUserSkills}
slashCommandItems={slashCommandItems}
activeMenuIndex={activeMenuIndex}
onActiveIndexChange={setActiveMenuIndex}
onSelectQuickMessage={insertQuickMessage}
onSelectSkill={insertUserSkillToken}
quickMessagesSectionLabel={t('ai.chat.slashQuickMessages')}
userSkillsSectionLabel={t('ai.chat.slashUserSkills')}
noResultsLabel={slashPickerNoResultsLabel}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg outline-none"
style={
menuPos
? {
left: menuPos.left,
bottom: menuPos.bottom,
minWidth: 220,
maxWidth: 360,
}
: {
left: inputPanelPos!.left,
bottom: inputPanelPos!.bottom,
width: 'auto',
minWidth: Math.min(200, inputPanelPos!.width),
maxWidth: inputPanelPos!.width,
}
}
/>
</>,
document.body,
)}
{/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputTools className="gap-1 min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
aria-label={t('ai.chat.attach')}
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
</Tooltip>
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="menu"
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuContext')}</div>
<button
type="button"
role="menuitem"
onClick={() => { fileInputRef.current?.setAttribute('accept', '*/*'); fileInputRef.current?.click(); closeAllMenus(); }}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<FileText size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuFiles')}</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); closeAllMenus(); }}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<button
type="button"
role="menuitem"
aria-label="Mention host"
onClick={() => openInputPanelMenu('atMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
<button
type="button"
role="menuitem"
aria-label={t('ai.chat.slashCommands')}
onClick={() => openInputPanelMenu('slashCommand')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<MessageSquare size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuSlashCommands')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
</div>
</>,
document.body,
)}
<button
ref={modelBtnRef}
type="button"
onClick={() => {
if (!hasModelPicker) return;
if (!showModelPicker) {
const rect = modelBtnRef.current?.getBoundingClientRect();
if (rect) {
// Clamp so the popover stays inside the viewport when
// the chip is near the right edge of a narrow AI side
// panel.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMaxWidth - 8));
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
if (selectedPreset?.thinkingLevels?.length) {
setHoveredModelId(selectedPreset.id);
}
setActiveMenu('model');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} min-w-0 ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
aria-expanded={showModelPicker}
>
{hasProviderSwitcher && selectedSwitcherProvider ? (
<ProviderIconBadge provider={selectedSwitcherProvider} size="xs" />
) : (
<Cpu size={11} className="text-muted-foreground/64" />
)}
<span className={`truncate min-w-0 ${modelChipMaxWidth}`}>{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{showModelPicker && hasModelPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: popoverMaxWidth }}
onMouseLeave={() => setHoveredModelId(null)}
>
{hasProviderSwitcher ? (
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
{providerSwitcher!.providers.map((p) => {
const isSelected = providerSwitcher!.selectedProviderId === p.id;
const defaultModel = p.defaultModel?.trim() ?? '';
const hasModel = defaultModel.length > 0;
// Rows without a defaultModel are inert — picking
// one would save a binding with an empty model id
// and produce a confusing model error at send time.
// User has to set a defaultModel in Settings first.
const disabled = !hasModel;
const modelCaption = hasModel
? defaultModel
: t('ai.chat.noProviderModel');
return (
<button
key={p.id}
type="button"
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
disabled={disabled}
title={disabled ? t('ai.chat.noProviderModel') : undefined}
onClick={() => {
if (disabled) return;
providerSwitcher!.onSelect(p.id, defaultModel);
closeAllMenus();
}}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 text-left transition-colors ${
disabled
? 'opacity-55 cursor-not-allowed'
: 'hover:bg-muted/30 cursor-pointer'
}`}
>
<ProviderIconBadge provider={p} size="md" />
<div className="flex-1 min-w-0">
<div className="truncate text-[12px] text-foreground/85">{p.name}</div>
<div className={`truncate text-[10.5px] ${hasModel ? 'text-muted-foreground/70 font-mono' : 'text-muted-foreground/55 italic'}`}>
{modelCaption}
</div>
</div>
{isSelected && <Check size={12} className="text-primary shrink-0" />}
</button>
);
})}
</div>
) : (
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
{modelPresets.map(preset => {
const isSelected = preset.id === selectedBaseModelId;
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
const showThinkingLevels = hasThinking && hoveredModelId === preset.id;
return (
<div
key={preset.id}
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
>
<button
type="button"
role="option"
aria-selected={isSelected}
aria-expanded={hasThinking ? showThinkingLevels : undefined}
onClick={() => {
if (!hasThinking) {
onModelSelect?.(preset.id);
closeAllMenus();
return;
}
setHoveredModelId(showThinkingLevels ? null : preset.id);
}}
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
{hasThinking && (
<ChevronRight
size={10}
className={`text-muted-foreground/50 shrink-0 transition-transform ${showThinkingLevels ? 'rotate-90' : ''}`}
/>
)}
</button>
{/* Inline thinking levels — flyout submenus get clipped by overflow-y-auto above. */}
{showThinkingLevels && (
<div role="listbox" aria-label="Thinking level" className="border-t border-border/30 bg-muted/10 py-0.5">
{preset.thinkingLevels!.map(level => {
const fullId = `${preset.id}/${level}`;
const isLevelSelected = selectedModelId === fullId;
return (
<button
key={level}
type="button"
role="option"
aria-selected={isLevelSelected}
tabIndex={0}
onClick={() => {
onModelSelect?.(fullId);
closeAllMenus();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onModelSelect?.(fullId);
closeAllMenus();
} else if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 pl-7 pr-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</>,
document.body,
)}
{/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} shrink-0 cursor-pointer hover:bg-muted/24 transition-colors`}
aria-label={t('ai.safety.permissionMode')}
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'auto' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'auto' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
</Tooltip>
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Permission mode"
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{([
{ mode: 'auto' as const, icon: Zap, color: 'text-green-400/70', label: t('ai.chat.permAuto'), desc: t('ai.chat.permAutoDesc') },
{ mode: 'confirm' as const, icon: ShieldCheck, color: 'text-yellow-400/70', label: t('ai.chat.permConfirm'), desc: t('ai.chat.permConfirmDesc') },
{ mode: 'observer' as const, icon: Eye, color: 'text-blue-400/70', label: t('ai.chat.permObserver'), desc: t('ai.chat.permObserverDesc') },
]).map(({ mode, icon: Icon, color, label, desc }) => (
<button
key={mode}
type="button"
role="option"
aria-selected={permissionMode === mode}
onClick={() => {
onPermissionModeChange(mode);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{permissionMode === mode
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />
}
<Icon size={12} className={`${color} shrink-0`} />
<div className="flex-1 min-w-0">
<div className="text-foreground/85">{label}</div>
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
</div>
</button>
))}
</div>
</>,
document.body,
)}
</>
)}
</PromptInputTools>
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1">
<PromptInputSubmit
status={status}
onStop={onStop}
disabled={(!value.trim() && !hasTerminalSelectionAttachment) || disabled}
/>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};
export default React.memo(ChatInput);