Expands README with complete config.yaml reference, CLI options table, skills documentation, and updated command list. Removes the old roadmap (all phases complete). Updates tweaks.md with current design notes. Adds .sneakycode/ to .gitignore and includes superpowers design specs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1803 lines
57 KiB
Markdown
1803 lines
57 KiB
Markdown
# 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.
|