title: Add fetchSkills API helper

description:
## Overview
Add fetchSkills() function to dashboard/utils/api.js for calling the skills endpoint.

## Background
This is the client-side helper that fetches the autocomplete config. It's a simple wrapper around fetch() following the existing API pattern in the file.

## Implementation (from plan IMP-3)
```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();
}
```

## Return Type
```typescript
type AutocompleteConfig = {
  trigger: '/' | '$';
  skills: Array<{ name: string; description: string }>;
}
```

## Error Handling
- HTTP error: return null (graceful degradation, no autocomplete)
- Network failure: returns null via failed fetch

## Why Return null on Error
- Autocomplete is a convenience feature, not critical
- Modal should still work without autocomplete
- Avoids error toasts for non-critical failure

## Success Criteria
- Function exported from api.js
- Agent param properly encoded in URL
- Returns parsed JSON on success
- Returns null on any failure

status: open

issue_type: task

created_by: tayloreernisse

type: blocks


title: Add autocomplete state management

description:
## Overview
Add state variables and refs for managing autocomplete visibility and selection in SimpleInput.js.

## Background
The autocomplete dropdown needs state to track:
- Whether it's currently visible
- Which item is highlighted (for keyboard navigation)
- The current trigger info (for insertion)
- Refs for DOM elements (for click-outside detection)

## Implementation
Add to SimpleInput component:
```javascript
// State
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);

// Refs for click-outside detection
const autocompleteRef = useRef(null);

// Compute triggerInfo on input change
const [triggerInfo, setTriggerInfo] = useState(null);

// Update trigger info and visibility on text/cursor change
useEffect(() => {
  const textarea = textareaRef.current;
  if (!textarea) return;

  const info = getTriggerInfo(text, textarea.selectionStart);
  setTriggerInfo(info);
  setShowAutocomplete(!!info);

  // Reset selection when dropdown opens
  if (info && !triggerInfo) {
    setSelectedIndex(0);
  }
}, [text, getTriggerInfo]);
```

## State Flow
1. User types -> text state updates
2. useEffect computes triggerInfo
3. If trigger detected -> showAutocomplete = true
4. selectedIndex tracks keyboard navigation
5. On selection/dismiss -> showAutocomplete = false

## Reset Behavior
- selectedIndex resets to 0 when dropdown opens
- When filter changes, clamp selectedIndex to valid range

## Success Criteria
- showAutocomplete correctly reflects trigger state
- selectedIndex tracks current selection
- State resets appropriately on open/close
- Refs available for DOM interaction


title: Implement autocomplete dropdown UI

description:
## Overview
Add the autocomplete dropdown JSX to SimpleInput.js render method, positioned above the input.

## Background
The dropdown shows filtered skills with visual highlighting. Per AC-11, AC-15, AC-16-19:
- Positioned above input (bottom-anchored)
- Left-aligned
- Max height with scroll
- Highlighted item visually distinct
- Respects color scheme
- Handles both "no skills available" AND "no matching skills"

## Implementation (from plan IMP-9, with AC-15 fix)
```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"
  >
    ${autocompleteConfig.skills.length === 0 ? html`
      <!-- AC-15: Session has no skills at all -->
      <div class="px-3 py-2 text-sm text-dim">No skills available</div>
    ` : filteredSkills.length === 0 ? html`
      <!-- AC-11: Filter matched nothing -->
      <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>
`}
```

## Two Empty States (IMPORTANT)
1. **"No skills available"** (AC-15): `autocompleteConfig.skills.length === 0`
   - Agent has zero skills installed
   - Dropdown still appears to inform user

2. **"No matching skills"** (AC-11): `filteredSkills.length === 0`
   - Skills exist, but filter text matches none
   - User typed something that does not match any skill

## Styling Decisions
- **bottom-full mb-1**: Positions above input with 4px gap
- **max-h-48**: ~192px max height (AC-17)
- **overflow-y-auto**: Vertical scroll for long lists
- **z-50**: Ensures dropdown above other content
- **bg-selection/50**: Highlighted item background
- **text-dim/text-bright**: Color hierarchy

## Key Details
- Uses index as key (plan note: handles duplicate skill names from curated + user)
- onMouseEnter updates selectedIndex (mouse hover selection)
- onClick calls insertSkill directly
- Nested ternary: skills.length → filteredSkills.length → map

