Planning documents for future AMC features: PLAN-slash-autocomplete.md: - Slash-command autocomplete for SimpleInput - Skills API endpoint, SlashMenu dropdown, keyboard navigation - 8 implementation steps with file locations and dependencies plans/agent-spawning.md: - Agent spawning acceptance criteria documentation - Spawn command integration, status tracking, error handling - Written as testable acceptance criteria (AC-1 through AC-10) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
Plan: Skill Autocomplete for Agent Sessions
Summary
Add autocomplete functionality to the SimpleInput component that displays available skills when the user types the agent-specific trigger character (/ for Claude, $ for Codex). Autocomplete triggers at the start of input or after any whitespace, enabling quick skill discovery and selection mid-message.
User Workflow
- User opens a session modal or card with the input field
- User types the trigger character (
/for Claude,$for Codex):- At position 0, OR
- After a space/newline (mid-message)
- Autocomplete dropdown appears showing available skills (alphabetically sorted)
- User can:
- Continue typing to filter the list
- Use arrow keys to navigate
- Press Enter/Tab to select and insert the skill name
- Press Escape or click outside to dismiss
- Selected skill replaces the trigger with
{trigger}skill-name(e.g.,/commitor$yeet)
Acceptance Criteria
Core Functionality
- AC-1: Autocomplete triggers when trigger character is typed at position 0 or after whitespace
- AC-2: Claude sessions use
/trigger; Codex sessions use$trigger - AC-3: Wrong trigger character for agent type is ignored (no autocomplete)
- AC-4: Dropdown displays skill names with trigger prefix and descriptions
- AC-5: Skills are sorted alphabetically by name
- AC-6: Typing additional characters filters the skill list (case-insensitive match on name)
- AC-7: Arrow up/down navigates the highlighted option
- AC-8: Enter or Tab inserts the selected skill name (with trigger) followed by a space
- AC-9: Escape, clicking outside, or backspacing over the trigger character dismisses the dropdown without insertion
- AC-10: Cursor movement (arrow left/right) is ignored while autocomplete is open; dropdown position is locked to trigger location
- AC-11: If no skills match the filter, dropdown shows "No matching skills"
Data Flow
- AC-12: On session open, an agent-specific config is loaded containing: (a) trigger character (
/for Claude,$for Codex), (b) enumerated skills list - AC-13: Claude skills are enumerated from
~/.claude/skills/ - AC-14: Codex skills are loaded from
~/.codex/vendor_imports/skills-curated-cache.jsonplus~/.codex/skills/ - AC-15: If session has no skills, dropdown shows "No skills available" when trigger is typed
UX Polish
- AC-16: Dropdown positions above the input (bottom-anchored), aligned left
- AC-17: Dropdown has max height with vertical scroll for long lists
- AC-18: Currently highlighted item is visually distinct
- AC-19: Dropdown respects the existing color scheme
- AC-20: After skill insertion, cursor is positioned after the trailing space, ready to continue typing
Known Limitations (Out of Scope)
- Duplicate skill names: If curated and user skills share a name, both appear (no deduplication)
- Long skill names: No truncation; names may overflow if extremely long
- Accessibility: ARIA roles, active-descendant, screen reader support deferred to future iteration
- IME/composition: Japanese/Korean input edge cases not handled in v1
- Server-side caching: Skills re-enumerated on each request; mtime-based cache could improve performance at scale
Architecture
Autocomplete Config Per Agent
Each session gets an autocomplete config loaded at modal open:
type AutocompleteConfig = {
trigger: '/' | '$';
skills: Array<{ name: string; description: string }>;
}
| Agent | Trigger | Skill Sources |
|---|---|---|
| Claude | / |
Enumerate ~/.claude/skills/*/ |
| Codex | $ |
~/.codex/vendor_imports/skills-curated-cache.json + ~/.codex/skills/*/ |
Server-Side: New Endpoint for Skills
Endpoint: GET /api/skills?agent={claude|codex}
Response:
{
"trigger": "/",
"skills": [
{ "name": "commit", "description": "Create a git commit with a message" },
{ "name": "review-pr", "description": "Review a pull request" }
]
}
Rationale: Skills are agent-global, not session-specific. The client already knows session.agent from state, so no session_id is needed. Server enumerates skill directories directly.
Component Structure
SimpleInput.js
├── Props: sessionId, status, onRespond, agent, autocompleteConfig
├── State: text, focused, sending, error
├── State: showAutocomplete, selectedIndex
├── Derived: triggerMatch (detects trigger at valid position)
├── Derived: filterText, filteredSkills (alphabetically sorted)
├── onInput: detect trigger character at pos 0 or after whitespace
├── onKeyDown: arrow/enter/escape handling for autocomplete
└── Render: textarea + autocomplete dropdown
Data Flow
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Modal opens │────▶│ GET /api/skills?agent│────▶│ SimpleInput │
│ (session) │ │ (server) │ │ (dropdown) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
Skills are agent-global, so the same response can be cached client-side per agent type.
Implementation Specifications
IMP-1: Server-side skill enumeration (fulfills AC-12, AC-13, AC-14, AC-15)
Location: amc_server/mixins/skills.py (new file)
class SkillsMixin:
def _serve_skills(self, agent):
"""Return autocomplete config for a session."""
if agent == "codex":
trigger = "$"
skills = self._enumerate_codex_skills()
else: # claude
trigger = "/"
skills = self._enumerate_claude_skills()
# Sort alphabetically
skills.sort(key=lambda s: s["name"].lower())
self._send_json(200, {"trigger": trigger, "skills": skills})
def _enumerate_codex_skills(self):
"""Load Codex skills from cache + user directory."""
skills = []
# Curated skills from cache
cache_file = Path.home() / ".codex/vendor_imports/skills-curated-cache.json"
if cache_file.exists():
try:
data = json.loads(cache_file.read_text())
for skill in data.get("skills", []):
skills.append({
"name": skill.get("id", skill.get("name", "")),
"description": skill.get("shortDescription", skill.get("description", ""))[:100]
})
except (json.JSONDecodeError, OSError):
pass
# User-installed skills
user_skills_dir = Path.home() / ".codex/skills"
if user_skills_dir.exists():
for skill_dir in user_skills_dir.iterdir():
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
skill_md = skill_dir / "SKILL.md"
description = ""
if skill_md.exists():
# Parse first non-empty line as description
try:
for line in skill_md.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
description = line[:100]
break
except OSError:
pass
skills.append({
"name": skill_dir.name,
"description": description or f"User skill: {skill_dir.name}"
})
return skills
def _enumerate_claude_skills(self):
"""Load Claude skills from user directory.
Note: Checks SKILL.md first (canonical casing used by Claude Code),
then falls back to lowercase variants for compatibility.
"""
skills = []
skills_dir = Path.home() / ".claude/skills"
if skills_dir.exists():
for skill_dir in skills_dir.iterdir():
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
# Look for SKILL.md (canonical), then fallbacks
description = ""
for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]:
md_file = skill_dir / md_name
if md_file.exists():
try:
content = md_file.read_text()
# Find first meaningful line
for line in content.splitlines():
line = line.strip()
if line and not line.startswith("#") and not line.startswith("<!--"):
description = line[:100]
break
if description:
break
except OSError:
pass
skills.append({
"name": skill_dir.name,
"description": description or f"Skill: {skill_dir.name}"
})
return skills
IMP-2: Add skills endpoint to HttpMixin (fulfills AC-12)
Location: amc_server/mixins/http.py
# In HttpMixin.do_GET, add route handling:
elif path == "/api/skills":
agent = query_params.get("agent", ["claude"])[0]
self._serve_skills(agent)
Note: Route goes in HttpMixin.do_GET (where all GET routing lives), not handler.py. The handler just composes mixins.
IMP-3: Client-side API call (fulfills AC-12)
Location: dashboard/utils/api.js
export const API_SKILLS = '/api/skills';
export async function fetchSkills(agent) {
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
const response = await fetch(url);
if (!response.ok) return null;
return response.json();
}
IMP-4: Autocomplete config loading in Modal (fulfills AC-12)
Location: dashboard/components/Modal.js
const [autocompleteConfig, setAutocompleteConfig] = useState(null);
// Load skills when agent type changes
useEffect(() => {
if (!session) {
setAutocompleteConfig(null);
return;
}
const agent = session.agent || 'claude';
fetchSkills(agent)
.then(config => setAutocompleteConfig(config))
.catch(() => setAutocompleteConfig(null));
}, [session?.agent]);
// Pass to SimpleInput
<${SimpleInput}
...
autocompleteConfig=${autocompleteConfig}
/>
IMP-5: Trigger detection logic (fulfills AC-1, AC-2, AC-3)
Location: dashboard/components/SimpleInput.js
// Detect if we should show autocomplete
const getTriggerInfo = useCallback((value, cursorPos) => {
if (!autocompleteConfig) return null;
const { trigger } = autocompleteConfig;
// Find the start of the current "word" (after last whitespace before cursor)
let wordStart = cursorPos;
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
wordStart--;
}
// Check if word starts with trigger
if (value[wordStart] === trigger) {
return {
trigger,
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
replaceStart: wordStart,
replaceEnd: cursorPos
};
}
return null;
}, [autocompleteConfig]);
IMP-6: Filtered and sorted skills (fulfills AC-5, AC-6)
Location: dashboard/components/SimpleInput.js
const filteredSkills = useMemo(() => {
if (!autocompleteConfig || !triggerInfo) return [];
const { skills } = autocompleteConfig;
const { filterText } = triggerInfo;
let filtered = skills;
if (filterText) {
filtered = skills.filter(s =>
s.name.toLowerCase().includes(filterText)
);
}
// Already sorted by server, but ensure alphabetical
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}, [autocompleteConfig, triggerInfo]);
IMP-7: Keyboard navigation (fulfills AC-7, AC-8, AC-9)
Location: dashboard/components/SimpleInput.js
Note: Enter with empty filter dismisses dropdown (doesn't submit message). This prevents accidental submissions when user types a partial match that has no results.
onKeyDown=${(e) => {
if (showAutocomplete) {
// Always handle Escape when dropdown is open
if (e.key === 'Escape') {
e.preventDefault();
setShowAutocomplete(false);
return;
}
// Handle Enter/Tab: select if matches exist, otherwise dismiss (don't submit)
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {
insertSkill(filteredSkills[selectedIndex]);
} else {
setShowAutocomplete(false);
}
return;
}
// Arrow navigation only when there are matches
if (filteredSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
return;
}
}
}
// Existing Enter-to-submit logic (only when dropdown is closed)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
IMP-8: Skill insertion (fulfills AC-8)
Location: dashboard/components/SimpleInput.js
const insertSkill = useCallback((skill) => {
if (!triggerInfo || !autocompleteConfig) return;
const { trigger } = autocompleteConfig;
const { replaceStart, replaceEnd } = triggerInfo;
const before = text.slice(0, replaceStart);
const after = text.slice(replaceEnd);
const inserted = `${trigger}${skill.name} `;
setText(before + inserted + after);
setShowAutocomplete(false);
// Move cursor after inserted text
const newCursorPos = replaceStart + inserted.length;
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
}, [text, triggerInfo, autocompleteConfig]);
IMP-9: Autocomplete dropdown UI (fulfills AC-4, AC-10, AC-15, AC-16, AC-17, AC-18)
Location: dashboard/components/SimpleInput.js
Note: Uses index as key instead of skill.name to handle potential duplicate skill names (curated + user skills with same name).
${showAutocomplete && html`
<div
ref=${autocompleteRef}
class="absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50"
>
${filteredSkills.length === 0 ? html`
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
` : filteredSkills.map((skill, i) => html`
<div
key=${i}
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
i === selectedIndex
? 'bg-selection/50 text-bright'
: 'text-fg hover:bg-selection/25'
}"
onClick=${() => insertSkill(skill)}
onMouseEnter=${() => setSelectedIndex(i)}
>
<div class="font-medium font-mono text-bright">
${autocompleteConfig.trigger}${skill.name}
</div>
<div class="text-micro text-dim truncate">${skill.description}</div>
</div>
`)}
</div>
`}
IMP-10: Click-outside dismissal (fulfills AC-9)
Location: dashboard/components/SimpleInput.js
useEffect(() => {
if (!showAutocomplete) return;
const handleClickOutside = (e) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
textareaRef.current && !textareaRef.current.contains(e.target)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showAutocomplete]);
Testing Considerations
Manual Testing Checklist
- Claude session: Type
/- dropdown appears with Claude skills - Codex session: Type
$- dropdown appears with Codex skills - Claude session: Type
$- nothing happens (wrong trigger) - Type
/com- list filters to skills containing "com" - Mid-message: Type "please run /commit" - autocomplete triggers on
/ - Arrow keys navigate, Enter selects
- Escape dismisses without selection
- Click outside dismisses
- Selected skill shows as
{trigger}skill-namein input - Verify alphabetical ordering
- Verify vertical scroll with many skills
Edge Cases
- Session without skills (dropdown doesn't appear)
- Single skill (still shows dropdown)
- Very long skill descriptions (truncated with ellipsis)
- Multiple triggers in one message (each can trigger independently)
- Backspace over trigger (dismisses autocomplete)
Rollout Slices
Slice 1: Server-side skill enumeration
- Add
SkillsMixinwith_enumerate_codex_skills()and_enumerate_claude_skills() - Add
/api/skills?agent=endpoint inHttpMixin.do_GET - Test endpoint returns correct data for each agent type
Slice 2: Client-side skill loading
- Add
fetchSkills()API helper - Load skills in Modal.js on session open
- Pass
autocompleteConfigto SimpleInput
Slice 3: Basic autocomplete trigger
- Add trigger detection logic (position 0 + after whitespace)
- Show/hide dropdown based on trigger
- Basic filtered list display
Slice 4: Keyboard navigation + selection
- Arrow key navigation
- Enter/Tab selection
- Escape dismissal
- Click-outside dismissal
Slice 5: Polish
- Mouse hover to select
- Scroll into view for long lists
- Cursor positioning after insertion