feat(ai): add quick messages with slash command picker (#691)

Closes #691
This commit is contained in:
陈大猫
2026-06-11 03:11:39 +08:00
committed by GitHub
parent 61188ab8e2
commit c0efc9d5c1
20 changed files with 1090 additions and 106 deletions

View File

@@ -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.',

View File

@@ -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 и попробуйте снова.',

View File

@@ -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 开发进程,然后重试。',

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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(

View File

@@ -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;

View File

@@ -155,6 +155,8 @@ const SettingsAITabContainer: React.FC = () => {
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
setQuickMessages={aiState.setQuickMessages}
/>
</AITabErrorBoundary>
);

View File

@@ -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}

View 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>
);
};

View File

@@ -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}

View 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>
);
};

View File

@@ -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]);
});

View File

@@ -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}

View File

@@ -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>>;
};
};

View 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({}), []);
});

View 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)}`;
}

View File

@@ -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';