claw-code/docs/tui/moa-runtime-integration.md

1080 lines
40 KiB
Markdown

# TUI Runtime Integration Report: claw-code
**Date:** 2026-06-12
**Repo:** `/mnt/data/git/claw-code` (branch `feat-tui`)
**Scope:** Map every integration point a TUI layer must call and every event it must receive
---
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [ConversationRuntime — the Core Loop](#2-conversationruntime--the-core-loop)
3. [TurnProgressReporter Trait](#3-turnprogressreporter-trait)
4. [run_turn() Streaming Model](#4-run_turn-streaming-model)
5. [Tool Execution Events & Lifecycle](#5-tool-execution-events--lifecycle)
6. [Permission Prompting](#6-permission-prompting)
7. [Compaction Flow](#7-compaction-flow)
8. [Reasoning / Thinking Content Streaming](#8-reasoning--thinking-content-streaming)
9. [AssistantEvent Enum — Full Event Catalog](#9-assistantevent-enum--full-event-catalog)
10. [Rendering Pipeline (Markdown → ANSI)](#10-rendering-pipeline-markdown--ansi)
11. [Input Layer (rustyline)](#11-input-layer-rustyline)
12. [Session Persistence](#12-session-persistence)
13. [Hook System](#13-hook-system)
14. [Integration Points Summary](#14-integration-points-summary)
15. [Proposed TUI Event Channel Design](#15-proposed-tui-event-channel-design)
16. [Key Risks & Open Questions](#16-key-risks--open-questions)
---
## 1. Architecture Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ CLI (rusty-claude-cli) │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ input.rs │ │ render.rs │ │ main.rs │ │
│ │ rustyline │ │ pulldown- │ │ Cli struct │ │
│ │ LineEditor │ │ cmark + │ │ ├── run_turn_to() │ │
│ │ ReadOutcome │ │ syntect │ │ ├── CliPermission- │ │
│ │ │ │ Terminal- │ │ │ Prompter │ │
│ │ │ │ Renderer │ │ ├── AnthropicRuntime- │ │
│ │ │ │ Markdown- │ │ │ Client (ApiClient) │ │
│ │ │ │ StreamState │ │ └── BuiltRuntime │ │
│ └──────────────┘ └──────────────┘ └────────┬───────────────┘ │
│ │ │
└───────────────────────────────────────────────┼──────────────────┘
│ calls
┌───────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Runtime Crate (runtime) │
│ │
│ ConversationRuntime<C: ApiClient, T: ToolExecutor> │
│ ├── session: Session │
│ ├── api_client: C │
│ ├── tool_executor: T │
│ ├── permission_policy: PermissionPolicy │
│ ├── hook_runner: HookRunner │
│ ├── usage_tracker: UsageTracker │
│ ├── turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>│
│ ├── hook_progress_reporter: Option<Box<dyn HookProgressReporter>>│
│ └── session_tracer: Option<SessionTracer> │
│ │
│ Key methods: │
│ │ run_turn(input, prompter) → Result<TurnSummary, RuntimeError>│
│ │ compact(config) → CompactionResult │
│ │ fork_session(branch) → Session │
│ │ session() / session_mut() → &Session │
│ │ usage() → &UsageTracker │
│ └────────────────────────────────────────────────────────────────│
└──────────────────────────────────────────────────────────────────┘
```
The CLI's `Cli` struct owns a `BuiltRuntime` (which wraps `ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>` plus plugin/MCP lifecycle). The TUI must either:
- **(A)** Own a similar `BuiltRuntime` and call `run_turn()` the same way, but intercept I/O instead of using stdout/stdin, OR
- **(B)** Refactor the runtime to emit typed events through a channel instead of writing to an `io::Write` sink.
---
## 2. ConversationRuntime — the Core Loop
**File:** `runtime/src/conversation.rs`
The runtime is generic over `C: ApiClient` and `T: ToolExecutor`:
```rust
pub struct ConversationRuntime<C, T> {
session: Session,
api_client: C,
tool_executor: T,
permission_policy: PermissionPolicy,
system_prompt: Vec<String>,
max_iterations: usize,
usage_tracker: UsageTracker,
hook_runner: HookRunner,
auto_compaction_input_tokens_threshold: u32,
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
session_tracer: Option<SessionTracer>,
turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>,
}
```
**Builder-style setters:**
- `.with_max_iterations(n)`
- `.with_auto_compaction_input_tokens_threshold(t)`
- `.with_hook_abort_signal(signal)`
- `.with_hook_progress_reporter(reporter)`
- `.with_session_tracer(tracer)`
- `.with_turn_progress_reporter(reporter)`
**Key public API:**
| Method | Returns | Notes |
|--------|---------|-------|
| `run_turn(input, prompter)` | `Result<TurnSummary, RuntimeError>` | Core loop — blocks until turn completes |
| `compact(config)` | `CompactionResult` | Manual compaction |
| `estimated_tokens()` | `usize` | Current token footprint |
| `session()` / `session_mut()` | `&Session` | Read/write session state |
| `usage()` | `&UsageTracker` | Cumulative token usage |
| `api_client_mut()` | `&mut C` | Access underlying API client |
| `fork_session(branch)` | `Session` | Branch the conversation |
| `into_session(self)` | `Session` | Consume runtime, keep session |
| `set_auto_compaction_input_tokens_threshold(t)` | `()` | Adjust threshold at runtime |
---
## 3. TurnProgressReporter Trait
**File:** `runtime/src/conversation.rs:165-175`
```rust
pub trait TurnProgressReporter: Send + Sync {
fn on_tool_result(
&self,
iteration: usize,
max_iterations: usize,
tool_name: &str,
input: &str,
result: Result<&str, &str>,
);
}
```
### What events does it emit?
**Only one:** `on_tool_result` — called after **each tool execution completes** (success or failure), inside the Phase 3 loop of `run_turn()`.
### Parameters
| Param | Meaning |
|-------|---------|
| `iteration` | 1-based index of the current loop iteration within the turn |
| `max_iterations` | The configured maximum (default: `usize::MAX`) |
| `tool_name` | e.g., `"read_file"`, `"bash"` |
| `input` | The effective tool input (after pre-hook mutation) |
| `result` | `Ok(output_str)` or `Err(error_str)` |
### When is it called?
After:
1. Tool pre-hooks run
2. Permission check passes
3. `tool_executor.execute_batch()` completes
4. Tool post-hooks run
5. Result message is pushed to session
The reporter is called **once per tool** in a batch, in original call order.
### Can a TUI version send events through a channel?
**Yes, easily.** The trait is `Send + Sync`, so a channel-based implementation is straightforward:
```rust
struct ChannelProgressReporter {
tx: mpsc::Sender<TuiEvent>,
}
impl TurnProgressReporter for ChannelProgressReporter {
fn on_tool_result(
&self,
iteration: usize,
max_iterations: usize,
tool_name: &str,
input: &str,
result: Result<&str, &str>,
) {
let _ = self.tx.send(TuiEvent::ToolResult {
iteration,
max_iterations,
tool_name: tool_name.to_string(),
input: input.to_string(),
result: result.map(str::to_string).map_err(str::to_string),
});
}
}
```
### ⚠️ Limitation
`TurnProgressReporter` does **NOT** emit:
- Tool-start events (when a tool begins execution)
- Streaming text deltas
- Thinking/reasoning events
- Token usage updates
- Auto-compaction events
- Permission prompts
For those, the TUI needs additional hooks (see Section 15).
---
## 4. run_turn() Streaming Model
### How does run_turn() stream tokens?
**It doesn't.** `run_turn()` is a **synchronous blocking call** that returns a `TurnSummary` after the entire turn completes. The streaming happens *inside* `ApiClient::stream()`, which is also blocking from the runtime's perspective — it returns `Result<Vec<AssistantEvent>, RuntimeError>` (a collected Vec, not a stream).
The current CLI architecture streams tokens to stdout **inside** `AnthropicRuntimeClient::stream()` (which implements `ApiClient::stream()`). The streaming is side-effectual: it writes ANSI-rendered markdown to an `io::Write` sink **while** collecting events.
### The streaming flow
```
run_turn(input, prompter)
└─► api_client.stream(request) // blocks until stream ends
└─► tokio::Runtime::block_on(async {
└─► client.stream_message(request).await
└─► loop {
event = stream.next_event().await
match event {
ContentBlockStart → write to out; push to events
ContentBlockDelta::TextDelta →
MarkdownStreamState::push() → write ANSI to out
push AssistantEvent::TextDelta
ContentBlockDelta::ThinkingDelta →
accumulate into pending_thinking
ContentBlockDelta::InputJsonDelta →
accumulate into pending_tool input
ContentBlockDelta::SignatureDelta →
accumulate into pending_thinking signature
ContentBlockStop →
flush MarkdownStreamState
emit accumulated Thinking as AssistantEvent::Thinking
emit accumulated ToolUse as AssistantEvent::ToolUse
MessageDelta → push AssistantEvent::Usage
MessageStop → push AssistantEvent::MessageStop
}
})
return Vec<AssistantEvent>
})
```
### Key insight for TUI
The `consume_stream()` method determines the "out" writer at the top:
```rust
let mut stdout = io::stdout();
let mut sink = io::sink();
let out: &mut dyn Write = if self.emit_output {
&mut stdout
} else {
&mut sink
};
```
Currently, the TUI workaround (`run_turn_to`) uses a `Vec<u8>` buffer as the writer and reads it after the turn completes. **This means no live streaming in TUI mode** — the TUI gets the rendered output only after the entire turn finishes.
### Is there a callback/channel pattern to hook into?
**Not currently.** The three extension points are:
1. **`TurnProgressReporter`** — only `on_tool_result` (post-tool, not during streaming)
2. **`HookProgressReporter`** — hook lifecycle events (Started/Completed/Cancelled)
3. **`InternalPromptProgressReporter`** — CLI-internal progress for ultraplan mode
None of these fire during the token-by-token streaming inside `consume_stream()`.
### What the TUI needs
To get live streaming in the TUI, one of these approaches is needed:
**Option A: Write to a channel instead of io::Write**
Replace the `out: &mut dyn Write` in `consume_stream()` with an enum that can write to either stdout or an `mpsc::Sender<String>`. Currently the renderer writes ANSI-escaped markdown incrementally, so the TUI could receive ANSI strings through the channel.
**Option B: Emit typed events instead of raw ANSI**
Refactor `consume_stream()` to accept an event sink trait:
```rust
trait StreamSink {
fn on_text_delta(&mut self, text: &str);
fn on_thinking_start(&mut self);
fn on_thinking_delta(&mut self, text: &str);
fn on_thinking_end(&mut self, char_count: usize, redacted: bool);
fn on_tool_start(&mut self, name: &str, input: &str);
fn on_tool_result(&mut self, name: &str, output: &str, is_error: bool);
fn on_usage(&mut self, usage: &TokenUsage);
fn on_message_stop(&mut self);
}
```
**Option B is strongly recommended** because:
- The TUI needs to know event types (text vs thinking vs tool call) for layout
- Raw ANSI in a channel defeats the purpose of a structured TUI
- It allows the TUI to render each event type with its own widget style
---
## 5. Tool Execution Events & Lifecycle
### Within run_turn()
The tool execution lifecycle inside `run_turn()` is:
```
1. API stream → assistant_message extracted
2. pending_tool_uses = filter ToolUse blocks from message
┌─── Phase 1: Pre-hooks + Permissions (sequential) ───┐
│ For each (tool_use_id, tool_name, input): │
│ a. run_pre_tool_use_hook(tool_name, input) │
│ b. Apply updated_input from hook │
│ c. Check if hook denied/cancelled/failed │
│ d. permission_policy.authorize_with_context(...) │
│ → may call PermissionPrompter::decide() │
│ e. Record PendingTool { allowed, deny_reason } │
└──────────────────────────────────────────────────────┘
┌─── Phase 2: Execute allowed tools (batch/parallel) ─┐
│ allowed_calls = filter pending where allowed │
│ record_tool_started(iteration, tool_name) │
│ batch_results = tool_executor.execute_batch(calls) │
└───────────────────────────────────────────────────────┘
┌─── Phase 3: Post-hooks + Session updates (sequential) ─┐
│ For each pending tool: │
│ a. Get batch_result (if allowed) │
│ b. run_post_tool_use_hook / run_post_tool_use_failure│
│ c. Merge hook feedback into output │
│ d. session.push_message(tool_result) │
│ e. record_tool_finished(iteration, result_message) │
│ f. turn_progress_reporter.on_tool_result(...) │
└──────────────────────────────────────────────────────────┘
Loop back to step 1 (next API call with tool results)
```
### What events exist?
| Event | Source | Data | Currently Observable? |
|-------|--------|------|---------------------|
| `tool_pre_hook_started` | `HookProgressReporter::on_event(Started)` | event, tool_name, command | ✅ via HookProgressReporter |
| `tool_pre_hook_completed` | `HookProgressReporter::on_event(Completed)` | event, tool_name, command | ✅ via HookProgressReporter |
| `tool_pre_hook_cancelled` | `HookProgressReporter::on_event(Cancelled)` | event, tool_name, command | ✅ via HookProgressReporter |
| `permission_prompt` | `PermissionPrompter::decide()` | tool_name, input, modes | ✅ via PermissionPrompter |
| `tool_execution_started` | `record_tool_started()` (SessionTracer) | iteration, tool_name | ⚠️ only via SessionTracer |
| `tool_execution_finished` | `record_tool_finished()` (SessionTracer) | iteration, tool_name, is_error | ⚠️ only via SessionTracer |
| `tool_result` | `TurnProgressReporter::on_tool_result()` | iteration, tool_name, input, result | ✅ via TurnProgressReporter |
### ⚠️ Tool execution start has no TUI-visible event
The `record_tool_started()` method only emits to `SessionTracer` (not to `TurnProgressReporter`). There is **no `on_tool_start` callback**. The TUI currently has no way to show "Running tool X…" before the tool completes.
**For the TUI**, you need either:
1. Add an `on_tool_start` method to `TurnProgressReporter`
2. Use `HookProgressReporter::on_event(Started)` which fires at pre-hook start (close to tool start)
3. Infer tool start from `AssistantEvent::ToolUse` in the stream (but this is inside `consume_stream()`, not `run_turn()`)
---
## 6. Permission Prompting
### Current CLI Implementation
**File:** `main.rs:12766-12813`
```rust
struct CliPermissionPrompter {
current_mode: PermissionMode,
}
impl PermissionPrompter for CliPermissionPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
println!();
println!("Permission approval required");
println!(" Tool {}", request.tool_name);
println!(" Current mode {}", self.current_mode.as_str());
println!(" Required mode {}", request.required_mode.as_str());
if let Some(reason) = &request.reason {
println!(" Reason {reason}");
}
println!(" Input {}", request.input);
print!("Approve this tool call? [y/N]: ");
let _ = io::stdout().flush();
let mut response = String::new();
match io::stdin().read_line(&mut response) {
Ok(_) => {
let normalized = response.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "y" | "yes") {
PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny { reason: ... }
}
}
Err(error) => PermissionPromptDecision::Deny { reason: ... },
}
}
}
```
### PermissionRequest structure
```rust
pub struct PermissionRequest {
pub tool_name: String,
pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
pub reason: Option<String>,
}
```
### PermissionPromptDecision
```rust
pub enum PermissionPromptDecision {
Allow,
Deny { reason: String },
}
```
### PermissionMode hierarchy
```rust
pub enum PermissionMode {
ReadOnly, // "read-only"
WorkspaceWrite, // "workspace-write"
DangerFullAccess,// "danger-full-access"
Prompt, // "prompt" (ask every time)
Allow, // "allow" (auto-approve)
}
```
### How should the TUI handle permissions?
The `PermissionPrompter` trait is synchronous and blocking. For a TUI, the implementation should:
1. **Send a permission request event** through a channel to the TUI event loop
2. **Block on a response channel** until the user selects Allow/Deny in the TUI
3. Use `std::sync::mpsc` or a oneshot channel for the response:
```rust
struct TuiPermissionPrompter {
tx: mpsc::Sender<TuiEvent>,
rx: mpsc::Receiver<PermissionPromptDecision>,
}
impl PermissionPrompter for TuiPermissionPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
let _ = self.tx.send(TuiEvent::PermissionRequired {
tool_name: request.tool_name.clone(),
required_mode: request.required_mode,
input: request.input.clone(),
reason: request.reason.clone(),
});
self.rx.recv().unwrap_or(PermissionPromptDecision::Deny {
reason: "TUI disconnected".into(),
})
}
}
```
The TUI renders a modal/bottom-bar with the permission details and two buttons. On click, it sends the decision back through the channel.
---
## 7. Compaction Flow
### Auto-compaction in run_turn()
Auto-compaction is checked **after each assistant message is pushed** (whether or not tools were used):
```rust
// Inside the loop in run_turn():
if let Some(compaction) = self.maybe_auto_compact() {
auto_compaction = Some(compaction);
}
```
### maybe_auto_compact()
```rust
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
if self.usage_tracker.cumulative_usage().input_tokens
< self.auto_compaction_input_tokens_threshold
{
return None;
}
let result = compact_session(
&self.session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
);
if result.removed_message_count == 0 {
return None;
}
self.session = result.compacted_session;
Some(AutoCompactionEvent {
removed_message_count: result.removed_message_count,
})
}
```
Threshold: default 100,000 input tokens (configurable via `CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS` env var).
### AutoCompactionEvent
```rust
pub struct AutoCompactionEvent {
pub removed_message_count: usize,
}
```
Returned in `TurnSummary.auto_compaction`. The CLI displays it:
```rust
if let Some(event) = summary.auto_compaction {
writeln!(out, "{}", format_auto_compaction_notice(event.removed_message_count))?;
}
```
### Manual compaction
Triggered by `/compact` command via `runtime.compact(config)` → returns `CompactionResult`:
```rust
pub struct CompactionResult {
pub summary: String,
pub formatted_summary: String,
pub compacted_session: Session,
pub removed_message_count: usize,
}
```
### Context-window retry loop
When the API returns a context window error, `run_turn_to()` enters a retry loop that:
1. Extracts context window size from the error
2. Auto-compacts with decreasing `preserve_recent_messages` (4 → 2 → 1 → 0)
3. Retries `run_turn()` with the compacted session
4. Up to 4 rounds
**TUI must handle:** Displaying compaction progress and "auto-compacting…" messages during this retry loop.
### Does compaction emit events the TUI needs?
Currently, **no events are emitted during compaction itself**. The CLI writes directly to its `out` writer. The TUI would need:
- A `Compacting { round, max_rounds, removed_count }` event
- A `CompactionComplete { removed_message_count }` event
---
## 8. Reasoning / Thinking Content Streaming
### How thinking works in the protocol
The Anthropic API and OpenAI-compatible APIs send thinking/reasoning as separate content blocks with deltas:
1. `ContentBlockStart::Thinking { thinking, signature }` — initial thinking text (may be partial)
2. `ContentBlockDelta::ThinkingDelta { thinking }` — incremental thinking chunks
3. `ContentBlockDelta::SignatureDelta { signature }` — signing data for verified thinking
4. `ContentBlockStop` — marks end of the thinking block
For OpenAI-compatible models (DeepSeek V4), `reasoning_content` is mapped to `ThinkingDelta`.
### How the CLI handles it
Inside `consume_stream()`:
```rust
// Accumulate thinking into pending_thinking
let mut pending_thinking: Option<(String, Option<String>)> = None;
let mut block_has_thinking_summary = false;
// On ContentBlockStart::Thinking:
pending_thinking = Some((thinking.clone(), signature.clone()));
// On ThinkingDelta:
if !block_has_thinking_summary {
render_thinking_block_summary(out, None, false)?; // "▶ Thinking hidden"
block_has_thinking_summary = true;
}
if let Some((t, _)) = &mut pending_thinking {
t.push_str(&thinking);
}
// On SignatureDelta:
if let Some((_, sig)) = &mut pending_thinking {
sig.get_or_insert_with(String::new).push_str(&signature);
}
// On ContentBlockStop:
block_has_thinking_summary = false;
if let Some((thinking, signature)) = pending_thinking.take() {
events.push(AssistantEvent::Thinking { thinking, signature });
}
```
### `render_thinking_block_summary()`
```rust
fn render_thinking_block_summary(
out: &mut (impl Write + ?Sized),
char_count: Option<usize>,
redacted: bool,
) -> Result<(), RuntimeError> {
let summary = if redacted {
"\n▶ Thinking block hidden by provider\n".to_string()
} else if let Some(char_count) = char_count {
format!("\n▶ Thinking ({char_count} chars hidden)\n")
} else {
"\n▶ Thinking hidden\n".to_string()
};
write!(out, "{summary}")...
}
```
**Key observation:** The CLI **hides** the thinking content in the terminal — it only shows "▶ Thinking (N chars hidden)". The actual thinking text is preserved in `AssistantEvent::Thinking` for session persistence but not displayed.
### For the TUI
The TUI has more screen real estate and could:
- **Show a collapsible thinking block** with the actual content
- **Show a live streaming thinking indicator** that expands on click
- Stream `ThinkingDelta` chunks directly to a TUI panel
But this requires the `StreamSink` approach (Option B from §4) since `ThinkingDelta` events are currently consumed inside `consume_stream()` and only the final accumulated `AssistantEvent::Thinking` is passed to the caller.
---
## 9. AssistantEvent Enum — Full Event Catalog
```rust
pub enum AssistantEvent {
Thinking {
thinking: String,
signature: Option<String>,
},
TextDelta(String),
ToolUse {
id: String,
name: String,
input: String,
},
Usage(TokenUsage),
PromptCache(PromptCacheEvent),
MessageStop,
}
```
| Event | When | TUI needs? |
|-------|------|------------|
| `Thinking` | After thinking block completes | ✅ Show in collapsible section |
| `TextDelta` | Per text chunk | ✅ Append to response area |
| `ToolUse` | After tool input fully accumulated | ✅ Show "Running tool…" |
| `Usage` | Per message delta (token counts) | ✅ Token counter |
| `PromptCache` | Stream metadata | ❌ Internal only |
| `MessageStop` | Stream ends | ✅ Mark turn complete |
**Note:** These events are collected inside `ApiClient::stream()` and returned as a `Vec`. They are NOT streamed to the caller one-by-one. This is the core architectural problem for live TUI updates.
---
## 10. Rendering Pipeline (Markdown → ANSI)
**File:** `rusty-claude-cli/src/render.rs`
### TerminalRenderer
```rust
pub struct TerminalRenderer {
syntax_set: SyntaxSet,
syntax_theme: Theme,
color_theme: ColorTheme,
}
```
Key methods:
- `render_markdown(markdown) → String` — full markdown → ANSI
- `markdown_to_ansi(markdown) → String` — alias
- `color_theme() → &ColorTheme`
### MarkdownStreamState (incremental rendering)
```rust
pub struct MarkdownStreamState {
pending: String,
}
```
- `push(renderer, delta) → Option<String>` — append delta, render complete chunks
- `flush(renderer) → Option<String>` — render remaining pending text
This handles the problem that markdown can't be partially rendered (e.g., a `**` bold marker spans two chunks). It finds a "stream-safe boundary" and only renders complete blocks.
### ColorTheme
```rust
pub struct ColorTheme {
heading: Color, // Cyan
emphasis: Color, // Magenta
strong: Color, // Yellow
inline_code: Color, // Green
link: Color, // Blue
quote: Color, // DarkGrey
table_border: Color, // DarkCyan
code_block_border: Color, // DarkGrey
spinner_active: Color, // Blue
spinner_done: Color, // Green
spinner_failed: Color, // Red
}
```
### For the TUI
The TUI should **NOT** use ANSI rendering. Instead:
- Parse the raw markdown with `pulldown-cmark` directly (same parser, different renderer)
- Map markdown events to TUI widget primitives (heading → styled text, code block → syntax-highlighted block, etc.)
- The `TerminalRenderer` stays available for fallback/legacy mode
The incremental `MarkdownStreamState` logic (boundary detection) could be adapted for the TUI.
---
## 11. Input Layer (rustyline)
**File:** `rusty-claude-cli/src/input.rs`
### LineEditor
```rust
pub struct LineEditor {
prompt: String,
editor: Editor<SlashCommandHelper, DefaultHistory>,
}
```
### ReadOutcome
```rust
pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
ProviderSwap,
TeamToggle,
}
```
### Key bindings
- `Enter` — submit
- `Ctrl+J` / `Shift+Enter` — newline (multiline input)
- `Ctrl+P` — provider swap (inserts `\x01` sentinel)
- `Ctrl+T` — team toggle (inserts `\x02` sentinel)
- `Ctrl+C` — cancel current input (or exit if empty)
- `Ctrl+D` — exit
### For the TUI
The TUI replaces `LineEditor` entirely with its own text input widget. The `ReadOutcome` enum should still be used as the adapter interface, but the TUI input widget handles key events directly.
Slash command completions come from the REPL loop's `SlashCommandHelper.completions` list.
---
## 12. Session Persistence
**File:** `runtime/src/session.rs`
### Key types
```rust
pub struct Session {
pub id: String,
pub messages: Vec<ConversationMessage>,
pub compaction: Option<SessionCompaction>,
}
pub struct SessionCompaction {
pub count: u32,
pub removed_message_count: usize,
pub summary: String,
}
pub struct ConversationMessage {
pub role: MessageRole,
pub blocks: Vec<ContentBlock>,
pub usage: Option<TokenUsage>,
}
pub enum ContentBlock {
Text { text: String },
Thinking { thinking: String, signature: Option<String> },
ToolUse { id: String, name: String, input: String },
ToolResult { tool_use_id: String, tool_name: String, output: String, is_error: bool },
}
pub enum MessageRole { System, User, Assistant, Tool }
```
Session is serialized to JSONL (`session.jsonl`) with rotation (256KB max, 3 rotated files). Large fields are truncated at 16KB.
### For the TUI
The TUI reads `Session.messages` to render the conversation history. No changes to the persistence format are needed — the runtime handles all reads/writes.
---
## 13. Hook System
**File:** `runtime/src/hooks.rs`
### HookEvent
```rust
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
```
### HookProgressEvent
```rust
pub enum HookProgressEvent {
Started { event: HookEvent, tool_name: String, command: String },
Completed { event: HookEvent, tool_name: String, command: String },
Cancelled { event: HookEvent, tool_name: String, command: String },
}
```
### HookProgressReporter trait
```rust
pub trait HookProgressReporter {
fn on_event(&mut self, event: &HookProgressEvent);
}
```
### For the TUI
`HookProgressReporter` can be implemented with a channel sender to show hook activity in the TUI (e.g., "Running pre-tool hook for bash…").
---
## 14. Integration Points Summary
### What the TUI Needs to CALL
| Call | Method | Notes |
|------|--------|-------|
| Start a turn | `runtime.run_turn(input, prompter)` | Blocks until complete |
| Compact session | `runtime.compact(config)` | Manual `/compact` |
| Get session state | `runtime.session()` | For rendering history |
| Get usage stats | `runtime.usage()` | Token counter display |
| Get estimated tokens | `runtime.estimated_tokens()` | Status bar |
| Set compaction threshold | `runtime.set_auto_compaction_threshold(t)` | After context window error |
| Fork session | `runtime.fork_session(branch)` | `/session fork` |
| Prepare runtime | `cli.prepare_turn_runtime(emit_output)` | Rebuilds runtime from session |
| Replace runtime | `cli.replace_runtime(runtime)` | After compaction retry |
### What the TUI Needs to RECEIVE
| Event | Current Source | Proposed TUI Source |
|-------|---------------|-------------------|
| Text delta (streaming) | **Inside consume_stream()** | Refactor: StreamSink |
| Thinking delta (streaming) | **Inside consume_stream()** | Refactor: StreamSink |
| Tool use start | `HookProgressReporter::Started` | Channel from HookProgressReporter |
| Tool use complete | `TurnProgressReporter::on_tool_result` | Channel from TurnProgressReporter |
| Permission prompt | `PermissionPrompter::decide()` | Channel + response channel |
| Token usage update | `AssistantEvent::Usage` (post-stream) | StreamSink or TurnSummary |
| Auto-compaction happened | `TurnSummary.auto_compaction` | Post-turn, or event |
| Auto-compaction retry | `run_turn_to()` retry loop | Needs new event source |
| Message stop | `AssistantEvent::MessageStop` | StreamSink |
| Hook started/completed | `HookProgressReporter` | Channel from reporter |
| Spinner state | CLI writes directly | TUI manages its own |
### Current Missing Events (Must Be Added for TUI)
| Missing Event | Why Needed |
|---------------|-----------|
| `on_tool_start(iteration, tool_name, input)` | Show "Running X…" before result |
| `on_text_delta(text)` | Live streaming text to response area |
| `on_thinking_delta(text)` | Live thinking indicator / collapsible |
| `on_compacting(round, max_rounds, removed)` | Compaction retry progress |
| `on_auto_compaction(event)` | Mid-turn compaction notification |
| `on_permission_prompt(request)` | Delegated to PermissionPrompter (has channel solution) |
---
## 15. Proposed TUI Event Channel Design
### Core Idea
Replace the `io::Write`-based output with a typed event channel that the TUI consumes in its render loop.
### Event Enum
```rust
enum TuiEvent {
// ── Streaming events (from consume_stream refactor) ──
TurnStarted { input: String },
TextDelta { text: String },
ThinkingStart,
ThinkingDelta { text: String },
ThinkingEnd { char_count: usize, redacted: bool },
ToolUseStart { id: String, name: String, input: String },
ToolUseResult { id: String, name: String, output: String, is_error: bool },
UsageUpdate { usage: TokenUsage },
MessageStop,
// ── Tool lifecycle (from TurnProgressReporter + new hooks) ──
ToolStarted { iteration: usize, tool_name: String },
ToolResult {
iteration: usize,
tool_name: String,
result: Result<String, String>
},
// ── Permission (from PermissionPrompter channel) ──
PermissionRequired {
tool_name: String,
required_mode: PermissionMode,
input: String,
reason: Option<String>,
// Response sent back through separate oneshot channel
response_tx: oneshot::Sender<PermissionPromptDecision>,
},
// ── Compaction ──
Compacting { round: usize, max_rounds: usize, removed: usize },
AutoCompaction { removed_message_count: usize },
// ── Hook progress ──
HookStarted { event: HookEvent, tool_name: String, command: String },
HookCompleted { event: HookEvent, tool_name: String, command: String },
HookCancelled { event: HookEvent, tool_name: String, command: String },
// ── Lifecycle ──
TurnCompleted { summary: TurnSummary },
TurnFailed { error: RuntimeError },
}
```
### Integration Architecture
```
┌───────────────────────┐
│ TUI Loop │
│ ┌─────────────────┐ │
│ │ Event Receiver │◄─── mpsc::Receiver<TuiEvent>
│ │ (render loop) │ │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ Input Widget │───► on submit: send input to runtime thread
│ └─────────────────┘ │
└───────────────────────┘
│ TuiEvent channel
┌────────┴──────────────┐
│ Runtime Thread │
│ │
│ ┌──────────────────┐ │
│ │ ChannelSender │ │ ← implements StreamSink
│ │ + TurnProgress │ │ ← implements TurnProgressReporter
│ │ + HookProgress │ │ ← implements HookProgressReporter
│ │ + PermPrompter │ │ ← implements PermissionPrompter
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ConversationRuntime │
│ .run_turn(input, ...) │
│ │
└────────────────────────┘
```
### Implementation Sketch
```rust
// --- StreamSink: replaces io::Write in consume_stream() ---
trait StreamSink {
fn text_delta(&mut self, text: &str);
fn thinking_start(&mut self);
fn thinking_delta(&mut self, text: &str);
fn thinking_end(&mut self, char_count: usize, redacted: bool);
fn tool_use(&mut self, id: &str, name: &str, input: &str);
fn usage(&mut self, usage: &TokenUsage);
fn message_stop(&mut self);
}
// Channel-based implementation
struct ChannelStreamSink {
tx: mpsc::Sender<TuiEvent>,
}
impl StreamSink for ChannelStreamSink {
fn text_delta(&mut self, text: &str) {
let _ = self.tx.send(TuiEvent::TextDelta { text: text.into() });
}
// ... etc
}
// --- Modified consume_stream signature ---
// BEFORE:
async fn consume_stream(&self, req: &MessageRequest, stall: bool)
-> Result<Vec<AssistantEvent>, RuntimeError>
// AFTER (or alongside):
async fn consume_stream_with_sink(
&self,
req: &MessageRequest,
stall: bool,
sink: &mut dyn StreamSink,
) -> Result<Vec<AssistantEvent>, RuntimeError>
```
### Migration Strategy
1. **Phase 1 (Minimal TUI):** Use `run_turn_to()` with `Vec<u8>` buffer — no live streaming, but functional
2. **Phase 2 (Channel events):** Add `StreamSink` trait, implement `ChannelStreamSink`, add `consume_stream_with_sink()`
3. **Phase 3 (Full TUI):** TUI consumes `Receiver<TuiEvent>`, renders each event with appropriate widget
---
## 16. Key Risks & Open Questions
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| `consume_stream()` is 200+ lines with deep coupling to `io::Write` | High — refactoring is invasive | Add parallel `consume_stream_with_sink()` rather than modifying existing |
| `run_turn()` is synchronous and blocks the runtime thread | Medium — TUI input can't be processed during turn | Accept this: runtime runs on a background thread, TUI sends input via channel |
| `PermissionPrompter` blocks during `decide()` | Medium — TUI must wait for user input | Implemented as channel round-trip (send event → receive decision) |
| Hook runner may deny tool calls silently (no TUI event) | Low — Denied tools appear as error results | `TurnProgressReporter.on_tool_result()` already receives `Err` for denials |
| Context window retry loop is inside `run_turn_to()` (CLI layer) | High — TUI must replicate or share this logic | Extract into runtime or shared utility |
### Open Questions
1. **Should the TUI own the `BuiltRuntime` or the `Cli` struct?**
Currently `Cli` holds state (session, model, permission_mode) and wraps `BuiltRuntime`. The TUI probably needs its own "TuiApp" struct with similar state management.
2. **How to handle the context-window retry loop?**
This is currently 80+ lines in `run_turn_to()`. The TUI needs the same logic. Options: extract to shared function, or move it into the runtime.
3. **Should `InternalPromptProgressReporter` be exposed for the TUI?**
It's CLI-internal (ultraplan mode). The TUI might want its own progress reporting.
4. **What about MCP/plugin lifecycle?**
`BuiltRuntime` manages plugin and MCP startup/shutdown. The TUI must handle the same lifecycle (currently `prepare_turn_runtime()` handles this).
5. **How does the TUI handle `/session`, `/compact`, `/model` etc.?**
These are all handled in the REPL loop in `main.rs` (thousands of lines of command dispatch). The TUI needs its own command handler that calls the same runtime methods.
6. **Token streaming granularity for the TUI render loop?**
The TUI render loop (e.g., ratatui) typically redraws at 60fps. TextDeltas may arrive much faster. Need to batch/merge deltas between frames.
---
*End of report. All source references point to `/mnt/data/git/claw-code` on branch `feat-tui`.*