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:
137
app/ui/app.py
137
app/ui/app.py
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user