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 self.chat.add_child(Arc::new(Spacer::new(1)));
169 }
170
171 pub fn apply(&self, event: &UiEvent, width: usize) {
174 match event {
175 UiEvent::AgentStart => {}
176 UiEvent::AgentEnd => {
177 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
178 comp.set_streaming(false);
179 }
180 }
181 UiEvent::Retry {
182 attempt,
183 max_retries,
184 delay_ms,
185 error,
186 } => {
187 self.add_notice(&format!(
188 "retry {attempt}/{max_retries} in {delay_ms}ms: {error}"
189 ));
190 }
191 UiEvent::AssistantStart { blocks } => {
192 self.start_assistant(blocks);
193 }
194 UiEvent::AssistantUpdate { blocks, tool_calls } => {
195 self.ensure_assistant();
196 self.sync_tool_calls(tool_calls);
197 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
198 comp.update_blocks(blocks);
199 }
200 }
201 UiEvent::AssistantEnd { blocks } => {
202 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
203 comp.update_blocks(blocks);
204 comp.set_streaming(false);
205 }
206 }
207 UiEvent::ToolStart { id, name, args } => self.tool_start(id, name, args, width),
208 UiEvent::ToolUpdate {
209 id,
210 name,
211 args,
212 result,
213 } => self.tool_update(id, name, args, result, width),
214 UiEvent::ToolEnd {
215 id,
216 name,
217 result,
218 is_error,
219 } => self.tool_end(id, name, result, *is_error, width),
220 }
221 }
222
223 pub fn set_expanded(&self, expanded: bool) {
226 *self.expanded.lock().unwrap() = expanded;
227 for child in self.chat.get_children() {
228 if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
229 component.set_expanded(expanded);
230 }
231 }
232 for comp in self.tool_components.lock().unwrap().values() {
233 comp.set_expanded(expanded);
234 }
235 for comp in self.bash_components.lock().unwrap().values() {
236 comp.set_expanded(expanded);
237 }
238 }
239
240 pub fn toggle_expanded(&self) -> bool {
242 let next = !*self.expanded.lock().unwrap();
243 self.set_expanded(next);
244 next
245 }
246
247 pub fn expanded(&self) -> bool {
249 *self.expanded.lock().unwrap()
250 }
251
252 pub fn set_hide_thinking(&self, hide: bool) {
254 *self.hide_thinking.lock().unwrap() = hide;
255 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
256 comp.set_hide_thinking(hide);
257 }
258 }
259
260 pub fn toggle_hide_thinking(&self) -> bool {
262 let next = !*self.hide_thinking.lock().unwrap();
263 self.set_hide_thinking(next);
264 next
265 }
266
267 pub fn hide_thinking(&self) -> bool {
269 *self.hide_thinking.lock().unwrap()
270 }
271
272 pub fn set_markdown_transformer(&self, transformer: Option<MarkdownTransformer>) {
275 *self.markdown_transformer.lock().unwrap() = transformer.clone();
276 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
277 comp.set_markdown_transformer(transformer);
278 }
279 }
280
281 pub fn finalize_assistant(&self) {
283 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
284 comp.set_streaming(false);
285 }
286 }
287
288 pub fn finalize_assistant_blocks(&self, blocks: &[AssistantBlock]) {
290 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
291 comp.update_blocks(blocks);
292 comp.set_streaming(false);
293 }
294 }
295
296 pub fn has_running_tool(&self) -> bool {
298 self.tool_components
299 .lock()
300 .unwrap()
301 .values()
302 .any(|component| component.status() == ToolStatus::Running)
303 }
304
305 pub fn has_active_bash(&self) -> bool {
307 !self.bash_components.lock().unwrap().is_empty()
308 }
309
310 fn start_assistant(&self, blocks: &[AssistantBlock]) {
311 let comp = Arc::new(AssistantMessageComponent::new(
312 AssistantMessageOptions::default(),
313 ));
314 comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
315 comp.set_show_images(self.show_images);
316 comp.set_trailing_spacing(1);
317 if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
318 comp.set_markdown_transformer(Some(transformer));
319 }
320 comp.set_streaming(true);
321 comp.update_blocks(blocks);
322 self.chat.add_child(comp.clone());
323 *self.current_assistant.lock().unwrap() = Some(comp);
324 }
325
326 fn ensure_assistant(&self) {
329 let mut current = self.current_assistant.lock().unwrap();
330 if current.is_none() {
331 let comp = Arc::new(AssistantMessageComponent::new(
332 AssistantMessageOptions::default(),
333 ));
334 comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
335 comp.set_show_images(self.show_images);
336 comp.set_trailing_spacing(1);
337 if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
338 comp.set_markdown_transformer(Some(transformer));
339 }
340 comp.set_streaming(true);
341 self.chat.add_child(comp.clone());
342 *current = Some(comp);
343 }
344 }
345
346 fn sync_tool_calls(&self, calls: &[UiToolCall]) {
349 for call in calls {
350 if matches!(
351 call.name.as_str(),
352 "todo" | "plan_mode_start" | "plan_mode_complete"
353 ) {
354 continue;
355 }
356 if call.name == "bash" {
357 let command = call
358 .args
359 .get("command")
360 .and_then(Value::as_str)
361 .unwrap_or("");
362 if command.trim().is_empty() {
363 continue;
364 }
365 let mut bash = self.bash_components.lock().unwrap();
366 if let Some(existing) = bash.get(&call.id) {
367 existing.set_command(command);
368 } else {
369 let comp = Arc::new(BashExecutionComponent::new(command));
370 comp.set_expanded(self.expanded());
371 self.chat.add_child(comp.clone());
372 bash.insert(call.id.clone(), comp);
373 }
374 continue;
375 }
376 let ask_user = is_ask_user_tool(&call.name);
377 let display = if ask_user {
378 ask_user_args_display(&call.args)
379 } else {
380 call.args.to_string()
381 };
382 let mut tools = self.tool_components.lock().unwrap();
383 if let Some(existing) = tools.get(&call.id) {
384 if !display.trim().is_empty() && display.trim() != "{}" {
385 existing.set_args(&display);
386 }
387 } else {
388 let comp = Arc::new(ToolExecutionComponent::new(&call.name, &display));
389 if ask_user {
390 comp.set_display_title("ASK USER");
391 }
392 if let Some(skill) = skill_tool_name(&call.name, &call.args) {
393 comp.set_skill_name(skill);
394 }
395 comp.set_expanded(self.expanded());
396 comp.set_running();
397 self.chat.add_child(comp.clone());
398 tools.insert(call.id.clone(), comp);
399 }
400 }
401 }
402
403 fn tool_start(&self, id: &str, name: &str, args: &Value, _width: usize) {
404 if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
405 return;
406 }
407 if name.trim().is_empty() {
408 return;
409 }
410 if name == "bash" {
411 let command = args.get("command").and_then(Value::as_str).unwrap_or("");
412 if command.trim().is_empty() {
415 return;
416 }
417 let mut bash = self.bash_components.lock().unwrap();
418 if let Some(existing) = bash.get(id) {
419 existing.set_command(command);
420 } else {
421 let comp = Arc::new(BashExecutionComponent::new(command));
422 comp.set_expanded(self.expanded());
423 self.chat.add_child(comp.clone());
424 bash.insert(id.to_string(), comp);
425 }
426 return;
427 }
428 let ask_user = is_ask_user_tool(name);
429 let display = if ask_user {
430 ask_user_args_display(args)
431 } else {
432 args.to_string()
433 };
434 let mut tools = self.tool_components.lock().unwrap();
435 if let Some(existing) = tools.get(id) {
436 if ask_user {
437 existing.set_display_title("ASK USER");
438 }
439 existing.set_args(&display);
440 } else {
441 let comp = Arc::new(ToolExecutionComponent::new(name, &display));
442 if ask_user {
443 comp.set_display_title("ASK USER");
444 }
445 if let Some(skill) = skill_tool_name(name, args) {
446 comp.set_skill_name(skill);
447 }
448 comp.set_expanded(self.expanded());
449 comp.set_running();
450 self.chat.add_child(comp.clone());
451 tools.insert(id.to_string(), comp);
452 }
453 }
454
455 fn tool_update(&self, id: &str, name: &str, args: &Value, result: &Value, width: usize) {
456 if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
457 return;
458 }
459 if name.trim().is_empty() {
460 return;
461 }
462 let ask_user = is_ask_user_tool(name);
463 let text = if ask_user {
464 ask_user_progress_text(args, result)
465 } else {
466 tool_result_text(result)
467 };
468 if name == "bash" {
469 if let Some(bash) = self.bash_components.lock().unwrap().get(id) {
470 if let Some(command) = args.get("command").and_then(Value::as_str) {
471 bash.set_command(command);
472 }
473 if !text.is_empty() {
474 bash.append_output(&text);
475 }
476 }
477 return;
478 }
479 let existing = self.tool_components.lock().unwrap().get(id).cloned();
480 match existing {
481 Some(comp) => {
482 if ask_user {
483 comp.set_display_title("ASK USER");
484 comp.set_args(&ask_user_args_display(args));
485 } else if !args.is_null() && args != &serde_json::json!({}) {
486 comp.set_args(&args.to_string());
487 }
488 if tool_update_has_payload(&text, result) {
489 comp.set_result(&text, false);
490 if !ask_user {
491 apply_edit_diff(&comp, name, result.get("details"), width);
492 }
493 }
494 }
495 None => {
496 if tool_update_has_payload(&text, result) {
499 let display = if ask_user {
500 ask_user_args_display(args)
501 } else {
502 args.to_string()
503 };
504 let comp = Arc::new(ToolExecutionComponent::new(name, &display));
505 if ask_user {
506 comp.set_display_title("ASK USER");
507 }
508 if let Some(skill) = skill_tool_name(name, args) {
509 comp.set_skill_name(skill);
510 }
511 comp.set_expanded(self.expanded());
512 comp.set_running();
513 comp.set_result(&text, false);
514 if !ask_user {
515 apply_edit_diff(&comp, name, result.get("details"), width);
516 }
517 self.chat.add_child(comp.clone());
518 self.tool_components
519 .lock()
520 .unwrap()
521 .insert(id.to_string(), comp);
522 }
523 }
524 }
525 }
526
527 fn tool_end(&self, id: &str, name: &str, result: &Value, is_error: bool, width: usize) {
528 if matches!(name, "plan_mode_start" | "plan_mode_complete")
529 && !is_error
530 && update_plan_panel(&self.chat, result)
531 {
532 return;
533 }
534 if name == "todo" && !is_error && update_todo_list(&self.chat, result) {
535 return;
536 }
537 if name.trim().is_empty() {
538 return;
539 }
540 if name == "bash" {
541 let bash = self.bash_components.lock().unwrap().remove(id);
542 match bash {
543 Some(comp) => finalize_bash(&comp, result, is_error),
544 None => {
545 let command = result
546 .get("details")
547 .and_then(|details| details.get("command"))
548 .and_then(Value::as_str)
549 .unwrap_or("");
550 let text = tool_result_text(result);
551 if command.trim().is_empty() && text.trim().is_empty() {
552 return;
553 }
554 let comp = Arc::new(BashExecutionComponent::new(command));
555 comp.set_expanded(self.expanded());
556 if !text.is_empty() {
557 comp.append_output(&text);
558 }
559 finalize_bash(&comp, result, is_error);
560 self.chat.add_child(comp);
561 }
562 }
563 return;
564 }
565 let ask_user = is_ask_user_tool(name);
566 let text = if ask_user {
567 ask_user_result_text(result)
568 } else {
569 tool_result_text(result)
570 };
571 let details = result.get("details");
572 let existing = self.tool_components.lock().unwrap().remove(id);
573 let comp = match existing {
574 Some(comp) => comp,
575 None => {
576 let comp = Arc::new(ToolExecutionComponent::new(name, ""));
578 if ask_user {
579 comp.set_display_title("ASK USER");
580 }
581 comp.set_expanded(self.expanded());
582 self.chat.add_child(comp.clone());
583 comp
584 }
585 };
586 comp.set_result(&text, is_error);
587 if !ask_user {
588 if tool_result_requests_markdown(details) {
589 comp.set_result_markdown(true);
590 }
591 apply_edit_diff(&comp, name, details, width);
592 }
593 }
594}
595
596pub(crate) fn update_plan_panel(chat: &Arc<Container>, result: &Value) -> bool {
598 let Some(details) = result.get("details") else {
599 return false;
600 };
601 if details.get("kind").and_then(Value::as_str) != Some("plan") || details.get("error").is_some()
602 {
603 return false;
604 }
605 let Some(active) = details.get("active").and_then(Value::as_bool) else {
606 return false;
607 };
608 let plan = match details.get("plan") {
609 None | Some(Value::Null) => String::new(),
610 Some(Value::String(plan)) => plan.clone(),
611 _ => return false,
612 };
613 for child in chat.get_children().into_iter().rev() {
614 if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
615 if component.is_plan_panel() {
616 component.set_plan_state(plan, active);
617 component.set_result("", false);
618 if let Some(expanded) = details.get("expanded").and_then(Value::as_bool) {
619 component.set_expanded(expanded);
620 }
621 chat.remove_child(&child);
622 chat.add_child(child);
623 return true;
624 }
625 }
626 }
627 let component = Arc::new(ToolExecutionComponent::new("plan_mode_complete", ""));
628 component.set_plan_state(plan, active);
629 component.set_result("", false);
630 component.set_expanded(
631 details
632 .get("expanded")
633 .and_then(Value::as_bool)
634 .unwrap_or(false),
635 );
636 chat.add_child(component);
637 true
638}
639
640pub(crate) fn update_todo_list(chat: &Arc<Container>, result: &Value) -> bool {
642 let Some(details) = result.get("details") else {
643 return false;
644 };
645 if details.get("error").is_some() {
646 return false;
647 }
648 let Some(items) = details.get("todos").and_then(Value::as_array) else {
649 return false;
650 };
651 let rows: Option<Vec<_>> = items
652 .iter()
653 .map(|item| {
654 let text = item.get("text")?.as_str()?.to_owned();
655 let status = if item.get("done").and_then(Value::as_bool) == Some(true) {
656 "completed"
657 } else {
658 item.get("status")
659 .and_then(Value::as_str)
660 .unwrap_or("pending")
661 };
662 Some((text, status.to_owned()))
663 })
664 .collect();
665 let Some(rows) = rows else {
666 return false;
667 };
668 let text = tool_result_text(result);
669 for child in chat.get_children().into_iter().rev() {
670 if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
671 if component.is_todo_list() {
672 component.set_todo_state(rows);
673 component.set_result(&text, false);
674 chat.remove_child(&child);
675 chat.add_child(child);
676 return true;
677 }
678 }
679 }
680 let candidate = Arc::new(ToolExecutionComponent::new("todo", ""));
681 candidate.set_todo_state(rows);
682 candidate.set_result(&text, false);
683 chat.add_child(candidate);
684 true
685}
686
687pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
691 let mut blocks = Vec::new();
692 let Some(content) = message.get("content").and_then(Value::as_array) else {
693 return blocks;
694 };
695 for block in content {
696 match block.get("type").and_then(Value::as_str) {
697 Some("text") => {
698 if let Some(text) = block.get("text").and_then(Value::as_str) {
699 blocks.push(AssistantBlock::Text(text.to_string()));
700 }
701 }
702 Some("thinking") => {
703 if let Some(text) = block.get("thinking").and_then(Value::as_str) {
704 blocks.push(AssistantBlock::Thinking(text.to_string()));
705 }
706 }
707 Some("image") => {
708 if let Some(data) = block.get("data").and_then(Value::as_str) {
709 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
710 if !bytes.is_empty() {
711 let png = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
712 Some(bytes)
713 } else {
714 rpi_tools::image_processing::process_image(
715 &bytes,
716 Default::default(),
717 )
718 .ok()
719 };
720 if let Some(png) = png {
721 blocks.push(AssistantBlock::Image(png));
722 }
723 }
724 }
725 }
726 }
727 _ => {}
728 }
729 }
730 blocks
731}
732
733pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
736 let mut calls = Vec::new();
737 let Some(content) = message.get("content").and_then(Value::as_array) else {
738 return calls;
739 };
740 for block in content {
741 if block.get("type").and_then(Value::as_str) != Some("toolCall") {
742 continue;
743 }
744 let id = block.get("id").and_then(Value::as_str).unwrap_or("");
745 let name = block.get("name").and_then(Value::as_str).unwrap_or("");
746 if id.is_empty() || name.trim().is_empty() {
747 continue;
748 }
749 calls.push(UiToolCall {
750 id: id.to_string(),
751 name: name.to_string(),
752 args: block.get("arguments").cloned().unwrap_or(Value::Null),
753 });
754 }
755 calls
756}
757
758pub fn tool_result_text(result: &Value) -> String {
760 let mut out = String::new();
761 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
762 for block in blocks {
763 if let Some(text) = block.get("text").and_then(Value::as_str) {
764 if !out.is_empty() {
765 out.push('\n');
766 }
767 out.push_str(text);
768 }
769 }
770 }
771 out
772}
773
774pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
777 details
778 .and_then(|details| details.get("markdown"))
779 .and_then(Value::as_bool)
780 .unwrap_or(false)
781}
782
783pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
787 if tool_name != "read" {
788 return None;
789 }
790 let path = args.get("path").and_then(Value::as_str)?;
791 let normalized = path.replace('\\', "/");
792 let file_name = normalized.rsplit('/').next()?;
793 if !file_name.eq_ignore_ascii_case("SKILL.md") {
794 return None;
795 }
796 normalized
797 .trim_end_matches('/')
798 .rsplit('/')
799 .nth(1)
800 .filter(|name| !name.is_empty())
801 .map(str::to_string)
802}
803
804fn is_ask_user_tool(tool_name: &str) -> bool {
807 matches!(tool_name, "ask_user" | "ask_user_question")
808}
809
810fn ask_user_args_display(args: &Value) -> String {
813 if args.is_null() {
814 return String::new();
815 }
816 if let Some(summary) = args
817 .get("summary")
818 .and_then(Value::as_str)
819 .map(str::trim)
820 .filter(|text| !text.is_empty())
821 {
822 return format!("Confirmation requested: {summary}");
823 }
824
825 let mut lines: Vec<String> = Vec::new();
826 let questions: Vec<&Value> = args
827 .get("questions")
828 .and_then(Value::as_array)
829 .filter(|items| !items.is_empty())
830 .map(|items| items.iter().collect())
831 .unwrap_or_else(|| vec![args]);
832
833 for question in questions {
834 let prompt = question
835 .get("question")
836 .and_then(Value::as_str)
837 .or_else(|| args.get("question").and_then(Value::as_str))
838 .map(str::trim)
839 .unwrap_or_default();
840 if prompt.is_empty() {
841 continue;
842 }
843 if let Some(header) = question
844 .get("header")
845 .and_then(Value::as_str)
846 .map(str::trim)
847 .filter(|text| !text.is_empty())
848 {
849 lines.push(format!("{header}: {prompt}"));
850 } else {
851 lines.push(prompt.to_string());
852 }
853 let context = question
854 .get("context")
855 .and_then(Value::as_str)
856 .or_else(|| args.get("context").and_then(Value::as_str))
857 .map(str::trim)
858 .filter(|text| !text.is_empty());
859 if let Some(context) = context {
860 lines.push(format!(" {context}"));
861 }
862 let options = question
863 .get("options")
864 .and_then(Value::as_array)
865 .or_else(|| args.get("options").and_then(Value::as_array));
866 if let Some(options) = options {
867 let labels: Vec<String> = options
868 .iter()
869 .filter_map(|option| {
870 option
871 .get("title")
872 .and_then(Value::as_str)
873 .or_else(|| option.as_str())
874 .map(str::trim)
875 .filter(|label| !label.is_empty())
876 .map(str::to_string)
877 })
878 .collect();
879 if !labels.is_empty() {
880 lines.push(format!("Choices: {}", labels.join(", ")));
881 }
882 }
883 if let Some(suggest) = question
884 .get("suggest")
885 .and_then(Value::as_str)
886 .or_else(|| args.get("suggest").and_then(Value::as_str))
887 .map(str::trim)
888 .filter(|text| !text.is_empty())
889 {
890 lines.push(format!("Suggestions: {suggest}"));
891 }
892 }
893
894 lines.join("\n")
895}
896
897fn ask_user_progress_text(args: &Value, result: &Value) -> String {
900 let text = tool_result_text(result);
901 if !text.trim().is_empty() {
902 return text;
903 }
904 let args_text = ask_user_args_display(args);
905 if args_text.trim().is_empty() {
906 "Waiting for your answer…".to_string()
907 } else {
908 args_text
909 }
910}
911
912fn ask_user_result_text(result: &Value) -> String {
914 let text = tool_result_text(result);
915 if text.trim().is_empty() {
916 "Answer recorded.".to_string()
917 } else {
918 text
919 }
920}
921
922fn tool_update_has_payload(text: &str, result: &Value) -> bool {
924 !text.trim().is_empty()
925 || result
926 .get("details")
927 .map(|details| !details.is_null())
928 .unwrap_or(false)
929}
930
931fn apply_edit_diff(
934 comp: &Arc<ToolExecutionComponent>,
935 tool_name: &str,
936 details: Option<&Value>,
937 width: usize,
938) {
939 if tool_name != "edit" {
940 return;
941 }
942 let Some(diff_text) = details
943 .and_then(|details| details.get("diff"))
944 .and_then(Value::as_str)
945 else {
946 return;
947 };
948 if diff_text.is_empty() {
949 return;
950 }
951 comp.set_diff(render_diff(diff_text, width));
952}
953
954fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
957 let details = result.get("details");
958 let exit_code = Some(if is_error { 1 } else { 0 });
959 let truncated = details
960 .and_then(|details| details.get("truncation"))
961 .and_then(|truncation| truncation.get("truncated"))
962 .and_then(Value::as_bool)
963 .unwrap_or(false);
964 let full_output_path = details
965 .and_then(|details| details.get("full_output_path"))
966 .and_then(Value::as_str)
967 .map(str::to_string);
968 if let Some(command) = details
969 .and_then(|details| details.get("command"))
970 .and_then(Value::as_str)
971 {
972 comp.set_command(command);
973 }
974 comp.set_complete(
975 exit_code,
976 false,
977 BashTruncation {
978 truncated,
979 full_output_path,
980 },
981 );
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987 use rpi_tui::{strip_ansi, Component};
988 use serde_json::json;
989
990 fn view() -> TranscriptView {
991 TranscriptView::new(Arc::new(Container::new()))
992 }
993
994 #[test]
995 fn adjacent_transcript_blocks_separated_by_exactly_one_blank() {
996 let view = view();
1010 view.add_user("question");
1011 view.apply(
1012 &UiEvent::AssistantStart {
1013 blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1014 },
1015 80,
1016 );
1017 view.apply(
1018 &UiEvent::AssistantUpdate {
1019 blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1020 tool_calls: vec![UiToolCall {
1021 id: "t1".into(),
1022 name: "read".into(),
1023 args: json!({"path": "f.txt"}),
1024 }],
1025 },
1026 80,
1027 );
1028 view.apply(
1029 &UiEvent::ToolEnd {
1030 id: "t1".into(),
1031 name: "read".into(),
1032 result: json!({"content": [{"type": "text", "text": "done"}]}),
1033 is_error: false,
1034 },
1035 80,
1036 );
1037 view.apply(
1038 &UiEvent::AssistantStart {
1039 blocks: vec![AssistantBlock::Text("answer".into())],
1040 },
1041 80,
1042 );
1043 view.apply(
1044 &UiEvent::ToolStart {
1045 id: "b1".into(),
1046 name: "bash".into(),
1047 args: json!({"command": "ls"}),
1048 },
1049 80,
1050 );
1051 view.apply(&UiEvent::AgentEnd, 80);
1052
1053 let lines = view.chat.render(80);
1054 let plain: Vec<String> = lines
1055 .iter()
1056 .map(|l| strip_ansi(l).trim().to_string())
1057 .collect();
1058 let find = |needle: &str| {
1061 plain
1062 .iter()
1063 .rposition(|l| l.contains(needle))
1064 .unwrap_or_else(|| panic!("{needle} missing: {plain:?}"))
1065 };
1066 let user = find("question");
1067 let thinking = find("reasoning");
1068 let panel = find("done");
1069 let answer = find("answer");
1070 let bash = find("$ ls");
1071 assert!(user < thinking && thinking < panel && panel < answer);
1072 assert!(answer < bash);
1073 for (top_name, top, bottom_name, bottom) in [
1074 ("user", user, "thinking", thinking),
1075 ("thinking", thinking, "panel", panel),
1076 ("panel", panel, "answer", answer),
1077 ("answer", answer, "bash", bash),
1078 ] {
1079 let gap = lines[top + 1..bottom]
1080 .iter()
1081 .filter(|l| l.is_empty())
1082 .count();
1083 assert_eq!(
1084 gap, 1,
1085 "{top_name}→{bottom_name} seam must be exactly one blank row: {plain:?}"
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn retry_notice_ends_with_a_trailing_spacer() {
1092 let view = view();
1095 view.apply(
1096 &UiEvent::Retry {
1097 attempt: 1,
1098 max_retries: 3,
1099 delay_ms: 500,
1100 error: "boom".into(),
1101 },
1102 80,
1103 );
1104 let lines = view.chat.render(80);
1105 let rendered = strip_ansi(&lines.join("\n"));
1106 assert!(rendered.contains("retry 1/3"));
1107 assert_eq!(lines.last(), Some(&String::new()));
1108 }
1109
1110 #[test]
1111 fn empty_assistant_start_and_whitespace_deltas_do_not_move_the_transcript() {
1112 for starts_with_event in [true, false] {
1113 let view = view();
1114 view.add_user("hi");
1115 let before = view.chat.render(80);
1116 if starts_with_event {
1117 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1118 assert_eq!(view.chat.render(80), before);
1119 }
1120 for blocks in [
1121 vec![],
1122 vec![AssistantBlock::Text(" ".into())],
1123 vec![AssistantBlock::Thinking("\n".into())],
1124 ] {
1125 view.apply(
1126 &UiEvent::AssistantUpdate {
1127 blocks,
1128 tool_calls: vec![],
1129 },
1130 80,
1131 );
1132 assert_eq!(view.chat.render(80), before);
1133 }
1134 view.apply(
1135 &UiEvent::AssistantUpdate {
1136 blocks: vec![AssistantBlock::Text("hello".into())],
1137 tool_calls: vec![],
1138 },
1139 80,
1140 );
1141 let visible = view.chat.render(80);
1142 assert!(visible.len() > before.len());
1143 assert!(visible.iter().any(|line| line.contains("hello")));
1144 assert_eq!(visible.last(), Some(&String::new()));
1145 view.apply(&UiEvent::AssistantEnd { blocks: vec![] }, 80);
1146 assert_eq!(view.chat.render(80), before);
1147 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1148 assert_eq!(view.chat.render(80), before);
1149 }
1150 }
1151
1152 #[test]
1153 fn assistant_blocks_follow_content_order() {
1154 let message = json!({
1155 "content": [
1156 {"type": "thinking", "thinking": "why"},
1157 {"type": "text", "text": "hello"},
1158 {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
1159 ]
1160 });
1161 assert_eq!(
1162 assistant_blocks_from_value(&message),
1163 vec![
1164 AssistantBlock::Thinking("why".into()),
1165 AssistantBlock::Text("hello".into()),
1166 ]
1167 );
1168 }
1169
1170 #[test]
1171 fn assistant_blocks_decode_images() {
1172 let mut bytes = Vec::new();
1173 {
1174 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1175 encoder.set_color(png::ColorType::Rgb);
1176 encoder
1177 .write_header()
1178 .unwrap()
1179 .write_image_data(&[255, 0, 0])
1180 .unwrap();
1181 }
1182 let message = json!({
1183 "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "mimeType": "image/png"}]
1184 });
1185 assert_eq!(
1186 assistant_blocks_from_value(&message),
1187 vec![AssistantBlock::Image(bytes.to_vec())]
1188 );
1189 let jpeg = rpi_tools::image_processing::process_image(
1190 &bytes,
1191 rpi_tools::image_processing::ImageProcessingOptions {
1192 format: Some(rpi_tools::image_processing::OutputFormat::Jpeg { quality: 85 }),
1193 ..Default::default()
1194 },
1195 )
1196 .unwrap();
1197 let blocks = assistant_blocks_from_value(&json!({
1198 "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(jpeg), "mimeType": "image/jpeg"}]
1199 }));
1200 assert!(
1201 matches!(&blocks[0], AssistantBlock::Image(data) if data.starts_with(b"\x89PNG\r\n\x1a\n"))
1202 );
1203 }
1204
1205 #[test]
1206 fn tool_calls_extract_ids_names_and_args() {
1207 let message = json!({
1208 "content": [
1209 {"type": "text", "text": "x"},
1210 {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
1211 {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
1212 ]
1213 });
1214 assert_eq!(
1215 tool_calls_from_value(&message),
1216 vec![UiToolCall {
1217 id: "b1".into(),
1218 name: "bash".into(),
1219 args: json!({"command": "ls"}),
1220 }]
1221 );
1222 }
1223
1224 #[test]
1225 fn markdown_flag_is_opt_in() {
1226 assert!(tool_result_requests_markdown(Some(
1227 &json!({"markdown": true})
1228 )));
1229 assert!(!tool_result_requests_markdown(Some(
1230 &json!({"markdown": false})
1231 )));
1232 assert!(!tool_result_requests_markdown(Some(&json!({}))));
1233 assert!(!tool_result_requests_markdown(None));
1234 }
1235
1236 #[test]
1237 fn skill_tool_name_only_matches_skill_reads() {
1238 assert_eq!(
1239 skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
1240 Some("foo".to_string())
1241 );
1242 assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
1243 assert_eq!(
1244 skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
1245 None
1246 );
1247 }
1248
1249 #[test]
1250 fn ask_user_args_display_never_leaks_transport_json() {
1251 let text = ask_user_args_display(&json!({
1252 "question": "你的代理端口是多少?",
1253 "context": "本机",
1254 "options": ["7890", {"title": "7897", "description": "clash"}],
1255 "suggest": "1080"
1256 }));
1257 assert!(text.contains("你的代理端口是多少?"));
1258 assert!(text.contains("本机"));
1259 assert!(text.contains("Choices: 7890, 7897"));
1260 assert!(text.contains("Suggestions: 1080"));
1261 assert!(!text.trim_start().starts_with('{'));
1262 assert_eq!(
1263 ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
1264 "Confirmation requested: Deploy?"
1265 );
1266 }
1267
1268 #[test]
1269 fn tool_start_routes_bash_to_its_own_map() {
1270 let view = view();
1271 view.apply(
1272 &UiEvent::ToolStart {
1273 id: "b1".into(),
1274 name: "bash".into(),
1275 args: json!({"command": "ls -la"}),
1276 },
1277 80,
1278 );
1279 view.apply(
1280 &UiEvent::ToolStart {
1281 id: "r1".into(),
1282 name: "read".into(),
1283 args: json!({"path": "a.txt"}),
1284 },
1285 80,
1286 );
1287 assert!(view.bash_components.lock().unwrap().contains_key("b1"));
1288 assert!(view.tool_components.lock().unwrap().contains_key("r1"));
1289 assert!(view.tool_components.lock().unwrap().get("b1").is_none());
1290 }
1291
1292 #[test]
1293 fn bash_start_without_a_command_is_deferred() {
1294 let view = view();
1295 view.apply(
1296 &UiEvent::ToolStart {
1297 id: "b1".into(),
1298 name: "bash".into(),
1299 args: json!({}),
1300 },
1301 80,
1302 );
1303 assert!(view.bash_components.lock().unwrap().is_empty());
1304 assert!(view.tool_components.lock().unwrap().is_empty());
1305 }
1306
1307 #[test]
1308 fn assistant_stream_reuses_one_component() {
1309 let view = view();
1310 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1311 view.apply(
1312 &UiEvent::AssistantUpdate {
1313 blocks: vec![AssistantBlock::Text("hi".into())],
1314 tool_calls: vec![],
1315 },
1316 80,
1317 );
1318 assert!(view.current_assistant.lock().unwrap().is_some());
1319 }
1320
1321 #[test]
1322 fn toggle_expanded_round_trips() {
1323 let view = view();
1324 assert!(!view.expanded());
1325 assert!(view.toggle_expanded());
1326 assert!(view.expanded());
1327 }
1328
1329 #[test]
1330 fn todo_updates_share_one_panel_and_preserve_errors() {
1331 let view = view();
1332 for (id, done) in [("a", false), ("b", true)] {
1333 view.sync_tool_calls(&[UiToolCall {
1334 id: id.into(),
1335 name: "todo".into(),
1336 args: json!({"action":"toggle"}),
1337 }]);
1338 view.tool_start(id, "todo", &json!({}), 80);
1339 view.tool_end(id, "todo", &json!({"content":[],"details":{"todos":[{"id":1,"text":"Run tests","done":done}]}}), false, 80);
1340 }
1341 assert_eq!(view.chat.child_count(), 1);
1342 assert!(view.tool_components.lock().unwrap().is_empty());
1343 let children = view.chat.get_children();
1344 let panel = children[0]
1345 .as_any()
1346 .downcast_ref::<ToolExecutionComponent>()
1347 .unwrap();
1348 let rendered = rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n"));
1349 assert!(rendered.contains("1/1 completed"));
1350 assert!(rendered.contains("[✓] Run tests"));
1351 view.tool_end(
1352 "error",
1353 "todo",
1354 &json!({"content":[{"type":"text","text":"invalid task"}]}),
1355 true,
1356 80,
1357 );
1358 assert_eq!(view.chat.child_count(), 2);
1359 view.tool_end("soft-error", "todo", &json!({"content":[{"type":"text","text":"todo not found"}],"details":{"todos":[],"error":"todo not found"}}), false, 80);
1360 assert_eq!(view.chat.child_count(), 3);
1361 assert!(update_todo_list(
1362 &view.chat,
1363 &json!({"details":{"todos":[]}})
1364 ));
1365 assert_eq!(view.chat.child_count(), 3);
1366 assert!(!update_todo_list(
1367 &view.chat,
1368 &json!({"details":{"todos":[{}]}})
1369 ));
1370 assert_eq!(view.chat.child_count(), 3);
1371 }
1372
1373 #[test]
1374 fn plan_tools_and_show_update_one_expandable_panel() {
1375 let view = view();
1376 view.sync_tool_calls(&[UiToolCall {
1377 id: "start".into(),
1378 name: "plan_mode_start".into(),
1379 args: json!({}),
1380 }]);
1381 view.tool_start("start", "plan_mode_start", &json!({}), 80);
1382 view.tool_end(
1383 "start",
1384 "plan_mode_start",
1385 &json!({"details":{"kind":"plan","active":true}}),
1386 false,
1387 80,
1388 );
1389 let body = (1..=30)
1390 .map(|i| format!("{i}. Step {i}"))
1391 .collect::<Vec<_>>()
1392 .join("\n");
1393 view.tool_end(
1394 "complete",
1395 "plan_mode_complete",
1396 &json!({"details":{"kind":"plan","active":false,"plan":body}}),
1397 false,
1398 80,
1399 );
1400 assert_eq!(view.chat.child_count(), 1);
1401 assert!(view.tool_components.lock().unwrap().is_empty());
1402 view.set_expanded(true);
1403 let children = view.chat.get_children();
1404 let panel = children[0]
1405 .as_any()
1406 .downcast_ref::<ToolExecutionComponent>()
1407 .unwrap();
1408 assert!(rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n")).contains("Step 30"));
1409 assert!(update_plan_panel(
1410 &view.chat,
1411 &json!({"kind":"message","details":{"kind":"plan","active":false,"plan":body,"expanded":true}})
1412 ));
1413 assert_eq!(view.chat.child_count(), 1);
1414 view.tool_end(
1415 "error",
1416 "plan_mode_complete",
1417 &json!({"content":[{"type":"text","text":"not active"}]}),
1418 true,
1419 80,
1420 );
1421 assert_eq!(view.chat.child_count(), 2);
1422 }
1423}