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        // PMAT-CODE-SLASH-PARITY-001 — 10 Claude-Code-parity commands.
123        ("/mcp", "List configured MCP servers"),
124        ("/config", "Show active config/manifest path"),
125        ("/review", "Code review mode (stub)"),
126        ("/memory", "Project memory CRUD (stub)"),
127        ("/permissions", "Permission mode (stub)"),
128        ("/hooks", "List registered hooks (stub)"),
129        ("/init", "Scaffold manifest / CLAUDE.md (stub)"),
130        ("/resume", "REPL-scope resume (stub)"),
131        ("/add-dir", "Add directory to tool allowlist (stub)"),
132        ("/agents", "List custom sub-agents (stub)"),
133    ];
134    println!("  {}", "Commands:".bold());
135    for (cmd, desc) in cmds {
136        println!("    {:10} {desc}", cmd.bright_yellow());
137    }
138}
139
140/// Print footer after each turn.
141pub(super) fn print_turn_footer(
142    result: &AgentLoopResult,
143    cost: f64,
144    session: &ReplSession,
145    _budget: f64,
146) {
147    let cost_str = if cost > 0.0 { format!("${:.4}", cost) } else { "free".into() };
148    println!(
149        "\n{}",
150        format!(
151            "  [turn {} | {} | {} tools | {}/{} tok]",
152            session.turn_count,
153            cost_str,
154            result.tool_calls,
155            result.usage.input_tokens,
156            result.usage.output_tokens,
157        )
158        .dimmed()
159    );
160}
161
162/// Print session summary on exit.
163pub(super) fn print_session_summary(session: &ReplSession) {
164    if session.turn_count == 0 {
165        return;
166    }
167    println!("\n  {}", "Session Summary".bold());
168    println!("    Turns: {}, Tool calls: {}", session.turn_count, session.total_tool_calls);
169    println!("    Tokens: {} in / {} out", session.total_input_tokens, session.total_output_tokens);
170    if let Some(id) = session.session_id() {
171        println!("    Session: {id}");
172    }
173}
174
175/// List recent sessions for the current working directory.
176pub(super) fn list_recent_sessions() {
177    let sessions_dir = match dirs::home_dir() {
178        Some(h) => h.join(".apr").join("sessions"),
179        None => {
180            println!("  Cannot determine home directory.");
181            return;
182        }
183    };
184    if !sessions_dir.is_dir() {
185        println!("  No sessions found.");
186        return;
187    }
188
189    let mut sessions: Vec<(String, u32, String)> = Vec::new();
190    if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
191        for entry in entries.flatten() {
192            let manifest_path = entry.path().join("manifest.json");
193            if let Ok(json) = std::fs::read_to_string(&manifest_path) {
194                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) {
195                    let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("?").to_string();
196                    let turns = v.get("turns").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
197                    let cwd = v.get("cwd").and_then(|c| c.as_str()).unwrap_or("?").to_string();
198                    sessions.push((id, turns, cwd));
199                }
200            }
201        }
202    }
203
204    if sessions.is_empty() {
205        println!("  No sessions found.");
206        return;
207    }
208    sessions.sort_by(|a, b| b.0.cmp(&a.0));
209    println!("  Recent sessions:");
210    for (id, turns, cwd) in sessions.iter().take(10) {
211        println!("    {} ({turns} turns) {}", id.cyan(), cwd.dimmed());
212    }
213    println!("  Resume: {} --resume=<id>", "batuta code".bright_yellow());
214}
215
216/// Run a shell command as a slash command shortcut.
217pub(super) fn run_shell_shortcut(cmd: &str) {
218    match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
219        Ok(output) => {
220            let stdout = String::from_utf8_lossy(&output.stdout);
221            let stderr = String::from_utf8_lossy(&output.stderr);
222            if !stdout.is_empty() {
223                println!("{stdout}");
224            }
225            if !stderr.is_empty() {
226                eprintln!("{stderr}");
227            }
228            if output.status.success() {
229                println!("  {} Done.", "✓".green());
230            } else {
231                println!("  {} Exit code: {}", "✗".bright_red(), output.status);
232            }
233        }
234        Err(e) => println!("  {} Failed: {e}", "✗".bright_red()),
235    }
236}
237
238/// Compact conversation history by removing tool call/result details
239/// from older turns, keeping only the user queries and assistant summaries.
240pub(super) fn compact_history(history: &mut Vec<super::driver::Message>) {
241    use super::driver::Message;
242    if history.len() <= 10 {
243        return;
244    }
245    let compact_boundary = history.len() - 10;
246    let mut compacted = Vec::new();
247    for msg in history.iter().take(compact_boundary) {
248        match msg {
249            Message::User(_) | Message::Assistant(_) => compacted.push(msg.clone()),
250            Message::AssistantToolUse(_) | Message::ToolResult(_) => {}
251            Message::System(_) => compacted.push(msg.clone()),
252        }
253    }
254    compacted.extend_from_slice(&history[compact_boundary..]);
255    *history = compacted;
256}