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