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 parse_json_line(line: &str) -> Option<serde_json::Value> {
64 let trimmed = line.trim();
65 if trimmed.is_empty() {
66 return None;
67 }
68 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
69 if value.is_object() {
70 Some(value)
71 } else {
72 None
73 }
74}
75
76fn apply_opencode_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) {
77 if let Some(text) = obj.get("response").and_then(|value| value.as_str()) {
78 result.final_text = text.to_string();
79 result.events.push(JsonEvent {
80 event_type: "text".into(),
81 text: text.into(),
82 thinking: String::new(),
83 tool_call: None,
84 tool_result: None,
85 });
86 } else if let Some(err) = obj.get("error").and_then(|value| value.as_str()) {
87 result.error = err.to_string();
88 result.events.push(JsonEvent {
89 event_type: "error".into(),
90 text: err.into(),
91 thinking: String::new(),
92 tool_call: None,
93 tool_result: None,
94 });
95 } else if obj.get("type").and_then(|value| value.as_str()) == Some("step_start") {
96 result.session_id = obj
97 .get("sessionID")
98 .and_then(|value| value.as_str())
99 .unwrap_or(&result.session_id)
100 .to_string();
101 } else if obj.get("type").and_then(|value| value.as_str()) == Some("text") {
102 if let Some(part) = obj.get("part").and_then(|value| value.as_object()) {
103 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
104 if !text.is_empty() {
105 result.final_text = text.to_string();
106 result.events.push(JsonEvent {
107 event_type: "text".into(),
108 text: text.into(),
109 thinking: String::new(),
110 tool_call: None,
111 tool_result: None,
112 });
113 }
114 }
115 }
116 } else if obj.get("type").and_then(|value| value.as_str()) == Some("step_finish") {
117 if let Some(part) = obj.get("part").and_then(|value| value.as_object()) {
118 if let Some(tokens) = part.get("tokens").and_then(|value| value.as_object()) {
119 let mut usage = BTreeMap::new();
120 for key in ["total", "input", "output", "reasoning"] {
121 if let Some(value) = tokens.get(key).and_then(|value| value.as_i64()) {
122 usage.insert(key.to_string(), value);
123 }
124 }
125 if let Some(cache) = tokens.get("cache").and_then(|value| value.as_object()) {
126 for key in ["write", "read"] {
127 if let Some(value) = cache.get(key).and_then(|value| value.as_i64()) {
128 usage.insert(format!("cache_{key}"), value);
129 }
130 }
131 }
132 if !usage.is_empty() {
133 result.usage = usage;
134 }
135 }
136 if let Some(cost) = part.get("cost").and_then(|value| value.as_f64()) {
137 result.cost_usd = cost;
138 }
139 }
140 }
141}
142
143fn apply_claude_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) {
144 let msg_type = obj.get("type").and_then(|value| value.as_str()).unwrap_or("");
145 match msg_type {
146 "system" => {
147 let subtype = obj.get("subtype").and_then(|value| value.as_str()).unwrap_or("");
148 if subtype == "init" {
149 result.session_id = obj
150 .get("session_id")
151 .and_then(|value| value.as_str())
152 .unwrap_or("")
153 .to_string();
154 } else if subtype == "api_retry" {
155 result.events.push(JsonEvent {
156 event_type: "system_retry".into(),
157 text: String::new(),
158 thinking: String::new(),
159 tool_call: None,
160 tool_result: None,
161 });
162 }
163 }
164 "assistant" => {
165 if let Some(message) = obj.get("message").and_then(|value| value.as_object()) {
166 if let Some(content) = message.get("content").and_then(|value| value.as_array()) {
167 let texts: Vec<String> = content
168 .iter()
169 .filter(|block| block.get("type").and_then(|value| value.as_str()) == Some("text"))
170 .filter_map(|block| block.get("text").and_then(|value| value.as_str()))
171 .map(|text| text.to_string())
172 .collect();
173 if !texts.is_empty() {
174 result.final_text = texts.join("\n");
175 result.events.push(JsonEvent {
176 event_type: "assistant".into(),
177 text: result.final_text.clone(),
178 thinking: String::new(),
179 tool_call: None,
180 tool_result: None,
181 });
182 }
183 }
184 if let Some(usage) = message.get("usage").and_then(|value| value.as_object()) {
185 result.usage = usage
186 .iter()
187 .filter_map(|(key, value)| value.as_i64().map(|count| (key.clone(), count)))
188 .collect();
189 }
190 }
191 }
192 "user" => {
193 if let Some(message) = obj.get("message").and_then(|value| value.as_object()) {
194 if let Some(content) = message.get("content").and_then(|value| value.as_array()) {
195 for block in content {
196 if block.get("type").and_then(|value| value.as_str()) == Some("tool_result") {
197 result.events.push(JsonEvent {
198 event_type: "tool_result".into(),
199 text: String::new(),
200 thinking: String::new(),
201 tool_call: None,
202 tool_result: Some(ToolResult {
203 tool_call_id: block
204 .get("tool_use_id")
205 .and_then(|value| value.as_str())
206 .unwrap_or("")
207 .to_string(),
208 content: block
209 .get("content")
210 .and_then(|value| value.as_str())
211 .unwrap_or("")
212 .to_string(),
213 is_error: block
214 .get("is_error")
215 .and_then(|value| value.as_bool())
216 .unwrap_or(false),
217 }),
218 });
219 }
220 }
221 }
222 }
223 }
224 "stream_event" => {
225 if let Some(event) = obj.get("event").and_then(|value| value.as_object()) {
226 let event_type = event.get("type").and_then(|value| value.as_str()).unwrap_or("");
227 if event_type == "content_block_delta" {
228 if let Some(delta) = event.get("delta").and_then(|value| value.as_object()) {
229 let delta_type =
230 delta.get("type").and_then(|value| value.as_str()).unwrap_or("");
231 match delta_type {
232 "text_delta" => result.events.push(JsonEvent {
233 event_type: "text_delta".into(),
234 text: delta
235 .get("text")
236 .and_then(|value| value.as_str())
237 .unwrap_or("")
238 .to_string(),
239 thinking: String::new(),
240 tool_call: None,
241 tool_result: None,
242 }),
243 "thinking_delta" => result.events.push(JsonEvent {
244 event_type: "thinking_delta".into(),
245 text: String::new(),
246 thinking: delta
247 .get("thinking")
248 .and_then(|value| value.as_str())
249 .unwrap_or("")
250 .to_string(),
251 tool_call: None,
252 tool_result: None,
253 }),
254 "input_json_delta" => result.events.push(JsonEvent {
255 event_type: "tool_input_delta".into(),
256 text: delta
257 .get("partial_json")
258 .and_then(|value| value.as_str())
259 .unwrap_or("")
260 .to_string(),
261 thinking: String::new(),
262 tool_call: None,
263 tool_result: None,
264 }),
265 _ => {}
266 }
267 }
268 } else if event_type == "content_block_start" {
269 if let Some(content_block) =
270 event.get("content_block").and_then(|value| value.as_object())
271 {
272 let block_type = content_block
273 .get("type")
274 .and_then(|value| value.as_str())
275 .unwrap_or("");
276 if block_type == "thinking" {
277 result.events.push(JsonEvent {
278 event_type: "thinking_start".into(),
279 text: String::new(),
280 thinking: String::new(),
281 tool_call: None,
282 tool_result: None,
283 });
284 } else if block_type == "tool_use" {
285 result.events.push(JsonEvent {
286 event_type: "tool_use_start".into(),
287 text: String::new(),
288 thinking: String::new(),
289 tool_call: Some(ToolCall {
290 id: content_block
291 .get("id")
292 .and_then(|value| value.as_str())
293 .unwrap_or("")
294 .to_string(),
295 name: content_block
296 .get("name")
297 .and_then(|value| value.as_str())
298 .unwrap_or("")
299 .to_string(),
300 arguments: String::new(),
301 }),
302 tool_result: None,
303 });
304 }
305 }
306 }
307 }
308 }
309 "tool_use" => {
310 let tool_input = obj.get("tool_input").cloned().unwrap_or(serde_json::Value::Null);
311 result.events.push(JsonEvent {
312 event_type: "tool_use".into(),
313 text: String::new(),
314 thinking: String::new(),
315 tool_call: Some(ToolCall {
316 id: String::new(),
317 name: obj
318 .get("tool_name")
319 .and_then(|value| value.as_str())
320 .unwrap_or("")
321 .to_string(),
322 arguments: serde_json::to_string(&tool_input).unwrap_or_default(),
323 }),
324 tool_result: None,
325 });
326 }
327 "tool_result" => {
328 result.events.push(JsonEvent {
329 event_type: "tool_result".into(),
330 text: String::new(),
331 thinking: String::new(),
332 tool_call: None,
333 tool_result: Some(ToolResult {
334 tool_call_id: obj
335 .get("tool_use_id")
336 .and_then(|value| value.as_str())
337 .unwrap_or("")
338 .to_string(),
339 content: obj
340 .get("content")
341 .and_then(|value| value.as_str())
342 .unwrap_or("")
343 .to_string(),
344 is_error: obj
345 .get("is_error")
346 .and_then(|value| value.as_bool())
347 .unwrap_or(false),
348 }),
349 });
350 }
351 "result" => {
352 let subtype = obj.get("subtype").and_then(|value| value.as_str()).unwrap_or("");
353 if subtype == "success" {
354 result.final_text = obj
355 .get("result")
356 .and_then(|value| value.as_str())
357 .unwrap_or(&result.final_text)
358 .to_string();
359 result.cost_usd = obj
360 .get("cost_usd")
361 .and_then(|value| value.as_f64())
362 .unwrap_or(0.0);
363 result.duration_ms = obj
364 .get("duration_ms")
365 .and_then(|value| value.as_i64())
366 .unwrap_or(0);
367 if let Some(usage) = obj.get("usage").and_then(|value| value.as_object()) {
368 result.usage = usage
369 .iter()
370 .filter_map(|(key, value)| value.as_i64().map(|count| (key.clone(), count)))
371 .collect();
372 }
373 result.events.push(JsonEvent {
374 event_type: "result".into(),
375 text: result.final_text.clone(),
376 thinking: String::new(),
377 tool_call: None,
378 tool_result: None,
379 });
380 } else if subtype == "error" {
381 result.error = obj
382 .get("error")
383 .and_then(|value| value.as_str())
384 .unwrap_or("")
385 .to_string();
386 result.events.push(JsonEvent {
387 event_type: "error".into(),
388 text: result.error.clone(),
389 thinking: String::new(),
390 tool_call: None,
391 tool_result: None,
392 });
393 }
394 }
395 _ => {}
396 }
397}
398
399fn apply_kimi_obj(result: &mut ParsedJsonOutput, obj: &serde_json::Value) {
400 let passthrough_events = [
401 "TurnBegin",
402 "StepBegin",
403 "StepInterrupted",
404 "TurnEnd",
405 "StatusUpdate",
406 "HookTriggered",
407 "HookResolved",
408 "ApprovalRequest",
409 "SubagentEvent",
410 "ToolCallRequest",
411 ];
412 let wire_type = obj.get("type").and_then(|value| value.as_str()).unwrap_or("");
413 if passthrough_events.contains(&wire_type) {
414 result.events.push(JsonEvent {
415 event_type: wire_type.to_ascii_lowercase(),
416 text: String::new(),
417 thinking: String::new(),
418 tool_call: None,
419 tool_result: None,
420 });
421 return;
422 }
423
424 let role = obj.get("role").and_then(|value| value.as_str()).unwrap_or("");
425 if role == "assistant" {
426 if let Some(text) = obj.get("content").and_then(|value| value.as_str()) {
427 result.final_text = text.to_string();
428 result.events.push(JsonEvent {
429 event_type: "assistant".into(),
430 text: text.to_string(),
431 thinking: String::new(),
432 tool_call: None,
433 tool_result: None,
434 });
435 } else if let Some(parts) = obj.get("content").and_then(|value| value.as_array()) {
436 let mut texts = Vec::new();
437 for part in parts {
438 let part_type = part.get("type").and_then(|value| value.as_str()).unwrap_or("");
439 if part_type == "text" {
440 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
441 texts.push(text.to_string());
442 }
443 } else if part_type == "think" {
444 result.events.push(JsonEvent {
445 event_type: "thinking".into(),
446 text: String::new(),
447 thinking: part
448 .get("think")
449 .and_then(|value| value.as_str())
450 .unwrap_or("")
451 .to_string(),
452 tool_call: None,
453 tool_result: None,
454 });
455 }
456 }
457 if !texts.is_empty() {
458 result.final_text = texts.join("\n");
459 result.events.push(JsonEvent {
460 event_type: "assistant".into(),
461 text: result.final_text.clone(),
462 thinking: String::new(),
463 tool_call: None,
464 tool_result: None,
465 });
466 }
467 }
468 if let Some(tool_calls) = obj.get("tool_calls").and_then(|value| value.as_array()) {
469 for tool_call in tool_calls {
470 let function = tool_call.get("function").and_then(|value| value.as_object());
471 result.events.push(JsonEvent {
472 event_type: "tool_call".into(),
473 text: String::new(),
474 thinking: String::new(),
475 tool_call: Some(ToolCall {
476 id: tool_call
477 .get("id")
478 .and_then(|value| value.as_str())
479 .unwrap_or("")
480 .to_string(),
481 name: function
482 .and_then(|f| f.get("name"))
483 .and_then(|value| value.as_str())
484 .unwrap_or("")
485 .to_string(),
486 arguments: function
487 .and_then(|f| f.get("arguments"))
488 .and_then(|value| value.as_str())
489 .unwrap_or("")
490 .to_string(),
491 }),
492 tool_result: None,
493 });
494 }
495 }
496 } else if role == "tool" {
497 let mut texts = Vec::new();
498 if let Some(parts) = obj.get("content").and_then(|value| value.as_array()) {
499 for part in parts {
500 if part.get("type").and_then(|value| value.as_str()) == Some("text") {
501 if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
502 if !text.starts_with("<system>") {
503 texts.push(text.to_string());
504 }
505 }
506 }
507 }
508 }
509 result.events.push(JsonEvent {
510 event_type: "tool_result".into(),
511 text: String::new(),
512 thinking: String::new(),
513 tool_call: None,
514 tool_result: Some(ToolResult {
515 tool_call_id: obj
516 .get("tool_call_id")
517 .and_then(|value| value.as_str())
518 .unwrap_or("")
519 .to_string(),
520 content: texts.join("\n"),
521 is_error: false,
522 }),
523 });
524 }
525}
526
527pub fn parse_opencode_json(raw: &str) -> ParsedJsonOutput {
528 let mut result = new_output("opencode");
529 for line in raw.lines() {
530 if let Some(obj) = parse_json_line(line) {
531 let before = (
532 result.events.len(),
533 result.final_text.clone(),
534 result.error.clone(),
535 result.session_id.clone(),
536 );
537 apply_opencode_obj(&mut result, &obj);
538 let after = (
539 result.events.len(),
540 result.final_text.clone(),
541 result.error.clone(),
542 result.session_id.clone(),
543 );
544 if before == after {
545 result.unknown_json_lines.push(line.trim().to_string());
546 }
547 }
548 }
549 result
550}
551
552pub fn parse_claude_code_json(raw: &str) -> ParsedJsonOutput {
553 let mut result = new_output("claude-code");
554 for line in raw.lines() {
555 if let Some(obj) = parse_json_line(line) {
556 let before = (
557 result.events.len(),
558 result.final_text.clone(),
559 result.error.clone(),
560 result.session_id.clone(),
561 );
562 apply_claude_obj(&mut result, &obj);
563 let after = (
564 result.events.len(),
565 result.final_text.clone(),
566 result.error.clone(),
567 result.session_id.clone(),
568 );
569 if before == after {
570 result.unknown_json_lines.push(line.trim().to_string());
571 }
572 }
573 }
574 result
575}
576
577pub fn parse_kimi_json(raw: &str) -> ParsedJsonOutput {
578 let mut result = new_output("kimi");
579 for line in raw.lines() {
580 if let Some(obj) = parse_json_line(line) {
581 let before = (
582 result.events.len(),
583 result.final_text.clone(),
584 result.error.clone(),
585 result.session_id.clone(),
586 );
587 apply_kimi_obj(&mut result, &obj);
588 let after = (
589 result.events.len(),
590 result.final_text.clone(),
591 result.error.clone(),
592 result.session_id.clone(),
593 );
594 if before == after {
595 result.unknown_json_lines.push(line.trim().to_string());
596 }
597 }
598 }
599 result
600}
601
602pub fn parse_json_output(raw: &str, schema: &str) -> ParsedJsonOutput {
603 match schema {
604 "opencode" => parse_opencode_json(raw),
605 "claude-code" => parse_claude_code_json(raw),
606 "kimi" => parse_kimi_json(raw),
607 _ => ParsedJsonOutput {
608 schema_name: schema.into(),
609 events: Vec::new(),
610 final_text: String::new(),
611 session_id: String::new(),
612 error: format!("unknown schema: {schema}"),
613 usage: BTreeMap::new(),
614 cost_usd: 0.0,
615 duration_ms: 0,
616 unknown_json_lines: Vec::new(),
617 },
618 }
619}
620
621fn summarize_text(text: &str, max_lines: usize, max_chars: usize) -> String {
622 let lines: Vec<&str> = text.trim().lines().collect();
623 if lines.is_empty() {
624 return String::new();
625 }
626 let mut clipped = lines.into_iter().take(max_lines).collect::<Vec<_>>().join("\n");
627 let truncated = clipped.len() > max_chars || text.trim().lines().count() > max_lines;
628 if clipped.len() > max_chars {
629 clipped.truncate(max_chars);
630 clipped = clipped.trim_end().to_string();
631 }
632 if truncated {
633 clipped.push_str(" …");
634 }
635 clipped
636}
637
638fn parse_tool_arguments(arguments: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
639 let value: serde_json::Value = serde_json::from_str(arguments).ok()?;
640 value.as_object().cloned()
641}
642
643fn bash_command_preview(tool_call: &ToolCall) -> Option<String> {
644 let args = parse_tool_arguments(&tool_call.arguments)?;
645 for key in ["command", "cmd", "bash_command", "script"] {
646 if let Some(value) = args.get(key).and_then(|value| value.as_str()) {
647 let mut preview = value.trim().to_string();
648 if preview.is_empty() {
649 continue;
650 }
651 if preview.len() > 400 {
652 preview.truncate(400);
653 preview = preview.trim_end().to_string();
654 preview.push_str(" …");
655 }
656 return Some(preview);
657 }
658 }
659 None
660}
661
662fn tool_preview(tool_name: &str, text: &str) -> String {
663 match tool_name.to_ascii_lowercase().as_str() {
664 "read" | "write" | "edit" | "multiedit" | "read_file" | "write_file" | "edit_file" => {
665 String::new()
666 }
667 _ => summarize_text(text, 8, 400),
668 }
669}
670
671pub fn resolve_human_tty(tty: bool, force_color: Option<&str>, no_color: Option<&str>) -> bool {
672 if force_color.is_some_and(|value| !value.is_empty()) {
673 return true;
674 }
675 if no_color.is_some_and(|value| !value.is_empty()) {
676 return false;
677 }
678 tty
679}
680
681fn style(text: &str, code: &str, tty: bool) -> String {
682 if tty {
683 format!("\x1b[{code}m{text}\x1b[0m")
684 } else {
685 text.to_string()
686 }
687}
688
689pub struct FormattedRenderer {
690 show_thinking: bool,
691 tty: bool,
692 seen_final_texts: BTreeSet<String>,
693 tool_calls_by_id: BTreeMap<String, ToolCall>,
694 pending_tool_call: Option<ToolCall>,
695 streamed_assistant_buffer: String,
696}
697
698impl FormattedRenderer {
699 pub fn new(show_thinking: bool, tty: bool) -> Self {
700 Self {
701 show_thinking,
702 tty,
703 seen_final_texts: BTreeSet::new(),
704 tool_calls_by_id: BTreeMap::new(),
705 pending_tool_call: None,
706 streamed_assistant_buffer: String::new(),
707 }
708 }
709
710 pub fn render_output(&mut self, output: &ParsedJsonOutput) -> String {
711 output
712 .events
713 .iter()
714 .filter_map(|event| self.render_event(event))
715 .collect::<Vec<_>>()
716 .join("\n")
717 }
718
719 pub fn render_event(&mut self, event: &JsonEvent) -> Option<String> {
720 match event.event_type.as_str() {
721 "text_delta" if !event.text.is_empty() => {
722 self.streamed_assistant_buffer.push_str(&event.text);
723 Some(self.render_message("assistant", &event.text))
724 }
725 "text" | "assistant" if !event.text.is_empty() => {
726 if !self.streamed_assistant_buffer.is_empty()
727 && event.text == self.streamed_assistant_buffer
728 {
729 self.seen_final_texts.insert(event.text.clone());
730 self.streamed_assistant_buffer.clear();
731 None
732 } else {
733 self.streamed_assistant_buffer.clear();
734 Some(self.render_message("assistant", &event.text))
735 }
736 }
737 "result" if !event.text.is_empty() => {
738 if !self.streamed_assistant_buffer.is_empty()
739 && event.text == self.streamed_assistant_buffer
740 {
741 self.seen_final_texts.insert(event.text.clone());
742 self.streamed_assistant_buffer.clear();
743 None
744 } else if self.seen_final_texts.contains(&event.text) {
745 None
746 } else {
747 self.streamed_assistant_buffer.clear();
748 Some(self.render_message("success", &event.text))
749 }
750 }
751 "thinking" | "thinking_delta" if !event.thinking.is_empty() && self.show_thinking => {
752 Some(self.render_message("thinking", &event.thinking))
753 }
754 "tool_use" | "tool_use_start" | "tool_call" => {
755 if let Some(tool_call) = &event.tool_call {
756 self.streamed_assistant_buffer.clear();
757 if !tool_call.id.is_empty() {
758 self.tool_calls_by_id.insert(tool_call.id.clone(), tool_call.clone());
759 }
760 self.pending_tool_call = Some(tool_call.clone());
761 Some(self.render_tool_start(tool_call))
762 } else {
763 None
764 }
765 }
766 "tool_input_delta" if !event.text.is_empty() => {
767 if let Some(tool_call) = &mut self.pending_tool_call {
768 tool_call.arguments.push_str(&event.text);
769 if !tool_call.id.is_empty() {
770 self.tool_calls_by_id
771 .insert(tool_call.id.clone(), tool_call.clone());
772 }
773 }
774 None
775 }
776 "tool_result" => event
777 .tool_result
778 .as_ref()
779 .map(|tool_result| {
780 self.streamed_assistant_buffer.clear();
781 self.render_tool_result(tool_result)
782 }),
783 "error" if !event.text.is_empty() => {
784 self.streamed_assistant_buffer.clear();
785 Some(self.render_message("error", &event.text))
786 }
787 _ => None,
788 }
789 }
790
791 fn render_message(&mut self, kind: &str, text: &str) -> String {
792 if matches!(kind, "assistant" | "success") {
793 self.seen_final_texts.insert(text.to_string());
794 }
795 let prefix = match kind {
796 "assistant" => prefix("💬", "[assistant]", "96", self.tty),
797 "thinking" => prefix("🧠", "[thinking]", "2;35", self.tty),
798 "success" => prefix("✅", "[ok]", "92", self.tty),
799 _ => prefix("❌", "[error]", "91", self.tty),
800 };
801 with_prefix(&prefix, text)
802 }
803
804 fn render_tool_start(&self, tool_call: &ToolCall) -> String {
805 let prefix = prefix("🛠️", "[tool:start]", "94", self.tty);
806 let mut detail = tool_call.name.clone();
807 if let Some(preview) = bash_command_preview(tool_call) {
808 detail.push_str(": ");
809 detail.push_str(&preview);
810 }
811 with_prefix(&prefix, &detail)
812 }
813
814 fn render_tool_result(&self, tool_result: &ToolResult) -> String {
815 let prefix = prefix("📎", "[tool:result]", "36", self.tty);
816 let tool_call = self
817 .tool_calls_by_id
818 .get(&tool_result.tool_call_id)
819 .or(self.pending_tool_call.as_ref());
820 let tool_name = tool_call
821 .map(|tool_call| tool_call.name.clone())
822 .unwrap_or_else(|| "tool".into());
823 let mut summary = format!(
824 "{} ({})",
825 tool_name,
826 if tool_result.is_error { "error" } else { "ok" }
827 );
828 if let Some(tool_call) = tool_call {
829 if let Some(preview) = bash_command_preview(tool_call) {
830 summary.push_str(": ");
831 summary.push_str(&preview);
832 }
833 }
834 let preview = tool_preview(&tool_name, &tool_result.content);
835 if !preview.is_empty() {
836 summary.push('\n');
837 summary.push_str(&preview);
838 }
839 with_prefix(&prefix, &summary)
840 }
841}
842
843fn prefix(emoji: &str, plain: &str, color_code: &str, tty: bool) -> String {
844 if tty {
845 style(emoji, color_code, true)
846 } else {
847 plain.to_string()
848 }
849}
850
851fn with_prefix(prefix: &str, text: &str) -> String {
852 text.lines()
853 .map(|line| {
854 if line.is_empty() {
855 prefix.to_string()
856 } else {
857 format!("{prefix} {line}")
858 }
859 })
860 .collect::<Vec<_>>()
861 .join("\n")
862}
863
864pub struct StructuredStreamProcessor {
865 schema: String,
866 renderer: FormattedRenderer,
867 output: ParsedJsonOutput,
868 buffer: String,
869 unknown_json_lines: Vec<String>,
870}
871
872impl StructuredStreamProcessor {
873 pub fn new(schema: &str, renderer: FormattedRenderer) -> Self {
874 Self {
875 schema: schema.into(),
876 renderer,
877 output: new_output(schema),
878 buffer: String::new(),
879 unknown_json_lines: Vec::new(),
880 }
881 }
882
883 pub fn feed(&mut self, chunk: &str) -> String {
884 self.buffer.push_str(chunk);
885 let mut rendered = Vec::new();
886 while let Some(index) = self.buffer.find('\n') {
887 let line = self.buffer[..index].to_string();
888 self.buffer = self.buffer[index + 1..].to_string();
889 if let Some(obj) = parse_json_line(&line) {
890 let before = (
891 self.output.events.len(),
892 self.output.final_text.clone(),
893 self.output.error.clone(),
894 self.output.session_id.clone(),
895 );
896 self.apply(&obj);
897 let after = (
898 self.output.events.len(),
899 self.output.final_text.clone(),
900 self.output.error.clone(),
901 self.output.session_id.clone(),
902 );
903 if before == after {
904 self.unknown_json_lines.push(line.trim().to_string());
905 }
906 for event in &self.output.events[before.0..] {
907 if let Some(text) = self.renderer.render_event(event) {
908 rendered.push(text);
909 }
910 }
911 }
912 }
913 rendered.join("\n")
914 }
915
916 pub fn finish(&mut self) -> String {
917 if self.buffer.trim().is_empty() {
918 return String::new();
919 }
920 let line = std::mem::take(&mut self.buffer);
921 if let Some(obj) = parse_json_line(&line) {
922 let before = (
923 self.output.events.len(),
924 self.output.final_text.clone(),
925 self.output.error.clone(),
926 self.output.session_id.clone(),
927 );
928 self.apply(&obj);
929 let after = (
930 self.output.events.len(),
931 self.output.final_text.clone(),
932 self.output.error.clone(),
933 self.output.session_id.clone(),
934 );
935 if before == after {
936 self.unknown_json_lines.push(line.trim().to_string());
937 }
938 return self.output.events[before.0..]
939 .iter()
940 .filter_map(|event| self.renderer.render_event(event))
941 .collect::<Vec<_>>()
942 .join("\n");
943 }
944 String::new()
945 }
946
947 pub fn take_unknown_json_lines(&mut self) -> Vec<String> {
948 std::mem::take(&mut self.unknown_json_lines)
949 }
950
951 fn apply(&mut self, obj: &serde_json::Value) {
952 match self.schema.as_str() {
953 "opencode" => apply_opencode_obj(&mut self.output, obj),
954 "claude-code" => apply_claude_obj(&mut self.output, obj),
955 "kimi" => apply_kimi_obj(&mut self.output, obj),
956 _ => {}
957 }
958 }
959}
960
961pub fn render_parsed(output: &ParsedJsonOutput, show_thinking: bool, tty: bool) -> String {
962 let mut renderer = FormattedRenderer::new(show_thinking, tty);
963 let rendered = renderer.render_output(output);
964 if rendered.is_empty() {
965 output.final_text.clone()
966 } else {
967 rendered
968 }
969}