"""SneakyCode Textual TUI application.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING from rich.markdown import Markdown 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 Input, RichLog from textual import work from app.agent.context import SessionContext from app.agent.loop import AgentLoop from app.models.config import AgentMode, AppConfig from app.services.llm import LLMClient 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 ( HeaderPanel, 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: from textual.worker import Worker logger = get_logger(__name__) class SneakyCodeApp(App): """Main TUI application for SneakyCode.""" TITLE = "SneakyCode" CSS_PATH = "styles.tcss" BINDINGS = [ Binding("ctrl+c", "cancel_or_quit", "Cancel/Quit", show=False), Binding("ctrl+p", "cycle_mode", "Cycle Mode"), ] def __init__(self, config: AppConfig, session_mgr: SessionManager | None = None) -> None: super().__init__() self._config = config self._session_mgr = session_mgr self._ctx: SessionContext | None = None self._agent: AgentLoop | None = None self._client: LLMClient | None = None self._tool_registry = None self._permissions: PermissionsService | None = None self._debug_logger = None self._skills_manager = None self._skill_runner = None self._current_worker: Worker | None = None self._cancel_count = 0 def compose(self) -> ComposeResult: yield HeaderPanel(model_name=self._config.llm.model) yield RichLog(id="chat-log", highlight=True, markup=True) yield StreamingStatic("", id="streaming") yield StatusBar() yield HistoryInput(placeholder="Enter your prompt...") async def on_mount(self) -> None: """Initialize agent components after the app is mounted.""" setup_logging_for_tui() self._ctx = SessionContext(self._config) # Create long-lived agent dependencies (reused across turns) self._client = LLMClient(self._config.llm) await self._client.__aenter__() 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 ) # Create tool registry (SkillRunner wired after registry exists) self._tool_registry = create_default_registry( self._config.agent.workspace_root, self._config, skills_manager=self._skills_manager, ) # Create SkillRunner and late-bind it to skill tools if self._skills_manager is not None and self._tool_registry is not None: from app.services.skill_runner import SkillRunner self._skill_runner = SkillRunner( self._skills_manager, self._config, self._ctx, self._tool_registry, ) # Late-bind runner to skill tools already in the registry load_tool = self._tool_registry.get("load_skill") if load_tool and hasattr(load_tool, "set_skill_runner"): load_tool.set_skill_runner(self._skill_runner) finish_tool = self._tool_registry.get("finish_skill") if finish_tool and hasattr(finish_tool, "set_skill_runner"): finish_tool.set_skill_runner(self._skill_runner) # Set up permission prompt callback async def permission_prompt(tool_name: str, description: str) -> bool: return await self._show_permission_modal(tool_name, description) self._permissions.set_prompt_callback(permission_prompt) # Offer session resume if configured (must run in a worker for push_screen_wait) self._offer_session_resume() @work async def _offer_session_resume(self) -> None: """Offer to resume a previous session, running in a worker for modal support.""" if self._session_mgr and self._config.session.offer_resume: saved = self._session_mgr.load_latest() if saved: log = self.query_one("#chat-log", RichLog) 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(HistoryInput).focus() async def on_input_submitted(self, event: Input.Submitted) -> None: """Handle user input submission.""" user_input = event.value.strip() if not user_input: return event.input.clear() event.input.record(user_input) log = self.query_one("#chat-log", RichLog) # Echo user prompt (condensed for multi-line) from app.utils.display import render_user_message log.write(render_user_message(user_input)) # Handle slash commands if user_input.startswith("/"): await self._handle_slash_command(user_input, log) return # Dispatch agent turn as async worker self._cancel_count = 0 self._current_worker = self.run_worker( self._run_agent_turn(user_input), name="agent-turn", exclusive=True, ) async def _handle_slash_command(self, command: str, log: RichLog) -> None: """Process slash commands.""" cmd = command.lower() if cmd == "/help": from rich.table import Table table = Table(title="SneakyCode Commands", show_lines=False) table.add_column("Command", style="cyan", no_wrap=True) table.add_column("Description") table.add_row("/help", "Show this help message") table.add_row("/quit, /exit, /bye", "Save session and exit") table.add_row("/clear", "Clear conversation history") table.add_row("/history", "Show conversation history") table.add_row("/save", "Manually save session") table.add_row("/session", "Show session info (messages, tokens, start time)") table.add_row("/models", "List available Ollama models") table.add_row("/models ", "Switch to a different model") table.add_row("/mode", "Show current agent mode") table.add_row("/mode normal|plan|auto", "Switch agent mode") table.add_row("/skills", "List available skills") table.add_row("/", "Load a skill by name") log.write(table) elif cmd in ("/quit", "/exit", "/bye"): self._save_session() self.exit() elif cmd == "/clear": if self._ctx: self._ctx.clear_history() log.clear() log.write(Text("✓ Conversation history cleared.", style="bold green")) elif cmd == "/history": if self._ctx: from app.utils.display import render_history log.write(render_history(self._ctx.get_history())) elif cmd == "/save": path = self._save_session() if path: log.write(Text(f"✓ Session saved to {path}", style="bold green")) else: log.write(Text("✗ No session to save", style="bold red")) elif cmd == "/session": if self._ctx: log.write(Text( f"Messages: {self._ctx.message_count} | " f"Tokens: ~{self._ctx.estimated_tokens:,} / {self._ctx.token_counter.budget:,} | " 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.query_one(HeaderPanel).update_model(new_model) log.write(Text(f"Switched to model: {new_model}", style="bold green")) elif cmd.startswith("/mode"): parts = command.split(maxsplit=1) if len(parts) == 1: current = self._permissions.mode log.write(Text(f"Current mode: {current.value}", style="cyan")) else: mode_str = parts[1].strip().lower() try: new_mode = AgentMode(mode_str) except ValueError: log.write(Text(f"Unknown mode: {mode_str}. Use normal, plan, or auto.", style="yellow")) return self._permissions.mode = new_mode self.query_one(HeaderPanel).update_mode(new_mode) log.write(Text(f"Switched to {new_mode.value} mode", 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 trigger (package skill via SkillRunner) if self._skill_runner and self._skills_manager: skill = self._skills_manager.get_skill_by_trigger(cmd.lstrip("/")) if skill is not None: content = self._skill_runner.activate(skill.name) status_bar = self.query_one(StatusBar) status_bar.set_active_skill(skill.name) log.write(Text(f"Skill activated: {skill.name}", style="bold green")) # Run an agent turn so the LLM sees the skill context self._cancel_count = 0 self._current_worker = self.run_worker( self._run_agent_turn(f"[Skill activated: {skill.name}]"), name="agent-turn", exclusive=True, ) return # Try as legacy 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: """Run a single agent turn (called as a worker).""" if self._ctx is None or self._client is None: return 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 header = self.query_one(HeaderPanel) def on_content(content: str) -> None: streaming_widget.update( Panel(Markdown(content), title="Assistant", border_style="green", expand=True) ) streaming_widget.show_streaming() stream_tokens = len(content) // 4 status_bar.update_stream_tokens(stream_tokens) header.update_tokens( self._ctx.estimated_tokens + stream_tokens, self._ctx.token_counter.budget, ) def on_thinking() -> None: streaming_widget.update(Text("Thinking...", style="dim")) streaming_widget.show_streaming() 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, skill_runner=self._skill_runner, ) await agent.run_turn(user_input) status_bar.stop_streaming() # Update token display in header header = self.query_one(HeaderPanel) header.update_tokens(self._ctx.estimated_tokens, self._ctx.token_counter.budget) # Update skill indicator (skill may have been deactivated via finish_skill) if self._skill_runner and not self._skill_runner.is_active: status_bar.set_active_skill(None) elif self._skill_runner and self._skill_runner.is_active: status_bar.set_active_skill(self._skill_runner.active_skill_name) # 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 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.""" self._cancel_count += 1 if self._cancel_count >= 2 or self._current_worker is None: self._save_session() self.exit() elif self._current_worker is not None: self._current_worker.cancel() log = self.query_one("#chat-log", RichLog) log.write(Text("⚠ Cancelling... (press Ctrl+C again to quit)", style="yellow")) def action_cycle_mode(self) -> None: """Cycle through agent modes: Normal → Plan → Auto → Normal.""" if self._permissions is None: return cycle = { AgentMode.NORMAL: AgentMode.PLAN, AgentMode.PLAN: AgentMode.AUTO, AgentMode.AUTO: AgentMode.NORMAL, } new_mode = cycle[self._permissions.mode] self._permissions.mode = new_mode self.query_one(HeaderPanel).update_mode(new_mode) log = self.query_one("#chat-log", RichLog) log.write(Text(f"Mode: {new_mode.value}", style="bold green")) 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 if event.worker.name != "agent-turn": return if event.state == WorkerState.ERROR: log = self.query_one("#chat-log", RichLog) error = event.worker.error log.write(Text(f"✗ Agent error: {error}", style="bold red")) if event.state in (WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED): self._current_worker = None # 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.""" if self._session_mgr and self._ctx and self._ctx.message_count > 0: try: return self._session_mgr.save(self._ctx) except OSError as e: logger.warning("session_save_failed", error=str(e)) return None