Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/ui_layer/adapters/browser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1686,6 +1686,10 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None:
env_value = data.get("value", "")
await self._handle_mcp_update_env(name, env_key, env_value)

# Slash command list (for autocomplete)
elif msg_type == "command_list":
await self._handle_command_list()

# Skill settings operations
elif msg_type == "skill_list":
await self._handle_skill_list()
Expand Down Expand Up @@ -5116,6 +5120,34 @@ async def _handle_mcp_update_env(
# Skill Settings Handlers
# ─────────────────────────────────────────────────────────────────────

async def _handle_command_list(self) -> None:
"""Get list of registered non-skill slash commands for autocomplete."""
try:
from app.ui_layer.commands.builtin.skill_invoke import SkillInvokeCommand

cmds = self._controller.command_registry.list_commands(include_hidden=False)
commands = [
{"name": c.name.lstrip("/"), "description": c.description}
for c in cmds
if not isinstance(c, SkillInvokeCommand)
]
await self._broadcast({
"type": "command_list",
"data": {
"success": True,
"commands": commands,
},
})
except Exception as e:
await self._broadcast({
"type": "command_list",
"data": {
"success": False,
"error": str(e),
"commands": [],
},
})

async def _handle_skill_list(self) -> None:
"""Get list of all skills."""
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
min-width: 0;
border-radius: var(--radius-md);
transition: outline var(--transition-fast), background var(--transition-fast);
position: relative;
}

.inputWrapperDragOver {
Expand Down
44 changes: 39 additions & 5 deletions app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Send, Paperclip, X, Loader2, File, AlertCircle, Reply, Mic, MicOff, Che
import { useVirtualizer } from '@tanstack/react-virtual'
import { useWebSocket } from '../../contexts/WebSocketContext'
import { useToast } from '../../contexts/ToastContext'
import { Button, IconButton, StatusIndicator } from '../ui'
import { Button, IconButton, SlashCommandAutocomplete, StatusIndicator } from '../ui'
import type { SlashCommandAutocompleteHandle } from '../ui'
import { useDerivedAgentStatus } from '../../hooks'
import { ChatMessageItem } from '../../pages/Chat/ChatMessage'
import styles from './Chat.module.css'
Expand Down Expand Up @@ -128,6 +129,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
const [isDragOver, setIsDragOver] = useState(false)
const [previewAttachment, setPreviewAttachment] = useState<PendingAttachment | null>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const autocompleteRef = useRef<SlashCommandAutocompleteHandle>(null)
const fileInputRef = useRef<HTMLInputElement>(null)

// Voice input state
Expand Down Expand Up @@ -404,8 +406,29 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
}

const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab' && !e.shiftKey) {
if (autocompleteRef.current?.handleTab()) {
e.preventDefault()
return
}
}
if (e.key === 'ArrowUp') {
if (autocompleteRef.current?.handleUpArrow()) {
e.preventDefault()
return
}
}
if (e.key === 'ArrowDown') {
if (autocompleteRef.current?.handleDownArrow()) {
e.preventDefault()
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (autocompleteRef.current?.handleEnter()) {
return
}
handleSend()
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const history = inputHistoryRef.current
Expand All @@ -422,6 +445,10 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
setInput(history[historyIndexRef.current])
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (autocompleteRef.current?.handleDownArrow()) {
e.preventDefault()
return
}
if (historyIndexRef.current === -1) return
if (historyIndexRef.current < history.length - 1) {
historyIndexRef.current++
Expand Down Expand Up @@ -625,13 +652,13 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
</button>
)}
</div>

{/* Status bar */}
<div className={styles.statusBar}>
<StatusIndicator status={status.state} size="sm" variant="dot" />
<span>{status.message}</span>
</div>

{/* Input area */}
<div className={styles.inputArea}>
<input ref={fileInputRef} type="file" multiple className={styles.hiddenFileInput} onChange={handleFileSelect} />
Expand Down Expand Up @@ -727,7 +754,15 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
))}
</div>
)}


<SlashCommandAutocomplete
ref={autocompleteRef}
input={input}
onSelectItem={(name) => {
setInput(`/${name}`)
inputRef.current?.focus()
}}
/>
<textarea
ref={inputRef}
className={`${styles.input}${isListening ? ` ${styles.inputListening}` : ''}`}
Expand All @@ -741,7 +776,6 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
inputMode="text"
/>
</div>

<Button
icon={<Send size={16} />}
onClick={handleSend}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
.autocomplete {
list-style: none;
padding: 0;
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: calc(100%);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
max-height: 220px;
overflow-y: auto;
overflow-x: hidden;
z-index: 100;
}

.header {
display: block;
width: 100%;
box-sizing: border-box;
resize: none;
min-height: 36px;
max-height: 116px;
overflow-y: auto;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: var(--font-medium);
font-family: inherit;
line-height: var(--leading-normal);
display: flex;
align-items: center;
gap: 6px;
}

.item {
display: block;
width: 100%;
box-sizing: border-box;
resize: none;
min-height: 36px;
max-height: 116px;
overflow-y: auto;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--bg-primary);
color: var(--text-primary);
font-size: var(--text-sm);
font-family: inherit;
line-height: var(--leading-normal);
}

