Add slash commands to agent chat UI

Introduce client-side slash commands (/clear, /help, /info, /soul,
/tools, /crons) with an autocomplete dropdown in the chat input.

- New lib/slash-commands.ts with command registry, parser, matcher,
  and executor
- Autocomplete dropdown appears when typing "/" with keyboard
  navigation (arrow keys, Enter/Tab to select, Escape to dismiss)
- System messages render as accent-bordered cards, filtered from
  API calls so they never reach the gateway
- 35 new tests covering all parser and executor paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zackbart
2026-03-06 18:42:06 -05:00
co-authored by Claude Opus 4.6
parent a829e32859
commit 9efebfdac0
4 changed files with 555 additions and 9 deletions
+155 -8
View File
@@ -7,6 +7,8 @@ import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
import { buildApiContent } from '@/lib/multimodal'
import { generateId } from '@/lib/id'
import { useSettings } from '@/app/settings-provider'
import { isSlashInput, matchCommands, parseSlashCommand, executeCommand } from '@/lib/slash-commands'
import type { SlashCommand } from '@/lib/slash-commands'
import { FileAttachment } from './FileAttachment'
import { MediaPreview } from './MediaPreview'
import { AgentAvatar } from '@/components/AgentAvatar'
@@ -293,6 +295,9 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
const [isStreaming, setIsStreaming] = useState(false)
const [pendingAttachments, setPendingAttachments] = useState<MediaAttachment[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [slashMatches, setSlashMatches] = useState<SlashCommand[]>([])
const [slashIndex, setSlashIndex] = useState(0)
const slashMenuOpen = slashMatches.length > 0
const bottomRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -355,10 +360,12 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
setIsStreaming(true)
// Use ref to read latest messages (avoids stale closure)
const apiMessages = [...messagesRef.current, userMsg].map(m => ({
role: m.role,
content: buildApiContent(m),
}))
const apiMessages = [...messagesRef.current, userMsg]
.filter(m => m.role !== 'system')
.map(m => ({
role: m.role,
content: buildApiContent(m),
}))
try {
const res = await fetch(`/api/chat/${agent.id}`, {
@@ -404,7 +411,52 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
}
}, [input, pendingAttachments, isStreaming, agent.id, onUpdate])
function runSlashCommand(command: string) {
const result = executeCommand(command, agent)
if (result.action === 'clear') {
clearChat()
} else {
const sysMsg: Message = {
id: generateId(),
role: 'system',
content: result.content,
timestamp: Date.now(),
}
onUpdate(agent.id, prev => addMessage(prev, agent.id, sysMsg))
}
setInput('')
setSlashMatches([])
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
function handleSlashSelect(cmd: SlashCommand) {
runSlashCommand(cmd.name)
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (slashMenuOpen) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSlashIndex(i => (i + 1) % slashMatches.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSlashIndex(i => (i - 1 + slashMatches.length) % slashMatches.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
handleSlashSelect(slashMatches[slashIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setSlashMatches([])
return
}
}
if (e.key === 'Escape') {
e.preventDefault()
textareaRef.current?.blur()
@@ -412,6 +464,11 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
const parsed = parseSlashCommand(input)
if (parsed) {
runSlashCommand(parsed.command)
return
}
sendMessage()
}
}
@@ -747,7 +804,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
const isUser = msg.role === 'user'
const showAvatar = shouldShowAvatar(messages, i)
const showTimestamp = shouldShowTimestamp(messages, i)
const isLastAssistant = !isUser && i === messages.length - 1 && (isStreaming || msg.isStreaming)
const isLastAssistant = msg.role === 'assistant' && i === messages.length - 1 && (isStreaming || msg.isStreaming)
const showTypingDots = isLastAssistant && !msg.content
const media = msg.media || parseMedia(msg.content)
@@ -817,8 +874,30 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
</div>
)}
{/* System message (slash command result) */}
{msg.role === 'system' && (
<div style={{
padding: '0 var(--space-4)',
marginBottom: 'var(--space-1)',
}}>
<div style={{
maxWidth: '85%',
margin: '0 auto',
padding: 'var(--space-3) var(--space-4)',
borderRadius: 'var(--radius-md)',
background: 'var(--fill-tertiary)',
borderLeft: '3px solid var(--accent)',
color: 'var(--text-secondary)',
fontSize: 'var(--text-footnote)',
lineHeight: 'var(--leading-relaxed)',
}}>
{formatMessage(msg.content)}
</div>
</div>
)}
{/* Assistant message */}
{!isUser && (
{msg.role === 'assistant' && (
<div style={{
display: 'flex',
justifyContent: 'flex-start',
@@ -898,6 +977,64 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
background: 'var(--material-regular)',
flexShrink: 0,
}}>
{/* Slash command autocomplete dropdown */}
{slashMenuOpen && (
<div
className="animate-slide-down"
style={{
marginBottom: 'var(--space-2)',
background: 'var(--material-thick)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-overlay)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
overflow: 'hidden',
}}
>
{slashMatches.map((cmd, i) => (
<button
key={cmd.name}
onMouseDown={e => {
e.preventDefault() // prevent textarea blur
handleSlashSelect(cmd)
}}
onMouseEnter={() => setSlashIndex(i)}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
width: '100%',
padding: 'var(--space-2) var(--space-3)',
background: i === slashIndex ? 'var(--fill-secondary)' : 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
color: 'var(--text-primary)',
fontSize: 'var(--text-subheadline)',
transition: 'background 100ms',
}}
>
<span style={{
color: 'var(--accent)',
fontWeight: 'var(--weight-semibold)',
fontFamily: '"SF Mono", Menlo, monospace',
fontSize: 'var(--text-footnote)',
minWidth: 60,
}}>
{cmd.name}
</span>
<span style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-caption1)',
}}>
{cmd.description}
</span>
</button>
))}
</div>
)}
{/* Pending attachments preview */}
{pendingAttachments.length > 0 && (
<div style={{ marginBottom: 'var(--space-2)' }}>
@@ -948,7 +1085,17 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onChange={e => {
const val = e.target.value
setInput(val)
if (isSlashInput(val) && !val.includes(' ')) {
const matches = matchCommands(val)
setSlashMatches(matches)
setSlashIndex(0)
} else {
setSlashMatches([])
}
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={`Message ${agent.name}...`}
@@ -1012,7 +1159,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
textAlign: 'center',
marginTop: 'var(--space-1)',
}}>
Enter to send &middot; Shift+Enter for newline
Enter to send &middot; Shift+Enter for newline &middot; / for commands
</div>
</div>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@ export interface MediaAttachment {
export interface Message {
id: string
role: 'user' | 'assistant'
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
media?: MediaAttachment[]
+276
View File
@@ -0,0 +1,276 @@
import { describe, it, expect } from 'vitest'
import {
isSlashInput,
parseSlashCommand,
matchCommands,
executeCommand,
COMMANDS,
} from './slash-commands'
import type { Agent } from './types'
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: 'test-agent',
name: 'TestBot',
title: 'Test Agent',
reportsTo: null,
directReports: [],
soulPath: null,
soul: null,
voiceId: null,
color: '#ff0000',
emoji: '🤖',
tools: [],
crons: [],
memoryPath: null,
description: 'A test agent for unit tests.',
...overrides,
}
}
/* ── isSlashInput ─────────────────────────────────────── */
describe('isSlashInput', () => {
it('returns true for input starting with /', () => {
expect(isSlashInput('/help')).toBe(true)
})
it('returns true for input with leading whitespace before /', () => {
expect(isSlashInput(' /clear')).toBe(true)
})
it('returns true for bare /', () => {
expect(isSlashInput('/')).toBe(true)
})
it('returns false for empty string', () => {
expect(isSlashInput('')).toBe(false)
})
it('returns false for regular text', () => {
expect(isSlashInput('hello world')).toBe(false)
})
it('returns false for slash in middle of text', () => {
expect(isSlashInput('use /help command')).toBe(false)
})
})
/* ── parseSlashCommand ────────────────────────────────── */
describe('parseSlashCommand', () => {
it('parses a known command', () => {
expect(parseSlashCommand('/help')).toEqual({ command: '/help', args: '' })
})
it('parses a command with args', () => {
expect(parseSlashCommand('/help extra stuff')).toEqual({ command: '/help', args: 'extra stuff' })
})
it('is case insensitive', () => {
expect(parseSlashCommand('/CLEAR')).toEqual({ command: '/clear', args: '' })
})
it('handles leading whitespace', () => {
expect(parseSlashCommand(' /soul')).toEqual({ command: '/soul', args: '' })
})
it('returns null for unknown command', () => {
expect(parseSlashCommand('/unknown')).toBeNull()
})
it('returns null for empty string', () => {
expect(parseSlashCommand('')).toBeNull()
})
it('returns null for non-slash input', () => {
expect(parseSlashCommand('hello')).toBeNull()
})
it('parses all registered commands', () => {
for (const cmd of COMMANDS) {
const result = parseSlashCommand(cmd.name)
expect(result).not.toBeNull()
expect(result!.command).toBe(cmd.name)
}
})
})
/* ── matchCommands ────────────────────────────────────── */
describe('matchCommands', () => {
it('returns all commands for bare /', () => {
const matches = matchCommands('/')
expect(matches).toHaveLength(COMMANDS.length)
})
it('filters by partial match', () => {
const matches = matchCommands('/cl')
expect(matches).toHaveLength(1)
expect(matches[0].name).toBe('/clear')
})
it('returns multiple matches when prefix matches several', () => {
const matches = matchCommands('/c')
expect(matches.length).toBeGreaterThanOrEqual(2) // /clear, /crons
expect(matches.map(m => m.name)).toContain('/clear')
expect(matches.map(m => m.name)).toContain('/crons')
})
it('returns exact match', () => {
const matches = matchCommands('/help')
expect(matches).toHaveLength(1)
expect(matches[0].name).toBe('/help')
})
it('returns empty for non-matching input', () => {
expect(matchCommands('/xyz')).toHaveLength(0)
})
it('returns empty for non-slash input', () => {
expect(matchCommands('hello')).toHaveLength(0)
})
it('returns empty for empty string', () => {
expect(matchCommands('')).toHaveLength(0)
})
it('is case insensitive', () => {
const matches = matchCommands('/HEL')
expect(matches).toHaveLength(1)
expect(matches[0].name).toBe('/help')
})
})
/* ── executeCommand ───────────────────────────────────── */
describe('executeCommand', () => {
const agent = makeAgent({
name: 'VERA',
title: 'Chief Strategy Officer',
description: 'Oversees strategic planning.',
tools: ['web-search', 'file-read'],
soul: '# VERA\nI am the strategy lead.',
memoryPath: '/memory/vera',
crons: [
{
id: 'daily-report',
name: 'Daily Report',
schedule: '0 8 * * *',
scheduleDescription: 'Daily at 8 AM',
timezone: 'US/Eastern',
status: 'ok',
lastRun: '2025-01-01',
nextRun: '2025-01-02',
lastError: null,
agentId: 'vera',
description: 'Generate daily report',
enabled: true,
delivery: null,
lastDurationMs: 5000,
consecutiveErrors: 0,
lastDeliveryStatus: null,
},
],
})
it('/clear returns action', () => {
const result = executeCommand('/clear', agent)
expect(result.content).toBe('Conversation cleared.')
expect(result.action).toBe('clear')
})
it('/help lists all commands', () => {
const result = executeCommand('/help', agent)
for (const cmd of COMMANDS) {
expect(result.content).toContain(cmd.name)
expect(result.content).toContain(cmd.description)
}
})
it('/info shows agent profile', () => {
const result = executeCommand('/info', agent)
expect(result.content).toContain('VERA')
expect(result.content).toContain('Chief Strategy Officer')
expect(result.content).toContain('web-search')
expect(result.content).toContain('Memory: /memory/vera')
})
it('/info shows "none" when agent has no tools', () => {
const bare = makeAgent()
const result = executeCommand('/info', bare)
expect(result.content).toContain('Tools: none')
})
it('/info shows "not configured" when no memory path', () => {
const bare = makeAgent()
const result = executeCommand('/info', bare)
expect(result.content).toContain('Memory: not configured')
})
it('/soul shows SOUL.md content', () => {
const result = executeCommand('/soul', agent)
expect(result.content).toBe('# VERA\nI am the strategy lead.')
})
it('/soul handles missing SOUL.md', () => {
const bare = makeAgent()
const result = executeCommand('/soul', bare)
expect(result.content).toContain('No SOUL.md found')
})
it('/tools lists tools', () => {
const result = executeCommand('/tools', agent)
expect(result.content).toContain('web-search')
expect(result.content).toContain('file-read')
})
it('/tools handles no tools', () => {
const bare = makeAgent()
const result = executeCommand('/tools', bare)
expect(result.content).toContain('no tools configured')
})
it('/crons lists cron jobs', () => {
const result = executeCommand('/crons', agent)
expect(result.content).toContain('Daily Report')
expect(result.content).toContain('Daily at 8 AM')
expect(result.content).toContain('ok')
})
it('/crons shows disabled status for disabled jobs', () => {
const agentWithDisabled = makeAgent({
crons: [{
id: 'test',
name: 'Test Job',
schedule: '0 0 * * *',
scheduleDescription: 'Daily at midnight',
timezone: null,
status: 'ok',
lastRun: null,
nextRun: null,
lastError: null,
agentId: null,
description: null,
enabled: false,
delivery: null,
lastDurationMs: null,
consecutiveErrors: 0,
lastDeliveryStatus: null,
}],
})
const result = executeCommand('/crons', agentWithDisabled)
expect(result.content).toContain('disabled')
})
it('/crons handles no cron jobs', () => {
const bare = makeAgent()
const result = executeCommand('/crons', bare)
expect(result.content).toContain('no cron jobs')
})
it('unknown command returns error message', () => {
const result = executeCommand('/bogus', agent)
expect(result.content).toContain('Unknown command')
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { Agent } from './types'
export interface SlashCommand {
name: string
description: string
}
export const COMMANDS: SlashCommand[] = [
{ name: '/clear', description: 'Clear conversation history' },
{ name: '/help', description: 'Show available commands' },
{ name: '/info', description: 'Show agent profile summary' },
{ name: '/soul', description: "Show agent's SOUL.md persona" },
{ name: '/tools', description: "List agent's available tools" },
{ name: '/crons', description: "Show agent's scheduled jobs" },
]
export interface ParsedCommand {
command: string
args: string
}
/** Returns true if input looks like the start of a slash command (leading `/`). */
export function isSlashInput(input: string): boolean {
return input.trimStart().startsWith('/')
}
/** Parse a complete slash command from input. Returns null if not a valid command. */
export function parseSlashCommand(input: string): ParsedCommand | null {
const trimmed = input.trimStart()
if (!trimmed.startsWith('/')) return null
const spaceIdx = trimmed.indexOf(' ')
const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase()
const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
const match = COMMANDS.find(c => c.name === command)
if (!match) return null
return { command: match.name, args }
}
/** Return commands matching a partial input (e.g. "/cl" matches "/clear"). */
export function matchCommands(partial: string): SlashCommand[] {
const trimmed = partial.trimStart().toLowerCase()
if (!trimmed.startsWith('/')) return []
// Show all commands for bare "/"
if (trimmed === '/') return [...COMMANDS]
return COMMANDS.filter(c => c.name.startsWith(trimmed))
}
/** Execute a slash command and return the formatted content string for a system message. */
export function executeCommand(command: string, agent: Agent): { content: string; action?: 'clear' } {
switch (command) {
case '/clear':
return { content: 'Conversation cleared.', action: 'clear' }
case '/help':
return {
content: [
'**Available commands**',
'',
...COMMANDS.map(c => `\`${c.name}\` -- ${c.description}`),
'',
'Type `/` to see the command menu.',
].join('\n'),
}
case '/info':
return {
content: [
`**${agent.name}**`,
agent.title,
'',
agent.description,
'',
`Tools: ${agent.tools.length > 0 ? agent.tools.join(', ') : 'none'}`,
`Cron jobs: ${agent.crons.length}`,
agent.memoryPath ? `Memory: ${agent.memoryPath}` : 'Memory: not configured',
].join('\n'),
}
case '/soul': {
if (!agent.soul) {
return { content: `No SOUL.md found for ${agent.name}.` }
}
return { content: agent.soul }
}
case '/tools': {
if (agent.tools.length === 0) {
return { content: `${agent.name} has no tools configured.` }
}
return {
content: [
`**${agent.name}'s tools**`,
'',
...agent.tools.map(t => `- ${t}`),
].join('\n'),
}
}
case '/crons': {
if (agent.crons.length === 0) {
return { content: `${agent.name} has no cron jobs.` }
}
return {
content: [
`**${agent.name}'s cron jobs**`,
'',
...agent.crons.map(c => {
const status = c.enabled ? c.status : 'disabled'
return `- **${c.name}** (${c.scheduleDescription}) -- ${status}`
}),
].join('\n'),
}
}
default:
return { content: `Unknown command: ${command}` }
}
}