Skip to main content

rpi_cli/
transcript_view.rs

1//! Transport-agnostic transcript rendering.
2//!
3//! This is the shared render layer behind unification ("only the transport
4//! differs"): both the local interactive TUI (driven by in-process
5//! `AgentEvent`s) and the remote client (driven by wire `RemoteEvent`s) build
6//! their transcript from the **same** `pi-tui` components — an
7//! [`AssistantMessageComponent`] per assistant message, a
8//! [`ToolExecutionComponent`] per tool call, a [`BashExecutionComponent`] per
9//! bash run, and user bubbles. Each side maps its native event stream into the
10//! provider-free [`UiEvent`] enum and feeds it to [`TranscriptView`]; the view
11//! holds the live component maps and updates them in place.
12//!
13//! Keeping this provider-free (only `pi-tui` + `serde_json::Value`) is what lets
14//! the local host and the remote client share it without dragging `rpi_agent`
15//! types into the client.
16
17use std::collections::HashMap;
18use std::sync::{Arc, Mutex};
19
20use base64::Engine;
21use rpi_tui::{
22    render_diff, AssistantBlock, AssistantMessageComponent, AssistantMessageOptions,
23    BashExecutionComponent, BashTruncation, Container, Spacer, ToolExecutionComponent, ToolStatus,
24    UserMessageComponent,
25};
26use serde_json::Value;
27
28/// A tool call visible in an assistant message snapshot, normalized to plain
29/// data so both the local (`rpi_ai::Content::ToolCall`) and the wire
30/// (`{"type":"toolCall",…}`) shapes map onto it.
31#[derive(Debug, Clone, PartialEq)]
32pub struct UiToolCall {
33    pub id: String,
34    pub name: String,
35    pub args: Value,
36}
37
38/// A normalized transcript event. Both the local `AgentEvent` stream and the
39/// remote `RemoteEvent` stream are projected into this enum before rendering.
40#[derive(Debug, Clone)]
41pub enum UiEvent {
42    /// A run started (no transcript effect; kept for symmetry).
43    AgentStart,
44    /// A run ended: finalize any still-streaming assistant panel.
45    AgentEnd,
46    /// A retry was scheduled — renders as a dim notice line.
47    Retry {
48        attempt: u32,
49        max_retries: u32,
50        delay_ms: u64,
51        error: String,
52    },
53    /// An assistant message began (carries any blocks already present).
54    AssistantStart { blocks: Vec<AssistantBlock> },
55    /// A streaming assistant snapshot: content blocks plus the finalized tool
56    /// calls visible in the snapshot.
57    AssistantUpdate {
58        blocks: Vec<AssistantBlock>,
59        tool_calls: Vec<UiToolCall>,
60    },
61    /// The assistant message finished.
62    AssistantEnd { blocks: Vec<AssistantBlock> },
63    /// Tool lifecycle (args/result carry the stable wire `Value` shape).
64    ToolStart {
65        id: String,
66        name: String,
67        args: Value,
68    },
69    ToolUpdate {
70        id: String,
71        name: String,
72        args: Value,
73        result: Value,
74    },
75    ToolEnd {
76        id: String,
77        name: String,
78        result: Value,
79        is_error: bool,
80    },
81}
82
83/// The retained transcript: the same component maps the local `TuiState` holds,
84/// so a streaming assistant message and each tool/bash panel are updated in
85/// place rather than rebuilt per event.
86pub struct TranscriptView {
87    chat: Arc<Container>,
88    current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
89    tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
90    /// Bash executions keyed by `tool_call_id` — kept separate from the generic
91    /// tool map so bash output streams into a [`BashExecutionComponent`] (command
92    /// header + live preview + exit/truncation status).
93    bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
94    /// When true, thinking blocks collapse to a label (toggled by the host).
95    hide_thinking: Arc<Mutex<bool>>,
96    /// Global tool-output expansion preference (toggled by the host).
97    expanded: Arc<Mutex<bool>>,
98    /// Optional transformer applied to raw assistant markdown before rendering
99    /// (installed by the local host from plugin `register_markdown_transformer`
100    /// handlers; always `None` on the remote client).
101    markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
102}
103
104/// A sync `Fn(&str) -> String` applied to raw assistant markdown. Kept as a
105/// trait object so this crate stays free of any plugin types.
106pub type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
107
108impl TranscriptView {
109    /// Self-contained view owning fresh maps (the remote client).
110    pub fn new(chat: Arc<Container>) -> Self {
111        Self::with_maps(
112            chat,
113            Arc::new(Mutex::new(None)),
114            Arc::new(Mutex::new(HashMap::new())),
115            Arc::new(Mutex::new(HashMap::new())),
116            Arc::new(Mutex::new(false)),
117            Arc::new(Mutex::new(false)),
118            Arc::new(Mutex::new(None)),
119        )
120    }
121
122    /// Wrap **shared** component maps. The local host keeps direct `Arc` handles
123    /// to these maps for its render-tick, reload and toggle logic, so it hands
124    /// the same handles to the view instead of duplicating them.
125    #[allow(clippy::too_many_arguments)]
126    pub fn with_maps(
127        chat: Arc<Container>,
128        current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
129        tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
130        bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
131        hide_thinking: Arc<Mutex<bool>>,
132        expanded: Arc<Mutex<bool>>,
133        markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
134    ) -> Self {
135        Self {
136            chat,
137            current_assistant,
138            tool_components,
139            bash_components,
140            hide_thinking,
141            expanded,
142            markdown_transformer,
143        }
144    }
145
146    /// Append a user prompt bubble.
147    pub fn add_user(&self, text: &str) {
148        self.chat
149            .add_child(Arc::new(UserMessageComponent::new(text.to_string())));
150        self.chat.add_child(Arc::new(Spacer::new(1)));
151    }
152
153    /// Append a dim one-line notice (retry / error / command feedback).
154    pub fn add_notice(&self, text: &str) {
155        self.chat
156            .add_child(Arc::new(rpi_tui::Text::new(format!("· {text}"), 1, 0)));
157    }
158
159    /// Fold one normalized event into the retained components. `width` is the
160    /// current terminal width, used to render `edit` diffs.
161    pub fn apply(&self, event: &UiEvent, width: usize) {
162        match event {
163            UiEvent::AgentStart => {}
164            UiEvent::AgentEnd => {
165                if let Some(comp) = self.current_assistant.lock().unwrap().take() {
166                    comp.set_streaming(false);
167                }
168            }
169            UiEvent::Retry {
170                attempt,
171                max_retries,
172                delay_ms,
173                error,
174            } => {
175                self.add_notice(&format!(
176                    "retry {attempt}/{max_retries} in {delay_ms}ms: {error}"
177                ));
178            }
179            UiEvent::AssistantStart { blocks } => {
180                self.start_assistant(blocks);
181            }
182            UiEvent::AssistantUpdate { blocks, tool_calls } => {
183                self.ensure_assistant();
184                self.sync_tool_calls(tool_calls);
185                if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
186                    comp.update_blocks(blocks);
187                }
188            }
189            UiEvent::AssistantEnd { blocks } => {
190                if let Some(comp) = self.current_assistant.lock().unwrap().take() {
191                    comp.update_blocks(blocks);
192                    comp.set_streaming(false);
193                }
194            }
195            UiEvent::ToolStart { id, name, args } => self.tool_start(id, name, args, width),
196            UiEvent::ToolUpdate {
197                id,
198                name,
199                args,
200                result,
201            } => self.tool_update(id, name, args, result, width),
202            UiEvent::ToolEnd {
203                id,
204                name,
205                result,
206                is_error,
207            } => self.tool_end(id, name, result, *is_error, width),
208        }
209    }
210
211    /// Set the global tool-output expansion preference and re-apply it to every
212    /// live panel.
213    pub fn set_expanded(&self, expanded: bool) {
214        *self.expanded.lock().unwrap() = expanded;
215        for comp in self.tool_components.lock().unwrap().values() {
216            comp.set_expanded(expanded);
217        }
218        for comp in self.bash_components.lock().unwrap().values() {
219            comp.set_expanded(expanded);
220        }
221    }
222
223    /// Flip the expansion preference; returns the new value.
224    pub fn toggle_expanded(&self) -> bool {
225        let next = !*self.expanded.lock().unwrap();
226        self.set_expanded(next);
227        next
228    }
229
230    /// Whether tool outputs are currently expanded.
231    pub fn expanded(&self) -> bool {
232        *self.expanded.lock().unwrap()
233    }
234
235    /// Hide/show thinking blocks on the in-flight assistant message.
236    pub fn set_hide_thinking(&self, hide: bool) {
237        *self.hide_thinking.lock().unwrap() = hide;
238        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
239            comp.set_hide_thinking(hide);
240        }
241    }
242
243    /// Flip the thinking-visibility preference; returns the new value.
244    pub fn toggle_hide_thinking(&self) -> bool {
245        let next = !*self.hide_thinking.lock().unwrap();
246        self.set_hide_thinking(next);
247        next
248    }
249
250    /// Whether thinking blocks are currently hidden.
251    pub fn hide_thinking(&self) -> bool {
252        *self.hide_thinking.lock().unwrap()
253    }
254
255    /// Install (or clear) the assistant-markdown transformer, re-applying it to
256    /// the in-flight assistant message immediately (mirrors the local reload).
257    pub fn set_markdown_transformer(&self, transformer: Option<MarkdownTransformer>) {
258        *self.markdown_transformer.lock().unwrap() = transformer.clone();
259        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
260            comp.set_markdown_transformer(transformer);
261        }
262    }
263
264    /// Finalize any still-streaming assistant panel (used at turn/run end).
265    pub fn finalize_assistant(&self) {
266        if let Some(comp) = self.current_assistant.lock().unwrap().take() {
267            comp.set_streaming(false);
268        }
269    }
270
271    /// Finalize the assistant panel with an authoritative final block list.
272    pub fn finalize_assistant_blocks(&self, blocks: &[AssistantBlock]) {
273        if let Some(comp) = self.current_assistant.lock().unwrap().take() {
274            comp.update_blocks(blocks);
275            comp.set_streaming(false);
276        }
277    }
278
279    /// Whether any tool panel is still running (drives the local render tick).
280    pub fn has_running_tool(&self) -> bool {
281        self.tool_components
282            .lock()
283            .unwrap()
284            .values()
285            .any(|component| component.status() == ToolStatus::Running)
286    }
287
288    /// Whether a bash panel is currently on screen.
289    pub fn has_active_bash(&self) -> bool {
290        !self.bash_components.lock().unwrap().is_empty()
291    }
292
293    fn start_assistant(&self, blocks: &[AssistantBlock]) {
294        let comp = Arc::new(AssistantMessageComponent::new(
295            AssistantMessageOptions::default(),
296        ));
297        comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
298        if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
299            comp.set_markdown_transformer(Some(transformer));
300        }
301        comp.set_streaming(true);
302        comp.update_blocks(blocks);
303        self.chat.add_child(comp.clone());
304        self.chat.add_child(Arc::new(Spacer::new(1)));
305        *self.current_assistant.lock().unwrap() = Some(comp);
306    }
307
308    /// Create the streaming assistant component if a delta arrives before
309    /// `assistant_start` (the wire does not strictly guarantee ordering).
310    fn ensure_assistant(&self) {
311        let mut current = self.current_assistant.lock().unwrap();
312        if current.is_none() {
313            let comp = Arc::new(AssistantMessageComponent::new(
314                AssistantMessageOptions::default(),
315            ));
316            comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
317            if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
318                comp.set_markdown_transformer(Some(transformer));
319            }
320            comp.set_streaming(true);
321            self.chat.add_child(comp.clone());
322            self.chat.add_child(Arc::new(Spacer::new(1)));
323            *current = Some(comp);
324        }
325    }
326
327    /// Pre-create panels for the tool calls visible in an assistant snapshot. A
328    /// later `tool_start` coalesces onto the same entry.
329    fn sync_tool_calls(&self, calls: &[UiToolCall]) {
330        for call in calls {
331            if call.name == "bash" {
332                let command = call
333                    .args
334                    .get("command")
335                    .and_then(Value::as_str)
336                    .unwrap_or("");
337                if command.trim().is_empty() {
338                    continue;
339                }
340                let mut bash = self.bash_components.lock().unwrap();
341                if let Some(existing) = bash.get(&call.id) {
342                    existing.set_command(command);
343                } else {
344                    let comp = Arc::new(BashExecutionComponent::new(command));
345                    comp.set_expanded(self.expanded());
346                    self.chat.add_child(comp.clone());
347                    bash.insert(call.id.clone(), comp);
348                }
349                continue;
350            }
351            let ask_user = is_ask_user_tool(&call.name);
352            let display = if ask_user {
353                ask_user_args_display(&call.args)
354            } else {
355                call.args.to_string()
356            };
357            let mut tools = self.tool_components.lock().unwrap();
358            if let Some(existing) = tools.get(&call.id) {
359                if !display.trim().is_empty() && display.trim() != "{}" {
360                    existing.set_args(&display);
361                }
362            } else {
363                let comp = Arc::new(ToolExecutionComponent::new(&call.name, &display));
364                if ask_user {
365                    comp.set_display_title("ASK USER");
366                }
367                if let Some(skill) = skill_tool_name(&call.name, &call.args) {
368                    comp.set_skill_name(skill);
369                }
370                comp.set_expanded(self.expanded());
371                comp.set_running();
372                self.chat.add_child(comp.clone());
373                tools.insert(call.id.clone(), comp);
374            }
375        }
376    }
377
378    fn tool_start(&self, id: &str, name: &str, args: &Value, _width: usize) {
379        if name.trim().is_empty() {
380            return;
381        }
382        if name == "bash" {
383            let command = args.get("command").and_then(Value::as_str).unwrap_or("");
384            // Arguments can still be `{}` when the lifecycle event races streamed
385            // arg finalization — defer until the command lands.
386            if command.trim().is_empty() {
387                return;
388            }
389            let mut bash = self.bash_components.lock().unwrap();
390            if let Some(existing) = bash.get(id) {
391                existing.set_command(command);
392            } else {
393                let comp = Arc::new(BashExecutionComponent::new(command));
394                comp.set_expanded(self.expanded());
395                self.chat.add_child(comp.clone());
396                bash.insert(id.to_string(), comp);
397            }
398            return;
399        }
400        let ask_user = is_ask_user_tool(name);
401        let display = if ask_user {
402            ask_user_args_display(args)
403        } else {
404            args.to_string()
405        };
406        let mut tools = self.tool_components.lock().unwrap();
407        if let Some(existing) = tools.get(id) {
408            if ask_user {
409                existing.set_display_title("ASK USER");
410            }
411            existing.set_args(&display);
412        } else {
413            let comp = Arc::new(ToolExecutionComponent::new(name, &display));
414            if ask_user {
415                comp.set_display_title("ASK USER");
416            }
417            if let Some(skill) = skill_tool_name(name, args) {
418                comp.set_skill_name(skill);
419            }
420            comp.set_expanded(self.expanded());
421            comp.set_running();
422            self.chat.add_child(comp.clone());
423            tools.insert(id.to_string(), comp);
424        }
425    }
426
427    fn tool_update(&self, id: &str, name: &str, args: &Value, result: &Value, width: usize) {
428        if name.trim().is_empty() {
429            return;
430        }
431        let ask_user = is_ask_user_tool(name);
432        let text = if ask_user {
433            ask_user_progress_text(args, result)
434        } else {
435            tool_result_text(result)
436        };
437        if name == "bash" {
438            if let Some(bash) = self.bash_components.lock().unwrap().get(id) {
439                if let Some(command) = args.get("command").and_then(Value::as_str) {
440                    bash.set_command(command);
441                }
442                if !text.is_empty() {
443                    bash.append_output(&text);
444                }
445            }
446            return;
447        }
448        let existing = self.tool_components.lock().unwrap().get(id).cloned();
449        match existing {
450            Some(comp) => {
451                if ask_user {
452                    comp.set_display_title("ASK USER");
453                    comp.set_args(&ask_user_args_display(args));
454                } else if !args.is_null() && args != &serde_json::json!({}) {
455                    comp.set_args(&args.to_string());
456                }
457                if tool_update_has_payload(&text, result) {
458                    comp.set_result(&text, false);
459                    if !ask_user {
460                        apply_edit_diff(&comp, name, result.get("details"), width);
461                    }
462                }
463            }
464            None => {
465                // Update before start (fast tool / out-of-order): create the
466                // running panel now so the partial has somewhere to land.
467                if tool_update_has_payload(&text, result) {
468                    let display = if ask_user {
469                        ask_user_args_display(args)
470                    } else {
471                        args.to_string()
472                    };
473                    let comp = Arc::new(ToolExecutionComponent::new(name, &display));
474                    if ask_user {
475                        comp.set_display_title("ASK USER");
476                    }
477                    if let Some(skill) = skill_tool_name(name, args) {
478                        comp.set_skill_name(skill);
479                    }
480                    comp.set_expanded(self.expanded());
481                    comp.set_running();
482                    comp.set_result(&text, false);
483                    if !ask_user {
484                        apply_edit_diff(&comp, name, result.get("details"), width);
485                    }
486                    self.chat.add_child(comp.clone());
487                    self.tool_components
488                        .lock()
489                        .unwrap()
490                        .insert(id.to_string(), comp);
491                }
492            }
493        }
494    }
495
496    fn tool_end(&self, id: &str, name: &str, result: &Value, is_error: bool, width: usize) {
497        if name.trim().is_empty() {
498            return;
499        }
500        if name == "bash" {
501            let bash = self.bash_components.lock().unwrap().remove(id);
502            match bash {
503                Some(comp) => finalize_bash(&comp, result, is_error),
504                None => {
505                    let command = result
506                        .get("details")
507                        .and_then(|details| details.get("command"))
508                        .and_then(Value::as_str)
509                        .unwrap_or("");
510                    let text = tool_result_text(result);
511                    if command.trim().is_empty() && text.trim().is_empty() {
512                        return;
513                    }
514                    let comp = Arc::new(BashExecutionComponent::new(command));
515                    comp.set_expanded(self.expanded());
516                    if !text.is_empty() {
517                        comp.append_output(&text);
518                    }
519                    finalize_bash(&comp, result, is_error);
520                    self.chat.add_child(comp);
521                }
522            }
523            return;
524        }
525        let ask_user = is_ask_user_tool(name);
526        let text = if ask_user {
527            ask_user_result_text(result)
528        } else {
529            tool_result_text(result)
530        };
531        let details = result.get("details");
532        let existing = self.tool_components.lock().unwrap().remove(id);
533        let comp = match existing {
534            Some(comp) => comp,
535            None => {
536                // Tool ended without a start/update — render a finalized panel.
537                let comp = Arc::new(ToolExecutionComponent::new(name, ""));
538                if ask_user {
539                    comp.set_display_title("ASK USER");
540                }
541                comp.set_expanded(self.expanded());
542                self.chat.add_child(comp.clone());
543                comp
544            }
545        };
546        comp.set_result(&text, is_error);
547        if !ask_user {
548            if tool_result_requests_markdown(details) {
549                comp.set_result_markdown(true);
550            }
551            apply_edit_diff(&comp, name, details, width);
552        }
553    }
554}
555
556/// Map a wire/local assistant message's content blocks into the provider-free
557/// [`AssistantBlock`] list the shared component renders (text + thinking +
558/// decoded image blocks, in document order).
559pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
560    let mut blocks = Vec::new();
561    let Some(content) = message.get("content").and_then(Value::as_array) else {
562        return blocks;
563    };
564    for block in content {
565        match block.get("type").and_then(Value::as_str) {
566            Some("text") => {
567                if let Some(text) = block.get("text").and_then(Value::as_str) {
568                    blocks.push(AssistantBlock::Text(text.to_string()));
569                }
570            }
571            Some("thinking") => {
572                if let Some(text) = block.get("thinking").and_then(Value::as_str) {
573                    blocks.push(AssistantBlock::Thinking(text.to_string()));
574                }
575            }
576            Some("image") => {
577                if let Some(data) = block.get("data").and_then(Value::as_str) {
578                    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
579                        if !bytes.is_empty() {
580                            blocks.push(AssistantBlock::Image(bytes));
581                        }
582                    }
583                }
584            }
585            _ => {}
586        }
587    }
588    blocks
589}
590
591/// Extract the finalized tool calls (`type: "toolCall"`) from an assistant
592/// message value, in document order.
593pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
594    let mut calls = Vec::new();
595    let Some(content) = message.get("content").and_then(Value::as_array) else {
596        return calls;
597    };
598    for block in content {
599        if block.get("type").and_then(Value::as_str) != Some("toolCall") {
600            continue;
601        }
602        let id = block.get("id").and_then(Value::as_str).unwrap_or("");
603        let name = block.get("name").and_then(Value::as_str).unwrap_or("");
604        if id.is_empty() || name.trim().is_empty() {
605            continue;
606        }
607        calls.push(UiToolCall {
608            id: id.to_string(),
609            name: name.to_string(),
610            args: block.get("arguments").cloned().unwrap_or(Value::Null),
611        });
612    }
613    calls
614}
615
616/// Text out of a wire tool-result payload (`{content:[{type:"text",text}]}`).
617pub fn tool_result_text(result: &Value) -> String {
618    let mut out = String::new();
619    if let Some(blocks) = result.get("content").and_then(Value::as_array) {
620        for block in blocks {
621            if let Some(text) = block.get("text").and_then(Value::as_str) {
622                if !out.is_empty() {
623                    out.push('\n');
624                }
625                out.push_str(text);
626            }
627        }
628    }
629    out
630}
631
632/// Whether a tool result opts into markdown rendering of its body
633/// (`details.markdown = true`).
634pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
635    details
636        .and_then(|details| details.get("markdown"))
637        .and_then(Value::as_bool)
638        .unwrap_or(false)
639}
640
641/// The skill name when `tool_name` is a `read` of a `SKILL.md` file (the
642/// `SKILL.md` parent directory's basename), else `None`. A skill read renders
643/// as upstream's `[skill] <name>` box.
644pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
645    if tool_name != "read" {
646        return None;
647    }
648    let path = args.get("path").and_then(Value::as_str)?;
649    let normalized = path.replace('\\', "/");
650    let file_name = normalized.rsplit('/').next()?;
651    if !file_name.eq_ignore_ascii_case("SKILL.md") {
652        return None;
653    }
654    normalized
655        .trim_end_matches('/')
656        .rsplit('/')
657        .nth(1)
658        .filter(|name| !name.is_empty())
659        .map(str::to_string)
660}
661
662/// Tool names rendered through the dedicated `ASK USER` panel instead of a
663/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
664fn is_ask_user_tool(tool_name: &str) -> bool {
665    matches!(tool_name, "ask_user" | "ask_user_question")
666}
667
668/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
669/// transport JSON must never leak into the transcript.
670fn ask_user_args_display(args: &Value) -> String {
671    if args.is_null() {
672        return String::new();
673    }
674    if let Some(summary) = args
675        .get("summary")
676        .and_then(Value::as_str)
677        .map(str::trim)
678        .filter(|text| !text.is_empty())
679    {
680        return format!("Confirmation requested: {summary}");
681    }
682
683    let mut lines: Vec<String> = Vec::new();
684    let questions: Vec<&Value> = args
685        .get("questions")
686        .and_then(Value::as_array)
687        .filter(|items| !items.is_empty())
688        .map(|items| items.iter().collect())
689        .unwrap_or_else(|| vec![args]);
690
691    for question in questions {
692        let prompt = question
693            .get("question")
694            .and_then(Value::as_str)
695            .or_else(|| args.get("question").and_then(Value::as_str))
696            .map(str::trim)
697            .unwrap_or_default();
698        if prompt.is_empty() {
699            continue;
700        }
701        if let Some(header) = question
702            .get("header")
703            .and_then(Value::as_str)
704            .map(str::trim)
705            .filter(|text| !text.is_empty())
706        {
707            lines.push(format!("{header}: {prompt}"));
708        } else {
709            lines.push(prompt.to_string());
710        }
711        let context = question
712            .get("context")
713            .and_then(Value::as_str)
714            .or_else(|| args.get("context").and_then(Value::as_str))
715            .map(str::trim)
716            .filter(|text| !text.is_empty());
717        if let Some(context) = context {
718            lines.push(format!("  {context}"));
719        }
720        let options = question
721            .get("options")
722            .and_then(Value::as_array)
723            .or_else(|| args.get("options").and_then(Value::as_array));
724        if let Some(options) = options {
725            let labels: Vec<String> = options
726                .iter()
727                .filter_map(|option| {
728                    option
729                        .get("title")
730                        .and_then(Value::as_str)
731                        .or_else(|| option.as_str())
732                        .map(str::trim)
733                        .filter(|label| !label.is_empty())
734                        .map(str::to_string)
735                })
736                .collect();
737            if !labels.is_empty() {
738                lines.push(format!("Choices: {}", labels.join(", ")));
739            }
740        }
741        if let Some(suggest) = question
742            .get("suggest")
743            .and_then(Value::as_str)
744            .or_else(|| args.get("suggest").and_then(Value::as_str))
745            .map(str::trim)
746            .filter(|text| !text.is_empty())
747        {
748            lines.push(format!("Suggestions: {suggest}"));
749        }
750    }
751
752    lines.join("\n")
753}
754
755/// Progress text while an ask_user tool is pending. Prefer the tool's own
756/// readable `content`, otherwise show the question so the panel is not blank.
757fn ask_user_progress_text(args: &Value, result: &Value) -> String {
758    let text = tool_result_text(result);
759    if !text.trim().is_empty() {
760        return text;
761    }
762    let args_text = ask_user_args_display(args);
763    if args_text.trim().is_empty() {
764        "Waiting for your answer…".to_string()
765    } else {
766        args_text
767    }
768}
769
770/// Final text for a completed ask_user tool.
771fn ask_user_result_text(result: &Value) -> String {
772    let text = tool_result_text(result);
773    if text.trim().is_empty() {
774        "Answer recorded.".to_string()
775    } else {
776        text
777    }
778}
779
780/// Whether a partial tool result carries anything worth rendering.
781fn tool_update_has_payload(text: &str, result: &Value) -> bool {
782    !text.trim().is_empty()
783        || result
784            .get("details")
785            .map(|details| !details.is_null())
786            .unwrap_or(false)
787}
788
789/// Attach a colored diff to an `edit` tool panel. `write` carries no diff and
790/// stays a plain summary.
791fn apply_edit_diff(
792    comp: &Arc<ToolExecutionComponent>,
793    tool_name: &str,
794    details: Option<&Value>,
795    width: usize,
796) {
797    if tool_name != "edit" {
798        return;
799    }
800    let Some(diff_text) = details
801        .and_then(|details| details.get("diff"))
802        .and_then(Value::as_str)
803    else {
804        return;
805    };
806    if diff_text.is_empty() {
807        return;
808    }
809    comp.set_diff(render_diff(diff_text, width));
810}
811
812/// Mark a bash panel complete from the wire result payload. The exit code is
813/// derived from `is_error`; truncation info is read from `details`.
814fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
815    let details = result.get("details");
816    let exit_code = Some(if is_error { 1 } else { 0 });
817    let truncated = details
818        .and_then(|details| details.get("truncation"))
819        .and_then(|truncation| truncation.get("truncated"))
820        .and_then(Value::as_bool)
821        .unwrap_or(false);
822    let full_output_path = details
823        .and_then(|details| details.get("full_output_path"))
824        .and_then(Value::as_str)
825        .map(str::to_string);
826    if let Some(command) = details
827        .and_then(|details| details.get("command"))
828        .and_then(Value::as_str)
829    {
830        comp.set_command(command);
831    }
832    comp.set_complete(
833        exit_code,
834        false,
835        BashTruncation {
836            truncated,
837            full_output_path,
838        },
839    );
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use serde_json::json;
846
847    fn view() -> TranscriptView {
848        TranscriptView::new(Arc::new(Container::new()))
849    }
850
851    #[test]
852    fn assistant_blocks_follow_content_order() {
853        let message = json!({
854            "content": [
855                {"type": "thinking", "thinking": "why"},
856                {"type": "text", "text": "hello"},
857                {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
858            ]
859        });
860        assert_eq!(
861            assistant_blocks_from_value(&message),
862            vec![
863                AssistantBlock::Thinking("why".into()),
864                AssistantBlock::Text("hello".into()),
865            ]
866        );
867    }
868
869    #[test]
870    fn assistant_blocks_decode_images() {
871        // "aGk=" is base64 for "hi".
872        let message = json!({
873            "content": [{"type": "image", "data": "aGk=", "mimeType": "image/png"}]
874        });
875        assert_eq!(
876            assistant_blocks_from_value(&message),
877            vec![AssistantBlock::Image(b"hi".to_vec())]
878        );
879    }
880
881    #[test]
882    fn tool_calls_extract_ids_names_and_args() {
883        let message = json!({
884            "content": [
885                {"type": "text", "text": "x"},
886                {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
887                {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
888            ]
889        });
890        assert_eq!(
891            tool_calls_from_value(&message),
892            vec![UiToolCall {
893                id: "b1".into(),
894                name: "bash".into(),
895                args: json!({"command": "ls"}),
896            }]
897        );
898    }
899
900    #[test]
901    fn markdown_flag_is_opt_in() {
902        assert!(tool_result_requests_markdown(Some(
903            &json!({"markdown": true})
904        )));
905        assert!(!tool_result_requests_markdown(Some(
906            &json!({"markdown": false})
907        )));
908        assert!(!tool_result_requests_markdown(Some(&json!({}))));
909        assert!(!tool_result_requests_markdown(None));
910    }
911
912    #[test]
913    fn skill_tool_name_only_matches_skill_reads() {
914        assert_eq!(
915            skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
916            Some("foo".to_string())
917        );
918        assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
919        assert_eq!(
920            skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
921            None
922        );
923    }
924
925    #[test]
926    fn ask_user_args_display_never_leaks_transport_json() {
927        let text = ask_user_args_display(&json!({
928            "question": "你的代理端口是多少?",
929            "context": "本机",
930            "options": ["7890", {"title": "7897", "description": "clash"}],
931            "suggest": "1080"
932        }));
933        assert!(text.contains("你的代理端口是多少?"));
934        assert!(text.contains("本机"));
935        assert!(text.contains("Choices: 7890, 7897"));
936        assert!(text.contains("Suggestions: 1080"));
937        assert!(!text.trim_start().starts_with('{'));
938        assert_eq!(
939            ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
940            "Confirmation requested: Deploy?"
941        );
942    }
943
944    #[test]
945    fn tool_start_routes_bash_to_its_own_map() {
946        let view = view();
947        view.apply(
948            &UiEvent::ToolStart {
949                id: "b1".into(),
950                name: "bash".into(),
951                args: json!({"command": "ls -la"}),
952            },
953            80,
954        );
955        view.apply(
956            &UiEvent::ToolStart {
957                id: "r1".into(),
958                name: "read".into(),
959                args: json!({"path": "a.txt"}),
960            },
961            80,
962        );
963        assert!(view.bash_components.lock().unwrap().contains_key("b1"));
964        assert!(view.tool_components.lock().unwrap().contains_key("r1"));
965        assert!(view.tool_components.lock().unwrap().get("b1").is_none());
966    }
967
968    #[test]
969    fn bash_start_without_a_command_is_deferred() {
970        let view = view();
971        view.apply(
972            &UiEvent::ToolStart {
973                id: "b1".into(),
974                name: "bash".into(),
975                args: json!({}),
976            },
977            80,
978        );
979        assert!(view.bash_components.lock().unwrap().is_empty());
980        assert!(view.tool_components.lock().unwrap().is_empty());
981    }
982
983    #[test]
984    fn assistant_stream_reuses_one_component() {
985        let view = view();
986        view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
987        view.apply(
988            &UiEvent::AssistantUpdate {
989                blocks: vec![AssistantBlock::Text("hi".into())],
990                tool_calls: vec![],
991            },
992            80,
993        );
994        assert!(view.current_assistant.lock().unwrap().is_some());
995    }
996
997    #[test]
998    fn toggle_expanded_round_trips() {
999        let view = view();
1000        assert!(!view.expanded());
1001        assert!(view.toggle_expanded());
1002        assert!(view.expanded());
1003    }
1004}