From 24b95e9fe12ba4abdfe1ff6e9f9fe42cb2ded011 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:43:40 +0200 Subject: [PATCH] feat: add session chat slash commands (#9748) --- .../copilot/chat/AIChatManager.svelte.ts | 31 +++- .../copilot/chat/AIChatManager.test.ts | 27 ++- .../copilot/chat/ChatCommandPicker.svelte | 68 ++++++++ .../copilot/chat/ContextTextarea.svelte | 162 +++++++++++++----- 4 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index e11b61d733..45f928d71c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -325,9 +325,10 @@ export class AIChatManager { sessionId: string | undefined = undefined // Workspace AI skills (name + description) advertised in the GLOBAL system - // prompt. Loaded asynchronously when entering GLOBAL mode; the system message - // is rebuilt once they resolve. - private globalSkills: AiSkillListItem[] = [] + // prompt and surfaced as slash commands in session chat. Loaded + // asynchronously when entering GLOBAL mode; the system message is rebuilt + // once they resolve. + globalSkills = $state([]) private globalSkillsRefreshId = 0 allowedModes: Record = $derived({ @@ -844,7 +845,7 @@ export class AIChatManager { // Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild // the system message so the next chat-loop iteration advertises them. Ignore // stale resolves so workspace changes cannot overwrite newer skills. - private refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => { + refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => { const refreshId = ++this.globalSkillsRefreshId const skills = await loadWorkspaceSkills(workspace) if (refreshId !== this.globalSkillsRefreshId) { @@ -859,6 +860,22 @@ export class AIChatManager { } } + private expandGlobalSkillCommand = (instructions: string): string => { + if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) { + return instructions + } + const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions) + if (!match) { + return instructions + } + const skill = this.globalSkills.find((s) => s.name === match[1]) + if (!skill) { + return instructions + } + const rest = match[2]?.trim() + return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.` + } + canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT) private changeModeTool = { @@ -1355,6 +1372,10 @@ export class AIChatManager { // The LLM gets the full pasted content; the display message above keeps // the compact tokens + registry so the bubble can render/expand chips. const oldInstructions = expanded(chatDraft(this.instructions, pastes)) + const modelInstructions = + this.mode === AIMode.GLOBAL + ? this.expandGlobalSkillCommand(oldInstructions) + : oldInstructions this.instructions = '' if (this.mode === AIMode.SCRIPT && !this.scriptEditorOptions && !options.lang) { @@ -1387,7 +1408,7 @@ export class AIChatManager { userMessage = prepareApiUserMessage(oldInstructions) break case AIMode.GLOBAL: - userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext, { + userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, { workspace: get(workspaceStore) }) break diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 3afb250b02..62b5999bc1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -244,6 +244,31 @@ describe('AIChatManager global skills', () => { expect(manager.systemMessage.content).toContain('child-skill') expect(manager.systemMessage.content).not.toContain('parent-skill') }) + + it('expands a leading slash skill command for the model while preserving the displayed text', async () => { + mocks.listAiSkills.mockResolvedValue([ + { name: 'review-code', description: 'review code for bugs' } + ]) + mocks.runChatLoop.mockImplementation(async (config: any) => { + const userMessage = config.messages[config.messages.length - 1] + expect(userMessage.content).toContain('Use the "review-code" skill. find bugs') + expect(userMessage.content).not.toContain('/review-code find bugs') + const message = { role: 'assistant' as const, content: 'done' } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + + const manager = new AIChatManager() + manager.isSessionChat = true + + await manager.sendRequest({ instructions: '/review-code find bugs', mode: AIMode.GLOBAL }) + + expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs') + }) }) describe('AIChatManager autonomy mode', () => { @@ -915,7 +940,7 @@ describe('AIChatManager context compaction', () => { // The request that went out begins with the summary user message, then the // recent tail verbatim, then the new question. - const sent = mocks.runChatLoop.mock.calls[0][0].messages + const sent = mocks.runChatLoop.mock.calls[mocks.runChatLoop.mock.calls.length - 1][0].messages expect(sent).toHaveLength(4) expect(sent[0].role).toBe('user') expect(sent[0].content).toContain('SUMMARY TEXT') diff --git a/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte b/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte new file mode 100644 index 0000000000..6cd72e50db --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte @@ -0,0 +1,68 @@ + + +{#snippet skillIcon(_leaf: DrillLeaf)} + +{/snippet} + +
+ +
diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 070df935e5..bc920be013 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -2,6 +2,8 @@ import autosize from '$lib/autosize' import { tick } from 'svelte' import type { ContextElement } from './context' + import { AIMode } from './AIChatManager.svelte' + import ChatCommandPicker from './ChatCommandPicker.svelte' import ChatContextPicker from './ChatContextPicker.svelte' import Portal from '$lib/components/Portal.svelte' import { zIndexes } from '$lib/zIndexes' @@ -68,11 +70,22 @@ let showContextTooltip = $state(false) let contextTooltipWord = $state('') + let showCommandTooltip = $state(false) + let commandTooltipWord = $state('') let textarea = $state(undefined) let tooltipElement = $state(undefined) let chatContextPicker: ChatContextPicker | undefined = $state() + let chatCommandPicker: ChatCommandPicker | undefined = $state() + let commandSkillsRefreshInFlight = false - // Virtual reference anchored at the `@` that opened the mention (not the + const commandSkills = $derived( + aiChatManager.mode === AIMode.GLOBAL && aiChatManager.isSessionChat + ? aiChatManager.globalSkills + : [] + ) + const activeTooltipWord = $derived(showContextTooltip ? contextTooltipWord : commandTooltipWord) + + // Virtual reference anchored at the trigger that opened the picker (not the // caret), so the picker stays put while the user types the query. // svelte-floating-ui's `createVirtualElement` takes a raw ClientRect and // wraps it in a function internally — re-`update()` on each anchor move. @@ -526,19 +539,33 @@ showContextTooltip = false } + function refreshCommandSkills() { + if (commandSkillsRefreshInFlight) return + commandSkillsRefreshInFlight = true + void aiChatManager.refreshGlobalSkills().finally(() => { + commandSkillsRefreshInFlight = false + }) + } + + function getCommandFilter(text: string): string | undefined { + if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined + const match = /^\/([a-z0-9-]*)$/.exec(text) + return match?.[1] + } + function updateAnchorRect() { if (!textarea) return + const triggerWord = activeTooltipWord + if (!triggerWord) return try { - // Index of the `@` that started the current mention. handleInput - // only opens the picker when `contextTooltipWord` (= `@xxx`) is the - // LAST whitespace-separated word in `value`, so the `@` always sits - // at `value.length - contextTooltipWord.length`. - const atIndex = value.length - contextTooltipWord.length - const coords = getCaretCoordinates(textarea, atIndex) + // Inline `@` anchors to the last word; slash commands only open when + // `/...` is the whole input, so the trigger sits at index 0. + const triggerIndex = triggerWord.startsWith('/') ? 0 : value.length - triggerWord.length + const coords = getCaretCoordinates(textarea, triggerIndex) const rect = textarea.getBoundingClientRect() // getCaretCoordinates returns content-relative coords; subtract the - // textarea's own scroll so the anchor tracks the `@` once the input is - // capped (max-height) and scrolls internally. + // textarea's own scroll so the anchor tracks the trigger once the input + // is capped (max-height) and scrolls internally. anchorRect = new DOMRect( rect.left + coords.left - textarea.scrollLeft, rect.top + coords.top - textarea.scrollTop, @@ -558,6 +585,19 @@ function handleInput(e: Event) { textarea = e.target as HTMLTextAreaElement + const commandFilter = getCommandFilter(value) + if (commandFilter !== undefined) { + const wasShowing = showCommandTooltip + showCommandTooltip = true + commandTooltipWord = `/${commandFilter}` + showContextTooltip = false + contextTooltipWord = '' + if (!wasShowing) refreshCommandSkills() + return + } + showCommandTooltip = false + commandTooltipWord = '' + const words = value.split(/\s+/) const lastWord = words[words.length - 1] @@ -574,6 +614,12 @@ } } + function handleCommandSelection(skill: { name: string }) { + value = `/${skill.name} ` + showCommandTooltip = false + setTimeout(() => textarea?.focus(), 0) + } + function handleKeyDown(e: KeyboardEvent) { // Pass to parent first if provided if (onKeyDown) { @@ -585,6 +631,22 @@ return } + if (showCommandTooltip) { + if ( + e.key === 'ArrowDown' || + e.key === 'ArrowUp' || + e.key === 'Enter' || + e.key === 'Tab' || + e.key === 'Escape' + ) { + chatCommandPicker?.handleKeydown(e) + } + if (e.key === 'Enter') { + e.preventDefault() + } + return + } + if (showContextTooltip) { // Forward navigation keys to the picker so the textarea-focused // user can drive it. The picker preventDefault/stopPropagation's @@ -622,11 +684,11 @@ } $effect(() => { - // Re-track on every value change. The `@` position can shift when the - // user adds/deletes text BEFORE it (line wrap, etc.); the picker should - // follow. floating-ui's autoUpdate only fires on scroll/resize. + // Re-track on every value change. The trigger position can shift when + // the user adds/deletes text before it (line wrap, etc.); the picker + // should follow. floating-ui's autoUpdate only fires on scroll/resize. void value - if (showContextTooltip) updateAnchorRect() + if (showContextTooltip || showCommandTooltip) updateAnchorRect() }) $effect(() => { @@ -700,9 +762,9 @@ ondragstart={handlePasteDragStart} onscroll={(e) => { scrollTop = e.currentTarget.scrollTop - // Keep the `@` picker pinned to its anchor while the input scrolls + // Keep the picker pinned to its anchor while the input scrolls // internally (autoUpdate can't observe a virtual ref's scroll). - if (showContextTooltip) updateAnchorRect() + if (showContextTooltip || showCommandTooltip) updateAnchorRect() }} onblur={() => { setTimeout(() => { @@ -711,6 +773,7 @@ return } showContextTooltip = false + showCommandTooltip = false }, 200) }} {placeholder} @@ -724,7 +787,7 @@ > -{#if showContextTooltip} +{#if showContextTooltip || showCommandTooltip}
- { - handleContextSelection(element) - }} - onSelectWorkspaceItem={(element) => { - onAddContext(element) - updateInstructionsWithContext(element) - showContextTooltip = false - setTimeout(() => textarea?.focus(), 0) - }} - externalFilter={contextTooltipWord.slice(1)} - autoFocus={false} - setShowing={(showing) => { - showContextTooltip = showing - }} - onSelectFile={(name) => { - // Replace the in-progress `@word` with the chosen mention (bracketed if the - // filename has spaces, so the highlighter captures it whole). - const index = value.lastIndexOf('@') - value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} ` - showContextTooltip = false - setTimeout(() => textarea?.focus(), 0) - }} - /> + {#if showCommandTooltip} + { + showCommandTooltip = showing + }} + /> + {:else} + { + handleContextSelection(element) + }} + onSelectWorkspaceItem={(element) => { + onAddContext(element) + updateInstructionsWithContext(element) + showContextTooltip = false + setTimeout(() => textarea?.focus(), 0) + }} + externalFilter={contextTooltipWord.slice(1)} + autoFocus={false} + setShowing={(showing) => { + showContextTooltip = showing + }} + onSelectFile={(name) => { + // Replace the in-progress `@word` with the chosen mention (bracketed if the + // filename has spaces, so the highlighter captures it whole). + const index = value.lastIndexOf('@') + value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} ` + showContextTooltip = false + setTimeout(() => textarea?.focus(), 0) + }} + /> + {/if}
{/if}