## Container Requirements
SimpleInput outer container needs position: relative for absolute positioning.

## Success Criteria
- Dropdown appears above input when showAutocomplete true
- "No skills available" when agent has zero skills
- "No matching skills" when filter matches nothing
- Selected item visually highlighted
- Scrollable when many skills
- Mouse hover updates selection
- Click inserts skill


title: Implement filtered skills computation

description:
## Overview
Add useMemo for filteredSkills in SimpleInput.js that filters the skills list based on user input.

## Background
As the user types after the trigger character, the list should filter to show only matching skills. This provides instant feedback and makes finding skills faster.

## Implementation (from plan IMP-6)
```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]);
```

## Filtering Behavior (from AC-6)
- Case-insensitive matching
- Matches anywhere in skill name (not just prefix)
- Empty filterText shows all skills
- No matches returns empty array (handled by UI)

## Why useMemo
- Skills list could be large (50+)
- Filter runs on every keystroke
- Sorting on each render would be wasteful
- Memoization prevents unnecessary recalculation

## Key Details
- triggerInfo.filterText is already lowercase
- Server pre-sorts, but we re-sort after filtering (stability)
- localeCompare for proper alphabetical ordering

## Success Criteria
- Returns empty array when no autocompleteConfig
- Filters based on filterText (case-insensitive)
- Results sorted alphabetically
- Empty input shows all skills
- Memoized to prevent unnecessary recalculation


title: Implement backspace dismissal of autocomplete

description:
## Overview
Ensure that backspacing over the trigger character dismisses the autocomplete dropdown (AC-9).

## Background
When the user types '/com' and then backspaces to '/', they might continue backspacing to remove the '/'. At that point, the autocomplete should dismiss.

## Implementation
This is already handled by the existing trigger detection:
- When text changes, triggerInfo is recomputed
- If cursor is before the trigger char, getTriggerInfo returns null
- showAutocomplete becomes false

## Verification Needed
- Type '/com'
- Backspace to '/'
- Backspace to ''
- Dropdown should dismiss when '/' is deleted

## Success Criteria
- Backspacing past trigger dismisses dropdown
- No special code needed if detection is correct
- Add test case if not already covered


title: Load autocomplete config in Modal

description:
## Overview
Add useEffect in Modal.js to load skills when a session is opened, and pass autocompleteConfig to SimpleInput.

## Background
Skills are agent-global, not session-specific. We fetch once when session.agent changes and cache client-side. This follows the plan's data flow: Modal opens -> GET /api/skills -> SimpleInput gets config.

## Implementation (from plan IMP-4)
```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]);

// In render, pass to SimpleInput:
<\${SimpleInput}
  ...
  autocompleteConfig=\${autocompleteConfig}
/>
```

## Dependency Array
- Uses session?.agent to re-fetch when agent type changes
- NOT session.id (would refetch on every session switch unnecessarily)
- Skills are agent-global, so same agent = same skills

## Error Handling
- fetch failure: autocompleteConfig stays null
- null config: SimpleInput disables autocomplete silently

## Props to Add
SimpleInput gets new optional prop:
- autocompleteConfig: { trigger, skills } | null

## Success Criteria
- useEffect fetches on session.agent change
- autocompleteConfig state managed correctly
- Passed to SimpleInput as prop
- Null on error (graceful degradation)


title: Implement skill insertion

description:
## Overview
Add insertSkill() function in SimpleInput.js that replaces the trigger+filter text with the full skill name.

## Background
When user selects a skill (Enter, Tab, or click), we need to:
1. Replace the trigger+partial text with trigger+full skill name
2. Add a trailing space
3. Position cursor after the inserted text

## Implementation (from plan IMP-8)
```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]);
```

## Example
User types: 'please run /com|' (cursor at |)
User selects 'commit'
Result: 'please run /commit |'

## Why setTimeout for Cursor
- React may not have updated the textarea value yet
- setTimeout(0) defers until after React render
- Ensures cursor positioning happens on updated DOM

## AC-20 Compliance
'After skill insertion, cursor is positioned after the trailing space, ready to continue typing.'

