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

57 KiB

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:

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
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:

"""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):

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
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:

"""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:

"""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
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:

"""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:

"""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
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:

"""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:

"""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
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):
from app.utils.display import DisplayAdapter
  1. Update __init__ signature (lines 37-54) to accept a DisplayAdapter:
    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
  1. Replace all print_* calls with self._display.write_* calls, guarded by if self._display:. For example, print_warning("msg") becomes:
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(...)
  1. Make _execute_tool_calls async (line 212):
    async def _execute_tool_calls(self, tool_calls: list[ToolCall]) -> list[ToolResult]:

Change the permissions check (line 263) to await:

            if not await self._permissions.check(name, description=desc):
  1. Update the call site in run_turn() (line 166):
            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:

    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):
from unittest.mock import AsyncMock, MagicMock, patch
  1. Add a display fixture:
@pytest.fixture
def display() -> MagicMock:
    mock = MagicMock(spec=DisplayAdapter)
    return mock
  1. Update the agent fixture to pass display:
@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)
  1. 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:
@pytest.fixture
def permissions(config: AppConfig) -> PermissionsService:
    svc = PermissionsService(config.permissions)
    return svc
  1. 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
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:

/* 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
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:

"""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
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:

"""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
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:

"""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
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
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 outputprint_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.