All skills
Skillintermediate

Research: Ralph's Stream Handler Architecture

Ralph has a clean trait-based streaming architecture that pi can plug into. The critical integration point is in `PtyExecutor::run_observe_streaming()` which branches on `OutputFormat` to choose between NDJSON parsing and raw text passthrough.

Claude Code Knowledge Pack7/10/2026

Overview

Research: Ralph's Stream Handler Architecture

Summary

Ralph has a clean trait-based streaming architecture that pi can plug into. The critical integration point is in PtyExecutor::run_observe_streaming() which branches on OutputFormat to choose between NDJSON parsing and raw text passthrough.

Architecture Overview

┌─────────────────┐     ┌──────────────┐     ┌─────────────────┐
│   PtyExecutor   │────▶│ StreamHandler│────▶│  TUI / Console  │
│ (pty_executor.rs)│     │   (trait)    │     │                 │
└────────┬────────┘     └──────────────┘     └─────────────────┘
         │
         ├── OutputFormat::StreamJson → ClaudeStreamParser::parse_line() → dispatch_stream_event()
         └── OutputFormat::Text → handler.on_text(raw) passthrough

StreamHandler Trait

trait StreamHandler: Send {
    fn on_text(&mut self, text: &str);
    fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
    fn on_tool_result(&mut self, id: &str, output: &str);
    fn on_error(&mut self, error: &str);
    fn on_complete(&mut self, result: &SessionResult);
}

4 implementations:

  • ConsoleStreamHandler — immediate text output (for Text format backends)
  • PrettyStreamHandler — markdown-rendered output (for StreamJson backends on TTY)
  • QuietStreamHandler — silent (CI mode)
  • TuiStreamHandler — ratatui Lines for TUI display

OutputFormat Enum

enum OutputFormat {
    Text,        // Plain text output (default for most adapters)
    StreamJson,  // Newline-delimited JSON stream (Claude only currently)
}

Currently only Claude uses StreamJson. All other backends (Kiro, Gemini, Codex, Amp, Copilot, OpenCode) use Text.

Parsing Flow in PtyExecutor

In run_observe_streaming():

let is_stream_json = output_format == OutputFormat::StreamJson;

// In the data processing loop:
if is_stream_json {
    // Buffer lines, parse as JSON
    line_buffer.push_str(text);
    while let Some(newline_pos) = line_buffer.find('\
') {
        let line = line_buffer[..newline_pos].to_string();
        if let Some(event) = ClaudeStreamParser::parse_line(&line) {
            dispatch_stream_event(event, handler, &mut extracted_text);
        }
    }
} else {
    // Raw text passthrough
    handler.on_text(text);
}

dispatch_stream_event()

Maps Claude events to StreamHandler calls:

fn dispatch_stream_event<H: StreamHandler>(event: ClaudeStreamEvent, handler: &mut H, extracted_text: &mut String) {
    match event {
        System { .. } => { /* ignore */ }
        Assistant { message, .. } => {
            for block in message.content {
                Text { text } => { handler.on_text(&text); extracted_text.push_str(&text); }
                ToolUse { name, id, input } => { handler.on_tool_call(&name, &id, &input); }
            }
        }
        User { message } => {
            for block in message.content {
                ToolResult { tool_use_id, content } => { handler.on_tool_result(&tool_use_id, &content); }
            }
        }
        Result { duration_ms, total_cost_usd, num_turns, is_error } => {
            handler.on_complete(&SessionResult { ... });
        }
    }
}

extracted_text

Critical for Ralph's core loop: the accumulated text output is used by EventParser to find LOOP_COMPLETE and event tags. For NDJSON backends, extracted_text collects only the text content (not JSON). For Text backends, stripped_output (ANSI-stripped raw output) is used instead.

Integration Options for Pi

Option A: New OutputFormat variant (Recommended)

Add OutputFormat::PiStreamJson and branch in run_observe_streaming():

match output_format {
    OutputFormat::StreamJson => { /* Claude parsing */ }
    OutputFormat::PiStreamJson => { /* Pi parsing */ }
    OutputFormat::Text => { /* raw passthrough */ }
}

Pros: Clean separation, no risk of breaking Claude parsing Cons: Another branch in the hot path

Option B: Unified parser with format detection

Parse the first JSON line to detect format ({"type":"session",...} = pi, {"type":"system",...} = Claude), then dispatch accordingly.

Pros: Single StreamJson format, auto-detection Cons: More complex, fragile detection

Option C: Text mode only

Use OutputFormat::Text and treat pi like Kiro/Gemini (raw text output).

Pros: Zero parsing work Cons: No structured tool call display, no cost tracking, no rich TUI output

Recommendation: Option A. Pi would be only the second NDJSON backend, and the schemas are different enough that a separate parser is cleaner. The branch cost is negligible.