zarz 0.3.4-alpha → 0.5.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repl.rs DELETED
@@ -1,2153 +0,0 @@
1
- use anyhow::{anyhow, Context, Result};
2
- use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor};
3
- use crossterm::{cursor, terminal::{self, ClearType}, ExecutableCommand, QueueableCommand};
4
- use dialoguer::{theme::ColorfulTheme, Select};
5
- use rustyline::completion::{Completer, Pair};
6
- use rustyline::error::ReadlineError;
7
- use rustyline::hint::{Hint as RtHint, Hinter};
8
- use rustyline::highlight::Highlighter;
9
- use rustyline::history::DefaultHistory;
10
- use rustyline::validate::{ValidationContext, ValidationResult, Validator};
11
- use rustyline::{Cmd as RlCmd, ConditionalEventHandler as RlConditionalEventHandler, Context as RtContext, Editor, Event as RlBindingEvent, EventContext as RlEventContext, EventHandler as RlEventHandler, Helper, KeyCode as RlKeyCode, KeyEvent as RlKeyEvent, Modifiers as RlModifiers, RepeatCount as RlRepeatCount};
12
- use similar::{ChangeTag, TextDiff};
13
- use std::collections::HashMap;
14
- use std::io::{stdout, Write};
15
- use std::path::{Path, PathBuf};
16
- use std::sync::{
17
- atomic::{AtomicBool, Ordering},
18
- Arc,
19
- Mutex,
20
- };
21
-
22
- use crate::cli::Provider;
23
- use crate::conversation_store::{ConversationStore, ConversationSummary};
24
- use crate::config::Config;
25
- use crate::fs_ops::FileSystemOps;
26
- use crate::mcp::{McpManager, McpTool};
27
- use crate::mcp::types::{CallToolResult, ToolContent};
28
- use crate::providers::{CompletionProvider, CompletionRequest, ProviderClient};
29
- use crate::session::{MessageRole, Session};
30
- use serde_json::{self, json, Value};
31
- use tokio::task::JoinHandle;
32
- use tokio::time::{sleep, Duration};
33
-
34
- struct CommandInfo {
35
- name: &'static str,
36
- description: &'static str,
37
- }
38
-
39
- const COMMANDS: &[CommandInfo] = &[
40
- CommandInfo { name: "help", description: "Show this help message" },
41
- CommandInfo { name: "apply", description: "Apply pending file changes" },
42
- CommandInfo { name: "diff", description: "Show pending changes" },
43
- CommandInfo { name: "undo", description: "Clear pending changes" },
44
- CommandInfo { name: "edit", description: "Load a file for editing" },
45
- CommandInfo { name: "search", description: "Search for a symbol" },
46
- CommandInfo { name: "context", description: "Find relevant files" },
47
- CommandInfo { name: "files", description: "List currently loaded files" },
48
- CommandInfo { name: "model", description: "Switch to a different AI model" },
49
- CommandInfo { name: "mcp", description: "Show MCP servers and available tools" },
50
- CommandInfo { name: "resume", description: "Resume a previous chat session" },
51
- CommandInfo { name: "clear", description: "Clear conversation history" },
52
- CommandInfo { name: "logout", description: "Remove stored API keys and sign out" },
53
- CommandInfo { name: "exit", description: "Exit the session" },
54
- ];
55
-
56
- #[derive(Clone, Default)]
57
- struct CommandHelper;
58
-
59
- #[derive(Clone)]
60
- struct CommandHint(String);
61
-
62
- impl RtHint for CommandHint {
63
- fn display(&self) -> &str {
64
- &self.0
65
- }
66
-
67
- fn completion(&self) -> Option<&str> {
68
- None
69
- }
70
- }
71
-
72
- impl Helper for CommandHelper {}
73
-
74
- impl Hinter for CommandHelper {
75
- type Hint = CommandHint;
76
-
77
- fn hint(&self, line: &str, pos: usize, _: &RtContext<'_>) -> Option<Self::Hint> {
78
- if !line.starts_with('/') || pos == 0 {
79
- return None;
80
- }
81
-
82
- let upto_cursor = &line[..pos];
83
- if upto_cursor.contains(' ') {
84
- return None;
85
- }
86
-
87
- let partial = upto_cursor.trim_start_matches('/');
88
-
89
- let matches: Vec<&CommandInfo> = COMMANDS
90
- .iter()
91
- .filter(|info| info.name.starts_with(partial))
92
- .collect();
93
-
94
- if matches.is_empty() {
95
- return None;
96
- }
97
-
98
- let mut hint_text = String::from("\n");
99
-
100
- if partial.is_empty() {
101
- hint_text.push_str("Available commands (press ↓ to browse):\n");
102
- } else {
103
- hint_text.push_str(&format!("Matches for '/{}' (press ↓ to browse):\n", partial));
104
- }
105
-
106
- let name_width = 10usize;
107
- for info in matches.iter().take(6) {
108
- hint_text.push_str(" /");
109
- hint_text.push_str(info.name);
110
- if info.name.len() < name_width {
111
- hint_text.push_str(&" ".repeat(name_width - info.name.len()));
112
- } else {
113
- hint_text.push(' ');
114
- }
115
- hint_text.push_str(info.description);
116
- hint_text.push('\n');
117
- }
118
-
119
- if matches.len() > 6 {
120
- hint_text.push_str(" ...\n");
121
- }
122
-
123
- Some(CommandHint(hint_text.trim_end().to_string()))
124
- }
125
- }
126
-
127
- impl Completer for CommandHelper {
128
- type Candidate = Pair;
129
-
130
- fn complete(
131
- &self,
132
- _line: &str,
133
- pos: usize,
134
- _ctx: &RtContext<'_>,
135
- ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
136
- Ok((pos, Vec::new()))
137
- }
138
- }
139
-
140
- impl Highlighter for CommandHelper {}
141
-
142
- impl Validator for CommandHelper {
143
- fn validate(
144
- &self,
145
- ctx: &mut ValidationContext<'_>,
146
- ) -> rustyline::Result<ValidationResult> {
147
- let input = ctx.input();
148
- if input.trim().is_empty() {
149
- Ok(ValidationResult::Invalid(Some(
150
- "Input cannot be empty".to_string(),
151
- )))
152
- } else {
153
- Ok(ValidationResult::Valid(None))
154
- }
155
- }
156
- }
157
-
158
- const REPL_SYSTEM_PROMPT: &str = r#"You are ZarzCLI, Fapzarz's official CLI for Claude and Codex.
159
-
160
- You are an interactive CLI tool that helps users with software engineering tasks.
161
-
162
- IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes.
163
-
164
- ## Bash Tool for Context Understanding
165
-
166
- You have access to a `bash` tool that allows you to execute shell commands to understand the codebase better. Use this tool proactively to:
167
- - Search for files: `find . -name "*.rs"` or `find . -type f -name "pattern"`
168
- - Search code content: `grep -r "function_name" src/` or `rg "pattern" --type rust`
169
- - Read file contents: `cat path/to/file.rs` or `head -n 20 file.py`
170
- - List directory structure: `ls -la src/` or `tree -L 2`
171
- - Check git status: `git log --oneline -10` or `git diff`
172
-
173
- IMPORTANT: Use the bash tool whenever you need to understand the codebase structure, find files, or read file contents. This helps you provide accurate and contextual responses.
174
-
175
- When making file changes, use code fences in this exact format:
176
- ```file:relative/path.rs
177
- <entire file content>
178
- ```
179
-
180
- Available commands the user can use:
181
- - /help - Show help
182
- - /apply - Apply pending changes
183
- - /diff - Show pending changes
184
- - /undo - Clear pending changes
185
- - /edit <file> - Load a file for editing
186
- - /search <symbol> - Search for a symbol in the codebase
187
- - /context <query> - Find relevant files for a query
188
- - /files - List currently loaded files
189
- - /model <name> - Switch to a different AI model
190
- - /mcp - Show MCP servers and available tools
191
- - /resume - Resume a previous chat session
192
- - /clear - Clear conversation history
193
- - /exit - Exit the session
194
-
195
- Tone and style:
196
- - Only use emojis if the user explicitly requests it
197
- - Responses should be short and concise
198
- - Focus on facts and problem-solving
199
- - Avoid over-the-top validation or excessive praise
200
-
201
- Provide clear, concise responses. When suggesting changes, always use the file block format above.
202
-
203
- Conversation format:
204
- - The prompt includes the recent transcript using prefixes like "User:", "Assistant:", and "Tool[server.tool]:".
205
- - Always respond in the voice of "Assistant" to the most recent user message.
206
- - File changes are applied automatically; never instruct the user to run /apply or similar commands.
207
-
208
- MCP tool usage:
209
- - When the prompt lists available MCP tools, you may request one by replying exactly: CALL_MCP_TOOL server=<server_name> tool=<tool_name> args=<json_object>
210
- - The JSON must be minified on a single line. Use {} when no arguments are required.
211
- - Do not include any additional text when making a tool request. Wait for Tool[...] messages that show the results, then continue the conversation.
212
- "#;
213
-
214
- pub struct Repl {
215
- session: Session,
216
- provider: ProviderClient,
217
- provider_kind: Provider,
218
- endpoint: Option<String>,
219
- timeout: Option<u64>,
220
- model: String,
221
- max_tokens: u32,
222
- temperature: f32,
223
- mcp_manager: Option<std::sync::Arc<McpManager>>,
224
- config: Config,
225
- logout_requested: bool,
226
- pending_command: Arc<Mutex<Option<String>>>,
227
- last_interrupt: Option<std::time::Instant>,
228
- current_mode: String,
229
- status_message: Option<String>,
230
- }
231
-
232
- impl Repl {
233
- fn command_list() -> &'static [CommandInfo] {
234
- COMMANDS
235
- }
236
-
237
- fn print_command_suggestions(partial: &str) -> Result<bool> {
238
- let matches: Vec<&CommandInfo> = Self::command_list()
239
- .iter()
240
- .filter(|info| info.name.starts_with(partial))
241
- .collect();
242
-
243
- if matches.is_empty() {
244
- return Ok(false);
245
- }
246
-
247
- stdout().execute(SetForegroundColor(Color::Yellow)).ok();
248
- if partial.is_empty() {
249
- println!("Available commands (press Enter to choose):");
250
- } else {
251
- println!(
252
- "Commands matching '/{}' (press Enter to choose):",
253
- partial
254
- );
255
- }
256
- for info in matches {
257
- println!(" /{:<8} - {}", info.name, info.description);
258
- }
259
- stdout().execute(ResetColor).ok();
260
- println!();
261
- std::io::stdout().flush().ok();
262
-
263
- Ok(true)
264
- }
265
-
266
- fn take_pending_command(&self) -> Option<String> {
267
- self.pending_command
268
- .lock()
269
- .ok()
270
- .and_then(|mut guard| guard.take())
271
- }
272
-
273
- fn record_message(&mut self, role: MessageRole, content: String) {
274
- self.session.add_message(role, content);
275
- self.persist_session_if_needed();
276
- }
277
-
278
- fn draw_prompt_frame(&self) {
279
- let mut out = stdout();
280
- let width = terminal::size().map(|(w, _)| w as usize).unwrap_or(120);
281
- let border = "─".repeat(width);
282
-
283
- out.queue(cursor::Hide).ok();
284
- out.queue(cursor::MoveToColumn(0)).ok();
285
- out.queue(Print(&border)).ok();
286
- out.queue(Print("\r\n")).ok();
287
- out.queue(Print("\r\n")).ok();
288
- out.queue(Print(&border)).ok();
289
- out.queue(Print("\r\n")).ok();
290
-
291
- if let Some(msg) = &self.status_message {
292
- out.execute(SetForegroundColor(Color::Yellow)).ok();
293
- out.queue(Print(msg)).ok();
294
- out.execute(ResetColor).ok();
295
- } else {
296
- out.execute(SetForegroundColor(Color::Green)).ok();
297
- out.queue(Print(format!(" ⏵⏵ Mode: {}", self.current_mode))).ok();
298
- out.execute(ResetColor).ok();
299
- }
300
-
301
- out.queue(cursor::MoveUp(2)).ok();
302
- out.queue(cursor::MoveToColumn(0)).ok();
303
- out.queue(cursor::Show).ok();
304
- out.flush().ok();
305
- }
306
-
307
- fn clear_prompt_frame() {
308
- let mut out = stdout();
309
- out.queue(cursor::Hide).ok();
310
- out.queue(cursor::MoveUp(1)).ok();
311
- out.queue(cursor::MoveToColumn(0)).ok();
312
- out.queue(terminal::Clear(ClearType::CurrentLine)).ok();
313
- out.queue(cursor::MoveDown(1)).ok();
314
- out.queue(cursor::MoveToColumn(0)).ok();
315
- out.queue(terminal::Clear(ClearType::CurrentLine)).ok();
316
- out.queue(cursor::MoveDown(1)).ok();
317
- out.queue(cursor::MoveToColumn(0)).ok();
318
- out.queue(terminal::Clear(ClearType::CurrentLine)).ok();
319
- out.queue(cursor::MoveDown(1)).ok();
320
- out.queue(cursor::MoveToColumn(0)).ok();
321
- out.queue(terminal::Clear(ClearType::CurrentLine)).ok();
322
- out.queue(cursor::MoveUp(3)).ok();
323
- out.queue(cursor::MoveToColumn(0)).ok();
324
- out.queue(cursor::Show).ok();
325
- out.flush().ok();
326
- }
327
-
328
- fn persist_session_if_needed(&mut self) {
329
- if self.session.conversation_history.is_empty() {
330
- return;
331
- }
332
-
333
- if let Err(err) = ConversationStore::save_session(
334
- &mut self.session,
335
- self.provider_kind.clone(),
336
- &self.model,
337
- ) {
338
- eprintln!("Warning: Failed to save session history: {:#}", err);
339
- }
340
- }
341
-
342
- pub fn new(
343
- working_dir: PathBuf,
344
- provider: ProviderClient,
345
- provider_kind: Provider,
346
- endpoint: Option<String>,
347
- timeout: Option<u64>,
348
- model: String,
349
- max_tokens: u32,
350
- temperature: f32,
351
- mcp_manager: Option<std::sync::Arc<McpManager>>,
352
- config: Config,
353
- ) -> Self {
354
- Self {
355
- session: Session::new(working_dir),
356
- provider,
357
- provider_kind,
358
- endpoint,
359
- timeout,
360
- model,
361
- max_tokens,
362
- temperature,
363
- mcp_manager,
364
- config,
365
- logout_requested: false,
366
- pending_command: Arc::new(Mutex::new(None)),
367
- last_interrupt: None,
368
- current_mode: "Auto".to_string(),
369
- status_message: None,
370
- }
371
- }
372
-
373
- pub async fn run(&mut self) -> Result<()> {
374
- let mut editor: Editor<CommandHelper, DefaultHistory> = Editor::new()
375
- .context("Failed to initialize readline editor")?;
376
- editor.set_helper(Some(CommandHelper::default()));
377
-
378
- let handler_down = CommandMenuHandler::new(self.pending_command.clone());
379
- editor.bind_sequence(
380
- RlKeyEvent(RlKeyCode::Down, RlModifiers::NONE),
381
- RlEventHandler::Conditional(Box::new(handler_down)),
382
- );
383
- let handler_up = CommandMenuHandler::new(self.pending_command.clone());
384
- editor.bind_sequence(
385
- RlKeyEvent(RlKeyCode::Up, RlModifiers::NONE),
386
- RlEventHandler::Conditional(Box::new(handler_up)),
387
- );
388
-
389
- loop {
390
- self.draw_prompt_frame();
391
- let readline = editor.readline("> ");
392
-
393
- match readline {
394
- Ok(line) => {
395
- self.last_interrupt = None;
396
- self.status_message = None;
397
-
398
- Self::clear_prompt_frame();
399
-
400
- let line = line.trim();
401
-
402
- if line.is_empty() {
403
- continue;
404
- }
405
-
406
- let mut out = stdout();
407
- out.execute(terminal::Clear(ClearType::CurrentLine)).ok();
408
- out.execute(cursor::MoveToColumn(0)).ok();
409
- println!("> {}", line);
410
-
411
- editor.add_history_entry(line)
412
- .context("Failed to add history entry")?;
413
-
414
- if line.starts_with('/') {
415
- if let Err(e) = self.handle_command(line).await {
416
- eprintln!("Error: {:#}", e);
417
- }
418
-
419
- if self.logout_requested {
420
- break;
421
- }
422
-
423
- if line == "/exit" {
424
- break;
425
- }
426
- } else {
427
- if self.logout_requested {
428
- break;
429
- }
430
-
431
- if let Err(e) = self.handle_user_input(line).await {
432
- eprintln!("Error: {:#}", e);
433
- }
434
-
435
- if self.logout_requested {
436
- break;
437
- }
438
- }
439
- }
440
- Err(ReadlineError::Interrupted) => {
441
- if let Some(cmd) = self.take_pending_command() {
442
- Self::clear_prompt_frame();
443
- println!("> {}", cmd);
444
- editor
445
- .add_history_entry(cmd.as_str())
446
- .context("Failed to add history entry")?;
447
- if let Err(e) = self.handle_command(&cmd).await {
448
- eprintln!("Error: {:#}", e);
449
- }
450
-
451
- if self.logout_requested {
452
- break;
453
- }
454
-
455
- continue;
456
- }
457
-
458
- let now = std::time::Instant::now();
459
- if let Some(last) = self.last_interrupt {
460
- if now.duration_since(last).as_secs() < 2 {
461
- Self::clear_prompt_frame();
462
- println!();
463
- println!("Exiting...");
464
- break;
465
- }
466
- }
467
-
468
- Self::clear_prompt_frame();
469
- self.last_interrupt = Some(now);
470
- self.status_message = Some(" Press Ctrl+C again to exit, or continue typing...".to_string());
471
-
472
- continue;
473
- }
474
- Err(ReadlineError::Eof) => {
475
- Self::clear_prompt_frame();
476
- println!("Exiting");
477
- break;
478
- }
479
- Err(err) => {
480
- Self::clear_prompt_frame();
481
- eprintln!("Error: {:#}", err);
482
- break;
483
- }
484
- }
485
- }
486
-
487
- Ok(())
488
- }
489
-
490
- async fn handle_command(&mut self, command: &str) -> Result<()> {
491
- let parts: Vec<&str> = command.splitn(2, ' ').collect();
492
- let cmd = parts[0];
493
- let args = parts.get(1).copied().unwrap_or("");
494
-
495
- if cmd == "/" {
496
- let matches: Vec<&CommandInfo> = Self::command_list().iter().collect();
497
- if let Some(choice) = pick_command_menu("", &matches, 0)? {
498
- let mut selected_command = format!("/{}", choice.name);
499
- if !args.is_empty() {
500
- selected_command.push(' ');
501
- selected_command.push_str(args);
502
- }
503
- return Self::execute_command(self, &selected_command).await;
504
- }
505
- return Ok(());
506
- }
507
-
508
- if let Some(partial) = cmd.strip_prefix('/') {
509
- if !partial.is_empty() && !Self::command_list().iter().any(|info| info.name == partial) {
510
- let matches: Vec<&CommandInfo> = Self::command_list()
511
- .iter()
512
- .filter(|info| info.name.starts_with(partial))
513
- .collect();
514
-
515
- if matches.len() == 1 {
516
- let mut selected_command = format!("/{}", matches[0].name);
517
- if !args.is_empty() {
518
- selected_command.push(' ');
519
- selected_command.push_str(args);
520
- }
521
- return Self::execute_command(self, &selected_command).await;
522
- } else if matches.len() > 1 {
523
- if let Some(choice) = pick_command_menu(partial, &matches, 0)? {
524
- let mut selected_command = format!("/{}", choice.name);
525
- if !args.is_empty() {
526
- selected_command.push(' ');
527
- selected_command.push_str(args);
528
- }
529
- return Self::execute_command(self, &selected_command).await;
530
- } else {
531
- return Ok(());
532
- }
533
- } else if Self::print_command_suggestions(partial)? {
534
- return Ok(());
535
- }
536
- }
537
- }
538
-
539
- Self::execute_command(self, command).await
540
- }
541
-
542
- async fn execute_command(&mut self, command: &str) -> Result<()> {
543
- let parts: Vec<&str> = command.splitn(2, ' ').collect();
544
- let cmd = parts[0];
545
- let args = parts.get(1).copied().unwrap_or("");
546
-
547
- match cmd {
548
- "/help" => self.show_help(),
549
- "/exit" => {
550
- println!("Goodbye!");
551
- Ok(())
552
- }
553
- "/apply" => self.apply_changes().await,
554
- "/diff" => self.show_diff(),
555
- "/undo" => self.undo_changes(),
556
- "/edit" => self.edit_file(args).await,
557
- "/search" => self.search_symbol(args).await,
558
- "/context" => self.find_context(args).await,
559
- "/files" => self.list_files(),
560
- "/model" => self.switch_model(args).await,
561
- "/mcp" => self.show_mcp_status().await,
562
- "/resume" => self.resume_session(args).await,
563
- "/clear" => self.clear_history(),
564
- "/logout" => self.logout(),
565
- _ => {
566
- println!("Unknown command: {}", cmd);
567
- println!("Type /help for available commands");
568
- Ok(())
569
- }
570
- }
571
- }
572
-
573
- async fn handle_user_input(&mut self, input: &str) -> Result<()> {
574
- if self.logout_requested {
575
- return Err(anyhow!(
576
- "You have logged out. Restart ZarzCLI and run 'zarz config' to sign in again."
577
- ));
578
- }
579
-
580
- self.record_message(MessageRole::User, input.to_string());
581
-
582
- let tools_snapshot = if let Some(manager) = &self.mcp_manager {
583
- match manager.get_all_tools().await {
584
- Ok(map) if !map.is_empty() => Some(map),
585
- Ok(_) => None,
586
- Err(e) => {
587
- eprintln!("Warning: Failed to fetch MCP tools: {}", e);
588
- None
589
- }
590
- }
591
- } else {
592
- None
593
- };
594
-
595
- let tool_prompt_section = tools_snapshot
596
- .as_ref()
597
- .map(|tools| build_tool_prompt_section(tools));
598
-
599
- let mut tool_calls = 0usize;
600
- let max_tool_calls = 5usize;
601
- #[allow(unused_assignments)]
602
- let mut final_response: Option<String> = None;
603
-
604
- loop {
605
- let mut prompt = String::new();
606
-
607
- if let Some(section) = &tool_prompt_section {
608
- prompt.push_str(section);
609
- prompt.push_str("\n\n");
610
- } else if self.mcp_manager.is_some() {
611
- prompt.push_str("No MCP tools are currently available.\n\n");
612
- }
613
-
614
- prompt.push_str(&self.session.build_prompt_with_context(true));
615
- prompt.push_str("Respond as the assistant to the latest user message.");
616
-
617
- let bash_tool = json!({
618
- "name": "bash",
619
- "description": "Execute bash commands to search files, read file contents, or perform other system operations. Use this to understand the codebase context better.",
620
- "input_schema": {
621
- "type": "object",
622
- "properties": {
623
- "command": {
624
- "type": "string",
625
- "description": "The bash command to execute (e.g., 'find . -name \"*.rs\"', 'grep -r \"function_name\" src/', 'cat src/main.rs')"
626
- }
627
- },
628
- "required": ["command"]
629
- }
630
- });
631
-
632
- let request = CompletionRequest {
633
- model: self.model.clone(),
634
- system_prompt: Some(REPL_SYSTEM_PROMPT.to_string()),
635
- user_prompt: prompt.clone(),
636
- max_output_tokens: self.max_tokens,
637
- temperature: self.temperature,
638
- messages: None,
639
- tools: Some(vec![bash_tool.clone()]),
640
- };
641
-
642
- let spinner = Spinner::start("Thinking...".to_string());
643
- let response_result = self.provider.complete(&request).await;
644
- spinner.stop().await;
645
- let mut response = response_result?;
646
-
647
- if !response.tool_calls.is_empty() {
648
- let is_anthropic = self.provider.name() == "anthropic";
649
-
650
- let mut messages = if is_anthropic {
651
- vec![json!({
652
- "role": "user",
653
- "content": [{
654
- "type": "text",
655
- "text": prompt
656
- }]
657
- })]
658
- } else {
659
- let mut msgs = Vec::new();
660
- if let Some(system) = &request.system_prompt {
661
- msgs.push(json!({
662
- "role": "system",
663
- "content": system
664
- }));
665
- }
666
- msgs.push(json!({
667
- "role": "user",
668
- "content": prompt
669
- }));
670
- msgs
671
- };
672
-
673
- if is_anthropic {
674
- let mut assistant_content = Vec::new();
675
- if !response.text.is_empty() {
676
- assistant_content.push(json!({
677
- "type": "text",
678
- "text": response.text
679
- }));
680
- }
681
-
682
- for tool_call in response.tool_calls.clone() {
683
- assistant_content.push(json!({
684
- "type": "tool_use",
685
- "id": tool_call.id,
686
- "name": tool_call.name,
687
- "input": tool_call.input
688
- }));
689
- }
690
-
691
- messages.push(json!({
692
- "role": "assistant",
693
- "content": assistant_content
694
- }));
695
- } else {
696
- let mut openai_tool_calls = Vec::new();
697
- for tool_call in response.tool_calls.clone() {
698
- openai_tool_calls.push(json!({
699
- "id": tool_call.id,
700
- "type": "function",
701
- "function": {
702
- "name": tool_call.name,
703
- "arguments": tool_call.input.to_string()
704
- }
705
- }));
706
- }
707
-
708
- messages.push(json!({
709
- "role": "assistant",
710
- "content": response.text,
711
- "tool_calls": openai_tool_calls
712
- }));
713
- }
714
-
715
- for tool_call in &response.tool_calls {
716
- if tool_call.name == "bash" {
717
- if let Some(command) = tool_call.input.get("command").and_then(|v| v.as_str()) {
718
- println!();
719
- stdout().execute(SetForegroundColor(Color::Cyan))?;
720
- println!(" $ {}", command);
721
- stdout().execute(ResetColor)?;
722
-
723
- let result = execute_bash_command(command)?;
724
- let truncated = if result.len() > 4000 {
725
- format!("{}... (truncated, {} total chars)", &result[..4000], result.len())
726
- } else {
727
- result
728
- };
729
-
730
- if is_anthropic {
731
- let tool_result_content = vec![json!({
732
- "type": "tool_result",
733
- "tool_use_id": tool_call.id,
734
- "content": truncated
735
- })];
736
- messages.push(json!({
737
- "role": "user",
738
- "content": tool_result_content
739
- }));
740
- } else {
741
- messages.push(json!({
742
- "role": "tool",
743
- "tool_call_id": tool_call.id,
744
- "content": truncated
745
- }));
746
- }
747
- }
748
- }
749
- }
750
-
751
- let follow_up_request = CompletionRequest {
752
- model: self.model.clone(),
753
- system_prompt: Some(REPL_SYSTEM_PROMPT.to_string()),
754
- user_prompt: String::new(),
755
- max_output_tokens: self.max_tokens,
756
- temperature: self.temperature,
757
- messages: Some(messages),
758
- tools: Some(vec![bash_tool]),
759
- };
760
-
761
- let spinner = Spinner::start("Thinking...".to_string());
762
- let follow_up_result = self.provider.complete(&follow_up_request).await;
763
- spinner.stop().await;
764
- response = follow_up_result?;
765
- }
766
-
767
- let raw_text = response.text;
768
-
769
- match parse_mcp_tool_call(&raw_text) {
770
- Ok(Some(parsed)) => {
771
- if let Some(prefix_text) = parsed.prefix.as_deref() {
772
- let display = strip_file_blocks(prefix_text);
773
- if !display.trim().is_empty() {
774
- print_assistant_message(&display, &self.model)?;
775
- }
776
- self.record_message(
777
- MessageRole::Assistant,
778
- prefix_text.to_string(),
779
- );
780
- } else {
781
- let note = format!(
782
- "Calling MCP tool {}.{}...",
783
- parsed.call.server, parsed.call.tool
784
- );
785
- print_assistant_message(&note, &self.model)?;
786
- self.record_message(MessageRole::Assistant, note);
787
- }
788
-
789
- self.record_message(
790
- MessageRole::Assistant,
791
- parsed.command_text.clone(),
792
- );
793
- print_tool_command(&parsed.command_text)?;
794
-
795
- if self.mcp_manager.is_none() {
796
- stdout().execute(SetForegroundColor(Color::Yellow)).ok();
797
- println!("MCP tool request ignored: no MCP manager configured.");
798
- stdout().execute(ResetColor).ok();
799
-
800
- self.record_message(
801
- MessageRole::Tool {
802
- server: parsed.call.server.clone(),
803
- tool: parsed.call.tool.clone(),
804
- },
805
- "ERROR: MCP tools are not available in this session.".to_string(),
806
- );
807
-
808
- continue;
809
- }
810
-
811
- if tool_calls >= max_tool_calls {
812
- stdout().execute(SetForegroundColor(Color::Yellow)).ok();
813
- println!("Skipping MCP tool call (limit of {} reached).", max_tool_calls);
814
- stdout().execute(ResetColor).ok();
815
-
816
- self.record_message(
817
- MessageRole::Tool {
818
- server: parsed.call.server.clone(),
819
- tool: parsed.call.tool.clone(),
820
- },
821
- "ERROR: MCP tool call limit reached for this request.".to_string(),
822
- );
823
-
824
- continue;
825
- }
826
-
827
- let manager = self.mcp_manager.as_ref().unwrap();
828
-
829
- let spinner = Spinner::start(format!(
830
- "Running MCP {}.{}...",
831
- parsed.call.server, parsed.call.tool
832
- ));
833
- let tool_result = manager
834
- .call_tool(
835
- &parsed.call.server,
836
- parsed.call.tool.clone(),
837
- parsed.call.arguments.clone(),
838
- )
839
- .await;
840
- spinner.stop().await;
841
-
842
- let (mut tool_output, is_error) = match tool_result {
843
- Ok(result) => {
844
- let is_error = result.is_error.unwrap_or(false);
845
- let mut text = format_tool_result(&result);
846
- if text.trim().is_empty() {
847
- if is_error {
848
- text = "ERROR: MCP tool returned no content.".to_string();
849
- } else {
850
- text = "MCP tool returned no content.".to_string();
851
- }
852
- }
853
- (text, is_error)
854
- }
855
- Err(err) => (format!("ERROR: {}", err), true),
856
- };
857
-
858
- tool_calls += 1;
859
-
860
- if is_error && !tool_output.starts_with("ERROR") {
861
- tool_output = format!("ERROR: {}", tool_output);
862
- }
863
-
864
- let stored_output = if tool_output.chars().count() > 8000 {
865
- let mut truncated = truncate_for_display(&tool_output, 8000);
866
- truncated.push_str("\n... (truncated for conversation history)");
867
- truncated
868
- } else {
869
- tool_output.clone()
870
- };
871
-
872
- self.record_message(
873
- MessageRole::Tool {
874
- server: parsed.call.server.clone(),
875
- tool: parsed.call.tool.clone(),
876
- },
877
- stored_output,
878
- );
879
-
880
- log_tool_execution(
881
- &parsed.call.server,
882
- &parsed.call.tool,
883
- &tool_output,
884
- is_error,
885
- )?;
886
-
887
- continue;
888
- }
889
- Ok(None) => {
890
- final_response = Some(raw_text.clone());
891
- self.record_message(MessageRole::Assistant, raw_text.clone());
892
- break;
893
- }
894
- Err(parse_error) => {
895
- self.record_message(MessageRole::Assistant, raw_text.clone());
896
- stdout().execute(SetForegroundColor(Color::Yellow)).ok();
897
- println!("Warning: {}", parse_error);
898
- stdout().execute(ResetColor).ok();
899
- final_response = Some(raw_text.clone());
900
- break;
901
- }
902
- }
903
- }
904
-
905
- if let Some(text) = final_response {
906
- let printable = strip_file_blocks(&text);
907
- if !printable.trim().is_empty() {
908
- print_assistant_message(&printable, &self.model)?;
909
- }
910
-
911
- let file_blocks = parse_file_blocks(&text);
912
- if !file_blocks.is_empty() {
913
- self.process_file_blocks(file_blocks).await?;
914
- }
915
- }
916
-
917
- Ok(())
918
- }
919
-
920
- async fn process_file_blocks(&mut self, blocks: HashMap<PathBuf, String>) -> Result<()> {
921
- if blocks.is_empty() {
922
- return Ok(());
923
- }
924
-
925
- for (path, new_content) in blocks {
926
- let full_path = self.session.working_directory.join(&path);
927
- let existed = FileSystemOps::file_exists(&full_path).await;
928
- let original = if existed {
929
- FileSystemOps::read_file(&full_path).await?
930
- } else {
931
- String::new()
932
- };
933
-
934
- if original == new_content {
935
- stdout().execute(SetForegroundColor(Color::DarkGrey)).ok();
936
- println!("No changes for {}", path.display());
937
- stdout().execute(ResetColor).ok();
938
- continue;
939
- }
940
-
941
- print_file_change_summary(&path, &original, &new_content)?;
942
-
943
- FileSystemOps::create_file(&full_path, &new_content).await?;
944
-
945
- let mut out = stdout();
946
- let message = if existed {
947
- format!("Updated {}", path.display())
948
- } else {
949
- format!("Created {}", path.display())
950
- };
951
- out.execute(SetForegroundColor(Color::Green)).ok();
952
- println!("{}", message);
953
- out.execute(ResetColor).ok();
954
- println!();
955
- }
956
-
957
- // Since changes are applied immediately, clear any stale pending state
958
- self.session.clear_pending_changes();
959
-
960
- Ok(())
961
- }
962
-
963
- fn show_help(&self) -> Result<()> {
964
- println!("Available commands:");
965
- println!(" /help - Show this help message");
966
- println!(" /apply - Apply pending file changes");
967
- println!(" /diff - Show pending changes");
968
- println!(" /undo - Clear pending changes");
969
- println!(" /edit <file> - Load a file for editing");
970
- println!(" /search <name> - Search for a symbol");
971
- println!(" /context <query>- Find relevant files");
972
- println!(" /files - List loaded files");
973
- println!(" /model <name> - Switch to a different AI model");
974
- println!(" Examples: claude-sonnet-4-5-20250929, claude-haiku-4-5,");
975
- println!(" gpt-5-codex, gpt-4o");
976
- println!(" /mcp - Show MCP servers and available tools");
977
- println!(" /resume - Resume a previous chat session");
978
- println!(" /clear - Clear conversation history");
979
- println!(" /logout - Remove stored API keys and sign out");
980
- println!(" /exit - Exit the session");
981
- println!();
982
- println!("Current model: {}", self.model);
983
- println!("Current provider: {}", self.provider.name());
984
- Ok(())
985
- }
986
-
987
- async fn apply_changes(&mut self) -> Result<()> {
988
- if self.session.pending_changes.is_empty() {
989
- println!("No pending changes to apply");
990
- return Ok(());
991
- }
992
-
993
- for change in &self.session.pending_changes {
994
- let full_path = self.session.working_directory.join(&change.path);
995
- FileSystemOps::create_file(&full_path, &change.new_content).await?;
996
- println!("Applied changes to {}", change.path.display());
997
- }
998
-
999
- self.session.clear_pending_changes();
1000
- println!("All changes applied successfully");
1001
-
1002
- Ok(())
1003
- }
1004
-
1005
- fn show_diff(&self) -> Result<()> {
1006
- if self.session.pending_changes.is_empty() {
1007
- println!("No pending changes");
1008
- return Ok(());
1009
- }
1010
-
1011
- for change in &self.session.pending_changes {
1012
- println!("--- {}", change.path.display());
1013
- println!("+++ {}", change.path.display());
1014
- print_diff(&change.original_content, &change.new_content);
1015
- println!();
1016
- }
1017
-
1018
- Ok(())
1019
- }
1020
-
1021
- fn undo_changes(&mut self) -> Result<()> {
1022
- let count = self.session.pending_changes.len();
1023
- self.session.clear_pending_changes();
1024
- println!("Cleared {} pending change(s)", count);
1025
- Ok(())
1026
- }
1027
-
1028
- async fn edit_file(&mut self, path: &str) -> Result<()> {
1029
- if path.is_empty() {
1030
- return Err(anyhow!("Usage: /edit <file>"));
1031
- }
1032
-
1033
- let file_path = PathBuf::from(path);
1034
- let full_path = self.session.working_directory.join(&file_path);
1035
-
1036
- if !FileSystemOps::file_exists(&full_path).await {
1037
- return Err(anyhow!("File not found: {}", path));
1038
- }
1039
-
1040
- let content = FileSystemOps::read_file(&full_path).await?;
1041
- self.session.load_file(file_path.clone(), content);
1042
-
1043
- println!("Loaded {} for editing", path);
1044
-
1045
- Ok(())
1046
- }
1047
-
1048
- async fn search_symbol(&self, name: &str) -> Result<()> {
1049
- if name.is_empty() {
1050
- return Err(anyhow!("Usage: /search <symbol>"));
1051
- }
1052
-
1053
- println!("Searching for symbol: {}", name);
1054
-
1055
- let symbols = self.session.search_symbol(name)?;
1056
-
1057
- if symbols.is_empty() {
1058
- println!("No symbols found matching '{}'", name);
1059
- } else {
1060
- println!("Found {} symbol(s):", symbols.len());
1061
- for symbol in symbols {
1062
- println!(" {:?} {} in {}", symbol.kind, symbol.name, symbol.file.display());
1063
- }
1064
- }
1065
-
1066
- Ok(())
1067
- }
1068
-
1069
- async fn find_context(&self, query: &str) -> Result<()> {
1070
- if query.is_empty() {
1071
- return Err(anyhow!("Usage: /context <query>"));
1072
- }
1073
-
1074
- println!("Finding relevant context for: {}", query);
1075
-
1076
- let files = self.session.get_relevant_context(query)?;
1077
-
1078
- if files.is_empty() {
1079
- println!("No relevant files found");
1080
- } else {
1081
- println!("Relevant files:");
1082
- for file in files {
1083
- println!(" {}", file.display());
1084
- }
1085
- }
1086
-
1087
- Ok(())
1088
- }
1089
-
1090
- fn list_files(&self) -> Result<()> {
1091
- if self.session.current_files.is_empty() {
1092
- println!("No files currently loaded");
1093
- } else {
1094
- println!("Currently loaded files:");
1095
- for path in self.session.current_files.keys() {
1096
- println!(" {}", path.display());
1097
- }
1098
- }
1099
-
1100
- Ok(())
1101
- }
1102
-
1103
- fn clear_history(&mut self) -> Result<()> {
1104
- self.session.conversation_history.clear();
1105
- self.session.reset_metadata();
1106
- println!("Conversation history cleared");
1107
- Ok(())
1108
- }
1109
-
1110
- async fn resume_session(&mut self, args: &str) -> Result<()> {
1111
- let summaries = ConversationStore::list_summaries()?;
1112
-
1113
- if summaries.is_empty() {
1114
- println!("No saved sessions found.");
1115
- return Ok(());
1116
- }
1117
-
1118
- let trimmed = args.trim();
1119
-
1120
- let selected_summary = if trimmed.is_empty() {
1121
- let items: Vec<String> = summaries
1122
- .iter()
1123
- .map(|summary| format_session_line(summary))
1124
- .collect();
1125
-
1126
- let selection = Select::with_theme(&ColorfulTheme::default())
1127
- .with_prompt("Select a session to resume")
1128
- .items(&items)
1129
- .default(0)
1130
- .interact_opt()?;
1131
-
1132
- match selection {
1133
- Some(index) => summaries.get(index).cloned(),
1134
- None => {
1135
- println!("Resume cancelled.");
1136
- return Ok(());
1137
- }
1138
- }
1139
- } else {
1140
- let needle = trimmed.to_ascii_lowercase();
1141
- summaries
1142
- .iter()
1143
- .find(|summary| {
1144
- summary.id.to_ascii_lowercase().starts_with(&needle)
1145
- || summary
1146
- .title
1147
- .to_ascii_lowercase()
1148
- .contains(&needle)
1149
- })
1150
- .cloned()
1151
- };
1152
-
1153
- let Some(summary) = selected_summary else {
1154
- println!("No saved session matches '{}'.", trimmed);
1155
- return Ok(());
1156
- };
1157
-
1158
- let snapshot = ConversationStore::load_snapshot(&summary.id)?;
1159
-
1160
- let previous_provider = self.provider_kind.clone();
1161
- let provider_kind = Provider::from_str(&snapshot.provider).ok_or_else(|| {
1162
- anyhow!(
1163
- "Unknown provider '{}' in saved session",
1164
- snapshot.provider
1165
- )
1166
- })?;
1167
-
1168
- let switching_provider = provider_kind != previous_provider;
1169
-
1170
- if switching_provider {
1171
- let api_key = match provider_kind {
1172
- Provider::Anthropic => self.config.get_anthropic_key(),
1173
- Provider::OpenAi => self.config.get_openai_key(),
1174
- Provider::Glm => self.config.get_glm_key(),
1175
- };
1176
-
1177
- let client = ProviderClient::new(
1178
- provider_kind.clone(),
1179
- api_key,
1180
- self.endpoint.clone(),
1181
- self.timeout,
1182
- )?;
1183
-
1184
- self.provider = client;
1185
- self.provider_kind = provider_kind;
1186
- }
1187
-
1188
- let previous_model = self.model.clone();
1189
- self.model = snapshot.model.clone();
1190
- self.session.conversation_history = snapshot.messages.clone();
1191
- self.session.storage_id = Some(snapshot.id.clone());
1192
- self.session.title = Some(snapshot.title.clone());
1193
- self.session.created_at = Some(snapshot.created_at);
1194
- self.session.updated_at = Some(snapshot.updated_at);
1195
- self.session.pending_changes.clear();
1196
- self.session.current_files.clear();
1197
-
1198
- if !snapshot.working_directory.eq(&self.session.working_directory) {
1199
- println!(
1200
- "Note: saved session was created in {}",
1201
- snapshot.working_directory.display()
1202
- );
1203
- }
1204
-
1205
- if switching_provider || self.model != previous_model {
1206
- println!(
1207
- "Active provider/model set to {} / {}",
1208
- snapshot.provider, self.model
1209
- );
1210
- }
1211
-
1212
- let formatted_time = snapshot
1213
- .updated_at
1214
- .with_timezone(&chrono::Local)
1215
- .format("%Y-%m-%d %H:%M")
1216
- .to_string();
1217
-
1218
- println!(
1219
- "Resumed session '{}' [{} • {}] ({} messages, updated {})",
1220
- snapshot.title,
1221
- snapshot.provider,
1222
- snapshot.model,
1223
- snapshot.message_count,
1224
- formatted_time
1225
- );
1226
-
1227
- if let Some(last_reply) = snapshot
1228
- .messages
1229
- .iter()
1230
- .rev()
1231
- .find(|message| matches!(message.role, MessageRole::Assistant))
1232
- {
1233
- let preview = truncate_for_display(&last_reply.content, 240);
1234
- if !preview.trim().is_empty() {
1235
- println!();
1236
- print_assistant_message(&preview, &self.model)?;
1237
- }
1238
- }
1239
-
1240
- Ok(())
1241
- }
1242
-
1243
- fn logout(&mut self) -> Result<()> {
1244
- let config_path = Config::config_path()?;
1245
- let had_keys = self.config.clear_api_keys()?;
1246
-
1247
- let mut env_removed = false;
1248
- for var in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GLM_API_KEY"] {
1249
- if std::env::var(var).is_ok() {
1250
- env_removed = true;
1251
- }
1252
- unsafe {
1253
- std::env::remove_var(var);
1254
- }
1255
- }
1256
-
1257
- if had_keys {
1258
- println!(
1259
- "Stored API keys removed from {}",
1260
- config_path.display()
1261
- );
1262
- } else {
1263
- println!(
1264
- "No stored API keys found at {}",
1265
- config_path.display()
1266
- );
1267
- }
1268
-
1269
- if env_removed {
1270
- println!("Cleared API key environment variables for this session.");
1271
- } else {
1272
- println!("No API key environment variables were set for this session.");
1273
- }
1274
-
1275
- println!("Restart ZarzCLI to complete logout. Run 'zarz config' to sign in again.");
1276
- self.logout_requested = true;
1277
- Ok(())
1278
- }
1279
-
1280
- async fn switch_model(&mut self, model_name: &str) -> Result<()> {
1281
- if model_name.is_empty() {
1282
- println!("Usage: /model <name>");
1283
- println!();
1284
- println!("Available models:");
1285
- println!(" Anthropic Claude:");
1286
- println!(" claude-sonnet-4-5-20250929 - Best for coding and agents");
1287
- println!(" claude-sonnet-4-5-20250929-thinking - Extended thinking mode");
1288
- println!(" claude-haiku-4-5 - Fast and cost-effective");
1289
- println!(" claude-opus-4-1 - Most powerful");
1290
- println!(" claude-sonnet-4 - General purpose");
1291
- println!();
1292
- println!(" OpenAI:");
1293
- println!(" gpt-5-codex - Optimized for coding");
1294
- println!(" gpt-4o - Multimodal");
1295
- println!(" gpt-4-turbo - Fast and efficient");
1296
- println!();
1297
- println!(" GLM (Z.AI - International):");
1298
- println!(" glm-4.6 - Best for coding (200K context)");
1299
- println!(" glm-4.5 - Previous generation");
1300
- println!();
1301
- println!("Current model: {}", self.model);
1302
- return Ok(());
1303
- }
1304
-
1305
- let new_model = model_name.to_string();
1306
-
1307
- let new_provider_kind = if new_model.starts_with("claude") {
1308
- Provider::Anthropic
1309
- } else if new_model.starts_with("gpt") {
1310
- Provider::OpenAi
1311
- } else if new_model.starts_with("glm") {
1312
- Provider::Glm
1313
- } else {
1314
- return Err(anyhow!("Unknown model provider for '{}'", new_model));
1315
- };
1316
-
1317
- if new_provider_kind != self.provider_kind {
1318
- let api_key = match new_provider_kind {
1319
- Provider::Anthropic => self.config.get_anthropic_key(),
1320
- Provider::OpenAi => self.config.get_openai_key(),
1321
- Provider::Glm => self.config.get_glm_key(),
1322
- };
1323
-
1324
- let new_provider = ProviderClient::new(
1325
- new_provider_kind.clone(),
1326
- api_key,
1327
- self.endpoint.clone(),
1328
- self.timeout,
1329
- )?;
1330
-
1331
- self.provider = new_provider;
1332
- self.provider_kind = new_provider_kind;
1333
- }
1334
-
1335
- self.model = new_model.clone();
1336
-
1337
- println!("Switched to model: {}", new_model);
1338
- println!("Provider: {}", self.provider.name());
1339
-
1340
- Ok(())
1341
- }
1342
-
1343
- async fn show_mcp_status(&self) -> Result<()> {
1344
- match &self.mcp_manager {
1345
- None => {
1346
- println!("MCP support is not enabled.");
1347
- println!();
1348
- println!("To use MCP servers, add them with:");
1349
- println!(" zarz mcp add <name> --command <cmd> --args <arg1> <arg2>");
1350
- println!();
1351
- println!("Example:");
1352
- println!(" zarz mcp add firecrawl --command npx --args -y firecrawl-mcp \\");
1353
- println!(" --env FIRECRAWL_API_KEY=your-key");
1354
- Ok(())
1355
- }
1356
- Some(manager) => {
1357
- let servers = manager.list_servers().await;
1358
-
1359
- let tools_by_server = match manager.get_all_tools().await {
1360
- Ok(map) => map,
1361
- Err(e) => {
1362
- eprintln!("Warning: Failed to fetch MCP tools: {}", e);
1363
- HashMap::new()
1364
- }
1365
- };
1366
-
1367
- if servers.is_empty() {
1368
- println!("No MCP servers are currently running.");
1369
- println!();
1370
- println!("To add MCP servers, use:");
1371
- println!(" zarz mcp add <name> --command <cmd> --args <arg1> <arg2>");
1372
- return Ok(());
1373
- }
1374
-
1375
- println!("Connected MCP Servers:");
1376
- println!();
1377
-
1378
- for server_name in &servers {
1379
- // Get server info
1380
- if let Some(info) = manager.get_server_info(server_name).await {
1381
- stdout().execute(SetForegroundColor(Color::Green))?;
1382
- println!(" ● {}", server_name);
1383
- stdout().execute(ResetColor)?;
1384
- println!(" Server: {}", info);
1385
- } else {
1386
- stdout().execute(SetForegroundColor(Color::Yellow))?;
1387
- println!(" ◐ {}", server_name);
1388
- stdout().execute(ResetColor)?;
1389
- println!(" Status: Initializing...");
1390
- }
1391
-
1392
- // Get tools for this server
1393
- if let Some(tools) = tools_by_server.get(server_name) {
1394
- if !tools.is_empty() {
1395
- println!(" Tools ({}):", tools.len());
1396
- for (i, tool) in tools.iter().enumerate() {
1397
- if i < 5 {
1398
- let description = tool
1399
- .description
1400
- .as_deref()
1401
- .map(|d| truncate_inline(d, 160))
1402
- .unwrap_or_else(|| "No description".to_string());
1403
- println!(" - {}: {}", tool.name, description);
1404
- }
1405
- }
1406
- if tools.len() > 5 {
1407
- println!(" ... and {} more", tools.len() - 5);
1408
- }
1409
- } else {
1410
- println!(" Tools: None available");
1411
- }
1412
- }
1413
- println!();
1414
- }
1415
-
1416
- println!("Total servers: {}", servers.len());
1417
- Ok(())
1418
- }
1419
- }
1420
- }
1421
- }
1422
-
1423
- fn format_session_line(summary: &ConversationSummary) -> String {
1424
- let time_str = summary
1425
- .updated_at
1426
- .with_timezone(&chrono::Local)
1427
- .format("%Y-%m-%d %H:%M")
1428
- .to_string();
1429
-
1430
- let mut title = summary.title.clone();
1431
- if title.len() > 60 {
1432
- title.truncate(60);
1433
- title.push('…');
1434
- }
1435
-
1436
- let plural = if summary.message_count == 1 { "" } else { "s" };
1437
-
1438
- format!(
1439
- "{} │ {} [{} • {}] • {} message{} (id: {})",
1440
- time_str,
1441
- title,
1442
- summary.provider,
1443
- summary.model,
1444
- summary.message_count,
1445
- plural,
1446
- summary.id
1447
- )
1448
- }
1449
-
1450
- #[derive(Clone)]
1451
- struct CommandMenuHandler {
1452
- pending_command: Arc<Mutex<Option<String>>>,
1453
- }
1454
-
1455
- impl CommandMenuHandler {
1456
- fn new(pending_command: Arc<Mutex<Option<String>>>) -> Self {
1457
- Self { pending_command }
1458
- }
1459
- }
1460
-
1461
- impl RlConditionalEventHandler for CommandMenuHandler {
1462
- fn handle(
1463
- &self,
1464
- evt: &RlBindingEvent,
1465
- _n: RlRepeatCount,
1466
- _positive: bool,
1467
- ctx: &RlEventContext,
1468
- ) -> Option<RlCmd> {
1469
- let Some(key) = evt.get(0) else {
1470
- return None;
1471
- };
1472
-
1473
- let is_navigation = *key == RlKeyEvent(RlKeyCode::Down, RlModifiers::NONE)
1474
- || *key == RlKeyEvent(RlKeyCode::Up, RlModifiers::NONE);
1475
-
1476
- if !is_navigation {
1477
- return None;
1478
- }
1479
-
1480
- let line = ctx.line();
1481
- if !line.starts_with('/') {
1482
- return None;
1483
- }
1484
-
1485
- let pos = ctx.pos().min(line.len());
1486
- let upto_cursor = &line[..pos];
1487
- if upto_cursor.contains(' ') {
1488
- return None;
1489
- }
1490
-
1491
- let partial = if pos > 1 { &line[1..pos] } else { "" };
1492
- let args_suffix = line
1493
- .find(' ')
1494
- .map(|idx| line[idx..].to_string())
1495
- .unwrap_or_default();
1496
-
1497
- let matches: Vec<&CommandInfo> = COMMANDS
1498
- .iter()
1499
- .filter(|info| info.name.starts_with(partial))
1500
- .collect();
1501
-
1502
- if matches.is_empty() {
1503
- return Some(RlCmd::Noop);
1504
- }
1505
-
1506
- let initial_index = match key.0 {
1507
- RlKeyCode::Up => matches.len().saturating_sub(1),
1508
- _ => 0,
1509
- };
1510
-
1511
- match pick_command_menu(partial, &matches, initial_index) {
1512
- Ok(Some(choice)) => {
1513
- if let Ok(mut pending) = self.pending_command.lock() {
1514
- let mut command = format!("/{}", choice.name);
1515
- if !args_suffix.is_empty() {
1516
- command.push_str(&args_suffix);
1517
- }
1518
- *pending = Some(command);
1519
- }
1520
- Some(RlCmd::Interrupt)
1521
- }
1522
- Ok(None) => {
1523
- if let Ok(mut pending) = self.pending_command.lock() {
1524
- if pending.is_some() {
1525
- *pending = None;
1526
- }
1527
- }
1528
- Some(RlCmd::Noop)
1529
- }
1530
- Err(err) => {
1531
- eprintln!("Error: {:#}", err);
1532
- Some(RlCmd::Noop)
1533
- }
1534
- }
1535
- }
1536
- }
1537
-
1538
- fn pick_command_menu<'a>(
1539
- partial: &str,
1540
- matches: &'a [&'a CommandInfo],
1541
- initial_index: usize,
1542
- ) -> Result<Option<&'a CommandInfo>> {
1543
- if matches.is_empty() {
1544
- return Ok(None);
1545
- }
1546
-
1547
- print!("\n\n");
1548
-
1549
- let theme = ColorfulTheme::default();
1550
- let items: Vec<String> = matches
1551
- .iter()
1552
- .map(|info| format!("/{:<16} {}", info.name, info.description))
1553
- .collect();
1554
-
1555
- let prompt = if partial.is_empty() {
1556
- "Select a command".to_string()
1557
- } else {
1558
- format!("Commands matching '/{}'", partial)
1559
- };
1560
-
1561
- let default_index = initial_index.min(items.len() - 1);
1562
-
1563
- let selection = Select::with_theme(&theme)
1564
- .with_prompt(prompt)
1565
- .items(&items)
1566
- .default(default_index)
1567
- .clear(true)
1568
- .report(false)
1569
- .interact_opt()?;
1570
-
1571
- Ok(selection.map(|idx| matches[idx]))
1572
- }
1573
-
1574
- #[derive(Debug, Clone)]
1575
- struct McpToolCall {
1576
- server: String,
1577
- tool: String,
1578
- arguments: Option<HashMap<String, Value>>,
1579
- }
1580
-
1581
- #[derive(Debug, Clone)]
1582
- struct ParsedToolCall {
1583
- prefix: Option<String>,
1584
- command_text: String,
1585
- call: McpToolCall,
1586
- }
1587
-
1588
- fn build_tool_prompt_section(tools_by_server: &HashMap<String, Vec<McpTool>>) -> String {
1589
- let mut section = String::from(
1590
- "Available MCP tools:\n\
1591
- Use CALL_MCP_TOOL server=<server_name> tool=<tool_name> args=<json_object> to request a tool.\n\
1592
- Only request a tool when it will help solve the task.\n",
1593
- );
1594
-
1595
- let mut server_names: Vec<&String> = tools_by_server.keys().collect();
1596
- server_names.sort();
1597
-
1598
- for server in server_names {
1599
- section.push_str(&format!("\nServer {}:\n", server));
1600
- if let Some(tools) = tools_by_server.get(server) {
1601
- let mut ordered: Vec<&McpTool> = tools.iter().collect();
1602
- ordered.sort_by(|a, b| a.name.cmp(&b.name));
1603
-
1604
- for tool in ordered.iter().take(8) {
1605
- let description = tool
1606
- .description
1607
- .as_deref()
1608
- .unwrap_or("No description provided");
1609
- section.push_str(&format!(" - {}: {}\n", tool.name, description));
1610
-
1611
- if let Ok(schema_str) = serde_json::to_string(&tool.input_schema) {
1612
- let snippet = truncate_inline(&schema_str, 200);
1613
- section.push_str(&format!(" schema: {}\n", snippet));
1614
- }
1615
- }
1616
-
1617
- if ordered.len() > 8 {
1618
- section.push_str(&format!(" - ... ({} more)\n", ordered.len() - 8));
1619
- }
1620
- }
1621
- }
1622
-
1623
- section
1624
- }
1625
-
1626
- fn parse_mcp_tool_call(text: &str) -> Result<Option<ParsedToolCall>> {
1627
- let Some(command_index) = text.find("CALL_MCP_TOOL") else {
1628
- return Ok(None);
1629
- };
1630
-
1631
- let prefix_text = text[..command_index].trim();
1632
- let prefix = if prefix_text.is_empty() {
1633
- None
1634
- } else {
1635
- Some(prefix_text.to_string())
1636
- };
1637
-
1638
- let command_and_rest = text[command_index..].trim();
1639
-
1640
- // Ensure the tool call is the only content after the prefix (allow trailing whitespace)
1641
- let (command_line, trailing_text) = if let Some(pos) = command_and_rest.find('\n') {
1642
- let (line, rest) = command_and_rest.split_at(pos);
1643
- (line.trim_end(), rest[pos + 1..].trim())
1644
- } else {
1645
- (command_and_rest, "")
1646
- };
1647
-
1648
- if !trailing_text.is_empty() {
1649
- anyhow::bail!("Additional text found after MCP tool call. Tool calls must be on a single line.");
1650
- }
1651
-
1652
- let command_line = command_line.trim();
1653
- if !command_line.starts_with("CALL_MCP_TOOL") {
1654
- return Ok(None);
1655
- }
1656
-
1657
- let remainder = command_line["CALL_MCP_TOOL".len()..].trim();
1658
- let mut parts = remainder.splitn(3, ' ');
1659
-
1660
- let server_part = parts
1661
- .next()
1662
- .ok_or_else(|| anyhow!("Missing server component in MCP tool call"))?;
1663
- let tool_part = parts
1664
- .next()
1665
- .ok_or_else(|| anyhow!("Missing tool component in MCP tool call"))?;
1666
- let args_part = parts
1667
- .next()
1668
- .ok_or_else(|| anyhow!("Missing args component in MCP tool call"))?;
1669
-
1670
- if !server_part.starts_with("server=") {
1671
- anyhow::bail!("Expected server=<server_name> in MCP tool call");
1672
- }
1673
- if !tool_part.starts_with("tool=") {
1674
- anyhow::bail!("Expected tool=<tool_name> in MCP tool call");
1675
- }
1676
- if !args_part.starts_with("args=") {
1677
- anyhow::bail!("Expected args=<json> in MCP tool call");
1678
- }
1679
-
1680
- let server = server_part["server=".len()..].to_string();
1681
- let tool = tool_part["tool=".len()..].to_string();
1682
- let args_raw = args_part["args=".len()..].trim();
1683
-
1684
- if server.is_empty() {
1685
- anyhow::bail!("Server name cannot be empty in MCP tool call");
1686
- }
1687
-
1688
- if tool.is_empty() {
1689
- anyhow::bail!("Tool name cannot be empty in MCP tool call");
1690
- }
1691
-
1692
- let arguments = if args_raw.eq_ignore_ascii_case("null") {
1693
- None
1694
- } else {
1695
- let value: Value = serde_json::from_str(args_raw)
1696
- .with_context(|| "Failed to parse MCP tool call arguments as JSON")?;
1697
-
1698
- match value {
1699
- Value::Null => None,
1700
- Value::Object(map) => Some(map.into_iter().collect()),
1701
- _ => {
1702
- anyhow::bail!("Tool arguments must be a JSON object or null");
1703
- }
1704
- }
1705
- };
1706
-
1707
- Ok(Some(ParsedToolCall {
1708
- prefix,
1709
- command_text: command_line.to_string(),
1710
- call: McpToolCall {
1711
- server,
1712
- tool,
1713
- arguments,
1714
- },
1715
- }))
1716
- }
1717
-
1718
- fn format_tool_result(result: &CallToolResult) -> String {
1719
- if result.content.is_empty() {
1720
- return String::new();
1721
- }
1722
-
1723
- let mut parts = Vec::new();
1724
-
1725
- for item in &result.content {
1726
- match item {
1727
- ToolContent::Text { text } => parts.push(text.clone()),
1728
- ToolContent::Image { mime_type, .. } => {
1729
- parts.push(format!("Image content returned (mime type: {})", mime_type));
1730
- }
1731
- ToolContent::Resource { resource } => {
1732
- let name = if resource.name.is_empty() {
1733
- resource.uri.clone()
1734
- } else {
1735
- format!("{} ({})", resource.name, resource.uri)
1736
- };
1737
- parts.push(format!("Resource: {}", name));
1738
- }
1739
- }
1740
- }
1741
-
1742
- parts.join("\n")
1743
- }
1744
-
1745
- fn log_tool_execution(server: &str, tool: &str, output: &str, is_error: bool) -> Result<()> {
1746
- let mut out = stdout();
1747
- let color = if is_error { Color::Yellow } else { Color::DarkGrey };
1748
-
1749
- out.execute(SetForegroundColor(color))?;
1750
-
1751
- if is_error {
1752
- println!("MCP tool {}.{} returned an error.", server, tool);
1753
- } else {
1754
- println!("MCP tool {}.{} executed.", server, tool);
1755
- }
1756
-
1757
- out.execute(ResetColor)?;
1758
-
1759
- let trimmed = output.trim();
1760
- if !trimmed.is_empty() {
1761
- println!("{}", truncate_for_display(trimmed, 600));
1762
- }
1763
-
1764
- println!();
1765
- Ok(())
1766
- }
1767
-
1768
- fn truncate_for_display(text: &str, max_chars: usize) -> String {
1769
- let mut result = String::new();
1770
- let mut count = 0;
1771
-
1772
- for ch in text.chars() {
1773
- if count >= max_chars {
1774
- result.push_str("\n... (truncated)");
1775
- break;
1776
- }
1777
- result.push(ch);
1778
- count += 1;
1779
- }
1780
-
1781
- result
1782
- }
1783
-
1784
- fn truncate_inline(text: &str, max_chars: usize) -> String {
1785
- let mut result = String::new();
1786
- let mut count = 0;
1787
-
1788
- for ch in text.chars() {
1789
- if count >= max_chars {
1790
- result.push_str("... (truncated)");
1791
- break;
1792
- }
1793
- if ch.is_control() && ch != '\n' && ch != '\t' {
1794
- continue;
1795
- }
1796
- result.push(ch);
1797
- count += 1;
1798
- }
1799
-
1800
- result.replace('\n', " ")
1801
- }
1802
-
1803
- fn strip_file_blocks(text: &str) -> String {
1804
- let mut output = String::new();
1805
- let mut lines = text.lines();
1806
-
1807
- while let Some(line) = lines.next() {
1808
- if line.trim_start().starts_with("```file:") {
1809
- while let Some(next) = lines.next() {
1810
- if next.trim() == "```" {
1811
- break;
1812
- }
1813
- }
1814
- continue;
1815
- }
1816
- output.push_str(line);
1817
- output.push('\n');
1818
- }
1819
-
1820
- output.trim_end_matches('\n').to_string()
1821
- }
1822
-
1823
- fn get_model_display_name(model: &str) -> String {
1824
- if model.contains("sonnet") {
1825
- "Sonnet".to_string()
1826
- } else if model.contains("opus") {
1827
- "Opus".to_string()
1828
- } else if model.contains("haiku") {
1829
- "Haiku".to_string()
1830
- } else if model.starts_with("gpt-5-codex") {
1831
- "GPT-5 Codex".to_string()
1832
- } else if model.starts_with("gpt-4o") {
1833
- "GPT-4o".to_string()
1834
- } else if model.starts_with("gpt-4-turbo") {
1835
- "GPT-4 Turbo".to_string()
1836
- } else if model.starts_with("glm-4.6") {
1837
- "GLM-4.6".to_string()
1838
- } else if model.starts_with("glm-4.5") {
1839
- "GLM-4.5".to_string()
1840
- } else if model.starts_with("glm") {
1841
- "GLM".to_string()
1842
- } else {
1843
- model.to_string()
1844
- }
1845
- }
1846
-
1847
- fn print_assistant_message(text: &str, model: &str) -> Result<()> {
1848
- let mut out = stdout();
1849
- let model_name = get_model_display_name(model);
1850
- let trimmed_text = text.trim();
1851
-
1852
- println!();
1853
- out.execute(SetForegroundColor(Color::Green))?;
1854
- out.execute(Print("● "))?;
1855
- out.execute(Print(format!("{}:", model_name)))?;
1856
- out.execute(ResetColor)?;
1857
- println!();
1858
-
1859
- print_formatted_text(trimmed_text, 2)?;
1860
- println!();
1861
- println!();
1862
- Ok(())
1863
- }
1864
-
1865
- fn print_formatted_text(text: &str, indent_spaces: usize) -> Result<()> {
1866
- let mut out = stdout();
1867
- let indent = " ".repeat(indent_spaces);
1868
- let lines: Vec<&str> = text.lines().collect();
1869
-
1870
- for (i, line) in lines.iter().enumerate() {
1871
- print!("{}", indent);
1872
-
1873
- let mut chars = line.chars().peekable();
1874
- let mut buffer = String::new();
1875
-
1876
- while let Some(ch) = chars.next() {
1877
- if ch == '*' && chars.peek() == Some(&'*') {
1878
- chars.next();
1879
-
1880
- if !buffer.is_empty() {
1881
- print!("{}", buffer);
1882
- buffer.clear();
1883
- }
1884
-
1885
- let mut bold_text = String::new();
1886
- let mut found_closing = false;
1887
-
1888
- while let Some(ch) = chars.next() {
1889
- if ch == '*' && chars.peek() == Some(&'*') {
1890
- chars.next();
1891
- found_closing = true;
1892
- break;
1893
- }
1894
- bold_text.push(ch);
1895
- }
1896
-
1897
- if found_closing && !bold_text.is_empty() {
1898
- out.execute(SetAttribute(Attribute::Bold))?;
1899
- print!("{}", bold_text);
1900
- out.execute(SetAttribute(Attribute::Reset))?;
1901
- } else {
1902
- print!("**{}", bold_text);
1903
- }
1904
- } else {
1905
- buffer.push(ch);
1906
- }
1907
- }
1908
-
1909
- if !buffer.is_empty() {
1910
- print!("{}", buffer);
1911
- }
1912
-
1913
- if i < lines.len() - 1 {
1914
- println!();
1915
- }
1916
- }
1917
-
1918
- Ok(())
1919
- }
1920
-
1921
- fn print_tool_command(command: &str) -> Result<()> {
1922
- let mut out = stdout();
1923
- out.execute(SetForegroundColor(Color::DarkGrey))?;
1924
- println!("{}", command);
1925
- out.execute(ResetColor)?;
1926
- Ok(())
1927
- }
1928
-
1929
- struct Spinner {
1930
- stop: Arc<AtomicBool>,
1931
- handle: JoinHandle<()>,
1932
- }
1933
-
1934
- impl Spinner {
1935
- fn start(message: String) -> Self {
1936
- let stop = Arc::new(AtomicBool::new(true));
1937
- let stop_clone = stop.clone();
1938
-
1939
- let handle = tokio::spawn(async move {
1940
- let symbols = ['|', '/', '-', '\\'];
1941
- let mut index = 0usize;
1942
-
1943
- while stop_clone.load(Ordering::Relaxed) {
1944
- let symbol = symbols[index % symbols.len()];
1945
- let mut out = stdout();
1946
- let _ = write!(out, "\r{} {}", symbol, message);
1947
- let _ = out.flush();
1948
- index = (index + 1) % symbols.len();
1949
- sleep(Duration::from_millis(120)).await;
1950
- }
1951
-
1952
- let mut out = stdout();
1953
- let _ = write!(out, "\r\x1B[K");
1954
- let _ = out.flush();
1955
- });
1956
-
1957
- Self { stop, handle }
1958
- }
1959
-
1960
- async fn stop(self) {
1961
- self.stop.store(false, Ordering::Relaxed);
1962
- let _ = self.handle.await;
1963
- }
1964
- }
1965
-
1966
- fn print_file_change_summary(path: &Path, before: &str, after: &str) -> Result<()> {
1967
- let mut out = stdout();
1968
-
1969
- let diff = TextDiff::from_lines(before, after);
1970
- let mut additions = 0;
1971
- let mut removals = 0;
1972
-
1973
- for change in diff.iter_all_changes() {
1974
- match change.tag() {
1975
- ChangeTag::Delete => removals += 1,
1976
- ChangeTag::Insert => additions += 1,
1977
- _ => {}
1978
- }
1979
- }
1980
-
1981
- if before.is_empty() {
1982
- out.execute(SetForegroundColor(Color::Green)).ok();
1983
- println!("● Create({})", path.display());
1984
- out.execute(ResetColor).ok();
1985
- println!(" ⎿ Created {} with {} lines", path.display(), additions);
1986
- } else {
1987
- out.execute(SetForegroundColor(Color::Green)).ok();
1988
- println!("● Update({})", path.display());
1989
- out.execute(ResetColor).ok();
1990
- println!(" ⎿ Updated {} with {} addition{} and {} removal{}",
1991
- path.display(),
1992
- additions, if additions == 1 { "" } else { "s" },
1993
- removals, if removals == 1 { "" } else { "s" }
1994
- );
1995
- }
1996
-
1997
- let mut old_line = 1usize;
1998
- let mut new_line = 1usize;
1999
- let mut context_before: Vec<(usize, String)> = Vec::new();
2000
- let max_context = 3;
2001
-
2002
- for change in diff.iter_all_changes() {
2003
- let value = change.value().trim_end_matches('\n');
2004
- match change.tag() {
2005
- ChangeTag::Equal => {
2006
- context_before.push((old_line, value.to_string()));
2007
- if context_before.len() > max_context {
2008
- context_before.remove(0);
2009
- }
2010
- old_line += 1;
2011
- new_line += 1;
2012
- }
2013
- ChangeTag::Delete => {
2014
- for (line_num, text) in &context_before {
2015
- print_context_line(*line_num, text);
2016
- }
2017
- context_before.clear();
2018
-
2019
- print_diff_line_with_bg('-', old_line, value, Color::Rgb { r: 60, g: 20, b: 20 })?;
2020
- old_line += 1;
2021
- }
2022
- ChangeTag::Insert => {
2023
- for (line_num, text) in &context_before {
2024
- print_context_line(*line_num, text);
2025
- }
2026
- context_before.clear();
2027
-
2028
- print_diff_line_with_bg('+', new_line, value, Color::Rgb { r: 20, g: 60, b: 20 })?;
2029
- new_line += 1;
2030
- }
2031
- }
2032
- }
2033
-
2034
- println!();
2035
- Ok(())
2036
- }
2037
-
2038
- fn print_context_line(line_number: usize, text: &str) {
2039
- println!(" {:>5} {}", line_number, text);
2040
- }
2041
-
2042
- fn print_diff_line_with_bg(prefix: char, line_number: usize, text: &str, bg_color: Color) -> Result<()> {
2043
- let mut out = stdout();
2044
-
2045
- out.execute(Print(format!(" {:>5} ", line_number)))?;
2046
-
2047
- let prefix_color = if prefix == '-' { Color::Red } else { Color::Green };
2048
- out.execute(SetBackgroundColor(bg_color))?;
2049
- out.execute(SetForegroundColor(prefix_color))?;
2050
- out.execute(Print(prefix))?;
2051
-
2052
- if !text.is_empty() {
2053
- out.execute(SetForegroundColor(Color::White))?;
2054
- out.execute(Print(format!(" {}", text)))?;
2055
- }
2056
-
2057
- out.execute(ResetColor)?;
2058
- println!();
2059
- Ok(())
2060
- }
2061
-
2062
-
2063
- fn execute_bash_command(command: &str) -> Result<String> {
2064
- use std::process::Command;
2065
-
2066
- let output = if cfg!(target_os = "windows") {
2067
- Command::new("cmd")
2068
- .args(&["/C", command])
2069
- .output()
2070
- .context("Failed to execute bash command")?
2071
- } else {
2072
- Command::new("sh")
2073
- .arg("-c")
2074
- .arg(command)
2075
- .output()
2076
- .context("Failed to execute bash command")?
2077
- };
2078
-
2079
- let stdout = String::from_utf8_lossy(&output.stdout);
2080
- let stderr = String::from_utf8_lossy(&output.stderr);
2081
-
2082
- let mut result = String::new();
2083
- if !stdout.is_empty() {
2084
- result.push_str(&stdout);
2085
- }
2086
- if !stderr.is_empty() {
2087
- if !result.is_empty() {
2088
- result.push_str("\n");
2089
- }
2090
- result.push_str("STDERR:\n");
2091
- result.push_str(&stderr);
2092
- }
2093
-
2094
- if result.is_empty() {
2095
- result = "(command produced no output)".to_string();
2096
- }
2097
-
2098
- Ok(result)
2099
- }
2100
-
2101
- fn parse_file_blocks(input: &str) -> HashMap<PathBuf, String> {
2102
- let mut map = HashMap::new();
2103
- let mut lines = input.lines();
2104
-
2105
- while let Some(line) = lines.next() {
2106
- if let Some(rest) = line.strip_prefix("```file:") {
2107
- let file_path = normalize_response_path(rest);
2108
- let mut content = String::new();
2109
-
2110
- while let Some(next_line) = lines.next() {
2111
- if next_line.trim() == "```" {
2112
- break;
2113
- }
2114
- content.push_str(next_line);
2115
- content.push('\n');
2116
- }
2117
-
2118
- if content.ends_with('\n') {
2119
- content.pop();
2120
- if content.ends_with('\r') {
2121
- content.pop();
2122
- }
2123
- }
2124
-
2125
- map.insert(file_path, content);
2126
- }
2127
- }
2128
-
2129
- map
2130
- }
2131
-
2132
- fn normalize_response_path(raw: &str) -> PathBuf {
2133
- let mut trimmed = raw.trim();
2134
- while let Some(rest) = trimmed.strip_prefix("./") {
2135
- trimmed = rest;
2136
- }
2137
- while let Some(rest) = trimmed.strip_prefix(".\\") {
2138
- trimmed = rest;
2139
- }
2140
- let normalized = trimmed.replace('\\', "/");
2141
- PathBuf::from(normalized)
2142
- }
2143
-
2144
- fn print_diff(before: &str, after: &str) {
2145
- let diff = TextDiff::from_lines(before, after);
2146
- for change in diff.iter_all_changes() {
2147
- match change.tag() {
2148
- ChangeTag::Delete => print!("-{}", change),
2149
- ChangeTag::Insert => print!("+{}", change),
2150
- ChangeTag::Equal => print!(" {}", change),
2151
- }
2152
- }
2153
- }