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."""
|
||||
|
||||
@@ -21,6 +21,35 @@ Screen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Modal dialog */
|
||||
#permission-dialog {
|
||||
width: 60;
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
border: thick $accent;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
.modal-buttons Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
/* StatusBar styles are in DEFAULT_CSS on the widget itself */
|
||||
|
||||
Input {
|
||||
|
||||
@@ -2,12 +2,130 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.events import Key
|
||||
from textual.screen import ModalScreen
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Button, Input, Static
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modal Dialogs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PermissionModal(ModalScreen[bool]):
|
||||
"""Modal dialog for tool permission approval."""
|
||||
|
||||
BINDINGS = [("y", "allow", "Allow"), ("n", "deny", "Deny")]
|
||||
|
||||
def __init__(self, tool_name: str, description: str) -> None:
|
||||
super().__init__()
|
||||
self._tool_name = tool_name
|
||||
self._description = description
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="permission-dialog"):
|
||||
yield Static(f"Tool: {self._tool_name}", classes="modal-title")
|
||||
yield Static(self._description, classes="modal-body")
|
||||
with Horizontal(classes="modal-buttons"):
|
||||
yield Button("Allow (y)", id="allow", variant="success")
|
||||
yield Button("Deny (n)", id="deny", variant="error")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.dismiss(event.button.id == "allow")
|
||||
|
||||
def action_allow(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def action_deny(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class SessionResumeModal(ModalScreen[bool]):
|
||||
"""Modal dialog for session resume prompt."""
|
||||
|
||||
BINDINGS = [("y", "resume", "Resume"), ("n", "fresh", "Start Fresh")]
|
||||
|
||||
def __init__(self, msg_count: int) -> None:
|
||||
super().__init__()
|
||||
self._msg_count = msg_count
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="permission-dialog"):
|
||||
yield Static("Resume Session?", classes="modal-title")
|
||||
yield Static(
|
||||
f"Found previous session with {self._msg_count} messages.",
|
||||
classes="modal-body",
|
||||
)
|
||||
with Horizontal(classes="modal-buttons"):
|
||||
yield Button("Resume (y)", id="resume", variant="success")
|
||||
yield Button("Start Fresh (n)", id="fresh", variant="warning")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.dismiss(event.button.id == "resume")
|
||||
|
||||
def action_resume(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def action_fresh(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input with History
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HistoryInput(Input):
|
||||
"""Input with up/down arrow history cycling."""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
self._draft: str = ""
|
||||
|
||||
def record(self, value: str) -> None:
|
||||
"""Record a submitted value in history."""
|
||||
if value.strip() and (not self._history or self._history[-1] != value):
|
||||
self._history.append(value)
|
||||
self._history_index = -1
|
||||
self._draft = ""
|
||||
|
||||
def on_key(self, event: Key) -> None:
|
||||
if event.key == "up" and self._history:
|
||||
event.prevent_default()
|
||||
if self._history_index == -1:
|
||||
self._draft = self.value
|
||||
self._history_index = len(self._history) - 1
|
||||
elif self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
self.value = self._history[self._history_index]
|
||||
self.cursor_position = len(self.value)
|
||||
elif event.key == "down" and self._history_index != -1:
|
||||
event.prevent_default()
|
||||
self._history_index += 1
|
||||
if self._history_index >= len(self._history):
|
||||
self._history_index = -1
|
||||
self.value = self._draft
|
||||
else:
|
||||
self.value = self._history[self._history_index]
|
||||
self.cursor_position = len(self.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status Bar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StatusBar(Static):
|
||||
"""Single-line status bar showing token usage and iteration count."""
|
||||
"""Single-line status bar showing token usage, iteration count, and streaming spinner."""
|
||||
|
||||
_SPINNER = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
|
||||
|
||||
DEFAULT_CSS = """
|
||||
StatusBar {
|
||||
@@ -25,6 +143,10 @@ class StatusBar(Static):
|
||||
self._budget: int = 0
|
||||
self._iteration: int = 0
|
||||
self._max_iterations: int = 0
|
||||
self._streaming: bool = False
|
||||
self._spinner_frame: int = 0
|
||||
self._spinner_timer: Timer | None = None
|
||||
self._stream_tokens: int = 0
|
||||
|
||||
def update_tokens(self, tokens: int, budget: int) -> None:
|
||||
"""Update the token usage display."""
|
||||
@@ -38,14 +160,48 @@ class StatusBar(Static):
|
||||
self._max_iterations = max_iterations
|
||||
self._refresh_display()
|
||||
|
||||
def start_streaming(self) -> None:
|
||||
"""Start the animated thinking spinner."""
|
||||
self._streaming = True
|
||||
self._spinner_frame = 0
|
||||
self._stream_tokens = 0
|
||||
self._spinner_timer = self.set_interval(0.08, self._tick_spinner)
|
||||
self._refresh_display()
|
||||
|
||||
def stop_streaming(self) -> None:
|
||||
"""Stop the animated thinking spinner."""
|
||||
self._streaming = False
|
||||
if self._spinner_timer:
|
||||
self._spinner_timer.stop()
|
||||
self._spinner_timer = None
|
||||
self._refresh_display()
|
||||
|
||||
def update_stream_tokens(self, tokens: int) -> None:
|
||||
"""Update estimated token count during streaming."""
|
||||
self._stream_tokens = tokens
|
||||
|
||||
def _tick_spinner(self) -> None:
|
||||
self._spinner_frame = (self._spinner_frame + 1) % len(self._SPINNER)
|
||||
self._refresh_display()
|
||||
|
||||
def _refresh_display(self) -> None:
|
||||
"""Rebuild the status bar text."""
|
||||
parts: list[str] = []
|
||||
if self._streaming:
|
||||
spinner = self._SPINNER[self._spinner_frame]
|
||||
parts.append(f"{spinner} Thinking")
|
||||
if self._stream_tokens > 0:
|
||||
parts.append(f"~{self._stream_tokens:,} tokens")
|
||||
if self._budget > 0:
|
||||
parts.append(f"Tokens: ~{self._tokens:,} / {self._budget:,}")
|
||||
if self._max_iterations > 0:
|
||||
parts.append(f"Iteration {self._iteration}/{self._max_iterations}")
|
||||
self.update(Text(" │ ".join(parts), style="dim"))
|
||||
self.update(Text(" \u2502 ".join(parts), style="dim"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming Display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StreamingStatic(Static):
|
||||
|
||||
Reference in New Issue
Block a user