feat: enhance autocomplete components with agent and command metadata support

This commit is contained in:
Bohdan Triapitsyn
2026-01-08 20:19:33 +02:00
parent a2f2d91a22
commit 2a895311d9
4 changed files with 169 additions and 89 deletions
+4 -2
View File
@@ -4,8 +4,10 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Added support for model variants (thinking effort). - Chat: Added support for model variants (thinking effort).
- Switched agent cycling shortcut from TAB to Shift + TAB. - Shortcuts: Switched agent cycling shortcut from TAB to Shift + TAB.
- Skills: added autocomplete for skills on "/" when it is not the first character in input.
- Autocomplete: added scope badges for commands/agents/skills.
## [1.4.4] - 2026-01-08 ## [1.4.4] - 2026-01-08
@@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useAgentsStore } from '@/stores/useAgentsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface AgentInfo { interface AgentInfo {
name: string; name: string;
description?: string; description?: string;
mode?: string | null; mode?: string | null;
scope?: string;
isBuiltIn?: boolean;
} }
export interface AgentMentionAutocompleteHandle { export interface AgentMentionAutocompleteHandle {
@@ -34,17 +37,30 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState<AgentInfo[]>([]); const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const { getVisibleAgents } = useConfigStore(); const { getVisibleAgents } = useConfigStore();
const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
React.useEffect(() => {
if (agentsWithMetadata.length === 0) {
void loadAgents();
}
}, [loadAgents, agentsWithMetadata.length]);
React.useEffect(() => { React.useEffect(() => {
const visibleAgents = getVisibleAgents(); const visibleAgents = getVisibleAgents();
const filtered = visibleAgents const filtered = visibleAgents
.filter((agent) => isMentionable(agent.mode)) .filter((agent) => isMentionable(agent.mode))
.map((agent) => ({ .map((agent) => {
const metadata = agentsWithMetadata.find(a => a.name === agent.name);
return {
name: agent.name, name: agent.name,
description: agent.description, description: agent.description,
mode: agent.mode ?? undefined, mode: agent.mode ?? undefined,
})); scope: (metadata as any)?.scope,
isBuiltIn: (metadata as any)?.native || (metadata as any)?.builtIn,
};
});
const normalizedQuery = searchQuery.trim().toLowerCase(); const normalizedQuery = searchQuery.trim().toLowerCase();
const matches = normalizedQuery.length const matches = normalizedQuery.length
@@ -57,6 +73,13 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
setSelectedIndex(0); setSelectedIndex(0);
}, [getVisibleAgents, searchQuery]); }, [getVisibleAgents, searchQuery]);
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}, [selectedIndex]);
React.useEffect(() => { React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => { const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null; const target = event.target as Node | null;
@@ -104,9 +127,16 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
}, },
}), [agents, onAgentSelect, onClose, selectedIndex]); }), [agents, onAgentSelect, onClose, selectedIndex]);
const renderAgent = (agent: AgentInfo, index: number) => ( const renderAgent = (agent: AgentInfo, index: number) => {
const isSystem = agent.isBuiltIn;
const isProject = agent.scope === 'project';
return (
<div <div
key={agent.name} key={agent.name}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={cn( className={cn(
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', 'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
index === selectedIndex && 'bg-muted' index === selectedIndex && 'bg-muted'
@@ -114,18 +144,33 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
onClick={() => onAgentSelect(agent.name)} onClick={() => onAgentSelect(agent.name)}
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
> >
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold">#{agent.name}</span> <span className="font-semibold">#{agent.name}</span>
{isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
</span>
) : agent.scope ? (
<span className={cn(
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0",
isProject
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
)}>
{agent.scope}
</span>
) : null}
</div> </div>
{agent.description && ( {agent.description && (
<div className="typography-meta text-muted-foreground truncate"> <div className="typography-meta text-muted-foreground mt-0.5 truncate">
{agent.description} {agent.description}
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
};
return ( return (
<div <div
@@ -3,6 +3,7 @@ import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsL
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -12,6 +13,7 @@ interface CommandInfo {
agent?: string; agent?: string;
model?: string; model?: string;
isBuiltIn?: boolean; isBuiltIn?: boolean;
scope?: string;
} }
export interface CommandAutocompleteHandle { export interface CommandAutocompleteHandle {
@@ -43,6 +45,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const [commands, setCommands] = React.useState<CommandInfo[]>([]); const [commands, setCommands] = React.useState<CommandInfo[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<HTMLDivElement | null>(null);
@@ -65,19 +68,22 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}; };
}, [onClose]); }, [onClose]);
React.useEffect(() => {
// Force refresh to get latest project context when mounting
void refreshCommands();
}, [refreshCommands]);
React.useEffect(() => { React.useEffect(() => {
const loadCommands = async () => { const loadCommands = async () => {
setLoading(true); setLoading(true);
try { try {
const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({
const apiCommands = await opencodeClient.listCommands();
const customCommands: CommandInfo[] = apiCommands.map(cmd => ({
name: cmd.name, name: cmd.name,
description: cmd.description, description: cmd.description,
agent: cmd.agent, agent: cmd.agent ?? undefined,
model: cmd.model, model: cmd.model ?? undefined,
isBuiltIn: false isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
scope: cmd.scope,
})); }));
const builtInCommands: CommandInfo[] = [ const builtInCommands: CommandInfo[] = [
@@ -154,7 +160,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}; };
loadCommands(); loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession]); }, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata]);
React.useEffect(() => { React.useEffect(() => {
setSelectedIndex(0); setSelectedIndex(0);
@@ -236,7 +242,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</div> </div>
) : ( ) : (
<div> <div>
{commands.map((command, index) => ( {commands.map((command, index) => {
const isSystem = command.isBuiltIn;
const isProject = command.scope === 'project';
return (
<div <div
key={command.name} key={command.name}
ref={(el) => { itemRefs.current[index] = el; }} ref={(el) => { itemRefs.current[index] = el; }}
@@ -253,8 +263,22 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-ui-label font-medium">/{command.name}</span> <span className="typography-ui-label font-medium">/{command.name}</span>
{isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
</span>
) : command.scope ? (
<span className={cn(
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0",
isProject
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
)}>
{command.scope}
</span>
) : null}
{command.agent && ( {command.agent && (
<span className="typography-meta text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <span className="text-[10px] leading-none font-bold tracking-tight bg-[var(--surface-subtle)] text-[var(--surface-foreground)] border-[var(--interactive-border)] px-1.5 py-1 rounded border flex-shrink-0">
{command.agent} {command.agent}
</span> </span>
)} )}
@@ -266,7 +290,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
)} )}
</div> </div>
</div> </div>
))} );
})}
{commands.length === 0 && ( {commands.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground"> <div className="px-3 py-2 typography-ui-label text-muted-foreground">
No commands found No commands found
@@ -106,7 +106,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
}, },
}), [filteredSkills, onSkillSelect, onClose, selectedIndex]); }), [filteredSkills, onSkillSelect, onClose, selectedIndex]);
const renderSkill = (skill: SkillInfo, index: number) => ( const renderSkill = (skill: SkillInfo, index: number) => {
const isProject = skill.scope === 'project';
return (
<div <div
key={`${skill.name}-${skill.scope}`} key={`${skill.name}-${skill.scope}`}
ref={(el) => { ref={(el) => {
@@ -122,7 +124,12 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold truncate">{skill.name}</span> <span className="font-semibold truncate">{skill.name}</span>
<span className="text-[10px] leading-none uppercase font-bold tracking-tight text-muted-foreground/70 bg-muted/50 px-1 py-0.5 rounded border border-border/40 flex-shrink-0"> <span className={cn(
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 transition-colors",
isProject
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
)}>
{skill.scope} {skill.scope}
</span> </span>
</div> </div>
@@ -134,6 +141,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
</div> </div>
</div> </div>
); );
};
return ( return (
<div <div