## Success Criteria
- Replaces trigger+filter with trigger+skill+space
- Preserves text before and after
- Cursor positioned at end of insertion
- Dropdown closes after insertion
- Focus remains on textarea


title: Add /api/skills route to HttpMixin

description:
## Overview
Add the GET /api/skills endpoint to HttpMixin.do_GET that routes to SkillsMixin._serve_skills().

## Background
The skills endpoint lives in HttpMixin because that's where all GET routing is handled. The handler just composes mixins. This follows the existing architecture pattern.

## Implementation (from plan IMP-2)
In amc_server/mixins/http.py, add to do_GET method:
```python
elif path == '/api/skills':
    agent = query_params.get('agent', ['claude'])[0]
    self._serve_skills(agent)
```

## API Design
- **Endpoint**: GET /api/skills?agent={claude|codex}
- **Default agent**: 'claude' (if param missing)
- **Response**: JSON from _serve_skills()

## Why This Location
- HttpMixin.do_GET handles ALL GET routing
- Keeps routing logic centralized
- SkillsMixin provides the implementation
- Handler composes both mixins

## Required Import
Add SkillsMixin to handler.py mixin list:
```python
from amc_server.mixins.skills import SkillsMixin
# Add to AMCHandler class inheritance
```

## Success Criteria
- Route /api/skills accessible via GET
- Query param 'agent' passed to _serve_skills
- Missing agent param defaults to 'claude'
- Follows existing routing patterns in do_GET


title: Integration: Wire up complete autocomplete feature

description:
## Overview
Final integration step: ensure all pieces work together in Modal.js and SimpleInput.js.

## Background
This is the capstone bead that verifies the complete feature is wired up correctly. All previous beads implement individual pieces; this ensures they connect.

## Checklist
1. **SkillsMixin added to handler**
   - Import SkillsMixin in handler.py
   - Add to AMCHandler class inheritance

2. **Modal loads config**
   - fetchSkills called on session.agent change
   - autocompleteConfig passed to SimpleInput

3. **SimpleInput receives config**
   - New prop: autocompleteConfig
   - State management wired up
   - Keyboard handlers connected
   - Dropdown renders correctly

## Verification Steps
1. Start dev server
2. Open a session modal
3. Type '/' -> dropdown should appear
4. Arrow down, Enter -> skill inserted
5. Check Codex session with '$'

## Common Issues to Check
- Import paths correct
- Mixin in correct position in inheritance
- Props threaded through correctly
- No console errors

## Success Criteria
- Feature works end-to-end
- No console errors
- Both Claude and Codex agents work
- Manual testing checklist passes


title: Unit tests for SkillsMixin

description:
## Overview
Add comprehensive unit tests for amc_server/mixins/skills.py in tests/test_skills.py.

## Background
The skill enumeration is the foundation of autocomplete. We need to test:
- Both enumeration methods
- Edge cases (missing files, bad JSON, empty directories)
- Sorting behavior
- Response format

## Test Cases

### Claude Enumeration
```python
def test_enumerate_claude_skills_empty_directory(tmp_home):
    '''Returns empty list when ~/.claude/skills doesn't exist'''

def test_enumerate_claude_skills_reads_skill_md(tmp_home, claude_skill):
    '''Reads description from SKILL.md'''

def test_enumerate_claude_skills_fallback_to_readme(tmp_home):
    '''Falls back to README.md when SKILL.md missing'''

def test_enumerate_claude_skills_skips_hidden_dirs(tmp_home):
    '''Ignores directories starting with .'''

def test_enumerate_claude_skills_truncates_description(tmp_home):
    '''Description truncated to 100 chars'''

def test_enumerate_claude_skills_skips_headers(tmp_home):
    '''First non-header line used as description'''
```

### Codex Enumeration
```python
def test_enumerate_codex_skills_reads_cache(tmp_home):
    '''Reads from skills-curated-cache.json'''

def test_enumerate_codex_skills_invalid_json(tmp_home):
    '''Continues without cache if JSON invalid'''

def test_enumerate_codex_skills_combines_cache_and_user(tmp_home):
    '''Returns both curated and user skills'''

def test_enumerate_codex_skills_no_deduplication(tmp_home):
    '''Duplicate names from cache and user both appear'''
```

