ralph_workflow/json_parser/opencode.rs
1//! `OpenCode` event parser implementation
2//!
3//! This module handles parsing and displaying `OpenCode` NDJSON event streams.
4//!
5//! # Source Code Reference
6//!
7//! This parser is based on analysis of the OpenCode source code from:
8//! - **Repository**: <https://github.com/anomalyco/opencode>
9//! - **Key source files**:
10//! - `/packages/opencode/src/cli/cmd/run.ts` - NDJSON output generation
11//! - `/packages/opencode/src/session/message-v2.ts` - Message part type definitions
12//!
13//! # NDJSON Event Format
14//!
15//! OpenCode outputs NDJSON (newline-delimited JSON) events via `--format json`.
16//! Each event has the structure:
17//!
18//! ```json
19//! {
20//! "type": "step_start" | "step_finish" | "tool_use" | "text" | "error",
21//! "timestamp": 1768191337567,
22//! "sessionID": "ses_44f9562d4ffe",
23//! ...event-specific data (usually in "part" field)
24//! }
25//! ```
26//!
27//! From `run.ts` lines 146-201, the event types are generated as:
28//! ```typescript
29//! outputJsonEvent("tool_use", { part }) // Tool invocations
30//! outputJsonEvent("step_start", { part }) // Step initialization
31//! outputJsonEvent("step_finish", { part }) // Step completion
32//! outputJsonEvent("text", { part }) // Streaming text content
33//! outputJsonEvent("error", { error }) // Error events
34//! ```
35//!
36//! # Part Type Definitions
37//!
38//! ## StepStartPart (`message-v2.ts` lines 194-200)
39//!
40//! ```typescript
41//! {
42//! id: string,
43//! sessionID: string,
44//! messageID: string,
45//! type: "step-start",
46//! snapshot: string | undefined // Git commit hash for state snapshot
47//! }
48//! ```
49//!
50//! ## StepFinishPart (`message-v2.ts` lines 202-219)
51//!
52//! ```typescript
53//! {
54//! id: string,
55//! sessionID: string,
56//! messageID: string,
57//! type: "step-finish",
58//! reason: string, // "tool-calls", "end_turn", etc.
59//! snapshot: string | undefined,
60//! cost: number, // Cost in USD
61//! tokens: {
62//! input: number,
63//! output: number,
64//! reasoning: number,
65//! cache: { read: number, write: number }
66//! }
67//! }
68//! ```
69//!
70//! ## TextPart (`message-v2.ts` lines 62-77)
71//!
72//! ```typescript
73//! {
74//! id: string,
75//! sessionID: string,
76//! messageID: string,
77//! type: "text",
78//! text: string,
79//! synthetic?: boolean,
80//! ignored?: boolean,
81//! time?: { start: number, end?: number },
82//! metadata?: Record<string, any>
83//! }
84//! ```
85//!
86//! ## ToolPart (`message-v2.ts` lines 289-298)
87//!
88//! ```typescript
89//! {
90//! id: string,
91//! sessionID: string,
92//! messageID: string,
93//! type: "tool",
94//! callID: string,
95//! tool: string, // Tool name: "read", "bash", "write", "edit", "glob", "grep", etc.
96//! state: ToolState,
97//! metadata?: Record<string, any>
98//! }
99//! ```
100//!
101//! ## ToolState Variants (`message-v2.ts` lines 221-287)
102//!
103//! The `state` field is a discriminated union based on `status`:
104//!
105//! ### Pending (`status: "pending"`)
106//! ```typescript
107//! { status: "pending", input: Record<string, any>, raw: string }
108//! ```
109//!
110//! ### Running (`status: "running"`)
111//! ```typescript
112//! {
113//! status: "running",
114//! input: Record<string, any>,
115//! title?: string,
116//! metadata?: Record<string, any>,
117//! time: { start: number }
118//! }
119//! ```
120//!
121//! ### Completed (`status: "completed"`)
122//! ```typescript
123//! {
124//! status: "completed",
125//! input: Record<string, any>,
126//! output: string,
127//! title: string,
128//! metadata: Record<string, any>,
129//! time: { start: number, end: number, compacted?: number },
130//! attachments?: FilePart[]
131//! }
132//! ```
133//!
134//! ### Error (`status: "error"`)
135//! ```typescript
136//! {
137//! status: "error",
138//! input: Record<string, any>,
139//! error: string,
140//! metadata?: Record<string, any>,
141//! time: { start: number, end: number }
142//! }
143//! ```
144//!
145//! ## Tool Input Parameters
146//!
147//! The `state.input` object contains tool-specific parameters:
148//!
149//! | Tool | Input Fields |
150//! |---------|-----------------------------------------------------|
151//! | `read` | `{ filePath: string, offset?: number, limit?: number }` |
152//! | `bash` | `{ command: string, timeout?: number }` |
153//! | `write` | `{ filePath: string, content: string }` |
154//! | `edit` | `{ filePath: string, ... }` |
155//! | `glob` | `{ pattern: string, path?: string }` |
156//! | `grep` | `{ pattern: string, path?: string, include?: string }` |
157//! | `fetch` | `{ url: string, format?: string, timeout?: number }` |
158//!
159//! From `run.ts` line 168, the title fallback is:
160//! ```typescript
161//! const title = part.state.title ||
162//! (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
163//! ```
164//!
165//! # Streaming Output Behavior
166//!
167//! This parser implements real-time streaming output for text deltas. When content
168//! arrives in multiple chunks (via `text` events), the parser:
169//!
170//! 1. **Accumulates** text deltas from each chunk into a buffer
171//! 2. **Displays** the accumulated text after each chunk
172//! 3. **Uses carriage return (`\r`) and line clearing (`\x1b[2K`)** to rewrite the entire line,
173//! creating an updating effect that shows the content building up in real-time
174//! 4. **Shows prefix on every delta**, rewriting the entire line each time (industry standard)
175//!
176//! Example output sequence for streaming "Hello World" in two chunks:
177//! ```text
178//! [OpenCode] Hello\r (first text event with prefix, no newline)
179//! \x1b[2K\r[OpenCode] Hello World\r (second text event clears line, rewrites with accumulated)
180//! [OpenCode] ✓ Step finished... (step_finish shows prefix with newline)
181//! ```
182//!
183//! # Single-Line Pattern
184//!
185//! The renderer uses a single-line pattern with carriage return for in-place updates.
186//! This is the industry standard for streaming CLIs (used by Rich, Ink, Bubble Tea).
187//!
188//! Each delta rewrites the entire line with prefix, ensuring that:
189//! - The user always sees the prefix
190//! - Content updates in-place without visual artifacts
191//! - Terminal state is clean and predictable
192
193use crate::common::truncate_text;
194use crate::config::Verbosity;
195use crate::logger::{Colors, CHECK, CROSS};
196use std::cell::{Cell, RefCell};
197use std::fmt::Write as _;
198use std::io::{self, BufRead, Write};
199use std::path::Path;
200use std::rc::Rc;
201
202use super::delta_display::{DeltaRenderer, TextDeltaRenderer};
203use super::health::HealthMonitor;
204#[cfg(feature = "test-utils")]
205use super::health::StreamingQualityMetrics;
206use super::printer::SharedPrinter;
207use super::streaming_state::StreamingSession;
208use super::terminal::TerminalMode;
209use super::types::{format_tool_input, format_unknown_json_event, ContentType};
210
211// Event type definitions
212include!("opencode/event_types.rs");
213
214// Parser implementation
215include!("opencode/parser.rs");
216
217// Event formatting methods
218include!("opencode/formatting/step.rs");
219include!("opencode/formatting/tool.rs");
220include!("opencode/formatting/text_and_error.rs");
221
222// Tests
223include!("opencode/tests.rs");