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>
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 nameRichLog(id="chat-log") — main scroll area, accepts Rich renderables via.write()StreamingStatic— persistent hiddenStaticwidget, shown/hidden during streaming (avoids mount/unmount overhead)StatusBar— customStaticwidget, 1 row, docked above InputInput— 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"), Inputon_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 — nocall_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
/quitexits.
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: noneby 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 viaasyncio.run(_preflight(config))(before Textual starts — this is fine, separate event loop), then instantiates and runsSneakyCodeApp(config).run()- CLI arg parsing stays (--config, -v, --log-file)
- Session resume:
_offer_session_resume()moves intoSneakyCodeApp.on_mount()— instead ofconsole.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 Liveandfrom rich.spinner import Spinner process_stream()no longer creates aRich.Livecontext- Instead, accepts callback parameters:
on_content: Callable[[str], None]— called with accumulated content on each content chunkon_thinking: Callable[[], None]— called once when first reasoning token arriveson_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 becomerender_*functions that return Rich renderables:render_user_message(content) -> Panelrender_assistant_message(content) -> Panelrender_tool_call(name, args) -> Textrender_tool_result(name, output, is_error) -> Textrender_iteration_header(iteration, max_iter) -> Textrender_warning(message) -> Textrender_error(message) -> Text
print_banner()removed — Header widget replaces itprint_token_usage()becomesrender_token_usage() -> Textfor the StatusBarprint_history()becomesrender_history() -> Table— written to RichLog, may need width constraints for narrow terminals- A
DisplayAdapterclass wraps aRichLogreference and provideswrite_user_message(),write_tool_call(), etc. methods that callrender_*thenrich_log.write()
app/agent/loop.py
AgentLoop.__init__()accepts aDisplayAdapterinstead of callingdisplay.pyprint 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()becomesasync def _execute_tool_calls()to support async permission checks- The loop logic (ReAct pattern, retry, truncation) is unchanged
app/services/permissions.py
PermissionsService.check()becomesasync def check()- Instead of
rich.prompt.Confirm.ask()(blocking stdin read), it:- Creates an
asyncio.Event - Posts a custom message to the app requesting a permission modal
- The app pushes a modal screen with the permission question and approve/deny buttons
- When the user responds, the modal sets the event and stores the result
check()awaits the event and reads the result
- Creates an
- 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
consoleobject still exists but should not be used for output during TUI mode — all output goes through the DisplayAdapter - Consider:
--log-filebecomes 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 untouchedapp/agent/context.py— session state untouchedapp/models/*— all data models untouchedapp/tools/*— all tool implementations untouchedapp/utils/file_helpers.py— path safety untouchedapp/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:
- App shows
StreamingStaticwidget, writes "Thinking..." initially - Worker calls
StreamHandler.process_stream(chunks, on_content=..., on_thinking=..., on_done=...) on_contentcallback: updatesStreamingStaticwithPanel(Markdown(partial_content), title="Assistant", border_style="green")— throttled to ~100ms intervalson_donecallback: hidesStreamingStatic, writes final content toRichLogviaDisplayAdapter
Since the worker is async (not threaded), callbacks run on the event loop and can call widget methods directly.
Permission Prompts
- Agent loop (in async worker) calls
await permissions.check(operation, details) check()creates anasyncio.Eventand postsPermissionRequestmessage to the app- App handles
PermissionRequest: pushes a modal screen with the question, approve/deny buttons - Modal screen: on button press, stores result and sets the event
check()awaits the event, reads result, returns approved/denied- Focus management: Input loses focus when modal appears, regains focus when modal dismisses
- 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 viaapp.exit()
Dependencies
- Add
textual>=4.0.0to pyproject.toml dependencies
Verification
- Run the app — header shows app name + model, no console corruption
- Type a prompt — user panel appears in scroll area, input clears
- During LLM streaming — assistant response types out live (throttled) in the scroll area
- Thinking indicator shows during reasoning-only phases
- Tool calls appear as compact lines in the scroll area
- Footer shows token usage and iteration count, updating each step
- Scroll area auto-scrolls to bottom on new content
- /quit, /clear, /history commands work from the input
- Permission prompts show as modal, approve/deny work, focus returns to input
- Ctrl+C cancels running agent turn without quitting
- Worker errors display as error panels in the scroll area
- Logging goes to file only — no console corruption
- Session resume works on startup via modal dialog