Files
SneakyCode/docs/superpowers/plans/2026-03-11-textual-tui.md
Phillip Tarrant 4496fce354 docs: update README with full config reference, remove completed roadmap
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>
2026-03-11 20:05:16 -05:00

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.