1use 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#[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 pub(crate) error: Option<OpenCodeError>,
232}
233
234#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct OpenCodeError {
241 pub(crate) name: Option<String>,
243 pub(crate) message: Option<String>,
245 pub(crate) data: Option<serde_json::Value>,
247}
248
249#[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 pub(crate) snapshot: Option<String>,
261 pub(crate) reason: Option<String>,
263 pub(crate) cost: Option<f64>,
264 pub(crate) tokens: Option<OpenCodeTokens>,
265 #[serde(rename = "callID")]
267 pub(crate) call_id: Option<String>,
268 pub(crate) tool: Option<String>,
269 pub(crate) state: Option<OpenCodeToolState>,
270 pub(crate) text: Option<String>,
272 pub(crate) time: Option<OpenCodeTime>,
274}
275
276#[derive(Debug, Clone, Deserialize, Serialize)]
284pub struct OpenCodeToolState {
285 pub(crate) status: Option<String>,
287 pub(crate) input: Option<serde_json::Value>,
289 pub(crate) output: Option<serde_json::Value>,
291 pub(crate) title: Option<String>,
293 pub(crate) metadata: Option<serde_json::Value>,
295 pub(crate) time: Option<OpenCodeTime>,
297 pub(crate) error: Option<String>,
299}
300
301#[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#[derive(Debug, Clone, Deserialize, Serialize)]
312pub struct OpenCodeCache {
313 pub(crate) read: Option<u64>,
314 pub(crate) write: Option<u64>,
315}
316
317#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct OpenCodeTime {
320 pub(crate) start: Option<u64>,
321 pub(crate) end: Option<u64>,
322}
323
324pub struct OpenCodeParser {
326 colors: Colors,
327 verbosity: Verbosity,
328 log_file: Option<String>,
329 display_name: String,
330 streaming_session: Rc<RefCell<StreamingSession>>,
332 terminal_mode: RefCell<TerminalMode>,
334 show_streaming_metrics: bool,
336 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 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 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 #[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 #[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 #[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 #[cfg(feature = "test-utils")]
440 pub fn printer(&self) -> SharedPrinter {
441 Rc::clone(&self.printer)
442 }
443
444 #[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 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 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 fn format_step_start_event(&self, event: &OpenCodeEvent) -> String {
501 let c = &self.colors;
502 let prefix = &self.display_name;
503
504 self.streaming_session.borrow_mut().on_message_start();
506
507 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 fn format_step_finish_event(&self, event: &OpenCodeEvent) -> String {
552 let c = &self.colors;
553 let prefix = &self.display_name;
554
555 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 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 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 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 let (icon, color) = match status {
653 "completed" => (CHECK, c.green()),
654 "error" => (CROSS, c.red()),
655 "running" => ('►', c.cyan()),
656 _ => ('…', c.yellow()), };
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 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 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 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 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 self.format_tool_output(&mut out, &output_str, limit, prefix, *c);
749 }
750 }
751 }
752 }
753 out
754 })
755 }
756
757 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 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 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 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 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 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 obj.get("command")
851 .and_then(|v| v.as_str())
852 .unwrap_or("")
853 .to_string()
854 }
855 "write" => {
856 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 obj.get("filePath")
872 .and_then(|v| v.as_str())
873 .unwrap_or("")
874 .to_string()
875 }
876 "glob" => {
877 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 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 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 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 format_tool_input(input)
919 }
920 }
921 }
922
923 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 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 let accumulated_text = session
936 .get_accumulated(ContentType::Text, "main")
937 .unwrap_or("")
938 .to_string();
939 (show_prefix, accumulated_text)
940 };
941
942 let limit = self.verbosity.truncate_limit("text");
944 let preview = truncate_text(&accumulated_text, limit);
945
946 let terminal_mode = *self.terminal_mode.borrow();
948 if show_prefix {
949 return TextDeltaRenderer::render_first_delta(
951 &preview,
952 prefix,
953 *c,
954 terminal_mode,
955 );
956 }
957 return TextDeltaRenderer::render_subsequent_delta(
959 &preview,
960 prefix,
961 *c,
962 terminal_mode,
963 );
964 }
965 }
966 String::new()
967 }
968
969 fn format_error_event(&self, event: &OpenCodeEvent, raw_line: &str) -> String {
982 let c = &self.colors;
983 let prefix = &self.display_name;
984
985 let error_msg = event.error.as_ref().map_or_else(
987 || {
988 if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw_line) {
990 json.get("error")
991 .and_then(|e| {
992 e.get("data")
994 .and_then(|d| d.get("message"))
995 .and_then(|m| m.as_str())
996 .map(String::from)
997 .or_else(|| {
999 e.get("message").and_then(|m| m.as_str()).map(String::from)
1000 })
1001 .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 err.data
1014 .as_ref()
1015 .and_then(|d| d.get("message"))
1016 .and_then(|m| m.as_str())
1017 .map(String::from)
1018 .or_else(|| err.message.clone())
1020 .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 fn is_control_event(event: &OpenCodeEvent) -> bool {
1049 match event.event_type.as_str() {
1050 "step_start" | "step_finish" => true,
1052 _ => false,
1053 }
1054 }
1055
1056 fn is_partial_event(event: &OpenCodeEvent) -> bool {
1061 match event.event_type.as_str() {
1062 "text" => true,
1064 _ => false,
1065 }
1066 }
1067
1068 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 let mut incremental_parser = IncrementalNdjsonParser::new();
1086 let mut byte_buffer = Vec::new();
1087
1088 loop {
1089 byte_buffer.clear();
1091 let chunk = reader.fill_buf()?;
1092 if chunk.is_empty() {
1093 break;
1094 }
1095
1096 byte_buffer.extend_from_slice(chunk);
1098 let consumed = chunk.len();
1099 reader.consume(consumed);
1100
1101 let json_events = incremental_parser.feed(&byte_buffer);
1103
1104 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 match self.parse_event(&line) {
1127 Some(output) => {
1128 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 let mut printer = self.printer.borrow_mut();
1144 write!(printer, "{output}")?;
1145 printer.flush()?;
1146 }
1147 None => {
1148 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 monitor.record_unknown_event();
1156 }
1157 } else {
1158 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 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 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 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 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("✓")); 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("…")); }
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 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("►")); }
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("✗")); 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 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}