From 11db596274c3261a6da4cb8b57968f895f32a1bf Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 24 Jun 2026 15:06:11 +0800 Subject: [PATCH] feat(skills): add inline skill references and picker support - render runtime skill snapshots and listings with inline skill tags - switch chat input skill insertion to token pills across slash and boost flows - support inline / and $ skill triggers and wrap command picker navigation --- .../prompt_builder/prompt_builder_impl.rs | 1 + .../execution/agent-runtime/src/prompt.rs | 5 +- .../agent-runtime/src/skill_agent_snapshot.rs | 47 +-- .../agent-runtime/src/skills/types.rs | 16 +- .../src/flow_chat/components/ChatInput.scss | 30 ++ .../src/flow_chat/components/ChatInput.tsx | 338 ++++++++++++++++-- .../flow_chat/components/RichTextInput.scss | 31 ++ .../components/RichTextInput.test.tsx | 187 ++++++++++ .../flow_chat/components/RichTextInput.tsx | 300 +++++++++++++--- .../utils/skillPromptReference.test.ts | 51 +++ .../flow_chat/utils/skillPromptReference.ts | 78 ++++ src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 14 files changed, 971 insertions(+), 116 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/skillPromptReference.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/skillPromptReference.ts diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 80c4debf6d..c0a5745894 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -572,6 +572,7 @@ mod tests { .expect("runtime context should build"); assert!(skill_listing.contains("# Skill Listing")); + assert!(skill_listing.contains("A skill is a set of instructions provided through a `SKILL.md` source.")); assert!(skill_listing.contains("")); assert!(!skill_listing.contains("# Agent Listing")); assert!(agent_listing.contains("# Agent Listing")); diff --git a/src/crates/execution/agent-runtime/src/prompt.rs b/src/crates/execution/agent-runtime/src/prompt.rs index b8870c7225..df7e93148a 100644 --- a/src/crates/execution/agent-runtime/src/prompt.rs +++ b/src/crates/execution/agent-runtime/src/prompt.rs @@ -3,8 +3,9 @@ use serde::{Deserialize, Serialize}; const SKILL_LISTING_TITLE: &str = "# Skill Listing"; -const SKILL_LISTING_GUIDANCE: &str = - "The following skills are available for use with the Skill tool:"; +const SKILL_LISTING_GUIDANCE: &str = r#"A skill is a set of instructions provided through a `SKILL.md` source. +If the user names a skill (with `[$SkillName]` or plain text) OR the task clearly matches a skill's description shown below, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +Below is the list of skills that can be used with the Skill tool. Each entry includes a name and description"#; const AGENT_LISTING_TITLE: &str = "# Agent Listing"; const AGENT_LISTING_GUIDANCE: &str = "Available subagent types for the Task tool:"; const COLLAPSED_TOOL_LISTING_TITLE: &str = "# Collapsed Tool Listing"; diff --git a/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs b/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs index 6ce7a8f8ad..e4f470ba66 100644 --- a/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs +++ b/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs @@ -13,20 +13,7 @@ pub struct SkillSnapshotEntry { impl SkillSnapshotEntry { fn to_xml_desc(&self) -> String { - format!( - r#" - -{} - - -{} - - -{} - -"#, - self.name, self.description, self.location - ) + format!(r#"{}"#, self.name, self.description) } } @@ -385,12 +372,12 @@ mod tests { SkillSnapshotEntry { name: "skill-a".to_string(), description: "desc-a".to_string(), - location: "/a".to_string(), + location: "C:/skills/skill-a".to_string(), }, SkillSnapshotEntry { name: "skill-b".to_string(), description: "desc-b".to_string(), - location: "/b".to_string(), + location: "C:/skills/skill-b".to_string(), }, ], subagents: vec![AgentSnapshotEntry { @@ -404,12 +391,12 @@ mod tests { SkillSnapshotEntry { name: "skill-a".to_string(), description: "desc-a2".to_string(), - location: "/a".to_string(), + location: "C:/skills/skill-a".to_string(), }, SkillSnapshotEntry { name: "skill-c".to_string(), description: "desc-c".to_string(), - location: "/c".to_string(), + location: "C:/skills/skill-c".to_string(), }, ], subagents: vec![AgentSnapshotEntry { @@ -430,13 +417,29 @@ mod tests { assert!(skill_update.contains("## Changed Skills")); assert!(skill_update.contains("## Added Skills")); assert!(skill_update.contains("## Removed Skills")); - assert!(skill_update.contains("skill-a")); - assert!(skill_update.contains("skill-c")); + assert!(skill_update.contains(r#"desc-a2"#)); + assert!(skill_update.contains(r#"desc-c"#)); + assert!(!skill_update.contains("C:/skills/skill-a")); + assert!(!skill_update.contains("C:/skills/skill-c")); assert!(skill_update.contains("- skill-b")); assert!(agent_update.contains("## Changed Agents")); assert!(agent_update.contains("Grep")); } + #[test] + fn full_skill_listing_renders_inline_name_and_description_without_location() { + let listing = super::render_full_skill_listing_body(&[SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "C:/skills/skill-a".to_string(), + }]); + + assert!(listing.contains("")); + assert!(listing.contains(r#"desc-a"#)); + assert!(!listing.contains("")); + assert!(!listing.contains("C:/skills/skill-a")); + } + #[test] fn skill_agent_diff_ignores_default_tool_reordering_for_agents() { let previous = TurnSkillAgentSnapshot { @@ -473,7 +476,7 @@ mod tests { skills: vec![SkillSnapshotEntry { name: "skill-a".to_string(), description: "desc-a".to_string(), - location: "/a".to_string(), + location: "C:/skills/skill-a".to_string(), }], ..Default::default() }, @@ -485,7 +488,7 @@ mod tests { skills: vec![SkillSnapshotEntry { name: "skill-b".to_string(), description: "desc-b".to_string(), - location: "/b".to_string(), + location: "C:/skills/skill-b".to_string(), }], ..Default::default() }, diff --git a/src/crates/execution/agent-runtime/src/skills/types.rs b/src/crates/execution/agent-runtime/src/skills/types.rs index 1023bd3cbd..d7c33430e6 100644 --- a/src/crates/execution/agent-runtime/src/skills/types.rs +++ b/src/crates/execution/agent-runtime/src/skills/types.rs @@ -51,21 +51,7 @@ pub struct SkillInfo { impl SkillInfo { pub fn to_xml_desc(&self) -> String { - format!( - r#" - -{} - - -{} - - -{} - - -"#, - self.name, self.description, self.path - ) + format!(r#"{}"#, self.name, self.description) } } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 0359b0f7b4..8134b2d620 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -1278,6 +1278,29 @@ } } } + + &__slash-command-section { + display: flex; + align-items: center; + gap: 8px; + padding: 8px var(--flowchat-card-expanded-pad-x) 4px; + } + + &__slash-command-section-line { + flex: 1; + min-width: 16px; + height: 1px; + background: var(--border-subtle); + } + + &__slash-command-section-title { + flex: 0 0 auto; + font-size: var(--flowchat-font-size-xxs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-muted); + } &__slash-command-item { display: flex; @@ -1325,6 +1348,13 @@ -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; + + &--single-line { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + -webkit-line-clamp: unset; + } } &__slash-command-current { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index ec84a59878..60f3cf5479 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'; import { ArrowUp, BotMessageSquare, Image, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight, Files, MessageSquarePlus, Star } from 'lucide-react'; import { ContextDropZone, useContextStore } from '../../shared/context-system'; import { useActiveSessionState } from '@/flow_chat/hooks'; -import { RichTextInput, type MentionState } from './RichTextInput'; +import { RichTextInput, type MentionState, type InlineTriggerState } from './RichTextInput'; import { FileMentionPicker } from './FileMentionPicker'; import { globalEventBus } from '@/infrastructure/event-bus'; import { @@ -75,6 +75,12 @@ import type { ModeSkillInfo } from '@/infrastructure/config/types'; import MCPAPI, { type MCPPrompt, type MCPPromptMessage, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; import { ChatInputWorkspaceStrip } from './ChatInputWorkspaceStrip'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; +import { + appendSkillPromptReferenceToken, + createSkillPromptReferenceToken, + isSlashAddressableSkillName, + replaceLeadingSlashCommandWithSkillToken, +} from '../utils/skillPromptReference'; import { useDeepReviewConsent } from './DeepReviewConsentDialog'; import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity'; import { shouldBlockDeepReviewCommand } from '../utils/deepReviewCommandGuard'; @@ -132,7 +138,20 @@ type SlashAcpCommandItem = { label: string; }; -type SlashPickerItem = SlashActionItem | SlashModeItem | SlashMcpPromptItem | SlashAcpCommandItem; +type SlashSkillItem = { + kind: 'skill'; + id: string; + command: string; + label: string; + skillName: string; +}; + +type SlashPickerItem = + | SlashActionItem + | SlashModeItem + | SlashMcpPromptItem + | SlashAcpCommandItem + | SlashSkillItem; type ChatInputTarget = 'main' | 'btw'; type PendingLargePasteMap = Record; @@ -658,8 +677,8 @@ export const ChatInput: React.FC = ({ const openScene = useSceneStore(s => s.openScene); const openCreateAgent = useAgentsStore(s => s.openCreateAgent); - const [boostPanelSkills, setBoostPanelSkills] = useState([]); - const [boostSkillsLoading, setBoostSkillsLoading] = useState(false); + const [resolvedModeSkills, setResolvedModeSkills] = useState([]); + const [resolvedModeSkillsLoading, setResolvedModeSkillsLoading] = useState(false); const [userDefaultModeId, setUserDefaultModeId] = useState(null); const [defaultModeSavingId, setDefaultModeSavingId] = useState(null); @@ -708,10 +727,10 @@ export const ChatInput: React.FC = ({ const setChatInputActive = useChatInputState(state => state.setActive); const setChatInputExpanded = useChatInputState(state => state.setExpanded); const setChatInputHeight = useChatInputState(state => state.setInputHeight); - const runtimeBoostSkills = useMemo( + const runtimeResolvedSkills = useMemo( // Only surface skills that this mode will actually resolve at runtime. - () => boostPanelSkills.filter(skill => skill.selectedForRuntime), - [boostPanelSkills] + () => resolvedModeSkills.filter(skill => skill.selectedForRuntime), + [resolvedModeSkills] ); useEffect(() => { @@ -910,10 +929,16 @@ export const ChatInput: React.FC = ({ query: '', startOffset: 0, }); + const [inlineTriggerState, setInlineTriggerState] = useState({ + isActive: false, + trigger: null, + query: '', + startOffset: 0, + }); const [slashCommandState, setSlashCommandState] = useState<{ isActive: boolean; - kind: 'modes' | 'actions' | 'all'; + kind: 'modes' | 'actions' | 'all' | 'skills'; query: string; selectedIndex: number; }>({ @@ -949,6 +974,39 @@ export const ChatInput: React.FC = ({ slashCommandState.selectedIndex, ]); + useEffect(() => { + if (isAcpInputSession) { + if (slashCommandState.isActive && slashCommandState.kind === 'skills') { + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + } + return; + } + + const inlineSlashSkillTrigger = + inlineTriggerState.isActive && + ( + inlineTriggerState.trigger === '$' || + (inlineTriggerState.trigger === '/' && inlineTriggerState.startOffset > 0) + ); + + if (inlineSlashSkillTrigger) { + setSlashCommandState(prev => ({ + isActive: true, + kind: 'skills', + query: inlineTriggerState.query.toLowerCase(), + selectedIndex: + prev.kind === 'skills' && prev.query === inlineTriggerState.query.toLowerCase() + ? prev.selectedIndex + : 0, + })); + return; + } + + if (slashCommandState.isActive && slashCommandState.kind === 'skills') { + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + } + }, [inlineTriggerState, isAcpInputSession, slashCommandState.isActive, slashCommandState.kind]); + const clearPendingLargePastes = useCallback(() => { pendingLargePastesRef.current = {}; }, []); @@ -1395,12 +1453,16 @@ export const ChatInput: React.FC = ({ }; }, [modeState.dropdownOpen]); + const shouldLoadResolvedModeSkills = + isModeDropdownOpen || + (slashCommandState.isActive && (slashCommandState.kind === 'all' || slashCommandState.kind === 'skills')); + useEffect(() => { - if (!isModeDropdownOpen) { + if (!shouldLoadResolvedModeSkills) { return; } let cancelled = false; - setBoostSkillsLoading(true); + setResolvedModeSkillsLoading(true); (async () => { try { const list = await configAPI.getModeSkillConfigs({ @@ -1408,23 +1470,27 @@ export const ChatInput: React.FC = ({ workspacePath: workspacePath || undefined, }); if (!cancelled) { - setBoostPanelSkills(list); + setResolvedModeSkills(list); } } catch (err) { - log.error('Failed to load mode-resolved skills for boost panel', { + log.error('Failed to load mode-resolved skills for chat input', { err, modeId: currentMode, workspacePath: workspacePath || undefined, }); - if (!cancelled) setBoostPanelSkills([]); + if (!cancelled) { + setResolvedModeSkills([]); + } } finally { - if (!cancelled) setBoostSkillsLoading(false); + if (!cancelled) { + setResolvedModeSkillsLoading(false); + } } })(); return () => { cancelled = true; }; - }, [currentMode, isModeDropdownOpen, workspacePath]); + }, [currentMode, shouldLoadResolvedModeSkills, workspacePath]); useEffect(() => { if (!modeState.dropdownOpen) { @@ -1623,6 +1689,45 @@ export const ChatInput: React.FC = ({ })); }, [acpAgentCommands, slashCommandState.query]); + const getFilteredSkills = useCallback((): SlashSkillItem[] => { + const q = (slashCommandState.query || '').trim().toLowerCase(); + const seenNames = new Set(); + return runtimeResolvedSkills + .filter(skill => { + const normalizedName = skill.name.trim(); + const normalizedNameKey = normalizedName.toLowerCase(); + if (!normalizedName || seenNames.has(normalizedNameKey)) { + return false; + } + if (!isSlashAddressableSkillName(normalizedName)) { + return false; + } + + const matches = + !q || + normalizedNameKey.includes(q) || + skill.description.toLowerCase().includes(q); + if (matches) { + seenNames.add(normalizedNameKey); + } + return matches; + }) + .map(skill => ({ + kind: 'skill' as const, + id: skill.key, + command: `/${skill.name}`, + label: skill.description || skill.name, + skillName: skill.name, + })) + .sort((a, b) => { + const aName = a.skillName.toLowerCase(); + const bName = b.skillName.toLowerCase(); + const aExact = aName === q ? 0 : aName.startsWith(q) ? 1 : 2; + const bExact = bName === q ? 0 : bName.startsWith(q) ? 1 : 2; + return aExact - bExact || aName.localeCompare(bName); + }); + }, [runtimeResolvedSkills, slashCommandState.query]); + const resolveTypedMcpPromptCommand = useCallback((text: string): SlashMcpPromptItem | null => { const trimmed = text.trim(); if (!trimmed.startsWith('/')) { @@ -1647,6 +1752,7 @@ export const ChatInput: React.FC = ({ const actions = getFilteredActions(); const mcpPrompts = getFilteredMcpPromptCommands(); + const skills = getFilteredSkills(); let modeList = incrementalCodeModes; if (canSwitchModes && slashCommandState.query) { const q = slashCommandState.query; @@ -1661,8 +1767,18 @@ export const ChatInput: React.FC = ({ id: mode.id, name: mode.name, })); - return [...acpCommands, ...actions, ...mcpPrompts, ...modes]; - }, [canSwitchModes, getFilteredActions, getFilteredAcpCommands, getFilteredMcpPromptCommands, incrementalCodeModes, isAcpInputSession, slashCommandState.query]); + return [...acpCommands, ...actions, ...mcpPrompts, ...modes, ...skills]; + }, [canSwitchModes, getFilteredActions, getFilteredAcpCommands, getFilteredMcpPromptCommands, getFilteredSkills, incrementalCodeModes, isAcpInputSession, slashCommandState.query]); + + const getActiveSlashPickerItems = useCallback((): SlashPickerItem[] => { + if (slashCommandState.kind === 'actions') { + return getFilteredActions(); + } + if (slashCommandState.kind === 'skills') { + return getFilteredSkills(); + } + return getSlashPickerItems(); + }, [getFilteredActions, getFilteredSkills, getSlashPickerItems, slashCommandState.kind]); const handleInputChange = useCallback((text: string, activeContexts: import('../../shared/types/context').ContextItem[]) => { if (!inputState.isActive && text.length > 0) { @@ -1751,6 +1867,9 @@ export const ChatInput: React.FC = ({ } if (slashCommandState.isActive) { + if (slashCommandState.kind === 'skills') { + return; + } setSlashCommandState({ isActive: false, kind: 'modes', @@ -2653,6 +2772,33 @@ export const ChatInput: React.FC = ({ window.setTimeout(() => richTextInputRef.current?.focus(), 0); }, [setQueuedInput]); + const getRichTextInlineTriggerController = useCallback(() => { + return richTextInputRef.current as (HTMLDivElement & { + replaceActiveInlineTrigger?: (replacementText: string) => void; + appendInlineTokenAtEnd?: (token: string) => void; + closeInlineTrigger?: () => void; + }) | null; + }, []); + + const selectSlashSkill = useCallback((item: SlashSkillItem) => { + const replaceInlineTrigger = getRichTextInlineTriggerController()?.replaceActiveInlineTrigger; + + if (inlineTriggerState.isActive) { + replaceInlineTrigger?.(`[$${item.skillName}]`); + setQueuedInput(null); + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + window.setTimeout(() => richTextInputRef.current?.focus(), 0); + return; + } + + const next = replaceLeadingSlashCommandWithSkillToken(inputState.value, item.skillName); + dispatchInput({ type: 'SET_VALUE', payload: next }); + inputValueRef.current = next; + setQueuedInput(null); + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + window.setTimeout(() => richTextInputRef.current?.focus(), 0); + }, [getRichTextInlineTriggerController, inlineTriggerState.isActive, inputState.value, setQueuedInput]); + const handleBoostStartBtw = useCallback( (e: React.SyntheticEvent) => { e.stopPropagation(); @@ -2734,7 +2880,9 @@ export const ChatInput: React.FC = ({ if (slashCommandState.isActive) { setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); - dispatchInput({ type: 'CLEAR_VALUE' }); + if (slashCommandState.kind !== 'skills') { + dispatchInput({ type: 'CLEAR_VALUE' }); + } } const currentIdx = modes.findIndex(m => m.id === modeNow); @@ -2752,16 +2900,19 @@ export const ChatInput: React.FC = ({ const items = slashCommandState.kind === 'modes' ? getFilteredIncrementalModes() - : slashCommandState.kind === 'actions' - ? getFilteredActions() - : getSlashPickerItems(); + : getActiveSlashPickerItems(); const maxIndex = Math.max(0, items.length - 1); if (e.key === 'ArrowDown') { e.preventDefault(); setSlashCommandState(prev => ({ ...prev, - selectedIndex: Math.min(prev.selectedIndex + 1, maxIndex), + selectedIndex: + items.length === 0 + ? 0 + : prev.selectedIndex >= maxIndex + ? 0 + : prev.selectedIndex + 1, })); return; } @@ -2770,7 +2921,12 @@ export const ChatInput: React.FC = ({ e.preventDefault(); setSlashCommandState(prev => ({ ...prev, - selectedIndex: Math.max(prev.selectedIndex - 1, 0), + selectedIndex: + items.length === 0 + ? 0 + : prev.selectedIndex <= 0 + ? maxIndex + : prev.selectedIndex - 1, })); return; } @@ -2792,6 +2948,8 @@ export const ChatInput: React.FC = ({ selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { selectSlashAcpCommand(item); + } else if (item.kind === 'skill') { + selectSlashSkill(item); } else { selectSlashCommandAction(item.id); } @@ -2803,10 +2961,13 @@ export const ChatInput: React.FC = ({ if (e.key === 'Escape') { e.preventDefault(); const kind = slashCommandState.kind; + if (kind === 'skills') { + getRichTextInlineTriggerController()?.closeInlineTrigger?.(); + } setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); // For mode switching picker, "/" is just a trigger and should be cleared on cancel. - if (kind !== 'actions') { + if (kind !== 'actions' && kind !== 'skills') { dispatchInput({ type: 'CLEAR_VALUE' }); } return; @@ -2829,6 +2990,8 @@ export const ChatInput: React.FC = ({ selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { selectSlashAcpCommand(item); + } else if (item.kind === 'skill') { + selectSlashSkill(item); } else { selectSlashCommandAction(item.id); } @@ -2956,7 +3119,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, selectSlashAcpCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -3031,17 +3194,22 @@ export const ChatInput: React.FC = ({ const insertSkillIntoInput = useCallback( (skillName: string) => { - const line = t('chatInput.insertSkillLine', { name: skillName }); dispatchInput({ type: 'ACTIVATE' }); - const cur = inputState.value; - const next = cur.trim() ? `${cur.trimEnd()}\n\n${line}` : line; - dispatchInput({ type: 'SET_VALUE', payload: next }); + const token = createSkillPromptReferenceToken(skillName); + const appendInlineTokenAtEnd = getRichTextInlineTriggerController()?.appendInlineTokenAtEnd; + if (appendInlineTokenAtEnd) { + appendInlineTokenAtEnd(token); + } else { + const next = appendSkillPromptReferenceToken(inputState.value, skillName); + dispatchInput({ type: 'SET_VALUE', payload: next }); + inputValueRef.current = next; + } clearSkillsTimer(); setSkillsFlyoutOpen(false); dispatchMode({ type: 'CLOSE_DROPDOWN' }); focusRichTextInputSoon(); }, - [clearSkillsTimer, focusRichTextInputSoon, inputState.value, t] + [clearSkillsTimer, focusRichTextInputSoon, getRichTextInlineTriggerController, inputState.value] ); const handleBoostPickImage = useCallback( @@ -3294,6 +3462,7 @@ export const ChatInput: React.FC = ({ contexts={contexts} onRemoveContext={removeContext} onMentionStateChange={setMentionState} + onInlineTriggerStateChange={setInlineTriggerState} data-testid="chat-input-textarea" /> @@ -3350,7 +3519,9 @@ export const ChatInput: React.FC = ({ } if (slashCommandState.kind === 'all') { - const items = getSlashPickerItems(); + const items = getActiveSlashPickerItems(); + const firstModeIndex = items.findIndex(item => item.kind === 'mode'); + const firstSkillIndex = items.findIndex(item => item.kind === 'skill'); return (
@@ -3358,19 +3529,108 @@ export const ChatInput: React.FC = ({ {t('chatInput.selectHint')}
- {mcpPromptCommandsLoading && items.length === 0 ? ( + {items.length === 0 && (mcpPromptCommandsLoading || resolvedModeSkillsLoading) ? (
- {t('chatInput.loadingMcpPrompts')} + {resolvedModeSkillsLoading && !mcpPromptCommandsLoading + ? t('chatInput.boostSkillsLoading') + : t('chatInput.loadingMcpPrompts')}
) : items.length > 0 ? ( items.map((item, index) => { const commandText = item.kind === 'mode' ? `/${item.id}` : item.command; const labelText = item.kind === 'mode' ? item.name + : item.kind === 'skill' + ? item.label : item.kind === 'mcpPrompt' ? `${item.serverName} · ${item.label}` : item.label; + return ( + + {index === firstModeIndex && ( +
+ + + {t('chatInput.modeSection')} + + +
+ )} + {index === firstSkillIndex && ( +
+ + + {t('chatInput.boostSkills')} + + +
+ )} +
{ + if (item.kind === 'mode') { + selectSlashCommandMode(item.id); + } else if (item.kind === 'skill') { + selectSlashSkill(item); + } else if (item.kind === 'mcpPrompt') { + selectSlashPromptCommand(item); + } else if (item.kind === 'acpCommand') { + selectSlashAcpCommand(item); + } else { + selectSlashCommandAction(item.id); + } + }} + onMouseEnter={() => setSlashCommandState(prev => ({ ...prev, selectedIndex: index }))} + > + + {commandText} + + + {labelText} + + {item.kind === 'mode' && item.id === modeState.current && {t('chatInput.current')}} +
+
+ ); + }) + ) : ( +
+ {t('chatInput.noMatchingCommand')} +
+ )} +
+
+ ); + } + + if (slashCommandState.kind === 'skills') { + const items = getActiveSlashPickerItems(); + return ( +
+
+ {t('chatInput.boostSkills')} + {t('chatInput.selectHint')} +
+
+ {items.length === 0 && resolvedModeSkillsLoading ? ( +
+ {t('chatInput.boostSkillsLoading')} +
+ ) : items.length > 0 ? ( + items.map((item, index) => { + const commandText = item.kind === 'mode' ? `/${item.id}` : item.command; + const labelText = item.kind === 'mode' + ? item.name + : item.kind === 'skill' + ? item.label + : item.kind === 'mcpPrompt' + ? `${item.serverName} · ${item.label}` + : item.label; + return (
= ({ onClick={() => { if (item.kind === 'mode') { selectSlashCommandMode(item.id); + } else if (item.kind === 'skill') { + selectSlashSkill(item); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { @@ -3392,7 +3654,9 @@ export const ChatInput: React.FC = ({ {commandText} - + {labelText} {item.kind === 'mode' && item.id === modeState.current && {t('chatInput.current')}} @@ -3611,16 +3875,16 @@ export const ChatInput: React.FC = ({ onMouseLeave={closeSkillsFlyout} >
- {boostSkillsLoading ? ( + {resolvedModeSkillsLoading ? (
{t('chatInput.boostSkillsLoading')}
- ) : runtimeBoostSkills.length === 0 ? ( + ) : runtimeResolvedSkills.length === 0 ? (
{t('chatInput.boostSkillsEmpty')}
) : (
- {runtimeBoostSkills.map(skill => ( + {runtimeResolvedSkills.map(skill => (
{ expect(editor.firstChild).not.toBe(originalTextNode); }); + it('renders externally inserted skill tokens as inline pills', async () => { + const harnessRef = createRef(); + const editor = await renderHarness(harnessRef); + + await act(async () => { + harnessRef.current?.setValue('Use [$pdf] please'); + }); + + const skillPill = editor.querySelector( + '[data-inline-token-type="skill-ref"]', + ) as HTMLElement | null; + expect(skillPill).toBeTruthy(); + expect(skillPill?.getAttribute('data-tag-format')).toBe('[$pdf]'); + expect(skillPill?.querySelector('.lucide-puzzle')).toBeTruthy(); + expect(editor.textContent).toContain('pdf'); + }); + it('keeps Escape owned by IME composition', async () => { const onKeyDown = vi.fn(); @@ -248,6 +265,176 @@ describeWithJsdom('RichTextInput external sync', () => { }); }); + it('reports inline skill triggers for $ and middle-of-text /', async () => { + const onInlineTriggerStateChange = vi.fn(); + + await act(async () => { + root.render( + {}} + onInlineTriggerStateChange={onInlineTriggerStateChange} + contexts={emptyContexts} + onRemoveContext={() => {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input'); + expect(editor).toBeInstanceOf(HTMLDivElement); + + await updateEditorText(editor as HTMLDivElement, '$pdf'); + expect(onInlineTriggerStateChange).toHaveBeenLastCalledWith({ + isActive: true, + trigger: '$', + query: 'pdf', + startOffset: 0, + }); + + await updateEditorText(editor as HTMLDivElement, 'please /pdf'); + expect(onInlineTriggerStateChange).toHaveBeenLastCalledWith({ + isActive: true, + trigger: '/', + query: 'pdf', + startOffset: 7, + }); + }); + + it('can replace an active inline trigger with a skill token', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as (HTMLDivElement & { + replaceActiveInlineTrigger?: (replacementText: string) => void; + }) | null; + expect(editor).toBeTruthy(); + + setCaret(editor!, '$pdf'.length); + await act(async () => { + editor!.dispatchEvent(new window.Event('input', { bubbles: true })); + }); + + await act(async () => { + editor?.replaceActiveInlineTrigger?.('[$pdf]'); + }); + + expect(onChange).toHaveBeenCalledWith('[$pdf]', emptyContexts); + const skillPill = editor?.querySelector('.rich-text-tag-pill--skill-ref'); + expect(skillPill).toBeTruthy(); + expect(skillPill?.querySelector('.lucide-puzzle')).toBeTruthy(); + expect(skillPill?.nextSibling?.textContent).toBe(' '); + const selection = window.getSelection(); + expect(selection?.anchorNode).toBe(editor); + expect(selection?.anchorOffset).toBeGreaterThan(Array.from(editor?.childNodes ?? []).indexOf(skillPill as ChildNode)); + }); + + it('can close an active inline trigger imperatively', async () => { + const onInlineTriggerStateChange = vi.fn(); + + await act(async () => { + root.render( + {}} + onInlineTriggerStateChange={onInlineTriggerStateChange} + contexts={emptyContexts} + onRemoveContext={() => {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as (HTMLDivElement & { + closeInlineTrigger?: () => void; + }) | null; + expect(editor).toBeTruthy(); + + setCaret(editor!, '$pdf'.length); + await act(async () => { + editor!.dispatchEvent(new window.Event('input', { bubbles: true })); + }); + + await act(async () => { + editor?.closeInlineTrigger?.(); + }); + + expect(onInlineTriggerStateChange).toHaveBeenLastCalledWith({ + isActive: false, + trigger: null, + query: '', + startOffset: 0, + }); + }); + + it('can append an inline skill token at the end with trailing space', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as (HTMLDivElement & { + appendInlineTokenAtEnd?: (token: string) => void; + }) | null; + expect(editor).toBeTruthy(); + + await act(async () => { + editor?.appendInlineTokenAtEnd?.('[$pdf]'); + }); + + expect(onChange).toHaveBeenCalledWith('hello [$pdf]', emptyContexts); + const skillPill = editor?.querySelector('.rich-text-tag-pill--skill-ref'); + expect(skillPill).toBeTruthy(); + expect(skillPill?.previousSibling?.textContent).toBe(' '); + expect(skillPill?.nextSibling?.textContent).toBe(' '); + }); + + it('clears placeholder br before appending the first inline skill token', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as (HTMLDivElement & { + appendInlineTokenAtEnd?: (token: string) => void; + }) | null; + expect(editor).toBeTruthy(); + + editor!.innerHTML = '
'; + + await act(async () => { + editor?.appendInlineTokenAtEnd?.('[$pdf]'); + }); + + expect(onChange).toHaveBeenCalledWith('[$pdf]', emptyContexts); + expect(editor?.querySelector('br')).toBeFalsy(); + expect(editor?.firstChild).toBe(editor?.querySelector('.rich-text-tag-pill--skill-ref')); + }); + it('inserts a separating space when opening mention from a mid-word caret', async () => { const onMentionStateChange = vi.fn(); diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index 6a4098f6b4..82deea447f 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -4,14 +4,24 @@ */ import React, { useRef, useEffect, useCallback, useState } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Puzzle } from 'lucide-react'; import type { ContextItem } from '../../shared/types/context'; import { getRichTextExternalSyncAction } from './richTextInputSync'; import { getWidgetPromptReferenceMatches, parseWidgetPromptReferenceToken, } from '@/tools/generative-widget/widgetPromptReference'; +import { + getSkillPromptReferenceMatches, + parseSkillPromptReferenceToken, +} from '../utils/skillPromptReference'; import './RichTextInput.scss'; +const SKILL_REFERENCE_BADGE_ICON = renderToStaticMarkup( +