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    show_images: bool,
88    chat: Arc<Container>,
89    current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
90    tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
91    /// Bash executions keyed by `tool_call_id` — kept separate from the generic
92    /// tool map so bash output streams into a [`BashExecutionComponent`] (command
93    /// header + live preview + exit/truncation status).
94    bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
95    /// When true, thinking blocks collapse to a label (toggled by the host).
96    hide_thinking: Arc<Mutex<bool>>,
97    /// Global tool-output expansion preference (toggled by the host).
98    expanded: Arc<Mutex<bool>>,
99    /// Optional transformer applied to raw assistant markdown before rendering
100    /// (installed by the local host from plugin `register_markdown_transformer`
101    /// handlers; always `None` on the remote client).
102    markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
103}
104
105/// A sync `Fn(&str) -> String` applied to raw assistant markdown. Kept as a
106/// trait object so this crate stays free of any plugin types.
107pub type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
108
109impl TranscriptView {
110    /// Self-contained view owning fresh maps (the remote client).
111    pub fn new(chat: Arc<Container>) -> Self {
112        Self::with_maps(
113            chat,
114            Arc::new(Mutex::new(None)),
115            Arc::new(Mutex::new(HashMap::new())),
116            Arc::new(Mutex::new(HashMap::new())),
117            Arc::new(Mutex::new(false)),
118            Arc::new(Mutex::new(false)),
119            Arc::new(Mutex::new(None)),
120        )
121    }
122
123    /// Wrap **shared** component maps. The local host keeps direct `Arc` handles
124    /// to these maps for its render-tick, reload and toggle logic, so it hands
125    /// the same handles to the view instead of duplicating them.
126    #[allow(clippy::too_many_arguments)]
127    pub fn with_maps(
128        chat: Arc<Container>,
129        current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
130        tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
131        bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
132        hide_thinking: Arc<Mutex<bool>>,
133        expanded: Arc<Mutex<bool>>,
134        markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
135    ) -> Self {
136        Self {
137            show_images: true,
138            chat,
139            current_assistant,
140            tool_components,
141            bash_components,
142            hide_thinking,
143            expanded,
144            markdown_transformer,
145        }
146    }
147
148    /// Configure image visibility for newly created assistant components.
149    pub fn with_show_images(mut self, show: bool) -> Self {
150        self.show_images = show;
151        self
152    }
153
154    /// Append a user prompt bubble.
155    pub fn add_user(&self, text: &str) {
156        self.chat
157            .add_child(Arc::new(UserMessageComponent::new(text.to_string())));
158        self.chat.add_child(Arc::new(Spacer::new(1)));
159    }
160
161    /// Append a dim one-line notice (retry / error / command feedback).
162    pub fn add_notice(&self, text: &str) {
163        self.chat
164            .add_child(Arc::new(rpi_tui::Text::new(format!("· {text}"), 1, 0)));
165        // One convention for every transcript entry: a trailing blank row,
166        // never a leading one — without it the next block would render flush
167        // against the notice.
168        self.chat.add_child(Arc::new(Spacer::new(1)));
169    }
170
171    /// Fold one normalized event into the retained components. `width` is the
172    /// current terminal width, used to render `edit` diffs.
173    pub fn apply(&self, event: &UiEvent, width: usize) {
174        match event {
175            UiEvent::AgentStart => {}
176            UiEvent::AgentEnd => {
177                if let Some(comp) = self.current_assistant.lock().unwrap().take() {
178                    comp.set_streaming(false);
179                }
180            }
181            UiEvent::Retry {
182                attempt,
183                max_retries,
184                delay_ms,
185                error,
186            } => {
187                self.add_notice(&format!(
188                    "retry {attempt}/{max_retries} in {delay_ms}ms: {error}"
189                ));
190            }
191            UiEvent::AssistantStart { blocks } => {
192                self.start_assistant(blocks);
193            }
194            UiEvent::AssistantUpdate { blocks, tool_calls } => {
195                self.ensure_assistant();
196                self.sync_tool_calls(tool_calls);
197                if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
198                    comp.update_blocks(blocks);
199                }
200            }
201            UiEvent::AssistantEnd { blocks } => {
202                if let Some(comp) = self.current_assistant.lock().unwrap().take() {
203                    comp.update_blocks(blocks);
204                    comp.set_streaming(false);
205                }
206            }
207            UiEvent::ToolStart { id, name, args } => self.tool_start(id, name, args, width),
208            UiEvent::ToolUpdate {
209                id,
210                name,
211                args,
212                result,
213            } => self.tool_update(id, name, args, result, width),
214            UiEvent::ToolEnd {
215                id,
216                name,
217                result,
218                is_error,
219            } => self.tool_end(id, name, result, *is_error, width),
220        }
221    }
222
223    /// Set the global tool-output expansion preference and re-apply it to every
224    /// live panel.
225    pub fn set_expanded(&self, expanded: bool) {
226        *self.expanded.lock().unwrap() = expanded;
227        for child in self.chat.get_children() {
228            if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
229                component.set_expanded(expanded);
230            }
231        }
232        for comp in self.tool_components.lock().unwrap().values() {
233            comp.set_expanded(expanded);
234        }
235        for comp in self.bash_components.lock().unwrap().values() {
236            comp.set_expanded(expanded);
237        }
238    }
239
240    /// Flip the expansion preference; returns the new value.
241    pub fn toggle_expanded(&self) -> bool {
242        let next = !*self.expanded.lock().unwrap();
243        self.set_expanded(next);
244        next
245    }
246
247    /// Whether tool outputs are currently expanded.
248    pub fn expanded(&self) -> bool {
249        *self.expanded.lock().unwrap()
250    }
251
252    /// Hide/show thinking blocks on the in-flight assistant message.
253    pub fn set_hide_thinking(&self, hide: bool) {
254        *self.hide_thinking.lock().unwrap() = hide;
255        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
256            comp.set_hide_thinking(hide);
257        }
258    }
259
260    /// Flip the thinking-visibility preference; returns the new value.
261    pub fn toggle_hide_thinking(&self) -> bool {
262        let next = !*self.hide_thinking.lock().unwrap();
263        self.set_hide_thinking(next);
264        next
265    }
266
267    /// Whether thinking blocks are currently hidden.
268    pub fn hide_thinking(&self) -> bool {
269        *self.hide_thinking.lock().unwrap()
270    }
271
272    /// Install (or clear) the assistant-markdown transformer, re-applying it to
273    /// the in-flight assistant message immediately (mirrors the local reload).
274    pub fn set_markdown_transformer(&self, transformer: Option<MarkdownTransformer>) {
275        *self.markdown_transformer.lock().unwrap() = transformer.clone();
276        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
277            comp.set_markdown_transformer(transformer);
278        }
279    }
280
281    /// Finalize any still-streaming assistant panel (used at turn/run end).
282    pub fn finalize_assistant(&self) {
283        if let Some(comp) = self.current_assistant.lock().unwrap().take() {
284            comp.set_streaming(false);
285        }
286    }
287
288    /// Finalize the assistant panel with an authoritative final block list.
289    pub fn finalize_assistant_blocks(&self, blocks: &[AssistantBlock]) {
290        if let Some(comp) = self.current_assistant.lock().unwrap().take() {
291            comp.update_blocks(blocks);
292            comp.set_streaming(false);
293        }
294    }
295
296    /// Whether any tool panel is still running (drives the local render tick).
297    pub fn has_running_tool(&self) -> bool {
298        self.tool_components
299            .lock()
300            .unwrap()
301            .values()
302            .any(|component| component.status() == ToolStatus::Running)
303    }
304
305    /// Whether a bash panel is currently on screen.
306    pub fn has_active_bash(&self) -> bool {
307        !self.bash_components.lock().unwrap().is_empty()
308    }
309
310    fn start_assistant(&self, blocks: &[AssistantBlock]) {
311        let comp = Arc::new(AssistantMessageComponent::new(
312            AssistantMessageOptions::default(),
313        ));
314        comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
315        comp.set_show_images(self.show_images);
316        comp.set_trailing_spacing(1);
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        comp.update_blocks(blocks);
322        self.chat.add_child(comp.clone());
323        *self.current_assistant.lock().unwrap() = Some(comp);
324    }
325
326    /// Create the streaming assistant component if a delta arrives before
327    /// `assistant_start` (the wire does not strictly guarantee ordering).
328    fn ensure_assistant(&self) {
329        let mut current = self.current_assistant.lock().unwrap();
330        if current.is_none() {
331            let comp = Arc::new(AssistantMessageComponent::new(
332                AssistantMessageOptions::default(),
333            ));
334            comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
335            comp.set_show_images(self.show_images);
336            comp.set_trailing_spacing(1);
337            if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
338                comp.set_markdown_transformer(Some(transformer));
339            }
340            comp.set_streaming(true);
341            self.chat.add_child(comp.clone());
342            *current = Some(comp);
343        }
344    }
345
346    /// Pre-create panels for the tool calls visible in an assistant snapshot. A
347    /// later `tool_start` coalesces onto the same entry.
348    fn sync_tool_calls(&self, calls: &[UiToolCall]) {
349        for call in calls {
350            if matches!(
351                call.name.as_str(),
352                "todo" | "plan_mode_start" | "plan_mode_complete"
353            ) {
354                continue;
355            }
356            if call.name == "bash" {
357                let command = call
358                    .args
359                    .get("command")
360                    .and_then(Value::as_str)
361                    .unwrap_or("");
362                if command.trim().is_empty() {
363                    continue;
364                }
365                let mut bash = self.bash_components.lock().unwrap();
366                if let Some(existing) = bash.get(&call.id) {
367                    existing.set_command(command);
368                } else {
369                    let comp = Arc::new(BashExecutionComponent::new(command));
370                    comp.set_expanded(self.expanded());
371                    self.chat.add_child(comp.clone());
372                    bash.insert(call.id.clone(), comp);
373                }
374                continue;
375            }
376            let ask_user = is_ask_user_tool(&call.name);
377            let display = if ask_user {
378                ask_user_args_display(&call.args)
379            } else {
380                call.args.to_string()
381            };
382            let mut tools = self.tool_components.lock().unwrap();
383            if let Some(existing) = tools.get(&call.id) {
384                if let Some(skill) = skill_tool_name(&call.name, &call.args) {
385                    existing.set_skill_name(skill);
386                }
387                if !display.trim().is_empty() && display.trim() != "{}" {
388                    existing.set_args(&display);
389                }
390            } else {
391                let comp = Arc::new(ToolExecutionComponent::new(&call.name, &display));
392                if ask_user {
393                    comp.set_display_title("ASK USER");
394                }
395                if let Some(skill) = skill_tool_name(&call.name, &call.args) {
396                    comp.set_skill_name(skill);
397                }
398                comp.set_expanded(self.expanded());
399                comp.set_running();
400                self.chat.add_child(comp.clone());
401                tools.insert(call.id.clone(), comp);
402            }
403        }
404    }
405
406    fn tool_start(&self, id: &str, name: &str, args: &Value, _width: usize) {
407        if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
408            return;
409        }
410        if name.trim().is_empty() {
411            return;
412        }
413        if name == "bash" {
414            let command = args.get("command").and_then(Value::as_str).unwrap_or("");
415            // Arguments can still be `{}` when the lifecycle event races streamed
416            // arg finalization — defer until the command lands.
417            if command.trim().is_empty() {
418                return;
419            }
420            let mut bash = self.bash_components.lock().unwrap();
421            if let Some(existing) = bash.get(id) {
422                existing.set_command(command);
423            } else {
424                let comp = Arc::new(BashExecutionComponent::new(command));
425                comp.set_expanded(self.expanded());
426                self.chat.add_child(comp.clone());
427                bash.insert(id.to_string(), comp);
428            }
429            return;
430        }
431        let ask_user = is_ask_user_tool(name);
432        let display = if ask_user {
433            ask_user_args_display(args)
434        } else {
435            args.to_string()
436        };
437        let mut tools = self.tool_components.lock().unwrap();
438        if let Some(existing) = tools.get(id) {
439            if ask_user {
440                existing.set_display_title("ASK USER");
441            }
442            if let Some(skill) = skill_tool_name(name, args) {
443                existing.set_skill_name(skill);
444            }
445            existing.set_args(&display);
446        } else {
447            let comp = Arc::new(ToolExecutionComponent::new(name, &display));
448            if ask_user {
449                comp.set_display_title("ASK USER");
450            }
451            if let Some(skill) = skill_tool_name(name, args) {
452                comp.set_skill_name(skill);
453            }
454            comp.set_expanded(self.expanded());
455            comp.set_running();
456            self.chat.add_child(comp.clone());
457            tools.insert(id.to_string(), comp);
458        }
459    }
460
461    fn tool_update(&self, id: &str, name: &str, args: &Value, result: &Value, width: usize) {
462        if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
463            return;
464        }
465        if name.trim().is_empty() {
466            return;
467        }
468        let ask_user = is_ask_user_tool(name);
469        let text = if ask_user {
470            ask_user_progress_text(args, result)
471        } else {
472            tool_result_text(result)
473        };
474        if name == "bash" {
475            if let Some(bash) = self.bash_components.lock().unwrap().get(id) {
476                if let Some(command) = args.get("command").and_then(Value::as_str) {
477                    bash.set_command(command);
478                }
479                if !text.is_empty() {
480                    bash.append_output(&text);
481                }
482            }
483            return;
484        }
485        let existing = self.tool_components.lock().unwrap().get(id).cloned();
486        match existing {
487            Some(comp) => {
488                if let Some(skill) = skill_tool_name(name, args) {
489                    comp.set_skill_name(skill);
490                }
491                if ask_user {
492                    comp.set_display_title("ASK USER");
493                    comp.set_args(&ask_user_args_display(args));
494                } else if !args.is_null() && args != &serde_json::json!({}) {
495                    comp.set_args(&args.to_string());
496                }
497                if tool_update_has_payload(&text, result) {
498                    comp.set_result(&text, false);
499                    if !ask_user {
500                        apply_edit_diff(&comp, name, result.get("details"), width);
501                    }
502                }
503            }
504            None => {
505                // Update before start (fast tool / out-of-order): create the
506                // running panel now so the partial has somewhere to land.
507                if tool_update_has_payload(&text, result) {
508                    let display = if ask_user {
509                        ask_user_args_display(args)
510                    } else {
511                        args.to_string()
512                    };
513                    let comp = Arc::new(ToolExecutionComponent::new(name, &display));
514                    if ask_user {
515                        comp.set_display_title("ASK USER");
516                    }
517                    if let Some(skill) = skill_tool_name(name, args) {
518                        comp.set_skill_name(skill);
519                    }
520                    comp.set_expanded(self.expanded());
521                    comp.set_running();
522                    comp.set_result(&text, false);
523                    if !ask_user {
524                        apply_edit_diff(&comp, name, result.get("details"), width);
525                    }
526                    self.chat.add_child(comp.clone());
527                    self.tool_components
528                        .lock()
529                        .unwrap()
530                        .insert(id.to_string(), comp);
531                }
532            }
533        }
534    }
535
536    fn tool_end(&self, id: &str, name: &str, result: &Value, is_error: bool, width: usize) {
537        if matches!(name, "plan_mode_start" | "plan_mode_complete")
538            && !is_error
539            && update_plan_panel(&self.chat, result)
540        {
541            return;
542        }
543        if name == "todo" && !is_error && update_todo_list(&self.chat, result) {
544            return;
545        }
546        if name.trim().is_empty() {
547            return;
548        }
549        if name == "bash" {
550            let bash = self.bash_components.lock().unwrap().remove(id);
551            match bash {
552                Some(comp) => finalize_bash(&comp, result, is_error),
553                None => {
554                    let command = result
555                        .get("details")
556                        .and_then(|details| details.get("command"))
557                        .and_then(Value::as_str)
558                        .unwrap_or("");
559                    let text = tool_result_text(result);
560                    if command.trim().is_empty() && text.trim().is_empty() {
561                        return;
562                    }
563                    let comp = Arc::new(BashExecutionComponent::new(command));
564                    comp.set_expanded(self.expanded());
565                    if !text.is_empty() {
566                        comp.append_output(&text);
567                    }
568                    finalize_bash(&comp, result, is_error);
569                    self.chat.add_child(comp);
570                }
571            }
572            return;
573        }
574        let ask_user = is_ask_user_tool(name);
575        let text = if ask_user {
576            ask_user_result_text(result)
577        } else {
578            tool_result_text(result)
579        };
580        let details = result.get("details");
581        let existing = self.tool_components.lock().unwrap().remove(id);
582        let comp = match existing {
583            Some(comp) => comp,
584            None => {
585                // Tool ended without a start/update — render a finalized panel.
586                let comp = Arc::new(ToolExecutionComponent::new(name, ""));
587                if ask_user {
588                    comp.set_display_title("ASK USER");
589                }
590                comp.set_expanded(self.expanded());
591                self.chat.add_child(comp.clone());
592                comp
593            }
594        };
595        comp.set_result(&text, is_error);
596        if !ask_user {
597            if tool_result_requests_markdown(details) {
598                comp.set_result_markdown(true);
599            }
600            apply_edit_diff(&comp, name, details, width);
601        }
602    }
603}
604
605/// Tool calls, slash commands, and restored entries share the same current plan.
606pub(crate) fn update_plan_panel(chat: &Arc<Container>, result: &Value) -> bool {
607    let Some(details) = result.get("details") else {
608        return false;
609    };
610    if details.get("kind").and_then(Value::as_str) != Some("plan") || details.get("error").is_some()
611    {
612        return false;
613    }
614    let Some(active) = details.get("active").and_then(Value::as_bool) else {
615        return false;
616    };
617    let plan = match details.get("plan") {
618        None | Some(Value::Null) => String::new(),
619        Some(Value::String(plan)) => plan.clone(),
620        _ => return false,
621    };
622    for child in chat.get_children().into_iter().rev() {
623        if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
624            if component.is_plan_panel() {
625                component.set_plan_state(plan, active);
626                component.set_result("", false);
627                if let Some(expanded) = details.get("expanded").and_then(Value::as_bool) {
628                    component.set_expanded(expanded);
629                }
630                chat.remove_child(&child);
631                chat.add_child(child);
632                return true;
633            }
634        }
635    }
636    let component = Arc::new(ToolExecutionComponent::new("plan_mode_complete", ""));
637    component.set_plan_state(plan, active);
638    component.set_result("", false);
639    component.set_expanded(
640        details
641            .get("expanded")
642            .and_then(Value::as_bool)
643            .unwrap_or(false),
644    );
645    chat.add_child(component);
646    true
647}
648
649/// Keep one visible current task list while retaining every result in session storage.
650pub(crate) fn update_todo_list(chat: &Arc<Container>, result: &Value) -> bool {
651    let Some(details) = result.get("details") else {
652        return false;
653    };
654    if details.get("error").is_some() {
655        return false;
656    }
657    let Some(items) = details.get("todos").and_then(Value::as_array) else {
658        return false;
659    };
660    let rows: Option<Vec<_>> = items
661        .iter()
662        .map(|item| {
663            let text = item.get("text")?.as_str()?.to_owned();
664            let status = if item.get("done").and_then(Value::as_bool) == Some(true) {
665                "completed"
666            } else {
667                item.get("status")
668                    .and_then(Value::as_str)
669                    .unwrap_or("pending")
670            };
671            Some((text, status.to_owned()))
672        })
673        .collect();
674    let Some(rows) = rows else {
675        return false;
676    };
677    let text = tool_result_text(result);
678    for child in chat.get_children().into_iter().rev() {
679        if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
680            if component.is_todo_list() {
681                component.set_todo_state(rows);
682                component.set_result(&text, false);
683                chat.remove_child(&child);
684                chat.add_child(child);
685                return true;
686            }
687        }
688    }
689    let candidate = Arc::new(ToolExecutionComponent::new("todo", ""));
690    candidate.set_todo_state(rows);
691    candidate.set_result(&text, false);
692    chat.add_child(candidate);
693    true
694}
695
696/// Map a wire/local assistant message's content blocks into the provider-free
697/// [`AssistantBlock`] list the shared component renders (text + thinking +
698/// decoded image blocks, in document order).
699pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
700    let mut blocks = Vec::new();
701    let Some(content) = message.get("content").and_then(Value::as_array) else {
702        return blocks;
703    };
704    for block in content {
705        match block.get("type").and_then(Value::as_str) {
706            Some("text") => {
707                if let Some(text) = block.get("text").and_then(Value::as_str) {
708                    blocks.push(AssistantBlock::Text(text.to_string()));
709                }
710            }
711            Some("thinking") => {
712                if let Some(text) = block.get("thinking").and_then(Value::as_str) {
713                    blocks.push(AssistantBlock::Thinking(text.to_string()));
714                }
715            }
716            Some("image") => {
717                if let Some(data) = block.get("data").and_then(Value::as_str) {
718                    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
719                        if !bytes.is_empty() {
720                            let png = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
721                                Some(bytes)
722                            } else {
723                                rpi_tools::image_processing::process_image(
724                                    &bytes,
725                                    Default::default(),
726                                )
727                                .ok()
728                            };
729                            if let Some(png) = png {
730                                blocks.push(AssistantBlock::Image(png));
731                            }
732                        }
733                    }
734                }
735            }
736            _ => {}
737        }
738    }
739    blocks
740}
741
742/// Extract the finalized tool calls (`type: "toolCall"`) from an assistant
743/// message value, in document order.
744pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
745    let mut calls = Vec::new();
746    let Some(content) = message.get("content").and_then(Value::as_array) else {
747        return calls;
748    };
749    for block in content {
750        if block.get("type").and_then(Value::as_str) != Some("toolCall") {
751            continue;
752        }
753        let id = block.get("id").and_then(Value::as_str).unwrap_or("");
754        let name = block.get("name").and_then(Value::as_str).unwrap_or("");
755        if id.is_empty() || name.trim().is_empty() {
756            continue;
757        }
758        calls.push(UiToolCall {
759            id: id.to_string(),
760            name: name.to_string(),
761            args: block.get("arguments").cloned().unwrap_or(Value::Null),
762        });
763    }
764    calls
765}
766
767/// Text out of a wire tool-result payload (`{content:[{type:"text",text}]}`).
768pub fn tool_result_text(result: &Value) -> String {
769    let mut out = String::new();
770    if let Some(blocks) = result.get("content").and_then(Value::as_array) {
771        for block in blocks {
772            if let Some(text) = block.get("text").and_then(Value::as_str) {
773                if !out.is_empty() {
774                    out.push('\n');
775                }
776                out.push_str(text);
777            }
778        }
779    }
780    out
781}
782
783/// Whether a tool result opts into markdown rendering of its body
784/// (`details.markdown = true`).
785pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
786    details
787        .and_then(|details| details.get("markdown"))
788        .and_then(Value::as_bool)
789        .unwrap_or(false)
790}
791
792/// The skill name when `tool_name` is a `read` of a `SKILL.md` file (the
793/// `SKILL.md` parent directory's basename), else `None`. A skill read renders
794/// as upstream's `[skill] <name>` box.
795pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
796    if tool_name != "read" {
797        return None;
798    }
799    let path = args.get("path").and_then(Value::as_str)?;
800    let normalized = path.replace('\\', "/");
801    let file_name = normalized.rsplit('/').next()?;
802    if !file_name.eq_ignore_ascii_case("SKILL.md") {
803        return None;
804    }
805    normalized
806        .trim_end_matches('/')
807        .rsplit('/')
808        .nth(1)
809        .filter(|name| !name.is_empty())
810        .map(str::to_string)
811}
812
813/// Tool names rendered through the dedicated `ASK USER` panel instead of a
814/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
815fn is_ask_user_tool(tool_name: &str) -> bool {
816    matches!(tool_name, "ask_user" | "ask_user_question")
817}
818
819/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
820/// transport JSON must never leak into the transcript.
821fn ask_user_args_display(args: &Value) -> String {
822    if args.is_null() {
823        return String::new();
824    }
825    if let Some(summary) = args
826        .get("summary")
827        .and_then(Value::as_str)
828        .map(str::trim)
829        .filter(|text| !text.is_empty())
830    {
831        return format!("Confirmation requested: {summary}");
832    }
833
834    let mut lines: Vec<String> = Vec::new();
835    let questions: Vec<&Value> = args
836        .get("questions")
837        .and_then(Value::as_array)
838        .filter(|items| !items.is_empty())
839        .map(|items| items.iter().collect())
840        .unwrap_or_else(|| vec![args]);
841
842    for question in questions {
843        let prompt = question
844            .get("question")
845            .and_then(Value::as_str)
846            .or_else(|| args.get("question").and_then(Value::as_str))
847            .map(str::trim)
848            .unwrap_or_default();
849        if prompt.is_empty() {
850            continue;
851        }
852        if let Some(header) = question
853            .get("header")
854            .and_then(Value::as_str)
855            .map(str::trim)
856            .filter(|text| !text.is_empty())
857        {
858            lines.push(format!("{header}: {prompt}"));
859        } else {
860            lines.push(prompt.to_string());
861        }
862        let context = question
863            .get("context")
864            .and_then(Value::as_str)
865            .or_else(|| args.get("context").and_then(Value::as_str))
866            .map(str::trim)
867            .filter(|text| !text.is_empty());
868        if let Some(context) = context {
869            lines.push(format!("  {context}"));
870        }
871        let options = question
872            .get("options")
873            .and_then(Value::as_array)
874            .or_else(|| args.get("options").and_then(Value::as_array));
875        if let Some(options) = options {
876            let labels: Vec<String> = options
877                .iter()
878                .filter_map(|option| {
879                    option
880                        .get("title")
881                        .and_then(Value::as_str)
882                        .or_else(|| option.as_str())
883                        .map(str::trim)
884                        .filter(|label| !label.is_empty())
885                        .map(str::to_string)
886                })
887                .collect();
888            if !labels.is_empty() {
889                lines.push(format!("Choices: {}", labels.join(", ")));
890            }
891        }
892        if let Some(suggest) = question
893            .get("suggest")
894            .and_then(Value::as_str)
895            .or_else(|| args.get("suggest").and_then(Value::as_str))
896            .map(str::trim)
897            .filter(|text| !text.is_empty())
898        {
899            lines.push(format!("Suggestions: {suggest}"));
900        }
901    }
902
903    lines.join("\n")
904}
905
906/// Progress text while an ask_user tool is pending. Prefer the tool's own
907/// readable `content`, otherwise show the question so the panel is not blank.
908fn ask_user_progress_text(args: &Value, result: &Value) -> String {
909    let text = tool_result_text(result);
910    if !text.trim().is_empty() {
911        return text;
912    }
913    let args_text = ask_user_args_display(args);
914    if args_text.trim().is_empty() {
915        "Waiting for your answer…".to_string()
916    } else {
917        args_text
918    }
919}
920
921/// Final text for a completed ask_user tool.
922fn ask_user_result_text(result: &Value) -> String {
923    let text = tool_result_text(result);
924    if text.trim().is_empty() {
925        "Answer recorded.".to_string()
926    } else {
927        text
928    }
929}
930
931/// Whether a partial tool result carries anything worth rendering.
932fn tool_update_has_payload(text: &str, result: &Value) -> bool {
933    !text.trim().is_empty()
934        || result
935            .get("details")
936            .map(|details| !details.is_null())
937            .unwrap_or(false)
938}
939
940/// Attach a colored diff to an `edit` tool panel. `write` carries no diff and
941/// stays a plain summary.
942fn apply_edit_diff(
943    comp: &Arc<ToolExecutionComponent>,
944    tool_name: &str,
945    details: Option<&Value>,
946    width: usize,
947) {
948    if tool_name != "edit" {
949        return;
950    }
951    let Some(diff_text) = details
952        .and_then(|details| details.get("diff"))
953        .and_then(Value::as_str)
954    else {
955        return;
956    };
957    if diff_text.is_empty() {
958        return;
959    }
960    comp.set_diff(render_diff(diff_text, width));
961}
962
963/// Mark a bash panel complete from the wire result payload. The exit code is
964/// derived from `is_error`; truncation info is read from `details`.
965fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
966    let details = result.get("details");
967    let exit_code = Some(if is_error { 1 } else { 0 });
968    let truncated = details
969        .and_then(|details| details.get("truncation"))
970        .and_then(|truncation| truncation.get("truncated"))
971        .and_then(Value::as_bool)
972        .unwrap_or(false);
973    let full_output_path = details
974        .and_then(|details| details.get("full_output_path"))
975        .and_then(Value::as_str)
976        .map(str::to_string);
977    if let Some(command) = details
978        .and_then(|details| details.get("command"))
979        .and_then(Value::as_str)
980    {
981        comp.set_command(command);
982    }
983    comp.set_complete(
984        exit_code,
985        false,
986        BashTruncation {
987            truncated,
988            full_output_path,
989        },
990    );
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996    use rpi_tui::{strip_ansi, Component};
997    use serde_json::json;
998
999    fn view() -> TranscriptView {
1000        TranscriptView::new(Arc::new(Container::new()))
1001    }
1002
1003    #[test]
1004    fn adjacent_transcript_blocks_separated_by_exactly_one_blank() {
1005        // The seam invariant: any two adjacent transcript blocks — whatever
1006        // their kinds — are separated by exactly one blank line. Every entry
1007        // contributes one trailing blank (assistant trailing spacing, panel
1008        // trailing spacer, host trailing Spacer for user/notice entries), and
1009        // no block opens with a leading spacer, so the gap never depends on
1010        // which kind sits on top. Old behavior: a panel above an assistant
1011        // message gave 0 blanks (thinking glued to the code card) while an
1012        // assistant message above a panel gave 2 (trailing spacing + panel
1013        // top spacer stacking).
1014        //
1015        // Only *truly* empty rows count as a gap: a panel's own padding rows
1016        // carry a background color (non-empty ANSI payload) and belong to the
1017        // panel, not to the seam.
1018        let view = view();
1019        view.add_user("question");
1020        view.apply(
1021            &UiEvent::AssistantStart {
1022                blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1023            },
1024            80,
1025        );
1026        view.apply(
1027            &UiEvent::AssistantUpdate {
1028                blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1029                tool_calls: vec![UiToolCall {
1030                    id: "t1".into(),
1031                    name: "read".into(),
1032                    args: json!({"path": "f.txt"}),
1033                }],
1034            },
1035            80,
1036        );
1037        view.apply(
1038            &UiEvent::ToolEnd {
1039                id: "t1".into(),
1040                name: "read".into(),
1041                result: json!({"content": [{"type": "text", "text": "done"}]}),
1042                is_error: false,
1043            },
1044            80,
1045        );
1046        view.apply(
1047            &UiEvent::AssistantStart {
1048                blocks: vec![AssistantBlock::Text("answer".into())],
1049            },
1050            80,
1051        );
1052        view.apply(
1053            &UiEvent::ToolStart {
1054                id: "b1".into(),
1055                name: "bash".into(),
1056                args: json!({"command": "ls"}),
1057            },
1058            80,
1059        );
1060        view.apply(&UiEvent::AgentEnd, 80);
1061
1062        let lines = view.chat.render(80);
1063        let plain: Vec<String> = lines
1064            .iter()
1065            .map(|l| strip_ansi(l).trim().to_string())
1066            .collect();
1067        // Anchor each block by its LAST content line, so the rows between two
1068        // anchors are exactly the seam between the two blocks.
1069        let find = |needle: &str| {
1070            plain
1071                .iter()
1072                .rposition(|l| l.contains(needle))
1073                .unwrap_or_else(|| panic!("{needle} missing: {plain:?}"))
1074        };
1075        let user = find("question");
1076        let thinking = find("reasoning");
1077        let panel = find("done");
1078        let answer = find("answer");
1079        let bash = find("$ ls");
1080        assert!(user < thinking && thinking < panel && panel < answer);
1081        assert!(answer < bash);
1082        for (top_name, top, bottom_name, bottom) in [
1083            ("user", user, "thinking", thinking),
1084            ("thinking", thinking, "panel", panel),
1085            ("panel", panel, "answer", answer),
1086            ("answer", answer, "bash", bash),
1087        ] {
1088            let gap = lines[top + 1..bottom]
1089                .iter()
1090                .filter(|l| l.is_empty())
1091                .count();
1092            assert_eq!(
1093                gap, 1,
1094                "{top_name}→{bottom_name} seam must be exactly one blank row: {plain:?}"
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn retry_notice_ends_with_a_trailing_spacer() {
1101        // A notice is a transcript entry like any other: without its own
1102        // trailing blank it would render flush against the next block.
1103        let view = view();
1104        view.apply(
1105            &UiEvent::Retry {
1106                attempt: 1,
1107                max_retries: 3,
1108                delay_ms: 500,
1109                error: "boom".into(),
1110            },
1111            80,
1112        );
1113        let lines = view.chat.render(80);
1114        let rendered = strip_ansi(&lines.join("\n"));
1115        assert!(rendered.contains("retry 1/3"));
1116        assert_eq!(lines.last(), Some(&String::new()));
1117    }
1118
1119    #[test]
1120    fn empty_assistant_start_and_whitespace_deltas_do_not_move_the_transcript() {
1121        for starts_with_event in [true, false] {
1122            let view = view();
1123            view.add_user("hi");
1124            let before = view.chat.render(80);
1125            if starts_with_event {
1126                view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1127                assert_eq!(view.chat.render(80), before);
1128            }
1129            for blocks in [
1130                vec![],
1131                vec![AssistantBlock::Text(" ".into())],
1132                vec![AssistantBlock::Thinking("\n".into())],
1133            ] {
1134                view.apply(
1135                    &UiEvent::AssistantUpdate {
1136                        blocks,
1137                        tool_calls: vec![],
1138                    },
1139                    80,
1140                );
1141                assert_eq!(view.chat.render(80), before);
1142            }
1143            view.apply(
1144                &UiEvent::AssistantUpdate {
1145                    blocks: vec![AssistantBlock::Text("hello".into())],
1146                    tool_calls: vec![],
1147                },
1148                80,
1149            );
1150            let visible = view.chat.render(80);
1151            assert!(visible.len() > before.len());
1152            assert!(visible.iter().any(|line| line.contains("hello")));
1153            assert_eq!(visible.last(), Some(&String::new()));
1154            view.apply(&UiEvent::AssistantEnd { blocks: vec![] }, 80);
1155            assert_eq!(view.chat.render(80), before);
1156            view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1157            assert_eq!(view.chat.render(80), before);
1158        }
1159    }
1160
1161    #[test]
1162    fn assistant_blocks_follow_content_order() {
1163        let message = json!({
1164            "content": [
1165                {"type": "thinking", "thinking": "why"},
1166                {"type": "text", "text": "hello"},
1167                {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
1168            ]
1169        });
1170        assert_eq!(
1171            assistant_blocks_from_value(&message),
1172            vec![
1173                AssistantBlock::Thinking("why".into()),
1174                AssistantBlock::Text("hello".into()),
1175            ]
1176        );
1177    }
1178
1179    #[test]
1180    fn assistant_blocks_decode_images() {
1181        let mut bytes = Vec::new();
1182        {
1183            let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1184            encoder.set_color(png::ColorType::Rgb);
1185            encoder
1186                .write_header()
1187                .unwrap()
1188                .write_image_data(&[255, 0, 0])
1189                .unwrap();
1190        }
1191        let message = json!({
1192            "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "mimeType": "image/png"}]
1193        });
1194        assert_eq!(
1195            assistant_blocks_from_value(&message),
1196            vec![AssistantBlock::Image(bytes.to_vec())]
1197        );
1198        let jpeg = rpi_tools::image_processing::process_image(
1199            &bytes,
1200            rpi_tools::image_processing::ImageProcessingOptions {
1201                format: Some(rpi_tools::image_processing::OutputFormat::Jpeg { quality: 85 }),
1202                ..Default::default()
1203            },
1204        )
1205        .unwrap();
1206        let blocks = assistant_blocks_from_value(&json!({
1207            "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(jpeg), "mimeType": "image/jpeg"}]
1208        }));
1209        assert!(
1210            matches!(&blocks[0], AssistantBlock::Image(data) if data.starts_with(b"\x89PNG\r\n\x1a\n"))
1211        );
1212    }
1213
1214    #[test]
1215    fn tool_calls_extract_ids_names_and_args() {
1216        let message = json!({
1217            "content": [
1218                {"type": "text", "text": "x"},
1219                {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
1220                {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
1221            ]
1222        });
1223        assert_eq!(
1224            tool_calls_from_value(&message),
1225            vec![UiToolCall {
1226                id: "b1".into(),
1227                name: "bash".into(),
1228                args: json!({"command": "ls"}),
1229            }]
1230        );
1231    }
1232
1233    #[test]
1234    fn markdown_flag_is_opt_in() {
1235        assert!(tool_result_requests_markdown(Some(
1236            &json!({"markdown": true})
1237        )));
1238        assert!(!tool_result_requests_markdown(Some(
1239            &json!({"markdown": false})
1240        )));
1241        assert!(!tool_result_requests_markdown(Some(&json!({}))));
1242        assert!(!tool_result_requests_markdown(None));
1243    }
1244
1245    #[test]
1246    fn skill_tool_name_only_matches_skill_reads() {
1247        assert_eq!(
1248            skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
1249            Some("foo".to_string())
1250        );
1251        assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
1252        assert_eq!(
1253            skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
1254            None
1255        );
1256    }
1257
1258    #[test]
1259    fn ask_user_args_display_never_leaks_transport_json() {
1260        let text = ask_user_args_display(&json!({
1261            "question": "你的代理端口是多少?",
1262            "context": "本机",
1263            "options": ["7890", {"title": "7897", "description": "clash"}],
1264            "suggest": "1080"
1265        }));
1266        assert!(text.contains("你的代理端口是多少?"));
1267        assert!(text.contains("本机"));
1268        assert!(text.contains("Choices: 7890, 7897"));
1269        assert!(text.contains("Suggestions: 1080"));
1270        assert!(!text.trim_start().starts_with('{'));
1271        assert_eq!(
1272            ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
1273            "Confirmation requested: Deploy?"
1274        );
1275    }
1276
1277    #[test]
1278    fn tool_start_routes_bash_to_its_own_map() {
1279        let view = view();
1280        view.apply(
1281            &UiEvent::ToolStart {
1282                id: "b1".into(),
1283                name: "bash".into(),
1284                args: json!({"command": "ls -la"}),
1285            },
1286            80,
1287        );
1288        view.apply(
1289            &UiEvent::ToolStart {
1290                id: "r1".into(),
1291                name: "read".into(),
1292                args: json!({"path": "a.txt"}),
1293            },
1294            80,
1295        );
1296        assert!(view.bash_components.lock().unwrap().contains_key("b1"));
1297        assert!(view.tool_components.lock().unwrap().contains_key("r1"));
1298        assert!(view.tool_components.lock().unwrap().get("b1").is_none());
1299    }
1300
1301    #[test]
1302    fn bash_start_without_a_command_is_deferred() {
1303        let view = view();
1304        view.apply(
1305            &UiEvent::ToolStart {
1306                id: "b1".into(),
1307                name: "bash".into(),
1308                args: json!({}),
1309            },
1310            80,
1311        );
1312        assert!(view.bash_components.lock().unwrap().is_empty());
1313        assert!(view.tool_components.lock().unwrap().is_empty());
1314    }
1315
1316    #[test]
1317    fn assistant_stream_reuses_one_component() {
1318        let view = view();
1319        view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1320        view.apply(
1321            &UiEvent::AssistantUpdate {
1322                blocks: vec![AssistantBlock::Text("hi".into())],
1323                tool_calls: vec![],
1324            },
1325            80,
1326        );
1327        assert!(view.current_assistant.lock().unwrap().is_some());
1328    }
1329
1330    #[test]
1331    fn toggle_expanded_round_trips() {
1332        let view = view();
1333        assert!(!view.expanded());
1334        assert!(view.toggle_expanded());
1335        assert!(view.expanded());
1336    }
1337
1338    #[test]
1339    fn todo_updates_share_one_panel_and_preserve_errors() {
1340        let view = view();
1341        for (id, done) in [("a", false), ("b", true)] {
1342            view.sync_tool_calls(&[UiToolCall {
1343                id: id.into(),
1344                name: "todo".into(),
1345                args: json!({"action":"toggle"}),
1346            }]);
1347            view.tool_start(id, "todo", &json!({}), 80);
1348            view.tool_end(id, "todo", &json!({"content":[],"details":{"todos":[{"id":1,"text":"Run tests","done":done}]}}), false, 80);
1349        }
1350        assert_eq!(view.chat.child_count(), 1);
1351        assert!(view.tool_components.lock().unwrap().is_empty());
1352        let children = view.chat.get_children();
1353        let panel = children[0]
1354            .as_any()
1355            .downcast_ref::<ToolExecutionComponent>()
1356            .unwrap();
1357        let rendered = rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n"));
1358        assert!(rendered.contains("1/1 completed"));
1359        assert!(rendered.contains("[✓] Run tests"));
1360        view.tool_end(
1361            "error",
1362            "todo",
1363            &json!({"content":[{"type":"text","text":"invalid task"}]}),
1364            true,
1365            80,
1366        );
1367        assert_eq!(view.chat.child_count(), 2);
1368        view.tool_end("soft-error", "todo", &json!({"content":[{"type":"text","text":"todo not found"}],"details":{"todos":[],"error":"todo not found"}}), false, 80);
1369        assert_eq!(view.chat.child_count(), 3);
1370        assert!(update_todo_list(
1371            &view.chat,
1372            &json!({"details":{"todos":[]}})
1373        ));
1374        assert_eq!(view.chat.child_count(), 3);
1375        assert!(!update_todo_list(
1376            &view.chat,
1377            &json!({"details":{"todos":[{}]}})
1378        ));
1379        assert_eq!(view.chat.child_count(), 3);
1380    }
1381
1382    #[test]
1383    fn plan_tools_and_show_update_one_expandable_panel() {
1384        let view = view();
1385        view.sync_tool_calls(&[UiToolCall {
1386            id: "start".into(),
1387            name: "plan_mode_start".into(),
1388            args: json!({}),
1389        }]);
1390        view.tool_start("start", "plan_mode_start", &json!({}), 80);
1391        view.tool_end(
1392            "start",
1393            "plan_mode_start",
1394            &json!({"details":{"kind":"plan","active":true}}),
1395            false,
1396            80,
1397        );
1398        let body = (1..=30)
1399            .map(|i| format!("{i}. Step {i}"))
1400            .collect::<Vec<_>>()
1401            .join("\n");
1402        view.tool_end(
1403            "complete",
1404            "plan_mode_complete",
1405            &json!({"details":{"kind":"plan","active":false,"plan":body}}),
1406            false,
1407            80,
1408        );
1409        assert_eq!(view.chat.child_count(), 1);
1410        assert!(view.tool_components.lock().unwrap().is_empty());
1411        view.set_expanded(true);
1412        let children = view.chat.get_children();
1413        let panel = children[0]
1414            .as_any()
1415            .downcast_ref::<ToolExecutionComponent>()
1416            .unwrap();
1417        assert!(rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n")).contains("Step 30"));
1418        assert!(update_plan_panel(
1419            &view.chat,
1420            &json!({"kind":"message","details":{"kind":"plan","active":false,"plan":body,"expanded":true}})
1421        ));
1422        assert_eq!(view.chat.child_count(), 1);
1423        view.tool_end(
1424            "error",
1425            "plan_mode_complete",
1426            &json!({"content":[{"type":"text","text":"not active"}]}),
1427            true,
1428            80,
1429        );
1430        assert_eq!(view.chat.child_count(), 2);
1431    }
1432}