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

40 KiB

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
  2. ConversationRuntime — the Core Loop
  3. TurnProgressReporter Trait
  4. run_turn() Streaming Model
  5. Tool Execution Events & Lifecycle
  6. Permission Prompting
  7. Compaction Flow
  8. Reasoning / Thinking Content Streaming
  9. AssistantEvent Enum — Full Event Catalog
  10. Rendering Pipeline (Markdown → ANSI)
  11. Input Layer (rustyline)
  12. Session Persistence
  13. Hook System
  14. Integration Points Summary
  15. Proposed TUI Event Channel Design
  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:

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

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:

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:

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:

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

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

pub struct PermissionRequest {
    pub tool_name: String,
    pub input: String,
    pub current_mode: PermissionMode,
    pub required_mode: PermissionMode,
    pub reason: Option<String>,
}

PermissionPromptDecision

pub enum PermissionPromptDecision {
    Allow,
    Deny { reason: String },
}

PermissionMode hierarchy

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:
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):

// Inside the loop in run_turn():
if let Some(compaction) = self.maybe_auto_compact() {
    auto_compaction = Some(compaction);
}

maybe_auto_compact()

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

pub struct AutoCompactionEvent {
    pub removed_message_count: usize,
}

Returned in TurnSummary.auto_compaction. The CLI displays it:

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:

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():

// 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()

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

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

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)

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

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

pub struct LineEditor {
    prompt: String,
    editor: Editor<SlashCommandHelper, DefaultHistory>,
}

ReadOutcome

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

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

pub enum HookEvent {
    PreToolUse,
    PostToolUse,
    PostToolUseFailure,
}

HookProgressEvent

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

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

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

// --- 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.