Files
SneakyCode/docs/superpowers/specs/2026-03-11-textual-tui-design.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

11 KiB

Textual TUI Redesign — Design Spec

Overview

Replace the current sequential print-and-scroll terminal UI with a full persistent split-screen TUI using Textual. Input is pinned at the bottom, scrollable message history above, with a header showing app/model info and a footer showing token usage and iteration count.

Layout

+------------------- Header --------------------+
|  SneakyCode              qwen2.5-coder:32b    |
+-----------------------------------------------+
|                                               |
|  +--- You ---+                                |
|  | prompt    |   <- RichLog widget             |
|  +-----------+      (handles own scrolling)   |
|                                               |
|  Thinking...                                  |
|                                               |
|  +-- Assistant --+                            |
|  | response...   |                            |
|  +---------------+                            |
|                                               |
|  > read_file README.md -- 148 lines, 5128 ch  |
|  > grep_files "pattern" -- 3 matches          |
|                                               |
+-----------------------------------------------+
| Tokens: ~1,511 / 32,000 | Iteration 5/25     |  <- StatusBar
+-----------------------------------------------+
| > [input cursor]                              |  <- Input widget
+-----------------------------------------------+

Widget hierarchy (no VerticalScroll wrapper — RichLog handles its own scrolling):

  • Header — Textual built-in, title="SneakyCode", subtitle=model name
  • RichLog (id="chat-log") — main scroll area, accepts Rich renderables via .write()
  • StreamingStatic — persistent hidden Static widget, shown/hidden during streaming (avoids mount/unmount overhead)
  • StatusBar — custom Static widget, 1 row, docked above Input
  • Input — Textual built-in, pinned at bottom

New Files

app/ui/app.py — Textual App

SneakyCodeApp subclasses textual.app.App. Responsibilities:

  • compose() yields: Header, RichLog(id="chat-log"), StreamingStatic(id="streaming"), StatusBar(id="status"), Input
  • on_input_submitted() handler: reads input value, clears input, writes user panel to chat log, dispatches agent turn as a worker
  • Agent turn runs via run_worker() (async worker, NOT threaded) so the UI stays responsive. Since the worker is async and on the event loop, widget methods can be called directly — no call_from_thread() needed.
  • Slash commands (/quit, /history, /clear, /save, /session) parsed from input before dispatching to agent
  • Holds references to config, SessionContext, AgentLoop (created in on_mount)
  • Header subtitle set to model name from config
  • on_worker_state_changed() handler: catches worker errors and writes error panels to RichLog
  • Ctrl+C binding: cancels the running agent worker (does NOT quit the app). A second Ctrl+C or /quit exits.

app/ui/widgets.py — Custom Widgets

StatusBar — A simple Static widget styled as a footer bar. Displays token usage and iteration count. Updated by the agent loop after each LLM step via status_bar.update(renderable).

StreamingStatic — A Static widget that stays mounted but hidden. During streaming, it becomes visible and receives update() calls with partial content. When streaming ends, it is hidden and its content is cleared. This avoids the overhead of mounting/unmounting on every LLM response.

app/ui/styles.tcss — Textual CSS

Layout rules:

  • RichLog fills available height (fraction-based sizing, e.g. height: 1fr)
  • StreamingStatic: display: none by default, shown during streaming
  • StatusBar is 1 row, docked bottom above Input
  • Input is 1 row, docked at very bottom
  • Color scheme matches existing SNEAKYCODE_THEME (cyan for user, green for assistant, magenta for tools, dim for metadata)

Modified Files

app/main.py

  • Remove _run_repl() async function entirely
  • Remove console.input() usage
  • main() creates config, runs preflight via asyncio.run(_preflight(config)) (before Textual starts — this is fine, separate event loop), then instantiates and runs SneakyCodeApp(config).run()
  • CLI arg parsing stays (--config, -v, --log-file)
  • Session resume: _offer_session_resume() moves into SneakyCodeApp.on_mount() — instead of console.input(), push a modal screen asking "Resume previous session? [y/n]" with button/key handlers
  • Auto-save: triggers after each agent turn completes (in the worker completion handler)
  • SIGTERM handler: removed — Textual manages its own signal handling and shutdown lifecycle

app/services/streaming.py

  • Remove from rich.live import Live and from rich.spinner import Spinner
  • process_stream() no longer creates a Rich.Live context
  • Instead, accepts callback parameters:
    • on_content: Callable[[str], None] — called with accumulated content on each content chunk
    • on_thinking: Callable[[], None] — called once when first reasoning token arrives
    • on_done: Callable[[], None] — called when streaming completes
  • Throttling: Content callback fires at most every 100ms (track last update time, skip intermediate chunks). Final content always fires on stream end.
  • Since the agent runs as an async worker (on the event loop), callbacks can directly call widget methods — no call_from_thread() needed.
  • All accumulation and tool-call parsing logic stays identical