### Serve Skills
```python
def test_serve_skills_claude_trigger(mock_handler):
    '''Returns / trigger for claude agent'''

def test_serve_skills_codex_trigger(mock_handler):
    '''Returns $ trigger for codex agent'''

def test_serve_skills_default_to_claude(mock_handler):
    '''Unknown agent defaults to claude'''

def test_serve_skills_alphabetical_sort(mock_handler, skills_fixture):
    '''Skills sorted alphabetically (case-insensitive)'''
```

## Fixtures
- tmp_home: pytest fixture that sets HOME to temp directory
- claude_skill: creates a skill directory with SKILL.md
- mock_handler: SkillsMixin instance with mocked _send_json

## Success Criteria
- All enumeration paths tested
- Error handling verified (bad JSON, missing files)
- Sorting correctness verified
- Response format validated


title: E2E tests for autocomplete workflow

description:
## Overview
Create end-to-end test script that validates the complete autocomplete workflow from typing to insertion.

## Background
The plan includes a manual testing checklist (lines 462-480). We should automate key workflows to ensure the feature works end-to-end.

## Test Scenarios (from plan's testing checklist)

### Core Flow
```
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 of skills
11. Verify vertical scroll with many skills
```

### Edge Cases (from plan section)
```
- Session without skills (dropdown shows "No skills available")
- Single skill (still shows dropdown)
- Very long skill descriptions (CSS truncates with ellipsis - visual check)
- Multiple triggers in one message (each "/" can trigger independently)
- Backspace over trigger (dismisses autocomplete)
```

### Multiple Triggers Test (important edge case)
User types: "first /commit then /review-pr finally"
- First "/" at position 6 can trigger
- After inserting "/commit ", cursor at position 14
- Second "/" at position after text can trigger again
- Verify each trigger works independently

## Implementation Approach
Use the Playwright MCP tools to:
1. Navigate to dashboard
2. Open a session modal
3. Type trigger character
4. Verify dropdown appears
5. Navigate with arrows
6. Select with Enter
7. Verify insertion

## Logging Requirements
- Log each step being performed
- Log expected vs actual behavior
- Log timing for performance visibility
- Log any errors with context

## Test Script Location
tests/e2e/test_autocomplete.py or similar

## Success Criteria
- All core scenarios pass
- Edge cases handled
- Detailed logging for debugging
- Can run in CI environment


title: Implement Codex skill enumeration

description:
## Overview
Implement _enumerate_codex_skills() method in SkillsMixin to discover skills from both the curated cache and user directory.

