feat: implement tweaks plan - modals, smart shell, spinner, /models, debug log, skills

Phase 1: Permission modal dialog, session resume modal, HistoryInput with
up/down arrow cycling, remove "You:" echo from chat log, LLM client cleanup
on unmount.

Phase 2: Smart shell auto-approve using allowed/denied command lists from
ToolsConfig, animated thinking spinner with live token count in status bar.

Phase 3: /models slash command (list + switch), CLI directory positional
argument, JSONL debug logger with rotation.

Phase 4: Skills system with SkillsManager, load_skill LLM tool, /skills
listing, skill invocation via slash commands, system prompt integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:46:44 -05:00
parent 7600195ecf
commit 3f9012e6c2
13 changed files with 683 additions and 37 deletions

71
app/services/skills.py Normal file
View File

@@ -0,0 +1,71 @@
"""Skills manager — scans for and loads skill markdown files."""
from __future__ import annotations
import logging
from pathlib import Path
from pydantic import BaseModel
from app.models.config import SkillsConfig
logger = logging.getLogger(__name__)
class Skill(BaseModel):
"""Metadata for a discovered skill file."""
name: str
description: str
path: Path
class SkillsManager:
"""Discovers, indexes, and loads skill files from configured directories."""
def __init__(self, config: SkillsConfig, workspace_root: Path) -> None:
self._config = config
self._workspace = workspace_root
self._skills: dict[str, Skill] = {}
self._scan()
def _scan(self) -> None:
"""Scan configured directories for .md skill files."""
for skill_dir in self._config.directories:
resolved = (self._workspace / skill_dir) if not skill_dir.is_absolute() else skill_dir
if not resolved.is_dir():
logger.debug("Skills directory does not exist: %s", resolved)
continue
for md in sorted(resolved.glob("*.md")):
name = md.stem
desc = self._extract_description(md)
self._skills[name] = Skill(name=name, description=desc, path=md)
logger.debug("Discovered skill: %s (%s)", name, desc)
@staticmethod
def _extract_description(path: Path) -> str:
"""Extract the first non-blank, non-heading line as the description."""
for line in path.read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
return stripped
return "(no description)"
def list_skills(self) -> list[Skill]:
"""Return all discovered skills."""
return list(self._skills.values())
def load_skill(self, name: str) -> str | None:
"""Load the full content of a skill by name. Returns None if not found."""
skill = self._skills.get(name)
return skill.path.read_text() if skill else None
def get_system_prompt_snippet(self) -> str:
"""Generate a snippet for the system prompt listing available skills."""
if not self._skills:
return ""
lines = ["\nAvailable skills (invoke with /skill-name):"]
for s in self._skills.values():
lines.append(f" - /{s.name}: {s.description}")
lines.append("To use a skill's full instructions, call the load_skill tool.")
return "\n".join(lines)