feat: add session chat slash commands (#9748)

This commit is contained in:
centdix
2026-06-24 13:43:40 +02:00
committed by GitHub
parent f5828780fd
commit 24b95e9fe1
4 changed files with 239 additions and 49 deletions
@@ -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<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
allowedModes: Record<AIMode, boolean> = $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
@@ -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')
@@ -0,0 +1,68 @@
<script lang="ts">
import { Sparkles } from 'lucide-svelte'
import DrillPicker from '$lib/components/DrillPicker.svelte'
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
import type { AiSkillListItem } from './global/core'
interface Props {
skills: AiSkillListItem[]
onSelect: (skill: AiSkillListItem) => void
setShowing?: (showing: boolean) => void
externalFilter?: string
autoFocus?: boolean
}
let { skills, onSelect, setShowing, externalFilter, autoFocus = true }: Props = $props()
type DrillPickerHandle = {
handleKeydown: (e: KeyboardEvent) => void
}
let inner = $state<DrillPickerHandle | undefined>(undefined)
const tree = $derived<DrillNode<AiSkillListItem>[]>(
skills.map((skill) => ({
type: 'leaf' as const,
key: `skill:${skill.name}`,
label: `/${skill.name}`,
secondary: skill.description,
searchableText: `${skill.name} ${skill.description}`,
data: skill
}))
)
export function handleKeydown(e: KeyboardEvent) {
inner?.handleKeydown(e)
}
function handlePick(leaf: DrillLeaf<AiSkillListItem>) {
onSelect(leaf.data)
}
function onDocumentKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && !e.defaultPrevented) {
setShowing?.(false)
}
}
$effect(() => {
document.addEventListener('keydown', onDocumentKeydown)
return () => document.removeEventListener('keydown', onDocumentKeydown)
})
</script>
{#snippet skillIcon(_leaf: DrillLeaf<AiSkillListItem>)}
<Sparkles size={12} class="shrink-0 text-tertiary" />
{/snippet}
<div class="w-[min(340px,calc(100vw-20px))] max-h-64 overflow-hidden">
<DrillPicker
bind:this={inner}
{tree}
onPick={handlePick}
{externalFilter}
{autoFocus}
leafIcon={skillIcon}
flush
/>
</div>
@@ -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<HTMLTextAreaElement | undefined>(undefined)
let tooltipElement = $state<HTMLDivElement | undefined>(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 @@
></textarea>
</div>
{#if showContextTooltip}
{#if showContextTooltip || showCommandTooltip}
<Portal target="body">
<div
bind:this={tooltipElement}
@@ -732,33 +795,46 @@
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md shadow-lg overflow-hidden"
style="z-index: {zIndexes.tooltip};"
>
<ChatContextPicker
bind:this={chatContextPicker}
{availableContext}
{selectedContext}
onSelect={(element) => {
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}
<ChatCommandPicker
bind:this={chatCommandPicker}
skills={commandSkills}
onSelect={handleCommandSelection}
externalFilter={commandTooltipWord.slice(1)}
autoFocus={false}
setShowing={(showing) => {
showCommandTooltip = showing
}}
/>
{:else}
<ChatContextPicker
bind:this={chatContextPicker}
{availableContext}
{selectedContext}
onSelect={(element) => {
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}
</div>
</Portal>
{/if}