Skip to main content

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");