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 return true;
642 }
643 }
644 }
645 let component = Arc::new(ToolExecutionComponent::new("plan_mode_complete", ""));
646 component.set_plan_state(plan, active);
647 component.set_result("", false);
648 component.set_expanded(
649 details
650 .get("expanded")
651 .and_then(Value::as_bool)
652 .unwrap_or(false),
653 );
654 chat.add_child(component);
655 true
656}
657
658pub(crate) fn update_todo_list(chat: &Arc<Container>, result: &Value) -> bool {
660 let Some(details) = result.get("details") else {
661 return false;
662 };
663 if details.get("error").is_some() {
664 return false;
665 }
666 let Some(items) = details.get("todos").and_then(Value::as_array) else {
667 return false;
668 };
669 let rows: Option<Vec<_>> = items
670 .iter()
671 .map(|item| {
672 let text = item.get("text")?.as_str()?.to_owned();
673 let status = if item.get("done").and_then(Value::as_bool) == Some(true) {
674 "completed"
675 } else {
676 item.get("status")
677 .and_then(Value::as_str)
678 .unwrap_or("pending")
679 };
680 Some((text, status.to_owned()))
681 })
682 .collect();
683 let Some(rows) = rows else {
684 return false;
685 };
686 let text = tool_result_text(result);
687 for child in chat.get_children().into_iter().rev() {
688 if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
689 if component.is_todo_list() {
690 component.set_todo_state(rows);
691 component.set_result(&text, false);
692 chat.remove_child(&child);
693 chat.add_child(child);
694 return true;
695 }
696 }
697 }
698 let candidate = Arc::new(ToolExecutionComponent::new("todo", ""));
699 candidate.set_todo_state(rows);
700 candidate.set_result(&text, false);
701 chat.add_child(candidate);
702 true
703}
704
705pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
709 let mut blocks = Vec::new();
710 let Some(content) = message.get("content").and_then(Value::as_array) else {
711 return blocks;
712 };
713 for block in content {
714 match block.get("type").and_then(Value::as_str) {
715 Some("text") => {
716 if let Some(text) = block.get("text").and_then(Value::as_str) {
717 blocks.push(AssistantBlock::Text(text.to_string()));
718 }
719 }
720 Some("thinking") => {
721 if let Some(text) = block.get("thinking").and_then(Value::as_str) {
722 blocks.push(AssistantBlock::Thinking(text.to_string()));
723 }
724 }
725 Some("image") => {
726 if let Some(data) = block.get("data").and_then(Value::as_str) {
727 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
728 if !bytes.is_empty() {
729 let png = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
730 Some(bytes)
731 } else {
732 rpi_tools::image_processing::process_image(
733 &bytes,
734 Default::default(),
735 )
736 .ok()
737 };
738 if let Some(png) = png {
739 blocks.push(AssistantBlock::Image(png));
740 }
741 }
742 }
743 }
744 }
745 _ => {}
746 }
747 }
748 blocks
749}
750
751pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
754 let mut calls = Vec::new();
755 let Some(content) = message.get("content").and_then(Value::as_array) else {
756 return calls;
757 };
758 for block in content {
759 if block.get("type").and_then(Value::as_str) != Some("toolCall") {
760 continue;
761 }
762 let id = block.get("id").and_then(Value::as_str).unwrap_or("");
763 let name = block.get("name").and_then(Value::as_str).unwrap_or("");
764 if id.is_empty() || name.trim().is_empty() {
765 continue;
766 }
767 calls.push(UiToolCall {
768 id: id.to_string(),
769 name: name.to_string(),
770 args: block.get("arguments").cloned().unwrap_or(Value::Null),
771 });
772 }
773 calls
774}
775
776pub fn tool_result_text(result: &Value) -> String {
778 let mut out = String::new();
779 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
780 for block in blocks {
781 if let Some(text) = block.get("text").and_then(Value::as_str) {
782 if !out.is_empty() {
783 out.push('\n');
784 }
785 out.push_str(text);
786 }
787 }
788 }
789 out
790}
791
792pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
795 details
796 .and_then(|details| details.get("markdown"))
797 .and_then(Value::as_bool)
798 .unwrap_or(false)
799}
800
801pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
805 if tool_name != "read" {
806 return None;
807 }
808 let path = args.get("path").and_then(Value::as_str)?;
809 let normalized = path.replace('\\', "/");
810 let file_name = normalized.rsplit('/').next()?;
811 if !file_name.eq_ignore_ascii_case("SKILL.md") {
812 return None;
813 }
814 normalized
815 .trim_end_matches('/')
816 .rsplit('/')
817 .nth(1)
818 .filter(|name| !name.is_empty())
819 .map(str::to_string)
820}
821
822fn is_ask_user_tool(tool_name: &str) -> bool {
825 matches!(tool_name, "ask_user" | "ask_user_question")
826}
827
828fn ask_user_args_display(args: &Value) -> String {
831 if args.is_null() {
832 return String::new();
833 }
834 if let Some(summary) = args
835 .get("summary")
836 .and_then(Value::as_str)
837 .map(str::trim)
838 .filter(|text| !text.is_empty())
839 {
840 return format!("Confirmation requested: {summary}");
841 }
842
843 let mut lines: Vec<String> = Vec::new();
844 let questions: Vec<&Value> = args
845 .get("questions")
846 .and_then(Value::as_array)
847 .filter(|items| !items.is_empty())
848 .map(|items| items.iter().collect())
849 .unwrap_or_else(|| vec![args]);
850
851 for question in questions {
852 let prompt = question
853 .get("question")
854 .and_then(Value::as_str)
855 .or_else(|| args.get("question").and_then(Value::as_str))
856 .map(str::trim)
857 .unwrap_or_default();
858 if prompt.is_empty() {
859 continue;
860 }
861 if let Some(header) = question
862 .get("header")
863 .and_then(Value::as_str)
864 .map(str::trim)
865 .filter(|text| !text.is_empty())
866 {
867 lines.push(format!("{header}: {prompt}"));
868 } else {
869 lines.push(prompt.to_string());
870 }
871 let context = question
872 .get("context")
873 .and_then(Value::as_str)
874 .or_else(|| args.get("context").and_then(Value::as_str))
875 .map(str::trim)
876 .filter(|text| !text.is_empty());
877 if let Some(context) = context {
878 lines.push(format!(" {context}"));
879 }
880 let options = question
881 .get("options")
882 .and_then(Value::as_array)
883 .or_else(|| args.get("options").and_then(Value::as_array));
884 if let Some(options) = options {
885 let labels: Vec<String> = options
886 .iter()
887 .filter_map(|option| {
888 option
889 .get("title")
890 .and_then(Value::as_str)
891 .or_else(|| option.as_str())
892 .map(str::trim)
893 .filter(|label| !label.is_empty())
894 .map(str::to_string)
895 })
896 .collect();
897 if !labels.is_empty() {
898 lines.push(format!("Choices: {}", labels.join(", ")));
899 }
900 }
901 if let Some(suggest) = question
902 .get("suggest")
903 .and_then(Value::as_str)
904 .or_else(|| args.get("suggest").and_then(Value::as_str))
905 .map(str::trim)
906 .filter(|text| !text.is_empty())
907 {
908 lines.push(format!("Suggestions: {suggest}"));
909 }
910 }
911
912 lines.join("\n")
913}
914
915fn ask_user_progress_text(args: &Value, result: &Value) -> String {
918 let text = tool_result_text(result);
919 if !text.trim().is_empty() {
920 return text;
921 }
922 let args_text = ask_user_args_display(args);
923 if args_text.trim().is_empty() {
924 "Waiting for your answer…".to_string()
925 } else {
926 args_text
927 }
928}
929
930fn ask_user_result_text(result: &Value) -> String {
932 let text = tool_result_text(result);
933 if text.trim().is_empty() {
934 "Answer recorded.".to_string()
935 } else {
936 text
937 }
938}
939
940fn tool_update_has_payload(text: &str, result: &Value) -> bool {
942 !text.trim().is_empty()
943 || result
944 .get("details")
945 .map(|details| !details.is_null())
946 .unwrap_or(false)
947}
948
949fn apply_edit_diff(
952 comp: &Arc<ToolExecutionComponent>,
953 tool_name: &str,
954 details: Option<&Value>,
955 width: usize,
956) {
957 if tool_name != "edit" {
958 return;
959 }
960 let Some(diff_text) = details
961 .and_then(|details| details.get("diff"))
962 .and_then(Value::as_str)
963 else {
964 return;
965 };
966 if diff_text.is_empty() {
967 return;
968 }
969 comp.set_diff(render_diff(diff_text, width));
970}
971
972fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
975 let details = result.get("details");
976 let exit_code = Some(if is_error { 1 } else { 0 });
977 let truncated = details
978 .and_then(|details| details.get("truncation"))
979 .and_then(|truncation| truncation.get("truncated"))
980 .and_then(Value::as_bool)
981 .unwrap_or(false);
982 let full_output_path = details
983 .and_then(|details| details.get("full_output_path"))
984 .and_then(Value::as_str)
985 .map(str::to_string);
986 if let Some(command) = details
987 .and_then(|details| details.get("command"))
988 .and_then(Value::as_str)
989 {
990 comp.set_command(command);
991 }
992 comp.set_complete(
993 exit_code,
994 false,
995 BashTruncation {
996 truncated,
997 full_output_path,
998 },
999 );
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use rpi_tui::{strip_ansi, Component};
1006 use serde_json::json;
1007
1008 fn view() -> TranscriptView {
1009 TranscriptView::new(Arc::new(Container::new()))
1010 }
1011
1012 #[test]
1013 fn adjacent_transcript_blocks_separated_by_exactly_one_blank() {
1014 let view = view();
1028 view.add_user("question");
1029 view.apply(
1030 &UiEvent::AssistantStart {
1031 blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1032 },
1033 80,
1034 );
1035 view.apply(
1036 &UiEvent::AssistantUpdate {
1037 blocks: vec![AssistantBlock::Thinking("reasoning".into())],
1038 tool_calls: vec![UiToolCall {
1039 id: "t1".into(),
1040 name: "read".into(),
1041 args: json!({"path": "f.txt"}),
1042 }],
1043 },
1044 80,
1045 );
1046 view.apply(
1047 &UiEvent::ToolEnd {
1048 id: "t1".into(),
1049 name: "read".into(),
1050 result: json!({"content": [{"type": "text", "text": "done"}]}),
1051 is_error: false,
1052 },
1053 80,
1054 );
1055 view.apply(
1056 &UiEvent::AssistantStart {
1057 blocks: vec![AssistantBlock::Text("answer".into())],
1058 },
1059 80,
1060 );
1061 view.apply(
1062 &UiEvent::ToolStart {
1063 id: "b1".into(),
1064 name: "bash".into(),
1065 args: json!({"command": "ls"}),
1066 },
1067 80,
1068 );
1069 view.apply(&UiEvent::AgentEnd, 80);
1070
1071 let lines = view.chat.render(80);
1072 let plain: Vec<String> = lines
1073 .iter()
1074 .map(|l| strip_ansi(l).trim().to_string())
1075 .collect();
1076 let find = |needle: &str| {
1079 plain
1080 .iter()
1081 .rposition(|l| l.contains(needle))
1082 .unwrap_or_else(|| panic!("{needle} missing: {plain:?}"))
1083 };
1084 let user = find("question");
1085 let thinking = find("reasoning");
1086 let panel = find("done");
1087 let answer = find("answer");
1088 let bash = find("$ ls");
1089 assert!(user < thinking && thinking < panel && panel < answer);
1090 assert!(answer < bash);
1091 for (top_name, top, bottom_name, bottom) in [
1092 ("user", user, "thinking", thinking),
1093 ("thinking", thinking, "panel", panel),
1094 ("panel", panel, "answer", answer),
1095 ("answer", answer, "bash", bash),
1096 ] {
1097 let gap = lines[top + 1..bottom]
1098 .iter()
1099 .filter(|l| l.is_empty())
1100 .count();
1101 assert_eq!(
1102 gap, 1,
1103 "{top_name}→{bottom_name} seam must be exactly one blank row: {plain:?}"
1104 );
1105 }
1106 }
1107
1108 #[test]
1109 fn retry_notice_ends_with_a_trailing_spacer() {
1110 let view = view();
1113 view.apply(
1114 &UiEvent::Retry {
1115 attempt: 1,
1116 max_retries: 3,
1117 delay_ms: 500,
1118 error: "boom".into(),
1119 },
1120 80,
1121 );
1122 let lines = view.chat.render(80);
1123 let rendered = strip_ansi(&lines.join("\n"));
1124 assert!(rendered.contains("retry 1/3"));
1125 assert_eq!(lines.last(), Some(&String::new()));
1126 }
1127
1128 #[test]
1129 fn empty_assistant_start_and_whitespace_deltas_do_not_move_the_transcript() {
1130 for starts_with_event in [true, false] {
1131 let view = view();
1132 view.add_user("hi");
1133 let before = view.chat.render(80);
1134 if starts_with_event {
1135 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1136 assert_eq!(view.chat.render(80), before);
1137 }
1138 for blocks in [
1139 vec![],
1140 vec![AssistantBlock::Text(" ".into())],
1141 vec![AssistantBlock::Thinking("\n".into())],
1142 ] {
1143 view.apply(
1144 &UiEvent::AssistantUpdate {
1145 blocks,
1146 tool_calls: vec![],
1147 },
1148 80,
1149 );
1150 assert_eq!(view.chat.render(80), before);
1151 }
1152 view.apply(
1153 &UiEvent::AssistantUpdate {
1154 blocks: vec![AssistantBlock::Text("hello".into())],
1155 tool_calls: vec![],
1156 },
1157 80,
1158 );
1159 let visible = view.chat.render(80);
1160 assert!(visible.len() > before.len());
1161 assert!(visible.iter().any(|line| line.contains("hello")));
1162 assert_eq!(visible.last(), Some(&String::new()));
1163 view.apply(&UiEvent::AssistantEnd { blocks: vec![] }, 80);
1164 assert_eq!(view.chat.render(80), before);
1165 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1166 assert_eq!(view.chat.render(80), before);
1167 }
1168 }
1169
1170 #[test]
1171 fn assistant_blocks_follow_content_order() {
1172 let message = json!({
1173 "content": [
1174 {"type": "thinking", "thinking": "why"},
1175 {"type": "text", "text": "hello"},
1176 {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
1177 ]
1178 });
1179 assert_eq!(
1180 assistant_blocks_from_value(&message),
1181 vec![
1182 AssistantBlock::Thinking("why".into()),
1183 AssistantBlock::Text("hello".into()),
1184 ]
1185 );
1186 }
1187
1188 #[test]
1189 fn assistant_blocks_decode_images() {
1190 let mut bytes = Vec::new();
1191 {
1192 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1193 encoder.set_color(png::ColorType::Rgb);
1194 encoder
1195 .write_header()
1196 .unwrap()
1197 .write_image_data(&[255, 0, 0])
1198 .unwrap();
1199 }
1200 let message = json!({
1201 "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "mimeType": "image/png"}]
1202 });
1203 assert_eq!(
1204 assistant_blocks_from_value(&message),
1205 vec![AssistantBlock::Image(bytes.to_vec())]
1206 );
1207 let jpeg = rpi_tools::image_processing::process_image(
1208 &bytes,
1209 rpi_tools::image_processing::ImageProcessingOptions {
1210 format: Some(rpi_tools::image_processing::OutputFormat::Jpeg { quality: 85 }),
1211 ..Default::default()
1212 },
1213 )
1214 .unwrap();
1215 let blocks = assistant_blocks_from_value(&json!({
1216 "content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(jpeg), "mimeType": "image/jpeg"}]
1217 }));
1218 assert!(
1219 matches!(&blocks[0], AssistantBlock::Image(data) if data.starts_with(b"\x89PNG\r\n\x1a\n"))
1220 );
1221 }
1222
1223 #[test]
1224 fn tool_calls_extract_ids_names_and_args() {
1225 let message = json!({
1226 "content": [
1227 {"type": "text", "text": "x"},
1228 {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
1229 {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
1230 ]
1231 });
1232 assert_eq!(
1233 tool_calls_from_value(&message),
1234 vec![UiToolCall {
1235 id: "b1".into(),
1236 name: "bash".into(),
1237 args: json!({"command": "ls"}),
1238 }]
1239 );
1240 }
1241
1242 #[test]
1243 fn markdown_flag_is_opt_in() {
1244 assert!(tool_result_requests_markdown(Some(
1245 &json!({"markdown": true})
1246 )));
1247 assert!(!tool_result_requests_markdown(Some(
1248 &json!({"markdown": false})
1249 )));
1250 assert!(!tool_result_requests_markdown(Some(&json!({}))));
1251 assert!(!tool_result_requests_markdown(None));
1252 }
1253
1254 #[test]
1255 fn skill_tool_name_only_matches_skill_reads() {
1256 assert_eq!(
1257 skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
1258 Some("foo".to_string())
1259 );
1260 assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
1261 assert_eq!(
1262 skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
1263 None
1264 );
1265 }
1266
1267 #[test]
1268 fn ask_user_args_display_never_leaks_transport_json() {
1269 let text = ask_user_args_display(&json!({
1270 "question": "你的代理端口是多少?",
1271 "context": "本机",
1272 "options": ["7890", {"title": "7897", "description": "clash"}],
1273 "suggest": "1080"
1274 }));
1275 assert!(text.contains("你的代理端口是多少?"));
1276 assert!(text.contains("本机"));
1277 assert!(text.contains("Choices: 7890, 7897"));
1278 assert!(text.contains("Suggestions: 1080"));
1279 assert!(!text.trim_start().starts_with('{'));
1280 assert_eq!(
1281 ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
1282 "Confirmation requested: Deploy?"
1283 );
1284 }
1285
1286 #[test]
1287 fn tool_start_routes_bash_to_its_own_map() {
1288 let view = view();
1289 view.apply(
1290 &UiEvent::ToolStart {
1291 id: "b1".into(),
1292 name: "bash".into(),
1293 args: json!({"command": "ls -la"}),
1294 },
1295 80,
1296 );
1297 view.apply(
1298 &UiEvent::ToolStart {
1299 id: "r1".into(),
1300 name: "read".into(),
1301 args: json!({"path": "a.txt"}),
1302 },
1303 80,
1304 );
1305 assert!(view.bash_components.lock().unwrap().contains_key("b1"));
1306 assert!(view.tool_components.lock().unwrap().contains_key("r1"));
1307 assert!(view.tool_components.lock().unwrap().get("b1").is_none());
1308 }
1309
1310 #[test]
1311 fn bash_start_without_a_command_is_deferred() {
1312 let view = view();
1313 view.apply(
1314 &UiEvent::ToolStart {
1315 id: "b1".into(),
1316 name: "bash".into(),
1317 args: json!({}),
1318 },
1319 80,
1320 );
1321 assert!(view.bash_components.lock().unwrap().is_empty());
1322 assert!(view.tool_components.lock().unwrap().is_empty());
1323 }
1324
1325 #[test]
1326 fn assistant_stream_reuses_one_component() {
1327 let view = view();
1328 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
1329 view.apply(
1330 &UiEvent::AssistantUpdate {
1331 blocks: vec![AssistantBlock::Text("hi".into())],
1332 tool_calls: vec![],
1333 },
1334 80,
1335 );
1336 assert!(view.current_assistant.lock().unwrap().is_some());
1337 }
1338
1339 #[test]
1340 fn toggle_expanded_round_trips() {
1341 let view = view();
1342 assert!(!view.expanded());
1343 assert!(view.toggle_expanded());
1344 assert!(view.expanded());
1345 }
1346
1347 #[test]
1348 fn todo_updates_share_one_panel_and_preserve_errors() {
1349 let view = view();
1350 for (id, done) in [("a", false), ("b", true)] {
1351 view.sync_tool_calls(&[UiToolCall {
1352 id: id.into(),
1353 name: "todo".into(),
1354 args: json!({"action":"toggle"}),
1355 }]);
1356 view.tool_start(id, "todo", &json!({}), 80);
1357 view.tool_end(id, "todo", &json!({"content":[],"details":{"todos":[{"id":1,"text":"Run tests","done":done}]}}), false, 80);
1358 }
1359 assert_eq!(view.chat.child_count(), 1);
1360 assert!(view.tool_components.lock().unwrap().is_empty());
1361 let children = view.chat.get_children();
1362 let panel = children[0]
1363 .as_any()
1364 .downcast_ref::<ToolExecutionComponent>()
1365 .unwrap();
1366 let rendered = rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n"));
1367 assert!(rendered.contains("1/1 completed"));
1368 assert!(rendered.contains("[✓] Run tests"));
1369 view.tool_end(
1370 "error",
1371 "todo",
1372 &json!({"content":[{"type":"text","text":"invalid task"}]}),
1373 true,
1374 80,
1375 );
1376 assert_eq!(view.chat.child_count(), 2);
1377 view.tool_end("soft-error", "todo", &json!({"content":[{"type":"text","text":"todo not found"}],"details":{"todos":[],"error":"todo not found"}}), false, 80);
1378 assert_eq!(view.chat.child_count(), 3);
1379 assert!(update_todo_list(
1380 &view.chat,
1381 &json!({"details":{"todos":[]}})
1382 ));
1383 assert_eq!(view.chat.child_count(), 3);
1384 assert!(!update_todo_list(
1385 &view.chat,
1386 &json!({"details":{"todos":[{}]}})
1387 ));
1388 assert_eq!(view.chat.child_count(), 3);
1389 }
1390
1391 #[test]
1392 fn completed_plan_keeps_its_transcript_position() {
1393 let view = view();
1394 view.apply(
1395 &UiEvent::AssistantStart {
1396 blocks: vec![AssistantBlock::Text("planning context".into())],
1397 },
1398 80,
1399 );
1400 view.apply(
1401 &UiEvent::AssistantEnd {
1402 blocks: vec![AssistantBlock::Text("planning context".into())],
1403 },
1404 80,
1405 );
1406 view.apply(
1407 &UiEvent::ToolEnd {
1408 id: "start".into(),
1409 name: "plan_mode_start".into(),
1410 result: json!({"details":{"kind":"plan","active":true}}),
1411 is_error: false,
1412 },
1413 80,
1414 );
1415 view.apply(
1416 &UiEvent::AssistantStart {
1417 blocks: vec![AssistantBlock::Text("repository findings".into())],
1418 },
1419 80,
1420 );
1421 view.apply(
1422 &UiEvent::AssistantEnd {
1423 blocks: vec![AssistantBlock::Text("repository findings".into())],
1424 },
1425 80,
1426 );
1427
1428 view.apply(
1429 &UiEvent::ToolEnd {
1430 id: "complete".into(),
1431 name: "plan_mode_complete".into(),
1432 result: json!({
1433 "details": {
1434 "kind": "plan",
1435 "active": false,
1436 "plan": "1. Keep the plan in place"
1437 }
1438 }),
1439 is_error: false,
1440 },
1441 80,
1442 );
1443
1444 let lines: Vec<_> = view
1445 .chat
1446 .render(80)
1447 .iter()
1448 .map(|line| strip_ansi(line))
1449 .collect();
1450 let plan = lines
1451 .iter()
1452 .position(|line| line.contains("Plan · Ready"))
1453 .expect("completed plan panel should render");
1454 let findings = lines
1455 .iter()
1456 .position(|line| line.contains("repository findings"))
1457 .expect("later assistant output should render");
1458 assert!(
1459 plan < findings,
1460 "plan completion must not move the plan below later transcript output: {lines:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn plan_tools_and_show_update_one_expandable_panel() {
1466 let view = view();
1467 view.sync_tool_calls(&[UiToolCall {
1468 id: "start".into(),
1469 name: "plan_mode_start".into(),
1470 args: json!({}),
1471 }]);
1472 view.tool_start("start", "plan_mode_start", &json!({}), 80);
1473 view.tool_end(
1474 "start",
1475 "plan_mode_start",
1476 &json!({"details":{"kind":"plan","active":true}}),
1477 false,
1478 80,
1479 );
1480 let body = (1..=30)
1481 .map(|i| format!("{i}. Step {i}"))
1482 .collect::<Vec<_>>()
1483 .join("\n");
1484 view.tool_end(
1485 "complete",
1486 "plan_mode_complete",
1487 &json!({"details":{"kind":"plan","active":false,"plan":body}}),
1488 false,
1489 80,
1490 );
1491 assert_eq!(view.chat.child_count(), 1);
1492 assert!(view.tool_components.lock().unwrap().is_empty());
1493 view.set_expanded(true);
1494 let children = view.chat.get_children();
1495 let panel = children[0]
1496 .as_any()
1497 .downcast_ref::<ToolExecutionComponent>()
1498 .unwrap();
1499 assert!(rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n")).contains("Step 30"));
1500 assert!(update_plan_panel(
1501 &view.chat,
1502 &json!({"kind":"message","details":{"kind":"plan","active":false,"plan":body,"expanded":true}})
1503 ));
1504 assert_eq!(view.chat.child_count(), 1);
1505 view.tool_end(
1506 "error",
1507 "plan_mode_complete",
1508 &json!({"content":[{"type":"text","text":"not active"}]}),
1509 true,
1510 80,
1511 );
1512 assert_eq!(view.chat.child_count(), 2);
1513 }
1514}