## Background
Codex stores skills in two locations:
1. **Curated cache**: ~/.codex/vendor_imports/skills-curated-cache.json (pre-installed skills)
2. **User skills**: ~/.codex/skills/*/ (user-created or installed)

Both need to be combined for the full skills list.

## Implementation (from plan IMP-1)
```python
def _enumerate_codex_skills(self):
    skills = []

    # 1. 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  # Continue without curated skills

    # 2. 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():
                    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
```

## Key Decisions
- **Cache file structure**: Expected format {skills: [{id, shortDescription}, ...]}
- **Fallback for missing fields**: Use 'name' if 'id' missing, 'description' if 'shortDescription' missing
- **No deduplication**: If curated and user skills share a name, both appear (per Known Limitations)
- **Error resilience**: JSON parse errors don't prevent user skills from loading

## Out of Scope (per plan)
- Duplicate skill names are NOT deduplicated
- Server-side caching of enumeration results

## Success Criteria
- Returns combined list from cache + user directory
- Handles missing files/directories gracefully
- Truncates descriptions to 100 chars
- JSON parse errors don't crash enumeration


title: Implement click-outside dismissal

description:
## Overview
Add useEffect in SimpleInput.js that dismisses the autocomplete dropdown when clicking outside.

## Background
AC-9 requires clicking outside to dismiss the dropdown. This is a common UX pattern that requires:
- Event listener on document
- Check if click target is inside dropdown or textarea
- Cleanup on unmount

## Implementation (from plan IMP-10)
```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]);
```

## Why mousedown (not click)
- mousedown fires immediately on press
- click fires after release (feels sluggish)
- Standard pattern for dropdown dismissal

## What Counts as 'Inside'
- Inside autocompleteRef (the dropdown)
- Inside textareaRef (the input)
- Both should keep dropdown open

## Cleanup
- Effect only adds listener when dropdown is visible
- Cleanup removes listener when:
  - Dropdown closes
  - Component unmounts
  - Dependencies change

## Success Criteria
- Clicking outside dropdown+textarea dismisses
- Clicking inside dropdown doesn't dismiss (onClick handles selection)
- Clicking in textarea doesn't dismiss (keeps typing)
- Listener cleaned up properly


title: Create SkillsMixin class for skill enumeration

description:
## Overview
Create a new SkillsMixin class in amc_server/mixins/skills.py that handles enumerating available skills for both Claude and Codex agents.

## Background & Rationale
The autocomplete feature needs to know what skills are available for each agent type. Skills are agent-global (not session-specific), so we enumerate them from well-known filesystem locations. This follows the existing mixin pattern used throughout the server (HttpMixin, StateMixin, etc.).

## Implementation Details
- Create new file: amc_server/mixins/skills.py
- Class: SkillsMixin with methods:
  - _serve_skills(agent: str) -> serves JSON response
  - _enumerate_claude_skills() -> list of {name, description}
  - _enumerate_codex_skills() -> list of {name, description}
- Sort skills alphabetically by name (case-insensitive)
- Return JSON: {trigger: '/' or '$', skills: [...]}

## File Locations
- Claude skills: ~/.claude/skills/*/
- Codex curated: ~/.codex/vendor_imports/skills-curated-cache.json
- Codex user: ~/.codex/skills/*/

## Acceptance Criteria (from plan)
- AC-12: On session open, agent-specific config loaded with trigger + skills
- AC-13: Claude skills from ~/.claude/skills/
- AC-14: Codex skills from cache + user directory
- AC-15: Empty skills list handled gracefully

## Error Handling
- Directory doesn't exist: return empty list
- JSON parse error (Codex cache): skip cache, continue with user skills
- File read error: skip that skill, continue enumeration

## Success Criteria
- SkillsMixin class exists with all three methods
- Claude enumeration reads SKILL.md (canonical), falls back to skill.md, prompt.md, README.md
- Codex enumeration reads curated cache + user skills directory
- All skills sorted alphabetically
- Empty directories return empty list (no error)

issue_type: feature


title: Implement trigger detection logic

description:
## Overview
Add getTriggerInfo() function in SimpleInput.js that detects when autocomplete should activate.

## Background
Autocomplete triggers when the agent-specific trigger character (/ for Claude, $ for Codex) appears at:
1. Position 0 (start of input)
2. After whitespace (space, newline, tab)

This enables mid-message skill invocation like "please run /commit".

## Acceptance Criteria Covered
- **AC-1**: Triggers at position 0 or after whitespace
- **AC-2**: Claude uses /, Codex uses $ (from autocompleteConfig.trigger)
- **AC-3**: Wrong trigger for agent type is ignored (returns null)

## Implementation (from plan IMP-5)
```javascript
const getTriggerInfo = useCallback((value, cursorPos) => {
  // AC-3: If no config, ignore all triggers
  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--;
  }

  // AC-3: Only match THIS agent's trigger, ignore others
  // Claude session typing "$" returns null (no autocomplete)
  // Codex session typing "/" returns null (no autocomplete)
  if (value[wordStart] === trigger) {
    return {
      trigger,
      filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
      replaceStart: wordStart,
      replaceEnd: cursorPos
    };
  }

  return null;
}, [autocompleteConfig]);
```

## Return Value
```typescript
type TriggerInfo = {
  trigger: string;       // The trigger char (/ or $)
  filterText: string;    // Text after trigger for filtering (lowercase)
  replaceStart: number;  // Start position for replacement
  replaceEnd: number;    // End position for replacement (cursor pos)
} | null;
```

## AC-3 Behavior (Wrong Trigger Ignored)
The function only checks for `autocompleteConfig.trigger`. If user types a different character:
- Claude session: "$" is just a dollar sign, no autocomplete
- Codex session: "/" is just a slash, no autocomplete

This is implicit in the implementation - we compare against ONE trigger character only.

## Edge Cases
- Empty input: wordStart = 0, checks value[0]
- Mid-word: wordStart finds last whitespace, checks that position
- Multiple triggers in message: each "/" or "$" can independently trigger
- Backspace over trigger: cursorPos moves, wordStart recalculates

## When This Returns null
1. No autocompleteConfig (not loaded yet)
2. Cursor not preceded by trigger character
3. Wrong trigger for this agent type (AC-3)

## Success Criteria
- Returns TriggerInfo when trigger at valid position
- Returns null for wrong trigger (AC-3)
- Returns null when no config
- filterText is lowercase for case-insensitive matching
- replaceStart/End correct for skill insertion


title: Ensure SimpleInput container has relative positioning

description:
## Overview
Verify/add position: relative to SimpleInput's container for proper dropdown positioning.

## Background
The autocomplete dropdown uses absolute positioning with 'bottom-full'. This requires the parent container to have position: relative.

## Implementation
Check SimpleInput.js render:
```javascript
// The outer wrapper needs position: relative
<div class="relative ...">
  <textarea ... />
  ${/* autocomplete dropdown here */}
