Commit 7ba2323
authored
feat(tui): add TUI foundation with event architecture and ratatui components (#10)
## Summary
Add a TUI foundation with ratatui + crossterm, including an event
architecture, component system, and agent loop refactoring. This is the
structural plumbing that future work (markdown rendering, multi-line
input, tool display, viewport virtualization) builds on.
The core design decision is the `AgentSink` trait, which decouples the
agent loop from display. The same `agent_turn` code drives all three
modes — the sink implementation determines where events go.
`ChannelSink` sends events through an mpsc channel for the TUI's
`tokio::select!` event loop; `StdioSink` writes directly to stdout /
stderr for the bare REPL and headless modes. This keeps the agent loop
DRY across all display modes.
- Add `tui/event.rs`: `AgentEvent` enum (stream tokens, thinking, tool
calls, turn complete, error), `UserAction` enum, `AgentSink` trait with
`ChannelSink` and `StdioSink` implementations. `tool_call_title` helper
extracts display titles (bash commands shown inline) for both sinks.
- Add `tui/app.rs`: root `App` struct with `tokio::select!` event loop
multiplexing crossterm events, agent channel events, and a 60 FPS tick
interval for render coalescing. Layout: status bar (top) + chat (fill) +
input (bottom). `finish_turn` helper consolidates the turn-complete
reset sequence. App creates a single `Theme` and passes it to all
component constructors.
- Add `tui/component.rs`: `Component` trait (`handle_event` + `render`)
and `Action` enum for upward communication from components to the root
app.
- Add `tui/components/chat.rs`: scrollable chat message list with
streaming buffer. Auto-scrolls on new content, pauses on manual scroll
up, resumes at bottom. `Cell` for content height avoids double
`build_text` per frame.
- Add `tui/components/input.rs`: single-line input with cursor
navigation, character insertion, backspace, forward-delete. Enter
submits, Ctrl+C / Ctrl+D quits. Cached `char_count` avoids repeated O(n)
scans; `byte_offset` helper DRYs up char-to-byte index conversion.
- Add `tui/components/status.rs`: status bar displaying `ox │ model │
status` with pipe separators. Status: Idle (green), Streaming (yellow),
ToolRunning (yellow).
- Add `tui/terminal.rs`: terminal init / restore, synchronized output
(`BeginSynchronizedUpdate` / `EndSynchronizedUpdate` for flicker
prevention), panic-safe restore hook.
- Add `tui/theme.rs`: Catppuccin Mocha palette with 11 named color slots
(grouped: text hierarchy → surfaces → semantic accents → status
indicators by ascending severity) and style helpers. `Copy` derive for
zero-cost pass-by-value. `separator_span` helper DRYs up the styled pipe
separator. Transparent background, designed for future user-configurable
overrides.
- Refactor `main.rs` into three entry points: `run_tui` (default),
`bare_repl` (`--no-tui`), `headless` (`-p`). Tool registry created once
and passed to all modes. `agent_turn` / `stream_response` take `&dyn
AgentSink`. `apply_delta` returns `Result` to propagate sink errors.
Abort agent task on quit to prevent hanging on active API streams.
- Add `docs/research/tui.md`: reference project analysis (claude-code
Ink architecture, opencode, Codex, Rust TUI apps), flickering root cause
investigation, streaming markdown strategy, design decisions.
## Changes
| File | Description |
| ---- | ----------- |
| `Cargo.toml` | Add `ratatui`, `crossterm`, `futures` to workspace
dependencies |
| `crates/oxide-code/Cargo.toml` | Wire workspace deps to binary crate |
| `client/anthropic.rs` | `#[derive(Clone)]` on `Client`, `model()`
accessor, preserve initial text from `ContentBlockInfo::Text` |
| `main.rs` | Three entry points (`run_tui`, `bare_repl`, `headless`),
tool registry created once and passed, `agent_turn` / `stream_response`
take `&dyn AgentSink`, `apply_delta` returns `Result`, abort agent task
on quit |
| `tui.rs` | Module root with `pub(crate)` submodules |
| `tui/event.rs` | `AgentEvent`, `UserAction`, `AgentSink` trait,
`ChannelSink`, `StdioSink`, `tool_call_title` helper |
| `tui/theme.rs` | Catppuccin Mocha palette (11 color slots, `Copy`),
style helpers, `separator_span` |
| `tui/terminal.rs` | Terminal init / restore, synchronized output,
panic hook |
| `tui/component.rs` | `Component` trait and `Action` enum |
| `tui/components/chat.rs` | Scrollable chat with streaming buffer,
auto-scroll, `Cell` content height |
| `tui/components/input.rs` | Single-line input with cursor, Delete key,
cached `char_count`, `byte_offset` helper |
| `tui/components/status.rs` | Status bar with model and status
indicator |
| `tui/app.rs` | Root `App` with `tokio::select!` loop, shared `Theme`,
`finish_turn`, `tool_call_title` |
| `CLAUDE.md` | Updated crate structure diagram with full `tui/` module
tree |
| `docs/roadmap.md` | Move TUI foundation to "Working Today", update
"Current Focus" |
| `docs/research/tui.md` | TUI research: reference projects, flickering,
ecosystem, design decisions |
| `docs/research/README.md` | Research docs index |
## Test plan
- [x] `cargo fmt --all --check` — clean
- [x] `cargo build` compiles cleanly
- [x] `cargo clippy --all-targets -- -D warnings` — zero warnings
- [x] `cargo test` — 226 tests pass
- [x] `ox` launches TUI with status bar, chat area, input area
- [x] `ox --no-tui` launches bare REPL with same agent behavior
- [x] `ox -p "hello"` runs headless and exits
- [x] Typing visible in input, Enter submits, response streams in chat
- [x] Mouse scroll and keyboard scroll (when input disabled) work
- [x] Ctrl+C / Ctrl+D quit from any state
- [x] Quit during active streaming aborts cleanly (no hang)
- [x] `pub(crate)` visibility on all TUI types1 parent 820f463 commit 7ba2323
22 files changed
Lines changed: 2751 additions & 180 deletions
File tree
- .cspell
- crates/oxide-code
- src
- client
- tui
- components
- docs
- research
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
| 5 | + | |
4 | 6 | | |
5 | 7 | | |
| 8 | + | |
6 | 9 | | |
7 | 10 | | |
| 11 | + | |
8 | 12 | | |
9 | 13 | | |
10 | 14 | | |
| 15 | + | |
11 | 16 | | |
12 | 17 | | |
13 | 18 | | |
| 19 | + | |
| 20 | + | |
14 | 21 | | |
| 22 | + | |
15 | 23 | | |
16 | 24 | | |
17 | 25 | | |
18 | 26 | | |
19 | 27 | | |
20 | 28 | | |
21 | 29 | | |
| 30 | + | |
22 | 31 | | |
23 | 32 | | |
| 33 | + | |
24 | 34 | | |
| 35 | + | |
25 | 36 | | |
26 | 37 | | |
27 | 38 | | |
28 | 39 | | |
| 40 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
34 | | - | |
| 34 | + | |
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
48 | 60 | | |
49 | 61 | | |
50 | 62 | | |
| |||
0 commit comments