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