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

View File

@@ -10,7 +10,7 @@ from rich.panel import Panel
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Header, Input, RichLog
from textual.widgets import Header, RichLog
from app.agent.context import SessionContext
from app.agent.loop import AgentLoop
@@ -20,8 +20,14 @@ from app.services.permissions import PermissionsService
from app.services.session import SessionManager
from app.services.streaming import StreamHandler
from app.tools.registry import create_default_registry
from app.ui.widgets import StatusBar, StreamingStatic
from app.utils.display import DisplayAdapter, render_user_message
from app.ui.widgets import (
HistoryInput,
PermissionModal,
SessionResumeModal,
StatusBar,
StreamingStatic,
)
from app.utils.display import DisplayAdapter
from app.utils.logging import get_logger, setup_logging_for_tui
if TYPE_CHECKING:
@@ -49,6 +55,8 @@ class SneakyCodeApp(App):
self._client: LLMClient | None = None
self._tool_registry = None
self._permissions: PermissionsService | None = None
self._debug_logger = None
self._skills_manager = None
self._current_worker: Worker | None = None
self._cancel_count = 0
self.sub_title = config.llm.model
@@ -58,7 +66,7 @@ class SneakyCodeApp(App):
yield RichLog(id="chat-log", highlight=True, markup=True)
yield StreamingStatic("", id="streaming")
yield StatusBar()
yield Input(placeholder="Enter your prompt...")
yield HistoryInput(placeholder="Enter your prompt...")
async def on_mount(self) -> None:
"""Initialize agent components after the app is mounted."""
@@ -69,8 +77,28 @@ class SneakyCodeApp(App):
# Create long-lived agent dependencies (reused across turns)
self._client = LLMClient(self._config.llm)
await self._client.__aenter__()
self._tool_registry = create_default_registry(self._config.agent.workspace_root, self._config)
self._permissions = PermissionsService(self._config.permissions)
self._permissions = PermissionsService(self._config.permissions, self._config.tools)
# Create debug logger if enabled
if self._config.debug.enabled:
from app.services.debug_log import DebugLogger
log_dir = self._config.agent.workspace_root / self._config.debug.log_dir
self._debug_logger = DebugLogger(log_dir, self._config.debug.max_files)
# Initialize skills system
if self._config.skills.enabled:
from app.services.skills import SkillsManager
self._skills_manager = SkillsManager(
self._config.skills, self._config.agent.workspace_root
)
self._tool_registry = create_default_registry(
self._config.agent.workspace_root,
self._config,
skills_manager=self._skills_manager,
)
# Set up permission prompt callback
async def permission_prompt(tool_name: str, description: str) -> bool:
@@ -82,14 +110,16 @@ class SneakyCodeApp(App):
if self._session_mgr and self._config.session.offer_resume:
saved = self._session_mgr.load_latest()
if saved:
msg_count = len(saved.messages)
log = self.query_one("#chat-log", RichLog)
log.write(Text(f"Found previous session ({msg_count} messages)", style="cyan"))
# Auto-resume for now (modal can be added later)
self._session_mgr.restore(saved, self._ctx)
log.write(Text(f"✓ Session restored ({msg_count} messages)", style="bold green"))
msg_count = len(saved.messages)
resume = await self.push_screen_wait(SessionResumeModal(msg_count))
if resume:
self._session_mgr.restore(saved, self._ctx)
log.write(Text("Session restored", style="bold green"))
else:
log.write(Text("Starting fresh session", style="cyan"))
self.query_one(Input).focus()
self.query_one(HistoryInput).focus()
async def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle user input submission."""
@@ -98,6 +128,7 @@ class SneakyCodeApp(App):
return
event.input.clear()
event.input.record(user_input)
log = self.query_one("#chat-log", RichLog)
# Handle slash commands
@@ -105,9 +136,6 @@ class SneakyCodeApp(App):
await self._handle_slash_command(user_input, log)
return
# Write user message to log
log.write(render_user_message(user_input))
# Dispatch agent turn as async worker
self._cancel_count = 0
self._current_worker = self.run_worker(
@@ -145,7 +173,58 @@ class SneakyCodeApp(App):
f"Started: {self._ctx.start_time.isoformat()}",
style="cyan",
))
elif cmd.startswith("/models"):
parts = command.split(maxsplit=1)
if len(parts) == 1:
# List available models
try:
from app.services.llm import LLMError
models = await self._client.list_models()
from rich.table import Table
table = Table(title="Available Models", show_lines=False)
table.add_column("Model", style="cyan")
table.add_column("Size", style="dim")
current = self._config.llm.model
for m in models:
name = m["name"]
marker = " (active)" if current in name or name.startswith(current) else ""
table.add_row(f"{name}{marker}", m["size"])
log.write(table)
except Exception as e:
log.write(Text(f"Failed to list models: {e}", style="red"))
else:
new_model = parts[1].strip()
self._config.llm.model = new_model
self.sub_title = new_model
log.write(Text(f"Switched to model: {new_model}", style="bold green"))
elif cmd == "/skills":
if self._skills_manager:
skills = self._skills_manager.list_skills()
if not skills:
log.write(Text("No skills found", style="yellow"))
else:
from rich.table import Table
table = Table(title="Available Skills")
table.add_column("Name", style="cyan")
table.add_column("Description")
for s in skills:
table.add_row(f"/{s.name}", s.description)
log.write(table)
else:
log.write(Text("Skills system is disabled", style="yellow"))
else:
# Try as skill invocation
skill_name = cmd.lstrip("/")
if self._skills_manager:
content = self._skills_manager.load_skill(skill_name)
if content is not None:
if self._ctx:
self._ctx.add_message("system", f"[Skill: {skill_name}]\n{content}")
log.write(Text(f"Loaded skill: {skill_name}", style="bold green"))
return
log.write(Text(f"⚠ Unknown command: {command}", style="yellow"))
async def _run_agent_turn(self, user_input: str) -> None:
@@ -155,17 +234,21 @@ class SneakyCodeApp(App):
log = self.query_one("#chat-log", RichLog)
streaming_widget = self.query_one("#streaming", StreamingStatic)
status_bar = self.query_one(StatusBar)
display = DisplayAdapter(log)
# StreamHandler is per-turn (has per-turn accumulators)
handler = StreamHandler(self._config.display)
status_bar.start_streaming()
# Set up streaming UI callbacks
def on_content(content: str) -> None:
streaming_widget.update(
Panel(Markdown(content), title="Assistant", border_style="green", expand=True)
)
streaming_widget.show_streaming()
status_bar.update_stream_tokens(len(content) // 4)
def on_thinking() -> None:
streaming_widget.update(Text("Thinking...", style="dim"))
@@ -173,30 +256,28 @@ class SneakyCodeApp(App):
def on_done() -> None:
streaming_widget.hide_streaming()
status_bar.stop_streaming()
handler.set_callbacks(on_content=on_content, on_thinking=on_thinking, on_done=on_done)
agent = AgentLoop(
self._config, self._ctx, self._client, handler,
self._tool_registry, self._permissions, display,
debug_logger=self._debug_logger,
skills_manager=self._skills_manager,
)
await agent.run_turn(user_input)
status_bar.stop_streaming()
# Auto-save
if self._config.session.auto_save:
self._save_session()
async def _show_permission_modal(self, tool_name: str, description: str) -> bool:
"""Show a permission prompt in the chat log and wait for response.
Uses the Input widget to capture y/n response.
"""
# For v1, auto-approve all tools that reach the prompt
# TODO: Implement proper modal dialog
log = self.query_one("#chat-log", RichLog)
log.write(Text(f"⚠ Auto-approved: {tool_name} ({description})", style="yellow"))
return True
"""Show a modal dialog for tool permission approval."""
return await self.push_screen_wait(PermissionModal(tool_name, description))
def action_cancel_or_quit(self) -> None:
"""Handle Ctrl+C: first press cancels worker, second press quits."""
@@ -209,6 +290,11 @@ class SneakyCodeApp(App):
log = self.query_one("#chat-log", RichLog)
log.write(Text("⚠ Cancelling... (press Ctrl+C again to quit)", style="yellow"))
async def on_unmount(self) -> None:
"""Clean up the LLM client on app shutdown."""
if self._client is not None:
await self._client.close()
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle worker completion or failure."""
from textual.worker import WorkerState
@@ -223,9 +309,10 @@ class SneakyCodeApp(App):
if event.state in (WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED):
self._current_worker = None
# Hide streaming widget in case it was left visible
# Hide streaming widget and stop spinner in case they were left active
streaming = self.query_one("#streaming", StreamingStatic)
streaming.hide_streaming()
self.query_one(StatusBar).stop_streaming()
def _save_session(self) -> Path | None:
"""Save session quietly, return path or None."""