Skip to main content

ralph_workflow/json_parser/
opencode.rs

1//! `OpenCode` event parser implementation
2//!
3//! This module handles parsing and displaying `OpenCode` NDJSON event streams.
4//!
5//! # Source Code Reference
6//!
7//! This parser is based on analysis of the OpenCode source code from:
8//! - **Repository**: <https://github.com/anomalyco/opencode>
9//! - **Key source files**:
10//!   - `/packages/opencode/src/cli/cmd/run.ts` - NDJSON output generation
11//!   - `/packages/opencode/src/session/message-v2.ts` - Message part type definitions
12//!
13//! # NDJSON Event Format
14//!
15//! OpenCode outputs NDJSON (newline-delimited JSON) events via `--format json`.
16//! Each event has the structure:
17//!
18//! ```json
19//! {
20//!   "type": "step_start" | "step_finish" | "tool_use" | "text" | "error",
21//!   "timestamp": 1768191337567,
22//!   "sessionID": "ses_44f9562d4ffe",
23//!   ...event-specific data (usually in "part" field)
24//! }
25//! ```
26//!
27//! From `run.ts` lines 146-201, the event types are generated as:
28//! ```typescript
29//! outputJsonEvent("tool_use", { part })    // Tool invocations
30//! outputJsonEvent("step_start", { part })  // Step initialization
31//! outputJsonEvent("step_finish", { part }) // Step completion
32//! outputJsonEvent("text", { part })        // Streaming text content
33//! outputJsonEvent("error", { error })      // Error events
34//! ```
35//!
36//! # Part Type Definitions
37//!
38//! ## StepStartPart (`message-v2.ts` lines 194-200)
39//!
40//! ```typescript
41//! {
42//!   id: string,
43//!   sessionID: string,
44//!   messageID: string,
45//!   type: "step-start",
46//!   snapshot: string | undefined  // Git commit hash for state snapshot
47//! }
48//! ```
49//!
50//! ## StepFinishPart (`message-v2.ts` lines 202-219)
51//!
52//! ```typescript
53//! {
54//!   id: string,
55//!   sessionID: string,
56//!   messageID: string,
57//!   type: "step-finish",
58//!   reason: string,               // "tool-calls", "end_turn", etc.
59//!   snapshot: string | undefined,
60//!   cost: number,                 // Cost in USD
61//!   tokens: {
62//!     input: number,
63//!     output: number,
64//!     reasoning: number,
65//!     cache: { read: number, write: number }
66//!   }
67//! }
68//! ```
69//!
70//! ## TextPart (`message-v2.ts` lines 62-77)
71//!
72//! ```typescript
73//! {
74//!   id: string,
75//!   sessionID: string,
76//!   messageID: string,
77//!   type: "text",
78//!   text: string,
79//!   synthetic?: boolean,
80//!   ignored?: boolean,
81//!   time?: { start: number, end?: number },
82//!   metadata?: Record<string, any>
83//! }
84//! ```
85//!
86//! ## ToolPart (`message-v2.ts` lines 289-298)
87//!
88//! ```typescript
89//! {
90//!   id: string,
91//!   sessionID: string,
92//!   messageID: string,
93//!   type: "tool",
94//!   callID: string,
95//!   tool: string,  // Tool name: "read", "bash", "write", "edit", "glob", "grep", etc.
96//!   state: ToolState,
97//!   metadata?: Record<string, any>
98//! }
99//! ```
100//!
101//! ## ToolState Variants (`message-v2.ts` lines 221-287)
102//!
103//! The `state` field is a discriminated union based on `status`:
104//!
105//! ### Pending (`status: "pending"`)
106//! ```typescript
107//! { status: "pending", input: Record<string, any>, raw: string }
108//! ```
109//!
110//! ### Running (`status: "running"`)
111//! ```typescript
112//! {
113//!   status: "running",
114//!   input: Record<string, any>,
115//!   title?: string,
116//!   metadata?: Record<string, any>,
117//!   time: { start: number }
118//! }
119//! ```
120//!
121//! ### Completed (`status: "completed"`)
122//! ```typescript
123//! {
124//!   status: "completed",
125//!   input: Record<string, any>,
126//!   output: string,
127//!   title: string,
128//!   metadata: Record<string, any>,
129//!   time: { start: number, end: number, compacted?: number },
130//!   attachments?: FilePart[]
131//! }
132//! ```
133//!
134//! ### Error (`status: "error"`)
135//! ```typescript
136//! {
137//!   status: "error",
138//!   input: Record<string, any>,
139//!   error: string,
140//!   metadata?: Record<string, any>,
141//!   time: { start: number, end: number }
142//! }
143//! ```
144//!
145//! ## Tool Input Parameters
146//!
147//! The `state.input` object contains tool-specific parameters:
148//!
149//! | Tool    | Input Fields                                         |
150//! |---------|-----------------------------------------------------|
151//! | `read`  | `{ filePath: string, offset?: number, limit?: number }` |
152//! | `bash`  | `{ command: string, timeout?: number }`              |
153//! | `write` | `{ filePath: string, content: string }`              |
154//! | `edit`  | `{ filePath: string, ... }`                          |
155//! | `glob`  | `{ pattern: string, path?: string }`                 |
156//! | `grep`  | `{ pattern: string, path?: string, include?: string }` |
157//! | `fetch` | `{ url: string, format?: string, timeout?: number }` |
158//!
159//! From `run.ts` line 168, the title fallback is:
160//! ```typescript
161//! const title = part.state.title ||
162//!   (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
163//! ```
164//!
165//! # Streaming Output Behavior
166//!
167//! This parser implements real-time streaming output for text deltas. When content
168//! arrives in multiple chunks (via `text` events), the parser:
169//!
170//! 1. **Accumulates** text deltas from each chunk into a buffer
171//! 2. **Displays** the accumulated text after each chunk
172//! 3. **Uses carriage return (`\r`) and line clearing (`\x1b[2K`)** to rewrite the entire line,
173//!    creating an updating effect that shows the content building up in real-time
174//! 4. **Shows prefix on every delta**, rewriting the entire line each time (industry standard)
175//!
176//! Example output sequence for streaming "Hello World" in two chunks:
177//! ```text
178//! [OpenCode] Hello\r       (first text event with prefix, no newline)
179//! \x1b[2K\r[OpenCode] Hello World\r  (second text event clears line, rewrites with accumulated)
180//! [OpenCode] ✓ Step finished... (step_finish shows prefix with newline)
181//! ```
182//!
183//! # Single-Line Pattern
184//!
185//! The renderer uses a single-line pattern with carriage return for in-place updates.
186//! This is the industry standard for streaming CLIs (used by Rich, Ink, Bubble Tea).
187//!
188//! Each delta rewrites the entire line with prefix, ensuring that:
189//! - The user always sees the prefix
190//! - Content updates in-place without visual artifacts
191//! - Terminal state is clean and predictable
192
193use crate::common::truncate_text;
194use crate::config::Verbosity;
195use crate::logger::{Colors, CHECK, CROSS};
196use 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    /// Relative path to log file (if logging enabled)
329    log_path: Option<std::path::PathBuf>,
330    display_name: String,
331    /// Unified streaming session for state tracking
332    streaming_session: Rc<RefCell<StreamingSession>>,
333    /// Terminal mode for output formatting
334    terminal_mode: RefCell<TerminalMode>,
335    /// Whether to show streaming quality metrics
336    show_streaming_metrics: bool,
337    /// Output printer for capturing or displaying output
338    printer: SharedPrinter,
339}
340
341impl OpenCodeParser {
342    pub(crate) fn new(colors: Colors, verbosity: Verbosity) -> Self {
343        Self::with_printer(colors, verbosity, super::printer::shared_stdout())
344    }
345
346    /// Create a new `OpenCodeParser` with a custom printer.
347    ///
348    /// # Arguments
349    ///
350    /// * `colors` - Colors for terminal output
351    /// * `verbosity` - Verbosity level for output
352    /// * `printer` - Shared printer for output
353    ///
354    /// # Returns
355    ///
356    /// A new `OpenCodeParser` instance
357    pub(crate) fn with_printer(
358        colors: Colors,
359        verbosity: Verbosity,
360        printer: SharedPrinter,
361    ) -> Self {
362        let verbose_warnings = matches!(verbosity, Verbosity::Debug);
363        let streaming_session = StreamingSession::new().with_verbose_warnings(verbose_warnings);
364
365        // Use the printer's is_terminal method to validate it's connected correctly
366        let _printer_is_terminal = printer.borrow().is_terminal();
367
368        Self {
369            colors,
370            verbosity,
371            log_path: None,
372            display_name: "OpenCode".to_string(),
373            streaming_session: Rc::new(RefCell::new(streaming_session)),
374            terminal_mode: RefCell::new(TerminalMode::detect()),
375            show_streaming_metrics: false,
376            printer,
377        }
378    }
379
380    pub(crate) const fn with_show_streaming_metrics(mut self, show: bool) -> Self {
381        self.show_streaming_metrics = show;
382        self
383    }
384
385    pub(crate) fn with_display_name(mut self, display_name: &str) -> Self {
386        self.display_name = display_name.to_string();
387        self
388    }
389
390    pub(crate) fn with_log_file(mut self, path: &str) -> Self {
391        self.log_path = Some(std::path::PathBuf::from(path));
392        self
393    }
394
395    #[cfg(test)]
396    pub fn with_terminal_mode(self, mode: TerminalMode) -> Self {
397        *self.terminal_mode.borrow_mut() = mode;
398        self
399    }
400
401    /// Create a new parser with a test printer.
402    ///
403    /// This is the primary entry point for integration tests that need
404    /// to capture parser output for verification.
405    #[cfg(any(test, feature = "test-utils"))]
406    pub fn with_printer_for_test(
407        colors: Colors,
408        verbosity: Verbosity,
409        printer: SharedPrinter,
410    ) -> Self {
411        Self::with_printer(colors, verbosity, printer)
412    }
413
414    /// Set the log file path for testing.
415    ///
416    /// This allows tests to verify log file content after parsing.
417    #[cfg(any(test, feature = "test-utils"))]
418    pub fn with_log_file_for_test(mut self, path: &str) -> Self {
419        self.log_path = Some(std::path::PathBuf::from(path));
420        self
421    }
422
423    /// Parse a stream for testing purposes.
424    ///
425    /// This exposes the internal `parse_stream` method for integration tests.
426    #[cfg(any(test, feature = "test-utils"))]
427    pub fn parse_stream_for_test<R: std::io::BufRead>(
428        &self,
429        reader: R,
430        workspace: &dyn crate::workspace::Workspace,
431    ) -> std::io::Result<()> {
432        self.parse_stream(reader, workspace)
433    }
434
435    /// Get a shared reference to the printer.
436    ///
437    /// This allows tests, monitoring, and other code to access the printer after parsing
438    /// to verify output content, check for duplicates, or capture output for analysis.
439    /// Only available with the `test-utils` feature.
440    ///
441    /// # Returns
442    ///
443    /// A clone of the shared printer reference (`Rc<RefCell<dyn Printable>>`)
444    #[cfg(feature = "test-utils")]
445    pub fn printer(&self) -> SharedPrinter {
446        Rc::clone(&self.printer)
447    }
448
449    /// Get streaming quality metrics from the current session.
450    ///
451    /// This provides insight into the deduplication and streaming quality of the
452    /// parsing session. Only available with the `test-utils` feature.
453    ///
454    /// # Returns
455    ///
456    /// A copy of the streaming quality metrics from the internal `StreamingSession`.
457    #[cfg(feature = "test-utils")]
458    pub fn streaming_metrics(&self) -> StreamingQualityMetrics {
459        self.streaming_session
460            .borrow()
461            .get_streaming_quality_metrics()
462    }
463
464    /// Parse and display a single `OpenCode` JSON event
465    ///
466    /// From OpenCode source (`run.ts` lines 146-201), the NDJSON format uses events with:
467    /// - `step_start`: Step initialization with snapshot info
468    /// - `step_finish`: Step completion with reason, cost, tokens
469    /// - `tool_use`: Tool invocation with tool name, callID, and state (status, input, output)
470    /// - `text`: Streaming text content
471    /// - `error`: Session/API error events
472    pub(crate) fn parse_event(&self, line: &str) -> Option<String> {
473        let event: OpenCodeEvent = if let Ok(e) = serde_json::from_str(line) {
474            e
475        } else {
476            let trimmed = line.trim();
477            if !trimmed.is_empty() && !trimmed.starts_with('{') {
478                return Some(format!("{trimmed}\n"));
479            }
480            return None;
481        };
482        let c = &self.colors;
483        let prefix = &self.display_name;
484
485        let output = match event.event_type.as_str() {
486            "step_start" => self.format_step_start_event(&event),
487            "step_finish" => self.format_step_finish_event(&event),
488            "tool_use" => self.format_tool_use_event(&event),
489            "text" => self.format_text_event(&event),
490            "error" => self.format_error_event(&event, line),
491            _ => {
492                // Unknown event type - use the generic formatter in verbose mode
493                format_unknown_json_event(line, prefix, *c, self.verbosity.is_verbose())
494            }
495        };
496
497        if output.is_empty() {
498            None
499        } else {
500            Some(output)
501        }
502    }
503
504    /// Format a `step_start` event
505    fn format_step_start_event(&self, event: &OpenCodeEvent) -> String {
506        let c = &self.colors;
507        let prefix = &self.display_name;
508
509        // Reset streaming state on new step
510        self.streaming_session.borrow_mut().on_message_start();
511
512        // Create unique step ID for duplicate detection
513        // Use part.message_id if available, otherwise combine session_id + part.id
514        let step_id = event.part.as_ref().map_or_else(
515            || {
516                event
517                    .session_id
518                    .clone()
519                    .unwrap_or_else(|| "unknown".to_string())
520            },
521            |part| {
522                part.message_id.as_ref().map_or_else(
523                    || {
524                        let session = event.session_id.as_deref().unwrap_or("unknown");
525                        let part_id = part.id.as_deref().unwrap_or("step");
526                        format!("{session}:{part_id}")
527                    },
528                    std::clone::Clone::clone,
529                )
530            },
531        );
532        self.streaming_session
533            .borrow_mut()
534            .set_current_message_id(Some(step_id));
535
536        let snapshot = event
537            .part
538            .as_ref()
539            .and_then(|p| p.snapshot.as_ref())
540            .map(|s| format!("({s:.8}...)"))
541            .unwrap_or_default();
542        format!(
543            "{}[{}]{} {}Step started{} {}{}{}\n",
544            c.dim(),
545            prefix,
546            c.reset(),
547            c.cyan(),
548            c.reset(),
549            c.dim(),
550            snapshot,
551            c.reset()
552        )
553    }
554
555    /// Format a `step_finish` event
556    fn format_step_finish_event(&self, event: &OpenCodeEvent) -> String {
557        let c = &self.colors;
558        let prefix = &self.display_name;
559
560        // Check for duplicate final message using message ID or fallback to streaming content check
561        let session = self.streaming_session.borrow();
562        let is_duplicate = session.get_current_message_id().map_or_else(
563            || session.has_any_streamed_content(),
564            |message_id| session.is_duplicate_final_message(message_id),
565        );
566        let was_streaming = session.has_any_streamed_content();
567        let metrics = session.get_streaming_quality_metrics();
568        drop(session);
569
570        // Finalize the message (this marks it as displayed)
571        let _was_in_block = self.streaming_session.borrow_mut().on_message_stop();
572
573        event.part.as_ref().map_or_else(String::new, |part| {
574            let reason = part.reason.as_deref().unwrap_or("unknown");
575            let cost = part.cost.unwrap_or(0.0);
576
577            let tokens_str = part.tokens.as_ref().map_or_else(String::new, |tokens| {
578                let input = tokens.input.unwrap_or(0);
579                let output = tokens.output.unwrap_or(0);
580                let reasoning = tokens.reasoning.unwrap_or(0);
581                let cache_read = tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0);
582                if reasoning > 0 {
583                    format!("in:{input} out:{output} reason:{reasoning} cache:{cache_read}")
584                } else if cache_read > 0 {
585                    format!("in:{input} out:{output} cache:{cache_read}")
586                } else {
587                    format!("in:{input} out:{output}")
588                }
589            });
590
591            let is_success = reason == "tool-calls" || reason == "end_turn";
592            let icon = if is_success { CHECK } else { CROSS };
593            let color = if is_success { c.green() } else { c.yellow() };
594
595            // Add final newline if we were streaming text
596            let terminal_mode = *self.terminal_mode.borrow();
597            let newline_prefix = if is_duplicate || was_streaming {
598                let completion = TextDeltaRenderer::render_completion(terminal_mode);
599                let show_metrics = (self.verbosity.is_debug() || self.show_streaming_metrics)
600                    && metrics.total_deltas > 0;
601                if show_metrics {
602                    format!("{}\n{}", completion, metrics.format(*c))
603                } else {
604                    completion
605                }
606            } else {
607                String::new()
608            };
609
610            let mut out = format!(
611                "{}{}[{}]{} {}{} Step finished{} {}({}",
612                newline_prefix,
613                c.dim(),
614                prefix,
615                c.reset(),
616                color,
617                icon,
618                c.reset(),
619                c.dim(),
620                reason
621            );
622            if !tokens_str.is_empty() {
623                let _ = write!(out, ", {tokens_str}");
624            }
625            if cost > 0.0 {
626                let _ = write!(out, ", ${cost:.4}");
627            }
628            let _ = writeln!(out, "){}", c.reset());
629            out
630        })
631    }
632
633    /// Format a `tool_use` event
634    ///
635    /// Based on OpenCode source (`run.ts` lines 163-174, `message-v2.ts` lines 221-287):
636    /// - Shows tool name with status-specific icon and color
637    /// - Status handling: pending (…), running (►), completed (✓), error (✗)
638    /// - Title/description when available (from `state.title`)
639    /// - Tool-specific input formatting based on tool type
640    /// - Tool output/results shown at Normal+ verbosity
641    /// - Error messages shown in red when status is "error"
642    fn format_tool_use_event(&self, event: &OpenCodeEvent) -> String {
643        let c = &self.colors;
644        let prefix = &self.display_name;
645
646        event.part.as_ref().map_or_else(String::new, |part| {
647            let tool_name = part.tool.as_deref().unwrap_or("unknown");
648            let status = part
649                .state
650                .as_ref()
651                .and_then(|s| s.status.as_deref())
652                .unwrap_or("pending");
653            let title = part.state.as_ref().and_then(|s| s.title.as_deref());
654
655            // Status-specific icon and color based on ToolState variants from message-v2.ts
656            // Statuses: "pending", "running", "completed", "error"
657            let (icon, color) = match status {
658                "completed" => (CHECK, c.green()),
659                "error" => (CROSS, c.red()),
660                "running" => ('►', c.cyan()),
661                _ => ('…', c.yellow()), // "pending" or unknown
662            };
663
664            let mut out = format!(
665                "{}[{}]{} {}Tool{}: {}{}{} {}{}{}\n",
666                c.dim(),
667                prefix,
668                c.reset(),
669                c.magenta(),
670                c.reset(),
671                c.bold(),
672                tool_name,
673                c.reset(),
674                color,
675                icon,
676                c.reset()
677            );
678
679            // Show title if available (from state.title)
680            if let Some(t) = title {
681                let limit = self.verbosity.truncate_limit("text");
682                let preview = truncate_text(t, limit);
683                let _ = writeln!(
684                    out,
685                    "{}[{}]{} {}  └─ {}{}",
686                    c.dim(),
687                    prefix,
688                    c.reset(),
689                    c.dim(),
690                    preview,
691                    c.reset()
692                );
693            }
694
695            // Show tool input at Normal+ verbosity with tool-specific formatting
696            if self.verbosity.show_tool_input() {
697                if let Some(ref state) = part.state {
698                    if let Some(ref input_val) = state.input {
699                        let input_str = Self::format_tool_specific_input(tool_name, input_val);
700                        let limit = self.verbosity.truncate_limit("tool_input");
701                        let preview = truncate_text(&input_str, limit);
702                        if !preview.is_empty() {
703                            let _ = writeln!(
704                                out,
705                                "{}[{}]{} {}  └─ {}{}",
706                                c.dim(),
707                                prefix,
708                                c.reset(),
709                                c.dim(),
710                                preview,
711                                c.reset()
712                            );
713                        }
714                    }
715                }
716            }
717
718            // Show error message when status is "error"
719            if status == "error" {
720                if let Some(ref state) = part.state {
721                    if let Some(ref error_msg) = state.error {
722                        let limit = self.verbosity.truncate_limit("tool_result");
723                        let preview = truncate_text(error_msg, limit);
724                        let _ = writeln!(
725                            out,
726                            "{}[{}]{} {}  └─ {}Error:{} {}{}{}",
727                            c.dim(),
728                            prefix,
729                            c.reset(),
730                            c.red(),
731                            c.bold(),
732                            c.reset(),
733                            c.red(),
734                            preview,
735                            c.reset()
736                        );
737                    }
738                }
739            }
740
741            // Show tool output at Normal+ verbosity when completed
742            // (Changed from verbose-only to match OpenCode's interactive mode behavior)
743            if self.verbosity.show_tool_input() && status == "completed" {
744                if let Some(ref state) = part.state {
745                    if let Some(ref output_val) = state.output {
746                        let output_str = match output_val {
747                            serde_json::Value::String(s) => s.clone(),
748                            other => other.to_string(),
749                        };
750                        if !output_str.is_empty() {
751                            let limit = self.verbosity.truncate_limit("tool_result");
752                            // Format multi-line output with proper indentation
753                            self.format_tool_output(&mut out, &output_str, limit, prefix, *c);
754                        }
755                    }
756                }
757            }
758            out
759        })
760    }
761
762    /// Format tool output with proper multi-line handling
763    ///
764    /// For single-line outputs, shows inline. For multi-line outputs (like file contents),
765    /// shows only the first few lines as a preview.
766    fn format_tool_output(
767        &self,
768        out: &mut String,
769        output: &str,
770        limit: usize,
771        prefix: &str,
772        c: Colors,
773    ) {
774        use crate::config::truncation::MAX_OUTPUT_LINES;
775
776        let lines: Vec<&str> = output.lines().collect();
777        let is_multiline = lines.len() > 1;
778
779        if is_multiline {
780            // Multi-line output: show header then first few lines
781            let _ = writeln!(
782                out,
783                "{}[{}]{} {}  └─ Output:{}",
784                c.dim(),
785                prefix,
786                c.reset(),
787                c.cyan(),
788                c.reset()
789            );
790
791            let mut chars_used = 0;
792            let indent = format!("{}[{}]{}     ", c.dim(), prefix, c.reset());
793
794            for (lines_shown, line) in lines.iter().enumerate() {
795                // Stop if we've shown enough lines OR exceeded char limit
796                if lines_shown >= MAX_OUTPUT_LINES || chars_used + line.len() > limit {
797                    let remaining = lines.len() - lines_shown;
798                    if remaining > 0 {
799                        let _ = writeln!(out, "{}{}...({} more lines)", indent, c.dim(), remaining);
800                    }
801                    break;
802                }
803                let _ = writeln!(out, "{}{}{}{}", indent, c.dim(), line, c.reset());
804                chars_used += line.len() + 1;
805            }
806        } else {
807            // Single-line output: show inline
808            let preview = truncate_text(output, limit);
809            if !preview.is_empty() {
810                let _ = writeln!(
811                    out,
812                    "{}[{}]{} {}  └─ Output:{} {}",
813                    c.dim(),
814                    prefix,
815                    c.reset(),
816                    c.cyan(),
817                    c.reset(),
818                    preview
819                );
820            }
821        }
822    }
823
824    /// Format tool input based on tool type
825    ///
826    /// From OpenCode source, each tool has specific input fields:
827    /// - `read`: `filePath`, `offset?`, `limit?`
828    /// - `bash`: `command`, `timeout?`
829    /// - `write`: `filePath`, `content`
830    /// - `edit`: `filePath`, ...
831    /// - `glob`: `pattern`, `path?`
832    /// - `grep`: `pattern`, `path?`, `include?`
833    /// - `fetch`: `url`, `format?`, `timeout?`
834    fn format_tool_specific_input(tool_name: &str, input: &serde_json::Value) -> String {
835        let obj = match input.as_object() {
836            Some(o) => o,
837            None => return format_tool_input(input),
838        };
839
840        match tool_name {
841            "read" | "view" => {
842                // Primary: filePath, optional: offset, limit
843                let file_path = obj.get("filePath").and_then(|v| v.as_str()).unwrap_or("");
844                let mut result = file_path.to_string();
845                if let Some(offset) = obj.get("offset").and_then(|v| v.as_u64()) {
846                    result.push_str(&format!(" (offset: {offset})"));
847                }
848                if let Some(limit) = obj.get("limit").and_then(|v| v.as_u64()) {
849                    result.push_str(&format!(" (limit: {limit})"));
850                }
851                result
852            }
853            "bash" => {
854                // Primary: command
855                obj.get("command")
856                    .and_then(|v| v.as_str())
857                    .unwrap_or("")
858                    .to_string()
859            }
860            "write" => {
861                // Primary: filePath (don't show content in summary)
862                let file_path = obj.get("filePath").and_then(|v| v.as_str()).unwrap_or("");
863                let content_len = obj
864                    .get("content")
865                    .and_then(|v| v.as_str())
866                    .map(|s| s.len())
867                    .unwrap_or(0);
868                if content_len > 0 {
869                    format!("{file_path} ({content_len} bytes)")
870                } else {
871                    file_path.to_string()
872                }
873            }
874            "edit" => {
875                // Primary: filePath
876                obj.get("filePath")
877                    .and_then(|v| v.as_str())
878                    .unwrap_or("")
879                    .to_string()
880            }
881            "glob" => {
882                // Primary: pattern, optional: path
883                let pattern = obj.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
884                let path = obj.get("path").and_then(|v| v.as_str());
885                if let Some(p) = path {
886                    format!("{pattern} in {p}")
887                } else {
888                    pattern.to_string()
889                }
890            }
891            "grep" => {
892                // Primary: pattern, optional: path, include
893                let pattern = obj.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
894                let mut result = format!("/{pattern}/");
895                if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
896                    result.push_str(&format!(" in {path}"));
897                }
898                if let Some(include) = obj.get("include").and_then(|v| v.as_str()) {
899                    result.push_str(&format!(" ({include})"));
900                }
901                result
902            }
903            "fetch" | "webfetch" => {
904                // Primary: url, optional: format
905                let url = obj.get("url").and_then(|v| v.as_str()).unwrap_or("");
906                let format = obj.get("format").and_then(|v| v.as_str());
907                if let Some(f) = format {
908                    format!("{url} ({f})")
909                } else {
910                    url.to_string()
911                }
912            }
913            "todowrite" | "todoread" => {
914                // Show count of todos if available
915                if let Some(todos) = obj.get("todos").and_then(|v| v.as_array()) {
916                    format!("{} items", todos.len())
917                } else {
918                    format_tool_input(input)
919                }
920            }
921            _ => {
922                // Fallback to generic formatting
923                format_tool_input(input)
924            }
925        }
926    }
927
928    /// Format a `text` event
929    fn format_text_event(&self, event: &OpenCodeEvent) -> String {
930        let c = &self.colors;
931        let prefix = &self.display_name;
932
933        if let Some(ref part) = event.part {
934            if let Some(ref text) = part.text {
935                // Accumulate streaming text using StreamingSession
936                let (show_prefix, accumulated_text) = {
937                    let mut session = self.streaming_session.borrow_mut();
938                    let show_prefix = session.on_text_delta_key("main", text);
939                    // Get accumulated text for streaming display
940                    let accumulated_text = session
941                        .get_accumulated(ContentType::Text, "main")
942                        .unwrap_or("")
943                        .to_string();
944                    (show_prefix, accumulated_text)
945                };
946
947                // Show delta in real-time (both verbose and normal mode)
948                let limit = self.verbosity.truncate_limit("text");
949                let preview = truncate_text(&accumulated_text, limit);
950
951                // Use TextDeltaRenderer for consistent rendering across all parsers
952                let terminal_mode = *self.terminal_mode.borrow();
953                if show_prefix {
954                    // First delta: use renderer with prefix
955                    return TextDeltaRenderer::render_first_delta(
956                        &preview,
957                        prefix,
958                        *c,
959                        terminal_mode,
960                    );
961                }
962                // Subsequent deltas: use renderer for in-place update
963                return TextDeltaRenderer::render_subsequent_delta(
964                    &preview,
965                    prefix,
966                    *c,
967                    terminal_mode,
968                );
969            }
970        }
971        String::new()
972    }
973
974    /// Format an `error` event
975    ///
976    /// From OpenCode source (`run.ts` lines 192-202), error events are emitted for session errors:
977    /// ```typescript
978    /// if (event.type === "session.error") {
979    ///   let err = String(props.error.name)
980    ///   if ("data" in props.error && props.error.data && "message" in props.error.data) {
981    ///     err = String(props.error.data.message)
982    ///   }
983    ///   outputJsonEvent("error", { error: props.error })
984    /// }
985    /// ```
986    fn format_error_event(&self, event: &OpenCodeEvent, raw_line: &str) -> String {
987        let c = &self.colors;
988        let prefix = &self.display_name;
989
990        // Try to extract error message from the event
991        let error_msg = event.error.as_ref().map_or_else(
992            || {
993                // Fallback: try to extract from raw JSON
994                if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw_line) {
995                    json.get("error")
996                        .and_then(|e| {
997                            // Try data.message first (as in run.ts)
998                            e.get("data")
999                                .and_then(|d| d.get("message"))
1000                                .and_then(|m| m.as_str())
1001                                .map(String::from)
1002                                // Then try direct message
1003                                .or_else(|| {
1004                                    e.get("message").and_then(|m| m.as_str()).map(String::from)
1005                                })
1006                                // Then try name
1007                                .or_else(|| {
1008                                    e.get("name").and_then(|n| n.as_str()).map(String::from)
1009                                })
1010                        })
1011                        .unwrap_or_else(|| "Unknown error".to_string())
1012                } else {
1013                    "Unknown error".to_string()
1014                }
1015            },
1016            |err| {
1017                // Try data.message first (as in run.ts)
1018                err.data
1019                    .as_ref()
1020                    .and_then(|d| d.get("message"))
1021                    .and_then(|m| m.as_str())
1022                    .map(String::from)
1023                    // Then try direct message
1024                    .or_else(|| err.message.clone())
1025                    // Then try name
1026                    .or_else(|| err.name.clone())
1027                    .unwrap_or_else(|| "Unknown error".to_string())
1028            },
1029        );
1030
1031        let limit = self.verbosity.truncate_limit("text");
1032        let preview = truncate_text(&error_msg, limit);
1033
1034        format!(
1035            "{}[{}]{} {}{} Error:{} {}{}{}\n",
1036            c.dim(),
1037            prefix,
1038            c.reset(),
1039            c.red(),
1040            CROSS,
1041            c.reset(),
1042            c.red(),
1043            preview,
1044            c.reset()
1045        )
1046    }
1047
1048    /// Check if an `OpenCode` event is a control event (state management with no user output)
1049    ///
1050    /// Control events are valid JSON that represent state transitions rather than
1051    /// user-facing content. They should be tracked separately from "ignored" events
1052    /// to avoid false health warnings.
1053    fn is_control_event(event: &OpenCodeEvent) -> bool {
1054        match event.event_type.as_str() {
1055            // Step lifecycle events are control events
1056            "step_start" | "step_finish" => true,
1057            _ => false,
1058        }
1059    }
1060
1061    /// Check if an `OpenCode` event is a partial/delta event (streaming content displayed incrementally)
1062    ///
1063    /// Partial events represent streaming text deltas that are shown to the user
1064    /// in real-time. These should be tracked separately to avoid inflating "ignored" percentages.
1065    fn is_partial_event(event: &OpenCodeEvent) -> bool {
1066        match event.event_type.as_str() {
1067            // Text events produce streaming content
1068            "text" => true,
1069            _ => false,
1070        }
1071    }
1072
1073    /// Parse a stream of `OpenCode` NDJSON events
1074    pub(crate) fn parse_stream<R: BufRead>(
1075        &self,
1076        mut reader: R,
1077        workspace: &dyn crate::workspace::Workspace,
1078    ) -> io::Result<()> {
1079        use super::incremental_parser::IncrementalNdjsonParser;
1080
1081        let c = &self.colors;
1082        let monitor = HealthMonitor::new("OpenCode");
1083        // Accumulate log content in memory, write to workspace at the end
1084        let logging_enabled = self.log_path.is_some();
1085        let mut log_buffer: Vec<u8> = Vec::new();
1086
1087        // Use incremental parser for true real-time streaming
1088        // This processes JSON as soon as it's complete, not waiting for newlines
1089        let mut incremental_parser = IncrementalNdjsonParser::new();
1090        let mut byte_buffer = Vec::new();
1091
1092        loop {
1093            // Read available bytes
1094            byte_buffer.clear();
1095            let chunk = reader.fill_buf()?;
1096            if chunk.is_empty() {
1097                break;
1098            }
1099
1100            // Process all bytes immediately
1101            byte_buffer.extend_from_slice(chunk);
1102            let consumed = chunk.len();
1103            reader.consume(consumed);
1104
1105            // Feed bytes to incremental parser
1106            let json_events = incremental_parser.feed(&byte_buffer);
1107
1108            // Process each complete JSON event immediately
1109            for line in json_events {
1110                let trimmed = line.trim();
1111                if trimmed.is_empty() {
1112                    continue;
1113                }
1114
1115                if self.verbosity.is_debug() {
1116                    let mut printer = self.printer.borrow_mut();
1117                    writeln!(
1118                        printer,
1119                        "{}[DEBUG]{} {}{}{}",
1120                        c.dim(),
1121                        c.reset(),
1122                        c.dim(),
1123                        &line,
1124                        c.reset()
1125                    )?;
1126                    printer.flush()?;
1127                }
1128
1129                // Parse the event once - parse_event handles malformed JSON by returning None
1130                match self.parse_event(&line) {
1131                    Some(output) => {
1132                        // Check if this is a partial/delta event (streaming content)
1133                        if trimmed.starts_with('{') {
1134                            if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
1135                                if Self::is_partial_event(&event) {
1136                                    monitor.record_partial_event();
1137                                } else {
1138                                    monitor.record_parsed();
1139                                }
1140                            } else {
1141                                monitor.record_parsed();
1142                            }
1143                        } else {
1144                            monitor.record_parsed();
1145                        }
1146                        // Write output to printer
1147                        let mut printer = self.printer.borrow_mut();
1148                        write!(printer, "{output}")?;
1149                        printer.flush()?;
1150                    }
1151                    None => {
1152                        // Check if this was a control event (state management with no user output)
1153                        if trimmed.starts_with('{') {
1154                            if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
1155                                if Self::is_control_event(&event) {
1156                                    monitor.record_control_event();
1157                                } else {
1158                                    // Valid JSON but not a control event - track as unknown
1159                                    monitor.record_unknown_event();
1160                                }
1161                            } else {
1162                                // Failed to deserialize - track as parse error
1163                                monitor.record_parse_error();
1164                            }
1165                        } else {
1166                            monitor.record_ignored();
1167                        }
1168                    }
1169                }
1170
1171                if logging_enabled {
1172                    writeln!(log_buffer, "{line}")?;
1173                }
1174            }
1175        }
1176
1177        // Handle any remaining buffered data when the stream ends.
1178        // Only process if it's valid JSON - incomplete buffered data should be skipped.
1179        if let Some(remaining) = incremental_parser.finish() {
1180            let trimmed = remaining.trim();
1181            if !trimmed.is_empty()
1182                && trimmed.starts_with('{')
1183                && serde_json::from_str::<OpenCodeEvent>(&remaining).is_ok()
1184            {
1185                // Process the remaining event
1186                if let Some(output) = self.parse_event(&remaining) {
1187                    monitor.record_parsed();
1188                    let mut printer = self.printer.borrow_mut();
1189                    write!(printer, "{output}")?;
1190                    printer.flush()?;
1191                }
1192                // Write to log buffer
1193                if logging_enabled {
1194                    writeln!(log_buffer, "{remaining}")?;
1195                }
1196            }
1197        }
1198
1199        // Write accumulated log content to workspace
1200        if let Some(log_path) = &self.log_path {
1201            workspace.append_bytes(log_path, &log_buffer)?;
1202        }
1203        if let Some(warning) = monitor.check_and_warn(*c) {
1204            let mut printer = self.printer.borrow_mut();
1205            writeln!(printer, "{warning}")?;
1206        }
1207        Ok(())
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214
1215    #[test]
1216    fn test_opencode_step_start() {
1217        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1218        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"}}"#;
1219        let output = parser.parse_event(json);
1220        assert!(output.is_some());
1221        let out = output.unwrap();
1222        assert!(out.contains("Step started"));
1223        assert!(out.contains("5d36aa03"));
1224    }
1225
1226    #[test]
1227    fn test_opencode_step_finish() {
1228        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1229        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}}}}"#;
1230        let output = parser.parse_event(json);
1231        assert!(output.is_some());
1232        let out = output.unwrap();
1233        assert!(out.contains("Step finished"));
1234        assert!(out.contains("tool-calls"));
1235        assert!(out.contains("in:108"));
1236        assert!(out.contains("out:151"));
1237        assert!(out.contains("cache:11236"));
1238    }
1239
1240    #[test]
1241    fn test_opencode_tool_use_completed() {
1242        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1243        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"}}}"#;
1244        let output = parser.parse_event(json);
1245        assert!(output.is_some());
1246        let out = output.unwrap();
1247        assert!(out.contains("Tool"));
1248        assert!(out.contains("read"));
1249        assert!(out.contains("✓")); // completed icon
1250        assert!(out.contains("PLAN.md"));
1251    }
1252
1253    #[test]
1254    fn test_opencode_tool_use_pending() {
1255        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1256        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"}}}}"#;
1257        let output = parser.parse_event(json);
1258        assert!(output.is_some());
1259        let out = output.unwrap();
1260        assert!(out.contains("Tool"));
1261        assert!(out.contains("bash"));
1262        assert!(out.contains("…")); // pending icon (WAIT)
1263    }
1264
1265    #[test]
1266    fn test_opencode_tool_use_shows_input() {
1267        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1268        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"}}}}"#;
1269        let output = parser.parse_event(json);
1270        assert!(output.is_some());
1271        let out = output.unwrap();
1272        assert!(out.contains("Tool"));
1273        assert!(out.contains("read"));
1274        assert!(out.contains("/Users/test/file.rs"));
1275    }
1276
1277    #[test]
1278    fn test_opencode_text_event() {
1279        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1280        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}}}"#;
1281        let output = parser.parse_event(json);
1282        assert!(output.is_some());
1283        let out = output.unwrap();
1284        assert!(out.contains("I'll start by reading the plan"));
1285    }
1286
1287    #[test]
1288    fn test_opencode_unknown_event_ignored() {
1289        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1290        let json = r#"{"type":"unknown_event","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{}}"#;
1291        let output = parser.parse_event(json);
1292        // Unknown events should return None
1293        assert!(output.is_none());
1294    }
1295
1296    #[test]
1297    fn test_opencode_parser_non_json_passthrough() {
1298        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1299        let output = parser.parse_event("Error: something went wrong");
1300        assert!(output.is_some());
1301        assert!(output.unwrap().contains("Error: something went wrong"));
1302    }
1303
1304    #[test]
1305    fn test_opencode_parser_malformed_json_ignored() {
1306        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1307        let output = parser.parse_event("{invalid json here}");
1308        assert!(output.is_none());
1309    }
1310
1311    #[test]
1312    fn test_opencode_step_finish_with_cost() {
1313        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1314        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}}}}"#;
1315        let output = parser.parse_event(json);
1316        assert!(output.is_some());
1317        let out = output.unwrap();
1318        assert!(out.contains("Step finished"));
1319        assert!(out.contains("end_turn"));
1320        assert!(out.contains("$0.0025"));
1321    }
1322
1323    #[test]
1324    fn test_opencode_tool_verbose_shows_output() {
1325        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Verbose);
1326        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\"); }"}}}"#;
1327        let output = parser.parse_event(json);
1328        assert!(output.is_some());
1329        let out = output.unwrap();
1330        assert!(out.contains("Tool"));
1331        assert!(out.contains("read"));
1332        assert!(out.contains("Output"));
1333        assert!(out.contains("fn main"));
1334    }
1335
1336    #[test]
1337    fn test_opencode_tool_running_status() {
1338        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1339        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}}}}"#;
1340        let output = parser.parse_event(json);
1341        assert!(output.is_some());
1342        let out = output.unwrap();
1343        assert!(out.contains("Tool"));
1344        assert!(out.contains("bash"));
1345        assert!(out.contains("►")); // running icon
1346    }
1347
1348    #[test]
1349    fn test_opencode_tool_error_status() {
1350        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1351        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}}}}"#;
1352        let output = parser.parse_event(json);
1353        assert!(output.is_some());
1354        let out = output.unwrap();
1355        assert!(out.contains("Tool"));
1356        assert!(out.contains("bash"));
1357        assert!(out.contains("✗")); // error icon
1358        assert!(out.contains("Error"));
1359        assert!(out.contains("Command not found"));
1360    }
1361
1362    #[test]
1363    fn test_opencode_error_event() {
1364        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1365        let json = r#"{"type":"error","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","error":{"name":"APIError","message":"Rate limit exceeded"}}"#;
1366        let output = parser.parse_event(json);
1367        assert!(output.is_some());
1368        let out = output.unwrap();
1369        assert!(out.contains("Error"));
1370        assert!(out.contains("Rate limit exceeded"));
1371    }
1372
1373    #[test]
1374    fn test_opencode_error_event_with_data_message() {
1375        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1376        // Error with data.message (as in run.ts lines 197-199)
1377        let json = r#"{"type":"error","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","error":{"name":"ProviderError","data":{"message":"Invalid API key"}}}"#;
1378        let output = parser.parse_event(json);
1379        assert!(output.is_some());
1380        let out = output.unwrap();
1381        assert!(out.contains("Error"));
1382        assert!(out.contains("Invalid API key"));
1383    }
1384
1385    #[test]
1386    fn test_opencode_tool_bash_formatting() {
1387        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1388        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"}}}"#;
1389        let output = parser.parse_event(json);
1390        assert!(output.is_some());
1391        let out = output.unwrap();
1392        assert!(out.contains("bash"));
1393        assert!(out.contains("git status"));
1394    }
1395
1396    #[test]
1397    fn test_opencode_tool_glob_formatting() {
1398        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1399        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"}}}"#;
1400        let output = parser.parse_event(json);
1401        assert!(output.is_some());
1402        let out = output.unwrap();
1403        assert!(out.contains("glob"));
1404        assert!(out.contains("**/*.rs"));
1405        assert!(out.contains("in src"));
1406    }
1407
1408    #[test]
1409    fn test_opencode_tool_grep_formatting() {
1410        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1411        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"}}}"#;
1412        let output = parser.parse_event(json);
1413        assert!(output.is_some());
1414        let out = output.unwrap();
1415        assert!(out.contains("grep"));
1416        assert!(out.contains("/TODO/"));
1417        assert!(out.contains("in src"));
1418        assert!(out.contains("(*.rs)"));
1419    }
1420
1421    #[test]
1422    fn test_opencode_tool_write_formatting() {
1423        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1424        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"}}}"#;
1425        let output = parser.parse_event(json);
1426        assert!(output.is_some());
1427        let out = output.unwrap();
1428        assert!(out.contains("write"));
1429        assert!(out.contains("test.txt"));
1430        assert!(out.contains("11 bytes"));
1431    }
1432
1433    #[test]
1434    fn test_opencode_tool_read_with_offset_limit() {
1435        let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
1436        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"}}}"#;
1437        let output = parser.parse_event(json);
1438        assert!(output.is_some());
1439        let out = output.unwrap();
1440        assert!(out.contains("read"));
1441        assert!(out.contains("large.txt"));
1442        assert!(out.contains("offset: 100"));
1443        assert!(out.contains("limit: 50"));
1444    }
1445}