syncable_cli/agent/ui/
hooks.rs

1//! Rig PromptHook implementations for Claude Code style UI
2//!
3//! Shows tool calls in a collapsible format:
4//! - `● tool_name(args...)` header with full command visible
5//! - Preview of output (first few lines)
6//! - `... +N lines` for long outputs
7//! - `└ Running...` while executing
8//! - Agent thinking shown between tool calls
9
10use crate::agent::ui::colors::ansi;
11use colored::Colorize;
12use rig::agent::CancelSignal;
13use rig::completion::{CompletionModel, CompletionResponse, Message};
14use rig::message::{AssistantContent, Reasoning};
15use std::io::{self, Write};
16use std::sync::Arc;
17use tokio::sync::Mutex;
18
19/// Maximum lines to show in preview before collapsing
20const PREVIEW_LINES: usize = 4;
21
22/// Tool call state with full output for expansion
23#[derive(Debug, Clone)]
24pub struct ToolCallState {
25    pub name: String,
26    pub args: String,
27    pub output: Option<String>,
28    pub output_lines: Vec<String>,
29    pub is_running: bool,
30    pub is_expanded: bool,
31    pub is_collapsible: bool,
32    pub status_ok: bool,
33}
34
35/// Shared state for the display
36#[derive(Debug, Default)]
37pub struct DisplayState {
38    pub tool_calls: Vec<ToolCallState>,
39    pub agent_messages: Vec<String>,
40    pub current_tool_index: Option<usize>,
41    pub last_expandable_index: Option<usize>,
42}
43
44/// A hook that shows Claude Code style tool execution
45#[derive(Clone)]
46pub struct ToolDisplayHook {
47    state: Arc<Mutex<DisplayState>>,
48}
49
50impl ToolDisplayHook {
51    pub fn new() -> Self {
52        Self {
53            state: Arc::new(Mutex::new(DisplayState::default())),
54        }
55    }
56
57    /// Get the shared state for external access
58    pub fn state(&self) -> Arc<Mutex<DisplayState>> {
59        self.state.clone()
60    }
61}
62
63impl Default for ToolDisplayHook {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
70where
71    M: CompletionModel,
72{
73    fn on_tool_call(
74        &self,
75        tool_name: &str,
76        args: &str,
77        _cancel: CancelSignal,
78    ) -> impl std::future::Future<Output = ()> + Send {
79        let state = self.state.clone();
80        let name = tool_name.to_string();
81        let args_str = args.to_string();
82
83        async move {
84            // Print tool header
85            print_tool_header(&name, &args_str);
86
87            // Store in state
88            let mut s = state.lock().await;
89            let idx = s.tool_calls.len();
90            s.tool_calls.push(ToolCallState {
91                name,
92                args: args_str,
93                output: None,
94                output_lines: Vec::new(),
95                is_running: true,
96                is_expanded: false,
97                is_collapsible: false,
98                status_ok: true,
99            });
100            s.current_tool_index = Some(idx);
101        }
102    }
103
104    fn on_tool_result(
105        &self,
106        tool_name: &str,
107        args: &str,
108        result: &str,
109        _cancel: CancelSignal,
110    ) -> impl std::future::Future<Output = ()> + Send {
111        let state = self.state.clone();
112        let name = tool_name.to_string();
113        let args_str = args.to_string();
114        let result_str = result.to_string();
115
116        async move {
117            // Print tool result and get the output info
118            let (status_ok, output_lines, is_collapsible) = print_tool_result(&name, &args_str, &result_str);
119
120            // Update state
121            let mut s = state.lock().await;
122            if let Some(idx) = s.current_tool_index {
123                if let Some(tool) = s.tool_calls.get_mut(idx) {
124                    tool.output = Some(result_str);
125                    tool.output_lines = output_lines;
126                    tool.is_running = false;
127                    tool.is_collapsible = is_collapsible;
128                    tool.status_ok = status_ok;
129                }
130                // Track last expandable output
131                if is_collapsible {
132                    s.last_expandable_index = Some(idx);
133                }
134            }
135            s.current_tool_index = None;
136        }
137    }
138
139    fn on_completion_response(
140        &self,
141        _prompt: &Message,
142        response: &CompletionResponse<M::Response>,
143        _cancel: CancelSignal,
144    ) -> impl std::future::Future<Output = ()> + Send {
145        let state = self.state.clone();
146
147        // Check if response contains tool calls - if so, any text is "thinking"
148        // If no tool calls, this is the final response - don't show as thinking
149        let has_tool_calls = response.choice.iter().any(|content| {
150            matches!(content, AssistantContent::ToolCall(_))
151        });
152
153        // Extract reasoning content (GPT-5.2 thinking summaries)
154        let reasoning_parts: Vec<String> = response.choice.iter()
155            .filter_map(|content| {
156                if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
157                    // Join all reasoning strings
158                    let text = reasoning.iter().cloned().collect::<Vec<_>>().join("\n");
159                    if !text.trim().is_empty() {
160                        Some(text)
161                    } else {
162                        None
163                    }
164                } else {
165                    None
166                }
167            })
168            .collect();
169
170        // Extract text content from the response (for non-reasoning models)
171        let text_parts: Vec<String> = response.choice.iter()
172            .filter_map(|content| {
173                if let AssistantContent::Text(text) = content {
174                    // Filter out empty or whitespace-only text
175                    let trimmed = text.text.trim();
176                    if !trimmed.is_empty() {
177                        Some(trimmed.to_string())
178                    } else {
179                        None
180                    }
181                } else {
182                    None
183                }
184            })
185            .collect();
186
187        async move {
188            // First, show reasoning content if available (GPT-5.2 thinking)
189            if !reasoning_parts.is_empty() {
190                let thinking_text = reasoning_parts.join("\n");
191
192                // Store in state for history tracking
193                let mut s = state.lock().await;
194                s.agent_messages.push(thinking_text.clone());
195                drop(s);
196
197                // Display reasoning as thinking
198                print_agent_thinking(&thinking_text);
199            }
200
201            // Also show text content if it's intermediate (has tool calls)
202            // but NOT if it's the final response
203            if !text_parts.is_empty() && has_tool_calls {
204                let thinking_text = text_parts.join("\n");
205
206                // Store in state for history tracking
207                let mut s = state.lock().await;
208                s.agent_messages.push(thinking_text.clone());
209                drop(s);
210
211                // Display as thinking
212                print_agent_thinking(&thinking_text);
213            }
214        }
215    }
216}
217
218/// Print agent thinking/reasoning text with nice formatting
219fn print_agent_thinking(text: &str) {
220    use crate::agent::ui::response::brand;
221
222    println!();
223
224    // Print thinking header in peach/coral
225    println!(
226        "{}{}  💭 Thinking...{}",
227        brand::CORAL,
228        brand::ITALIC,
229        brand::RESET
230    );
231
232    // Format the content with markdown support
233    let mut in_code_block = false;
234
235    for line in text.lines() {
236        let trimmed = line.trim();
237
238        // Handle code blocks
239        if trimmed.starts_with("```") {
240            if in_code_block {
241                println!("{}  └────────────────────────────────────────────────────────┘{}", brand::LIGHT_PEACH, brand::RESET);
242                in_code_block = false;
243            } else {
244                let lang = trimmed.strip_prefix("```").unwrap_or("");
245                let lang_display = if lang.is_empty() { "code" } else { lang };
246                println!(
247                    "{}  ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
248                    brand::LIGHT_PEACH, brand::CYAN, lang_display, brand::LIGHT_PEACH, brand::RESET
249                );
250                in_code_block = true;
251            }
252            continue;
253        }
254
255        if in_code_block {
256            println!("{}  │ {}{}{}  │", brand::LIGHT_PEACH, brand::CYAN, line, brand::RESET);
257            continue;
258        }
259
260        // Handle bullet points
261        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
262            let content = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).unwrap_or(trimmed);
263            println!("{}  {} {}{}", brand::PEACH, "•", format_thinking_inline(content), brand::RESET);
264            continue;
265        }
266
267        // Handle numbered lists
268        if trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
269            && trimmed.chars().nth(1) == Some('.')
270        {
271            println!("{}  {}{}", brand::PEACH, format_thinking_inline(trimmed), brand::RESET);
272            continue;
273        }
274
275        // Regular text with inline formatting
276        if trimmed.is_empty() {
277            println!();
278        } else {
279            // Word wrap long lines
280            let wrapped = wrap_text(trimmed, 76);
281            for wrapped_line in wrapped {
282                println!("{}  {}{}", brand::PEACH, format_thinking_inline(&wrapped_line), brand::RESET);
283            }
284        }
285    }
286
287    println!();
288    let _ = io::stdout().flush();
289}
290
291/// Format inline elements in thinking text (code, bold)
292fn format_thinking_inline(text: &str) -> String {
293    use crate::agent::ui::response::brand;
294
295    let mut result = String::new();
296    let chars: Vec<char> = text.chars().collect();
297    let mut i = 0;
298
299    while i < chars.len() {
300        // Handle `code`
301        if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') {
302            if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
303                let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
304                result.push_str(brand::CYAN);
305                result.push('`');
306                result.push_str(&code_text);
307                result.push('`');
308                result.push_str(brand::RESET);
309                result.push_str(brand::PEACH);
310                i = i + 2 + end;
311                continue;
312            }
313        }
314
315        // Handle **bold**
316        if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
317            if let Some(end_offset) = find_double_star(&chars, i + 2) {
318                let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
319                result.push_str(brand::RESET);
320                result.push_str(brand::CORAL);
321                result.push_str(brand::BOLD);
322                result.push_str(&bold_text);
323                result.push_str(brand::RESET);
324                result.push_str(brand::PEACH);
325                i = i + 4 + end_offset;
326                continue;
327            }
328        }
329
330        result.push(chars[i]);
331        i += 1;
332    }
333
334    result
335}
336
337/// Find closing ** marker
338fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
339    for i in start..chars.len().saturating_sub(1) {
340        if chars[i] == '*' && chars[i + 1] == '*' {
341            return Some(i - start);
342        }
343    }
344    None
345}
346
347/// Simple word wrap helper
348fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
349    if text.len() <= max_width {
350        return vec![text.to_string()];
351    }
352
353    let mut lines = Vec::new();
354    let mut current_line = String::new();
355
356    for word in text.split_whitespace() {
357        if current_line.is_empty() {
358            current_line = word.to_string();
359        } else if current_line.len() + 1 + word.len() <= max_width {
360            current_line.push(' ');
361            current_line.push_str(word);
362        } else {
363            lines.push(current_line);
364            current_line = word.to_string();
365        }
366    }
367
368    if !current_line.is_empty() {
369        lines.push(current_line);
370    }
371
372    if lines.is_empty() {
373        lines.push(text.to_string());
374    }
375
376    lines
377}
378
379/// Print tool call header in Claude Code style
380fn print_tool_header(name: &str, args: &str) {
381    let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
382    let args_display = format_args_display(name, &parsed);
383
384    // Print header with yellow dot (running)
385    if args_display.is_empty() {
386        println!("\n{} {}", "●".yellow(), name.cyan().bold());
387    } else {
388        println!("\n{} {}({})", "●".yellow(), name.cyan().bold(), args_display.dimmed());
389    }
390
391    // Print running indicator
392    println!("  {} {}", "└".dimmed(), "Running...".dimmed());
393
394    let _ = io::stdout().flush();
395}
396
397/// Print tool result with preview and collapse
398/// Returns (status_ok, output_lines, is_collapsible)
399fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
400    // Clear the "Running..." line
401    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
402    let _ = io::stdout().flush();
403
404    // Parse the result - handle potential double-encoding from Rig
405    let parsed: Result<serde_json::Value, _> = serde_json::from_str(result)
406        .map(|v: serde_json::Value| {
407            // If the parsed value is a string, it might be double-encoded JSON
408            // Try to parse the inner string, but fall back to original if it fails
409            if let Some(inner_str) = v.as_str() {
410                serde_json::from_str(inner_str).unwrap_or(v)
411            } else {
412                v
413            }
414        });
415
416    // Format output based on tool type
417    let (status_ok, output_lines) = match name {
418        "shell" => format_shell_result(&parsed),
419        "write_file" | "write_files" => format_write_result(&parsed),
420        "read_file" => format_read_result(&parsed),
421        "list_directory" => format_list_result(&parsed),
422        "analyze_project" => format_analyze_result(&parsed),
423        "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
424        _ => (true, vec!["done".to_string()]),
425    };
426
427    // Clear the header line to update dot color
428    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
429
430    // Reprint header with green/red dot and args
431    let dot = if status_ok { "●".green() } else { "●".red() };
432
433    // Format args for display (same logic as print_tool_header)
434    let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
435    let args_display = format_args_display(name, &args_parsed);
436
437    if args_display.is_empty() {
438        println!("{} {}", dot, name.cyan().bold());
439    } else {
440        println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
441    }
442
443    // Print output preview
444    let total_lines = output_lines.len();
445    let is_collapsible = total_lines > PREVIEW_LINES;
446
447    for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
448        let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
449            "└"
450        } else {
451            "│"
452        };
453        println!("  {} {}", prefix.dimmed(), line);
454    }
455
456    // Show collapse indicator if needed
457    if is_collapsible {
458        println!(
459            "  {} {}",
460            "└".dimmed(),
461            format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
462        );
463    }
464
465    let _ = io::stdout().flush();
466    (status_ok, output_lines, is_collapsible)
467}
468
469/// Format args for display based on tool type
470fn format_args_display(name: &str, parsed: &Result<serde_json::Value, serde_json::Error>) -> String {
471    match name {
472        "shell" => {
473            if let Ok(v) = parsed {
474                v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string()
475            } else {
476                String::new()
477            }
478        }
479        "write_file" => {
480            if let Ok(v) = parsed {
481                v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
482            } else {
483                String::new()
484            }
485        }
486        "write_files" => {
487            if let Ok(v) = parsed {
488                if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
489                    let paths: Vec<&str> = files
490                        .iter()
491                        .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
492                        .take(3)
493                        .collect();
494                    let more = if files.len() > 3 {
495                        format!(", +{} more", files.len() - 3)
496                    } else {
497                        String::new()
498                    };
499                    format!("{}{}", paths.join(", "), more)
500                } else {
501                    String::new()
502                }
503            } else {
504                String::new()
505            }
506        }
507        "read_file" => {
508            if let Ok(v) = parsed {
509                v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
510            } else {
511                String::new()
512            }
513        }
514        "list_directory" => {
515            if let Ok(v) = parsed {
516                v.get("path").and_then(|p| p.as_str()).unwrap_or(".").to_string()
517            } else {
518                ".".to_string()
519            }
520        }
521        _ => String::new(),
522    }
523}
524
525/// Format shell command result
526fn format_shell_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
527    if let Ok(v) = parsed {
528        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
529        let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
530        let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
531        let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
532
533        let mut lines = Vec::new();
534
535        // Add stdout lines
536        for line in stdout.lines() {
537            if !line.trim().is_empty() {
538                lines.push(line.to_string());
539            }
540        }
541
542        // Add stderr lines if failed
543        if !success {
544            for line in stderr.lines() {
545                if !line.trim().is_empty() {
546                    lines.push(format!("{}", line.red()));
547                }
548            }
549            if let Some(code) = exit_code {
550                lines.push(format!("exit code: {}", code).red().to_string());
551            }
552        }
553
554        if lines.is_empty() {
555            lines.push(if success { "completed".to_string() } else { "failed".to_string() });
556        }
557
558        (success, lines)
559    } else {
560        (false, vec!["parse error".to_string()])
561    }
562}
563
564/// Format write file result
565fn format_write_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
566    if let Ok(v) = parsed {
567        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
568        let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
569        let lines_written = v.get("lines_written")
570            .or_else(|| v.get("total_lines"))
571            .and_then(|n| n.as_u64())
572            .unwrap_or(0);
573        let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
574
575        let msg = if files_written > 1 {
576            format!("{} {} files ({} lines)", action, files_written, lines_written)
577        } else {
578            format!("{} ({} lines)", action, lines_written)
579        };
580
581        (success, vec![msg])
582    } else {
583        (false, vec!["write failed".to_string()])
584    }
585}
586
587/// Format read file result
588fn format_read_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
589    if let Ok(v) = parsed {
590        // Handle error field
591        if v.get("error").is_some() {
592            let error_msg = v.get("error").and_then(|e| e.as_str()).unwrap_or("file not found");
593            return (false, vec![error_msg.to_string()]);
594        }
595
596        // Try to get total_lines from object
597        if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
598            let msg = if total_lines == 1 {
599                "read 1 line".to_string()
600            } else {
601                format!("read {} lines", total_lines)
602            };
603            return (true, vec![msg]);
604        }
605
606        // Fallback: if we have a string value (failed inner parse) or missing fields,
607        // try to extract line count from content or just say "read"
608        if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
609            let lines = content.lines().count();
610            return (true, vec![format!("read {} lines", lines)]);
611        }
612
613        // Last resort: check if it's a string (double-encoding fallback)
614        if v.is_string() {
615            // The inner JSON couldn't be parsed, but we got something
616            return (true, vec!["read file".to_string()]);
617        }
618
619        (true, vec!["read file".to_string()])
620    } else {
621        (false, vec!["read failed".to_string()])
622    }
623}
624
625/// Format list directory result
626fn format_list_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
627    if let Ok(v) = parsed {
628        let entries = v.get("entries").and_then(|e| e.as_array());
629
630        let mut lines = Vec::new();
631
632        if let Some(entries) = entries {
633            let total = entries.len();
634            for entry in entries.iter().take(PREVIEW_LINES + 2) {
635                let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
636                let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
637                let prefix = if entry_type == "directory" { "📁" } else { "📄" };
638                lines.push(format!("{} {}", prefix, name));
639            }
640            // Add count if there are more entries than shown
641            if total > PREVIEW_LINES + 2 {
642                lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
643            }
644        }
645
646        if lines.is_empty() {
647            lines.push("empty directory".to_string());
648        }
649
650        (true, lines)
651    } else {
652        (false, vec!["parse error".to_string()])
653    }
654}
655
656/// Format analyze result
657fn format_analyze_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
658    if let Ok(v) = parsed {
659        let mut lines = Vec::new();
660
661        // Languages
662        if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
663            let lang_names: Vec<&str> = langs
664                .iter()
665                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
666                .take(5)
667                .collect();
668            if !lang_names.is_empty() {
669                lines.push(format!("Languages: {}", lang_names.join(", ")));
670            }
671        }
672
673        // Frameworks
674        if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
675            let fw_names: Vec<&str> = frameworks
676                .iter()
677                .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
678                .take(5)
679                .collect();
680            if !fw_names.is_empty() {
681                lines.push(format!("Frameworks: {}", fw_names.join(", ")));
682            }
683        }
684
685        if lines.is_empty() {
686            lines.push("analysis complete".to_string());
687        }
688
689        (true, lines)
690    } else {
691        (false, vec!["parse error".to_string()])
692    }
693}
694
695/// Format security scan result
696fn format_security_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
697    if let Ok(v) = parsed {
698        let findings = v.get("findings")
699            .or_else(|| v.get("vulnerabilities"))
700            .and_then(|f| f.as_array())
701            .map(|a| a.len())
702            .unwrap_or(0);
703
704        if findings == 0 {
705            (true, vec!["no issues found".to_string()])
706        } else {
707            (false, vec![format!("{} issues found", findings)])
708        }
709    } else {
710        (false, vec!["parse error".to_string()])
711    }
712}
713
714// Legacy exports for compatibility
715pub use crate::agent::ui::Spinner;
716use tokio::sync::mpsc;
717
718/// Events for backward compatibility
719#[derive(Debug, Clone)]
720pub enum ToolEvent {
721    ToolStart { name: String, args: String },
722    ToolComplete { name: String, result: String },
723}
724
725/// Legacy spawn function - now a no-op since display is handled in hooks
726pub fn spawn_tool_display_handler(
727    _receiver: mpsc::Receiver<ToolEvent>,
728    _spinner: Arc<crate::agent::ui::Spinner>,
729) -> tokio::task::JoinHandle<()> {
730    tokio::spawn(async {})
731}