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