1use std::collections::{BTreeMap, BTreeSet};
2
3#[derive(Clone, Debug, PartialEq, Eq)]
4pub struct TextContent {
5 pub text: String,
6}
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct ThinkingContent {
10 pub thinking: String,
11}
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct ToolCall {
15 pub id: String,
16 pub name: String,
17 pub arguments: String,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ToolResult {
22 pub tool_call_id: String,
23 pub content: String,
24 pub is_error: bool,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct JsonEvent {
29 pub event_type: String,
30 pub text: String,
31 pub thinking: String,
32 pub tool_call: Option<ToolCall>,
33 pub tool_result: Option<ToolResult>,
34}
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct ParsedJsonOutput {
38 pub schema_name: String,
39 pub events: Vec<JsonEvent>,
40 pub final_text: String,
41 pub session_id: String,
42 pub error: String,
43 pub usage: BTreeMap<String, i64>,
44 pub cost_usd: f64,
45 pub duration_ms: i64,
46 pub unknown_json_lines: Vec<String>,
47}
48
49fn new_output(schema_name: &str) -> ParsedJsonOutput {
50 ParsedJsonOutput {
51 schema_name: schema_name.into(),
52 events: Vec::new(),
53 final_text: String::new(),
54 session_id: String::new(),
55 error: String::new(),
56 usage: BTreeMap::new(),
57 cost_usd: 0.0,
58 duration_ms: 0,
59 unknown_json_lines: Vec::new(),
60 }
61}
62
63fn parser_state(
64 result: &ParsedJsonOutput,
65) -> (
66 usize,
67 String,
68 String,
69 String,
70 BTreeMap<String, i64>,
71 i64,
72 u64,
73) {
74 (
75 result.events.len(),
76 result.final_text.clone(),
77 result.error.clone(),
78 result.session_id.clone(),
79 result.usage.clone(),
80 result.duration_ms,
81 result.cost_usd.to_bits(),
82 )
83}
84
85fn parse_json_line(line: &str) -> Option<serde_json::Value> {
86 let trimmed = line.trim();
87 if trimmed.is_empty() {
88 return None;
89 }
90 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
91 if value.is_object() {
92 Some(value)
93 } else {
94 None
95 }
96}
97
98fn apply_opencode_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) {
99 if let Some(text) = obj.get("response").and_then(|value| value.as_str()) {
100 result.final_text = text.to_string();
101 result.events.push(JsonEvent {
102 event_type: "text".into(),
103 text: text.into(),
104 thinking: String::new(),
105 tool_call: None,
106 tool_result: None,
107 });
108 } else if let Some(err) = obj.get("error").and_then(|value| value.as_str()) {
109 result.error = err.to_string();
110 result.events.push(JsonEvent {
111 event_type: "error".into(),
112 text: err.into(),
113 thinking: String::new(),
114 tool_call: None,
115 tool_result: None,
116 });
117 } else if obj.get("type").and_then(|value| value.as_str()) == Some("reasoning") {
118 if let Some(session_id) = obj.get("sessionID").and_then(|value| value.as_str()) {
119 if !session_id.is_empty() {
120 result.session_id = session_id.to_string();
121 }
122 }
123 if let Some(part) = obj.get("part").and_then(|value| value.as_object()) {
124 if result.session_id.is_empty() {
125 if let Some(part_session_id) = part.get("sessionID").and_then(|value| value.as_str()) {
126 if !part_session_id.is_empty() {
127 result.session_id = part_session_id.to_string();
128 }
129 }
130 }
131 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
132 if !text.is_empty() {
133 result.events.push(JsonEvent {
134 event_type: "thinking".into(),
135 text: String::new(),
136 thinking: text.to_string(),
137 tool_call: None,
138 tool_result: None,
139 });
140 }
141 }
142 }
143 } else if obj.get("type").and_then(|value| value.as_str()) == Some("step_start") {
144 result.session_id = obj
145 .get("sessionID")
146 .and_then(|value| value.as_str())
147 .unwrap_or(&result.session_id)
148 .to_string();
149 } else if obj.get("type").and_then(|value| value.as_str()) == Some("text") {
150 if let Some(part) = obj.get("part").and_then(|value| value.as_object()) {
151 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
152 if !text.is_empty() {
153 result.final_text = text.to_string();
154 result.events.push(JsonEvent {
155 event_type: "text".into(),
156 text: text.into(),
157 thinking: String::new(),
158 tool_call: None,
159 tool_result: None,
160 });
161 }
162 }
163 }
164 } else if obj.get("type").and_then(|value| value.as_str()) == Some("tool_use") {
165 if let Some(part) = obj.get("part").and_then(|value| value.as_object()) {
166 let tool_name = part
167 .get("tool")
168 .and_then(|value| value.as_str())
169 .unwrap_or("")
170 .to_string();
171 let call_id = part
172 .get("callID")
173 .and_then(|value| value.as_str())
174 .unwrap_or("")
175 .to_string();
176 let state = part
177 .get("state")
178 .and_then(|value| value.as_object())
179 .cloned()
180 .unwrap_or_default();
181 let tool_input = state
182 .get("input")
183 .cloned()
184 .unwrap_or(serde_json::Value::Null);
185 let tool_output = state
186 .get("output")
187 .and_then(|value| value.as_str())
188 .unwrap_or("")
189 .to_string();
190 let is_error = state
191 .get("status")
192 .and_then(|value| value.as_str())
193 .map(|value| value.eq_ignore_ascii_case("error"))
194 .unwrap_or(false);
195 result.events.push(JsonEvent {
196 event_type: "tool_use".into(),
197 text: String::new(),
198 thinking: String::new(),
199 tool_call: Some(ToolCall {
200 id: call_id.clone(),
201 name: tool_name,
202 arguments: serde_json::to_string(&tool_input).unwrap_or_default(),
203 }),
204 tool_result: None,
205 });
206 result.events.push(JsonEvent {
207 event_type: "tool_result".into(),
208 text: String::new(),
209 thinking: String::new(),
210 tool_call: None,
211 tool_result: Some(ToolResult {
212 tool_call_id: call_id,
213 content: tool_output,
214 is_error,
215 }),
216 });
217 }
218 } else if obj.get("type").and_then(|value| value.as_str()) == Some("step_finish") {
219 if let Some(part) = obj.get("part").and_then(|value| value.as_object()) {
220 if let Some(tokens) = part.get("tokens").and_then(|value| value.as_object()) {
221 let mut usage = BTreeMap::new();
222 for key in ["total", "input", "output", "reasoning"] {
223 if let Some(value) = tokens.get(key).and_then(|value| value.as_i64()) {
224 usage.insert(key.to_string(), value);
225 }
226 }
227 if let Some(cache) = tokens.get("cache").and_then(|value| value.as_object()) {
228 for key in ["write", "read"] {
229 if let Some(value) = cache.get(key).and_then(|value| value.as_i64()) {
230 usage.insert(format!("cache_{key}"), value);
231 }
232 }
233 }
234 if !usage.is_empty() {
235 result.usage = usage;
236 }
237 }
238 if let Some(cost) = part.get("cost").and_then(|value| value.as_f64()) {
239 result.cost_usd = cost;
240 }
241 }
242 }
243}
244
245fn apply_claude_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) -> bool {
246 let msg_type = obj
247 .get("type")
248 .and_then(|value| value.as_str())
249 .unwrap_or("");
250 match msg_type {
251 "system" => {
252 let subtype = obj
253 .get("subtype")
254 .and_then(|value| value.as_str())
255 .unwrap_or("");
256 if subtype == "init" {
257 result.session_id = obj
258 .get("session_id")
259 .and_then(|value| value.as_str())
260 .unwrap_or("")
261 .to_string();
262 return true;
263 } else if subtype == "api_retry" {
264 result.events.push(JsonEvent {
265 event_type: "system_retry".into(),
266 text: String::new(),
267 thinking: String::new(),
268 tool_call: None,
269 tool_result: None,
270 });
271 return true;
272 } else if matches!(
273 subtype,
274 "hook_started"
275 | "hook_progress"
276 | "hook_response"
277 | "status"
278 | "compact_boundary"
279 | "post_turn_summary"
280 | "local_command_output"
281 | "files_persisted"
282 | "task_notification"
283 | "task_started"
284 | "task_progress"
285 | "session_state_changed"
286 | "elicitation_complete"
287 | "bridge_state"
288 ) {
289 return true;
290 }
291 false
292 }
293 "assistant" => {
294 if let Some(message) = obj.get("message").and_then(|value| value.as_object()) {
295 if let Some(content) = message.get("content").and_then(|value| value.as_array()) {
296 let texts: Vec<String> = content
297 .iter()
298 .filter(|block| {
299 block.get("type").and_then(|value| value.as_str()) == Some("text")
300 })
301 .filter_map(|block| block.get("text").and_then(|value| value.as_str()))
302 .map(|text| text.to_string())
303 .collect();
304 if !texts.is_empty() {
305 result.final_text = texts.join("\n");
306 result.events.push(JsonEvent {
307 event_type: "assistant".into(),
308 text: result.final_text.clone(),
309 thinking: String::new(),
310 tool_call: None,
311 tool_result: None,
312 });
313 }
314 }
315 if let Some(usage) = message.get("usage").and_then(|value| value.as_object()) {
316 result.usage = usage
317 .iter()
318 .filter_map(|(key, value)| value.as_i64().map(|count| (key.clone(), count)))
319 .collect();
320 }
321 }
322 true
323 }
324 "user" => {
325 if let Some(message) = obj.get("message").and_then(|value| value.as_object()) {
326 if let Some(content) = message.get("content").and_then(|value| value.as_array()) {
327 for block in content {
328 if block.get("type").and_then(|value| value.as_str()) == Some("tool_result")
329 {
330 result.events.push(JsonEvent {
331 event_type: "tool_result".into(),
332 text: String::new(),
333 thinking: String::new(),
334 tool_call: None,
335 tool_result: Some(ToolResult {
336 tool_call_id: block
337 .get("tool_use_id")
338 .and_then(|value| value.as_str())
339 .unwrap_or("")
340 .to_string(),
341 content: block
342 .get("content")
343 .and_then(|value| value.as_str())
344 .unwrap_or("")
345 .to_string(),
346 is_error: block
347 .get("is_error")
348 .and_then(|value| value.as_bool())
349 .unwrap_or(false),
350 }),
351 });
352 }
353 }
354 }
355 }
356 true
357 }
358 "stream_event" => {
359 if let Some(event) = obj.get("event").and_then(|value| value.as_object()) {
360 let event_type = event
361 .get("type")
362 .and_then(|value| value.as_str())
363 .unwrap_or("");
364 if event_type == "content_block_delta" {
365 if let Some(delta) = event.get("delta").and_then(|value| value.as_object()) {
366 let delta_type = delta
367 .get("type")
368 .and_then(|value| value.as_str())
369 .unwrap_or("");
370 match delta_type {
371 "text_delta" => result.events.push(JsonEvent {
372 event_type: "text_delta".into(),
373 text: delta
374 .get("text")
375 .and_then(|value| value.as_str())
376 .unwrap_or("")
377 .to_string(),
378 thinking: String::new(),
379 tool_call: None,
380 tool_result: None,
381 }),
382 "thinking_delta" => result.events.push(JsonEvent {
383 event_type: "thinking_delta".into(),
384 text: String::new(),
385 thinking: delta
386 .get("thinking")
387 .and_then(|value| value.as_str())
388 .unwrap_or("")
389 .to_string(),
390 tool_call: None,
391 tool_result: None,
392 }),
393 "input_json_delta" => result.events.push(JsonEvent {
394 event_type: "tool_input_delta".into(),
395 text: delta
396 .get("partial_json")
397 .and_then(|value| value.as_str())
398 .unwrap_or("")
399 .to_string(),
400 thinking: String::new(),
401 tool_call: None,
402 tool_result: None,
403 }),
404 "signature_delta" | "citations_delta" | "connector_text_delta" => {}
405 _ => {}
406 }
407 }
408 } else if event_type == "content_block_start" {
409 if let Some(content_block) = event
410 .get("content_block")
411 .and_then(|value| value.as_object())
412 {
413 let block_type = content_block
414 .get("type")
415 .and_then(|value| value.as_str())
416 .unwrap_or("");
417 if block_type == "thinking" {
418 result.events.push(JsonEvent {
419 event_type: "thinking_start".into(),
420 text: String::new(),
421 thinking: String::new(),
422 tool_call: None,
423 tool_result: None,
424 });
425 } else if block_type == "tool_use" {
426 result.events.push(JsonEvent {
427 event_type: "tool_use_start".into(),
428 text: String::new(),
429 thinking: String::new(),
430 tool_call: Some(ToolCall {
431 id: content_block
432 .get("id")
433 .and_then(|value| value.as_str())
434 .unwrap_or("")
435 .to_string(),
436 name: content_block
437 .get("name")
438 .and_then(|value| value.as_str())
439 .unwrap_or("")
440 .to_string(),
441 arguments: String::new(),
442 }),
443 tool_result: None,
444 });
445 }
446 }
448 }
449 }
450 true
451 }
452 "tool_use" => {
453 let tool_input = obj
454 .get("tool_input")
455 .cloned()
456 .unwrap_or(serde_json::Value::Null);
457 result.events.push(JsonEvent {
458 event_type: "tool_use".into(),
459 text: String::new(),
460 thinking: String::new(),
461 tool_call: Some(ToolCall {
462 id: String::new(),
463 name: obj
464 .get("tool_name")
465 .and_then(|value| value.as_str())
466 .unwrap_or("")
467 .to_string(),
468 arguments: serde_json::to_string(&tool_input).unwrap_or_default(),
469 }),
470 tool_result: None,
471 });
472 true
473 }
474 "tool_result" => {
475 result.events.push(JsonEvent {
476 event_type: "tool_result".into(),
477 text: String::new(),
478 thinking: String::new(),
479 tool_call: None,
480 tool_result: Some(ToolResult {
481 tool_call_id: obj
482 .get("tool_use_id")
483 .and_then(|value| value.as_str())
484 .unwrap_or("")
485 .to_string(),
486 content: obj
487 .get("content")
488 .and_then(|value| value.as_str())
489 .unwrap_or("")
490 .to_string(),
491 is_error: obj
492 .get("is_error")
493 .and_then(|value| value.as_bool())
494 .unwrap_or(false),
495 }),
496 });
497 true
498 }
499 "result" => {
500 let subtype = obj
501 .get("subtype")
502 .and_then(|value| value.as_str())
503 .unwrap_or("");
504 if subtype == "success" {
505 result.final_text = obj
506 .get("result")
507 .and_then(|value| value.as_str())
508 .unwrap_or(&result.final_text)
509 .to_string();
510 result.cost_usd = obj
511 .get("cost_usd")
512 .and_then(|value| value.as_f64())
513 .unwrap_or(0.0);
514 result.duration_ms = obj
515 .get("duration_ms")
516 .and_then(|value| value.as_i64())
517 .unwrap_or(0);
518 if let Some(usage) = obj.get("usage").and_then(|value| value.as_object()) {
519 result.usage = usage
520 .iter()
521 .filter_map(|(key, value)| value.as_i64().map(|count| (key.clone(), count)))
522 .collect();
523 }
524 result.events.push(JsonEvent {
525 event_type: "result".into(),
526 text: result.final_text.clone(),
527 thinking: String::new(),
528 tool_call: None,
529 tool_result: None,
530 });
531 true
532 } else if matches!(
533 subtype,
534 "error"
535 | "error_during_execution"
536 | "error_max_turns"
537 | "error_max_budget_usd"
538 | "error_max_structured_output_retries"
539 ) {
540 result.error = obj
541 .get("error")
542 .and_then(|value| value.as_str())
543 .unwrap_or("")
544 .to_string();
545 result.events.push(JsonEvent {
546 event_type: "error".into(),
547 text: result.error.clone(),
548 thinking: String::new(),
549 tool_call: None,
550 tool_result: None,
551 });
552 true
553 } else {
554 false
555 }
556 }
557 "rate_limit_event"
558 | "tool_progress"
559 | "tool_use_summary"
560 | "auth_status"
561 | "streamlined_text"
562 | "streamlined_tool_use_summary"
563 | "prompt_suggestion" => true,
564 _ => false,
565 }
566}
567
568fn apply_kimi_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) {
569 let passthrough_events = [
570 "TurnBegin",
571 "StepBegin",
572 "StepInterrupted",
573 "TurnEnd",
574 "StatusUpdate",
575 "HookTriggered",
576 "HookResolved",
577 "ApprovalRequest",
578 "SubagentEvent",
579 "ToolCallRequest",
580 ];
581 let wire_type = obj
582 .get("type")
583 .and_then(|value| value.as_str())
584 .unwrap_or("");
585 if passthrough_events.contains(&wire_type) {
586 result.events.push(JsonEvent {
587 event_type: wire_type.to_ascii_lowercase(),
588 text: String::new(),
589 thinking: String::new(),
590 tool_call: None,
591 tool_result: None,
592 });
593 return;
594 }
595
596 let role = obj
597 .get("role")
598 .and_then(|value| value.as_str())
599 .unwrap_or("");
600 if role == "assistant" {
601 if let Some(text) = obj.get("content").and_then(|value| value.as_str()) {
602 result.final_text = text.to_string();
603 result.events.push(JsonEvent {
604 event_type: "assistant".into(),
605 text: text.to_string(),
606 thinking: String::new(),
607 tool_call: None,
608 tool_result: None,
609 });
610 } else if let Some(parts) = obj.get("content").and_then(|value| value.as_array()) {
611 let mut texts = Vec::new();
612 for part in parts {
613 let part_type = part
614 .get("type")
615 .and_then(|value| value.as_str())
616 .unwrap_or("");
617 if part_type == "text" {
618 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
619 texts.push(text.to_string());
620 }
621 } else if part_type == "think" {
622 result.events.push(JsonEvent {
623 event_type: "thinking".into(),
624 text: String::new(),
625 thinking: part
626 .get("think")
627 .and_then(|value| value.as_str())
628 .unwrap_or("")
629 .to_string(),
630 tool_call: None,
631 tool_result: None,
632 });
633 }
634 }
635 if !texts.is_empty() {
636 result.final_text = texts.join("\n");
637 result.events.push(JsonEvent {
638 event_type: "assistant".into(),
639 text: result.final_text.clone(),
640 thinking: String::new(),
641 tool_call: None,
642 tool_result: None,
643 });
644 }
645 }
646 if let Some(tool_calls) = obj.get("tool_calls").and_then(|value| value.as_array()) {
647 for tool_call in tool_calls {
648 let function = tool_call
649 .get("function")
650 .and_then(|value| value.as_object());
651 result.events.push(JsonEvent {
652 event_type: "tool_call".into(),
653 text: String::new(),
654 thinking: String::new(),
655 tool_call: Some(ToolCall {
656 id: tool_call
657 .get("id")
658 .and_then(|value| value.as_str())
659 .unwrap_or("")
660 .to_string(),
661 name: function
662 .and_then(|f| f.get("name"))
663 .and_then(|value| value.as_str())
664 .unwrap_or("")
665 .to_string(),
666 arguments: function
667 .and_then(|f| f.get("arguments"))
668 .and_then(|value| value.as_str())
669 .unwrap_or("")
670 .to_string(),
671 }),
672 tool_result: None,
673 });
674 }
675 }
676 } else if role == "tool" {
677 let mut texts = Vec::new();
678 if let Some(parts) = obj.get("content").and_then(|value| value.as_array()) {
679 for part in parts {
680 if part.get("type").and_then(|value| value.as_str()) == Some("text") {
681 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
682 if !text.starts_with("<system>") {
683 texts.push(text.to_string());
684 }
685 }
686 }
687 }
688 }
689 result.events.push(JsonEvent {
690 event_type: "tool_result".into(),
691 text: String::new(),
692 thinking: String::new(),
693 tool_call: None,
694 tool_result: Some(ToolResult {
695 tool_call_id: obj
696 .get("tool_call_id")
697 .and_then(|value| value.as_str())
698 .unwrap_or("")
699 .to_string(),
700 content: texts.join("\n"),
701 is_error: false,
702 }),
703 });
704 }
705}
706
707fn message_text(message: &serde_json::Value) -> String {
708 if let Some(text) = message.get("content").and_then(|value| value.as_str()) {
709 return text.to_string();
710 }
711 let Some(content) = message.get("content").and_then(|value| value.as_array()) else {
712 return String::new();
713 };
714 content
715 .iter()
716 .filter(|block| block.get("type").and_then(|value| value.as_str()) == Some("text"))
717 .filter_map(|block| block.get("text").and_then(|value| value.as_str()))
718 .map(str::to_string)
719 .collect::<Vec<_>>()
720 .join("\n")
721}
722
723fn normalize_cursor_text(text: &str) -> String {
724 text.trim_matches('\n').to_string()
725}
726
727fn apply_cursor_agent_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) {
728 match obj
729 .get("type")
730 .and_then(|value| value.as_str())
731 .unwrap_or("")
732 {
733 "system" => {
734 if obj.get("subtype").and_then(|value| value.as_str()) == Some("init") {
735 result.session_id = obj
736 .get("session_id")
737 .and_then(|value| value.as_str())
738 .unwrap_or("")
739 .to_string();
740 }
741 }
742 "assistant" => {
743 let text =
744 normalize_cursor_text(&obj.get("message").map(message_text).unwrap_or_default());
745 if !text.is_empty() {
746 result.final_text = text.clone();
747 result.events.push(JsonEvent {
748 event_type: "assistant".into(),
749 text,
750 thinking: String::new(),
751 tool_call: None,
752 tool_result: None,
753 });
754 }
755 }
756 "result" => {
757 if let Some(session_id) = obj.get("session_id").and_then(|value| value.as_str()) {
758 result.session_id = session_id.to_string();
759 }
760 if let Some(duration) = obj.get("duration_ms").and_then(|value| value.as_i64()) {
761 result.duration_ms = duration;
762 }
763 if let Some(usage) = obj.get("usage").and_then(|value| value.as_object()) {
764 result.usage = usage
765 .iter()
766 .filter_map(|(key, value)| value.as_i64().map(|number| (key.clone(), number)))
767 .collect();
768 }
769 let is_error = obj
770 .get("is_error")
771 .and_then(|value| value.as_bool())
772 .unwrap_or(false);
773 let subtype = obj
774 .get("subtype")
775 .and_then(|value| value.as_str())
776 .unwrap_or("");
777 if subtype == "success" && !is_error {
778 let text = normalize_cursor_text(
779 obj.get("result")
780 .and_then(|value| value.as_str())
781 .unwrap_or(&result.final_text),
782 );
783 result.final_text = text.clone();
784 if !text.is_empty() {
785 result.events.push(JsonEvent {
786 event_type: "result".into(),
787 text,
788 thinking: String::new(),
789 tool_call: None,
790 tool_result: None,
791 });
792 }
793 } else {
794 let text = obj
795 .get("error")
796 .or_else(|| obj.get("result"))
797 .and_then(|value| value.as_str())
798 .unwrap_or("")
799 .to_string();
800 result.error = text.clone();
801 result.events.push(JsonEvent {
802 event_type: "error".into(),
803 text,
804 thinking: String::new(),
805 tool_call: None,
806 tool_result: None,
807 });
808 }
809 }
810 _ => {}
811 }
812}
813
814fn apply_codex_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) -> bool {
815 match obj
816 .get("type")
817 .and_then(|value| value.as_str())
818 .unwrap_or("")
819 {
820 "thread.started" => {
821 result.session_id = obj
822 .get("thread_id")
823 .and_then(|value| value.as_str())
824 .unwrap_or("")
825 .to_string();
826 true
827 }
828 "turn.started" => true,
829 "turn.completed" => {
830 if let Some(usage) = obj.get("usage").and_then(|value| value.as_object()) {
831 result.usage = usage
832 .iter()
833 .filter_map(|(key, value)| value.as_i64().map(|count| (key.clone(), count)))
834 .collect();
835 }
836 true
837 }
838 "error" => {
839 let text = obj
840 .get("message")
841 .or_else(|| obj.get("error"))
842 .map(codex_error_event_text)
843 .unwrap_or_default();
844 record_codex_error(result, text)
845 }
846 "turn.failed" => {
847 let text = obj
848 .get("error")
849 .map(codex_error_event_text)
850 .unwrap_or_default();
851 record_codex_error(result, text)
852 }
853 "item.started" | "item.completed" => {
854 let Some(item) = obj.get("item").and_then(|value| value.as_object()) else {
855 return false;
856 };
857 let item_type = item
858 .get("type")
859 .and_then(|value| value.as_str())
860 .unwrap_or("");
861 if item_type == "agent_message"
862 && obj.get("type").and_then(|value| value.as_str()) == Some("item.completed")
863 {
864 let text = item
865 .get("text")
866 .and_then(|value| value.as_str())
867 .unwrap_or("")
868 .to_string();
869 result.final_text = text.clone();
870 result.events.push(JsonEvent {
871 event_type: "assistant".into(),
872 text,
873 thinking: String::new(),
874 tool_call: None,
875 tool_result: None,
876 });
877 true
878 } else if item_type == "command_execution" {
879 let call_id = item
880 .get("id")
881 .and_then(|value| value.as_str())
882 .unwrap_or("")
883 .to_string();
884 let command = item
885 .get("command")
886 .and_then(|value| value.as_str())
887 .unwrap_or("")
888 .to_string();
889 if obj.get("type").and_then(|value| value.as_str()) == Some("item.started") {
890 result.events.push(JsonEvent {
891 event_type: "tool_use_start".into(),
892 text: String::new(),
893 thinking: String::new(),
894 tool_call: Some(ToolCall {
895 id: call_id,
896 name: "command_execution".into(),
897 arguments: serde_json::json!({ "command": command }).to_string(),
898 }),
899 tool_result: None,
900 });
901 true
902 } else {
903 let status = item
904 .get("status")
905 .and_then(|value| value.as_str())
906 .unwrap_or("");
907 let exit_code = item.get("exit_code").and_then(|value| value.as_i64());
908 result.events.push(JsonEvent {
909 event_type: "tool_result".into(),
910 text: String::new(),
911 thinking: String::new(),
912 tool_call: None,
913 tool_result: Some(ToolResult {
914 tool_call_id: call_id,
915 content: item
916 .get("aggregated_output")
917 .and_then(|value| value.as_str())
918 .unwrap_or("")
919 .to_string(),
920 is_error: exit_code.is_some_and(|code| code != 0)
921 || (!status.is_empty() && status != "completed"),
922 }),
923 });
924 true
925 }
926 } else {
927 false
928 }
929 }
930 _ => false,
931 }
932}
933
934fn record_codex_error(result: &mut ParsedJsonOutput, text: String) -> bool {
935 if text.is_empty() {
936 return false;
937 }
938 if result.error == text {
939 return true;
940 }
941 result.error = text.clone();
942 result.events.push(JsonEvent {
943 event_type: "error".into(),
944 text,
945 thinking: String::new(),
946 tool_call: None,
947 tool_result: None,
948 });
949 true
950}
951
952fn codex_error_event_text(value: &serde_json::Value) -> String {
953 if let Some(obj) = value.as_object() {
954 if let Some(message) = obj.get("message") {
955 let nested = codex_error_event_text(message);
956 if !nested.is_empty() {
957 return nested;
958 }
959 }
960 return format_codex_error_payload(value);
961 }
962
963 let Some(text) = value.as_str().map(str::trim) else {
964 return String::new();
965 };
966 if text.is_empty() {
967 return String::new();
968 }
969 let Ok(decoded) = serde_json::from_str::<serde_json::Value>(text) else {
970 return text.to_string();
971 };
972 if decoded.as_object().is_some() {
973 let formatted = format_codex_error_payload(&decoded);
974 if !formatted.is_empty() {
975 return formatted;
976 }
977 }
978 text.to_string()
979}
980
981fn format_codex_error_payload(payload: &serde_json::Value) -> String {
982 let error = payload.get("error");
983 let error_obj = error.and_then(|value| value.as_object());
984 let message = error_obj
985 .and_then(|obj| obj.get("message"))
986 .or_else(|| payload.get("message"))
987 .or(error)
988 .and_then(|value| value.as_str())
989 .unwrap_or("");
990 if message.is_empty() {
991 return String::new();
992 }
993
994 let status = payload.get("status").and_then(|value| value.as_i64());
995 let error_type = error_obj
996 .and_then(|obj| obj.get("type"))
997 .or_else(|| payload.get("type"))
998 .and_then(|value| value.as_str())
999 .unwrap_or("");
1000 let mut prefix = String::new();
1001 if !error_type.is_empty() && error_type != "error" {
1002 prefix.push_str(error_type);
1003 }
1004 if let Some(status) = status {
1005 if prefix.is_empty() {
1006 prefix = format!("HTTP {status}");
1007 } else {
1008 prefix = format!("{prefix} ({status})");
1009 }
1010 }
1011 if prefix.is_empty() {
1012 message.to_string()
1013 } else {
1014 format!("{prefix}: {message}")
1015 }
1016}
1017
1018fn apply_gemini_stats(
1019 result: &mut ParsedJsonOutput,
1020 stats: &serde_json::Map<String, serde_json::Value>,
1021) {
1022 let usage: BTreeMap<String, i64> = stats
1023 .iter()
1024 .filter_map(|(key, value)| value.as_i64().map(|count| (key.clone(), count)))
1025 .collect();
1026 if !usage.is_empty() {
1027 result.usage = usage;
1028 }
1029 if let Some(duration_ms) = stats.get("duration_ms").and_then(|value| value.as_i64()) {
1030 result.duration_ms = duration_ms;
1031 }
1032}
1033
1034fn apply_gemini_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) -> bool {
1035 if let Some(session_id) = obj.get("session_id").and_then(|value| value.as_str()) {
1036 if !session_id.is_empty() {
1037 result.session_id = session_id.to_string();
1038 }
1039 }
1040
1041 if let Some(response) = obj.get("response").and_then(|value| value.as_str()) {
1042 result.final_text = response.to_string();
1043 if !response.is_empty() {
1044 result.events.push(JsonEvent {
1045 event_type: "assistant".into(),
1046 text: response.into(),
1047 thinking: String::new(),
1048 tool_call: None,
1049 tool_result: None,
1050 });
1051 }
1052 if let Some(stats) = obj.get("stats").and_then(|value| value.as_object()) {
1053 apply_gemini_stats(result, stats);
1054 }
1055 return true;
1056 }
1057
1058 match obj
1059 .get("type")
1060 .and_then(|value| value.as_str())
1061 .unwrap_or("")
1062 {
1063 "init" => true,
1064 "message" => {
1065 let role = obj
1066 .get("role")
1067 .and_then(|value| value.as_str())
1068 .unwrap_or("");
1069 if role == "assistant" {
1070 let text = obj
1071 .get("content")
1072 .and_then(|value| value.as_str())
1073 .unwrap_or("");
1074 result.final_text.push_str(text);
1075 if !text.is_empty() {
1076 result.events.push(JsonEvent {
1077 event_type: if obj
1078 .get("delta")
1079 .and_then(|value| value.as_bool())
1080 .unwrap_or(false)
1081 {
1082 "text_delta".into()
1083 } else {
1084 "assistant".into()
1085 },
1086 text: text.into(),
1087 thinking: String::new(),
1088 tool_call: None,
1089 tool_result: None,
1090 });
1091 }
1092 true
1093 } else {
1094 role == "user"
1095 }
1096 }
1097 "result" => {
1098 if let Some(stats) = obj.get("stats").and_then(|value| value.as_object()) {
1099 apply_gemini_stats(result, stats);
1100 }
1101 let status = obj
1102 .get("status")
1103 .and_then(|value| value.as_str())
1104 .unwrap_or("");
1105 if !status.is_empty() && status != "success" {
1106 result.error = obj
1107 .get("error")
1108 .and_then(|value| value.as_str())
1109 .unwrap_or(status)
1110 .to_string();
1111 result.events.push(JsonEvent {
1112 event_type: "error".into(),
1113 text: result.error.clone(),
1114 thinking: String::new(),
1115 tool_call: None,
1116 tool_result: None,
1117 });
1118 }
1119 true
1120 }
1121 _ => false,
1122 }
1123}
1124
1125pub fn parse_opencode_json(raw: &str) -> ParsedJsonOutput {
1126 let mut result = new_output("opencode");
1127 for line in raw.lines() {
1128 if let Some(obj) = parse_json_line(line) {
1129 let is_reasoning = obj.get("type").and_then(|value| value.as_str()) == Some("reasoning");
1130 let before = parser_state(&result);
1131 apply_opencode_obj(&mut result, &obj);
1132 let after = parser_state(&result);
1133 if before == after && !is_reasoning {
1134 result.unknown_json_lines.push(line.trim().to_string());
1135 }
1136 }
1137 }
1138 result
1139}
1140
1141pub fn parse_claude_code_json(raw: &str) -> ParsedJsonOutput {
1142 let mut result = new_output("claude-code");
1143 for line in raw.lines() {
1144 if let Some(obj) = parse_json_line(line) {
1145 if !apply_claude_obj(&mut result, &obj) {
1146 result.unknown_json_lines.push(line.trim().to_string());
1147 }
1148 }
1149 }
1150 result
1151}
1152
1153pub fn parse_kimi_json(raw: &str) -> ParsedJsonOutput {
1154 let mut result = new_output("kimi");
1155 for line in raw.lines() {
1156 if let Some(obj) = parse_json_line(line) {
1157 let before = parser_state(&result);
1158 apply_kimi_obj(&mut result, &obj);
1159 let after = parser_state(&result);
1160 if before == after {
1161 result.unknown_json_lines.push(line.trim().to_string());
1162 }
1163 }
1164 }
1165 result
1166}
1167
1168pub fn parse_cursor_agent_json(raw: &str) -> ParsedJsonOutput {
1169 let mut result = new_output("cursor-agent");
1170 for line in raw.lines() {
1171 if let Some(obj) = parse_json_line(line) {
1172 let before = parser_state(&result);
1173 apply_cursor_agent_obj(&mut result, &obj);
1174 let after = parser_state(&result);
1175 if before == after {
1176 result.unknown_json_lines.push(line.trim().to_string());
1177 }
1178 }
1179 }
1180 result
1181}
1182
1183pub fn parse_codex_json(raw: &str) -> ParsedJsonOutput {
1184 let mut result = new_output("codex");
1185 for line in raw.lines() {
1186 if let Some(obj) = parse_json_line(line) {
1187 if !apply_codex_obj(&mut result, &obj) {
1188 result.unknown_json_lines.push(line.trim().to_string());
1189 }
1190 }
1191 }
1192 result
1193}
1194
1195pub fn parse_gemini_json(raw: &str) -> ParsedJsonOutput {
1196 let mut result = new_output("gemini");
1197 for line in raw.lines() {
1198 if let Some(obj) = parse_json_line(line) {
1199 if !apply_gemini_obj(&mut result, &obj) {
1200 result.unknown_json_lines.push(line.trim().to_string());
1201 }
1202 }
1203 }
1204 result
1205}
1206
1207fn apply_pi_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) -> bool {
1208 let msg_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1209
1210 match msg_type {
1211 "session" => {
1212 if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {
1213 if !id.is_empty() {
1214 result.session_id = id.to_string();
1215 }
1216 }
1217 true
1218 }
1219 "agent_start" | "agent_end" | "turn_start" | "message_start" | "message_end" => true,
1220 "turn_end" => {
1221 if let Some(message) = obj.get("message").and_then(|v| v.as_object()) {
1222 if let Some(usage) = message.get("usage").and_then(|v| v.as_object()) {
1223 let mut usage_map = BTreeMap::new();
1224 for key in &["input", "output", "cacheRead", "cacheWrite", "totalTokens"] {
1225 if let Some(val) = usage.get(*key).and_then(|v| v.as_i64()) {
1226 usage_map.insert(key.to_string(), val as i64);
1227 }
1228 }
1229 if !usage_map.is_empty() {
1230 result.usage = usage_map;
1231 }
1232 if let Some(cost) = usage.get("cost").and_then(|v| v.as_object()) {
1233 if let Some(total) = cost.get("total").and_then(|v| v.as_f64()) {
1234 result.cost_usd = total;
1235 }
1236 }
1237 }
1238 }
1239 true
1240 }
1241 "message_update" => {
1242 if let Some(event) = obj.get("assistantMessageEvent").and_then(|v| v.as_object()) {
1243 let event_subtype = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
1244 match event_subtype {
1245 "text_end" => {
1246 if let Some(content) = event.get("content").and_then(|v| v.as_str()) {
1247 if !content.is_empty() {
1248 result.final_text = content.to_string();
1249 result.events.push(JsonEvent {
1250 event_type: "text".into(),
1251 text: content.to_string(),
1252 thinking: String::new(),
1253 tool_call: None,
1254 tool_result: None,
1255 });
1256 }
1257 }
1258 true
1259 }
1260 "thinking_end" => {
1261 if let Some(content) = event.get("content").and_then(|v| v.as_str()) {
1262 if !content.is_empty() {
1263 result.events.push(JsonEvent {
1264 event_type: "thinking".into(),
1265 text: String::new(),
1266 thinking: content.to_string(),
1267 tool_call: None,
1268 tool_result: None,
1269 });
1270 }
1271 }
1272 true
1273 }
1274 "toolcall_end" => {
1275 if let Some(tool_call_obj) = event.get("toolCall").and_then(|v| v.as_object()) {
1276 let call_id = tool_call_obj.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
1277 let tool_name = tool_call_obj.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
1278 let arguments = tool_call_obj.get("arguments").map(|v| {
1279 if v.is_object() {
1280 serde_json::to_string(v).unwrap_or_default()
1281 } else {
1282 v.as_str().unwrap_or("").to_string()
1283 }
1284 }).unwrap_or_default();
1285 result.events.push(JsonEvent {
1286 event_type: "tool_use".into(),
1287 text: String::new(),
1288 thinking: String::new(),
1289 tool_call: Some(ToolCall {
1290 id: call_id,
1291 name: tool_name,
1292 arguments,
1293 }),
1294 tool_result: None,
1295 });
1296 }
1297 true
1298 }
1299 _ => true,
1300 }
1301 } else {
1302 true
1303 }
1304 }
1305 "tool_execution_end" => {
1306 let tool_call_id = obj.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string();
1307 let is_error = obj.get("isError").and_then(|v| v.as_bool()).unwrap_or(false);
1308 let mut content = String::new();
1309 if let Some(tool_result) = obj.get("result").and_then(|v| v.as_object()) {
1310 if let Some(content_items) = tool_result.get("content").and_then(|v| v.as_array()) {
1311 let parts: Vec<String> = content_items.iter()
1312 .filter_map(|item| {
1313 if item.get("type").and_then(|v| v.as_str()) == Some("text") {
1314 item.get("text").and_then(|v| v.as_str()).map(|s| s.to_string())
1315 } else {
1316 None
1317 }
1318 })
1319 .collect();
1320 content = parts.join("\n");
1321 }
1322 }
1323 result.events.push(JsonEvent {
1324 event_type: "tool_result".into(),
1325 text: String::new(),
1326 thinking: String::new(),
1327 tool_call: None,
1328 tool_result: Some(ToolResult {
1329 tool_call_id,
1330 content,
1331 is_error,
1332 }),
1333 });
1334 true
1335 }
1336 "tool_execution_start" | "tool_execution_update" => true,
1337 _ => true,
1338 }
1339}
1340
1341pub fn parse_pi_json(raw: &str) -> ParsedJsonOutput {
1342 let mut result = new_output("pi");
1343 for line in raw.lines() {
1344 if let Some(obj) = parse_json_line(line) {
1345 if !apply_pi_obj(&mut result, &obj) {
1346 result.unknown_json_lines.push(line.trim().to_string());
1347 }
1348 }
1349 }
1350 result
1351}
1352
1353fn apply_grok_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) -> bool {
1354 let msg_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1355
1356 if msg_type.is_empty()
1358 && (obj.get("text").is_some()
1359 || obj.get("sessionId").is_some()
1360 || obj.get("stopReason").is_some())
1361 {
1362 if let Some(session_id) = obj.get("sessionId").and_then(|v| v.as_str()) {
1363 if !session_id.is_empty() {
1364 result.session_id = session_id.to_string();
1365 }
1366 }
1367 if let Some(thought) = obj.get("thought").and_then(|v| v.as_str()) {
1368 if !thought.is_empty() {
1369 result.events.push(JsonEvent {
1370 event_type: "thinking".into(),
1371 text: String::new(),
1372 thinking: thought.to_string(),
1373 tool_call: None,
1374 tool_result: None,
1375 });
1376 }
1377 }
1378 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
1379 result.final_text = text.to_string();
1380 if !text.is_empty() {
1381 result.events.push(JsonEvent {
1382 event_type: "assistant".into(),
1383 text: text.to_string(),
1384 thinking: String::new(),
1385 tool_call: None,
1386 tool_result: None,
1387 });
1388 }
1389 }
1390 return true;
1391 }
1392
1393 if let Some(session_id) = obj.get("sessionId").and_then(|v| v.as_str()) {
1394 if !session_id.is_empty() {
1395 result.session_id = session_id.to_string();
1396 }
1397 }
1398
1399 match msg_type {
1400 "text" => {
1401 if let Some(text) = obj.get("data").and_then(|v| v.as_str()) {
1402 result.final_text.push_str(text);
1403 if !text.is_empty() {
1404 result.events.push(JsonEvent {
1405 event_type: "text_delta".into(),
1406 text: text.to_string(),
1407 thinking: String::new(),
1408 tool_call: None,
1409 tool_result: None,
1410 });
1411 }
1412 }
1413 true
1414 }
1415 "thought" => {
1416 if let Some(thinking) = obj.get("data").and_then(|v| v.as_str()) {
1417 if !thinking.is_empty() {
1418 result.events.push(JsonEvent {
1419 event_type: "thinking_delta".into(),
1420 text: String::new(),
1421 thinking: thinking.to_string(),
1422 tool_call: None,
1423 tool_result: None,
1424 });
1425 }
1426 }
1427 true
1428 }
1429 "end" => true,
1430 "error" => {
1431 let message = obj
1432 .get("message")
1433 .and_then(|v| v.as_str())
1434 .or_else(|| obj.get("data").and_then(|v| v.as_str()))
1435 .unwrap_or("error")
1436 .to_string();
1437 result.error = message.clone();
1438 result.events.push(JsonEvent {
1439 event_type: "error".into(),
1440 text: message,
1441 thinking: String::new(),
1442 tool_call: None,
1443 tool_result: None,
1444 });
1445 true
1446 }
1447 "max_turns_reached" | "auto_compact_start" | "auto_compact_end" => true,
1448 _ => false,
1449 }
1450}
1451
1452pub fn parse_grok_json(raw: &str) -> ParsedJsonOutput {
1453 let mut result = new_output("grok");
1454 let stripped = raw.trim();
1455 if stripped.starts_with('{') {
1458 if let Ok(obj) = serde_json::from_str::<serde_json::Value>(stripped) {
1459 if obj.get("type").and_then(|v| v.as_str()) == Some("error") {
1460 let message = obj
1461 .get("message")
1462 .and_then(|v| v.as_str())
1463 .unwrap_or("error")
1464 .to_string();
1465 result.error = message.clone();
1466 result.events.push(JsonEvent {
1467 event_type: "error".into(),
1468 text: message,
1469 thinking: String::new(),
1470 tool_call: None,
1471 tool_result: None,
1472 });
1473 return result;
1474 }
1475 if obj.get("text").is_some()
1476 || obj.get("sessionId").is_some()
1477 || obj.get("stopReason").is_some()
1478 {
1479 apply_grok_obj(&mut result, &obj);
1480 return result;
1481 }
1482 }
1483 }
1484 for line in raw.lines() {
1485 if let Some(obj) = parse_json_line(line) {
1486 if !apply_grok_obj(&mut result, &obj) {
1487 result.unknown_json_lines.push(line.trim().to_string());
1488 }
1489 }
1490 }
1491 result
1492}
1493
1494pub fn parse_json_output(raw: &str, schema: &str) -> ParsedJsonOutput {
1495 match schema {
1496 "opencode" => parse_opencode_json(raw),
1497 "claude-code" => parse_claude_code_json(raw),
1498 "kimi" => parse_kimi_json(raw),
1499 "cursor-agent" => parse_cursor_agent_json(raw),
1500 "codex" => parse_codex_json(raw),
1501 "gemini" => parse_gemini_json(raw),
1502 "pi" => parse_pi_json(raw),
1503 "grok" => parse_grok_json(raw),
1504 _ => ParsedJsonOutput {
1505 schema_name: schema.into(),
1506 events: Vec::new(),
1507 final_text: String::new(),
1508 session_id: String::new(),
1509 error: format!("unknown schema: {schema}"),
1510 usage: BTreeMap::new(),
1511 cost_usd: 0.0,
1512 duration_ms: 0,
1513 unknown_json_lines: Vec::new(),
1514 },
1515 }
1516}
1517
1518fn truncate_to_char_limit(text: &str, max_chars: usize) -> Option<String> {
1519 text.char_indices()
1520 .nth(max_chars)
1521 .map(|(index, _)| text[..index].to_string())
1522}
1523
1524fn summarize_text(text: &str, max_lines: usize, max_chars: usize) -> String {
1525 let lines: Vec<&str> = text.trim().lines().collect();
1526 if lines.is_empty() {
1527 return String::new();
1528 }
1529 let mut clipped = lines
1530 .into_iter()
1531 .take(max_lines)
1532 .collect::<Vec<_>>()
1533 .join("\n");
1534 let mut truncated = text.trim().lines().count() > max_lines;
1535 if let Some(safe_clipped) = truncate_to_char_limit(&clipped, max_chars) {
1536 clipped = safe_clipped;
1537 clipped = clipped.trim_end().to_string();
1538 truncated = true;
1539 }
1540 if truncated {
1541 clipped.push_str(" …");
1542 }
1543 clipped
1544}
1545
1546fn parse_tool_arguments(arguments: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
1547 let value: serde_json::Value = serde_json::from_str(arguments).ok()?;
1548 value.as_object().cloned()
1549}
1550
1551fn bash_command_preview(tool_call: &ToolCall) -> Option<String> {
1552 let args = parse_tool_arguments(&tool_call.arguments)?;
1553 for key in ["command", "cmd", "bash_command", "script"] {
1554 if let Some(value) = args.get(key).and_then(|value| value.as_str()) {
1555 let mut preview = value.trim().to_string();
1556 if preview.is_empty() {
1557 continue;
1558 }
1559 if let Some(safe_preview) = truncate_to_char_limit(&preview, 400) {
1560 preview = safe_preview.trim_end().to_string() + " …";
1561 }
1562 return Some(preview);
1563 }
1564 }
1565 None
1566}
1567
1568fn tool_preview(tool_name: &str, text: &str) -> String {
1569 match tool_name.to_ascii_lowercase().as_str() {
1570 "read" | "write" | "edit" | "multiedit" | "read_file" | "write_file" | "edit_file" => {
1571 String::new()
1572 }
1573 _ => summarize_text(text, 8, 400),
1574 }
1575}
1576
1577pub fn resolve_human_tty(tty: bool, force_color: Option<&str>, no_color: Option<&str>) -> bool {
1578 if force_color.is_some_and(|value| !value.is_empty()) {
1579 return true;
1580 }
1581 if no_color.is_some_and(|value| !value.is_empty()) {
1582 return false;
1583 }
1584 tty
1585}
1586
1587fn style(text: &str, code: &str, tty: bool) -> String {
1588 if tty {
1589 format!("\x1b[{code}m{text}\x1b[0m")
1590 } else {
1591 text.to_string()
1592 }
1593}
1594
1595pub struct FormattedRenderer {
1596 show_thinking: bool,
1597 tty: bool,
1598 seen_final_texts: BTreeSet<String>,
1599 tool_calls_by_id: BTreeMap<String, ToolCall>,
1600 pending_tool_call: Option<ToolCall>,
1601 streamed_assistant_buffer: String,
1602 plain_text_tool_work: bool,
1603}
1604
1605impl FormattedRenderer {
1606 pub fn new(show_thinking: bool, tty: bool) -> Self {
1607 Self {
1608 show_thinking,
1609 tty,
1610 seen_final_texts: BTreeSet::new(),
1611 tool_calls_by_id: BTreeMap::new(),
1612 pending_tool_call: None,
1613 streamed_assistant_buffer: String::new(),
1614 plain_text_tool_work: false,
1615 }
1616 }
1617
1618 pub fn render_output(&mut self, output: &ParsedJsonOutput) -> String {
1619 output
1620 .events
1621 .iter()
1622 .filter_map(|event| self.render_event(event))
1623 .collect::<Vec<_>>()
1624 .join("\n")
1625 }
1626
1627 pub fn render_event(&mut self, event: &JsonEvent) -> Option<String> {
1628 match event.event_type.as_str() {
1629 "text_delta" if !event.text.is_empty() => {
1630 self.streamed_assistant_buffer.push_str(&event.text);
1631 Some(self.render_message("assistant", &event.text))
1632 }
1633 "text" | "assistant" if !event.text.is_empty() => {
1634 if !self.streamed_assistant_buffer.is_empty()
1635 && event.text == self.streamed_assistant_buffer
1636 {
1637 self.seen_final_texts.insert(event.text.clone());
1638 self.streamed_assistant_buffer.clear();
1639 None
1640 } else {
1641 self.streamed_assistant_buffer.clear();
1642 Some(self.render_message("assistant", &event.text))
1643 }
1644 }
1645 "result" if !event.text.is_empty() => {
1646 if !self.streamed_assistant_buffer.is_empty()
1647 && event.text == self.streamed_assistant_buffer
1648 {
1649 self.seen_final_texts.insert(event.text.clone());
1650 self.streamed_assistant_buffer.clear();
1651 None
1652 } else if self.seen_final_texts.contains(&event.text) {
1653 None
1654 } else {
1655 self.streamed_assistant_buffer.clear();
1656 Some(self.render_message("success", &event.text))
1657 }
1658 }
1659 "thinking" | "thinking_delta" if !event.thinking.is_empty() && self.show_thinking => {
1660 Some(self.render_message("thinking", &event.thinking))
1661 }
1662 "tool_use" | "tool_use_start" | "tool_call" => {
1663 if let Some(tool_call) = &event.tool_call {
1664 self.streamed_assistant_buffer.clear();
1665 if !tool_call.id.is_empty() {
1666 self.tool_calls_by_id
1667 .insert(tool_call.id.clone(), tool_call.clone());
1668 }
1669 self.pending_tool_call = Some(tool_call.clone());
1670 self.plain_text_tool_work = true;
1671 Some(self.render_tool_start(tool_call))
1672 } else {
1673 None
1674 }
1675 }
1676 "tool_input_delta" if !event.text.is_empty() => {
1677 if let Some(tool_call) = &mut self.pending_tool_call {
1678 tool_call.arguments.push_str(&event.text);
1679 if !tool_call.id.is_empty() {
1680 self.tool_calls_by_id
1681 .insert(tool_call.id.clone(), tool_call.clone());
1682 }
1683 }
1684 None
1685 }
1686 "tool_result" => event.tool_result.as_ref().map(|tool_result| {
1687 self.streamed_assistant_buffer.clear();
1688 self.render_tool_result(tool_result)
1689 }),
1690 "error" if !event.text.is_empty() => {
1691 self.streamed_assistant_buffer.clear();
1692 Some(self.render_message("error", &event.text))
1693 }
1694 _ => None,
1695 }
1696 }
1697
1698 fn render_message(&mut self, kind: &str, text: &str) -> String {
1699 if matches!(kind, "assistant" | "success") {
1700 self.seen_final_texts.insert(text.to_string());
1701 }
1702 let prefix = match kind {
1703 "assistant" => renderer_prefix(
1704 "💬",
1705 "[assistant]",
1706 "96",
1707 self.tty,
1708 self.plain_text_tool_work,
1709 ),
1710 "thinking" => renderer_prefix(
1711 "🧠",
1712 "[thinking]",
1713 "2;35",
1714 self.tty,
1715 self.plain_text_tool_work,
1716 ),
1717 "success" => renderer_prefix("✅", "[ok]", "92", self.tty, self.plain_text_tool_work),
1718 _ => renderer_prefix("❌", "[error]", "91", self.tty, self.plain_text_tool_work),
1719 };
1720 with_prefix(&prefix, text)
1721 }
1722
1723 fn render_tool_start(&self, tool_call: &ToolCall) -> String {
1724 let prefix = prefix("🛠️", "[tool:start]", "94", self.tty);
1725 let mut detail = tool_call.name.clone();
1726 if let Some(preview) = bash_command_preview(tool_call) {
1727 detail.push_str(": ");
1728 detail.push_str(&preview);
1729 }
1730 with_prefix(&prefix, &detail)
1731 }
1732
1733 fn render_tool_result(&self, tool_result: &ToolResult) -> String {
1734 let prefix = prefix("📎", "[tool:result]", "36", self.tty);
1735 let tool_call = self
1736 .tool_calls_by_id
1737 .get(&tool_result.tool_call_id)
1738 .or(self.pending_tool_call.as_ref());
1739 let tool_name = tool_call
1740 .map(|tool_call| tool_call.name.clone())
1741 .unwrap_or_else(|| "tool".into());
1742 let mut summary = format!(
1743 "{} ({})",
1744 tool_name,
1745 if tool_result.is_error { "error" } else { "ok" }
1746 );
1747 if let Some(tool_call) = tool_call {
1748 if let Some(preview) = bash_command_preview(tool_call) {
1749 summary.push_str(": ");
1750 summary.push_str(&preview);
1751 }
1752 }
1753 let preview = tool_preview(&tool_name, &tool_result.content);
1754 if !preview.is_empty() {
1755 summary.push('\n');
1756 summary.push_str(&preview);
1757 }
1758 with_prefix(&prefix, &summary)
1759 }
1760}
1761
1762fn prefix(emoji: &str, plain: &str, color_code: &str, tty: bool) -> String {
1763 if tty {
1764 style(emoji, color_code, true)
1765 } else {
1766 plain.to_string()
1767 }
1768}
1769
1770fn renderer_prefix(
1771 emoji: &str,
1772 plain: &str,
1773 color_code: &str,
1774 tty: bool,
1775 plain_text_tool_work: bool,
1776) -> String {
1777 if tty {
1778 return style(emoji, color_code, true);
1779 }
1780 if plain_text_tool_work && matches!(plain, "[assistant]" | "[thinking]" | "[ok]" | "[error]") {
1781 return plain.to_string();
1782 }
1783 plain.to_string()
1784}
1785
1786fn with_prefix(prefix: &str, text: &str) -> String {
1787 text.lines()
1788 .map(|line| {
1789 if line.is_empty() {
1790 prefix.to_string()
1791 } else {
1792 format!("{prefix} {line}")
1793 }
1794 })
1795 .collect::<Vec<_>>()
1796 .join("\n")
1797}
1798
1799pub struct StructuredStreamProcessor {
1800 schema: String,
1801 renderer: FormattedRenderer,
1802 output: ParsedJsonOutput,
1803 buffer: String,
1804 unknown_json_lines: Vec<String>,
1805}
1806
1807impl StructuredStreamProcessor {
1808 pub fn new(schema: &str, renderer: FormattedRenderer) -> Self {
1809 Self {
1810 schema: schema.into(),
1811 renderer,
1812 output: new_output(schema),
1813 buffer: String::new(),
1814 unknown_json_lines: Vec::new(),
1815 }
1816 }
1817
1818 pub fn output(&self) -> &ParsedJsonOutput {
1819 &self.output
1820 }
1821
1822 pub fn feed(&mut self, chunk: &str) -> String {
1823 self.buffer.push_str(chunk);
1824 let mut rendered = Vec::new();
1825 while let Some(index) = self.buffer.find('\n') {
1826 let line = self.buffer[..index].to_string();
1827 self.buffer = self.buffer[index + 1..].to_string();
1828 if let Some(obj) = parse_json_line(&line) {
1829 let before = parser_state(&self.output);
1830 let event_count = self.output.events.len();
1831 let recognized = self.apply(&obj);
1832 let after = parser_state(&self.output);
1833 if before == after && !recognized {
1834 self.unknown_json_lines.push(line.trim().to_string());
1835 }
1836 for event in &self.output.events[event_count..] {
1837 if let Some(text) = self.renderer.render_event(event) {
1838 rendered.push(text);
1839 }
1840 }
1841 }
1842 }
1843 rendered.join("\n")
1844 }
1845
1846 pub fn finish(&mut self) -> String {
1847 if self.buffer.trim().is_empty() {
1848 return String::new();
1849 }
1850 let line = std::mem::take(&mut self.buffer);
1851 if let Some(obj) = parse_json_line(&line) {
1852 let before = parser_state(&self.output);
1853 let event_count = self.output.events.len();
1854 let recognized = self.apply(&obj);
1855 let after = parser_state(&self.output);
1856 if before == after && !recognized {
1857 self.unknown_json_lines.push(line.trim().to_string());
1858 }
1859 return self.output.events[event_count..]
1860 .iter()
1861 .filter_map(|event| self.renderer.render_event(event))
1862 .collect::<Vec<_>>()
1863 .join("\n");
1864 }
1865 String::new()
1866 }
1867
1868 pub fn take_unknown_json_lines(&mut self) -> Vec<String> {
1869 std::mem::take(&mut self.unknown_json_lines)
1870 }
1871
1872 fn apply(&mut self, obj: &serde_json::Value) -> bool {
1873 match self.schema.as_str() {
1874 "opencode" => {
1875 apply_opencode_obj(&mut self.output, obj);
1876 false
1877 }
1878 "claude-code" => apply_claude_obj(&mut self.output, obj),
1879 "kimi" => {
1880 apply_kimi_obj(&mut self.output, obj);
1881 false
1882 }
1883 "cursor-agent" => {
1884 apply_cursor_agent_obj(&mut self.output, obj);
1885 false
1886 }
1887 "codex" => apply_codex_obj(&mut self.output, obj),
1888 "gemini" => apply_gemini_obj(&mut self.output, obj),
1889 "pi" => apply_pi_obj(&mut self.output, obj),
1890 "grok" => apply_grok_obj(&mut self.output, obj),
1891 _ => false,
1892 }
1893 }
1894}
1895
1896pub fn render_parsed(output: &ParsedJsonOutput, show_thinking: bool, tty: bool) -> String {
1897 let mut renderer = FormattedRenderer::new(show_thinking, tty);
1898 let rendered = renderer.render_output(output);
1899 if rendered.is_empty() {
1900 output.final_text.clone()
1901 } else {
1902 rendered
1903 }
1904}