docs: add implementation plans for upcoming features
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>
This commit is contained in:
509
PLAN-slash-autocomplete.md
Normal file
509
PLAN-slash-autocomplete.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# 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
|
||||
|
||||
1. User opens a session modal or card with the input field
|
||||
2. User types the trigger character (`/` for Claude, `$` for Codex):
|
||||
- At position 0, OR
|
||||
- After a space/newline (mid-message)
|
||||
3. Autocomplete dropdown appears showing available skills (alphabetically sorted)
|
||||
4. 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
|
||||
5. Selected skill replaces the trigger with `{trigger}skill-name ` (e.g., `/commit ` or `$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.json` plus `~/.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:
|
||||
|
||||
```typescript
|
||||
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**:
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
|
||||
```python
|
||||
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`
|
||||
|
||||
```python
|
||||
# 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`
|
||||
|
||||
```javascript
|
||||
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`
|
||||
|
||||
```javascript
|
||||
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`
|
||||
|
||||
```javascript
|
||||
// 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`
|
||||
|
||||
```javascript
|
||||
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.
|
||||
|
||||
```javascript
|
||||
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`
|
||||
|
||||
```javascript
|
||||
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).
|
||||
|
||||
```javascript
|
||||
${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`
|
||||
|
||||
```javascript
|
||||
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
|
||||
1. Claude session: Type `/` - dropdown appears with Claude skills
|
||||
2. Codex session: Type `$` - dropdown appears with Codex skills
|
||||
3. Claude session: Type `$` - nothing happens (wrong trigger)
|
||||
4. Type `/com` - list filters to skills containing "com"
|
||||
5. Mid-message: Type "please run /commit" - autocomplete triggers on `/`
|
||||
6. Arrow keys navigate, Enter selects
|
||||
7. Escape dismisses without selection
|
||||
8. Click outside dismisses
|
||||
9. Selected skill shows as `{trigger}skill-name ` in input
|
||||
10. Verify alphabetical ordering
|
||||
11. 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 `SkillsMixin` with `_enumerate_codex_skills()` and `_enumerate_claude_skills()`
|
||||
- Add `/api/skills?agent=` endpoint in `HttpMixin.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 `autocompleteConfig` to 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
|
||||
Reference in New Issue
Block a user