# 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("