feat: enhance autocomplete components with agent and command metadata support
This commit is contained in:
+4
-2
@@ -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) => {
|
||||||
name: agent.name,
|
const metadata = agentsWithMetadata.find(a => a.name === agent.name);
|
||||||
description: agent.description,
|
return {
|
||||||
mode: agent.mode ?? undefined,
|
name: agent.name,
|
||||||
}));
|
description: agent.description,
|
||||||
|
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,28 +127,50 @@ 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) => {
|
||||||
<div
|
const isSystem = agent.isBuiltIn;
|
||||||
key={agent.name}
|
const isProject = agent.scope === 'project';
|
||||||
className={cn(
|
|
||||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
return (
|
||||||
index === selectedIndex && 'bg-muted'
|
<div
|
||||||
)}
|
key={agent.name}
|
||||||
onClick={() => onAgentSelect(agent.name)}
|
ref={(el) => {
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
itemRefs.current[index] = el;
|
||||||
>
|
}}
|
||||||
<div className="min-w-0">
|
className={cn(
|
||||||
<div className="flex items-center gap-2">
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||||
<span className="font-semibold">#{agent.name}</span>
|
index === selectedIndex && 'bg-muted'
|
||||||
</div>
|
|
||||||
{agent.description && (
|
|
||||||
<div className="typography-meta text-muted-foreground truncate">
|
|
||||||
{agent.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
onClick={() => onAgentSelect(agent.name)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
{agent.description && (
|
||||||
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||||
|
{agent.description}
|
||||||
|
</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,37 +242,56 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{commands.map((command, index) => (
|
{commands.map((command, index) => {
|
||||||
<div
|
const isSystem = command.isBuiltIn;
|
||||||
key={command.name}
|
const isProject = command.scope === 'project';
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
|
||||||
className={cn(
|
return (
|
||||||
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
<div
|
||||||
index === selectedIndex && "bg-muted"
|
key={command.name}
|
||||||
)}
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
onClick={() => onCommandSelect(command)}
|
className={cn(
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
||||||
>
|
index === selectedIndex && "bg-muted"
|
||||||
<div className="mt-0.5">
|
)}
|
||||||
{getCommandIcon(command)}
|
onClick={() => onCommandSelect(command)}
|
||||||
</div>
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
<div className="flex-1 min-w-0">
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="mt-0.5">
|
||||||
<span className="typography-ui-label font-medium">/{command.name}</span>
|
{getCommandIcon(command)}
|
||||||
{command.agent && (
|
</div>
|
||||||
<span className="typography-meta text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
<div className="flex-1 min-w-0">
|
||||||
{command.agent}
|
<div className="flex items-center gap-2">
|
||||||
</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 && (
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{command.description && (
|
||||||
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||||
|
{command.description}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{command.description && (
|
|
||||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
||||||
{command.description}
|
|
||||||
</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,34 +106,42 @@ 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) => {
|
||||||
<div
|
const isProject = skill.scope === 'project';
|
||||||
key={`${skill.name}-${skill.scope}`}
|
return (
|
||||||
ref={(el) => {
|
<div
|
||||||
itemRefs.current[index] = el;
|
key={`${skill.name}-${skill.scope}`}
|
||||||
}}
|
ref={(el) => {
|
||||||
className={cn(
|
itemRefs.current[index] = el;
|
||||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
}}
|
||||||
index === selectedIndex && 'bg-muted'
|
className={cn(
|
||||||
)}
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||||
onClick={() => onSkillSelect(skill.name)}
|
index === selectedIndex && 'bg-muted'
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<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">
|
|
||||||
{skill.scope}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{skill.description && (
|
|
||||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
||||||
{skill.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
onClick={() => onSkillSelect(skill.name)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold truncate">{skill.name}</span>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{skill.description && (
|
||||||
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||||
|
{skill.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user