1use 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#[derive(Debug, Clone, PartialEq)]
32pub struct UiToolCall {
33 pub id: String,
34 pub name: String,
35 pub args: Value,
36}
37
38#[derive(Debug, Clone)]
41pub enum UiEvent {
42 AgentStart,
44 AgentEnd,
46 Retry {
48 attempt: u32,
49 max_retries: u32,
50 delay_ms: u64,
51 error: String,
52 },
53 AssistantStart { blocks: Vec<AssistantBlock> },
55 AssistantUpdate {
58 blocks: Vec<AssistantBlock>,
59 tool_calls: Vec<UiToolCall>,
60 },
61 AssistantEnd { blocks: Vec<AssistantBlock> },
63 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
83pub 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_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
95 hide_thinking: Arc<Mutex<bool>>,
97 expanded: Arc<Mutex<bool>>,
99 markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
103}
104
105pub type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
108
109impl TranscriptView {
110 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 #[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 pub fn with_show_images(mut self, show: bool) -> Self {
150 self.show_images = show;
151 self
152 }
153
154 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 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 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 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 pub fn toggle_expanded(&self) -> bool {
238 let next = !*self.expanded.lock().unwrap();
239 self.set_expanded(next);
240 next
241 }
242
243 pub fn expanded(&self) -> bool {
245 *self.expanded.lock().unwrap()
246 }
247
248 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 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 pub fn hide_thinking(&self) -> bool {
265 *self.hide_thinking.lock().unwrap()
266 }
267
268 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 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 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 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 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 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 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 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 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 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
592pub(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
636pub(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
683pub 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
729pub 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
754pub 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
770pub 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
779pub 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
800fn is_ask_user_tool(tool_name: &str) -> bool {
803 matches!(tool_name, "ask_user" | "ask_user_question")
804}
805
806fn 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
893fn 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
908fn 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
918fn 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
927fn 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
950fn 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}