</div>
```

## Why This Matters
Without position: relative on parent:
- Dropdown positions relative to nearest positioned ancestor
- Could appear in wrong location or off-screen
- Layout becomes unpredictable

## Verification
1. Check existing SimpleInput wrapper class
2. Add 'relative' if not present
3. Test dropdown appears correctly above input

## Success Criteria
- Dropdown appears directly above textarea
- No layout shifts or misalignment
- Works in both modal and any other contexts


title: Implement keyboard navigation for autocomplete

description:
## Overview
Add onKeyDown handler in SimpleInput.js for arrow key navigation, Enter/Tab selection, and Escape dismissal.

## Background
AC-7, AC-8, AC-9, AC-10 require keyboard control of the autocomplete dropdown. This must work WITHOUT interfering with normal textarea behavior when dropdown is closed.

## Implementation (from plan IMP-7)
```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
    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;
      }
    }

    // AC-10: ArrowLeft/ArrowRight are NOT handled here.
    // They fall through to default behavior but since we only call
    // getTriggerInfo on input events, cursor movement does not
    // reposition the dropdown. The dropdown stays anchored to the
    // original trigger position.
  }

  // Existing Enter-to-submit logic (only when dropdown is closed)
  if (e.key === "Enter" && \!e.shiftKey) {
    e.preventDefault();
    handleSubmit(e);
  }
}}
```

## Key Behaviors
- **AC-7**: ArrowUp/Down navigates highlighted option
- **AC-8**: Enter or Tab inserts selected skill
- **AC-9**: Escape dismisses without insertion
- **AC-10**: ArrowLeft/Right fall through - dropdown position locked to trigger
- **Enter with no matches**: Dismisses dropdown (does NOT submit message\!)

## Why ArrowLeft/Right Are Ignored (AC-10)
The plan specifies that cursor movement should not reposition the dropdown. Since we only recalculate trigger position on `onInput` events (not cursor movement), ArrowLeft/Right naturally do not affect the dropdown. No special handling needed - just document this behavior.

## Success Criteria
- ArrowUp/Down changes selectedIndex
- Enter/Tab inserts selected skill or dismisses if no matches
- Escape always dismisses
- ArrowLeft/Right do not interfere with dropdown
- Normal Enter-to-submit works when dropdown closed


title: Implement scroll-into-view for selected item

description:
## Overview
Ensure the highlighted item in the autocomplete dropdown scrolls into view when navigating with arrow keys.

## Background
From plan Slice 5 (Polish): 'Scroll into view for long lists'. When the user arrows down through a list longer than max-height, the selected item should remain visible.

## Implementation
```javascript
// In the keyboard navigation effect or handler
useEffect(() => {
  if (showAutocomplete && autocompleteRef.current) {
    const container = autocompleteRef.current;
    const selectedEl = container.children[selectedIndex];
    if (selectedEl) {
      selectedEl.scrollIntoView({ block: 'nearest' });
    }
  }
}, [selectedIndex, showAutocomplete]);
```

## Key Details
- block: 'nearest' prevents jarring scroll when item already visible
- Only scroll when autocomplete is open
- children[selectedIndex] assumes direct children are the items

## Alternative: scrollIntoViewIfNeeded
Some browsers support scrollIntoViewIfNeeded, but scrollIntoView with block: 'nearest' is more widely supported and achieves the same effect.

## Success Criteria
- Arrowing to item below viewport scrolls it into view
- Arrowing to item above viewport scrolls it into view
- Items in viewport don't cause scroll


title: Unit tests for trigger detection and filtering

description:
## Overview
Add unit tests for the client-side autocomplete logic (trigger detection, filtering) in a test file.

## Background
The client-side logic is pure functions that can be tested in isolation:
- getTriggerInfo: detects trigger and extracts filter text
- filteredSkills: filters and sorts skills list

## Test Cases

### Trigger Detection
```javascript
describe('getTriggerInfo', () => {
  it('returns null when no autocompleteConfig')
  it('detects trigger at position 0')
  it('detects trigger after space')
  it('detects trigger after newline')
  it('returns null for non-trigger character')
  it('returns null for wrong trigger (/ in codex session)')
  it('extracts filterText correctly')
  it('filterText is lowercase')
  it('replaceStart/replaceEnd are correct')
})
```

### Filtered Skills
```javascript
describe('filteredSkills', () => {
  it('returns empty array without config')
  it('returns all skills with empty filter')
  it('filters case-insensitively')
  it('matches anywhere in name')
  it('sorts alphabetically')
  it('returns empty array when no matches')
})
```

## Test Data
```javascript
const mockConfig = {
  trigger: '/',
  skills: [
    { name: 'commit', description: 'Create a git commit' },
    { name: 'review-pr', description: 'Review a pull request' },
    { name: 'comment', description: 'Add a comment' }
  ]
};
```

## Implementation Notes
- Extract getTriggerInfo to a testable module if needed
- filteredSkills computation can be tested by extracting the logic

## Success Criteria
- All trigger detection scenarios covered
- All filtering edge cases tested
- Tests run in CI


title: Implement _serve_skills endpoint handler

description:
## Overview
Implement _serve_skills(agent) method in SkillsMixin that serves the skills API response.

## Background
This is the main entry point called by the HTTP route. It determines the agent type, calls the appropriate enumeration method, and returns a properly formatted JSON response.

## Implementation (from plan IMP-1)
```python
def _serve_skills(self, agent):
    """Return autocomplete config for an agent."""
    if agent == 'codex':
        trigger = '$'
        skills = self._enumerate_codex_skills()
    else:  # claude (default)
        trigger = '/'
        skills = self._enumerate_claude_skills()

    # Sort alphabetically
    skills.sort(key=lambda s: s['name'].lower())

    self._send_json(200, {'trigger': trigger, 'skills': skills})
