Skip to main content

batuta/agent/
repl_display.rs

1//! Display helpers for the interactive REPL.
2//!
3//! Extracted from repl.rs to stay under the 500-line limit.
4//! Contains welcome banner, help, turn footer, session summary,
5//! and streaming event rendering.
6
7use std::io::{self, Write};
8
9use super::driver::{LlmDriver, StreamEvent};
10use super::repl::ReplSession;
11use super::result::AgentLoopResult;
12use super::AgentManifest;
13use crate::ansi_colors::Colorize;
14
15/// Print a streaming event in REPL format.
16pub(super) fn print_stream_event_repl(event: &StreamEvent) {
17    use crate::agent::phase::LoopPhase;
18    match event {
19        StreamEvent::PhaseChange { phase } => match phase {
20            LoopPhase::Perceive => print!("{} ", "  [perceive]".dimmed()),
21            LoopPhase::Reason => {}
22            LoopPhase::Act { tool_name } => {
23                println!("  {} {}", "  [tool]".bright_yellow(), tool_name.cyan());
24            }
25            LoopPhase::Done => {}
26            LoopPhase::Error { message } => {
27                println!("  {} {}", "  [error]".bright_red(), message);
28            }
29        },
30        StreamEvent::TextDelta { text } => {
31            print!("{text}");
32            io::stdout().flush().ok();
33        }
34        StreamEvent::ToolUseStart { name, .. } => {
35            print!("  {} {} ", "⚙".bright_yellow(), name.cyan());
36            io::stdout().flush().ok();
37        }
38        StreamEvent::ToolUseEnd { result, .. } => {
39            let preview =
40                if result.len() > 60 { format!("{}...", &result[..57]) } else { result.clone() };
41            println!("{}", preview.dimmed());
42        }
43        StreamEvent::ContentComplete { .. } => println!(),
44    }
45}
46
47/// Print welcome banner.
48pub(super) fn print_welcome(manifest: &AgentManifest, driver: &dyn LlmDriver) {
49    let tier = driver.privacy_tier();
50    println!();
51    println!(
52        "  {} {} ({})",
53        "apr code".bright_cyan().bold(),
54        env!("CARGO_PKG_VERSION"),
55        format!("{tier:?} tier").dimmed()
56    );
57
58    if let Some(ref path) = manifest.model.model_path {
59        let name = path.file_name().map(|f| f.to_string_lossy()).unwrap_or_default();
60        let ext = path.extension().and_then(|e| e.to_str());
61        match ext {
62            Some("apr") => {
63                println!("  {} {} (APR — native format)", "Model:".dimmed(), name.bright_cyan());
64            }
65            Some("gguf") => {
66                println!("  {} {} (GGUF)", "Model:".dimmed(), name.bright_cyan());
67                println!(
68                    "  {} Convert to APR for faster loading: {}",
69                    "tip:".dimmed(),
70                    "apr convert --to-apr <model>.gguf".dimmed()
71                );
72            }
73            _ => {
74                println!("  {} {} (model)", "Model:".dimmed(), name.bright_cyan());
75            }
76        }
77    } else {
78        println!("  {} {}", "Model:".dimmed(), "mock (no model loaded)".bright_yellow());
79    }
80
81    // Warn about models known to lack tool-use capability (PMAT-178 dogfood)
82    if let Some(ref path) = manifest.model.model_path {
83        let name_lower =
84            path.file_name().map(|f| f.to_string_lossy().to_lowercase()).unwrap_or_default();
85        if name_lower.contains("qwen2.5-coder") || name_lower.contains("qwen2_5-coder") {
86            println!(
87                "  {} Qwen2.5-Coder cannot do tool-use (PMAT-178). Use Qwen3 1.7B+ instead.",
88                "⚠".bright_yellow()
89            );
90        }
91    }
92
93    // Warn about small models that may not support tool-use
94    let ctx = driver.context_window();
95    if ctx <= 2048 {
96        println!("  {} Small context ({ctx} tokens) — tool-use may not work.", "⚠".bright_yellow());
97        println!("  {} Recommended: 7B+ model with 8K+ context.", " ".dimmed());
98    }
99
100    println!();
101    println!(
102        "  Type a message, {} for commands, {} to exit.",
103        "/help".bright_yellow(),
104        "/quit".bright_yellow()
105    );
106    println!("  {}", "─".repeat(56).dimmed());
107}
108
109/// Print help for slash commands.
110pub(super) fn print_help() {
111    let cmds = [
112        ("/help", "Show this help"),
113        ("/test", "Run cargo test"),
114        ("/quality", "Run quality gate"),
115        ("/context", "Show context/token usage"),
116        ("/compact", "Compact old messages"),
117        ("/session", "Show session info"),
118        ("/sessions", "List recent sessions"),
119        ("/cost", "Show session cost"),
120        ("/clear", "Clear screen + history"),
121        ("/quit", "Exit apr code"),
122    ];
123    println!("  {}", "Commands:".bold());
124    for (cmd, desc) in cmds {
125        println!("    {:10} {desc}", cmd.bright_yellow());
126    }
127}
128
129/// Print footer after each turn.
130pub(super) fn print_turn_footer(
131    result: &AgentLoopResult,
132    cost: f64,
133    session: &ReplSession,
134    _budget: f64,
135) {
136    let cost_str = if cost > 0.0 { format!("${:.4}", cost) } else { "free".into() };
137    println!(
138        "\n{}",
139        format!(
140            "  [turn {} | {} | {} tools | {}/{} tok]",
141            session.turn_count,
142            cost_str,
143            result.tool_calls,
144            result.usage.input_tokens,
145            result.usage.output_tokens,
146        )
147        .dimmed()
148    );
149}
150
151/// Print session summary on exit.
152pub(super) fn print_session_summary(session: &ReplSession) {
153    if session.turn_count == 0 {
154        return;
155    }
156    println!("\n  {}", "Session Summary".bold());
157    println!("    Turns: {}, Tool calls: {}", session.turn_count, session.total_tool_calls);
158    println!("    Tokens: {} in / {} out", session.total_input_tokens, session.total_output_tokens);
159    if let Some(id) = session.session_id() {
160        println!("    Session: {id}");
161    }
162}
163
164/// List recent sessions for the current working directory.
165pub(super) fn list_recent_sessions() {
166    let sessions_dir = match dirs::home_dir() {
167        Some(h) => h.join(".apr").join("sessions"),
168        None => {
169            println!("  Cannot determine home directory.");
170            return;
171        }
172    };
173    if !sessions_dir.is_dir() {
174        println!("  No sessions found.");
175        return;
176    }
177
178    let mut sessions: Vec<(String, u32, String)> = Vec::new();
179    if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
180        for entry in entries.flatten() {
181            let manifest_path = entry.path().join("manifest.json");
182            if let Ok(json) = std::fs::read_to_string(&manifest_path) {
183                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) {
184                    let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("?").to_string();
185                    let turns = v.get("turns").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
186                    let cwd = v.get("cwd").and_then(|c| c.as_str()).unwrap_or("?").to_string();
187                    sessions.push((id, turns, cwd));
188                }
189            }
190        }
191    }
192
193    if sessions.is_empty() {
194        println!("  No sessions found.");
195        return;
196    }
197    sessions.sort_by(|a, b| b.0.cmp(&a.0));
198    println!("  Recent sessions:");
199    for (id, turns, cwd) in sessions.iter().take(10) {
200        println!("    {} ({turns} turns) {}", id.cyan(), cwd.dimmed());
201    }
202    println!("  Resume: {} --resume=<id>", "batuta code".bright_yellow());
203}
204
205/// Run a shell command as a slash command shortcut.
206pub(super) fn run_shell_shortcut(cmd: &str) {
207    match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
208        Ok(output) => {
209            let stdout = String::from_utf8_lossy(&output.stdout);
210            let stderr = String::from_utf8_lossy(&output.stderr);
211            if !stdout.is_empty() {
212                println!("{stdout}");
213            }
214            if !stderr.is_empty() {
215                eprintln!("{stderr}");
216            }
217            if output.status.success() {
218                println!("  {} Done.", "✓".green());
219            } else {
220                println!("  {} Exit code: {}", "✗".bright_red(), output.status);
221            }
222        }
223        Err(e) => println!("  {} Failed: {e}", "✗".bright_red()),
224    }
225}
226
227/// Compact conversation history by removing tool call/result details
228/// from older turns, keeping only the user queries and assistant summaries.
229pub(super) fn compact_history(history: &mut Vec<super::driver::Message>) {
230    use super::driver::Message;
231    if history.len() <= 10 {
232        return;
233    }
234    let compact_boundary = history.len() - 10;
235    let mut compacted = Vec::new();
236    for msg in history.iter().take(compact_boundary) {
237        match msg {
238            Message::User(_) | Message::Assistant(_) => compacted.push(msg.clone()),
239            Message::AssistantToolUse(_) | Message::ToolResult(_) => {}
240            Message::System(_) => compacted.push(msg.clone()),
241        }
242    }
243    compacted.extend_from_slice(&history[compact_boundary..]);
244    *history = compacted;
245}