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                // Keep the panel at the position where planning started. Moving
638                // it to the tail makes all later transcript rows jump above the
639                // completed plan and, in regular mode, turns the reorder into a
640                // native-scrollback rebuild on macOS.
641                return true;
642            }
643        }
644    }
645    let component = Arc::new(ToolExecutionComponent::new("plan_mode_complete", ""));
646    component.set_plan_state(plan, active);
647    component.set_result("", false);
648    component.set_expanded(
649        details
650            .get("expanded")
651            .and_then(Value::as_bool)
652            .unwrap_or(false),
653    );
654    chat.add_child(component);
655    true
656}
657
658/// Keep one visible current task list while retaining every result in session storage.
659pub(crate) fn update_todo_list(chat: &Arc<Container>, result: &Value) -> bool {
660    let Some(details) = result.get("details") else {
661        return false;
662    };
663    if details.get("error").is_some() {
664        return false;
665    }
666    let Some(items) = details.get("todos").and_then(Value::as_array) else {
667        return false;
668    };
669    let rows: Option<Vec<_>> = items
670        .iter()
671        .map(|item| {
672            let text = item.get("text")?.as_str()?.to_owned();
673            let status = if item.get("done").and_then(Value::as_bool) == Some(true) {
674                "completed"
675            } else {
676                item.get("status")
677                    .and_then(Value::as_str)
678                    .unwrap_or("pending")
679            };
680            Some((text, status.to_owned()))
681        })
682        .collect();
683    let Some(rows) = rows else {
684        return false;
685    };
686    let text = tool_result_text(result);
687    for child in chat.get_children().into_iter().rev() {
688        if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
689            if component.is_todo_list() {
690                component.set_todo_state(rows);
691                component.set_result(&text, false);
692                chat.remove_child(&child);
693                chat.add_child(child);
694                return true;
695            }
696        }
697    }
698    let candidate = Arc::new(ToolExecutionComponent::new("todo", ""));
699    candidate.set_todo_state(rows);
700    candidate.set_result(&text, false);
701    chat.add_child(candidate);
702    true
703}
704
705/// Map a wire/local assistant message's content blocks into the provider-free
706/// [`AssistantBlock`] list the shared component renders (text + thinking +
707/// decoded image blocks, in document order).
708pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
709    let mut blocks = Vec::new();
710    let Some(content) = message.get("content").and_then(Value::as_array) else {
711        return blocks;
712    };
713    for block in content {
714        match block.get("type").and_then(Value::as_str) {
715            Some("text") => {
716                if let Some(text) = block.get("text").and_then(Value::as_str) {
717                    blocks.push(AssistantBlock::Text(text.to_string()));
718                }
719            }
720            Some("thinking") => {
721                if let Some(text) = block.get("thinking").and_then(Value::as_str) {
722                    blocks.push(AssistantBlock::Thinking(text.to_string()));
723                }
724            }
725            Some("image") => {
726                if let Some(data) = block.get("data").and_then(Value::as_str) {
727                    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
728                        if !bytes.is_empty() {
729                            let png = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
730                                Some(bytes)
731                            } else {
732                                rpi_tools::image_processing::process_image(
733                                    &bytes,
734                                    Default::default(),
735                                )
736                                .ok()
737                            };
738                            if let Some(png) = png {
739                                blocks.push(AssistantBlock::Image(png));
740                            }
741                        }
742                    }
743                }
744            }
745            _ => {}
746        }
747    }
748    blocks
749}
750
751/// Extract the finalized tool calls (`type: "toolCall"`) from an assistant
752/// message value, in document order.
753pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
754    let mut calls = Vec::new();
755    let Some(content) = message.get("content").and_then(Value::as_array) else {
756        return calls;
757    };
758    for block in content {
759        if block.get("type").and_then(Value::as_str) != Some("toolCall") {
760            continue;
761        }
762        let id = block.get("id").and_then(Value::as_str).unwrap_or("");
763        let name = block.get("name").and_then(Value::as_str).unwrap_or("");
764        if id.is_empty() || name.trim().is_empty() {
765            continue;
766        }
767        calls.push(UiToolCall {
768            id: id.to_string(),
769            name: name.to_string(),
770            args: block.get("arguments").cloned().unwrap_or(Value::Null),
771        });
772    }
773    calls
774}
775
776/// Text out of a wire tool-result payload (`{content:[{type:"text",text}]}`).
777pub fn tool_result_text(result: &Value) -> String {
778    let mut out = String::new();
779    if let Some(blocks) = result.get("content").and_then(Value::as_array) {
780        for block in blocks {
781            if let Some(text) = block.get("text").and_then(Value::as_str) {
782                if !out.is_empty() {
783                    out.push('\n');
784                }
785                out.push_str(text);
786            }
787        }
788    }
789    out
790}
791
792/// Whether a tool result opts into markdown rendering of its body
793/// (`details.markdown = true`).
794pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
795    details
796        .and_then(|details| details.get("markdown"))
797        .and_then(Value::as_bool)
798        .unwrap_or(false)
799}
800
801/// The skill name when `tool_name` is a `read` of a `SKILL.md` file (the
802/// `SKILL.md` parent directory's basename), else `None`. A skill read renders
803/// as upstream's `[skill] <name>` box.
804pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
805    if tool_name != "read" {
806        return None;
807    }
808    let path = args.get("path").and_then(Value::as_str)?;
809    let normalized = path.replace('\\', "/");
810    let file_name = normalized.rsplit('/').next()?;
811    if !file_name.eq_ignore_ascii_case("SKILL.md") {
812        return None;
813    }
814    normalized
815        .trim_end_matches('/')
816        .rsplit('/')
817        .nth(1)
818        .filter(|name| !name.is_empty())
819        .map(str::to_string)
820}
821
822/// Tool names rendered through the dedicated `ASK USER` panel instead of a
823/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
824fn is_ask_user_tool(tool_name: &str) -> bool {
825    matches!(tool_name, "ask_user" | "ask_user_question")
826}
827
828/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
829/// transport JSON must never leak into the transcript.
830fn ask_user_args_display(args: &Value) -> String {
831    if args.is_null() {
832        return String::new();
833    }
834    if let Some(summary) = args
835        .get("summary")
836        .and_then(Value::as_str)
837        .map(str::trim)
838        .filter(|text| !text.is_empty())
839    {
840        return format!("Confirmation requested: {summary}");
841    }
842
843    let mut lines: Vec<String> = Vec::new();
844    let questions: Vec<&Value> = args
845        .get("questions")
846        .and_then(Value::as_array)
847        .filter(|items| !items.is_empty())
848        .map(|items| items.iter().collect())
849        .unwrap_or_else(|| vec![args]);
850
851    for question in questions {
852        let prompt = question
853            .get("question")
854            .and_then(Value::as_str)
855            .or_else(|| args.get("question").and_then(Value::as_str))
856            .map(str::trim)
857            .unwrap_or_default();
858        if prompt.is_empty() {
859            continue;
860        }
861        if let Some(header) = question
862            .get("header")
863            .and_then(Value::as_str)
864            .map(str::trim)
865            .filter(|text| !text.is_empty())
866        {
867            lines.push(format!("{header}: {prompt}"));
868        } else {
869            lines.push(prompt.to_string());
870        }
871        let context = question
872            .get("context")
873            .and_then(Value::as_str)
874            .or_else(|| args.get("context").and_then(Value::as_str))
875            .map(str::trim)
876            .filter(|text| !text.is_empty());
877        if let Some(context) = context {
878            lines.push(format!("  {context}"));
879        }
880        let options = question
881            .get("options")
882            .and_then(Value::as_array)
883            .or_else(|| args.get("options").and_then(Value::as_array));
884        if let Some(options) = options {
885            let labels: Vec<String> = options
886                .iter()
887                .filter_map(|option| {
888                    option
889                        .get("title")
890                        .and_then(Value::as_str)
891                        .or_else(|| option.as_str())
892                        .map(str::trim)
893                        .filter(|label| !label.is_empty())
894                        .map(str::to_string)
895                })
896                .collect();
897            if !labels.is_empty() {
898                lines.push(format!("Choices: {}", labels.join(", ")));
899            }
900        }
901        if let Some(suggest) = question
902            .get("suggest")
903            .and_then(Value::as_str)
904            .or_else(|| args.get("suggest").and_then(Value::as_str))
905            .map(str::trim)
906            .filter(|text| !text.is_empty())
907        {
908            lines.push(format!("Suggestions: {suggest}"));
909        }
910    }
911
912    lines.join("\n")
913}
914
915/// Progress text while an ask_user tool is pending. Prefer the tool's own
916/// readable `content`, otherwise show the question so the panel is not blank.
917fn ask_user_progress_text(args: &Value, result: &Value) -> String {
918    let text = tool_result_text(result);
919    if !text.trim().is_empty() {
920        return text;
921    }
922    let args_text = ask_user_args_display(args);
923    if args_text.trim().is_empty() {
924        "Waiting for your answer…".to_string()
925    } else {
926        args_text
927    }
928}
929
930/// Final text for a completed ask_user tool.
931fn ask_user_result_text(result: &Value) -> String {
932    let text = tool_result_text(result);
933    if text.trim().is_empty() {
934        "Answer recorded.".to_string()
935    } else {
936        text
937    }
938}
939
940/// Whether a partial tool result carries anything worth rendering.
941fn tool_update_has_payload(text: &str, result: &Value) -> bool {
942    !text.trim().is_empty()
943        || result
944            .get("details")
945            .map(|details| !details.is_null())
946            .unwrap_or(false)
947}
948
949/// Attach a colored diff to an `edit` tool panel. `write` carries no diff and
950/// stays a plain summary.
951fn apply_edit_diff(
952    comp: &Arc<ToolExecutionComponent>,
953    tool_name: &str,
954    details: Option<&Value>,
955    width: usize,
956) {
957    if tool_name != "edit" {
958        return;
959    }
960    let Some(diff_text) = details
961        .and_then(|details| details.get("diff"))
962        .and_then(Value::as_str)
963    else {
964        return;
965    };
966    if diff_text.is_empty() {
967        return;
968    }
969    comp.set_diff(render_diff(diff_text, width));
970}
971
972/// Mark a bash panel complete from the wire result payload. The exit code is
973/// derived from `is_error`; truncation info is read from `details`.
974fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
975    let details = result.get("details");
976    let exit_code = Some(if is_error { 1 } else { 0 });
977    let truncated = details
978        .and_then(|details| details.get("truncation"))
979        .and_then(|truncation| truncation.get("truncated"))
980        .and_then(Value::as_bool)
981        .unwrap_or(false);
982    let full_output_path = details
983        .and_then(|details| details.get("full_output_path"))
984        .and_then(Value::as_str)
985        .map(str::to_string);
986    if let Some(command) = details
987        .and_then(|details| details.get("command"))
988        .and_then(Value::as_str)
989    {
990        comp.set_command(command);
991    }
992    comp.set_complete(
993        exit_code,
994        false,
995        BashTruncation {
996            truncated,
997            full_output_path,
998        },
999    );
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use rpi_tui::{strip_ansi, Component};
1006    use serde_json::json;
1007
1008    fn view() -> TranscriptView {
1009        TranscriptView::new(Arc::new(Container::new()))
1010    }
1011
1012    #[test]
1013    fn adjacent_transcript_blocks_separated_by_exactly_one_blank() {
1014        // The seam invariant: any two adjacent transcript blocks — whatever
1015        // their kinds — are separated by exactly one blank line. Every entry
1016        // contributes one trailing blank (assistant trailing spacing, panel
1017        // trailing spacer, host trailing Spacer for user/notice entries), and
1018        // no block opens with a leading spacer, so the gap never depends on
1019        // which kind sits on top. Old behavior: a panel above an assistant
1020        // message gave 0 blanks (thinking glued to the code card) while an
1021        // assistant message above a panel gave 2 (trailing spacing + panel
1022        // top spacer stacking).
1023        //
1024        // Only *truly* empty rows count as a gap: a panel's own padding rows
1025        // carry a background color (non-empty ANSI payload) and belong to the
1026        // panel, not to the seam.
1027        let view = view();
1028        view.add_user("question");
1029        view.apply(
1030            &UiEvent::AssistantStart {
1031                blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1032            },
1033            80,
1034        );
1035        view.apply(
1036            &UiEvent::AssistantUpdate {
1037                blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1038                tool_calls: vec![UiToolCall {
1039                    id: "t1".into(),
1040                    name: "read".into(),
1041                    args: json!({"path": "f.txt"}),
1042                }],
1043            },
1044            80,
1045        );
1046        view.apply(
1047            &UiEvent::ToolEnd {
1048                id: "t1".into(),
1049                name: "read".into(),
1050                result: json!({"content": [{"type": "text", "text": "done"}]}),
1051                is_error: false,
1052            },
1053            80,
1054        );
1055        view.apply(
1056            &UiEvent::AssistantStart {
1057                blocks: vec![AssistantBlock::Text("answer".into())],
1058            },
1059            80,
1060        );
1061        view.apply(
1062            &UiEvent::ToolStart {
1063                id: "b1".into(),
1064                name: "bash".into(),
1065                args: json!({"command": "ls"}),
1066            },
1067            80,
1068        );
1069        view.apply(&UiEvent::AgentEnd, 80);
1070
1071        let lines = view.chat.render(80);
1072        let plain: Vec<String> = lines
1073            .iter()
1074            .map(|l| strip_ansi(l).trim().to_string())
1075            .collect();
1076        // Anchor each block by its LAST content line, so the rows between two
1077        // anchors are exactly the seam between the two blocks.
1078        let find = |needle: &str| {
1079            plain
1080                .iter()
1081                .rposition(|l| l.contains(needle))
1082                .unwrap_or_else(|| panic!("{needle} missing: {plain:?}"))
1083        };
1084        let user = find("question");
1085        let thinking = find("reasoning");
1086        let panel = find("done");
1087        let answer = find("answer");
1088        let bash = find("$ ls");
1089        assert!(user < thinking && thinking < panel && panel < answer);
1090        assert!(answer < bash);
1091        for (top_name, top, bottom_name, bottom) in [
1092            ("user", user, "thinking", thinking),
1093            ("thinking", thinking, "panel", panel),
1094            ("panel", panel, "answer", answer),
1095            ("answer", answer, "bash", bash),
1096        ] {
1097            let gap = lines[top + 1..bottom]
1098                .iter()
1099                .filter(|l| l.is_empty())
1100                .count();
1101            assert_eq!(
1102                gap, 1,
1103                "{top_name}→{bottom_name} seam must be exactly one blank row: {plain:?}"
1104            );
1105        }
1106    }
1107
1108    #[test]
1109    fn retry_notice_ends_with_a_trailing_spacer() {
1110        // A notice is a transcript entry like any other: without its own
1111        // trailing blank it would render flush against the next block.
1112        let view = view();
1113        view.apply(
1114            &UiEvent::Retry {
1115                attempt: 1,
1116                max_retries: 3,
1117                delay_ms: 500,
1118                error: "boom".into(),
1119            },
1120            80,
1121        );
1122        let lines = view.chat.render(80);
1123        let rendered = strip_ansi(&lines.join("\n"));
1124        assert!(rendered.contains("retry 1/3"));
1125        assert_eq!(lines.last(), Some(&String::new()));
1126    }
1127
1128    #[test]
1129    fn empty_assistant_start_and_whitespace_deltas_do_not_move_the_transcript() {
1130        for starts_with_event in [true, false] {
1131            let view = view();
1132            view.add_user("hi");
1133            let before = view.chat.render(80);
1134            if starts_with_event {
1135                view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1136                assert_eq!(view.chat.render(80), before);
1137            }
1138            for blocks in [
1139                vec![],
1140                vec![AssistantBlock::Text(" ".into())],
1141                vec![AssistantBlock::Thinking("\n".into())],
1142            ] {
1143                view.apply(
1144                    &UiEvent::AssistantUpdate {
1145                        blocks,
1146                        tool_calls: vec![],
1147                    },
1148                    80,
1149                );
1150                assert_eq!(view.chat.render(80), before);
1151            }
1152            view.apply(
1153                &UiEvent::AssistantUpdate {
1154                    blocks: vec![AssistantBlock::Text("hello".into())],
1155                    tool_calls: vec![],
1156                },
1157                80,
1158            );
1159            let visible = view.chat.render(80);
1160            assert!(visible.len() > before.len());
1161            assert!(visible.iter().any(|line| line.contains("hello")));
1162            assert_eq!(visible.last(), Some(&String::new()));
1163            view.apply(&UiEvent::AssistantEnd { blocks: vec![] }, 80);
1164            assert_eq!(view.chat.render(80), before);
1165            view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1166            assert_eq!(view.chat.render(80), before);
1167        }
1168    }
1169
1170    #[test]
1171    fn assistant_blocks_follow_content_order() {
1172        let message = json!({
1173            "content": [
1174                {"type": "thinking", "thinking": "why"},
1175                {"type": "text", "text": "hello"},
1176                {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
1177            ]
1178        });
1179        assert_eq!(
1180            assistant_blocks_from_value(&message),
1181            vec![
1182                AssistantBlock::Thinking("why".into()),
1183                AssistantBlock::Text("hello".into()),
1184            ]
1185        );
1186    }
1187
1188    #[test]
1189    fn assistant_blocks_decode_images() {
1190        let mut bytes = Vec::new();
1191        {
1192            let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1193            encoder.set_color(png::ColorType::Rgb);
1194            encoder
1195                .write_header()
1196                .unwrap()
1197                .write_image_data(&[255, 0, 0])
1198                .unwrap();
1199        }
1200        let message = json!({
1201            "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "mimeType": "image/png"}]
1202        });
1203        assert_eq!(
1204            assistant_blocks_from_value(&message),
1205            vec![AssistantBlock::Image(bytes.to_vec())]
1206        );
1207        let jpeg = rpi_tools::image_processing::process_image(
1208            &bytes,
1209            rpi_tools::image_processing::ImageProcessingOptions {
1210                format: Some(rpi_tools::image_processing::OutputFormat::Jpeg { quality: 85 }),
1211                ..Default::default()
1212            },
1213        )
1214        .unwrap();
1215        let blocks = assistant_blocks_from_value(&json!({
1216            "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(jpeg), "mimeType": "image/jpeg"}]
1217        }));
1218        assert!(
1219            matches!(&blocks[0], AssistantBlock::Image(data) if data.starts_with(b"\x89PNG\r\n\x1a\n"))
1220        );
1221    }
1222
1223    #[test]
1224    fn tool_calls_extract_ids_names_and_args() {
1225        let message = json!({
1226            "content": [
1227                {"type": "text", "text": "x"},
1228                {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
1229                {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
1230            ]
1231        });
1232        assert_eq!(
1233            tool_calls_from_value(&message),
1234            vec![UiToolCall {
1235                id: "b1".into(),
1236                name: "bash".into(),
1237                args: json!({"command": "ls"}),
1238            }]
1239        );
1240    }
1241
1242    #[test]
1243    fn markdown_flag_is_opt_in() {
1244        assert!(tool_result_requests_markdown(Some(
1245            &json!({"markdown": true})
1246        )));
1247        assert!(!tool_result_requests_markdown(Some(
1248            &json!({"markdown": false})
1249        )));
1250        assert!(!tool_result_requests_markdown(Some(&json!({}))));
1251        assert!(!tool_result_requests_markdown(None));
1252    }
1253
1254    #[test]
1255    fn skill_tool_name_only_matches_skill_reads() {
1256        assert_eq!(
1257            skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
1258            Some("foo".to_string())
1259        );
1260        assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
1261        assert_eq!(
1262            skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
1263            None
1264        );
1265    }
1266
1267    #[test]
1268    fn ask_user_args_display_never_leaks_transport_json() {
1269        let text = ask_user_args_display(&json!({
1270            "question": "你的代理端口是多少?",
1271            "context": "本机",
1272            "options": ["7890", {"title": "7897", "description": "clash"}],
1273            "suggest": "1080"
1274        }));
1275        assert!(text.contains("你的代理端口是多少?"));
1276        assert!(text.contains("本机"));
1277        assert!(text.contains("Choices: 7890, 7897"));
1278        assert!(text.contains("Suggestions: 1080"));
1279        assert!(!text.trim_start().starts_with('{'));
1280        assert_eq!(
1281            ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
1282            "Confirmation requested: Deploy?"
1283        );
1284    }
1285
1286    #[test]
1287    fn tool_start_routes_bash_to_its_own_map() {
1288        let view = view();
1289        view.apply(
1290            &UiEvent::ToolStart {
1291                id: "b1".into(),
1292                name: "bash".into(),
1293                args: json!({"command": "ls -la"}),
1294            },
1295            80,
1296        );
1297        view.apply(
1298            &UiEvent::ToolStart {
1299                id: "r1".into(),
1300                name: "read".into(),
1301                args: json!({"path": "a.txt"}),
1302            },
1303            80,
1304        );
1305        assert!(view.bash_components.lock().unwrap().contains_key("b1"));
1306        assert!(view.tool_components.lock().unwrap().contains_key("r1"));
1307        assert!(view.tool_components.lock().unwrap().get("b1").is_none());
1308    }
1309
1310    #[test]
1311    fn bash_start_without_a_command_is_deferred() {
1312        let view = view();
1313        view.apply(
1314            &UiEvent::ToolStart {
1315                id: "b1".into(),
1316                name: "bash".into(),
1317                args: json!({}),
1318            },
1319            80,
1320        );
1321        assert!(view.bash_components.lock().unwrap().is_empty());
1322        assert!(view.tool_components.lock().unwrap().is_empty());
1323    }
1324
1325    #[test]
1326    fn assistant_stream_reuses_one_component() {
1327        let view = view();
1328        view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1329        view.apply(
1330            &UiEvent::AssistantUpdate {
1331                blocks: vec![AssistantBlock::Text("hi".into())],
1332                tool_calls: vec![],
1333            },
1334            80,
1335        );
1336        assert!(view.current_assistant.lock().unwrap().is_some());
1337    }
1338
1339    #[test]
1340    fn toggle_expanded_round_trips() {
1341        let view = view();
1342        assert!(!view.expanded());
1343        assert!(view.toggle_expanded());
1344        assert!(view.expanded());
1345    }
1346
1347    #[test]
1348    fn todo_updates_share_one_panel_and_preserve_errors() {
1349        let view = view();
1350        for (id, done) in [("a", false), ("b", true)] {
1351            view.sync_tool_calls(&[UiToolCall {
1352                id: id.into(),
1353                name: "todo".into(),
1354                args: json!({"action":"toggle"}),
1355            }]);
1356            view.tool_start(id, "todo", &json!({}), 80);
1357            view.tool_end(id, "todo", &json!({"content":[],"details":{"todos":[{"id":1,"text":"Run tests","done":done}]}}), false, 80);
1358        }
1359        assert_eq!(view.chat.child_count(), 1);
1360        assert!(view.tool_components.lock().unwrap().is_empty());
1361        let children = view.chat.get_children();
1362        let panel = children[0]
1363            .as_any()
1364            .downcast_ref::<ToolExecutionComponent>()
1365            .unwrap();
1366        let rendered = rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n"));
1367        assert!(rendered.contains("1/1 completed"));
1368        assert!(rendered.contains("[✓] Run tests"));
1369        view.tool_end(
1370            "error",
1371            "todo",
1372            &json!({"content":[{"type":"text","text":"invalid task"}]}),
1373            true,
1374            80,
1375        );
1376        assert_eq!(view.chat.child_count(), 2);
1377        view.tool_end("soft-error", "todo", &json!({"content":[{"type":"text","text":"todo not found"}],"details":{"todos":[],"error":"todo not found"}}), false, 80);
1378        assert_eq!(view.chat.child_count(), 3);
1379        assert!(update_todo_list(
1380            &view.chat,
1381            &json!({"details":{"todos":[]}})
1382        ));
1383        assert_eq!(view.chat.child_count(), 3);
1384        assert!(!update_todo_list(
1385            &view.chat,
1386            &json!({"details":{"todos":[{}]}})
1387        ));
1388        assert_eq!(view.chat.child_count(), 3);
1389    }
1390
1391    #[test]
1392    fn completed_plan_keeps_its_transcript_position() {
1393        let view = view();
1394        view.apply(
1395            &UiEvent::AssistantStart {
1396                blocks: vec![AssistantBlock::Text("planning context".into())],
1397            },
1398            80,
1399        );
1400        view.apply(
1401            &UiEvent::AssistantEnd {
1402                blocks: vec![AssistantBlock::Text("planning context".into())],
1403            },
1404            80,
1405        );
1406        view.apply(
1407            &UiEvent::ToolEnd {
1408                id: "start".into(),
1409                name: "plan_mode_start".into(),
1410                result: json!({"details":{"kind":"plan","active":true}}),
1411                is_error: false,
1412            },
1413            80,
1414        );
1415        view.apply(
1416            &UiEvent::AssistantStart {
1417                blocks: vec![AssistantBlock::Text("repository findings".into())],
1418            },
1419            80,
1420        );
1421        view.apply(
1422            &UiEvent::AssistantEnd {
1423                blocks: vec![AssistantBlock::Text("repository findings".into())],
1424            },
1425            80,
1426        );
1427
1428        view.apply(
1429            &UiEvent::ToolEnd {
1430                id: "complete".into(),
1431                name: "plan_mode_complete".into(),
1432                result: json!({
1433                    "details": {
1434                        "kind": "plan",
1435                        "active": false,
1436                        "plan": "1. Keep the plan in place"
1437                    }
1438                }),
1439                is_error: false,
1440            },
1441            80,
1442        );
1443
1444        let lines: Vec<_> = view
1445            .chat
1446            .render(80)
1447            .iter()
1448            .map(|line| strip_ansi(line))
1449            .collect();
1450        let plan = lines
1451            .iter()
1452            .position(|line| line.contains("Plan · Ready"))
1453            .expect("completed plan panel should render");
1454        let findings = lines
1455            .iter()
1456            .position(|line| line.contains("repository findings"))
1457            .expect("later assistant output should render");
1458        assert!(
1459            plan < findings,
1460            "plan completion must not move the plan below later transcript output: {lines:?}"
1461        );
1462    }
1463
1464    #[test]
1465    fn plan_tools_and_show_update_one_expandable_panel() {
1466        let view = view();
1467        view.sync_tool_calls(&[UiToolCall {
1468            id: "start".into(),
1469            name: "plan_mode_start".into(),
1470            args: json!({}),
1471        }]);
1472        view.tool_start("start", "plan_mode_start", &json!({}), 80);
1473        view.tool_end(
1474            "start",
1475            "plan_mode_start",
1476            &json!({"details":{"kind":"plan","active":true}}),
1477            false,
1478            80,
1479        );
1480        let body = (1..=30)
1481            .map(|i| format!("{i}. Step {i}"))
1482            .collect::<Vec<_>>()
1483            .join("\n");
1484        view.tool_end(
1485            "complete",
1486            "plan_mode_complete",
1487            &json!({"details":{"kind":"plan","active":false,"plan":body}}),
1488            false,
1489            80,
1490        );
1491        assert_eq!(view.chat.child_count(), 1);
1492        assert!(view.tool_components.lock().unwrap().is_empty());
1493        view.set_expanded(true);
1494        let children = view.chat.get_children();
1495        let panel = children[0]
1496            .as_any()
1497            .downcast_ref::<ToolExecutionComponent>()
1498            .unwrap();
1499        assert!(rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n")).contains("Step 30"));
1500        assert!(update_plan_panel(
1501            &view.chat,
1502            &json!({"kind":"message","details":{"kind":"plan","active":false,"plan":body,"expanded":true}})
1503        ));
1504        assert_eq!(view.chat.child_count(), 1);
1505        view.tool_end(
1506            "error",
1507            "plan_mode_complete",
1508            &json!({"content":[{"type":"text","text":"not active"}]}),
1509            true,
1510            80,
1511        );
1512        assert_eq!(view.chat.child_count(), 2);
1513    }
1514}