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 serde::{Deserialize, Serialize};
197use std::cell::RefCell;
198use std::fmt::Write as _;
199use std::io::{self, BufRead, Write};
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/// `OpenCode` event types
212///
213/// Based on `OpenCode`'s actual NDJSON output format (`run.ts` lines 146-201), events include:
214/// - `step_start`: Step initialization with snapshot info
215/// - `step_finish`: Step completion with reason, cost, tokens
216/// - `tool_use`: Tool invocation with tool name, callID, and state (status, input, output)
217/// - `text`: Streaming text content
218/// - `error`: Session/API error events (from `session.error` in run.ts)
219///
220/// The top-level structure is: `{ "type": "...", "timestamp": ..., "sessionID": "...", "part": {...} }`
221/// For error events: `{ "type": "error", "timestamp": ..., "sessionID": "...", "error": {...} }`
222#[derive(Debug, Clone, Deserialize, Serialize)]
223pub struct OpenCodeEvent {
224    #[serde(rename = "type")]
225    pub(crate) event_type: String,
226    pub(crate) timestamp: Option<u64>,
227    #[serde(rename = "sessionID")]
228    pub(crate) session_id: Option<String>,
229    pub(crate) part: Option<OpenCodePart>,
230    /// Error information for error events (from `session.error` in run.ts line 201)
231    pub(crate) error: Option<OpenCodeError>,
232}
233
234/// Error information from error events
235///
236/// From `run.ts` lines 192-202, error events contain:
237/// - `name`: Error type name
238/// - `data`: Optional additional error data (may contain `message` field)
239#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct OpenCodeError {
241    /// Error type name
242    pub(crate) name: Option<String>,
243    /// Error message (direct or extracted from data.message)
244    pub(crate) message: Option<String>,
245    /// Additional error data (may contain `message` field)
246    pub(crate) data: Option<serde_json::Value>,
247}
248
249/// Nested part object containing the actual event data
250#[derive(Debug, Clone, Deserialize, Serialize)]
251pub struct OpenCodePart {
252    pub(crate) id: Option<String>,
253    #[serde(rename = "sessionID")]
254    pub(crate) session_id: Option<String>,
255    #[serde(rename = "messageID")]
256    pub(crate) message_id: Option<String>,
257    #[serde(rename = "type")]
258    pub(crate) part_type: Option<String>,
259    // For step_start events
260    pub(crate) snapshot: Option<String>,
261    // For step_finish events
262    pub(crate) reason: Option<String>,
263    pub(crate) cost: Option<f64>,
264    pub(crate) tokens: Option<OpenCodeTokens>,
265    // For tool_use events
266    #[serde(rename = "callID")]
267    pub(crate) call_id: Option<String>,
268    pub(crate) tool: Option<String>,
269    pub(crate) state: Option<OpenCodeToolState>,
270    // For text events
271    pub(crate) text: Option<String>,
272    // Time info for text events
273    pub(crate) time: Option<OpenCodeTime>,
274}
275
276/// Tool state containing status, input, and output
277///
278/// From `message-v2.ts` lines 221-287, the state is a discriminated union based on `status`:
279/// - `pending`: Tool call received, waiting to execute (`input`, `raw`)
280/// - `running`: Tool is executing (`input`, `title?`, `metadata?`, `time.start`)
281/// - `completed`: Tool finished successfully (`input`, `output`, `title`, `metadata`, `time`)
282/// - `error`: Tool failed (`input`, `error`, `metadata?`, `time`)
283#[derive(Debug, Clone, Deserialize, Serialize)]
284pub struct OpenCodeToolState {
285    /// Status: "pending", "running", "completed", or "error"
286    pub(crate) status: Option<String>,
287    /// Tool input parameters (tool-specific, e.g., `filePath` for read, `command` for bash)
288    pub(crate) input: Option<serde_json::Value>,
289    /// Tool output (only present when status is "completed")
290    pub(crate) output: Option<serde_json::Value>,
291    /// Human-readable title/description (e.g., filename for read operations)
292    pub(crate) title: Option<String>,
293    /// Additional metadata from tool execution
294    pub(crate) metadata: Option<serde_json::Value>,
295    /// Timing information
296    pub(crate) time: Option<OpenCodeTime>,
297    /// Error message (only present when status is "error")
298    pub(crate) error: Option<String>,
299}
300
301/// Token statistics from `step_finish` events
302#[derive(Debug, Clone, Deserialize, Serialize)]
303pub struct OpenCodeTokens {
304    pub(crate) input: Option<u64>,
305    pub(crate) output: Option<u64>,
306    pub(crate) reasoning: Option<u64>,
307    pub(crate) cache: Option<OpenCodeCache>,
308}
309
310/// Cache statistics
311#[derive(Debug, Clone, Deserialize, Serialize)]
312pub struct OpenCodeCache {
313    pub(crate) read: Option<u64>,
314    pub(crate) write: Option<u64>,
315}
316
317/// Time information
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct OpenCodeTime {
320    pub(crate) start: Option<u64>,
321    pub(crate) end: Option<u64>,
322}
323
324/// `OpenCode` event parser
325pub struct OpenCodeParser {
326    colors: Colors,
327    verbosity: Verbosity,
328    log_file: Option<String>,
329    display_name: String,
330    /// Unified streaming session for state tracking
331    streaming_session: Rc<RefCell<StreamingSession>>,
332    /// Terminal mode for output formatting
333    terminal_mode: RefCell<TerminalMode>,
334    /// Whether to show streaming quality metrics
335    show_streaming_metrics: bool,
336    /// Output printer for capturing or displaying output
337    printer: SharedPrinter,
338}
339
340impl OpenCodeParser {
341    pub(crate) fn new(colors: Colors, verbosity: Verbosity) -> Self {
342        Self::with_printer(colors, verbosity, super::printer::shared_stdout())
343    }
344
345    /// Create a new `OpenCodeParser` with a custom printer.
346    ///
347    /// # Arguments
348    ///
349    /// * `colors` - Colors for terminal output
350    /// * `verbosity` - Verbosity level for output
351    /// * `printer` - Shared printer for output
352    ///
353    /// # Returns
354    ///
355    /// A new `OpenCodeParser` instance
356    pub(crate) fn with_printer(
357        colors: Colors,
358        verbosity: Verbosity,
359        printer: SharedPrinter,
360    ) -> Self {
361        let verbose_warnings = matches!(verbosity, Verbosity::Debug);
362        let streaming_session = StreamingSession::new().with_verbose_warnings(verbose_warnings);
363
364        // Use the printer's is_terminal method to validate it's connected correctly
365        let _printer_is_terminal = printer.borrow().is_terminal();
366
367        Self {
368            colors,
369            verbosity,
370            log_file: None,
371            display_name: "OpenCode".to_string(),
372            streaming_session: Rc::new(RefCell::new(streaming_session)),
373            terminal_mode: RefCell::new(TerminalMode::detect()),
374            show_streaming_metrics: false,
375            printer,
376        }
377    }
378
379    pub(crate) const fn with_show_streaming_metrics(mut self, show: bool) -> Self {
380        self.show_streaming_metrics = show;
381        self
382    }
383
384    pub(crate) fn with_display_name(mut self, display_name: &str) -> Self {
385        self.display_name = display_name.to_string();
386        self
387    }
388
389    pub(crate) fn with_log_file(mut self, path: &str) -> Self {
390        self.log_file = Some(path.to_string());
391        self
392    }
393
394    #[cfg(test)]
395    pub fn with_terminal_mode(self, mode: TerminalMode) -> Self {
396        *self.terminal_mode.borrow_mut() = mode;
397        self
398    }
399
400    /// Create a new parser with a test printer.
401    ///
402    /// This is the primary entry point for integration tests that need
403    /// to capture parser output for verification.
404    #[cfg(any(test, feature = "test-utils"))]
405    pub fn with_printer_for_test(
406        colors: Colors,
407        verbosity: Verbosity,
408        printer: SharedPrinter,
409    ) -> Self {
410        Self::with_printer(colors, verbosity, printer)
411    }
412
413    /// Set the log file path for testing.
414    ///
415    /// This allows tests to verify log file content after parsing.
416    #[cfg(any(test, feature = "test-utils"))]
417    pub fn with_log_file_for_test(mut self, path: &str) -> Self {
418        self.log_file = Some(path.to_string());
419        self
420    }
421
422    /// Parse a stream for testing purposes.
423    ///
424    /// This exposes the internal `parse_stream` method for integration tests.
425    #[cfg(any(test, feature = "test-utils"))]
426    pub fn parse_stream_for_test<R: std::io::BufRead>(&self, reader: R) -> std::io::Result<()> {
427        self.parse_stream(reader)
428    }
429
430    /// Get a shared reference to the printer.
431    ///
432    /// This allows tests, monitoring, and other code to access the printer after parsing
433    /// to verify output content, check for duplicates, or capture output for analysis.
434    /// Only available with the `test-utils` feature.
435    ///
436    /// # Returns
437    ///
438    /// A clone of the shared printer reference (`Rc<RefCell<dyn Printable>>`)
439    #[cfg(feature = "test-utils")]
440    pub fn printer(&self) -> SharedPrinter {
441        Rc::clone(&self.printer)
442    }
443
444    /// Get streaming quality metrics from the current session.
445    ///
446    /// This provides insight into the deduplication and streaming quality of the
447    /// parsing session. Only available with the `test-utils` feature.
448    ///
449    /// # Returns
450    ///
451    /// A copy of the streaming quality metrics from the internal `StreamingSession`.
452    #[cfg(feature = "test-utils")]
453    pub fn streaming_metrics(&self) -> StreamingQualityMetrics {
454        self.streaming_session
455            .borrow()
456            .get_streaming_quality_metrics()
457    }
458
459    /// Parse and display a single `OpenCode` JSON event
460    ///
461    /// From OpenCode source (`run.ts` lines 146-201), the NDJSON format uses events with:
462    /// - `step_start`: Step initialization with snapshot info
463    /// - `step_finish`: Step completion with reason, cost, tokens
464    /// - `tool_use`: Tool invocation with tool name, callID, and state (status, input, output)
465    /// - `text`: Streaming text content
466    /// - `error`: Session/API error events
467    pub(crate) fn parse_event(&self, line: &str) -> Option<String> {
468        let event: OpenCodeEvent = if let Ok(e) = serde_json::from_str(line) {
469            e
470        } else {
471            let trimmed = line.trim();
472            if !trimmed.is_empty() && !trimmed.starts_with('{') {
473                return Some(format!("{trimmed}\n"));
474            }
475            return None;
476        };
477        let c = &self.colors;
478        let prefix = &self.display_name;
479
480        let output = match event.event_type.as_str() {
481            "step_start" => self.format_step_start_event(&event),
482            "step_finish" => self.format_step_finish_event(&event),
483            "tool_use" => self.format_tool_use_event(&event),
484            "text" => self.format_text_event(&event),
485            "error" => self.format_error_event(&event, line),
486            _ => {
487                // Unknown event type - use the generic formatter in verbose mode
488                format_unknown_json_event(line, prefix, *c, self.verbosity.is_verbose())
489            }
490        };
491
492        if output.is_empty() {
493            None
494        } else {
495            Some(output)
496        }
497    }
498
499    /// Format a `step_start` event
500    fn format_step_start_event(&self, event: &OpenCodeEvent) -> String {
501        let c = &self.colors;
502        let prefix = &self.display_name;
503
504        // Reset streaming state on new step
505        self.streaming_session.borrow_mut().on_message_start();
506
507        // Create unique step ID for duplicate detection
508        // Use part.message_id if available, otherwise combine session_id + part.id
509        let step_id = event.part.as_ref().map_or_else(
510            || {
511                event
512                    .session_id
513                    .clone()
514                    .unwrap_or_else(|| "unknown".to_string())
515            },
516            |part| {
517                part.message_id.as_ref().map_or_else(
518                    || {
519                        let session = event.session_id.as_deref().unwrap_or("unknown");
520                        let part_id = part.id.as_deref().unwrap_or("step");
521                        format!("{session}:{part_id}")
522                    },
523                    std::clone::Clone::clone,
524                )
525            },
526        );
527        self.streaming_session
528            .borrow_mut()
529            .set_current_message_id(Some(step_id));
530
531        let snapshot = event
532            .part
533            .as_ref()
534            .and_then(|p| p.snapshot.as_ref())
535            .map(|s| format!("({s:.8}...)"))
536            .unwrap_or_default();
537        format!(
538            "{}[{}]{} {}Step started{} {}{}{}\n",
539            c.dim(),
540            prefix,
541            c.reset(),
542            c.cyan(),
543            c.reset(),
544            c.dim(),
545            snapshot,
546            c.reset()
547        )
548    }
549
550    /// Format a `step_finish` event
551    fn format_step_finish_event(&self, event: &OpenCodeEvent) -> String {
552        let c = &self.colors;
553        let prefix = &self.display_name;
554
555        // Check for duplicate final message using message ID or fallback to streaming content check
556        let session = self.streaming_session.borrow();
557        let is_duplicate = session.get_current_message_id().map_or_else(
558            || session.has_any_streamed_content(),
559            |message_id| session.is_duplicate_final_message(message_id),
560        );
561        let was_streaming = session.has_any_streamed_content();
562        let metrics = session.get_streaming_quality_metrics();
563        drop(session);
564
565        // Finalize the message (this marks it as displayed)
566        let _was_in_block = self.streaming_session.borrow_mut().on_message_stop();
567
568        event.part.as_ref().map_or_else(String::new, |part| {
569            let reason = part.reason.as_deref().unwrap_or("unknown");
570            let cost = part.cost.unwrap_or(0.0);
571
572            let tokens_str = part.tokens.as_ref().map_or_else(String::new, |tokens| {
573                let input = tokens.input.unwrap_or(0);
574                let output = tokens.output.unwrap_or(0);
575                let reasoning = tokens.reasoning.unwrap_or(0);
576                let cache_read = tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0);
577                if reasoning > 0 {
578                    format!("in:{input} out:{output} reason:{reasoning} cache:{cache_read}")
579                } else if cache_read > 0 {
580                    format!("in:{input} out:{output} cache:{cache_read}")
581                } else {
582                    format!("in:{input} out:{output}")
583                }
584            });
585
586            let is_success = reason == "tool-calls" || reason == "end_turn";
587            let icon = if is_success { CHECK } else { CROSS };
588            let color = if is_success { c.green() } else { c.yellow() };
589
590            // Add final newline if we were streaming text
591            let terminal_mode = *self.terminal_mode.borrow();
592            let newline_prefix = if is_duplicate || was_streaming {
593                let completion = TextDeltaRenderer::render_completion(terminal_mode);
594                let show_metrics = (self.verbosity.is_debug() || self.show_streaming_metrics)
595                    && metrics.total_deltas > 0;
596                if show_metrics {
597                    format!("{}\n{}", completion, metrics.format(*c))
598                } else {
599                    completion
600                }
601            } else {
602                String::new()
603            };
604
605            let mut out = format!(
606                "{}{}[{}]{} {}{} Step finished{} {}({}",
607                newline_prefix,
608                c.dim(),
609                prefix,
610                c.reset(),
611                color,
612                icon,
613                c.reset(),
614                c.dim(),
615                reason
616            );
617            if !tokens_str.is_empty() {
618                let _ = write!(out, ", {tokens_str}");
619            }
620            if cost > 0.0 {
621                let _ = write!(out, ", ${cost:.4}");
622            }
623            let _ = writeln!(out, "){}", c.reset());
624            out
625        })
626    }
627
628    /// Format a `tool_use` event
629    ///
630    /// Based on OpenCode source (`run.ts` lines 163-174, `message-v2.ts` lines 221-287):
631    /// - Shows tool name with status-specific icon and color
632    /// - Status handling: pending (…), running (►), completed (✓), error (✗)
633    /// - Title/description when available (from `state.title`)
634    /// - Tool-specific input formatting based on tool type
635    /// - Tool output/results shown at Normal+ verbosity
636    /// - Error messages shown in red when status is "error"
637    fn format_tool_use_event(&self, event: &OpenCodeEvent) -> String {
638        let c = &self.colors;
639        let prefix = &self.display_name;
640
641        event.part.as_ref().map_or_else(String::new, |part| {
642            let tool_name = part.tool.as_deref().unwrap_or("unknown");
643            let status = part
644                .state
645                .as_ref()
646                .and_then(|s| s.status.as_deref())
647                .unwrap_or("pending");
648            let title = part.state.as_ref().and_then(|s| s.title.as_deref());
649
650            // Status-specific icon and color based on ToolState variants from message-v2.ts
651            // Statuses: "pending", "running", "completed", "error"
652            let (icon, color) = match status {
653                "completed" => (CHECK, c.green()),
654                "error" => (CROSS, c.red()),
655                "running" => ('►', c.cyan()),
656                _ => ('…', c.yellow()), // "pending" or unknown
657            };
658
659            let mut out = format!(
660                "{}[{}]{} {}Tool{}: {}{}{} {}{}{}\n",
661                c.dim(),
662                prefix,
663                c.reset(),
664                c.magenta(),
665                c.reset(),
666                c.bold(),
667                tool_name,
668                c.reset(),
669                color,
670                icon,
671                c.reset()
672            );
673
674            // Show title if available (from state.title)
675            if let Some(t) = title {
676                let limit = self.verbosity.truncate_limit("text");
677                let preview = truncate_text(t, limit);
678                let _ = writeln!(
679                    out,
680                    "{}[{}]{} {}  └─ {}{}",
681                    c.dim(),
682                    prefix,
683                    c.reset(),
684                    c.dim(),
685                    preview,
686                    c.reset()
687                );
688            }
689
690            // Show tool input at Normal+ verbosity with tool-specific formatting
691            if self.verbosity.show_tool_input() {
692                if let Some(ref state) = part.state {
693                    if let Some(ref input_val) = state.input {
694                        let input_str = Self::format_tool_specific_input(tool_name, input_val);
695                        let limit = self.verbosity.truncate_limit("tool_input");
696                        let preview = truncate_text(&input_str, limit);
697                        if !preview.is_empty() {
698                            let _ = writeln!(
699                                out,
700                                "{}[{}]{} {}  └─ {}{}",
701                                c.dim(),
702                                prefix,
703                                c.reset(),
704                                c.dim(),
705                                preview,
706                                c.reset()
707                            );
708                        }
709                    }
710                }
711            }
712
713            // Show error message when status is "error"
714            if status == "error" {
715                if let Some(ref state) = part.state {
716                    if let Some(ref error_msg) = state.error {
717                        let limit = self.verbosity.truncate_limit("tool_result");
718                        let preview = truncate_text(error_msg, limit);
719                        let _ = writeln!(
720                            out,
721                            "{}[{}]{} {}  └─ {}Error:{} {}{}{}",
722                            c.dim(),
723                            prefix,
724                            c.reset(),
725                            c.red(),
726                            c.bold(),
727                            c.reset(),
728                            c.red(),
729                            preview,
730                            c.reset()
731                        );
732                    }
733                }
734            }
735
736            // Show tool output at Normal+ verbosity when completed
737            // (Changed from verbose-only to match OpenCode's interactive mode behavior)
738            if self.verbosity.show_tool_input() && status == "completed" {
739                if let Some(ref state) = part.state {
740                    if let Some(ref output_val) = state.output {
741                        let output_str = match output_val {
742                            serde_json::Value::String(s) => s.clone(),
743                            other => other.to_string(),
744                        };
745                        if !output_str.is_empty() {
746                            let limit = self.verbosity.truncate_limit("tool_result");
747                            // Format multi-line output with proper indentation
748                            self.format_tool_output(&mut out, &output_str, limit, prefix, *c);
749                        }
750                    }
751                }
752            }
753            out
754        })
755    }
756
757    /// Format tool output with proper multi-line handling
758    ///
759    /// For single-line outputs, shows inline. For multi-line outputs (like file contents),
760    /// shows only the first few lines as a preview.
761    fn format_tool_output(
762        &self,
763        out: &mut String,
764        output: &str,
765        limit: usize,
766        prefix: &str,
767        c: Colors,
768    ) {
769        use crate::config::truncation::MAX_OUTPUT_LINES;
770
771        let lines: Vec<&str> = output.lines().collect();
772        let is_multiline = lines.len() > 1;
773
774        if is_multiline {
775            // Multi-line output: show header then first few lines
776            let _ = writeln!(
777                out,
778                "{}[{}]{} {}  └─ Output:{}",
779                c.dim(),
780                prefix,
781                c.reset(),
782                c.cyan(),
783                c.reset()
784            );
785
786            let mut chars_used = 0;
787            let indent = format!("{}[{}]{}     ", c.dim(), prefix, c.reset());
788
789            for (lines_shown, line) in lines.iter().enumerate() {
790                // Stop if we've shown enough lines OR exceeded char limit
791                if lines_shown >= MAX_OUTPUT_LINES || chars_used + line.len() > limit {
792                    let remaining = lines.len() - lines_shown;
793                    if remaining > 0 {
794                        let _ = writeln!(out, "{}{}...({} more lines)", indent, c.dim(), remaining);
795                    }
796                    break;
797                }
798                let _ = writeln!(out, "{}{}{}{}", indent, c.dim(), line, c.reset());
799                chars_used += line.len() + 1;
800            }
801        } else {
802            // Single-line output: show inline
803            let preview = truncate_text(output, limit);
804            if !preview.is_empty() {
805                let _ = writeln!(
806                    out,
807                    "{}[{}]{} {}  └─ Output:{} {}",
808                    c.dim(),
809                    prefix,
810                    c.reset(),
811                    c.cyan(),
812                    c.reset(),
813                    preview
814                );
815            }
816        }
817    }
818
819    /// Format tool input based on tool type
820    ///
821    /// From OpenCode source, each tool has specific input fields:
822    /// - `read`: `filePath`, `offset?`, `limit?`
823    /// - `bash`: `command`, `timeout?`
824    /// - `write`: `filePath`, `content`
825    /// - `edit`: `filePath`, ...
826    /// - `glob`: `pattern`, `path?`
827    /// - `grep`: `pattern`, `path?`, `include?`
828    /// - `fetch`: `url`, `format?`, `timeout?`
829    fn format_tool_specific_input(tool_name: &str, input: &serde_json::Value) -> String {
830        let obj = match input.as_object() {
831            Some(o) => o,
832            None => return format_tool_input(input),
833        };
834
835        match tool_name {
836            "read" | "view" => {
837                // Primary: filePath, optional: offset, limit
838                let file_path = obj.get("filePath").and_then(|v| v.as_str()).unwrap_or("");
839                let mut result = file_path.to_string();
840                if let Some(offset) = obj.get("offset").and_then(|v| v.as_u64()) {
841                    result.push_str(&format!(" (offset: {offset})"));
842                }
843                if let Some(limit) = obj.get("limit").and_then(|v| v.as_u64()) {
844                    result.push_str(&format!(" (limit: {limit})"));
845                }
846                result
847            }
848            "bash" => {
849                // Primary: command
850                obj.get("command")
851                    .and_then(|v| v.as_str())
852                    .unwrap_or("")
853                    .to_string()
854            }
855            "write" => {
856                // Primary: filePath (don't show content in summary)
857                let file_path = obj.get("filePath").and_then(|v| v.as_str()).unwrap_or("");
858                let content_len = obj
859                    .get("content")
860                    .and_then(|v| v.as_str())
861                    .map(|s| s.len())
862                    .unwrap_or(0);
863                if content_len > 0 {
864                    format!("{file_path} ({content_len} bytes)")
865                } else {
866                    file_path.to_string()
867                }
868            }
869            "edit" => {
870                // Primary: filePath
871                obj.get("filePath")
872                    .and_then(|v| v.as_str())
873                    .unwrap_or("")
874                    .to_string()
875            }
876            "glob" => {
877                // Primary: pattern, optional: path
878                let pattern = obj.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
879                let path = obj.get("path").and_then(|v| v.as_str());
880                if let Some(p) = path {
881                    format!("{pattern} in {p}")
882                } else {
883                    pattern.to_string()
884                }
885            }
886            "grep" => {
887                // Primary: pattern, optional: path, include
888                let pattern = obj.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
889                let mut result = format!("/{pattern}/");
890                if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
891                    result.push_str(&format!(" in {path}"));
892                }
893                if let Some(include) = obj.get("include").and_then(|v| v.as_str()) {
894                    result.push_str(&format!(" ({include})"));
895                }
896                result
897            }
898            "fetch" | "webfetch" => {
899                // Primary: url, optional: format
900                let url = obj.get("url").and_then(|v| v.as_str()).unwrap_or("");
901                let format = obj.get("format").and_then(|v| v.as_str());
902                if let Some(f) = format {
903                    format!("{url} ({f})")
904                } else {
905                    url.to_string()
906                }
907            }
908            "todowrite" | "todoread" => {
909                // Show count of todos if available
910                if let Some(todos) = obj.get("todos").and_then(|v| v.as_array()) {
911                    format!("{} items", todos.len())
912                } else {
913                    format_tool_input(input)
914                }
915            }
916            _ => {
917                // Fallback to generic formatting
918                format_tool_input(input)
919            }
920        }
921    }
922
923    /// Format a `text` event
924    fn format_text_event(&self, event: &OpenCodeEvent) -> String {
925        let c = &self.colors;
926        let prefix = &self.display_name;
927
928        if let Some(ref part) = event.part {
929            if let Some(ref text) = part.text {
930                // Accumulate streaming text using StreamingSession
931                let (show_prefix, accumulated_text) = {
932                    let mut session = self.streaming_session.borrow_mut();
933                    let show_prefix = session.on_text_delta_key("main", text);
934                    // Get accumulated text for streaming display
935                    let accumulated_text = session
936                        .get_accumulated(ContentType::Text, "main")
937                        .unwrap_or("")
938                        .to_string();
939                    (show_prefix, accumulated_text)
940                };
941
942                // Show delta in real-time (both verbose and normal mode)
943                let limit = self.verbosity.truncate_limit("text");
944                let preview = truncate_text(&accumulated_text, limit);
945
946                // Use TextDeltaRenderer for consistent rendering across all parsers
947                let terminal_mode = *self.terminal_mode.borrow();
948                if show_prefix {
949                    // First delta: use renderer with prefix
950                    return TextDeltaRenderer::render_first_delta(
951                        &preview,
952                        prefix,
953                        *c,
954                        terminal_mode,
955                    );
956                }
957                // Subsequent deltas: use renderer for in-place update
958                return TextDeltaRenderer::render_subsequent_delta(
959                    &preview,
960                    prefix,
961                    *c,
962                    terminal_mode,
963                );
964            }
965        }
966        String::new()
967    }
968
969    /// Format an `error` event
970    ///
971    /// From OpenCode source (`run.ts` lines 192-202), error events are emitted for session errors:
972    /// ```typescript
973    /// if (event.type === "session.error") {
974    ///   let err = String(props.error.name)
975    ///   if ("data" in props.error && props.error.data && "message" in props.error.data) {
976    ///     err = String(props.error.data.message)
977    ///   }
978    ///   outputJsonEvent("error", { error: props.error })
979    /// }
980    /// ```
981    fn format_error_event(&self, event: &OpenCodeEvent, raw_line: &str) -> String {
982        let c = &self.colors;
983        let prefix = &self.display_name;
984
985        // Try to extract error message from the event
986        let error_msg = event.error.as_ref().map_or_else(
987            || {
988                // Fallback: try to extract from raw JSON
989                if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw_line) {
990                    json.get("error")
991                        .and_then(|e| {
992                            // Try data.message first (as in run.ts)
993                            e.get("data")
994                                .and_then(|d| d.get("message"))
995                                .and_then(|m| m.as_str())
996                                .map(String::from)
997                                // Then try direct message
998                                .or_else(|| {
999                                    e.get("message").and_then(|m| m.as_str()).map(String::from)
1000                                })
1001                                // Then try name
1002                                .or_else(|| {
1003                                    e.get("name").and_then(|n| n.as_str()).map(String::from)
1004                                })
1005                        })
1006                        .unwrap_or_else(|| "Unknown error".to_string())
1007                } else {
1008                    "Unknown error".to_string()
1009                }
1010            },
1011            |err| {
1012                // Try data.message first (as in run.ts)
1013                err.data
1014                    .as_ref()
1015                    .and_then(|d| d.get("message"))
1016                    .and_then(|m| m.as_str())
1017                    .map(String::from)
1018                    // Then try direct message
1019                    .or_else(|| err.message.clone())
1020                    // Then try name
1021                    .or_else(|| err.name.clone())
1022                    .unwrap_or_else(|| "Unknown error".to_string())
1023            },
1024        );
1025
1026        let limit = self.verbosity.truncate_limit("text");
1027        let preview = truncate_text(&error_msg, limit);
1028
1029        format!(
1030            "{}[{}]{} {}{} Error:{} {}{}{}\n",
1031            c.dim(),
1032            prefix,
1033            c.reset(),
1034            c.red(),
1035            CROSS,
1036            c.reset(),
1037            c.red(),
1038            preview,
1039            c.reset()
1040        )
1041    }
1042
1043    /// Check if an `OpenCode` event is a control event (state management with no user output)
1044    ///
1045    /// Control events are valid JSON that represent state transitions rather than
1046    /// user-facing content. They should be tracked separately from "ignored" events
1047    /// to avoid false health warnings.
1048    fn is_control_event(event: &OpenCodeEvent) -> bool {
1049        match event.event_type.as_str() {
1050            // Step lifecycle events are control events
1051            "step_start" | "step_finish" => true,
1052            _ => false,
1053        }
1054    }
1055
1056    /// Check if an `OpenCode` event is a partial/delta event (streaming content displayed incrementally)
1057    ///
1058    /// Partial events represent streaming text deltas that are shown to the user
1059    /// in real-time. These should be tracked separately to avoid inflating "ignored" percentages.
1060    fn is_partial_event(event: &OpenCodeEvent) -> bool {
1061        match event.event_type.as_str() {
1062            // Text events produce streaming content
1063            "text" => true,
1064            _ => false,
1065        }
1066    }
1067
1068    /// Parse a stream of `OpenCode` NDJSON events
1069    pub(crate) fn parse_stream<R: BufRead>(&self, mut reader: R) -> io::Result<()> {
1070        use super::incremental_parser::IncrementalNdjsonParser;
1071
1072        let c = &self.colors;
1073        let monitor = HealthMonitor::new("OpenCode");
1074        let mut log_writer = self.log_file.as_ref().and_then(|log_path| {
1075            std::fs::OpenOptions::new()
1076                .create(true)
1077                .append(true)
1078                .open(log_path)
1079                .ok()
1080                .map(std::io::BufWriter::new)
1081        });
1082
1083        // Use incremental parser for true real-time streaming
1084        // This processes JSON as soon as it's complete, not waiting for newlines
1085        let mut incremental_parser = IncrementalNdjsonParser::new();
1086        let mut byte_buffer = Vec::new();
1087
1088        loop {
1089            // Read available bytes
1090            byte_buffer.clear();
1091            let chunk = reader.fill_buf()?;
1092            if chunk.is_empty() {
1093                break;
1094            }
1095
1096            // Process all bytes immediately
1097            byte_buffer.extend_from_slice(chunk);
1098            let consumed = chunk.len();
1099            reader.consume(consumed);
1100
1101            // Feed bytes to incremental parser
1102            let json_events = incremental_parser.feed(&byte_buffer);
1103
1104            // Process each complete JSON event immediately
1105            for line in json_events {
1106                let trimmed = line.trim();
1107                if trimmed.is_empty() {
1108                    continue;
1109                }
1110
1111                if self.verbosity.is_debug() {
1112                    let mut printer = self.printer.borrow_mut();
1113                    writeln!(
1114                        printer,
1115                        "{}[DEBUG]{} {}{}{}",
1116                        c.dim(),
1117                        c.reset(),
1118                        c.dim(),
1119                        &line,
1120                        c.reset()
1121                    )?;
1122                    printer.flush()?;
1123                }
1124
1125                // Parse the event once - parse_event handles malformed JSON by returning None
1126                match self.parse_event(&line) {
1127                    Some(output) => {
1128                        // Check if this is a partial/delta event (streaming content)
1129                        if trimmed.starts_with('{') {
1130                            if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
1131                                if Self::is_partial_event(&event) {
1132                                    monitor.record_partial_event();
1133                                } else {
1134                                    monitor.record_parsed();
1135                                }
1136                            } else {
1137                                monitor.record_parsed();
1138                            }
1139                        } else {
1140                            monitor.record_parsed();
1141                        }
1142                        // Write output to printer
1143                        let mut printer = self.printer.borrow_mut();
1144                        write!(printer, "{output}")?;
1145                        printer.flush()?;
1146                    }
1147                    None => {
1148                        // Check if this was a control event (state management with no user output)
1149                        if trimmed.starts_with('{') {
1150                            if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
1151                                if Self::is_control_event(&event) {
1152                                    monitor.record_control_event();
1153                                } else {
1154                                    // Valid JSON but not a control event - track as unknown
1155                                    monitor.record_unknown_event();
1156                                }
1157                            } else {
1158                                // Failed to deserialize - track as parse error
1159                                monitor.record_parse_error();
1160                            }
1161                        } else {
1162                            monitor.record_ignored();
1163                        }
1164                    }
1165                }
1166
1167                if let Some(ref mut file) = log_writer {
1168                    writeln!(file, "{line}")?;
1169                }
1170            }
1171        }
1172
1173        // Handle any remaining buffered data when the stream ends.
1174        // Only process if it's valid JSON - incomplete buffered data should be skipped.
1175        if let Some(remaining) = incremental_parser.finish() {
1176            let trimmed = remaining.trim();
1177            if !trimmed.is_empty()
1178                && trimmed.starts_with('{')
1179                && serde_json::from_str::<OpenCodeEvent>(&remaining).is_ok()
1180            {
1181                // Process the remaining event
1182                if let Some(output) = self.parse_event(&remaining) {
1183                    monitor.record_parsed();
1184                    let mut printer = self.printer.borrow_mut();
1185                    write!(printer, "{output}")?;
1186                    printer.flush()?;
1187                }
1188                // Write to log file
1189                if let Some(ref mut file) = log_writer {
1190                    writeln!(file, "{remaining}")?;
1191                }
1192            }
1193        }
1194
1195        if let Some(ref mut file) = log_writer {
1196            file.flush()?;
1197            // Ensure data is written to disk before continuing
1198            // This prevents race conditions where extraction runs before OS commits writes
1199            let _ = file.get_mut().sync_all();
1200        }
1201        if let Some(warning) = monitor.check_and_warn(*c) {
1202            let mut printer = self.printer.borrow_mut();
1203            writeln!(printer, "{warning}")?;
1204        }
1205        Ok(())
1206    }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212
1213    #[test]
1214    fn test_opencode_step_start() {
1215        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1216        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"}}"#;
1217        let output = parser.parse_event(json);
1218        assert!(output.is_some());
1219        let out = output.unwrap();
1220        assert!(out.contains("Step started"));
1221        assert!(out.contains("5d36aa03"));
1222    }
1223
1224    #[test]
1225    fn test_opencode_step_finish() {
1226        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1227        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}}}}"#;
1228        let output = parser.parse_event(json);
1229        assert!(output.is_some());
1230        let out = output.unwrap();
1231        assert!(out.contains("Step finished"));
1232        assert!(out.contains("tool-calls"));
1233        assert!(out.contains("in:108"));
1234        assert!(out.contains("out:151"));
1235        assert!(out.contains("cache:11236"));
1236    }
1237
1238    #[test]
1239    fn test_opencode_tool_use_completed() {
1240        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1241        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"}}}"#;
1242        let output = parser.parse_event(json);
1243        assert!(output.is_some());
1244        let out = output.unwrap();
1245        assert!(out.contains("Tool"));
1246        assert!(out.contains("read"));
1247        assert!(out.contains("✓")); // completed icon
1248        assert!(out.contains("PLAN.md"));
1249    }
1250
1251    #[test]
1252    fn test_opencode_tool_use_pending() {
1253        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1254        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"}}}}"#;
1255        let output = parser.parse_event(json);
1256        assert!(output.is_some());
1257        let out = output.unwrap();
1258        assert!(out.contains("Tool"));
1259        assert!(out.contains("bash"));
1260        assert!(out.contains("…")); // pending icon (WAIT)
1261    }
1262
1263    #[test]
1264    fn test_opencode_tool_use_shows_input() {
1265        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1266        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"}}}}"#;
1267        let output = parser.parse_event(json);
1268        assert!(output.is_some());
1269        let out = output.unwrap();
1270        assert!(out.contains("Tool"));
1271        assert!(out.contains("read"));
1272        assert!(out.contains("/Users/test/file.rs"));
1273    }
1274
1275    #[test]
1276    fn test_opencode_text_event() {
1277        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1278        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}}}"#;
1279        let output = parser.parse_event(json);
1280        assert!(output.is_some());
1281        let out = output.unwrap();
1282        assert!(out.contains("I'll start by reading the plan"));
1283    }
1284
1285    #[test]
1286    fn test_opencode_unknown_event_ignored() {
1287        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1288        let json = r#"{"type":"unknown_event","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{}}"#;
1289        let output = parser.parse_event(json);
1290        // Unknown events should return None
1291        assert!(output.is_none());
1292    }
1293
1294    #[test]
1295    fn test_opencode_parser_non_json_passthrough() {
1296        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1297        let output = parser.parse_event("Error: something went wrong");
1298        assert!(output.is_some());
1299        assert!(output.unwrap().contains("Error: something went wrong"));
1300    }
1301
1302    #[test]
1303    fn test_opencode_parser_malformed_json_ignored() {
1304        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1305        let output = parser.parse_event("{invalid json here}");
1306        assert!(output.is_none());
1307    }
1308
1309    #[test]
1310    fn test_opencode_step_finish_with_cost() {
1311        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1312        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}}}}"#;
1313        let output = parser.parse_event(json);
1314        assert!(output.is_some());
1315        let out = output.unwrap();
1316        assert!(out.contains("Step finished"));
1317        assert!(out.contains("end_turn"));
1318        assert!(out.contains("$0.0025"));
1319    }
1320
1321    #[test]
1322    fn test_opencode_tool_verbose_shows_output() {
1323        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Verbose);
1324        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\"); }"}}}"#;
1325        let output = parser.parse_event(json);
1326        assert!(output.is_some());
1327        let out = output.unwrap();
1328        assert!(out.contains("Tool"));
1329        assert!(out.contains("read"));
1330        assert!(out.contains("Output"));
1331        assert!(out.contains("fn main"));
1332    }
1333
1334    #[test]
1335    fn test_opencode_tool_running_status() {
1336        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1337        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","type":"tool","tool":"bash","state":{"status":"running","input":{"command":"npm test"},"time":{"start":1768191346712}}}}"#;
1338        let output = parser.parse_event(json);
1339        assert!(output.is_some());
1340        let out = output.unwrap();
1341        assert!(out.contains("Tool"));
1342        assert!(out.contains("bash"));
1343        assert!(out.contains("►")); // running icon
1344    }
1345
1346    #[test]
1347    fn test_opencode_tool_error_status() {
1348        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1349        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","type":"tool","tool":"bash","state":{"status":"error","input":{"command":"invalid_cmd"},"error":"Command not found: invalid_cmd","time":{"start":1768191346712,"end":1768191346800}}}}"#;
1350        let output = parser.parse_event(json);
1351        assert!(output.is_some());
1352        let out = output.unwrap();
1353        assert!(out.contains("Tool"));
1354        assert!(out.contains("bash"));
1355        assert!(out.contains("✗")); // error icon
1356        assert!(out.contains("Error"));
1357        assert!(out.contains("Command not found"));
1358    }
1359
1360    #[test]
1361    fn test_opencode_error_event() {
1362        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1363        let json = r#"{"type":"error","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","error":{"name":"APIError","message":"Rate limit exceeded"}}"#;
1364        let output = parser.parse_event(json);
1365        assert!(output.is_some());
1366        let out = output.unwrap();
1367        assert!(out.contains("Error"));
1368        assert!(out.contains("Rate limit exceeded"));
1369    }
1370
1371    #[test]
1372    fn test_opencode_error_event_with_data_message() {
1373        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1374        // Error with data.message (as in run.ts lines 197-199)
1375        let json = r#"{"type":"error","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","error":{"name":"ProviderError","data":{"message":"Invalid API key"}}}"#;
1376        let output = parser.parse_event(json);
1377        assert!(output.is_some());
1378        let out = output.unwrap();
1379        assert!(out.contains("Error"));
1380        assert!(out.contains("Invalid API key"));
1381    }
1382
1383    #[test]
1384    fn test_opencode_tool_bash_formatting() {
1385        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1386        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"git status"},"output":"On branch main","title":"git status"}}}"#;
1387        let output = parser.parse_event(json);
1388        assert!(output.is_some());
1389        let out = output.unwrap();
1390        assert!(out.contains("bash"));
1391        assert!(out.contains("git status"));
1392    }
1393
1394    #[test]
1395    fn test_opencode_tool_glob_formatting() {
1396        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1397        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"type":"tool","tool":"glob","state":{"status":"completed","input":{"pattern":"**/*.rs","path":"src"},"output":"found 10 files","title":"**/*.rs"}}}"#;
1398        let output = parser.parse_event(json);
1399        assert!(output.is_some());
1400        let out = output.unwrap();
1401        assert!(out.contains("glob"));
1402        assert!(out.contains("**/*.rs"));
1403        assert!(out.contains("in src"));
1404    }
1405
1406    #[test]
1407    fn test_opencode_tool_grep_formatting() {
1408        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1409        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"type":"tool","tool":"grep","state":{"status":"completed","input":{"pattern":"TODO","path":"src","include":"*.rs"},"output":"3 matches","title":"TODO"}}}"#;
1410        let output = parser.parse_event(json);
1411        assert!(output.is_some());
1412        let out = output.unwrap();
1413        assert!(out.contains("grep"));
1414        assert!(out.contains("/TODO/"));
1415        assert!(out.contains("in src"));
1416        assert!(out.contains("(*.rs)"));
1417    }
1418
1419    #[test]
1420    fn test_opencode_tool_write_formatting() {
1421        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1422        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"type":"tool","tool":"write","state":{"status":"completed","input":{"filePath":"test.txt","content":"Hello World"},"output":"wrote 11 bytes","title":"test.txt"}}}"#;
1423        let output = parser.parse_event(json);
1424        assert!(output.is_some());
1425        let out = output.unwrap();
1426        assert!(out.contains("write"));
1427        assert!(out.contains("test.txt"));
1428        assert!(out.contains("11 bytes"));
1429    }
1430
1431    #[test]
1432    fn test_opencode_tool_read_with_offset_limit() {
1433        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1434        let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"large.txt","offset":100,"limit":50},"output":"content...","title":"large.txt"}}}"#;
1435        let output = parser.parse_event(json);
1436        assert!(output.is_some());
1437        let out = output.unwrap();
1438        assert!(out.contains("read"));
1439        assert!(out.contains("large.txt"));
1440        assert!(out.contains("offset: 100"));
1441        assert!(out.contains("limit: 50"));
1442    }
1443}