feat(ai): add quick messages with slash command picker (#691)
Closes #691
This commit is contained in:
@@ -127,6 +127,29 @@ export const enAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Quick Messages',
|
||||
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
|
||||
'ai.quickMessages.add': 'Add Quick Message',
|
||||
'ai.quickMessages.createTitle': 'New Quick Message',
|
||||
'ai.quickMessages.editTitle': 'Edit Quick Message',
|
||||
'ai.quickMessages.name': 'Name',
|
||||
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
|
||||
'ai.quickMessages.slug': 'Command',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Description (optional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
|
||||
'ai.quickMessages.content': 'Message content',
|
||||
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
|
||||
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
|
||||
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'Name is required.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
|
||||
'ai.quickMessages.error.contentRequired': 'Message content is required.',
|
||||
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
|
||||
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
@@ -185,6 +208,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
'ai.chat.slashQuickMessages': 'Quick messages',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Slash commands',
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
@@ -127,6 +127,29 @@ export const ruAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Быстрые сообщения',
|
||||
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
|
||||
'ai.quickMessages.add': 'Добавить быстрое сообщение',
|
||||
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
|
||||
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
|
||||
'ai.quickMessages.name': 'Название',
|
||||
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
|
||||
'ai.quickMessages.slug': 'Команда',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
|
||||
'ai.quickMessages.content': 'Текст сообщения',
|
||||
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
|
||||
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
|
||||
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
|
||||
'ai.quickMessages.error.nameRequired': 'Укажите название.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
|
||||
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
|
||||
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
|
||||
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
@@ -185,6 +208,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские skills',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Команды /',
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
@@ -127,6 +127,29 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': '快捷消息',
|
||||
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
|
||||
'ai.quickMessages.add': '添加快捷消息',
|
||||
'ai.quickMessages.createTitle': '新建快捷消息',
|
||||
'ai.quickMessages.editTitle': '编辑快捷消息',
|
||||
'ai.quickMessages.name': '名称',
|
||||
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
|
||||
'ai.quickMessages.slug': '命令',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': '说明(可选)',
|
||||
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
|
||||
'ai.quickMessages.content': '消息内容',
|
||||
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
|
||||
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
|
||||
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
|
||||
'ai.quickMessages.error.nameRequired': '请填写名称。',
|
||||
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
|
||||
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
|
||||
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
|
||||
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
@@ -185,6 +208,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
'ai.chat.slashQuickMessages': '快捷消息',
|
||||
'ai.chat.slashUserSkills': '用户 Skills',
|
||||
'ai.chat.quickMessages': '快捷命令',
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
import type {
|
||||
AIDraft,
|
||||
AISession,
|
||||
@@ -160,6 +163,11 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
// ── Quick Messages (slash prompts) ──
|
||||
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
||||
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
@@ -275,6 +283,16 @@ export function useAIState() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
|
||||
setQuickMessagesRaw((prev) => {
|
||||
const nextRaw = typeof value === 'function' ? value(prev) : value;
|
||||
const next = sanitizeQuickMessages(nextRaw);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
@@ -466,6 +484,11 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
case STORAGE_KEY_AI_QUICK_MESSAGES: {
|
||||
const messages = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
setQuickMessagesRaw(sanitizeQuickMessages(messages));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
@@ -999,6 +1022,8 @@ export function useAIState() {
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
@@ -1054,6 +1079,8 @@ export function useAIState() {
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export function subscribeWindowFullscreenChanged(
|
||||
cb: (isFullscreen: boolean) => void,
|
||||
): () => void {
|
||||
try {
|
||||
return netcattyBridge.get()?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useWindowControls = () => {
|
||||
const notifyRendererReady = useCallback(() => {
|
||||
try {
|
||||
@@ -45,10 +55,7 @@ export const useWindowControls = () => {
|
||||
return bridge?.windowIsFullscreen?.() ?? false;
|
||||
}, []);
|
||||
|
||||
const onFullscreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
|
||||
|
||||
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -244,6 +246,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -448,6 +451,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
@@ -582,6 +587,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ai.quickMessages != null) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
@@ -622,6 +630,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
@@ -65,6 +66,7 @@ interface AIChatPanelContentProps {
|
||||
terminalSessions: TerminalSessionSummary[];
|
||||
selectedUserSkills: UserSkillOption[];
|
||||
userSkillOptions: UserSkillOption[];
|
||||
quickMessages: AIQuickMessage[];
|
||||
addSelectedUserSkill: (slug: string) => void;
|
||||
removeSelectedUserSkill: (slug: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
@@ -112,6 +114,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
terminalSessions,
|
||||
selectedUserSkills,
|
||||
userSkillOptions,
|
||||
quickMessages,
|
||||
addSelectedUserSkill,
|
||||
removeSelectedUserSkill,
|
||||
globalPermissionMode,
|
||||
@@ -263,6 +266,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
|
||||
@@ -126,6 +126,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
quickMessages = [],
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
@@ -991,6 +992,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
@@ -1037,6 +1039,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'commandBlocklist',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
'quickMessages',
|
||||
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
|
||||
|
||||
function aiChatSidePanelPropsAreEqual(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -72,6 +73,9 @@ export interface AIChatSidePanelProps {
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Quick messages (slash prompts)
|
||||
quickMessages?: AIQuickMessage[];
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
|
||||
@@ -155,6 +155,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
setQuickMessages={aiState.setQuickMessages}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
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, 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';
|
||||
@@ -81,6 +83,8 @@ interface ChatInputProps {
|
||||
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 */
|
||||
@@ -118,6 +122,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
hosts = [],
|
||||
selectedUserSkills = [],
|
||||
userSkills = [],
|
||||
quickMessages = [],
|
||||
onAddUserSkill,
|
||||
onRemoveUserSkill,
|
||||
permissionMode,
|
||||
@@ -128,7 +133,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
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' | 'slashSkill' | 'perm' | null;
|
||||
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);
|
||||
@@ -142,7 +147,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showSlashSkillPicker = activeMenu === 'slashSkill';
|
||||
const showSlashCommandPicker = activeMenu === 'slashCommand';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
@@ -158,6 +163,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const quickMsgBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const slashPickerListRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
|
||||
@@ -202,21 +209,24 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
|
||||
const slashTrigger = findSlashTrigger(newValue, caretPosition);
|
||||
if (userSkills.length > 0 && slashTrigger) {
|
||||
if (slashTrigger) {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (pos) setInputPanelPos(pos);
|
||||
if (pos) {
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(pos);
|
||||
}
|
||||
setSlashQuery(slashTrigger.query);
|
||||
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
|
||||
setActiveMenu('slashSkill');
|
||||
setActiveMenu('slashCommand');
|
||||
return;
|
||||
}
|
||||
|
||||
if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
} else if (showSlashSkillPicker) {
|
||||
} else if (showSlashCommandPicker) {
|
||||
closeAllMenus();
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, showSlashCommandPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
@@ -229,22 +239,84 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (!pos) return;
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(pos);
|
||||
if (menu === 'slashSkill') {
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
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);
|
||||
}, [getInputPanelMenuPos]);
|
||||
}, [findSlashTrigger, getInputPanelMenuPos, value]);
|
||||
|
||||
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
|
||||
if (!slashQuery) return true;
|
||||
const lowerQuery = slashQuery.toLowerCase();
|
||||
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
|
||||
}), [userSkills, slashQuery]);
|
||||
const openSlashCommandPicker = useCallback((anchor?: 'toolbar') => {
|
||||
if (anchor === 'toolbar') {
|
||||
const rect = quickMsgBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setInputPanelPos(null);
|
||||
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('slashCommand');
|
||||
return;
|
||||
}
|
||||
openInputPanelMenu('slashCommand');
|
||||
}, [findSlashTrigger, openInputPanelMenu, 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;
|
||||
@@ -264,6 +336,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
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
|
||||
@@ -273,16 +367,64 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
() => hosts.map((h) => h.sessionId).join('|'),
|
||||
[hosts],
|
||||
);
|
||||
const slashSkillKey = useMemo(
|
||||
() => filteredUserSkills.map((s) => s.id).join('|'),
|
||||
[filteredUserSkills],
|
||||
const slashCommandKey = useMemo(
|
||||
() => slashCommandItems.map(getSlashCommandItemKey).join('|'),
|
||||
[slashCommandItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showAtMention) setActiveMenuIndex(0);
|
||||
}, [showAtMention, atMentionKey]);
|
||||
useEffect(() => {
|
||||
if (showSlashSkillPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashSkillPicker, slashSkillKey]);
|
||||
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;
|
||||
@@ -310,31 +452,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// / skill popover keyboard navigation
|
||||
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
|
||||
if (skill) insertUserSkillToken(skill);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
// / command popover keyboard navigation (input-anchored picker)
|
||||
if (showSlashCommandPicker && !menuPos) {
|
||||
handleSlashCommandKeyDown(e);
|
||||
return;
|
||||
}
|
||||
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
|
||||
}, [showAtMention, hosts, showSlashCommandPicker, menuPos, activeMenuIndex, handleSelectAtMention, handleSlashCommandKeyDown, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
@@ -585,48 +708,42 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* / skill popover */}
|
||||
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
|
||||
{/* / command popover */}
|
||||
{showSlashPickerUI && 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="Insert user skill"
|
||||
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : 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">
|
||||
{filteredUserSkills.map((skill, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`slash-skill-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
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]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<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,
|
||||
)}
|
||||
@@ -696,19 +813,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{userSkills.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Insert user skill"
|
||||
onClick={() => openInputPanelMenu('slashSkill')}
|
||||
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"
|
||||
>
|
||||
<Package size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
|
||||
<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,
|
||||
@@ -953,6 +1068,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={quickMsgBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showSlashCommandPicker) {
|
||||
openSlashCommandPicker('toolbar');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
iconButtonClassName,
|
||||
isSlashCatalogEmpty ? 'opacity-45 hover:opacity-80' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-label={t('ai.chat.slashCommands')}
|
||||
aria-expanded={showSlashCommandPicker}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.slashCommands')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
|
||||
146
components/ai/SlashCommandPicker.tsx
Normal file
146
components/ai/SlashCommandPicker.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { MessageSquare, Package } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type { AIQuickMessage, SlashCommandItem, UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
|
||||
import { getSlashCommandItemId } from '../../infrastructure/ai/quickMessages';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface SlashCommandPickerProps {
|
||||
listboxId: string;
|
||||
ariaLabel: string;
|
||||
quickMessages: AIQuickMessage[];
|
||||
userSkills: UserSkillSlashOption[];
|
||||
slashCommandItems: SlashCommandItem[];
|
||||
activeMenuIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onSelectQuickMessage: (message: AIQuickMessage) => void;
|
||||
onSelectSkill: (skill: UserSkillSlashOption) => void;
|
||||
quickMessagesSectionLabel: string;
|
||||
userSkillsSectionLabel: string;
|
||||
noResultsLabel: string;
|
||||
emptyHintLabel?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
listRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
|
||||
listboxId,
|
||||
ariaLabel,
|
||||
quickMessages,
|
||||
userSkills,
|
||||
slashCommandItems,
|
||||
activeMenuIndex,
|
||||
onActiveIndexChange,
|
||||
onSelectQuickMessage,
|
||||
onSelectSkill,
|
||||
quickMessagesSectionLabel,
|
||||
userSkillsSectionLabel,
|
||||
noResultsLabel,
|
||||
emptyHintLabel,
|
||||
className,
|
||||
style,
|
||||
listRef,
|
||||
}) => {
|
||||
const activeItem = slashCommandItems[activeMenuIndex];
|
||||
const activeDescendantId = activeItem ? `${listboxId}-${getSlashCommandItemId(activeItem)}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={ariaLabel}
|
||||
aria-activedescendant={activeDescendantId}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{slashCommandItems.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center space-y-1">
|
||||
<p className="text-[12px] text-muted-foreground/70">{noResultsLabel}</p>
|
||||
{emptyHintLabel ? (
|
||||
<p className="text-[11px] text-muted-foreground/45 leading-relaxed">{emptyHintLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{quickMessages.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{quickMessagesSectionLabel}
|
||||
</div>
|
||||
{quickMessages.map((message) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'quickMessage' && item.message.id === message.id,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${message.id}`}
|
||||
key={message.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectQuickMessage(message)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] min-w-0">
|
||||
<MessageSquare size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90 truncate">{message.name}</span>
|
||||
<span className="text-muted-foreground/45 font-mono shrink-0">/{message.slug}</span>
|
||||
</div>
|
||||
{(message.description || message.content) ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{message.description || message.content}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{userSkills.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{userSkillsSectionLabel}
|
||||
</div>
|
||||
{userSkills.map((skill) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'skill' && item.skill.id === skill.id,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -43,6 +43,8 @@ import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
import { QuickMessagesSettings } from "./ai/QuickMessagesSettings";
|
||||
import type { AIQuickMessage } from "../../../infrastructure/ai/quickMessages";
|
||||
import {
|
||||
areExternalAgentListsEqual,
|
||||
buildManagedAgentState,
|
||||
@@ -79,6 +81,8 @@ interface SettingsAITabProps {
|
||||
setMaxIterations: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,6 +114,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
@@ -436,6 +442,15 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
};
|
||||
}, [refreshUserSkillsStatus]);
|
||||
|
||||
const reservedUserSkillSlugs = useMemo(
|
||||
() => (userSkillsStatus?.ok && userSkillsStatus.skills
|
||||
? userSkillsStatus.skills
|
||||
.filter((skill) => skill.status === 'ready' && typeof skill.slug === 'string' && skill.slug.length > 0)
|
||||
.map((skill) => skill.slug)
|
||||
: []),
|
||||
[userSkillsStatus],
|
||||
);
|
||||
|
||||
const handleOpenUserSkillsFolder = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiUserSkillsOpenFolder) return;
|
||||
@@ -698,6 +713,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<QuickMessagesSettings
|
||||
quickMessages={quickMessages}
|
||||
setQuickMessages={setQuickMessages}
|
||||
reservedUserSkillSlugs={reservedUserSkillSlugs}
|
||||
/>
|
||||
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
setWebSearchConfig={setWebSearchConfig}
|
||||
|
||||
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
|
||||
import {
|
||||
createQuickMessageId,
|
||||
isValidQuickMessageSlug,
|
||||
normalizeQuickMessageSlug,
|
||||
QUICK_MESSAGE_LIMITS,
|
||||
slugFromQuickMessageName,
|
||||
} from "../../../../infrastructure/ai/quickMessages";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingsSection } from "../../settings-ui";
|
||||
|
||||
interface QuickMessagesSettingsProps {
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
reservedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
type DraftQuickMessage = {
|
||||
name: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const emptyDraft = (): DraftQuickMessage => ({
|
||||
name: "",
|
||||
slug: "",
|
||||
content: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
reservedUserSkillSlugs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sortedMessages = useMemo(
|
||||
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[quickMessages],
|
||||
);
|
||||
|
||||
const resetEditor = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(false);
|
||||
setDraft(emptyDraft());
|
||||
setSlugTouched(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const beginCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(true);
|
||||
setDraft(emptyDraft());
|
||||
setSlugTouched(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const beginEdit = useCallback((message: AIQuickMessage) => {
|
||||
setIsCreating(false);
|
||||
setEditingId(message.id);
|
||||
setDraft({
|
||||
name: message.name,
|
||||
slug: message.slug,
|
||||
content: message.content,
|
||||
description: message.description ?? "",
|
||||
});
|
||||
setSlugTouched(true);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback((name: string) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
|
||||
}));
|
||||
}, [slugTouched]);
|
||||
|
||||
const handleSlugChange = useCallback((slug: string) => {
|
||||
setSlugTouched(true);
|
||||
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
|
||||
}, []);
|
||||
|
||||
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
|
||||
const name = nextDraft.name.trim();
|
||||
const slug = normalizeQuickMessageSlug(nextDraft.slug);
|
||||
const content = nextDraft.content.trim();
|
||||
|
||||
if (!name) return t("ai.quickMessages.error.nameRequired");
|
||||
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
|
||||
if (!content) return t("ai.quickMessages.error.contentRequired");
|
||||
|
||||
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
|
||||
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
|
||||
}
|
||||
|
||||
const slugTaken = quickMessages.some(
|
||||
(message) => message.slug === slug && message.id !== excludeId,
|
||||
);
|
||||
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
|
||||
|
||||
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
|
||||
if (skillConflict) {
|
||||
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [quickMessages, reservedUserSkillSlugs, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const validationError = validateDraft(draft, editingId);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AIQuickMessage = {
|
||||
id: editingId ?? createQuickMessageId(),
|
||||
name: draft.name.trim(),
|
||||
slug: normalizeQuickMessageSlug(draft.slug),
|
||||
content: draft.content.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
setQuickMessages((prev) => prev.map((message) => (
|
||||
message.id === editingId ? payload : message
|
||||
)));
|
||||
} else {
|
||||
setQuickMessages((prev) => [...prev, payload]);
|
||||
}
|
||||
resetEditor();
|
||||
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
|
||||
|
||||
const handleDelete = useCallback((message: AIQuickMessage) => {
|
||||
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
|
||||
if (!ok) return;
|
||||
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
|
||||
if (editingId === message.id) {
|
||||
resetEditor();
|
||||
}
|
||||
}, [editingId, resetEditor, setQuickMessages, t]);
|
||||
|
||||
const showEditor = isCreating || editingId != null;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("ai.quickMessages.title")}
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
|
||||
<Plus size={14} className="mr-2" />
|
||||
{t("ai.quickMessages.add")}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SettingCard padded className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("ai.quickMessages.description")}
|
||||
</p>
|
||||
|
||||
{showEditor ? (
|
||||
<div className="rounded-md border border-border/60 bg-background/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">
|
||||
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetEditor}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder={t("ai.quickMessages.name.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.name}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<input
|
||||
value={draft.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder={t("ai.quickMessages.slug.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.slug}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
|
||||
<input
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.description}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
|
||||
<textarea
|
||||
value={draft.content}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
|
||||
placeholder={t("ai.quickMessages.content.placeholder")}
|
||||
rows={5}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.content}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={resetEditor}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedMessages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="rounded-md border border-border/60 bg-background/70 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="text-primary/70 shrink-0" />
|
||||
<span className="font-medium">{message.name}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">/{message.slug}</span>
|
||||
</div>
|
||||
{message.description ? (
|
||||
<p className="text-sm text-muted-foreground">{message.description}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground/80 line-clamp-2 whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => beginEdit(message)}
|
||||
aria-label={t("ai.quickMessages.editTitle")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(message)}
|
||||
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showEditor ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
|
||||
<MessageSquare size={24} className="mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { subscribeWindowFullscreenChanged } from '../../application/state/useWindowControls';
|
||||
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type TerminalEffectsContext = Record<string, any>;
|
||||
|
||||
@@ -1045,7 +1045,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
|
||||
// Fullscreen changes layout for every visible pane.
|
||||
const unsubscribeFullscreen = netcattyBridge.get()?.onWindowFullScreenChanged?.((isFullscreen) => {
|
||||
const unsubscribeFullscreen = subscribeWindowFullscreenChanged((isFullscreen) => {
|
||||
scheduleLayoutRecoveryRefit(isFullscreen ? [0, 150, 400] : [0, 100, 300]);
|
||||
});
|
||||
|
||||
|
||||
@@ -434,6 +434,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
scopeType={context.scopeType}
|
||||
scopeTargetId={context.scopeTargetId}
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
|
||||
@@ -272,6 +272,7 @@ export interface SyncPayload {
|
||||
agentModelMap?: Record<string, string>;
|
||||
agentProviderMap?: Record<string, string>;
|
||||
webSearchConfig?: Record<string, unknown> | null;
|
||||
quickMessages?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
72
infrastructure/ai/quickMessages.test.ts
Normal file
72
infrastructure/ai/quickMessages.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildSlashCommandItems,
|
||||
filterQuickMessages,
|
||||
isValidQuickMessageSlug,
|
||||
normalizeQuickMessageSlug,
|
||||
sanitizeQuickMessages,
|
||||
slugFromQuickMessageName,
|
||||
} from './quickMessages';
|
||||
|
||||
test('normalizeQuickMessageSlug lowercases and hyphenates', () => {
|
||||
assert.equal(normalizeQuickMessageSlug('Check Disk Space'), 'check-disk-space');
|
||||
assert.equal(normalizeQuickMessageSlug(' foo__bar!! '), 'foo-bar');
|
||||
});
|
||||
|
||||
test('slugFromQuickMessageName mirrors normalize', () => {
|
||||
assert.equal(slugFromQuickMessageName('Check Disk'), 'check-disk');
|
||||
});
|
||||
|
||||
test('isValidQuickMessageSlug accepts simple tokens', () => {
|
||||
assert.equal(isValidQuickMessageSlug('disk-check'), true);
|
||||
assert.equal(isValidQuickMessageSlug('Disk'), false);
|
||||
assert.equal(isValidQuickMessageSlug(''), false);
|
||||
});
|
||||
|
||||
test('filterQuickMessages matches slug prefix and name substring', () => {
|
||||
const messages = [
|
||||
{ id: '1', name: 'Check disk', slug: 'disk', content: 'df -h' },
|
||||
{ id: '2', name: 'List processes', slug: 'ps', content: 'ps aux' },
|
||||
];
|
||||
assert.equal(filterQuickMessages(messages, 'di').length, 1);
|
||||
assert.equal(filterQuickMessages(messages, 'proc').length, 1);
|
||||
assert.equal(filterQuickMessages(messages, '').length, 2);
|
||||
});
|
||||
|
||||
test('sanitizeQuickMessages rejects invalid and dedupes slugs', () => {
|
||||
const result = sanitizeQuickMessages([
|
||||
{ id: '1', name: 'Valid', slug: 'valid', content: 'hello' },
|
||||
{ id: '2', name: 'Duplicate', slug: 'valid', content: 'ignored' },
|
||||
{ id: '3', name: '', slug: 'empty', content: 'nope' },
|
||||
{ id: '4', name: 'Bad slug', slug: '!!!', content: 'nope' },
|
||||
null,
|
||||
'not-an-object',
|
||||
]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0]?.slug, 'valid');
|
||||
});
|
||||
|
||||
test('buildSlashCommandItems prefers quick messages over conflicting skill slugs', () => {
|
||||
const items = buildSlashCommandItems(
|
||||
[{ id: '1', name: 'Disk', slug: 'disk', content: 'df -h' }],
|
||||
[{ id: 's1', slug: 'disk', name: 'Disk skill', description: '' }],
|
||||
'',
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0]?.kind, 'quickMessage');
|
||||
});
|
||||
|
||||
test('buildSlashCommandItems excludes skills whose slug matches any quick message', () => {
|
||||
const items = buildSlashCommandItems(
|
||||
[{ id: '1', name: 'Disk check', slug: 'disk', content: 'df -h' }],
|
||||
[{ id: 's1', slug: 'disk', name: 'Disk skill label', description: '' }],
|
||||
'label',
|
||||
);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test('sanitizeQuickMessages returns empty array for non-array input', () => {
|
||||
assert.deepEqual(sanitizeQuickMessages(null), []);
|
||||
assert.deepEqual(sanitizeQuickMessages({}), []);
|
||||
});
|
||||
152
infrastructure/ai/quickMessages.ts
Normal file
152
infrastructure/ai/quickMessages.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
export interface AIQuickMessage {
|
||||
id: string;
|
||||
/** Display label shown in pickers and settings. */
|
||||
name: string;
|
||||
/** Slash-command token, e.g. `status` for `/status`. */
|
||||
slug: string;
|
||||
/** Prompt text inserted into the composer when selected. */
|
||||
content: string;
|
||||
/** Optional short hint shown in the slash picker. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UserSkillSlashOption {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type SlashCommandItem =
|
||||
| { kind: 'quickMessage'; message: AIQuickMessage }
|
||||
| { kind: 'skill'; skill: UserSkillSlashOption };
|
||||
|
||||
export const QUICK_MESSAGE_LIMITS = {
|
||||
name: 120,
|
||||
slug: 48,
|
||||
description: 240,
|
||||
content: 10000,
|
||||
maxItems: 200,
|
||||
} as const;
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function normalizeQuickMessageSlug(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, QUICK_MESSAGE_LIMITS.slug);
|
||||
}
|
||||
|
||||
export function isValidQuickMessageSlug(slug: string): boolean {
|
||||
return slug.length > 0 && SLUG_PATTERN.test(slug);
|
||||
}
|
||||
|
||||
export function slugFromQuickMessageName(name: string): string {
|
||||
return normalizeQuickMessageSlug(name);
|
||||
}
|
||||
|
||||
function clampString(value: unknown, maxLen: number): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.slice(0, maxLen);
|
||||
}
|
||||
|
||||
/** Validate and dedupe quick messages from localStorage or cloud sync. */
|
||||
export function sanitizeQuickMessages(raw: unknown): AIQuickMessage[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const seenSlugs = new Set<string>();
|
||||
const result: AIQuickMessage[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const idRaw = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
const id = idRaw.length > 0 ? idRaw.slice(0, 64) : createQuickMessageId();
|
||||
const name = clampString(record.name, QUICK_MESSAGE_LIMITS.name).trim();
|
||||
const slug = normalizeQuickMessageSlug(clampString(record.slug, QUICK_MESSAGE_LIMITS.slug));
|
||||
const content = clampString(record.content, QUICK_MESSAGE_LIMITS.content).trim();
|
||||
const description = clampString(record.description, QUICK_MESSAGE_LIMITS.description).trim();
|
||||
|
||||
if (!name || !isValidQuickMessageSlug(slug) || !content) continue;
|
||||
if (seenSlugs.has(slug)) continue;
|
||||
seenSlugs.add(slug);
|
||||
|
||||
result.push({
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
content,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
if (result.length >= QUICK_MESSAGE_LIMITS.maxItems) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function filterQuickMessages(
|
||||
messages: AIQuickMessage[],
|
||||
query: string,
|
||||
): AIQuickMessage[] {
|
||||
const lowerQuery = query.trim().toLowerCase();
|
||||
if (!lowerQuery) return messages;
|
||||
return messages.filter((message) => {
|
||||
const slug = message.slug.toLowerCase();
|
||||
const name = message.name.toLowerCase();
|
||||
return slug.startsWith(lowerQuery) || name.includes(lowerQuery);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterUserSkillsForSlash(
|
||||
skills: UserSkillSlashOption[],
|
||||
query: string,
|
||||
): UserSkillSlashOption[] {
|
||||
return skills.filter((skill) => {
|
||||
if (typeof skill.slug !== 'string' || skill.slug.length === 0) return false;
|
||||
if (!slashQueryMatches(query, skill.slug, skill.name)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function slashQueryMatches(query: string, slug: string, name: string): boolean {
|
||||
const lowerQuery = query.trim().toLowerCase();
|
||||
if (!lowerQuery) return true;
|
||||
return slug.toLowerCase().startsWith(lowerQuery) || name.toLowerCase().includes(lowerQuery);
|
||||
}
|
||||
|
||||
export function buildSlashCommandItems(
|
||||
quickMessages: AIQuickMessage[],
|
||||
userSkills: UserSkillSlashOption[],
|
||||
query: string,
|
||||
): SlashCommandItem[] {
|
||||
const reservedSlugs = new Set(quickMessages.map((message) => message.slug));
|
||||
const filteredMessages = filterQuickMessages(quickMessages, query);
|
||||
return [
|
||||
...filteredMessages.map((message) => ({
|
||||
kind: 'quickMessage' as const,
|
||||
message,
|
||||
})),
|
||||
...filterUserSkillsForSlash(userSkills, query)
|
||||
.filter((skill) => !reservedSlugs.has(skill.slug))
|
||||
.map((skill) => ({
|
||||
kind: 'skill' as const,
|
||||
skill,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function getSlashCommandItemKey(item: SlashCommandItem): string {
|
||||
return item.kind === 'quickMessage' ? `qm:${item.message.id}` : `sk:${item.skill.id}`;
|
||||
}
|
||||
|
||||
export function getSlashCommandItemId(item: SlashCommandItem): string {
|
||||
return item.kind === 'quickMessage' ? item.message.id : item.skill.id;
|
||||
}
|
||||
|
||||
export function createQuickMessageId(): string {
|
||||
return `qm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -145,6 +145,7 @@ export const STORAGE_KEY_AI_ACTIVE_SESSION_MAP = 'netcatty_ai_active_session_map
|
||||
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
|
||||
export const STORAGE_KEY_AI_AGENT_PROVIDER_MAP = 'netcatty_ai_agent_provider_map_v1';
|
||||
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
|
||||
export const STORAGE_KEY_AI_QUICK_MESSAGES = 'netcatty_ai_quick_messages_v1';
|
||||
|
||||
// SFTP Transfer Concurrency
|
||||
export const STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY = 'netcatty_sftp_transfer_concurrency_v1';
|
||||
|
||||
Reference in New Issue
Block a user