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>
193 lines
11 KiB
Markdown
193 lines
11 KiB
Markdown
# 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
|