Skip to main content

call_coding_clis/
json_output.rs

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}