Files
amc/PLAN-slash-autocomplete.md
teernisse 2926645b10 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>
2026-02-26 15:25:09 -05:00

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

  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:

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

  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