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_path: Option<std::path::PathBuf>,
330 display_name: String,
331 streaming_session: Rc<RefCell<StreamingSession>>,
333 terminal_mode: RefCell<TerminalMode>,
335 show_streaming_metrics: bool,
337 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 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 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 #[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 #[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 #[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 #[cfg(feature = "test-utils")]
445 pub fn printer(&self) -> SharedPrinter {
446 Rc::clone(&self.printer)
447 }
448
449 #[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 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 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 fn format_step_start_event(&self, event: &OpenCodeEvent) -> String {
506 let c = &self.colors;
507 let prefix = &self.display_name;
508
509 self.streaming_session.borrow_mut().on_message_start();
511
512 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 fn format_step_finish_event(&self, event: &OpenCodeEvent) -> String {
557 let c = &self.colors;
558 let prefix = &self.display_name;
559
560 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 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 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 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 let (icon, color) = match status {
658 "completed" => (CHECK, c.green()),
659 "error" => (CROSS, c.red()),
660 "running" => ('►', c.cyan()),
661 _ => ('…', c.yellow()), };
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 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 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 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 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 self.format_tool_output(&mut out, &output_str, limit, prefix, *c);
754 }
755 }
756 }
757 }
758 out
759 })
760 }
761
762 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 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 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 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 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 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 obj.get("command")
856 .and_then(|v| v.as_str())
857 .unwrap_or("")
858 .to_string()
859 }
860 "write" => {
861 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 obj.get("filePath")
877 .and_then(|v| v.as_str())
878 .unwrap_or("")
879 .to_string()
880 }
881 "glob" => {
882 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 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 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 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 format_tool_input(input)
924 }
925 }
926 }
927
928 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 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 let accumulated_text = session
941 .get_accumulated(ContentType::Text, "main")
942 .unwrap_or("")
943 .to_string();
944 (show_prefix, accumulated_text)
945 };
946
947 let limit = self.verbosity.truncate_limit("text");
949 let preview = truncate_text(&accumulated_text, limit);
950
951 let terminal_mode = *self.terminal_mode.borrow();
953 if show_prefix {
954 return TextDeltaRenderer::render_first_delta(
956 &preview,
957 prefix,
958 *c,
959 terminal_mode,
960 );
961 }
962 return TextDeltaRenderer::render_subsequent_delta(
964 &preview,
965 prefix,
966 *c,
967 terminal_mode,
968 );
969 }
970 }
971 String::new()
972 }
973
974 fn format_error_event(&self, event: &OpenCodeEvent, raw_line: &str) -> String {
987 let c = &self.colors;
988 let prefix = &self.display_name;
989
990 let error_msg = event.error.as_ref().map_or_else(
992 || {
993 if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw_line) {
995 json.get("error")
996 .and_then(|e| {
997 e.get("data")
999 .and_then(|d| d.get("message"))
1000 .and_then(|m| m.as_str())
1001 .map(String::from)
1002 .or_else(|| {
1004 e.get("message").and_then(|m| m.as_str()).map(String::from)
1005 })
1006 .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 err.data
1019 .as_ref()
1020 .and_then(|d| d.get("message"))
1021 .and_then(|m| m.as_str())
1022 .map(String::from)
1023 .or_else(|| err.message.clone())
1025 .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 fn is_control_event(event: &OpenCodeEvent) -> bool {
1054 match event.event_type.as_str() {
1055 "step_start" | "step_finish" => true,
1057 _ => false,
1058 }
1059 }
1060
1061 fn is_partial_event(event: &OpenCodeEvent) -> bool {
1066 match event.event_type.as_str() {
1067 "text" => true,
1069 _ => false,
1070 }
1071 }
1072
1073 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 let logging_enabled = self.log_path.is_some();
1085 let mut log_buffer: Vec<u8> = Vec::new();
1086
1087 let mut incremental_parser = IncrementalNdjsonParser::new();
1090 let mut byte_buffer = Vec::new();
1091
1092 loop {
1093 byte_buffer.clear();
1095 let chunk = reader.fill_buf()?;
1096 if chunk.is_empty() {
1097 break;
1098 }
1099
1100 byte_buffer.extend_from_slice(chunk);
1102 let consumed = chunk.len();
1103 reader.consume(consumed);
1104
1105 let json_events = incremental_parser.feed(&byte_buffer);
1107
1108 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 match self.parse_event(&line) {
1131 Some(output) => {
1132 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 let mut printer = self.printer.borrow_mut();
1148 write!(printer, "{output}")?;
1149 printer.flush()?;
1150 }
1151 None => {
1152 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 monitor.record_unknown_event();
1160 }
1161 } else {
1162 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 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 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 if logging_enabled {
1194 writeln!(log_buffer, "{remaining}")?;
1195 }
1196 }
1197 }
1198
1199 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("✓")); 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("…")); }
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 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("►")); }
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("✗")); 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 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}