# Textual TUI Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the sequential print-and-scroll terminal UI with a full Textual TUI — fixed input at bottom, scrollable chat log above, header with app/model info, footer with token/iteration stats. **Architecture:** Textual App owns the terminal. Agent loop runs as an async worker, posting renderables to a RichLog widget. StreamHandler uses callbacks instead of Rich.Live. Permissions use modal screens with asyncio.Event for blocking. **Tech Stack:** Textual >=4.0.0, Rich (existing), Python 3.11+ **Spec:** `docs/superpowers/specs/2026-03-11-textual-tui-design.md` --- ## File Structure | File | Action | Responsibility | |------|--------|----------------| | `app/ui/__init__.py` | Create | Package init | | `app/ui/app.py` | Create | Textual App subclass — layout, input handling, worker dispatch | | `app/ui/widgets.py` | Create | StatusBar, StreamingStatic, PermissionModal widgets | | `app/ui/styles.tcss` | Create | Textual CSS for layout and colors | | `app/utils/display.py` | Modify | Convert `print_*` to `render_*` (return renderables), add `DisplayAdapter` class | | `app/services/streaming.py` | Modify | Remove Rich.Live, use callbacks for UI updates | | `app/services/permissions.py` | Modify | Make `check()` async, use asyncio.Event + app message | | `app/agent/loop.py` | Modify | Accept DisplayAdapter, make `_execute_tool_calls` async | | `app/utils/logging.py` | Modify | Add `setup_logging_for_tui()` to disable RichHandler | | `app/main.py` | Modify | Replace REPL loop with `SneakyCodeApp().run()` | | `pyproject.toml` | Modify | Add `textual>=4.0.0` dependency | | `tests/unit/test_display.py` | Create | Tests for render_* functions and DisplayAdapter | | `tests/unit/test_streaming.py` | Create | Tests for callback-based StreamHandler | | `tests/unit/test_permissions.py` | Create | Tests for async PermissionsService | | `tests/unit/test_agent_loop.py` | Modify | Update for async _execute_tool_calls and DisplayAdapter | --- ## Chunk 1: Foundation — Dependencies, Logging, and Display Adapter ### Task 1: Add Textual dependency **Files:** - Modify: `pyproject.toml:10-16` - [ ] **Step 1: Add textual to dependencies** In `pyproject.toml`, add `"textual>=4.0.0"` to the `dependencies` list: ```toml dependencies = [ "pydantic>=2.5,<3", "rich>=13.0", "pyyaml>=6.0", "httpx>=0.27", "structlog>=24.0", "textual>=4.0.0", ] ``` - [ ] **Step 2: Install the dependency** Run: `.venv/bin/python -m pip install textual>=4.0.0` Expected: Successfully installed textual - [ ] **Step 3: Verify import works** Run: `.venv/bin/python -c "import textual; print(textual.__version__)"` Expected: Version number printed (4.x.x) - [ ] **Step 4: Sync dev deps** Run: `cd /home/ptarrant/repos/sneakygeek/SneakyCode && uv sync --dev` Expected: All deps synced - [ ] **Step 5: Commit** ```bash git add pyproject.toml uv.lock git commit -m "feat: add textual dependency for TUI" ``` --- ### Task 2: Add TUI-safe logging mode **Files:** - Modify: `app/utils/logging.py:1-90` - Test: `tests/unit/test_logging_tui.py` - [ ] **Step 1: Write the failing test** Create `tests/unit/test_logging_tui.py`: ```python """Tests for TUI-safe logging configuration.""" import logging from app.utils.logging import setup_logging, setup_logging_for_tui class TestTuiLogging: def test_setup_logging_for_tui_removes_rich_handler(self) -> None: """TUI mode should have no RichHandler on root logger.""" from rich.logging import RichHandler # First set up normal logging (adds RichHandler) setup_logging() root = logging.getLogger() assert any(isinstance(h, RichHandler) for h in root.handlers) # Switch to TUI mode setup_logging_for_tui() root = logging.getLogger() assert not any(isinstance(h, RichHandler) for h in root.handlers) def test_setup_logging_for_tui_keeps_file_handler(self, tmp_path) -> None: """TUI mode should preserve file handler if configured.""" log_file = tmp_path / "test.log" setup_logging(log_file=log_file) setup_logging_for_tui() root = logging.getLogger() file_handlers = [h for h in root.handlers if isinstance(h, logging.FileHandler)] assert len(file_handlers) == 1 ``` - [ ] **Step 2: Run test to verify it fails** Run: `.venv/bin/python -m pytest tests/unit/test_logging_tui.py -v` Expected: FAIL — `setup_logging_for_tui` not found - [ ] **Step 3: Implement setup_logging_for_tui** Add to `app/utils/logging.py` after the `setup_logging` function (after line 77): ```python def setup_logging_for_tui() -> None: """Reconfigure logging for Textual TUI mode. Removes the RichHandler (which writes to stdout and corrupts the TUI) while preserving any file handlers. Call this from the Textual App's on_mount() before any agent work begins. """ from rich.logging import RichHandler root_logger = logging.getLogger() root_logger.handlers = [ h for h in root_logger.handlers if not isinstance(h, RichHandler) ] ``` - [ ] **Step 4: Run test to verify it passes** Run: `.venv/bin/python -m pytest tests/unit/test_logging_tui.py -v` Expected: 2 PASSED - [ ] **Step 5: Commit** ```bash git add app/utils/logging.py tests/unit/test_logging_tui.py git commit -m "feat: add TUI-safe logging mode that disables RichHandler" ``` --- ### Task 3: Convert display.py to render functions + DisplayAdapter **Files:** - Modify: `app/utils/display.py:1-129` - Test: `tests/unit/test_display.py` - [ ] **Step 1: Write tests for render functions** Create `tests/unit/test_display.py`: ```python """Tests for display render functions and DisplayAdapter.""" from unittest.mock import MagicMock from rich.panel import Panel from rich.table import Table from rich.text import Text from app.models.message import Message from app.utils.display import ( DisplayAdapter, render_assistant_message, render_error, render_iteration_header, render_tool_call, render_tool_result, render_token_usage, render_user_message, render_warning, ) class TestRenderFunctions: def test_render_user_message_returns_panel(self) -> None: result = render_user_message("hello") assert isinstance(result, Panel) assert result.title == "You" def test_render_assistant_message_returns_panel(self) -> None: result = render_assistant_message("response") assert isinstance(result, Panel) assert result.title == "Assistant" def test_render_tool_call_returns_text(self) -> None: result = render_tool_call("read_file", '{"path": "foo.py"}') assert isinstance(result, Text) assert "read_file" in result.plain def test_render_tool_result_success(self) -> None: result = render_tool_result("read_file", "file contents here", is_error=False) assert isinstance(result, Text) assert "read_file" in result.plain def test_render_tool_result_error(self) -> None: result = render_tool_result("read_file", "not found", is_error=True) assert isinstance(result, Text) assert "read_file" in result.plain def test_render_iteration_header(self) -> None: result = render_iteration_header(3, 25) assert isinstance(result, Text) assert "3/25" in result.plain def test_render_token_usage(self) -> None: result = render_token_usage(1500, 32000) assert isinstance(result, Text) assert "1,500" in result.plain def test_render_warning(self) -> None: result = render_warning("something happened") assert isinstance(result, Text) def test_render_error(self) -> None: result = render_error("bad thing") assert isinstance(result, Text) class TestDisplayAdapter: def test_write_user_message(self) -> None: mock_log = MagicMock() adapter = DisplayAdapter(mock_log) adapter.write_user_message("hello") mock_log.write.assert_called_once() arg = mock_log.write.call_args[0][0] assert isinstance(arg, Panel) def test_write_tool_call(self) -> None: mock_log = MagicMock() adapter = DisplayAdapter(mock_log) adapter.write_tool_call("read_file", '{"path": "x"}') mock_log.write.assert_called_once() def test_write_warning(self) -> None: mock_log = MagicMock() adapter = DisplayAdapter(mock_log) adapter.write_warning("oops") mock_log.write.assert_called_once() ``` - [ ] **Step 2: Run tests to verify they fail** Run: `.venv/bin/python -m pytest tests/unit/test_display.py -v` Expected: FAIL — render_* functions and DisplayAdapter don't exist - [ ] **Step 3: Rewrite display.py with render functions and DisplayAdapter** Replace `app/utils/display.py` entirely: ```python """Rich terminal display helpers for SneakyCode. Provides two interfaces: - render_* functions: return Rich renderables (for use in Textual RichLog) - DisplayAdapter: wraps a RichLog widget and provides write_* convenience methods - print_* functions: legacy console.print wrappers (for non-TUI fallback) """ from __future__ import annotations from typing import TYPE_CHECKING, Protocol from rich.panel import Panel from rich.table import Table from rich.text import Text from rich.theme import Theme from app.models.message import Message from app.utils.logging import console # Custom theme for consistent styling across the application SNEAKYCODE_THEME = Theme( { "info": "cyan", "warning": "yellow", "error": "bold red", "success": "bold green", "tool": "magenta", "dim": "dim white", } ) # Apply the theme to the shared console console.push_theme(SNEAKYCODE_THEME) if TYPE_CHECKING: from rich.console import RenderableType # --------------------------------------------------------------------------- # Render functions — return Rich renderables # --------------------------------------------------------------------------- def render_user_message(content: str) -> Panel: """Render a user message as a styled panel.""" return Panel(content, title="You", border_style="cyan", expand=False) def render_assistant_message(content: str) -> Panel: """Render an assistant message as a styled panel.""" return Panel(content, title="Assistant", border_style="green", expand=True) def render_tool_call(name: str, args: str) -> Text: """Render a compact tool call line.""" truncated_args = args[:80] + "..." if len(args) > 80 else args text = Text() text.append(" ") text.append(name, style="magenta") text.append(f" {truncated_args}", style="dim") return text def render_tool_result(name: str, output: str, is_error: bool = False) -> Text: """Render a compact tool result line.""" text = Text() text.append(" ") if is_error: truncated = output[:200] + "..." if len(output) > 200 else output text.append(f"{name}: {truncated}", style="bold red") else: lines = output.count("\n") + 1 if output else 0 chars = len(output) text.append(f"{name} — {lines} lines, {chars} chars", style="dim") return text def render_iteration_header(iteration: int, max_iterations: int) -> Text: """Render the current agent loop iteration.""" return Text(f"── iteration {iteration}/{max_iterations} ──", style="dim") def render_token_usage(usage_tokens: int, budget: int) -> Text: """Render token usage as styled text.""" return Text(f"Tokens: ~{usage_tokens:,} / {budget:,}", style="dim") def render_warning(message: str) -> Text: """Render a warning message.""" return Text(f"⚠ {message}", style="yellow") def render_error(message: str) -> Text: """Render an error message.""" return Text(f"✗ {message}", style="bold red") def render_success(message: str) -> Text: """Render a success message.""" return Text(f"✓ {message}", style="bold green") def render_info(message: str) -> Text: """Render an info message.""" return Text(message, style="cyan") def render_history(messages: list[Message]) -> Table | Text: """Render conversation history as a Rich table.""" if not messages: return Text("No messages in history.", style="dim") table = Table(title="Conversation History") table.add_column("#", style="dim", width=4) table.add_column("Role", width=10) table.add_column("Content") role_styles = { "user": "cyan", "assistant": "green", "system": "yellow", "tool": "magenta", } for i, msg in enumerate(messages, 1): style = role_styles.get(msg.role, "white") content = msg.content or "(no content)" if len(content) > 120: content = content[:117] + "..." table.add_row(str(i), f"[{style}]{msg.role}[/{style}]", content) return table # --------------------------------------------------------------------------- # DisplayAdapter — wraps a writable target (RichLog or similar) # --------------------------------------------------------------------------- class WritableLog(Protocol): """Protocol for anything that accepts Rich renderables via write().""" def write(self, content: "RenderableType") -> None: ... class DisplayAdapter: """Bridges agent loop display calls to a RichLog widget. All write_* methods call the corresponding render_* function and write the result to the target log. """ def __init__(self, log: WritableLog) -> None: self._log = log def write_user_message(self, content: str) -> None: self._log.write(render_user_message(content)) def write_assistant_message(self, content: str) -> None: self._log.write(render_assistant_message(content)) def write_tool_call(self, name: str, args: str) -> None: self._log.write(render_tool_call(name, args)) def write_tool_result(self, name: str, output: str, is_error: bool = False) -> None: self._log.write(render_tool_result(name, output, is_error)) def write_iteration_header(self, iteration: int, max_iterations: int) -> None: self._log.write(render_iteration_header(iteration, max_iterations)) def write_warning(self, message: str) -> None: self._log.write(render_warning(message)) def write_error(self, message: str) -> None: self._log.write(render_error(message)) def write_success(self, message: str) -> None: self._log.write(render_success(message)) def write_info(self, message: str) -> None: self._log.write(render_info(message)) def write_history(self, messages: list[Message]) -> None: self._log.write(render_history(messages)) # --------------------------------------------------------------------------- # Legacy print functions — for non-TUI fallback and pre-TUI startup # --------------------------------------------------------------------------- def print_banner() -> None: """Print the SneakyCode startup banner.""" console.print( "\n[bold cyan] SneakyCode[/bold cyan] [dim]— Local AI Coding Agent[/dim]\n", ) def print_info(message: str) -> None: """Print an informational message.""" console.print(f"[info]{message}[/info]") def print_warning(message: str) -> None: """Print a warning message.""" console.print(f"[warning]⚠ {message}[/warning]") def print_error(message: str) -> None: """Print an error message.""" console.print(f"[error]✗ {message}[/error]") def print_success(message: str) -> None: """Print a success message.""" console.print(f"[success]✓ {message}[/success]") def print_user_message(content: str) -> None: """Print a user message in a styled panel.""" console.print(Panel(content, title="You", border_style="cyan", expand=False)) def print_assistant_message(content: str) -> None: """Print an assistant message in a styled panel.""" console.print(Panel(content, title="Assistant", border_style="green", expand=False)) def print_tool_call(name: str, args: str) -> None: """Print a compact tool call line.""" truncated_args = args[:80] + "..." if len(args) > 80 else args console.print(f" [tool]{name}[/tool] [dim]{truncated_args}[/dim]") def print_tool_result(name: str, output: str, is_error: bool = False) -> None: """Print a compact tool result.""" if is_error: truncated = output[:200] + "..." if len(output) > 200 else output console.print(f" [error]{name}: {truncated}[/error]") else: lines = output.count("\n") + 1 if output else 0 chars = len(output) console.print(f" [dim]{name} — {lines} lines, {chars} chars[/dim]") def print_iteration_header(iteration: int, max_iterations: int) -> None: """Print the current agent loop iteration.""" console.print(f"[dim]── iteration {iteration}/{max_iterations} ──[/dim]") def print_token_usage(usage_tokens: int, budget: int) -> None: """Print current token usage against budget.""" console.print(f"[dim]Tokens: ~{usage_tokens:,} / {budget:,}[/dim]") def print_history(messages: list[Message]) -> None: """Print conversation history as a Rich table.""" console.print(render_history(messages)) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `.venv/bin/python -m pytest tests/unit/test_display.py -v` Expected: All PASSED - [ ] **Step 5: Run existing tests to verify nothing broken** Run: `.venv/bin/python -m pytest tests/ -v` Expected: All existing tests still pass (print_* functions preserved as legacy) - [ ] **Step 6: Commit** ```bash git add app/utils/display.py tests/unit/test_display.py git commit -m "feat: add render functions and DisplayAdapter for TUI display" ``` --- ## Chunk 2: StreamHandler Callbacks and Async Permissions ### Task 4: Convert StreamHandler to callback-based **Files:** - Modify: `app/services/streaming.py:1-175` - Test: `tests/unit/test_streaming.py` - [ ] **Step 1: Write tests for callback-based streaming** Create `tests/unit/test_streaming.py`: ```python """Tests for callback-based StreamHandler.""" import asyncio from unittest.mock import MagicMock, call import pytest from app.models.config import DisplayConfig from app.services.streaming import StreamHandler def _make_chunk(content: str | None = None, reasoning: str | None = None) -> dict: """Helper to create a fake SSE chunk.""" delta: dict = {} if content is not None: delta["content"] = content if reasoning is not None: delta["reasoning"] = reasoning return {"choices": [{"delta": delta}]} def _make_tool_call_chunk(index: int, tc_id: str = "", name: str = "", args: str = "") -> dict: """Helper to create a fake tool call chunk.""" tc_delta: dict = {"index": index} if tc_id: tc_delta["id"] = tc_id func: dict = {} if name: func["name"] = name if args: func["arguments"] = args if func: tc_delta["function"] = func return {"choices": [{"delta": {"tool_calls": [tc_delta]}}]} async def _async_iter(items: list[dict]): for item in items: yield item class TestStreamHandlerCallbacks: @pytest.mark.asyncio async def test_on_content_called_with_accumulated_text(self) -> None: handler = StreamHandler(DisplayConfig(stream_output=True)) on_content = MagicMock() on_thinking = MagicMock() on_done = MagicMock() handler.set_callbacks(on_content=on_content, on_thinking=on_thinking, on_done=on_done) chunks = [_make_chunk(content="Hello"), _make_chunk(content=" world")] msg = await handler.process_stream(_async_iter(chunks)) assert msg.content == "Hello world" assert on_content.call_count >= 1 # Last call should have full accumulated content last_content = on_content.call_args_list[-1][0][0] assert last_content == "Hello world" on_done.assert_called_once() @pytest.mark.asyncio async def test_on_thinking_called_for_reasoning(self) -> None: handler = StreamHandler(DisplayConfig(stream_output=True)) on_content = MagicMock() on_thinking = MagicMock() on_done = MagicMock() handler.set_callbacks(on_content=on_content, on_thinking=on_thinking, on_done=on_done) chunks = [_make_chunk(reasoning="let me think")] msg = await handler.process_stream(_async_iter(chunks)) on_thinking.assert_called_once() on_content.assert_not_called() on_done.assert_called_once() @pytest.mark.asyncio async def test_display_callbacks_skip_when_stream_output_disabled(self) -> None: """on_content and on_thinking are suppressed, but on_done always fires.""" handler = StreamHandler(DisplayConfig(stream_output=False)) on_content = MagicMock() on_thinking = MagicMock() on_done = MagicMock() handler.set_callbacks(on_content=on_content, on_thinking=on_thinking, on_done=on_done) chunks = [_make_chunk(content="Hello")] msg = await handler.process_stream(_async_iter(chunks)) assert msg.content == "Hello" on_content.assert_not_called() on_thinking.assert_not_called() on_done.assert_called_once() @pytest.mark.asyncio async def test_no_callbacks_by_default(self) -> None: """process_stream works without set_callbacks (backward compat).""" handler = StreamHandler(DisplayConfig(stream_output=True)) chunks = [_make_chunk(content="Hello")] msg = await handler.process_stream(_async_iter(chunks)) assert msg.content == "Hello" @pytest.mark.asyncio async def test_tool_calls_still_accumulated(self) -> None: handler = StreamHandler(DisplayConfig(stream_output=True)) chunks = [ _make_tool_call_chunk(0, tc_id="call_1", name="read_file"), _make_tool_call_chunk(0, args='{"path": "foo.py"}'), ] msg = await handler.process_stream(_async_iter(chunks)) assert msg.tool_calls is not None assert len(msg.tool_calls) == 1 assert msg.tool_calls[0].function.name == "read_file" ``` - [ ] **Step 2: Run tests to verify they fail** Run: `.venv/bin/python -m pytest tests/unit/test_streaming.py -v` Expected: FAIL — `process_stream` doesn't accept callbacks - [ ] **Step 3: Rewrite StreamHandler.process_stream with callbacks** Replace `app/services/streaming.py`: ```python """Streaming response handler — accumulates SSE chunks into a complete Message.""" import time from collections.abc import AsyncIterator, Callable from app.models.config import DisplayConfig from app.models.message import Message from app.models.tool_call import ToolCall, ToolCallFunction from app.utils.logging import get_logger from app.utils.token_counter import TokenUsage logger = get_logger(__name__) # Minimum interval between content update callbacks (seconds) _UPDATE_THROTTLE_INTERVAL = 0.1 class StreamHandler: """Processes an SSE chunk stream and produces a complete assistant Message. Accumulates content deltas and tool call fragments. Notifies the UI via optional callbacks during streaming. """ def __init__(self, display_config: DisplayConfig) -> None: self._display_config = display_config self._accumulated_content: str = "" self._accumulated_reasoning: str = "" self._tool_calls: dict[int, dict[str, str]] = {} self._usage: TokenUsage | None = None self._on_content: Callable[[str], None] | None = None self._on_thinking: Callable[[], None] | None = None self._on_done: Callable[[], None] | None = None def set_callbacks( self, on_content: Callable[[str], None] | None = None, on_thinking: Callable[[], None] | None = None, on_done: Callable[[], None] | None = None, ) -> None: """Set UI callbacks for streaming updates. Args: on_content: Called with accumulated content string (throttled to ~100ms). on_thinking: Called once when first reasoning token arrives. on_done: Called when streaming is complete. """ self._on_content = on_content self._on_thinking = on_thinking self._on_done = on_done async def process_stream(self, chunk_iter: AsyncIterator[dict]) -> Message: """Consume a chunk iterator and return the final Message. Args: chunk_iter: Async iterator of parsed SSE chunk dicts. Returns: Complete assistant Message with accumulated content and tool calls. """ thinking_notified = False last_update_time = 0.0 async for chunk in chunk_iter: self._process_chunk(chunk) if not self._display_config.stream_output: continue # Notify thinking once if ( not thinking_notified and not self._accumulated_content and self._accumulated_reasoning and self._on_thinking is not None ): self._on_thinking() thinking_notified = True # Throttled content updates if self._accumulated_content and self._on_content is not None: now = time.monotonic() if now - last_update_time >= _UPDATE_THROTTLE_INTERVAL: self._on_content(self._accumulated_content) last_update_time = now # Final content update (always fires, ensures last chunk is shown) if self._accumulated_content and self._on_content is not None: self._on_content(self._accumulated_content) if self._on_done is not None: self._on_done() tool_calls = self._build_tool_calls() or None return Message( role="assistant", content=self._accumulated_content or None, tool_calls=tool_calls, ) def _process_chunk(self, chunk: dict) -> None: """Extract content, tool calls, and usage from a single SSE chunk.""" choices = chunk.get("choices", []) if choices: delta = choices[0].get("delta", {}) content_piece = delta.get("content") if content_piece: self._accumulated_content += content_piece reasoning_piece = delta.get("reasoning") if reasoning_piece: self._accumulated_reasoning += reasoning_piece for tc_delta in delta.get("tool_calls", []): idx = tc_delta.get("index", 0) if idx not in self._tool_calls: self._tool_calls[idx] = { "id": tc_delta.get("id", ""), "name": "", "arguments": "", } entry = self._tool_calls[idx] if tc_delta.get("id"): entry["id"] = tc_delta["id"] func = tc_delta.get("function", {}) if func.get("name"): entry["name"] += func["name"] if func.get("arguments"): entry["arguments"] += func["arguments"] usage_data = chunk.get("usage") if usage_data: self._usage = TokenUsage( prompt_tokens=usage_data.get("prompt_tokens", 0), completion_tokens=usage_data.get("completion_tokens", 0), total_tokens=usage_data.get("total_tokens", 0), ) def _build_tool_calls(self) -> list[ToolCall]: """Convert accumulated tool call fragments into sorted ToolCall list.""" if not self._tool_calls: return [] result: list[ToolCall] = [] for idx in sorted(self._tool_calls): entry = self._tool_calls[idx] result.append( ToolCall( id=entry["id"], type="function", function=ToolCallFunction( name=entry["name"], arguments=entry["arguments"], ), ) ) return result def get_partial_message(self) -> Message | None: """Return whatever content/tool_calls have been accumulated so far.""" tool_calls = self._build_tool_calls() or None if not self._accumulated_content and not tool_calls: return None return Message( role="assistant", content=self._accumulated_content or None, tool_calls=tool_calls, ) @property def usage(self) -> TokenUsage | None: """Token usage reported by the API, if available.""" return self._usage @property def had_reasoning_only(self) -> bool: """True if the model produced reasoning tokens but no content or tool calls.""" return bool(self._accumulated_reasoning) and not self._accumulated_content and not self._tool_calls def reset(self) -> None: """Clear all accumulators for the next turn.""" self._accumulated_content = "" self._accumulated_reasoning = "" self._tool_calls.clear() self._usage = None self._on_content = None self._on_thinking = None self._on_done = None ``` - [ ] **Step 4: Run new tests** Run: `.venv/bin/python -m pytest tests/unit/test_streaming.py -v` Expected: All PASSED - [ ] **Step 5: Run all tests to verify nothing broken** Run: `.venv/bin/python -m pytest tests/ -v` Expected: All pass (existing tests use mocked StreamHandler) - [ ] **Step 6: Commit** ```bash git add app/services/streaming.py tests/unit/test_streaming.py git commit -m "feat: convert StreamHandler to callback-based (remove Rich.Live)" ``` --- ### Task 5: Make PermissionsService async **Files:** - Modify: `app/services/permissions.py:1-47` - Test: `tests/unit/test_permissions.py` - [ ] **Step 1: Write tests for async permissions** Create `tests/unit/test_permissions.py`: ```python """Tests for async PermissionsService.""" import asyncio import pytest from app.models.config import PermissionsConfig from app.services.permissions import PermissionsService class TestPermissionsService: @pytest.mark.asyncio async def test_auto_approve(self) -> None: config = PermissionsConfig(auto_approve=["read_file"]) svc = PermissionsService(config) assert await svc.check("read_file") is True @pytest.mark.asyncio async def test_deny(self) -> None: config = PermissionsConfig(deny=["rm_file"]) svc = PermissionsService(config) assert await svc.check("rm_file") is False @pytest.mark.asyncio async def test_prompt_callback_approve(self) -> None: config = PermissionsConfig() svc = PermissionsService(config) async def approve_callback(tool_name: str, description: str) -> bool: return True svc.set_prompt_callback(approve_callback) assert await svc.check("write_file", description="write something") is True @pytest.mark.asyncio async def test_prompt_callback_deny(self) -> None: config = PermissionsConfig() svc = PermissionsService(config) async def deny_callback(tool_name: str, description: str) -> bool: return False svc.set_prompt_callback(deny_callback) assert await svc.check("write_file") is False @pytest.mark.asyncio async def test_no_callback_defaults_to_deny(self) -> None: """Without a callback set, unlisted tools are denied.""" config = PermissionsConfig() svc = PermissionsService(config) assert await svc.check("write_file") is False ``` - [ ] **Step 2: Run tests to verify they fail** Run: `.venv/bin/python -m pytest tests/unit/test_permissions.py -v` Expected: FAIL — `check()` is not async - [ ] **Step 3: Rewrite permissions.py with async check and prompt callback** Replace `app/services/permissions.py`: ```python """Permission gating for tool execution.""" from __future__ import annotations import logging from collections.abc import Awaitable, Callable from app.models.config import PermissionsConfig logger = logging.getLogger(__name__) # Type alias for the async prompt callback PromptCallback = Callable[[str, str], Awaitable[bool]] class PermissionDenied(Exception): """Raised when a tool is denied execution by permissions policy.""" class PermissionsService: """Check whether a tool is allowed to execute based on config tiers. In TUI mode, set a prompt callback via set_prompt_callback() that shows a modal dialog. Without a callback, unlisted tools are denied. """ def __init__(self, config: PermissionsConfig) -> None: self.config = config self._prompt_callback: PromptCallback | None = None def set_prompt_callback(self, callback: PromptCallback) -> None: """Set the async callback used to prompt the user for permission. Args: callback: Async function(tool_name, description) -> bool. """ self._prompt_callback = callback async def check(self, tool_name: str, description: str = "") -> bool: """Check if a tool is permitted to run. Returns: True if permitted, False if denied. """ if tool_name in self.config.deny: logger.info("Tool '%s' is in deny list — blocked", tool_name) return False if tool_name in self.config.auto_approve: logger.debug("Tool '%s' is auto-approved", tool_name) return True # Prompt user via callback (TUI modal, etc.) if self._prompt_callback is not None: return await self._prompt_callback(tool_name, description) # No callback set — deny by default (safe fallback) logger.warning("Tool '%s' requires approval but no prompt callback set — denied", tool_name) return False ``` - [ ] **Step 4: Run tests to verify they pass** Run: `.venv/bin/python -m pytest tests/unit/test_permissions.py -v` Expected: All PASSED - [ ] **Step 5: Commit** ```bash git add app/services/permissions.py tests/unit/test_permissions.py git commit -m "feat: make PermissionsService async with pluggable prompt callback" ``` --- ### Task 6: Update AgentLoop for DisplayAdapter and async permissions **Files:** - Modify: `app/agent/loop.py:1-285` - Modify: `tests/unit/test_agent_loop.py:1-292` - [ ] **Step 1: Update AgentLoop to accept DisplayAdapter** Modify `app/agent/loop.py`: 1. Replace the display imports (lines 14-21): ```python from app.utils.display import DisplayAdapter ``` 2. Update `__init__` signature (lines 37-54) to accept a `DisplayAdapter`: ```python def __init__( self, config: AppConfig, ctx: SessionContext, client: LLMClient, handler: StreamHandler, registry: ToolRegistry, permissions: PermissionsService, display: DisplayAdapter | None = None, ) -> None: self._config = config self._ctx = ctx self._client = client self._handler = handler self._registry = registry self._permissions = permissions self._display = display self._tools_schema = registry.get_openai_tools_schema() self._system_prompt = self._build_system_prompt() self._cancelled = False ``` 3. Replace all `print_*` calls with `self._display.write_*` calls, guarded by `if self._display:`. For example, `print_warning("msg")` becomes: ```python if self._display: self._display.write_warning("msg") ``` Apply this pattern to every display call in `run_turn()` and `_llm_step()`: - `print_warning(...)` → `self._display.write_warning(...)` - `print_error(...)` → `self._display.write_error(...)` - `print_iteration_header(...)` → `self._display.write_iteration_header(...)` - `print_token_usage(...)` → `self._display.write_token_usage(...)` (add this method to DisplayAdapter — see below) - `print_tool_call(...)` → `self._display.write_tool_call(...)` - `print_tool_result(...)` → `self._display.write_tool_result(...)` 4. Make `_execute_tool_calls` async (line 212): ```python async def _execute_tool_calls(self, tool_calls: list[ToolCall]) -> list[ToolResult]: ``` Change the permissions check (line 263) to await: ```python if not await self._permissions.check(name, description=desc): ``` 5. Update the call site in `run_turn()` (line 166): ```python results = await self._execute_tool_calls(assistant_msg.tool_calls) ``` - [ ] **Step 2: Add write_token_usage to DisplayAdapter** In `app/utils/display.py`, add to the `DisplayAdapter` class: ```python def write_token_usage(self, usage_tokens: int, budget: int) -> None: self._log.write(render_token_usage(usage_tokens, budget)) ``` - [ ] **Step 3: Update test fixtures in test_agent_loop.py** Update `tests/unit/test_agent_loop.py`: 1. Update imports (line 4): ```python from unittest.mock import AsyncMock, MagicMock, patch ``` 2. Add a display fixture: ```python @pytest.fixture def display() -> MagicMock: mock = MagicMock(spec=DisplayAdapter) return mock ``` 3. Update the agent fixture to pass display: ```python @pytest.fixture def agent( config: AppConfig, ctx: SessionContext, client: MagicMock, handler: MagicMock, registry: ToolRegistry, permissions: PermissionsService, display: MagicMock, ) -> AgentLoop: return AgentLoop(config, ctx, client, handler, registry, permissions, display) ``` 4. Update the `permissions` fixture to use the async version. Since `PermissionsService.check` is now async, and the test uses `auto_approve` list, tests should still pass because `check()` returns True for auto-approved tools. But we need to make the fixture create a permissions service with auto-approve and provide an async mock where needed: ```python @pytest.fixture def permissions(config: AppConfig) -> PermissionsService: svc = PermissionsService(config.permissions) return svc ``` 5. Update `test_permission_denied` to use the async deny list (no callback needed — deny list is checked synchronously before the async prompt): The test should still work as-is since denied tools return False before reaching the callback. - [ ] **Step 4: Run all tests** Run: `.venv/bin/python -m pytest tests/ -v` Expected: All PASSED - [ ] **Step 5: Verify integration tests still pass** Run: `.venv/bin/python -m pytest tests/integration/ -v` Expected: All pass. Integration tests construct `AgentLoop` without the `display` parameter — this works because `display=None` is the default and all display calls are guarded by `if self._display:`. This is the backward-compatible path. - [ ] **Step 6: Commit** ```bash git add app/agent/loop.py app/utils/display.py tests/unit/test_agent_loop.py git commit -m "feat: wire AgentLoop to DisplayAdapter and async permissions" ``` --- ## Chunk 3: Textual App and Widgets ### Task 7: Create Textual CSS stylesheet **Files:** - Create: `app/ui/__init__.py` - Create: `app/ui/styles.tcss` - [ ] **Step 1: Create package init** Create `app/ui/__init__.py` as empty file. - [ ] **Step 2: Create stylesheet** Create `app/ui/styles.tcss`: ```css /* SneakyCode TUI Layout */ Screen { layout: vertical; } #chat-log { height: 1fr; border: none; scrollbar-gutter: stable; } #streaming { display: none; height: auto; max-height: 50%; padding: 0 1; } #streaming.visible { display: block; } /* StatusBar styles are in DEFAULT_CSS on the widget itself */ Input { dock: bottom; margin: 0; } Header { dock: top; } ``` - [ ] **Step 3: Commit** ```bash git add app/ui/__init__.py app/ui/styles.tcss git commit -m "feat: add Textual CSS stylesheet for TUI layout" ``` --- ### Task 8: Create custom widgets **Files:** - Create: `app/ui/widgets.py` - [ ] **Step 1: Create widgets module** Create `app/ui/widgets.py`: ```python """Custom Textual widgets for SneakyCode TUI.""" from __future__ import annotations from rich.text import Text from textual.widgets import Static class StatusBar(Static): """Single-line status bar showing token usage and iteration count.""" DEFAULT_CSS = """ StatusBar { dock: bottom; height: 1; background: $surface; color: $text-muted; padding: 0 2; } """ def __init__(self) -> None: super().__init__("") self._tokens: int = 0 self._budget: int = 0 self._iteration: int = 0 self._max_iterations: int = 0 def update_tokens(self, tokens: int, budget: int) -> None: """Update the token usage display.""" self._tokens = tokens self._budget = budget self._refresh_display() def update_iteration(self, iteration: int, max_iterations: int) -> None: """Update the iteration count display.""" self._iteration = iteration self._max_iterations = max_iterations self._refresh_display() def _refresh_display(self) -> None: """Rebuild the status bar text.""" parts: list[str] = [] 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")) class StreamingStatic(Static): """A Static widget that stays mounted but hidden during non-streaming periods. During streaming, call show() to make visible and update() with partial content. When streaming ends, call hide() to conceal. """ def show_streaming(self) -> None: """Make the widget visible for streaming.""" self.add_class("visible") def hide_streaming(self) -> None: """Hide the widget and clear content.""" self.remove_class("visible") self.update("") ``` - [ ] **Step 2: Commit** ```bash git add app/ui/widgets.py git commit -m "feat: add StatusBar and StreamingStatic widgets" ``` --- ### Task 9: Create the Textual App **Files:** - Create: `app/ui/app.py` - [ ] **Step 1: Create the app module** Create `app/ui/app.py`: ```python """SneakyCode Textual TUI application.""" from __future__ import annotations import asyncio 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 Header, Input, RichLog from app.agent.context import SessionContext from app.agent.loop import AgentLoop from app.models.config import 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 StatusBar, StreamingStatic from app.utils.display import DisplayAdapter, render_user_message 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), ] 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._current_worker: Worker | None = None self._cancel_count = 0 self.sub_title = config.llm.model def compose(self) -> ComposeResult: yield Header() yield RichLog(id="chat-log", highlight=True, markup=True) yield StreamingStatic("", id="streaming") yield StatusBar() yield Input(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._registry = create_default_registry(self._config.agent.workspace_root, self._config) self._permissions = PermissionsService(self._config.permissions) # 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 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")) self.query_one(Input).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() log = self.query_one("#chat-log", RichLog) # Handle slash commands if user_input.startswith("/"): 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( 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 == "/quit": 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", )) else: 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) display = DisplayAdapter(log) # StreamHandler is per-turn (has per-turn accumulators) handler = StreamHandler(self._config.display) # 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() def on_thinking() -> None: streaming_widget.update(Text("Thinking...", style="dim")) streaming_widget.show_streaming() def on_done() -> None: streaming_widget.hide_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._registry, self._permissions, display, ) await agent.run_turn(user_input) # 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 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 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 in case it was left visible streaming = self.query_one("#streaming", StreamingStatic) streaming.hide_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 ``` - [ ] **Step 2: Verify the app module imports cleanly** Run: `.venv/bin/python -c "from app.ui.app import SneakyCodeApp; print('OK')"` Expected: `OK` - [ ] **Step 3: Commit** ```bash git add app/ui/app.py git commit -m "feat: add SneakyCodeApp Textual application" ``` --- ### Task 10: Update main.py to use Textual App **Files:** - Modify: `app/main.py:1-260` - [ ] **Step 1: Rewrite main.py** Replace `app/main.py`: ```python """SneakyCode entrypoint — argument parsing, config loading, and TUI launch.""" import argparse import asyncio import sys from pathlib import Path from app.models.config import AppConfig, load_config from app.services.llm import LLMClient, LLMConnectionError, LLMError from app.services.session import SessionManager from app.utils.display import print_banner, print_error, print_info, print_success from app.utils.logging import get_logger, setup_logging def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( prog="sneakycode", description="SneakyCode — A privacy-first local AI coding agent", ) parser.add_argument( "--config", type=Path, default=None, help="Path to config YAML file (default: config/config.yaml)", ) parser.add_argument( "-v", "--verbose", action="store_true", default=False, help="Enable verbose (DEBUG) logging", ) parser.add_argument( "--log-file", type=Path, default=None, help="Path to log file for persistent logging", ) return parser.parse_args() async def _preflight(config: AppConfig) -> None: """Check that Ollama is reachable and the configured model is available.""" async with LLMClient(config.llm) as client: await client.preflight_check() def main() -> None: """Main entrypoint: load config, preflight check, launch Textual TUI.""" args = parse_args() # Setup logging first (will be reconfigured for TUI on mount) setup_logging( log_file=args.log_file, verbose=args.verbose, ) logger = get_logger(__name__) # Load configuration try: config = load_config(config_path=args.config) except (FileNotFoundError, ValueError) as e: print_error(f"Configuration error: {e}") sys.exit(1) logger.info("config_loaded", model=config.llm.model, endpoint=config.llm.endpoint) # Pre-TUI startup info (printed to console before Textual takes over) print_banner() print_info(f"Model: {config.llm.model}") print_info(f"Endpoint: {config.llm.endpoint}") print_info(f"Workspace: {config.agent.workspace_root}") if args.verbose: print_info("Verbose mode enabled") # Preflight: check Ollama is reachable and model exists try: asyncio.run(_preflight(config)) except LLMConnectionError as e: print_error(str(e)) sys.exit(1) except LLMError as e: print_error(str(e)) sys.exit(1) print_success("Ollama connected, model ready.") # Create session manager session_mgr = SessionManager(config.session, config.agent.workspace_root, config.llm.model) # Clean up old session files cleaned = session_mgr.cleanup_old() if cleaned > 0: logger.info("old_sessions_cleaned", count=cleaned) # Launch Textual TUI from app.ui.app import SneakyCodeApp app = SneakyCodeApp(config, session_mgr=session_mgr) app.run() if __name__ == "__main__": main() ``` - [ ] **Step 2: Verify the app starts** Run: `.venv/bin/python -m app.main --help` Expected: Help text shown (verifies imports work) - [ ] **Step 3: Commit** ```bash git add app/main.py git commit -m "feat: replace REPL loop with Textual TUI launch" ``` --- ## Chunk 4: Integration Testing and Polish ### Task 11: Manual integration test - [ ] **Step 1: Start the app** Run: `.venv/bin/python -m app.main` Expected: Textual TUI launches with Header (SneakyCode + model name), empty chat log, status bar, and input widget at bottom. - [ ] **Step 2: Test basic prompt** Type a simple prompt and press Enter. Expected: User message appears as cyan-bordered panel in chat log. Thinking indicator shows. Assistant response streams in. Response appears as green-bordered panel. - [ ] **Step 3: Test tool calls** Give a task that requires tools (e.g., "list the files in the current directory"). Expected: Tool call names appear as compact lines. Assistant responds with results. - [ ] **Step 4: Test slash commands** Type `/session`, `/history`, `/clear`, `/save`. Expected: Each command produces appropriate output in the chat log. - [ ] **Step 5: Test Ctrl+C** Start an agent turn, press Ctrl+C once. Expected: "Cancelling..." message, agent stops. Second Ctrl+C exits. - [ ] **Step 6: Test /quit** Type `/quit`. Expected: App exits, session saved. ### Task 12: Run full test suite - [ ] **Step 1: Run all tests** Run: `.venv/bin/python -m pytest tests/ -v` Expected: All tests pass. - [ ] **Step 2: Fix any failures** Address any test failures discovered during the full suite run. - [ ] **Step 3: Final commit** ```bash git add -A git commit -m "feat: complete Textual TUI integration" ``` --- ## Known Gaps (Follow-up Tasks) These items are intentionally deferred from v1 to keep scope manageable: 1. **Permission modal** — `_show_permission_modal()` currently auto-approves all tools. A proper modal screen with approve/deny buttons and `asyncio.Event` blocking is needed. This is the biggest UX gap. 2. **Session resume modal** — Currently auto-resumes if a saved session exists. Should show a modal dialog asking y/n. 3. **LLM client cleanup** — The `LLMClient` is opened in `on_mount()` via `__aenter__()` but `__aexit__()` is never called. Add cleanup in an `on_unmount()` handler or use Textual's shutdown lifecycle. 4. **Automated TUI tests** — Textual provides `textual.testing.AppTest` for headless testing. Add tests for widget composition, slash commands, and worker dispatch. 5. **Pre-TUI console output** — `print_banner()`, `print_info()`, etc. are called before Textual takes the terminal. They flash briefly then get overwritten. Consider removing them or adding a brief pause.