.item:hover,
.itemSelected {
color: var(--text-primary);
background: var(--bg-tertiary);
cursor: pointer;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React, { useEffect, useRef, useState, useMemo, useImperativeHandle, forwardRef } from 'react'
import { useSettingsWebSocket } from '@/pages/Settings/useSettingsWebSocket';
import { ActivitySquare, Terminal } from 'lucide-react'
import styles from './SlashCommandAutocomplete.module.css';
import { useAppSelector } from '../../store/hooks';
import {
selectSkillsHasLoaded,
selectEnabledSkillNames,
} from '../../store/selectors/skillsSettings'
import {
selectCommandNames,
selectCommandsHasLoaded,
} from '../../store/selectors/commandsSettings'

type ItemKind = 'command' | 'skill'

interface AutocompleteItem {
name: string
kind: ItemKind
}

export interface SlashCommandAutocompleteHandle {
/**
* Handle a Tab keypress from the parent input.
* - When 1 item is visible: commits it via `onSelectItem` and returns true.
* - When >1 items are visible: cycles the highlighted index and returns true.
* - When no items are visible: returns false so the caller can do nothing / fall through.
*/
handleTab: () => boolean
handleUpArrow: () => boolean
handleDownArrow: () => boolean
handleEnter: () => boolean
isOpen: () => boolean
}

interface SlashCommandProps {
input: string;
onSelectItem: (name: string) => void;
}

export const SlashCommandAutocomplete = forwardRef<SlashCommandAutocompleteHandle, SlashCommandProps>(
function SlashCommandAutocomplete({ input, onSelectItem }, ref) {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const itemRefs = useRef<(HTMLLIElement | null)[]>([])

const skills = useAppSelector(selectEnabledSkillNames);
const skillsHasLoaded = useAppSelector(selectSkillsHasLoaded);

const commands = useAppSelector(selectCommandNames);
const commandsHasLoaded = useAppSelector(selectCommandsHasLoaded);
const { send, isConnected } = useSettingsWebSocket()

// Fetch only if no one else has loaded the data yet this session.
useEffect(() => {
if (!isConnected) return
if (!skillsHasLoaded) send('skill_list')
if (!commandsHasLoaded) send('command_list')
}, [isConnected, skillsHasLoaded, commandsHasLoaded, send])

const query = input[0] === '/' ? input.slice(1).toLowerCase() : null

const { filteredCommands, filteredSkills, flatItems } = useMemo(() => {
if (query === null) {
return { filteredCommands: [], filteredSkills: [], flatItems: [] as AutocompleteItem[] }
}
const fc = commands.filter((item: string) => item.toLowerCase().includes(query))
const fs = skills.filter((item: string) => item.toLowerCase().includes(query))
const flat: AutocompleteItem[] = [
...fc.map<AutocompleteItem>((name: string) => ({ name, kind: 'command' })),
...fs.map<AutocompleteItem>((name: string) => ({ name, kind: 'skill' })),
]
return { filteredCommands: fc, filteredSkills: fs, flatItems: flat }
}, [query, commands, skills])

useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])

// Reset / clamp the selected index whenever the visible list changes.
useEffect(() => {
if (flatItems.length === 0) {
if (selectedIndex !== 0) setSelectedIndex(0)
return
}
if (selectedIndex >= flatItems.length) {
setSelectedIndex(0)
}
}, [flatItems.length, selectedIndex])

useImperativeHandle(ref, () => ({
handleTab: () => {
if (flatItems.length === 0) return false
onSelectItem(flatItems[selectedIndex].name)
return true
},
handleUpArrow: () => {
if (flatItems.length === 0 || flatItems.length === 1) return false
setSelectedIndex(prev => (prev - 1 + flatItems.length) % flatItems.length)
return true
},
handleDownArrow: () => {
if (flatItems.length === 0 || flatItems.length === 1) return false
setSelectedIndex(prev => (prev + 1) % flatItems.length)
return true
},
handleEnter: () => {
if (flatItems.length === 0) return false
if (input.toLowerCase() === `/${flatItems[selectedIndex].name}`) return false
onSelectItem(flatItems[selectedIndex].name)
return true
},
isOpen: () => flatItems.length > 0,
}), [flatItems, onSelectItem, selectedIndex])

if (flatItems.length === 0) return null

let runningIndex = 0
return (
<div>
<ul className={styles.autocomplete}>
{filteredCommands.length > 0 && (
<>
<p className={styles.header}><Terminal size={12} />Commands</p>
{filteredCommands.map((item: string) => {
const idx = runningIndex++
const isSelected = idx === selectedIndex
return (
<li
key={`cmd-${item}`}
ref={el => { itemRefs.current[idx] = el }}
className={`${styles.item}${isSelected ? ` ${styles.itemSelected}` : ''}`}
onClick={() => onSelectItem(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>/{item}</li>
)
})}
</>
)}
{filteredSkills.length > 0 && (
<>
<p className={styles.header}><ActivitySquare size={12} />Skills</p>
{filteredSkills.map((item: string) => {
const idx = runningIndex++
const isSelected = idx === selectedIndex
return (
<li
key={`skill-${item}`}
ref={el => { itemRefs.current[idx] = el }}
className={`${styles.item}${isSelected ? ` ${styles.itemSelected}` : ''}`}
onClick={() => onSelectItem(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>/{item}</li>
)
})}
</>
)}
</ul>
</div>
);
})
3 changes: 3 additions & 0 deletions app/ui_layer/browser/frontend/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ export type { ConfirmModalProps } from './ConfirmModal'

export { SkillCreatorModal } from './SkillCreatorModal'
export type { SkillCreatorModalProps, SkillCreatorMode, SkillCreatorSubmit } from './SkillCreatorModal'

export { SlashCommandAutocomplete } from './SlashCommandAutocomplete'
export type { SlashCommandAutocompleteHandle } from './SlashCommandAutocomplete'
Loading