```

## Response Format
```json
{
  "trigger": "/",
  "skills": [
    { "name": "commit", "description": "Create a git commit with a message" },
    { "name": "review-pr", "description": "Review a pull request" }
  ]
}
```

## Key Decisions
- **Claude is default**: Unknown agents get Claude behavior (fail-safe)
- **Sorting is server-side**: Client receives pre-sorted list (AC-5)
- **Uses _send_json**: Follows existing pattern from HttpMixin

## Success Criteria
- Returns correct trigger character per agent
- Skills are alphabetically sorted (case-insensitive)
- Unknown agents default to Claude
- Valid JSON response with 200 status


title: Implement Claude skill enumeration

description:
## Overview
Implement _enumerate_claude_skills() method in SkillsMixin to discover skills from ~/.claude/skills/.

## Background
Claude Code stores user skills as directories under ~/.claude/skills/. Each skill directory contains a SKILL.md (canonical casing) with the skill definition. We need to enumerate these and extract descriptions.

## Implementation (from plan IMP-1)
```python
def _enumerate_claude_skills(self):
    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('.'):
                description = ''
                # Check files in priority order
                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()
                            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
```

## Key Decisions
- **SKILL.md first**: This is the canonical casing used by Claude Code
- **Fallbacks**: skill.md, prompt.md, README.md for compatibility
- **Description extraction**: First non-empty, non-header, non-comment line (truncated to 100 chars)
- **Hidden directories skipped**: Names starting with '.' are ignored

## Success Criteria
- Returns list of {name, description} dicts
- Handles missing ~/.claude/skills/ gracefully (returns [])
- Extracts meaningful descriptions from SKILL.md
- Ignores hidden directories