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