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