app/utils/display.py

  • All print_* functions become render_* functions that return Rich renderables:
    • render_user_message(content) -> Panel
    • render_assistant_message(content) -> Panel
    • render_tool_call(name, args) -> Text
    • render_tool_result(name, output, is_error) -> Text
    • render_iteration_header(iteration, max_iter) -> Text
    • render_warning(message) -> Text
    • render_error(message) -> Text
  • print_banner() removed — Header widget replaces it
  • print_token_usage() becomes render_token_usage() -> Text for the StatusBar
  • print_history() becomes render_history() -> Table — written to RichLog, may need width constraints for narrow terminals
  • A DisplayAdapter class wraps a RichLog reference and provides write_user_message(), write_tool_call(), etc. methods that call render_* then rich_log.write()

app/agent/loop.py

  • AgentLoop.__init__() accepts a DisplayAdapter instead of calling display.py print functions directly
  • All display calls route through the adapter: self._display.write_tool_call(name, args), self._display.write_iteration_header(i, max), etc.
  • _execute_tool_calls() becomes async def _execute_tool_calls() to support async permission checks
  • The loop logic (ReAct pattern, retry, truncation) is unchanged

app/services/permissions.py

  • PermissionsService.check() becomes async def check()
  • Instead of rich.prompt.Confirm.ask() (blocking stdin read), it:
    1. Creates an asyncio.Event
    2. Posts a custom message to the app requesting a permission modal
    3. The app pushes a modal screen with the permission question and approve/deny buttons
    4. When the user responds, the modal sets the event and stores the result
    5. check() awaits the event and reads the result
  • Edge cases: dismiss without choosing = deny. Ctrl+C during modal = deny. Focus returns to Input after modal dismisses.

app/utils/logging.py

  • Critical change: The shared console = Console() instance will corrupt the Textual display since Textual takes exclusive terminal control
  • When running under Textual: disable RichHandler (console handler), keep only the file handler
  • Add a setup_logging_for_tui() function that reconfigures logging to file-only mode
  • Called from SneakyCodeApp.on_mount() before any agent work begins
  • The console object still exists but should not be used for output during TUI mode — all output goes through the DisplayAdapter
  • Consider: --log-file becomes required (or auto-set to a default) when running in TUI mode, so logs are not lost

Unchanged Files

  • app/services/llm.py — HTTP client, SSE parsing untouched
  • app/agent/context.py — session state untouched
  • app/models/* — all data models untouched
  • app/tools/* — all tool implementations untouched
  • app/utils/file_helpers.py — path safety untouched
  • app/utils/token_counter.py — token counting untouched

Key Patterns

Streaming in Textual

The agent loop runs as an async worker (on the event loop, NOT threaded). During streaming:

  1. App shows StreamingStatic widget, writes "Thinking..." initially
  2. Worker calls StreamHandler.process_stream(chunks, on_content=..., on_thinking=..., on_done=...)
  3. on_content callback: updates StreamingStatic with Panel(Markdown(partial_content), title="Assistant", border_style="green") — throttled to ~100ms intervals
  4. on_done callback: hides StreamingStatic, writes final content to RichLog via DisplayAdapter

Since the worker is async (not threaded), callbacks run on the event loop and can call widget methods directly.

Permission Prompts

  1. Agent loop (in async worker) calls await permissions.check(operation, details)
  2. check() creates an asyncio.Event and posts PermissionRequest message to the app
  3. App handles PermissionRequest: pushes a modal screen with the question, approve/deny buttons
  4. Modal screen: on button press, stores result and sets the event
  5. check() awaits the event, reads result, returns approved/denied
  6. Focus management: Input loses focus when modal appears, regains focus when modal dismisses
  7. Default on dismiss/Ctrl+C: deny

Cancellation

  • Ctrl+C (first press): cancels the running agent worker via worker.cancel(). The agent loop should check for cancellation between iterations.
  • Ctrl+C (second press) or /quit: exits the app via app.exit()

Dependencies

  • Add textual>=4.0.0 to pyproject.toml dependencies

Verification

  1. Run the app — header shows app name + model, no console corruption
  2. Type a prompt — user panel appears in scroll area, input clears
  3. During LLM streaming — assistant response types out live (throttled) in the scroll area
  4. Thinking indicator shows during reasoning-only phases
  5. Tool calls appear as compact lines in the scroll area
  6. Footer shows token usage and iteration count, updating each step
  7. Scroll area auto-scrolls to bottom on new content
  8. /quit, /clear, /history commands work from the input
  9. Permission prompts show as modal, approve/deny work, focus returns to input
  10. Ctrl+C cancels running agent turn without quitting
  11. Worker errors display as error panels in the scroll area
  12. Logging goes to file only — no console corruption
  13. Session resume works on startup via modal dialog