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 chat: Arc<Container>,
88 current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
89 tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
90 bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
94 hide_thinking: Arc<Mutex<bool>>,
96 expanded: Arc<Mutex<bool>>,
98 markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
102}
103
104pub type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
107
108impl TranscriptView {
109 pub fn new(chat: Arc<Container>) -> Self {
111 Self::with_maps(
112 chat,
113 Arc::new(Mutex::new(None)),
114 Arc::new(Mutex::new(HashMap::new())),
115 Arc::new(Mutex::new(HashMap::new())),
116 Arc::new(Mutex::new(false)),
117 Arc::new(Mutex::new(false)),
118 Arc::new(Mutex::new(None)),
119 )
120 }
121
122 #[allow(clippy::too_many_arguments)]
126 pub fn with_maps(
127 chat: Arc<Container>,
128 current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
129 tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
130 bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
131 hide_thinking: Arc<Mutex<bool>>,
132 expanded: Arc<Mutex<bool>>,
133 markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
134 ) -> Self {
135 Self {
136 chat,
137 current_assistant,
138 tool_components,
139 bash_components,
140 hide_thinking,
141 expanded,
142 markdown_transformer,
143 }
144 }
145
146 pub fn add_user(&self, text: &str) {
148 self.chat
149 .add_child(Arc::new(UserMessageComponent::new(text.to_string())));
150 self.chat.add_child(Arc::new(Spacer::new(1)));
151 }
152
153 pub fn add_notice(&self, text: &str) {
155 self.chat
156 .add_child(Arc::new(rpi_tui::Text::new(format!("· {text}"), 1, 0)));
157 }
158
159 pub fn apply(&self, event: &UiEvent, width: usize) {
162 match event {
163 UiEvent::AgentStart => {}
164 UiEvent::AgentEnd => {
165 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
166 comp.set_streaming(false);
167 }
168 }
169 UiEvent::Retry {
170 attempt,
171 max_retries,
172 delay_ms,
173 error,
174 } => {
175 self.add_notice(&format!(
176 "retry {attempt}/{max_retries} in {delay_ms}ms: {error}"
177 ));
178 }
179 UiEvent::AssistantStart { blocks } => {
180 self.start_assistant(blocks);
181 }
182 UiEvent::AssistantUpdate { blocks, tool_calls } => {
183 self.ensure_assistant();
184 self.sync_tool_calls(tool_calls);
185 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
186 comp.update_blocks(blocks);
187 }
188 }
189 UiEvent::AssistantEnd { blocks } => {
190 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
191 comp.update_blocks(blocks);
192 comp.set_streaming(false);
193 }
194 }
195 UiEvent::ToolStart { id, name, args } => self.tool_start(id, name, args, width),
196 UiEvent::ToolUpdate {
197 id,
198 name,
199 args,
200 result,
201 } => self.tool_update(id, name, args, result, width),
202 UiEvent::ToolEnd {
203 id,
204 name,
205 result,
206 is_error,
207 } => self.tool_end(id, name, result, *is_error, width),
208 }
209 }
210
211 pub fn set_expanded(&self, expanded: bool) {
214 *self.expanded.lock().unwrap() = expanded;
215 for comp in self.tool_components.lock().unwrap().values() {
216 comp.set_expanded(expanded);
217 }
218 for comp in self.bash_components.lock().unwrap().values() {
219 comp.set_expanded(expanded);
220 }
221 }
222
223 pub fn toggle_expanded(&self) -> bool {
225 let next = !*self.expanded.lock().unwrap();
226 self.set_expanded(next);
227 next
228 }
229
230 pub fn expanded(&self) -> bool {
232 *self.expanded.lock().unwrap()
233 }
234
235 pub fn set_hide_thinking(&self, hide: bool) {
237 *self.hide_thinking.lock().unwrap() = hide;
238 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
239 comp.set_hide_thinking(hide);
240 }
241 }
242
243 pub fn toggle_hide_thinking(&self) -> bool {
245 let next = !*self.hide_thinking.lock().unwrap();
246 self.set_hide_thinking(next);
247 next
248 }
249
250 pub fn hide_thinking(&self) -> bool {
252 *self.hide_thinking.lock().unwrap()
253 }
254
255 pub fn set_markdown_transformer(&self, transformer: Option<MarkdownTransformer>) {
258 *self.markdown_transformer.lock().unwrap() = transformer.clone();
259 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
260 comp.set_markdown_transformer(transformer);
261 }
262 }
263
264 pub fn finalize_assistant(&self) {
266 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
267 comp.set_streaming(false);
268 }
269 }
270
271 pub fn finalize_assistant_blocks(&self, blocks: &[AssistantBlock]) {
273 if let Some(comp) = self.current_assistant.lock().unwrap().take() {
274 comp.update_blocks(blocks);
275 comp.set_streaming(false);
276 }
277 }
278
279 pub fn has_running_tool(&self) -> bool {
281 self.tool_components
282 .lock()
283 .unwrap()
284 .values()
285 .any(|component| component.status() == ToolStatus::Running)
286 }
287
288 pub fn has_active_bash(&self) -> bool {
290 !self.bash_components.lock().unwrap().is_empty()
291 }
292
293 fn start_assistant(&self, blocks: &[AssistantBlock]) {
294 let comp = Arc::new(AssistantMessageComponent::new(
295 AssistantMessageOptions::default(),
296 ));
297 comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
298 if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
299 comp.set_markdown_transformer(Some(transformer));
300 }
301 comp.set_streaming(true);
302 comp.update_blocks(blocks);
303 self.chat.add_child(comp.clone());
304 self.chat.add_child(Arc::new(Spacer::new(1)));
305 *self.current_assistant.lock().unwrap() = Some(comp);
306 }
307
308 fn ensure_assistant(&self) {
311 let mut current = self.current_assistant.lock().unwrap();
312 if current.is_none() {
313 let comp = Arc::new(AssistantMessageComponent::new(
314 AssistantMessageOptions::default(),
315 ));
316 comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
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 self.chat.add_child(comp.clone());
322 self.chat.add_child(Arc::new(Spacer::new(1)));
323 *current = Some(comp);
324 }
325 }
326
327 fn sync_tool_calls(&self, calls: &[UiToolCall]) {
330 for call in calls {
331 if call.name == "bash" {
332 let command = call
333 .args
334 .get("command")
335 .and_then(Value::as_str)
336 .unwrap_or("");
337 if command.trim().is_empty() {
338 continue;
339 }
340 let mut bash = self.bash_components.lock().unwrap();
341 if let Some(existing) = bash.get(&call.id) {
342 existing.set_command(command);
343 } else {
344 let comp = Arc::new(BashExecutionComponent::new(command));
345 comp.set_expanded(self.expanded());
346 self.chat.add_child(comp.clone());
347 bash.insert(call.id.clone(), comp);
348 }
349 continue;
350 }
351 let ask_user = is_ask_user_tool(&call.name);
352 let display = if ask_user {
353 ask_user_args_display(&call.args)
354 } else {
355 call.args.to_string()
356 };
357 let mut tools = self.tool_components.lock().unwrap();
358 if let Some(existing) = tools.get(&call.id) {
359 if !display.trim().is_empty() && display.trim() != "{}" {
360 existing.set_args(&display);
361 }
362 } else {
363 let comp = Arc::new(ToolExecutionComponent::new(&call.name, &display));
364 if ask_user {
365 comp.set_display_title("ASK USER");
366 }
367 if let Some(skill) = skill_tool_name(&call.name, &call.args) {
368 comp.set_skill_name(skill);
369 }
370 comp.set_expanded(self.expanded());
371 comp.set_running();
372 self.chat.add_child(comp.clone());
373 tools.insert(call.id.clone(), comp);
374 }
375 }
376 }
377
378 fn tool_start(&self, id: &str, name: &str, args: &Value, _width: usize) {
379 if name.trim().is_empty() {
380 return;
381 }
382 if name == "bash" {
383 let command = args.get("command").and_then(Value::as_str).unwrap_or("");
384 if command.trim().is_empty() {
387 return;
388 }
389 let mut bash = self.bash_components.lock().unwrap();
390 if let Some(existing) = bash.get(id) {
391 existing.set_command(command);
392 } else {
393 let comp = Arc::new(BashExecutionComponent::new(command));
394 comp.set_expanded(self.expanded());
395 self.chat.add_child(comp.clone());
396 bash.insert(id.to_string(), comp);
397 }
398 return;
399 }
400 let ask_user = is_ask_user_tool(name);
401 let display = if ask_user {
402 ask_user_args_display(args)
403 } else {
404 args.to_string()
405 };
406 let mut tools = self.tool_components.lock().unwrap();
407 if let Some(existing) = tools.get(id) {
408 if ask_user {
409 existing.set_display_title("ASK USER");
410 }
411 existing.set_args(&display);
412 } else {
413 let comp = Arc::new(ToolExecutionComponent::new(name, &display));
414 if ask_user {
415 comp.set_display_title("ASK USER");
416 }
417 if let Some(skill) = skill_tool_name(name, args) {
418 comp.set_skill_name(skill);
419 }
420 comp.set_expanded(self.expanded());
421 comp.set_running();
422 self.chat.add_child(comp.clone());
423 tools.insert(id.to_string(), comp);
424 }
425 }
426
427 fn tool_update(&self, id: &str, name: &str, args: &Value, result: &Value, width: usize) {
428 if name.trim().is_empty() {
429 return;
430 }
431 let ask_user = is_ask_user_tool(name);
432 let text = if ask_user {
433 ask_user_progress_text(args, result)
434 } else {
435 tool_result_text(result)
436 };
437 if name == "bash" {
438 if let Some(bash) = self.bash_components.lock().unwrap().get(id) {
439 if let Some(command) = args.get("command").and_then(Value::as_str) {
440 bash.set_command(command);
441 }
442 if !text.is_empty() {
443 bash.append_output(&text);
444 }
445 }
446 return;
447 }
448 let existing = self.tool_components.lock().unwrap().get(id).cloned();
449 match existing {
450 Some(comp) => {
451 if ask_user {
452 comp.set_display_title("ASK USER");
453 comp.set_args(&ask_user_args_display(args));
454 } else if !args.is_null() && args != &serde_json::json!({}) {
455 comp.set_args(&args.to_string());
456 }
457 if tool_update_has_payload(&text, result) {
458 comp.set_result(&text, false);
459 if !ask_user {
460 apply_edit_diff(&comp, name, result.get("details"), width);
461 }
462 }
463 }
464 None => {
465 if tool_update_has_payload(&text, result) {
468 let display = if ask_user {
469 ask_user_args_display(args)
470 } else {
471 args.to_string()
472 };
473 let comp = Arc::new(ToolExecutionComponent::new(name, &display));
474 if ask_user {
475 comp.set_display_title("ASK USER");
476 }
477 if let Some(skill) = skill_tool_name(name, args) {
478 comp.set_skill_name(skill);
479 }
480 comp.set_expanded(self.expanded());
481 comp.set_running();
482 comp.set_result(&text, false);
483 if !ask_user {
484 apply_edit_diff(&comp, name, result.get("details"), width);
485 }
486 self.chat.add_child(comp.clone());
487 self.tool_components
488 .lock()
489 .unwrap()
490 .insert(id.to_string(), comp);
491 }
492 }
493 }
494 }
495
496 fn tool_end(&self, id: &str, name: &str, result: &Value, is_error: bool, width: usize) {
497 if name.trim().is_empty() {
498 return;
499 }
500 if name == "bash" {
501 let bash = self.bash_components.lock().unwrap().remove(id);
502 match bash {
503 Some(comp) => finalize_bash(&comp, result, is_error),
504 None => {
505 let command = result
506 .get("details")
507 .and_then(|details| details.get("command"))
508 .and_then(Value::as_str)
509 .unwrap_or("");
510 let text = tool_result_text(result);
511 if command.trim().is_empty() && text.trim().is_empty() {
512 return;
513 }
514 let comp = Arc::new(BashExecutionComponent::new(command));
515 comp.set_expanded(self.expanded());
516 if !text.is_empty() {
517 comp.append_output(&text);
518 }
519 finalize_bash(&comp, result, is_error);
520 self.chat.add_child(comp);
521 }
522 }
523 return;
524 }
525 let ask_user = is_ask_user_tool(name);
526 let text = if ask_user {
527 ask_user_result_text(result)
528 } else {
529 tool_result_text(result)
530 };
531 let details = result.get("details");
532 let existing = self.tool_components.lock().unwrap().remove(id);
533 let comp = match existing {
534 Some(comp) => comp,
535 None => {
536 let comp = Arc::new(ToolExecutionComponent::new(name, ""));
538 if ask_user {
539 comp.set_display_title("ASK USER");
540 }
541 comp.set_expanded(self.expanded());
542 self.chat.add_child(comp.clone());
543 comp
544 }
545 };
546 comp.set_result(&text, is_error);
547 if !ask_user {
548 if tool_result_requests_markdown(details) {
549 comp.set_result_markdown(true);
550 }
551 apply_edit_diff(&comp, name, details, width);
552 }
553 }
554}
555
556pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
560 let mut blocks = Vec::new();
561 let Some(content) = message.get("content").and_then(Value::as_array) else {
562 return blocks;
563 };
564 for block in content {
565 match block.get("type").and_then(Value::as_str) {
566 Some("text") => {
567 if let Some(text) = block.get("text").and_then(Value::as_str) {
568 blocks.push(AssistantBlock::Text(text.to_string()));
569 }
570 }
571 Some("thinking") => {
572 if let Some(text) = block.get("thinking").and_then(Value::as_str) {
573 blocks.push(AssistantBlock::Thinking(text.to_string()));
574 }
575 }
576 Some("image") => {
577 if let Some(data) = block.get("data").and_then(Value::as_str) {
578 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
579 if !bytes.is_empty() {
580 blocks.push(AssistantBlock::Image(bytes));
581 }
582 }
583 }
584 }
585 _ => {}
586 }
587 }
588 blocks
589}
590
591pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
594 let mut calls = Vec::new();
595 let Some(content) = message.get("content").and_then(Value::as_array) else {
596 return calls;
597 };
598 for block in content {
599 if block.get("type").and_then(Value::as_str) != Some("toolCall") {
600 continue;
601 }
602 let id = block.get("id").and_then(Value::as_str).unwrap_or("");
603 let name = block.get("name").and_then(Value::as_str).unwrap_or("");
604 if id.is_empty() || name.trim().is_empty() {
605 continue;
606 }
607 calls.push(UiToolCall {
608 id: id.to_string(),
609 name: name.to_string(),
610 args: block.get("arguments").cloned().unwrap_or(Value::Null),
611 });
612 }
613 calls
614}
615
616pub fn tool_result_text(result: &Value) -> String {
618 let mut out = String::new();
619 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
620 for block in blocks {
621 if let Some(text) = block.get("text").and_then(Value::as_str) {
622 if !out.is_empty() {
623 out.push('\n');
624 }
625 out.push_str(text);
626 }
627 }
628 }
629 out
630}
631
632pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
635 details
636 .and_then(|details| details.get("markdown"))
637 .and_then(Value::as_bool)
638 .unwrap_or(false)
639}
640
641pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
645 if tool_name != "read" {
646 return None;
647 }
648 let path = args.get("path").and_then(Value::as_str)?;
649 let normalized = path.replace('\\', "/");
650 let file_name = normalized.rsplit('/').next()?;
651 if !file_name.eq_ignore_ascii_case("SKILL.md") {
652 return None;
653 }
654 normalized
655 .trim_end_matches('/')
656 .rsplit('/')
657 .nth(1)
658 .filter(|name| !name.is_empty())
659 .map(str::to_string)
660}
661
662fn is_ask_user_tool(tool_name: &str) -> bool {
665 matches!(tool_name, "ask_user" | "ask_user_question")
666}
667
668fn ask_user_args_display(args: &Value) -> String {
671 if args.is_null() {
672 return String::new();
673 }
674 if let Some(summary) = args
675 .get("summary")
676 .and_then(Value::as_str)
677 .map(str::trim)
678 .filter(|text| !text.is_empty())
679 {
680 return format!("Confirmation requested: {summary}");
681 }
682
683 let mut lines: Vec<String> = Vec::new();
684 let questions: Vec<&Value> = args
685 .get("questions")
686 .and_then(Value::as_array)
687 .filter(|items| !items.is_empty())
688 .map(|items| items.iter().collect())
689 .unwrap_or_else(|| vec![args]);
690
691 for question in questions {
692 let prompt = question
693 .get("question")
694 .and_then(Value::as_str)
695 .or_else(|| args.get("question").and_then(Value::as_str))
696 .map(str::trim)
697 .unwrap_or_default();
698 if prompt.is_empty() {
699 continue;
700 }
701 if let Some(header) = question
702 .get("header")
703 .and_then(Value::as_str)
704 .map(str::trim)
705 .filter(|text| !text.is_empty())
706 {
707 lines.push(format!("{header}: {prompt}"));
708 } else {
709 lines.push(prompt.to_string());
710 }
711 let context = question
712 .get("context")
713 .and_then(Value::as_str)
714 .or_else(|| args.get("context").and_then(Value::as_str))
715 .map(str::trim)
716 .filter(|text| !text.is_empty());
717 if let Some(context) = context {
718 lines.push(format!(" {context}"));
719 }
720 let options = question
721 .get("options")
722 .and_then(Value::as_array)
723 .or_else(|| args.get("options").and_then(Value::as_array));
724 if let Some(options) = options {
725 let labels: Vec<String> = options
726 .iter()
727 .filter_map(|option| {
728 option
729 .get("title")
730 .and_then(Value::as_str)
731 .or_else(|| option.as_str())
732 .map(str::trim)
733 .filter(|label| !label.is_empty())
734 .map(str::to_string)
735 })
736 .collect();
737 if !labels.is_empty() {
738 lines.push(format!("Choices: {}", labels.join(", ")));
739 }
740 }
741 if let Some(suggest) = question
742 .get("suggest")
743 .and_then(Value::as_str)
744 .or_else(|| args.get("suggest").and_then(Value::as_str))
745 .map(str::trim)
746 .filter(|text| !text.is_empty())
747 {
748 lines.push(format!("Suggestions: {suggest}"));
749 }
750 }
751
752 lines.join("\n")
753}
754
755fn ask_user_progress_text(args: &Value, result: &Value) -> String {
758 let text = tool_result_text(result);
759 if !text.trim().is_empty() {
760 return text;
761 }
762 let args_text = ask_user_args_display(args);
763 if args_text.trim().is_empty() {
764 "Waiting for your answer…".to_string()
765 } else {
766 args_text
767 }
768}
769
770fn ask_user_result_text(result: &Value) -> String {
772 let text = tool_result_text(result);
773 if text.trim().is_empty() {
774 "Answer recorded.".to_string()
775 } else {
776 text
777 }
778}
779
780fn tool_update_has_payload(text: &str, result: &Value) -> bool {
782 !text.trim().is_empty()
783 || result
784 .get("details")
785 .map(|details| !details.is_null())
786 .unwrap_or(false)
787}
788
789fn apply_edit_diff(
792 comp: &Arc<ToolExecutionComponent>,
793 tool_name: &str,
794 details: Option<&Value>,
795 width: usize,
796) {
797 if tool_name != "edit" {
798 return;
799 }
800 let Some(diff_text) = details
801 .and_then(|details| details.get("diff"))
802 .and_then(Value::as_str)
803 else {
804 return;
805 };
806 if diff_text.is_empty() {
807 return;
808 }
809 comp.set_diff(render_diff(diff_text, width));
810}
811
812fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
815 let details = result.get("details");
816 let exit_code = Some(if is_error { 1 } else { 0 });
817 let truncated = details
818 .and_then(|details| details.get("truncation"))
819 .and_then(|truncation| truncation.get("truncated"))
820 .and_then(Value::as_bool)
821 .unwrap_or(false);
822 let full_output_path = details
823 .and_then(|details| details.get("full_output_path"))
824 .and_then(Value::as_str)
825 .map(str::to_string);
826 if let Some(command) = details
827 .and_then(|details| details.get("command"))
828 .and_then(Value::as_str)
829 {
830 comp.set_command(command);
831 }
832 comp.set_complete(
833 exit_code,
834 false,
835 BashTruncation {
836 truncated,
837 full_output_path,
838 },
839 );
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use serde_json::json;
846
847 fn view() -> TranscriptView {
848 TranscriptView::new(Arc::new(Container::new()))
849 }
850
851 #[test]
852 fn assistant_blocks_follow_content_order() {
853 let message = json!({
854 "content": [
855 {"type": "thinking", "thinking": "why"},
856 {"type": "text", "text": "hello"},
857 {"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
858 ]
859 });
860 assert_eq!(
861 assistant_blocks_from_value(&message),
862 vec![
863 AssistantBlock::Thinking("why".into()),
864 AssistantBlock::Text("hello".into()),
865 ]
866 );
867 }
868
869 #[test]
870 fn assistant_blocks_decode_images() {
871 let message = json!({
873 "content": [{"type": "image", "data": "aGk=", "mimeType": "image/png"}]
874 });
875 assert_eq!(
876 assistant_blocks_from_value(&message),
877 vec![AssistantBlock::Image(b"hi".to_vec())]
878 );
879 }
880
881 #[test]
882 fn tool_calls_extract_ids_names_and_args() {
883 let message = json!({
884 "content": [
885 {"type": "text", "text": "x"},
886 {"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
887 {"type": "toolCall", "id": "", "name": "read", "arguments": {}},
888 ]
889 });
890 assert_eq!(
891 tool_calls_from_value(&message),
892 vec![UiToolCall {
893 id: "b1".into(),
894 name: "bash".into(),
895 args: json!({"command": "ls"}),
896 }]
897 );
898 }
899
900 #[test]
901 fn markdown_flag_is_opt_in() {
902 assert!(tool_result_requests_markdown(Some(
903 &json!({"markdown": true})
904 )));
905 assert!(!tool_result_requests_markdown(Some(
906 &json!({"markdown": false})
907 )));
908 assert!(!tool_result_requests_markdown(Some(&json!({}))));
909 assert!(!tool_result_requests_markdown(None));
910 }
911
912 #[test]
913 fn skill_tool_name_only_matches_skill_reads() {
914 assert_eq!(
915 skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
916 Some("foo".to_string())
917 );
918 assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
919 assert_eq!(
920 skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
921 None
922 );
923 }
924
925 #[test]
926 fn ask_user_args_display_never_leaks_transport_json() {
927 let text = ask_user_args_display(&json!({
928 "question": "你的代理端口是多少?",
929 "context": "本机",
930 "options": ["7890", {"title": "7897", "description": "clash"}],
931 "suggest": "1080"
932 }));
933 assert!(text.contains("你的代理端口是多少?"));
934 assert!(text.contains("本机"));
935 assert!(text.contains("Choices: 7890, 7897"));
936 assert!(text.contains("Suggestions: 1080"));
937 assert!(!text.trim_start().starts_with('{'));
938 assert_eq!(
939 ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
940 "Confirmation requested: Deploy?"
941 );
942 }
943
944 #[test]
945 fn tool_start_routes_bash_to_its_own_map() {
946 let view = view();
947 view.apply(
948 &UiEvent::ToolStart {
949 id: "b1".into(),
950 name: "bash".into(),
951 args: json!({"command": "ls -la"}),
952 },
953 80,
954 );
955 view.apply(
956 &UiEvent::ToolStart {
957 id: "r1".into(),
958 name: "read".into(),
959 args: json!({"path": "a.txt"}),
960 },
961 80,
962 );
963 assert!(view.bash_components.lock().unwrap().contains_key("b1"));
964 assert!(view.tool_components.lock().unwrap().contains_key("r1"));
965 assert!(view.tool_components.lock().unwrap().get("b1").is_none());
966 }
967
968 #[test]
969 fn bash_start_without_a_command_is_deferred() {
970 let view = view();
971 view.apply(
972 &UiEvent::ToolStart {
973 id: "b1".into(),
974 name: "bash".into(),
975 args: json!({}),
976 },
977 80,
978 );
979 assert!(view.bash_components.lock().unwrap().is_empty());
980 assert!(view.tool_components.lock().unwrap().is_empty());
981 }
982
983 #[test]
984 fn assistant_stream_reuses_one_component() {
985 let view = view();
986 view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
987 view.apply(
988 &UiEvent::AssistantUpdate {
989 blocks: vec![AssistantBlock::Text("hi".into())],
990 tool_calls: vec![],
991 },
992 80,
993 );
994 assert!(view.current_assistant.lock().unwrap().is_some());
995 }
996
997 #[test]
998 fn toggle_expanded_round_trips() {
999 let view = view();
1000 assert!(!view.expanded());
1001 assert!(view.toggle_expanded());
1002 assert!(view.expanded());
1003 }
1004}