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//! # Streaming Output Behavior
6//!
7//! This parser implements real-time streaming output for text deltas. When content
8//! arrives in multiple chunks (via `text` events), the parser:
9//!
10//! 1. **Accumulates** text deltas from each chunk into a buffer
11//! 2. **Displays** the accumulated text after each chunk
12//! 3. **Uses carriage return (`\r`) and line clearing (`\x1b[2K`)** to rewrite the entire line,
13//!    creating an updating effect that shows the content building up in real-time
14//! 4. **Shows prefix on every delta**, rewriting the entire line each time (industry standard)
15//!
16//! Example output sequence for streaming "Hello World" in two chunks:
17//! ```text
18//! [OpenCode] Hello\r       (first text event with prefix, no newline)
19//! \x1b[2K\r[OpenCode] Hello World\r  (second text event clears line, rewrites with accumulated)
20//! [OpenCode] ✓ Step finished... (step_finish shows prefix with newline)
21//! ```
22//!
23//! # Single-Line Pattern
24//!
25//! The renderer uses a single-line pattern with carriage return for in-place updates.
26//! This is the industry standard for streaming CLIs (used by Rich, Ink, Bubble Tea).
27//!
28//! Each delta rewrites the entire line with prefix, ensuring that:
29//! - The user always sees the prefix
30//! - Content updates in-place without visual artifacts
31//! - Terminal state is clean and predictable
32
33use crate::common::truncate_text;
34use crate::config::Verbosity;
35use crate::logger::{Colors, CHECK, CROSS};
36use serde::{Deserialize, Serialize};
37use std::cell::RefCell;
38use std::fmt::Write as _;
39use std::io::{self, BufRead, Write};
40use std::rc::Rc;
41
42use super::delta_display::{DeltaRenderer, TextDeltaRenderer};
43use super::health::HealthMonitor;
44use super::health::StreamingQualityMetrics;
45use super::printer::SharedPrinter;
46use super::streaming_state::StreamingSession;
47use super::terminal::TerminalMode;
48use super::types::{format_tool_input, format_unknown_json_event, ContentType};
49
50/// `OpenCode` event types
51///
52/// Based on `OpenCode`'s actual NDJSON output format, events include:
53/// - `step_start`: Step initialization with snapshot info
54/// - `step_finish`: Step completion with reason, cost, tokens
55/// - `tool_use`: Tool invocation with tool name, callID, and state (status, input, output)
56/// - `text`: Streaming text content
57///
58/// The top-level structure is: `{ "type": "...", "timestamp": ..., "sessionID": "...", "part": {...} }`
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct OpenCodeEvent {
61    #[serde(rename = "type")]
62    pub(crate) event_type: String,
63    pub(crate) timestamp: Option<u64>,
64    #[serde(rename = "sessionID")]
65    pub(crate) session_id: Option<String>,
66    pub(crate) part: Option<OpenCodePart>,
67}
68
69/// Nested part object containing the actual event data
70#[derive(Debug, Clone, Deserialize, Serialize)]
71pub struct OpenCodePart {
72    pub(crate) id: Option<String>,
73    #[serde(rename = "sessionID")]
74    pub(crate) session_id: Option<String>,
75    #[serde(rename = "messageID")]
76    pub(crate) message_id: Option<String>,
77    #[serde(rename = "type")]
78    pub(crate) part_type: Option<String>,
79    // For step_start events
80    pub(crate) snapshot: Option<String>,
81    // For step_finish events
82    pub(crate) reason: Option<String>,
83    pub(crate) cost: Option<f64>,
84    pub(crate) tokens: Option<OpenCodeTokens>,
85    // For tool_use events
86    #[serde(rename = "callID")]
87    pub(crate) call_id: Option<String>,
88    pub(crate) tool: Option<String>,
89    pub(crate) state: Option<OpenCodeToolState>,
90    // For text events
91    pub(crate) text: Option<String>,
92    // Time info for text events
93    pub(crate) time: Option<OpenCodeTime>,
94}
95
96/// Tool state containing status, input, and output
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct OpenCodeToolState {
99    pub(crate) status: Option<String>,
100    pub(crate) input: Option<serde_json::Value>,
101    pub(crate) output: Option<serde_json::Value>,
102    pub(crate) title: Option<String>,
103    pub(crate) metadata: Option<serde_json::Value>,
104    pub(crate) time: Option<OpenCodeTime>,
105}
106
107/// Token statistics from `step_finish` events
108#[derive(Debug, Clone, Deserialize, Serialize)]
109pub struct OpenCodeTokens {
110    pub(crate) input: Option<u64>,
111    pub(crate) output: Option<u64>,
112    pub(crate) reasoning: Option<u64>,
113    pub(crate) cache: Option<OpenCodeCache>,
114}
115
116/// Cache statistics
117#[derive(Debug, Clone, Deserialize, Serialize)]
118pub struct OpenCodeCache {
119    pub(crate) read: Option<u64>,
120    pub(crate) write: Option<u64>,
121}
122
123/// Time information
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct OpenCodeTime {
126    pub(crate) start: Option<u64>,
127    pub(crate) end: Option<u64>,
128}
129
130/// `OpenCode` event parser
131pub struct OpenCodeParser {
132    colors: Colors,
133    verbosity: Verbosity,
134    log_file: Option<String>,
135    display_name: String,
136    /// Unified streaming session for state tracking
137    streaming_session: Rc<RefCell<StreamingSession>>,
138    /// Terminal mode for output formatting
139    terminal_mode: RefCell<TerminalMode>,
140    /// Whether to show streaming quality metrics
141    show_streaming_metrics: bool,
142    /// Output printer for capturing or displaying output
143    printer: SharedPrinter,
144}
145
146impl OpenCodeParser {
147    pub(crate) fn new(colors: Colors, verbosity: Verbosity) -> Self {
148        Self::with_printer(colors, verbosity, super::printer::shared_stdout())
149    }
150
151    /// Create a new `OpenCodeParser` with a custom printer.
152    ///
153    /// # Arguments
154    ///
155    /// * `colors` - Colors for terminal output
156    /// * `verbosity` - Verbosity level for output
157    /// * `printer` - Shared printer for output
158    ///
159    /// # Returns
160    ///
161    /// A new `OpenCodeParser` instance
162    pub(crate) fn with_printer(
163        colors: Colors,
164        verbosity: Verbosity,
165        printer: SharedPrinter,
166    ) -> Self {
167        let verbose_warnings = matches!(verbosity, Verbosity::Debug);
168        let streaming_session = StreamingSession::new().with_verbose_warnings(verbose_warnings);
169
170        // Use the printer's is_terminal method to validate it's connected correctly
171        let _printer_is_terminal = printer.borrow().is_terminal();
172
173        Self {
174            colors,
175            verbosity,
176            log_file: None,
177            display_name: "OpenCode".to_string(),
178            streaming_session: Rc::new(RefCell::new(streaming_session)),
179            terminal_mode: RefCell::new(TerminalMode::detect()),
180            show_streaming_metrics: false,
181            printer,
182        }
183    }
184
185    pub(crate) const fn with_show_streaming_metrics(mut self, show: bool) -> Self {
186        self.show_streaming_metrics = show;
187        self
188    }
189
190    pub(crate) fn with_display_name(mut self, display_name: &str) -> Self {
191        self.display_name = display_name.to_string();
192        self
193    }
194
195    pub(crate) fn with_log_file(mut self, path: &str) -> Self {
196        self.log_file = Some(path.to_string());
197        self
198    }
199
200    #[cfg(test)]
201    pub fn with_terminal_mode(self, mode: TerminalMode) -> Self {
202        *self.terminal_mode.borrow_mut() = mode;
203        self
204    }
205
206    /// Get a shared reference to the printer.
207    ///
208    /// This allows tests, monitoring, and other code to access the printer after parsing
209    /// to verify output content, check for duplicates, or capture output for analysis.
210    ///
211    /// # Returns
212    ///
213    /// A clone of the shared printer reference (`Rc<RefCell<dyn Printable>>`)
214    #[cfg_attr(any(debug_assertions, test, feature = "monitoring"), allow(dead_code))]
215    pub fn printer(&self) -> SharedPrinter {
216        Rc::clone(&self.printer)
217    }
218
219    /// Get streaming quality metrics from the current session.
220    ///
221    /// This provides insight into the deduplication and streaming quality of the
222    /// parsing session.
223    ///
224    /// # Returns
225    ///
226    /// A copy of the streaming quality metrics from the internal `StreamingSession`.
227    #[cfg_attr(any(debug_assertions, test, feature = "monitoring"), allow(dead_code))]
228    pub fn streaming_metrics(&self) -> StreamingQualityMetrics {
229        self.streaming_session
230            .borrow()
231            .get_streaming_quality_metrics()
232    }
233
234    /// Parse and display a single `OpenCode` JSON event
235    ///
236    /// The `OpenCode` NDJSON format uses events with:
237    /// - `step_start`: Step initialization with snapshot info
238    /// - `step_finish`: Step completion with reason, cost, tokens
239    /// - `tool_use`: Tool invocation with tool name, callID, and state (status, input, output)
240    /// - `text`: Streaming text content
241    pub(crate) fn parse_event(&self, line: &str) -> Option<String> {
242        let event: OpenCodeEvent = if let Ok(e) = serde_json::from_str(line) {
243            e
244        } else {
245            let trimmed = line.trim();
246            if !trimmed.is_empty() && !trimmed.starts_with('{') {
247                return Some(format!("{trimmed}\n"));
248            }
249            return None;
250        };
251        let c = &self.colors;
252        let prefix = &self.display_name;
253
254        let output = match event.event_type.as_str() {
255            "step_start" => self.format_step_start_event(&event),
256            "step_finish" => self.format_step_finish_event(&event),
257            "tool_use" => self.format_tool_use_event(&event),
258            "text" => self.format_text_event(&event),
259            _ => {
260                // Unknown event type - use the generic formatter in verbose mode
261                format_unknown_json_event(line, prefix, *c, self.verbosity.is_verbose())
262            }
263        };
264
265        if output.is_empty() {
266            None
267        } else {
268            Some(output)
269        }
270    }
271
272    /// Format a `step_start` event
273    fn format_step_start_event(&self, event: &OpenCodeEvent) -> String {
274        let c = &self.colors;
275        let prefix = &self.display_name;
276
277        // Reset streaming state on new step
278        self.streaming_session.borrow_mut().on_message_start();
279
280        // Create unique step ID for duplicate detection
281        // Use part.message_id if available, otherwise combine session_id + part.id
282        let step_id = event.part.as_ref().map_or_else(
283            || {
284                event
285                    .session_id
286                    .clone()
287                    .unwrap_or_else(|| "unknown".to_string())
288            },
289            |part| {
290                part.message_id.as_ref().map_or_else(
291                    || {
292                        let session = event.session_id.as_deref().unwrap_or("unknown");
293                        let part_id = part.id.as_deref().unwrap_or("step");
294                        format!("{session}:{part_id}")
295                    },
296                    std::clone::Clone::clone,
297                )
298            },
299        );
300        self.streaming_session
301            .borrow_mut()
302            .set_current_message_id(Some(step_id));
303
304        let snapshot = event
305            .part
306            .as_ref()
307            .and_then(|p| p.snapshot.as_ref())
308            .map(|s| format!("({s:.8}...)"))
309            .unwrap_or_default();
310        format!(
311            "{}[{}]{} {}Step started{} {}{}{}\n",
312            c.dim(),
313            prefix,
314            c.reset(),
315            c.cyan(),
316            c.reset(),
317            c.dim(),
318            snapshot,
319            c.reset()
320        )
321    }
322
323    /// Format a `step_finish` event
324    fn format_step_finish_event(&self, event: &OpenCodeEvent) -> String {
325        let c = &self.colors;
326        let prefix = &self.display_name;
327
328        // Check for duplicate final message using message ID or fallback to streaming content check
329        let session = self.streaming_session.borrow();
330        let is_duplicate = session.get_current_message_id().map_or_else(
331            || session.has_any_streamed_content(),
332            |message_id| session.is_duplicate_final_message(message_id),
333        );
334        let was_streaming = session.has_any_streamed_content();
335        let metrics = session.get_streaming_quality_metrics();
336        drop(session);
337
338        // Finalize the message (this marks it as displayed)
339        let _was_in_block = self.streaming_session.borrow_mut().on_message_stop();
340
341        event.part.as_ref().map_or_else(String::new, |part| {
342            let reason = part.reason.as_deref().unwrap_or("unknown");
343            let cost = part.cost.unwrap_or(0.0);
344
345            let tokens_str = part.tokens.as_ref().map_or_else(String::new, |tokens| {
346                let input = tokens.input.unwrap_or(0);
347                let output = tokens.output.unwrap_or(0);
348                let reasoning = tokens.reasoning.unwrap_or(0);
349                let cache_read = tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0);
350                if reasoning > 0 {
351                    format!("in:{input} out:{output} reason:{reasoning} cache:{cache_read}")
352                } else if cache_read > 0 {
353                    format!("in:{input} out:{output} cache:{cache_read}")
354                } else {
355                    format!("in:{input} out:{output}")
356                }
357            });
358
359            let is_success = reason == "tool-calls" || reason == "end_turn";
360            let icon = if is_success { CHECK } else { CROSS };
361            let color = if is_success { c.green() } else { c.yellow() };
362
363            // Add final newline if we were streaming text
364            let terminal_mode = *self.terminal_mode.borrow();
365            let newline_prefix = if is_duplicate || was_streaming {
366                let completion = TextDeltaRenderer::render_completion(terminal_mode);
367                let show_metrics = (self.verbosity.is_debug() || self.show_streaming_metrics)
368                    && metrics.total_deltas > 0;
369                if show_metrics {
370                    format!("{}\n{}", completion, metrics.format(*c))
371                } else {
372                    completion
373                }
374            } else {
375                String::new()
376            };
377
378            let mut out = format!(
379                "{}{}[{}]{} {}{} Step finished{} {}({}",
380                newline_prefix,
381                c.dim(),
382                prefix,
383                c.reset(),
384                color,
385                icon,
386                c.reset(),
387                c.dim(),
388                reason
389            );
390            if !tokens_str.is_empty() {
391                let _ = write!(out, ", {tokens_str}");
392            }
393            if cost > 0.0 {
394                let _ = write!(out, ", ${cost:.4}");
395            }
396            let _ = writeln!(out, "){}", c.reset());
397            out
398        })
399    }
400
401    /// Format a `tool_use` event
402    fn format_tool_use_event(&self, event: &OpenCodeEvent) -> String {
403        let c = &self.colors;
404        let prefix = &self.display_name;
405
406        event.part.as_ref().map_or_else(String::new, |part| {
407            let tool_name = part.tool.as_deref().unwrap_or("unknown");
408            let status = part
409                .state
410                .as_ref()
411                .and_then(|s| s.status.as_deref())
412                .unwrap_or("pending");
413            let title = part.state.as_ref().and_then(|s| s.title.as_deref());
414
415            let is_completed = status == "completed";
416            let icon = if is_completed { CHECK } else { '…' };
417            let color = if is_completed { c.green() } else { c.yellow() };
418
419            let mut out = format!(
420                "{}[{}]{} {}Tool{}: {}{}{} {}{}{}\n",
421                c.dim(),
422                prefix,
423                c.reset(),
424                c.magenta(),
425                c.reset(),
426                c.bold(),
427                tool_name,
428                c.reset(),
429                color,
430                icon,
431                c.reset()
432            );
433
434            // Show title if available
435            if let Some(t) = title {
436                let limit = self.verbosity.truncate_limit("text");
437                let preview = truncate_text(t, limit);
438                let _ = writeln!(
439                    out,
440                    "{}[{}]{} {}  └─ {}{}",
441                    c.dim(),
442                    prefix,
443                    c.reset(),
444                    c.dim(),
445                    preview,
446                    c.reset()
447                );
448            }
449
450            // Show tool input at Normal+ verbosity
451            if self.verbosity.show_tool_input() {
452                if let Some(ref state) = part.state {
453                    if let Some(ref input_val) = state.input {
454                        let input_str = format_tool_input(input_val);
455                        let limit = self.verbosity.truncate_limit("tool_input");
456                        let preview = truncate_text(&input_str, limit);
457                        if !preview.is_empty() {
458                            let _ = writeln!(
459                                out,
460                                "{}[{}]{} {}  └─ {}{}",
461                                c.dim(),
462                                prefix,
463                                c.reset(),
464                                c.dim(),
465                                preview,
466                                c.reset()
467                            );
468                        }
469                    }
470                }
471            }
472
473            // Show tool output in verbose mode if completed
474            if self.verbosity.is_verbose() && is_completed {
475                if let Some(ref state) = part.state {
476                    if let Some(ref output_val) = state.output {
477                        let output_str = match output_val {
478                            serde_json::Value::String(s) => s.as_str(),
479                            _ => "",
480                        };
481                        let output_str = if output_str.is_empty() {
482                            output_val.to_string()
483                        } else {
484                            output_str.to_string()
485                        };
486                        let limit = self.verbosity.truncate_limit("tool_result");
487                        let preview = truncate_text(&output_str, limit);
488                        if !preview.is_empty() {
489                            let _ = writeln!(
490                                out,
491                                "{}[{}]{} {}  └─ Output: {}{}",
492                                c.dim(),
493                                prefix,
494                                c.reset(),
495                                c.dim(),
496                                preview,
497                                c.reset()
498                            );
499                        }
500                    }
501                }
502            }
503            out
504        })
505    }
506
507    /// Format a `text` event
508    fn format_text_event(&self, event: &OpenCodeEvent) -> String {
509        let c = &self.colors;
510        let prefix = &self.display_name;
511
512        if let Some(ref part) = event.part {
513            if let Some(ref text) = part.text {
514                // Accumulate streaming text using StreamingSession
515                let (show_prefix, accumulated_text) = {
516                    let mut session = self.streaming_session.borrow_mut();
517                    let show_prefix = session.on_text_delta_key("main", text);
518                    // Get accumulated text for streaming display
519                    let accumulated_text = session
520                        .get_accumulated(ContentType::Text, "main")
521                        .unwrap_or("")
522                        .to_string();
523                    (show_prefix, accumulated_text)
524                };
525
526                // Show delta in real-time (both verbose and normal mode)
527                let limit = self.verbosity.truncate_limit("text");
528                let preview = truncate_text(&accumulated_text, limit);
529
530                // Use TextDeltaRenderer for consistent rendering across all parsers
531                let terminal_mode = *self.terminal_mode.borrow();
532                if show_prefix {
533                    // First delta: use renderer with prefix
534                    return TextDeltaRenderer::render_first_delta(
535                        &preview,
536                        prefix,
537                        *c,
538                        terminal_mode,
539                    );
540                }
541                // Subsequent deltas: use renderer for in-place update
542                return TextDeltaRenderer::render_subsequent_delta(
543                    &preview,
544                    prefix,
545                    *c,
546                    terminal_mode,
547                );
548            }
549        }
550        String::new()
551    }
552
553    /// Check if an `OpenCode` event is a control event (state management with no user output)
554    ///
555    /// Control events are valid JSON that represent state transitions rather than
556    /// user-facing content. They should be tracked separately from "ignored" events
557    /// to avoid false health warnings.
558    fn is_control_event(event: &OpenCodeEvent) -> bool {
559        match event.event_type.as_str() {
560            // Step lifecycle events are control events
561            "step_start" | "step_finish" => true,
562            _ => false,
563        }
564    }
565
566    /// Check if an `OpenCode` event is a partial/delta event (streaming content displayed incrementally)
567    ///
568    /// Partial events represent streaming text deltas that are shown to the user
569    /// in real-time. These should be tracked separately to avoid inflating "ignored" percentages.
570    fn is_partial_event(event: &OpenCodeEvent) -> bool {
571        match event.event_type.as_str() {
572            // Text events produce streaming content
573            "text" => true,
574            _ => false,
575        }
576    }
577
578    /// Parse a stream of `OpenCode` NDJSON events
579    pub(crate) fn parse_stream<R: BufRead>(&self, mut reader: R) -> io::Result<()> {
580        use super::incremental_parser::IncrementalNdjsonParser;
581
582        let c = &self.colors;
583        let monitor = HealthMonitor::new("OpenCode");
584        let mut log_writer = self.log_file.as_ref().and_then(|log_path| {
585            std::fs::OpenOptions::new()
586                .create(true)
587                .append(true)
588                .open(log_path)
589                .ok()
590                .map(std::io::BufWriter::new)
591        });
592
593        // Use incremental parser for true real-time streaming
594        // This processes JSON as soon as it's complete, not waiting for newlines
595        let mut incremental_parser = IncrementalNdjsonParser::new();
596        let mut byte_buffer = Vec::new();
597
598        loop {
599            // Read available bytes
600            byte_buffer.clear();
601            let chunk = reader.fill_buf()?;
602            if chunk.is_empty() {
603                break;
604            }
605
606            // Process all bytes immediately
607            byte_buffer.extend_from_slice(chunk);
608            let consumed = chunk.len();
609            reader.consume(consumed);
610
611            // Feed bytes to incremental parser
612            let json_events = incremental_parser.feed(&byte_buffer);
613
614            // Process each complete JSON event immediately
615            for line in json_events {
616                let trimmed = line.trim();
617                if trimmed.is_empty() {
618                    continue;
619                }
620
621                if self.verbosity.is_debug() {
622                    let mut printer = self.printer.borrow_mut();
623                    writeln!(
624                        printer,
625                        "{}[DEBUG]{} {}{}{}",
626                        c.dim(),
627                        c.reset(),
628                        c.dim(),
629                        &line,
630                        c.reset()
631                    )?;
632                    printer.flush()?;
633                }
634
635                // Parse the event once - parse_event handles malformed JSON by returning None
636                match self.parse_event(&line) {
637                    Some(output) => {
638                        // Check if this is a partial/delta event (streaming content)
639                        if trimmed.starts_with('{') {
640                            if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
641                                if Self::is_partial_event(&event) {
642                                    monitor.record_partial_event();
643                                } else {
644                                    monitor.record_parsed();
645                                }
646                            } else {
647                                monitor.record_parsed();
648                            }
649                        } else {
650                            monitor.record_parsed();
651                        }
652                        // Write output to printer
653                        let mut printer = self.printer.borrow_mut();
654                        write!(printer, "{output}")?;
655                        printer.flush()?;
656                    }
657                    None => {
658                        // Check if this was a control event (state management with no user output)
659                        if trimmed.starts_with('{') {
660                            if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
661                                if Self::is_control_event(&event) {
662                                    monitor.record_control_event();
663                                } else {
664                                    // Valid JSON but not a control event - track as unknown
665                                    monitor.record_unknown_event();
666                                }
667                            } else {
668                                // Failed to deserialize - track as parse error
669                                monitor.record_parse_error();
670                            }
671                        } else {
672                            monitor.record_ignored();
673                        }
674                    }
675                }
676
677                if let Some(ref mut file) = log_writer {
678                    writeln!(file, "{line}")?;
679                }
680            }
681        }
682
683        if let Some(ref mut file) = log_writer {
684            file.flush()?;
685        }
686        if let Some(warning) = monitor.check_and_warn(*c) {
687            let mut printer = self.printer.borrow_mut();
688            writeln!(printer, "{warning}")?;
689        }
690        Ok(())
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn test_opencode_step_start() {
700        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
701        let json = r#"{"type":"step_start","timestamp":1768191337567,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06aa45c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"step-start","snapshot":"5d36aa035d4df6edb73a68058733063258114ed5"}}"#;
702        let output = parser.parse_event(json);
703        assert!(output.is_some());
704        let out = output.unwrap();
705        assert!(out.contains("Step started"));
706        assert!(out.contains("5d36aa03"));
707    }
708
709    #[test]
710    fn test_opencode_step_finish() {
711        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
712        let json = r#"{"type":"step_finish","timestamp":1768191347296,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06aca1d001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"step-finish","reason":"tool-calls","snapshot":"5d36aa035d4df6edb73a68058733063258114ed5","cost":0,"tokens":{"input":108,"output":151,"reasoning":0,"cache":{"read":11236,"write":0}}}}"#;
713        let output = parser.parse_event(json);
714        assert!(output.is_some());
715        let out = output.unwrap();
716        assert!(out.contains("Step finished"));
717        assert!(out.contains("tool-calls"));
718        assert!(out.contains("in:108"));
719        assert!(out.contains("out:151"));
720        assert!(out.contains("cache:11236"));
721    }
722
723    #[test]
724    fn test_opencode_tool_use_completed() {
725        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
726        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"tool","callID":"call_8a2985d92e63","tool":"read","state":{"status":"completed","input":{"filePath":"/test/PLAN.md"},"output":"<file>\n00001| # Implementation Plan\n</file>","title":"PLAN.md"}}}"#;
727        let output = parser.parse_event(json);
728        assert!(output.is_some());
729        let out = output.unwrap();
730        assert!(out.contains("Tool"));
731        assert!(out.contains("read"));
732        assert!(out.contains("✓")); // completed icon
733        assert!(out.contains("PLAN.md"));
734    }
735
736    #[test]
737    fn test_opencode_tool_use_pending() {
738        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
739        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"tool","callID":"call_8a2985d92e63","tool":"bash","state":{"status":"pending","input":{"command":"ls -la"}}}}"#;
740        let output = parser.parse_event(json);
741        assert!(output.is_some());
742        let out = output.unwrap();
743        assert!(out.contains("Tool"));
744        assert!(out.contains("bash"));
745        assert!(out.contains("…")); // pending icon (WAIT)
746    }
747
748    #[test]
749    fn test_opencode_tool_use_shows_input() {
750        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
751        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"tool","callID":"call_8a2985d92e63","tool":"read","state":{"status":"completed","input":{"filePath":"/Users/test/file.rs"}}}}"#;
752        let output = parser.parse_event(json);
753        assert!(output.is_some());
754        let out = output.unwrap();
755        assert!(out.contains("Tool"));
756        assert!(out.contains("read"));
757        assert!(out.contains("filePath=/Users/test/file.rs"));
758    }
759
760    #[test]
761    fn test_opencode_text_event() {
762        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
763        let json = r#"{"type":"text","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac63300","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"text","text":"I'll start by reading the plan and requirements to understand what needs to be implemented.","time":{"start":1768191347226,"end":1768191347226}}}"#;
764        let output = parser.parse_event(json);
765        assert!(output.is_some());
766        let out = output.unwrap();
767        assert!(out.contains("I'll start by reading the plan"));
768    }
769
770    #[test]
771    fn test_opencode_unknown_event_ignored() {
772        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
773        let json = r#"{"type":"unknown_event","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{}}"#;
774        let output = parser.parse_event(json);
775        // Unknown events should return None
776        assert!(output.is_none());
777    }
778
779    #[test]
780    fn test_opencode_parser_non_json_passthrough() {
781        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
782        let output = parser.parse_event("Error: something went wrong");
783        assert!(output.is_some());
784        assert!(output.unwrap().contains("Error: something went wrong"));
785    }
786
787    #[test]
788    fn test_opencode_parser_malformed_json_ignored() {
789        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
790        let output = parser.parse_event("{invalid json here}");
791        assert!(output.is_none());
792    }
793
794    #[test]
795    fn test_opencode_step_finish_with_cost() {
796        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
797        let json = r#"{"type":"step_finish","timestamp":1768191347296,"sessionID":"ses_44f9562d4ffe","part":{"type":"step-finish","reason":"end_turn","cost":0.0025,"tokens":{"input":1000,"output":500,"reasoning":0,"cache":{"read":0,"write":0}}}}"#;
798        let output = parser.parse_event(json);
799        assert!(output.is_some());
800        let out = output.unwrap();
801        assert!(out.contains("Step finished"));
802        assert!(out.contains("end_turn"));
803        assert!(out.contains("$0.0025"));
804    }
805
806    #[test]
807    fn test_opencode_tool_verbose_shows_output() {
808        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Verbose);
809        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"/test.rs"},"output":"fn main() { println!(\"Hello\"); }"}}}"#;
810        let output = parser.parse_event(json);
811        assert!(output.is_some());
812        let out = output.unwrap();
813        assert!(out.contains("Tool"));
814        assert!(out.contains("read"));
815        assert!(out.contains("Output"));
816        assert!(out.contains("fn main"));
817    }
818}