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 !display.trim().is_empty() && display.trim() != "{}" {
385                    existing.set_args(&display);
386                }
387            } else {
388                let comp = Arc::new(ToolExecutionComponent::new(&call.name, &display));
389                if ask_user {
390                    comp.set_display_title("ASK USER");
391                }
392                if let Some(skill) = skill_tool_name(&call.name, &call.args) {
393                    comp.set_skill_name(skill);
394                }
395                comp.set_expanded(self.expanded());
396                comp.set_running();
397                self.chat.add_child(comp.clone());
398                tools.insert(call.id.clone(), comp);
399            }
400        }
401    }
402
403    fn tool_start(&self, id: &str, name: &str, args: &Value, _width: usize) {
404        if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
405            return;
406        }
407        if name.trim().is_empty() {
408            return;
409        }
410        if name == "bash" {
411            let command = args.get("command").and_then(Value::as_str).unwrap_or("");
412            // Arguments can still be `{}` when the lifecycle event races streamed
413            // arg finalization — defer until the command lands.
414            if command.trim().is_empty() {
415                return;
416            }
417            let mut bash = self.bash_components.lock().unwrap();
418            if let Some(existing) = bash.get(id) {
419                existing.set_command(command);
420            } else {
421                let comp = Arc::new(BashExecutionComponent::new(command));
422                comp.set_expanded(self.expanded());
423                self.chat.add_child(comp.clone());
424                bash.insert(id.to_string(), comp);
425            }
426            return;
427        }
428        let ask_user = is_ask_user_tool(name);
429        let display = if ask_user {
430            ask_user_args_display(args)
431        } else {
432            args.to_string()
433        };
434        let mut tools = self.tool_components.lock().unwrap();
435        if let Some(existing) = tools.get(id) {
436            if ask_user {
437                existing.set_display_title("ASK USER");
438            }
439            existing.set_args(&display);
440        } else {
441            let comp = Arc::new(ToolExecutionComponent::new(name, &display));
442            if ask_user {
443                comp.set_display_title("ASK USER");
444            }
445            if let Some(skill) = skill_tool_name(name, args) {
446                comp.set_skill_name(skill);
447            }
448            comp.set_expanded(self.expanded());
449            comp.set_running();
450            self.chat.add_child(comp.clone());
451            tools.insert(id.to_string(), comp);
452        }
453    }
454
455    fn tool_update(&self, id: &str, name: &str, args: &Value, result: &Value, width: usize) {
456        if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
457            return;
458        }
459        if name.trim().is_empty() {
460            return;
461        }
462        let ask_user = is_ask_user_tool(name);
463        let text = if ask_user {
464            ask_user_progress_text(args, result)
465        } else {
466            tool_result_text(result)
467        };
468        if name == "bash" {
469            if let Some(bash) = self.bash_components.lock().unwrap().get(id) {
470                if let Some(command) = args.get("command").and_then(Value::as_str) {
471                    bash.set_command(command);
472                }
473                if !text.is_empty() {
474                    bash.append_output(&text);
475                }
476            }
477            return;
478        }
479        let existing = self.tool_components.lock().unwrap().get(id).cloned();
480        match existing {
481            Some(comp) => {
482                if ask_user {
483                    comp.set_display_title("ASK USER");
484                    comp.set_args(&ask_user_args_display(args));
485                } else if !args.is_null() && args != &serde_json::json!({}) {
486                    comp.set_args(&args.to_string());
487                }
488                if tool_update_has_payload(&text, result) {
489                    comp.set_result(&text, false);
490                    if !ask_user {
491                        apply_edit_diff(&comp, name, result.get("details"), width);
492                    }
493                }
494            }
495            None => {
496                // Update before start (fast tool / out-of-order): create the
497                // running panel now so the partial has somewhere to land.
498                if tool_update_has_payload(&text, result) {
499                    let display = if ask_user {
500                        ask_user_args_display(args)
501                    } else {
502                        args.to_string()
503                    };
504                    let comp = Arc::new(ToolExecutionComponent::new(name, &display));
505                    if ask_user {
506                        comp.set_display_title("ASK USER");
507                    }
508                    if let Some(skill) = skill_tool_name(name, args) {
509                        comp.set_skill_name(skill);
510                    }
511                    comp.set_expanded(self.expanded());
512                    comp.set_running();
513                    comp.set_result(&text, false);
514                    if !ask_user {
515                        apply_edit_diff(&comp, name, result.get("details"), width);
516                    }
517                    self.chat.add_child(comp.clone());
518                    self.tool_components
519                        .lock()
520                        .unwrap()
521                        .insert(id.to_string(), comp);
522                }
523            }
524        }
525    }
526
527    fn tool_end(&self, id: &str, name: &str, result: &Value, is_error: bool, width: usize) {
528        if matches!(name, "plan_mode_start" | "plan_mode_complete")
529            && !is_error
530            && update_plan_panel(&self.chat, result)
531        {
532            return;
533        }
534        if name == "todo" && !is_error && update_todo_list(&self.chat, result) {
535            return;
536        }
537        if name.trim().is_empty() {
538            return;
539        }
540        if name == "bash" {
541            let bash = self.bash_components.lock().unwrap().remove(id);
542            match bash {
543                Some(comp) => finalize_bash(&comp, result, is_error),
544                None => {
545                    let command = result
546                        .get("details")
547                        .and_then(|details| details.get("command"))
548                        .and_then(Value::as_str)
549                        .unwrap_or("");
550                    let text = tool_result_text(result);
551                    if command.trim().is_empty() && text.trim().is_empty() {
552                        return;
553                    }
554                    let comp = Arc::new(BashExecutionComponent::new(command));
555                    comp.set_expanded(self.expanded());
556                    if !text.is_empty() {
557                        comp.append_output(&text);
558                    }
559                    finalize_bash(&comp, result, is_error);
560                    self.chat.add_child(comp);
561                }
562            }
563            return;
564        }
565        let ask_user = is_ask_user_tool(name);
566        let text = if ask_user {
567            ask_user_result_text(result)
568        } else {
569            tool_result_text(result)
570        };
571        let details = result.get("details");
572        let existing = self.tool_components.lock().unwrap().remove(id);
573        let comp = match existing {
574            Some(comp) => comp,
575            None => {
576                // Tool ended without a start/update — render a finalized panel.
577                let comp = Arc::new(ToolExecutionComponent::new(name, ""));
578                if ask_user {
579                    comp.set_display_title("ASK USER");
580                }
581                comp.set_expanded(self.expanded());
582                self.chat.add_child(comp.clone());
583                comp
584            }
585        };
586        comp.set_result(&text, is_error);
587        if !ask_user {
588            if tool_result_requests_markdown(details) {
589                comp.set_result_markdown(true);
590            }
591            apply_edit_diff(&comp, name, details, width);
592        }
593    }
594}
595
596/// Tool calls, slash commands, and restored entries share the same current plan.
597pub(crate) fn update_plan_panel(chat: &Arc<Container>, result: &Value) -> bool {
598    let Some(details) = result.get("details") else {
599        return false;
600    };
601    if details.get("kind").and_then(Value::as_str) != Some("plan") || details.get("error").is_some()
602    {
603        return false;
604    }
605    let Some(active) = details.get("active").and_then(Value::as_bool) else {
606        return false;
607    };
608    let plan = match details.get("plan") {
609        None | Some(Value::Null) => String::new(),
610        Some(Value::String(plan)) => plan.clone(),
611        _ => return false,
612    };
613    for child in chat.get_children().into_iter().rev() {
614        if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
615            if component.is_plan_panel() {
616                component.set_plan_state(plan, active);
617                component.set_result("", false);
618                if let Some(expanded) = details.get("expanded").and_then(Value::as_bool) {
619                    component.set_expanded(expanded);
620                }
621                chat.remove_child(&child);
622                chat.add_child(child);
623                return true;
624            }
625        }
626    }
627    let component = Arc::new(ToolExecutionComponent::new("plan_mode_complete", ""));
628    component.set_plan_state(plan, active);
629    component.set_result("", false);
630    component.set_expanded(
631        details
632            .get("expanded")
633            .and_then(Value::as_bool)
634            .unwrap_or(false),
635    );
636    chat.add_child(component);
637    true
638}
639
640/// Keep one visible current task list while retaining every result in session storage.
641pub(crate) fn update_todo_list(chat: &Arc<Container>, result: &Value) -> bool {
642    let Some(details) = result.get("details") else {
643        return false;
644    };
645    if details.get("error").is_some() {
646        return false;
647    }
648    let Some(items) = details.get("todos").and_then(Value::as_array) else {
649        return false;
650    };
651    let rows: Option<Vec<_>> = items
652        .iter()
653        .map(|item| {
654            let text = item.get("text")?.as_str()?.to_owned();
655            let status = if item.get("done").and_then(Value::as_bool) == Some(true) {
656                "completed"
657            } else {
658                item.get("status")
659                    .and_then(Value::as_str)
660                    .unwrap_or("pending")
661            };
662            Some((text, status.to_owned()))
663        })
664        .collect();
665    let Some(rows) = rows else {
666        return false;
667    };
668    let text = tool_result_text(result);
669    for child in chat.get_children().into_iter().rev() {
670        if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
671            if component.is_todo_list() {
672                component.set_todo_state(rows);
673                component.set_result(&text, false);
674                chat.remove_child(&child);
675                chat.add_child(child);
676                return true;
677            }
678        }
679    }
680    let candidate = Arc::new(ToolExecutionComponent::new("todo", ""));
681    candidate.set_todo_state(rows);
682    candidate.set_result(&text, false);
683    chat.add_child(candidate);
684    true
685}
686
687/// Map a wire/local assistant message's content blocks into the provider-free
688/// [`AssistantBlock`] list the shared component renders (text + thinking +
689/// decoded image blocks, in document order).
690pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
691    let mut blocks = Vec::new();
692    let Some(content) = message.get("content").and_then(Value::as_array) else {
693        return blocks;
694    };
695    for block in content {
696        match block.get("type").and_then(Value::as_str) {
697            Some("text") => {
698                if let Some(text) = block.get("text").and_then(Value::as_str) {
699                    blocks.push(AssistantBlock::Text(text.to_string()));
700                }
701            }
702            Some("thinking") => {
703                if let Some(text) = block.get("thinking").and_then(Value::as_str) {
704                    blocks.push(AssistantBlock::Thinking(text.to_string()));
705                }
706            }
707            Some("image") => {
708                if let Some(data) = block.get("data").and_then(Value::as_str) {
709                    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
710                        if !bytes.is_empty() {
711                            let png = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
712                                Some(bytes)
713                            } else {
714                                rpi_tools::image_processing::process_image(
715                                    &bytes,
716                                    Default::default(),
717                                )
718                                .ok()
719                            };
720                            if let Some(png) = png {
721                                blocks.push(AssistantBlock::Image(png));
722                            }
723                        }
724                    }
725                }
726            }
727            _ => {}
728        }
729    }
730    blocks
731}
732
733/// Extract the finalized tool calls (`type: "toolCall"`) from an assistant
734/// message value, in document order.
735pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
736    let mut calls = Vec::new();
737    let Some(content) = message.get("content").and_then(Value::as_array) else {
738        return calls;
739    };
740    for block in content {
741        if block.get("type").and_then(Value::as_str) != Some("toolCall") {
742            continue;
743        }
744        let id = block.get("id").and_then(Value::as_str).unwrap_or("");
745        let name = block.get("name").and_then(Value::as_str).unwrap_or("");
746        if id.is_empty() || name.trim().is_empty() {
747            continue;
748        }
749        calls.push(UiToolCall {
750            id: id.to_string(),
751            name: name.to_string(),
752            args: block.get("arguments").cloned().unwrap_or(Value::Null),
753        });
754    }
755    calls
756}
757
758/// Text out of a wire tool-result payload (`{content:[{type:"text",text}]}`).
759pub fn tool_result_text(result: &Value) -> String {
760    let mut out = String::new();
761    if let Some(blocks) = result.get("content").and_then(Value::as_array) {
762        for block in blocks {
763            if let Some(text) = block.get("text").and_then(Value::as_str) {
764                if !out.is_empty() {
765                    out.push('\n');
766                }
767                out.push_str(text);
768            }
769        }
770    }
771    out
772}
773
774/// Whether a tool result opts into markdown rendering of its body
775/// (`details.markdown = true`).
776pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
777    details
778        .and_then(|details| details.get("markdown"))
779        .and_then(Value::as_bool)
780        .unwrap_or(false)
781}
782
783/// The skill name when `tool_name` is a `read` of a `SKILL.md` file (the
784/// `SKILL.md` parent directory's basename), else `None`. A skill read renders
785/// as upstream's `[skill] <name>` box.
786pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
787    if tool_name != "read" {
788        return None;
789    }
790    let path = args.get("path").and_then(Value::as_str)?;
791    let normalized = path.replace('\\', "/");
792    let file_name = normalized.rsplit('/').next()?;
793    if !file_name.eq_ignore_ascii_case("SKILL.md") {
794        return None;
795    }
796    normalized
797        .trim_end_matches('/')
798        .rsplit('/')
799        .nth(1)
800        .filter(|name| !name.is_empty())
801        .map(str::to_string)
802}
803
804/// Tool names rendered through the dedicated `ASK USER` panel instead of a
805/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
806fn is_ask_user_tool(tool_name: &str) -> bool {
807    matches!(tool_name, "ask_user" | "ask_user_question")
808}
809
810/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
811/// transport JSON must never leak into the transcript.
812fn ask_user_args_display(args: &Value) -> String {
813    if args.is_null() {
814        return String::new();
815    }
816    if let Some(summary) = args
817        .get("summary")
818        .and_then(Value::as_str)
819        .map(str::trim)
820        .filter(|text| !text.is_empty())
821    {
822        return format!("Confirmation requested: {summary}");
823    }
824
825    let mut lines: Vec<String> = Vec::new();
826    let questions: Vec<&Value> = args
827        .get("questions")
828        .and_then(Value::as_array)
829        .filter(|items| !items.is_empty())
830        .map(|items| items.iter().collect())
831        .unwrap_or_else(|| vec![args]);
832
833    for question in questions {
834        let prompt = question
835            .get("question")
836            .and_then(Value::as_str)
837            .or_else(|| args.get("question").and_then(Value::as_str))
838            .map(str::trim)
839            .unwrap_or_default();
840        if prompt.is_empty() {
841            continue;
842        }
843        if let Some(header) = question
844            .get("header")
845            .and_then(Value::as_str)
846            .map(str::trim)
847            .filter(|text| !text.is_empty())
848        {
849            lines.push(format!("{header}: {prompt}"));
850        } else {
851            lines.push(prompt.to_string());
852        }
853        let context = question
854            .get("context")
855            .and_then(Value::as_str)
856            .or_else(|| args.get("context").and_then(Value::as_str))
857            .map(str::trim)
858            .filter(|text| !text.is_empty());
859        if let Some(context) = context {
860            lines.push(format!("  {context}"));
861        }
862        let options = question
863            .get("options")
864            .and_then(Value::as_array)
865            .or_else(|| args.get("options").and_then(Value::as_array));
866        if let Some(options) = options {
867            let labels: Vec<String> = options
868                .iter()
869                .filter_map(|option| {
870                    option
871                        .get("title")
872                        .and_then(Value::as_str)
873                        .or_else(|| option.as_str())
874                        .map(str::trim)
875                        .filter(|label| !label.is_empty())
876                        .map(str::to_string)
877                })
878                .collect();
879            if !labels.is_empty() {
880                lines.push(format!("Choices: {}", labels.join(", ")));
881            }
882        }
883        if let Some(suggest) = question
884            .get("suggest")
885            .and_then(Value::as_str)
886            .or_else(|| args.get("suggest").and_then(Value::as_str))
887            .map(str::trim)
888            .filter(|text| !text.is_empty())
889        {
890            lines.push(format!("Suggestions: {suggest}"));
891        }
892    }
893
894    lines.join("\n")
895}
896
897/// Progress text while an ask_user tool is pending. Prefer the tool's own
898/// readable `content`, otherwise show the question so the panel is not blank.
899fn ask_user_progress_text(args: &Value, result: &Value) -> String {
900    let text = tool_result_text(result);
901    if !text.trim().is_empty() {
902        return text;
903    }
904    let args_text = ask_user_args_display(args);
905    if args_text.trim().is_empty() {
906        "Waiting for your answer…".to_string()
907    } else {
908        args_text
909    }
910}
911
912/// Final text for a completed ask_user tool.
913fn ask_user_result_text(result: &Value) -> String {
914    let text = tool_result_text(result);
915    if text.trim().is_empty() {
916        "Answer recorded.".to_string()
917    } else {
918        text
919    }
920}
921
922/// Whether a partial tool result carries anything worth rendering.
923fn tool_update_has_payload(text: &str, result: &Value) -> bool {
924    !text.trim().is_empty()
925        || result
926            .get("details")
927            .map(|details| !details.is_null())
928            .unwrap_or(false)
929}
930
931/// Attach a colored diff to an `edit` tool panel. `write` carries no diff and
932/// stays a plain summary.
933fn apply_edit_diff(
934    comp: &Arc<ToolExecutionComponent>,
935    tool_name: &str,
936    details: Option<&Value>,
937    width: usize,
938) {
939    if tool_name != "edit" {
940        return;
941    }
942    let Some(diff_text) = details
943        .and_then(|details| details.get("diff"))
944        .and_then(Value::as_str)
945    else {
946        return;
947    };
948    if diff_text.is_empty() {
949        return;
950    }
951    comp.set_diff(render_diff(diff_text, width));
952}
953
954/// Mark a bash panel complete from the wire result payload. The exit code is
955/// derived from `is_error`; truncation info is read from `details`.
956fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
957    let details = result.get("details");
958    let exit_code = Some(if is_error { 1 } else { 0 });
959    let truncated = details
960        .and_then(|details| details.get("truncation"))
961        .and_then(|truncation| truncation.get("truncated"))
962        .and_then(Value::as_bool)
963        .unwrap_or(false);
964    let full_output_path = details
965        .and_then(|details| details.get("full_output_path"))
966        .and_then(Value::as_str)
967        .map(str::to_string);
968    if let Some(command) = details
969        .and_then(|details| details.get("command"))
970        .and_then(Value::as_str)
971    {
972        comp.set_command(command);
973    }
974    comp.set_complete(
975        exit_code,
976        false,
977        BashTruncation {
978            truncated,
979            full_output_path,
980        },
981    );
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987    use rpi_tui::{strip_ansi, Component};
988    use serde_json::json;
989
990    fn view() -> TranscriptView {
991        TranscriptView::new(Arc::new(Container::new()))
992    }
993
994    #[test]
995    fn adjacent_transcript_blocks_separated_by_exactly_one_blank() {
996        // The seam invariant: any two adjacent transcript blocks — whatever
997        // their kinds — are separated by exactly one blank line. Every entry
998        // contributes one trailing blank (assistant trailing spacing, panel
999        // trailing spacer, host trailing Spacer for user/notice entries), and
1000        // no block opens with a leading spacer, so the gap never depends on
1001        // which kind sits on top. Old behavior: a panel above an assistant
1002        // message gave 0 blanks (thinking glued to the code card) while an
1003        // assistant message above a panel gave 2 (trailing spacing + panel
1004        // top spacer stacking).
1005        //
1006        // Only *truly* empty rows count as a gap: a panel's own padding rows
1007        // carry a background color (non-empty ANSI payload) and belong to the
1008        // panel, not to the seam.
1009        let view = view();
1010        view.add_user("question");
1011        view.apply(
1012            &UiEvent::AssistantStart {
1013                blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1014            },
1015            80,
1016        );
1017        view.apply(
1018            &UiEvent::AssistantUpdate {
1019                blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1020                tool_calls: vec![UiToolCall {
1021                    id: "t1".into(),
1022                    name: "read".into(),
1023                    args: json!({"path": "f.txt"}),
1024                }],
1025            },
1026            80,
1027        );
1028        view.apply(
1029            &UiEvent::ToolEnd {
1030                id: "t1".into(),
1031                name: "read".into(),
1032                result: json!({"content": [{"type": "text", "text": "done"}]}),
1033                is_error: false,
1034            },
1035            80,
1036        );
1037        view.apply(
1038            &UiEvent::AssistantStart {
1039                blocks: vec![AssistantBlock::Text("answer".into())],
1040            },
1041            80,
1042        );
1043        view.apply(
1044            &UiEvent::ToolStart {
1045                id: "b1".into(),
1046                name: "bash".into(),
1047                args: json!({"command": "ls"}),
1048            },
1049            80,
1050        );
1051        view.apply(&UiEvent::AgentEnd, 80);
1052
1053        let lines = view.chat.render(80);
1054        let plain: Vec<String> = lines
1055            .iter()
1056            .map(|l| strip_ansi(l).trim().to_string())
1057            .collect();
1058        // Anchor each block by its LAST content line, so the rows between two
1059        // anchors are exactly the seam between the two blocks.
1060        let find = |needle: &str| {
1061            plain
1062                .iter()
1063                .rposition(|l| l.contains(needle))
1064                .unwrap_or_else(|| panic!("{needle} missing: {plain:?}"))
1065        };
1066        let user = find("question");
1067        let thinking = find("reasoning");
1068        let panel = find("done");
1069        let answer = find("answer");
1070        let bash = find("$ ls");
1071        assert!(user < thinking && thinking < panel && panel < answer);
1072        assert!(answer < bash);
1073        for (top_name, top, bottom_name, bottom) in [
1074            ("user", user, "thinking", thinking),
1075            ("thinking", thinking, "panel", panel),
1076            ("panel", panel, "answer", answer),
1077            ("answer", answer, "bash", bash),
1078        ] {
1079            let gap = lines[top + 1..bottom]
1080                .iter()
1081                .filter(|l| l.is_empty())
1082                .count();
1083            assert_eq!(
1084                gap, 1,
1085                "{top_name}→{bottom_name} seam must be exactly one blank row: {plain:?}"
1086            );
1087        }
1088    }
1089
1090    #[test]
1091    fn retry_notice_ends_with_a_trailing_spacer() {
1092        // A notice is a transcript entry like any other: without its own
1093        // trailing blank it would render flush against the next block.
1094        let view = view();
1095        view.apply(
1096            &UiEvent::Retry {
1097                attempt: 1,
1098                max_retries: 3,
1099                delay_ms: 500,
1100                error: "boom".into(),
1101            },
1102            80,
1103        );
1104        let lines = view.chat.render(80);
1105        let rendered = strip_ansi(&lines.join("\n"));
1106        assert!(rendered.contains("retry 1/3"));
1107        assert_eq!(lines.last(), Some(&String::new()));
1108    }
1109
1110    #[test]
1111    fn empty_assistant_start_and_whitespace_deltas_do_not_move_the_transcript() {
1112        for starts_with_event in [true, false] {
1113            let view = view();
1114            view.add_user("hi");
1115            let before = view.chat.render(80);
1116            if starts_with_event {
1117                view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1118                assert_eq!(view.chat.render(80), before);
1119            }
1120            for blocks in [
1121                vec![],
1122                vec![AssistantBlock::Text(" ".into())],
1123                vec![AssistantBlock::Thinking("\n".into())],
1124            ] {
1125                view.apply(
1126                    &UiEvent::AssistantUpdate {
1127                        blocks,
1128                        tool_calls: vec![],
1129                    },
1130                    80,
1131                );
1132                assert_eq!(view.chat.render(80), before);
1133            }
1134            view.apply(
1135                &UiEvent::AssistantUpdate {
1136                    blocks: vec![AssistantBlock::Text("hello".into())],
1137                    tool_calls: vec![],
1138                },
1139                80,
1140            );
1141            let visible = view.chat.render(80);
1142            assert!(visible.len() > before.len());
1143            assert!(visible.iter().any(|line| line.contains("hello")));
1144            assert_eq!(visible.last(), Some(&String::new()));
1145            view.apply(&UiEvent::AssistantEnd { blocks: vec![] }, 80);
1146            assert_eq!(view.chat.render(80), before);
1147            view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1148            assert_eq!(view.chat.render(80), before);
1149        }
1150    }
1151
1152    #[test]
1153    fn assistant_blocks_follow_content_order() {
1154        let message = json!({
1155            "content": [
1156                {"type": "thinking", "thinking": "why"},
1157                {"type": "text", "text": "hello"},
1158                {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
1159            ]
1160        });
1161        assert_eq!(
1162            assistant_blocks_from_value(&message),
1163            vec![
1164                AssistantBlock::Thinking("why".into()),
1165                AssistantBlock::Text("hello".into()),
1166            ]
1167        );
1168    }
1169
1170    #[test]
1171    fn assistant_blocks_decode_images() {
1172        let mut bytes = Vec::new();
1173        {
1174            let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1175            encoder.set_color(png::ColorType::Rgb);
1176            encoder
1177                .write_header()
1178                .unwrap()
1179                .write_image_data(&[255, 0, 0])
1180                .unwrap();
1181        }
1182        let message = json!({
1183            "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "mimeType": "image/png"}]
1184        });
1185        assert_eq!(
1186            assistant_blocks_from_value(&message),
1187            vec![AssistantBlock::Image(bytes.to_vec())]
1188        );
1189        let jpeg = rpi_tools::image_processing::process_image(
1190            &bytes,
1191            rpi_tools::image_processing::ImageProcessingOptions {
1192                format: Some(rpi_tools::image_processing::OutputFormat::Jpeg { quality: 85 }),
1193                ..Default::default()
1194            },
1195        )
1196        .unwrap();
1197        let blocks = assistant_blocks_from_value(&json!({
1198            "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(jpeg), "mimeType": "image/jpeg"}]
1199        }));
1200        assert!(
1201            matches!(&blocks[0], AssistantBlock::Image(data) if data.starts_with(b"\x89PNG\r\n\x1a\n"))
1202        );
1203    }
1204
1205    #[test]
1206    fn tool_calls_extract_ids_names_and_args() {
1207        let message = json!({
1208            "content": [
1209                {"type": "text", "text": "x"},
1210                {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
1211                {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
1212            ]
1213        });
1214        assert_eq!(
1215            tool_calls_from_value(&message),
1216            vec![UiToolCall {
1217                id: "b1".into(),
1218                name: "bash".into(),
1219                args: json!({"command": "ls"}),
1220            }]
1221        );
1222    }
1223
1224    #[test]
1225    fn markdown_flag_is_opt_in() {
1226        assert!(tool_result_requests_markdown(Some(
1227            &json!({"markdown": true})
1228        )));
1229        assert!(!tool_result_requests_markdown(Some(
1230            &json!({"markdown": false})
1231        )));
1232        assert!(!tool_result_requests_markdown(Some(&json!({}))));
1233        assert!(!tool_result_requests_markdown(None));
1234    }
1235
1236    #[test]
1237    fn skill_tool_name_only_matches_skill_reads() {
1238        assert_eq!(
1239            skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
1240            Some("foo".to_string())
1241        );
1242        assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
1243        assert_eq!(
1244            skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
1245            None
1246        );
1247    }
1248
1249    #[test]
1250    fn ask_user_args_display_never_leaks_transport_json() {
1251        let text = ask_user_args_display(&json!({
1252            "question": "你的代理端口是多少?",
1253            "context": "本机",
1254            "options": ["7890", {"title": "7897", "description": "clash"}],
1255            "suggest": "1080"
1256        }));
1257        assert!(text.contains("你的代理端口是多少?"));
1258        assert!(text.contains("本机"));
1259        assert!(text.contains("Choices: 7890, 7897"));
1260        assert!(text.contains("Suggestions: 1080"));
1261        assert!(!text.trim_start().starts_with('{'));
1262        assert_eq!(
1263            ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
1264            "Confirmation requested: Deploy?"
1265        );
1266    }
1267
1268    #[test]
1269    fn tool_start_routes_bash_to_its_own_map() {
1270        let view = view();
1271        view.apply(
1272            &UiEvent::ToolStart {
1273                id: "b1".into(),
1274                name: "bash".into(),
1275                args: json!({"command": "ls -la"}),
1276            },
1277            80,
1278        );
1279        view.apply(
1280            &UiEvent::ToolStart {
1281                id: "r1".into(),
1282                name: "read".into(),
1283                args: json!({"path": "a.txt"}),
1284            },
1285            80,
1286        );
1287        assert!(view.bash_components.lock().unwrap().contains_key("b1"));
1288        assert!(view.tool_components.lock().unwrap().contains_key("r1"));
1289        assert!(view.tool_components.lock().unwrap().get("b1").is_none());
1290    }
1291
1292    #[test]
1293    fn bash_start_without_a_command_is_deferred() {
1294        let view = view();
1295        view.apply(
1296            &UiEvent::ToolStart {
1297                id: "b1".into(),
1298                name: "bash".into(),
1299                args: json!({}),
1300            },
1301            80,
1302        );
1303        assert!(view.bash_components.lock().unwrap().is_empty());
1304        assert!(view.tool_components.lock().unwrap().is_empty());
1305    }
1306
1307    #[test]
1308    fn assistant_stream_reuses_one_component() {
1309        let view = view();
1310        view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1311        view.apply(
1312            &UiEvent::AssistantUpdate {
1313                blocks: vec![AssistantBlock::Text("hi".into())],
1314                tool_calls: vec![],
1315            },
1316            80,
1317        );
1318        assert!(view.current_assistant.lock().unwrap().is_some());
1319    }
1320
1321    #[test]
1322    fn toggle_expanded_round_trips() {
1323        let view = view();
1324        assert!(!view.expanded());
1325        assert!(view.toggle_expanded());
1326        assert!(view.expanded());
1327    }
1328
1329    #[test]
1330    fn todo_updates_share_one_panel_and_preserve_errors() {
1331        let view = view();
1332        for (id, done) in [("a", false), ("b", true)] {
1333            view.sync_tool_calls(&[UiToolCall {
1334                id: id.into(),
1335                name: "todo".into(),
1336                args: json!({"action":"toggle"}),
1337            }]);
1338            view.tool_start(id, "todo", &json!({}), 80);
1339            view.tool_end(id, "todo", &json!({"content":[],"details":{"todos":[{"id":1,"text":"Run tests","done":done}]}}), false, 80);
1340        }
1341        assert_eq!(view.chat.child_count(), 1);
1342        assert!(view.tool_components.lock().unwrap().is_empty());
1343        let children = view.chat.get_children();
1344        let panel = children[0]
1345            .as_any()
1346            .downcast_ref::<ToolExecutionComponent>()
1347            .unwrap();
1348        let rendered = rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n"));
1349        assert!(rendered.contains("1/1 completed"));
1350        assert!(rendered.contains("[✓] Run tests"));
1351        view.tool_end(
1352            "error",
1353            "todo",
1354            &json!({"content":[{"type":"text","text":"invalid task"}]}),
1355            true,
1356            80,
1357        );
1358        assert_eq!(view.chat.child_count(), 2);
1359        view.tool_end("soft-error", "todo", &json!({"content":[{"type":"text","text":"todo not found"}],"details":{"todos":[],"error":"todo not found"}}), false, 80);
1360        assert_eq!(view.chat.child_count(), 3);
1361        assert!(update_todo_list(
1362            &view.chat,
1363            &json!({"details":{"todos":[]}})
1364        ));
1365        assert_eq!(view.chat.child_count(), 3);
1366        assert!(!update_todo_list(
1367            &view.chat,
1368            &json!({"details":{"todos":[{}]}})
1369        ));
1370        assert_eq!(view.chat.child_count(), 3);
1371    }
1372
1373    #[test]
1374    fn plan_tools_and_show_update_one_expandable_panel() {
1375        let view = view();
1376        view.sync_tool_calls(&[UiToolCall {
1377            id: "start".into(),
1378            name: "plan_mode_start".into(),
1379            args: json!({}),
1380        }]);
1381        view.tool_start("start", "plan_mode_start", &json!({}), 80);
1382        view.tool_end(
1383            "start",
1384            "plan_mode_start",
1385            &json!({"details":{"kind":"plan","active":true}}),
1386            false,
1387            80,
1388        );
1389        let body = (1..=30)
1390            .map(|i| format!("{i}. Step {i}"))
1391            .collect::<Vec<_>>()
1392            .join("\n");
1393        view.tool_end(
1394            "complete",
1395            "plan_mode_complete",
1396            &json!({"details":{"kind":"plan","active":false,"plan":body}}),
1397            false,
1398            80,
1399        );
1400        assert_eq!(view.chat.child_count(), 1);
1401        assert!(view.tool_components.lock().unwrap().is_empty());
1402        view.set_expanded(true);
1403        let children = view.chat.get_children();
1404        let panel = children[0]
1405            .as_any()
1406            .downcast_ref::<ToolExecutionComponent>()
1407            .unwrap();
1408        assert!(rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n")).contains("Step 30"));
1409        assert!(update_plan_panel(
1410            &view.chat,
1411            &json!({"kind":"message","details":{"kind":"plan","active":false,"plan":body,"expanded":true}})
1412        ));
1413        assert_eq!(view.chat.child_count(), 1);
1414        view.tool_end(
1415            "error",
1416            "plan_mode_complete",
1417            &json!({"content":[{"type":"text","text":"not active"}]}),
1418            true,
1419            80,
1420        );
1421        assert_eq!(view.chat.child_count(), 2);
1422    }
1423}