@pheem49/mint 1.8.2 → 1.10.0

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.
Files changed (106) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +297 -107
  4. package/bin/mint +0 -0
  5. package/crates/mint-cli/Cargo.toml +1 -0
  6. package/crates/mint-cli/src/actions.rs +233 -0
  7. package/crates/mint-cli/src/agent.rs +382 -127
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/image.rs +8 -8
  10. package/crates/mint-cli/src/interactive.rs +2888 -0
  11. package/crates/mint-cli/src/main.rs +391 -2506
  12. package/crates/mint-cli/src/markdown.rs +294 -0
  13. package/crates/mint-cli/src/onboard.rs +152 -109
  14. package/crates/mint-cli/src/setup.rs +143 -143
  15. package/crates/mint-cli/src/updater.rs +48 -6
  16. package/crates/mint-core/Cargo.toml +1 -0
  17. package/crates/mint-core/src/agent_loop.rs +1 -0
  18. package/crates/mint-core/src/api_server.rs +431 -65
  19. package/crates/mint-core/src/browser.rs +630 -102
  20. package/crates/mint-core/src/channels.rs +105 -6
  21. package/crates/mint-core/src/chat.rs +174 -11
  22. package/crates/mint-core/src/code_tools.rs +47 -31
  23. package/crates/mint-core/src/config.rs +302 -17
  24. package/crates/mint-core/src/image_gen.rs +299 -38
  25. package/crates/mint-core/src/lib.rs +10 -7
  26. package/crates/mint-core/src/mcp.rs +119 -6
  27. package/crates/mint-core/src/memory.rs +6 -1
  28. package/crates/mint-core/src/orchestration.rs +237 -96
  29. package/crates/mint-core/src/pictures.rs +67 -5
  30. package/crates/mint-core/src/semantic.rs +1 -1
  31. package/crates/mint-core/src/shell.rs +1 -1
  32. package/crates/mint-core/src/skills.rs +128 -25
  33. package/crates/mint-core/src/video_gen.rs +235 -0
  34. package/package.json +8 -2
  35. package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
  36. package/shared-modules/linux-audio/fluidsynth2.json +8 -2
  37. package/shared-modules/linux-audio/lilv.json +26 -19
  38. package/shared-modules/linux-audio/lv2.json +0 -1
  39. package/shared-modules/luajit/luajit.json +2 -2
  40. package/skills-lock.json +41 -0
  41. package/src/renderer/index-web.html +1 -1
  42. package/src/renderer/index.html +1 -1
  43. package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
  44. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  45. package/src/renderer/shared/platform.ts +3 -0
  46. package/src/renderer/shared/types.ts +4 -0
  47. package/src/renderer/shared/utils/approval.tsx +86 -7
  48. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  49. package/src/renderer/shared/utils/markdown.tsx +217 -88
  50. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  51. package/src/renderer/src/components/ChatPanel.tsx +486 -69
  52. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  53. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  54. package/src/renderer/src/components/MintDashboard.tsx +195 -7
  55. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  56. package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
  57. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  58. package/src/renderer/src/components/Settings/GeneralTab.tsx +795 -413
  59. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  60. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  61. package/src/renderer/src/components/SettingsWindow.tsx +106 -3
  62. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  63. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  64. package/src/renderer/src/css/image-studio.css +106 -0
  65. package/src/renderer/src/css/settings.css +158 -0
  66. package/src/renderer/src/css/styles.css +368 -1
  67. package/src/renderer/src/css/veo-studio.css +626 -0
  68. package/src/renderer/src/index.css +1 -0
  69. package/src/renderer/src/tauri.ts +144 -17
  70. package/src/renderer/src-web/components/ChatPanel.tsx +385 -93
  71. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  72. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  73. package/src/renderer/src-web/components/MintDashboard.tsx +156 -5
  74. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  75. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
  76. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  77. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +795 -411
  78. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  79. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  80. package/src/renderer/src-web/components/SettingsWindow.tsx +110 -3
  81. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  82. package/src/renderer/src-web/css/image-studio.css +106 -0
  83. package/src/renderer/src-web/css/settings.css +158 -0
  84. package/src/renderer/src-web/css/styles.css +369 -1
  85. package/src/renderer/src-web/css/veo-studio.css +650 -0
  86. package/src/renderer/src-web/index.css +1 -0
  87. package/src/renderer/src-web/tauri.ts +135 -16
  88. package/src-tauri/src/desktop.rs +18 -16
  89. package/src-tauri/src/discord_rpc.rs +1 -1
  90. package/src-tauri/src/lib.rs +155 -25
  91. package/src-tauri/src/webhooks.rs +45 -31
  92. package/src-tauri/src/workflows.rs +1 -0
  93. package/src-tauri/tauri.conf.json +1 -1
  94. package/temp_gridgeist/AGENTS.md +8 -0
  95. package/temp_gridgeist/CHANGELOG.md +130 -0
  96. package/temp_gridgeist/LICENSE +21 -0
  97. package/temp_gridgeist/README.md +323 -0
  98. package/temp_gridgeist/README.th.md +325 -0
  99. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  100. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  101. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  102. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  103. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  104. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  105. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  106. package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
@@ -2,7 +2,6 @@ use anyhow::Result;
2
2
  use clap::{Parser, Subcommand};
3
3
  use std::{
4
4
  fs,
5
- io::{self, Write},
6
5
  path::{Path, PathBuf},
7
6
  };
8
7
 
@@ -12,41 +11,98 @@ use mint_core::{
12
11
  build_symbol_index, classify_shell_command, config_path, create_folder, execute_native_plugin,
13
12
  fetch_github_repo_summary, find_paths, generate_images, index_semantic_code, initialize_config,
14
13
  inspect_code_plan, list_code_files, load_config, native_plugins,
15
- orchestrate_chat_stream_with_fallback, orchestrate_chat_with_fallback, parse_github_url,
16
- propose_code_edits, read_code_file, repository_summary, run_shell_command, search_code,
17
- search_semantic_code, set_config_value,
14
+ orchestrate_chat_with_fallback, parse_github_url, propose_code_edits, read_code_file,
15
+ repository_summary, run_shell_command, search_code, search_semantic_code, set_config_value,
18
16
  };
19
17
 
18
+ mod actions;
20
19
  mod agent;
21
20
  mod gmail;
22
21
  mod image;
22
+ mod interactive;
23
+ mod markdown;
23
24
  mod mcp;
24
25
  mod onboard;
25
26
  mod setup;
26
27
  mod skills;
27
28
  mod updater;
28
29
 
29
- const RESET: &str = "\x1b[0m";
30
- const MINT: &str = "\x1b[32m";
31
- const BLUE: &str = "\x1b[38;2;78;201;216m";
32
- const DIM: &str = "\x1b[90m";
33
- const ERROR: &str = "\x1b[31m";
34
- const WARN: &str = "\x1b[33m";
35
- const COMPOSER_BG: &str = "\x1b[48;2;35;39;45m";
30
+ pub use interactive::{
31
+ SESSION_APPROVED, active_model, confirm, print_welcome_banner, run_interactive_chat,
32
+ };
33
+
34
+ pub const RESET: &str = "\x1b[0m";
35
+ pub const MINT: &str = "\x1b[32m";
36
+ pub const BLUE: &str = "\x1b[38;2;78;201;216m";
37
+ pub const DIM: &str = "\x1b[90m";
38
+ pub const ERROR: &str = "\x1b[31m";
39
+ pub const WARN: &str = "\x1b[33m";
40
+ pub const COMPOSER_BG: &str = "\x1b[48;2;35;39;45m";
36
41
 
37
- async fn run_code_agent_with_saved_image(
42
+ pub(crate) async fn run_code_agent_with_saved_image(
38
43
  task: &str,
39
44
  current_dir: &Path,
40
45
  config: &MintConfig,
41
46
  image_data_uri: Option<String>,
47
+ video_data_uri: Option<String>,
42
48
  options: agent::AgentOptions,
43
49
  ) -> Result<()> {
44
50
  let sent_image = image_data_uri.clone();
45
- agent::run_code_agent_with_options(task, current_dir, config, image_data_uri, options).await?;
51
+ let sent_video = video_data_uri.clone();
52
+ agent::run_code_agent_with_options(
53
+ task,
54
+ current_dir,
55
+ config,
56
+ image_data_uri,
57
+ video_data_uri,
58
+ options,
59
+ )
60
+ .await?;
61
+ // Save any attached images and videos that were sent with the task
46
62
  image::save_sent_image_after_send(sent_image.as_deref(), task);
63
+ image::save_sent_image_after_send(sent_video.as_deref(), task);
47
64
  Ok(())
48
65
  }
49
66
 
67
+ fn configured(config: &mint_core::MintConfig, keys: &[&str]) -> bool {
68
+ keys.iter().all(|key| {
69
+ config
70
+ .extra
71
+ .get(*key)
72
+ .and_then(serde_json::Value::as_str)
73
+ .is_some_and(|value| !value.trim().is_empty())
74
+ })
75
+ }
76
+
77
+ fn edit_content(
78
+ content: Option<String>,
79
+ from_file: Option<PathBuf>,
80
+ config: &MintConfig,
81
+ ) -> Result<String> {
82
+ match from_file {
83
+ Some(path) => {
84
+ let path = assert_path_capability(&path, Capability::Read, config)?;
85
+ Ok(fs::read_to_string(path)?)
86
+ }
87
+ None => Ok(content.unwrap_or_default()),
88
+ }
89
+ }
90
+
91
+ fn file_edits(values: &[String], config: &MintConfig) -> Result<Vec<CodeEdit>> {
92
+ values
93
+ .iter()
94
+ .map(|value| {
95
+ let (target, source) = value
96
+ .split_once('=')
97
+ .ok_or_else(|| anyhow::anyhow!("edit must use TARGET=SOURCE format"))?;
98
+ Ok(CodeEdit {
99
+ path: PathBuf::from(target),
100
+ content: edit_content(None, Some(PathBuf::from(source)), config)?,
101
+ })
102
+ })
103
+ .collect()
104
+ }
105
+
50
106
  #[derive(Debug, Parser)]
51
107
  #[command(name = "mint", version, about = "Mint native CLI")]
52
108
  struct Cli {
@@ -199,6 +255,23 @@ enum Command {
199
255
  #[arg(long)]
200
256
  output: Option<PathBuf>,
201
257
  },
258
+ /// Generate a video from a text prompt using Google Veo.
259
+ Veo {
260
+ /// Text description of the video to generate.
261
+ prompt: String,
262
+ /// Aspect ratio: 16:9, 9:16, or 1:1 [default: 16:9]
263
+ #[arg(long, default_value = "16:9")]
264
+ aspect: String,
265
+ /// Duration of the video in seconds (5 or 8) [default: 5]
266
+ #[arg(long, default_value_t = 5)]
267
+ duration: u32,
268
+ /// Negative prompt — elements to avoid in the video
269
+ #[arg(long)]
270
+ negative: Option<String>,
271
+ /// Save the generated video to this path
272
+ #[arg(long)]
273
+ output: Option<PathBuf>,
274
+ },
202
275
  }
203
276
 
204
277
  #[derive(Debug, Subcommand)]
@@ -455,7 +528,9 @@ enum MemoryCommand {
455
528
  },
456
529
  }
457
530
 
458
- fn print_mcp_servers(servers: &std::collections::BTreeMap<String, mint_core::mcp::McpServer>) {
531
+ pub(crate) fn print_mcp_servers(
532
+ servers: &std::collections::BTreeMap<String, mint_core::mcp::McpServer>,
533
+ ) {
459
534
  if servers.is_empty() {
460
535
  println!("{DIM}(No MCP servers configured.){RESET}\n");
461
536
  return;
@@ -474,6 +549,7 @@ fn print_mcp_servers(servers: &std::collections::BTreeMap<String, mint_core::mcp
474
549
  async fn main() -> Result<()> {
475
550
  match Cli::parse().command {
476
551
  None => {
552
+ mint_core::channels::start_channels();
477
553
  run_interactive_chat().await?;
478
554
  }
479
555
  Some(cmd) => match cmd {
@@ -494,8 +570,8 @@ async fn main() -> Result<()> {
494
570
  println!("{}", serde_json::to_string_pretty(&load_config()?)?)
495
571
  }
496
572
  ConfigCommand::Set { key, value } => {
497
- let value = serde_json::from_str(&value)
498
- .unwrap_or_else(|_| serde_json::Value::String(value));
573
+ let value =
574
+ serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value));
499
575
  println!(
500
576
  "{}",
501
577
  serde_json::to_string_pretty(&set_config_value(&key, value)?)?
@@ -541,8 +617,9 @@ async fn main() -> Result<()> {
541
617
  run_cli_agent_task(task).await?;
542
618
  }
543
619
  Command::Auto => {
544
- println!("🚀 Starting Mint Browser Automation Environment...");
545
- let config = mint_core::load_config()?;
620
+ let config = load_config()?;
621
+ print_welcome_banner(&config);
622
+ println!("\n🚀 Starting Mint Browser Automation Environment...");
546
623
  mint_core::spawn_automation_browser(&config)
547
624
  .await
548
625
  .map_err(anyhow::Error::msg)?;
@@ -555,6 +632,10 @@ async fn main() -> Result<()> {
555
632
  "browser_click",
556
633
  "browser_type",
557
634
  "browser_read",
635
+ "browser_mouse_move",
636
+ "browser_mouse_click",
637
+ "browser_key_press",
638
+ "browser_screenshot",
558
639
  ] {
559
640
  if config_mut.disabled_tools.contains(&tool.to_string()) {
560
641
  config_mut.disabled_tools.retain(|x| x != *tool);
@@ -564,7 +645,7 @@ async fn main() -> Result<()> {
564
645
  if changed {
565
646
  mint_core::save_config(&config_mut)?;
566
647
  println!(
567
- "✅ Enabled browser automation tools in config: browser_open, browser_click, browser_type, browser_read"
648
+ "✅ Enabled browser automation tools in config: browser_open, browser_click, browser_type, browser_read, browser_mouse_move, browser_mouse_click, browser_key_press, browser_screenshot"
568
649
  );
569
650
  }
570
651
 
@@ -579,7 +660,7 @@ async fn main() -> Result<()> {
579
660
 
580
661
  // Start tailing the log file in a background task
581
662
  let log_dir = dirs::config_dir()
582
- .unwrap_or_else(|| std::env::temp_dir())
663
+ .unwrap_or_else(std::env::temp_dir)
583
664
  .join("mint");
584
665
  let log_file = log_dir.join("browser-automation.log");
585
666
 
@@ -591,40 +672,53 @@ async fn main() -> Result<()> {
591
672
  use std::io::{BufRead, BufReader, Seek, SeekFrom};
592
673
  let mut file_pos = 0;
593
674
  loop {
594
- if log_file_clone.exists() {
595
- if let Ok(mut file) = std::fs::File::open(&log_file_clone) {
596
- if let Ok(_) = file.seek(SeekFrom::Start(file_pos)) {
597
- let reader = BufReader::new(file);
598
- for line in reader.lines() {
599
- if let Ok(line_str) = line {
600
- if line_str.contains("[NAVIGATE]")
601
- || line_str.contains("[NAVIGATE_SUCCESS]")
602
- {
603
- println!("🌐 {line_str}");
604
- } else if line_str.contains("[CLICK]")
605
- || line_str.contains("[CLICK_SUCCESS]")
606
- {
607
- println!("🖱️ {line_str}");
608
- } else if line_str.contains("[TYPE]")
609
- || line_str.contains("[TYPE_SUCCESS]")
610
- {
611
- println!("⌨️ {line_str}");
612
- } else if line_str.contains("[READ]")
613
- || line_str.contains("[READ_SUCCESS]")
614
- {
615
- println!("📖 {line_str}");
616
- } else if line_str.contains("_ERROR]") {
617
- println!("❌ {line_str}");
618
- } else {
619
- println!("📝 {line_str}");
620
- }
621
- }
622
- }
623
- if let Ok(pos) = log_file_clone.metadata().map(|m| m.len()) {
624
- file_pos = pos;
625
- }
675
+ if log_file_clone.exists()
676
+ && let Ok(mut file) = std::fs::File::open(&log_file_clone)
677
+ && file.seek(SeekFrom::Start(file_pos)).is_ok()
678
+ {
679
+ let reader = BufReader::new(file);
680
+ for line_str in reader.lines().flatten() {
681
+ if line_str.contains("[NAVIGATE]")
682
+ || line_str.contains("[NAVIGATE_SUCCESS]")
683
+ {
684
+ println!("🌐 {line_str}");
685
+ } else if line_str.contains("[CLICK]")
686
+ || line_str.contains("[CLICK_SUCCESS]")
687
+ {
688
+ println!("🖱️ {line_str}");
689
+ } else if line_str.contains("[TYPE]")
690
+ || line_str.contains("[TYPE_SUCCESS]")
691
+ {
692
+ println!("⌨️ {line_str}");
693
+ } else if line_str.contains("[READ]")
694
+ || line_str.contains("[READ_SUCCESS]")
695
+ {
696
+ println!("📖 {line_str}");
697
+ } else if line_str.contains("[MOUSE_MOVE]")
698
+ || line_str.contains("[MOUSE_MOVE_SUCCESS]")
699
+ {
700
+ println!("🖱️ {line_str}");
701
+ } else if line_str.contains("[MOUSE_CLICK]")
702
+ || line_str.contains("[MOUSE_CLICK_SUCCESS]")
703
+ {
704
+ println!("🖱️ ● {line_str}");
705
+ } else if line_str.contains("[KEY_PRESS]")
706
+ || line_str.contains("[KEY_PRESS_SUCCESS]")
707
+ {
708
+ println!("⌨️ {line_str}");
709
+ } else if line_str.contains("[SCREENSHOT]")
710
+ || line_str.contains("[SCREENSHOT_SUCCESS]")
711
+ {
712
+ println!("📸 {line_str}");
713
+ } else if line_str.contains("_ERROR]") {
714
+ println!("❌ {line_str}");
715
+ } else {
716
+ println!("📝 {line_str}");
626
717
  }
627
718
  }
719
+ if let Ok(pos) = log_file_clone.metadata().map(|m| m.len()) {
720
+ file_pos = pos;
721
+ }
628
722
  }
629
723
  tokio::time::sleep(std::time::Duration::from_millis(100)).await;
630
724
  }
@@ -637,6 +731,40 @@ async fn main() -> Result<()> {
637
731
  launch_mint_target("web".into()).await?;
638
732
  }
639
733
  Command::Api { port } => {
734
+ let config = load_config()?;
735
+ print_welcome_banner(&config);
736
+
737
+ println!("\n{MINT}✔ Mint API Server is running!{RESET}\n");
738
+ println!(
739
+ " {BLUE}API Server URL:{RESET} {MINT}http://localhost:{}{RESET}\n",
740
+ port
741
+ );
742
+
743
+ println!("Messaging Bridges Status:");
744
+
745
+ let bridges = [
746
+ ("enableTelegramBridge", "Telegram Bot Bridge"),
747
+ ("enableDiscordBridge", "Discord Bot Bridge"),
748
+ ("enableSlackBridge", "Slack Bot Bridge"),
749
+ ("enableLineBridge", "LINE Bot Bridge"),
750
+ ("enableWhatsappBridge", "WhatsApp Cloud Bridge"),
751
+ ];
752
+
753
+ for &(key, name) in &bridges {
754
+ let enabled = config
755
+ .extra
756
+ .get(key)
757
+ .and_then(|v| v.as_bool())
758
+ .unwrap_or(false);
759
+ if enabled {
760
+ println!(" {MINT}● {name:<23} [Active]{RESET}");
761
+ } else {
762
+ println!(" {DIM}○ {name:<23} [Inactive]{RESET}");
763
+ }
764
+ }
765
+
766
+ println!("\n{DIM}(API Requests & Error logs will be displayed below){RESET}\n");
767
+ println!("{DIM}Press Ctrl+C to stop{RESET}\n");
640
768
  mint_core::start_api_server(port).await?;
641
769
  }
642
770
  Command::Mcp { command } => match command {
@@ -678,14 +806,26 @@ async fn main() -> Result<()> {
678
806
  server,
679
807
  tool,
680
808
  arguments,
681
- } => println!(
682
- "{}",
683
- serde_json::to_string_pretty(&mcp::call(
684
- &server,
685
- &tool,
686
- serde_json::from_str(&arguments)?
687
- )?)?
688
- ),
809
+ } => {
810
+ use indicatif::{ProgressBar, ProgressStyle};
811
+ use std::time::Duration;
812
+ let spinner = ProgressBar::new_spinner();
813
+ spinner.set_style(
814
+ ProgressStyle::default_spinner()
815
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
816
+ .template("{spinner:.green} {msg}")
817
+ .unwrap(),
818
+ );
819
+ spinner.set_message(format!(
820
+ "Executing MCP tool '{}' on server '{}'...",
821
+ tool, server
822
+ ));
823
+ spinner.enable_steady_tick(Duration::from_millis(80));
824
+
825
+ let res = mcp::call(&server, &tool, serde_json::from_str(&arguments)?);
826
+ spinner.finish_and_clear();
827
+ println!("{}", serde_json::to_string_pretty(&res?)?);
828
+ }
689
829
  },
690
830
  Command::Gmail { command } => match command {
691
831
  GmailCommand::Auth { no_open, port } => gmail::auth(no_open, port).await?,
@@ -705,20 +845,20 @@ async fn main() -> Result<()> {
705
845
  home.join(".config").join("mint").join("mint-skills");
706
846
  mint_core::skills::load_skills_from_dir(&global_skills_path, &mut skills);
707
847
  }
708
- if let Ok(root) = std::env::current_dir() {
709
- if let Ok(root) = root.canonicalize() {
710
- let workspace_skills_path1 = root.join(".agents").join("skills");
711
- mint_core::skills::load_skills_from_dir(
712
- &workspace_skills_path1,
713
- &mut skills,
714
- );
848
+ if let Ok(root) = std::env::current_dir()
849
+ && let Ok(root) = root.canonicalize()
850
+ {
851
+ let workspace_skills_path1 = root.join(".agents").join("skills");
852
+ mint_core::skills::load_skills_from_dir(
853
+ &workspace_skills_path1,
854
+ &mut skills,
855
+ );
715
856
 
716
- let workspace_skills_path2 = root.join("skills");
717
- mint_core::skills::load_skills_from_dir(
718
- &workspace_skills_path2,
719
- &mut skills,
720
- );
721
- }
857
+ let workspace_skills_path2 = root.join("skills");
858
+ mint_core::skills::load_skills_from_dir(
859
+ &workspace_skills_path2,
860
+ &mut skills,
861
+ );
722
862
  }
723
863
 
724
864
  let mut unique_skills = std::collections::BTreeMap::new();
@@ -755,17 +895,39 @@ async fn main() -> Result<()> {
755
895
  anyhow::bail!("use mint learn <path>, --list, or --delete <id|path|name>");
756
896
  }
757
897
  }
758
- Command::Symbols { root, limit } => println!(
759
- "{}",
760
- serde_json::to_string_pretty(&build_symbol_index(&root, limit, &load_config()?)?)?
761
- ),
898
+ Command::Symbols { root, limit } => {
899
+ use indicatif::{ProgressBar, ProgressStyle};
900
+ use std::time::Duration;
901
+ let spinner = ProgressBar::new_spinner();
902
+ spinner.set_style(
903
+ ProgressStyle::default_spinner()
904
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
905
+ .template("{spinner:.green} {msg}")
906
+ .unwrap(),
907
+ );
908
+ spinner.set_message("Building symbol index...");
909
+ spinner.enable_steady_tick(Duration::from_millis(80));
910
+ let index = build_symbol_index(&root, limit, &load_config()?);
911
+ spinner.finish_and_clear();
912
+ println!("{}", serde_json::to_string_pretty(&index?)?);
913
+ }
762
914
  Command::SemanticCode { command } => match command {
763
- SemanticCodeCommand::Index { root } => println!(
764
- "{}",
765
- serde_json::to_string_pretty(
766
- &index_semantic_code(&root, &load_config()?).await?
767
- )?
768
- ),
915
+ SemanticCodeCommand::Index { root } => {
916
+ use indicatif::{ProgressBar, ProgressStyle};
917
+ use std::time::Duration;
918
+ let spinner = ProgressBar::new_spinner();
919
+ spinner.set_style(
920
+ ProgressStyle::default_spinner()
921
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
922
+ .template("{spinner:.green} {msg}")
923
+ .unwrap(),
924
+ );
925
+ spinner.set_message("Indexing semantic code workspace...");
926
+ spinner.enable_steady_tick(Duration::from_millis(80));
927
+ let res = index_semantic_code(&root, &load_config()?).await;
928
+ spinner.finish_and_clear();
929
+ println!("{}", serde_json::to_string_pretty(&res?)?);
930
+ }
769
931
  SemanticCodeCommand::Search { query, root, limit } => println!(
770
932
  "{}",
771
933
  serde_json::to_string_pretty(
@@ -789,6 +951,7 @@ async fn main() -> Result<()> {
789
951
  &std::env::current_dir()?,
790
952
  &load_config()?,
791
953
  image_data_uri,
954
+ None,
792
955
  agent::AgentOptions::default(),
793
956
  )
794
957
  .await?;
@@ -801,6 +964,7 @@ async fn main() -> Result<()> {
801
964
  chat_id: Some(CHAT_CLI_ID.to_owned()),
802
965
  image_data_uri,
803
966
  audio_data_uri: None,
967
+ video_data_uri: None,
804
968
  document_attachment: None,
805
969
  workspace_path: None,
806
970
  agent_id: None,
@@ -856,7 +1020,7 @@ async fn main() -> Result<()> {
856
1020
  command,
857
1021
  } => {
858
1022
  let output = run_shell_command(&command.join(" "), &cwd, approve, &load_config()?)?;
859
- print_shell_output(&output);
1023
+ actions::print_shell_output(&output);
860
1024
  if !output.success {
861
1025
  anyhow::bail!(
862
1026
  "shell command exited with status {}",
@@ -1083,16 +1247,16 @@ async fn main() -> Result<()> {
1083
1247
  }
1084
1248
  }
1085
1249
  Command::Open { target } => {
1086
- open_system_handler(&target)?;
1250
+ actions::open_system_handler(&target)?;
1087
1251
  }
1088
1252
  Command::OpenApp { name } => {
1089
- launch_desktop_app(&name)?;
1253
+ actions::launch_desktop_app(&name)?;
1090
1254
  }
1091
1255
  Command::ReadFile { path } => {
1092
- read_file_content(&path)?;
1256
+ actions::read_file_content(&path)?;
1093
1257
  }
1094
1258
  Command::ReadFolder { path } => {
1095
- read_folder_content(&path)?;
1259
+ actions::read_folder_content(&path)?;
1096
1260
  }
1097
1261
  Command::Onboard => {
1098
1262
  onboard::run().await?;
@@ -1111,8 +1275,18 @@ async fn main() -> Result<()> {
1111
1275
  } => {
1112
1276
  let config = load_config()?;
1113
1277
  let count = count.clamp(1, 4);
1114
- eprint!("{DIM}✦ Generating {count} image(s)...{RESET}");
1115
- let _ = std::io::stderr().flush();
1278
+ use indicatif::{ProgressBar, ProgressStyle};
1279
+ use std::time::Duration;
1280
+ let spinner = ProgressBar::new_spinner();
1281
+ spinner.set_style(
1282
+ ProgressStyle::default_spinner()
1283
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1284
+ .template("{spinner:.green} {msg}")
1285
+ .unwrap(),
1286
+ );
1287
+ spinner.set_message(format!("Generating {} image(s)...", count));
1288
+ spinner.enable_steady_tick(Duration::from_millis(80));
1289
+
1116
1290
  let request = ImageGenRequest {
1117
1291
  prompt: prompt.clone(),
1118
1292
  negative_prompt: negative,
@@ -1120,13 +1294,14 @@ async fn main() -> Result<()> {
1120
1294
  num_images: Some(count),
1121
1295
  model: None,
1122
1296
  provider: None,
1297
+ image_data_uri: None,
1298
+ mask_data_uri: None,
1299
+ mode: None,
1123
1300
  };
1124
1301
  match generate_images(&config, &request).await {
1125
1302
  Ok(result) => {
1126
- eprintln!(
1127
- "\r{MINT}✦ Generated {} image(s) {RESET}",
1128
- result.images.len()
1129
- );
1303
+ spinner.finish_and_clear();
1304
+ eprintln!("{MINT}✦ Generated {} image(s){RESET}", result.images.len());
1130
1305
  let data_uris: Vec<String> = result
1131
1306
  .images
1132
1307
  .iter()
@@ -1153,21 +1328,83 @@ async fn main() -> Result<()> {
1153
1328
  ),
1154
1329
  }
1155
1330
  }
1156
- if let Some(desc) = &result.description {
1157
- if !desc.is_empty() {
1158
- println!("\n{DIM}{desc}{RESET}");
1159
- }
1331
+ if let Some(desc) = &result.description
1332
+ && !desc.is_empty()
1333
+ {
1334
+ println!("\n{DIM}{desc}{RESET}");
1160
1335
  }
1161
1336
  }
1162
1337
  Err(e) => eprintln!("{ERROR}Failed to save images: {e}{RESET}"),
1163
1338
  }
1164
1339
  }
1165
1340
  Err(e) => {
1341
+ spinner.finish_and_clear();
1166
1342
  eprintln!("{ERROR}✗ Image generation failed: {e}{RESET}");
1167
1343
  anyhow::bail!("image generation failed: {e}");
1168
1344
  }
1169
1345
  }
1170
1346
  }
1347
+ Command::Veo {
1348
+ prompt,
1349
+ aspect,
1350
+ duration,
1351
+ negative,
1352
+ output,
1353
+ } => {
1354
+ let config = load_config()?;
1355
+ use indicatif::{ProgressBar, ProgressStyle};
1356
+ use std::time::Duration as StdDuration;
1357
+ let spinner = ProgressBar::new_spinner();
1358
+ spinner.set_style(
1359
+ ProgressStyle::default_spinner()
1360
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1361
+ .template("{spinner:.magenta} {msg}")
1362
+ .unwrap(),
1363
+ );
1364
+ spinner.set_message(format!(
1365
+ "Generating {}s (aspect: {}) video with Veo for: \"{}\"...",
1366
+ duration, aspect, prompt
1367
+ ));
1368
+ spinner.enable_steady_tick(StdDuration::from_millis(80));
1369
+
1370
+ let gen_request = mint_core::VideoGenRequest {
1371
+ prompt: prompt.clone(),
1372
+ negative_prompt: negative,
1373
+ aspect_ratio: aspect.clone(),
1374
+ duration,
1375
+ model: None,
1376
+ provider: "veo".to_string(),
1377
+ };
1378
+
1379
+ match mint_core::generate_video(&config, &gen_request).await {
1380
+ Ok(result) => {
1381
+ spinner.finish_and_clear();
1382
+ if let Some(video) = result.videos.first() {
1383
+ println!("{MINT}✓ Video generated successfully!{RESET}");
1384
+ println!("{MINT}Saved to: {}{RESET}", video.path.display());
1385
+ if let Some(out_path) = output {
1386
+ if let Err(e) = std::fs::copy(&video.path, &out_path) {
1387
+ eprintln!(
1388
+ "{ERROR}✗ Failed to copy generated video to output path: {e}{RESET}"
1389
+ );
1390
+ } else {
1391
+ println!(
1392
+ "{MINT}✓ Copied to output destination: {}{RESET}",
1393
+ out_path.display()
1394
+ );
1395
+ }
1396
+ }
1397
+ } else {
1398
+ eprintln!("{ERROR}✗ Video generation returned no videos.{RESET}");
1399
+ }
1400
+ }
1401
+ Err(e) => {
1402
+ spinner.finish_and_clear();
1403
+ eprintln!("{ERROR}✗ Video generation failed: {e}{RESET}");
1404
+ anyhow::bail!("video generation failed: {e}");
1405
+ }
1406
+ }
1407
+ }
1171
1408
  },
1172
1409
  }
1173
1410
  Ok(())
@@ -1183,12 +1420,11 @@ async fn launch_mint_target(target: String) -> Result<()> {
1183
1420
  const APP_URL: &str = "https://mint.aemeth.xyz";
1184
1421
  println!("{MINT}Opening Mint App Link...{RESET}");
1185
1422
  println!("{BLUE}Open app:{RESET} {APP_URL}\n");
1186
- open_system_handler(APP_URL)?;
1423
+ actions::open_system_handler(APP_URL)?;
1187
1424
  }
1188
1425
  "web" => {
1189
- println!(
1190
- "{MINT}Launching Web App (vite) in background...{RESET} {DIM}(Vite Dev UI at http://localhost:9000){RESET}"
1191
- );
1426
+ let config = load_config()?;
1427
+ print_welcome_banner(&config);
1192
1428
  let project_root = {
1193
1429
  let mut found = None;
1194
1430
  if let Ok(exe_path) = std::env::current_exe() {
@@ -1201,16 +1437,16 @@ async fn launch_mint_target(target: String) -> Result<()> {
1201
1437
  path = p.parent();
1202
1438
  }
1203
1439
  }
1204
- if found.is_none() {
1205
- if let Ok(cwd) = std::env::current_dir() {
1206
- let mut path = Some(cwd.as_path());
1207
- while let Some(p) = path {
1208
- if p.join("package.json").exists() {
1209
- found = Some(p.to_path_buf());
1210
- break;
1211
- }
1212
- path = p.parent();
1440
+ if found.is_none()
1441
+ && let Ok(cwd) = std::env::current_dir()
1442
+ {
1443
+ let mut path = Some(cwd.as_path());
1444
+ while let Some(p) = path {
1445
+ if p.join("package.json").exists() {
1446
+ found = Some(p.to_path_buf());
1447
+ break;
1213
1448
  }
1449
+ path = p.parent();
1214
1450
  }
1215
1451
  }
1216
1452
  found.ok_or_else(|| {
@@ -1219,390 +1455,33 @@ async fn launch_mint_target(target: String) -> Result<()> {
1219
1455
  };
1220
1456
  std::process::Command::new("npm")
1221
1457
  .current_dir(&project_root)
1222
- .args(&["run", "dev:web"])
1458
+ .args(["run", "dev:web"])
1223
1459
  .stdout(std::process::Stdio::null())
1224
1460
  .stderr(std::process::Stdio::null())
1225
1461
  .spawn()
1226
1462
  .map_err(|e| anyhow::anyhow!("Failed to launch web app: {e}"))?;
1227
1463
 
1228
- println!("{MINT}Starting local API server in foreground on port 3000...{RESET}\n");
1229
- let local_ip_msg = if let Some(ip) = mint_core::api_server::get_local_ip() {
1230
- format!("http://localhost:9000 (or http://{}:9000 from mobile)", ip)
1231
- } else {
1232
- "http://localhost:9000".to_string()
1233
- };
1234
- println!(
1235
- "{BLUE}Please open {RESET}{}{BLUE} in your web browser to access the Mint Web UI.{RESET}\n",
1236
- local_ip_msg
1237
- );
1238
- mint_core::start_api_server(3000).await?;
1239
- }
1240
- _ => {}
1241
- }
1242
-
1243
- Ok(())
1244
- }
1245
-
1246
- fn is_table_line(line: &str) -> bool {
1247
- let trimmed = line.trim();
1248
- trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.len() > 1
1249
- }
1250
-
1251
- fn process_inline_bold(s: &str) -> String {
1252
- let mut result = String::new();
1253
- let parts = s.split("**");
1254
- let mut is_bold = false;
1255
- for part in parts {
1256
- if is_bold {
1257
- result.push_str("\x1b[1m");
1258
- result.push_str(part);
1259
- result.push_str("\x1b[0m");
1260
- } else {
1261
- result.push_str(part);
1262
- }
1263
- is_bold = !is_bold;
1264
- }
1265
- result
1266
- }
1267
-
1268
- fn format_line(line: &str) -> String {
1269
- let mut formatted = line.to_string();
1270
- let trimmed = line.trim_start();
1271
- if (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
1272
- && trimmed.len() >= 2
1273
- {
1274
- let leading_len = line.len() - trimmed.len();
1275
- let leading_spaces = &line[..leading_len];
1276
- formatted = format!("{}•{}", leading_spaces, &trimmed[1..]);
1277
- }
1278
- process_inline_bold(&formatted)
1279
- }
1280
-
1281
- fn unicode_width(s: &str) -> usize {
1282
- let mut count = 0;
1283
- for c in s.chars() {
1284
- let val = c as u32;
1285
- if (0x0E31..=0x0E31).contains(&val)
1286
- || (0x0E34..=0x0E3A).contains(&val)
1287
- || (0x0E47..=0x0E4E).contains(&val)
1288
- {
1289
- continue;
1290
- }
1291
- if is_cjk(c) {
1292
- count += 2;
1293
- } else {
1294
- count += 1;
1295
- }
1296
- }
1297
- count
1298
- }
1299
-
1300
- fn is_cjk(c: char) -> bool {
1301
- let val = c as u32;
1302
- (0x4E00..=0x9FFF).contains(&val)
1303
- || (0x3400..=0x4DBF).contains(&val)
1304
- || (0x20000..=0x2A6DF).contains(&val)
1305
- || (0xAC00..=0xD7AF).contains(&val)
1306
- || (0x3040..=0x309F).contains(&val)
1307
- || (0x30A0..=0x30FF).contains(&val)
1308
- }
1309
-
1310
- fn render_markdown_table(table_lines: &[String]) -> String {
1311
- let mut rows: Vec<Vec<String>> = Vec::new();
1312
- for line in table_lines {
1313
- let trimmed = line.trim();
1314
- if trimmed.is_empty() {
1315
- continue;
1316
- }
1317
- let content = &trimmed[1..trimmed.len() - 1];
1318
- let content_escaped = content.replace("\\|", "\u{0000}");
1319
- let cols: Vec<String> = content_escaped
1320
- .split('|')
1321
- .map(|s| s.replace("\u{0000}", "|").trim().to_string())
1322
- .collect();
1323
- rows.push(cols);
1324
- }
1325
-
1326
- if rows.is_empty() {
1327
- return String::new();
1328
- }
1329
-
1330
- let mut has_separator = false;
1331
- let mut separator_idx = None;
1332
- for (i, row) in rows.iter().enumerate() {
1333
- if row.iter().all(|col| {
1334
- col.chars()
1335
- .all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
1336
- }) && !row.is_empty()
1337
- {
1338
- has_separator = true;
1339
- separator_idx = Some(i);
1340
- break;
1341
- }
1342
- }
1343
-
1344
- let mut data_rows: Vec<Vec<String>> = Vec::new();
1345
- let mut header_row: Option<Vec<String>> = None;
1346
-
1347
- if has_separator {
1348
- let sep_idx = separator_idx.unwrap();
1349
- if sep_idx > 0 {
1350
- header_row = Some(rows[sep_idx - 1].clone());
1351
- for (i, row) in rows.iter().enumerate() {
1352
- if i != sep_idx && i != sep_idx - 1 {
1353
- data_rows.push(row.clone());
1354
- }
1355
- }
1356
- } else {
1357
- for (i, row) in rows.iter().enumerate() {
1358
- if i != sep_idx {
1359
- data_rows.push(row.clone());
1360
- }
1361
- }
1362
- }
1363
- } else {
1364
- header_row = Some(rows[0].clone());
1365
- data_rows = rows[1..].to_vec();
1366
- }
1367
-
1368
- let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
1369
- let mut col_widths = vec![0; num_cols];
1370
-
1371
- if let Some(ref header) = header_row {
1372
- for (i, col) in header.iter().enumerate() {
1373
- if i < num_cols {
1374
- col_widths[i] = col_widths[i].max(unicode_width(col));
1375
- }
1376
- }
1377
- }
1378
- for row in &data_rows {
1379
- for (i, col) in row.iter().enumerate() {
1380
- if i < num_cols {
1381
- col_widths[i] = col_widths[i].max(unicode_width(col));
1382
- }
1383
- }
1384
- }
1385
-
1386
- let mut rendered = String::new();
1387
-
1388
- let draw_border =
1389
- |left: &str, mid: &str, right: &str, fill: &str, widths: &[usize]| -> String {
1390
- let mut s = left.to_string();
1391
- for (i, &w) in widths.iter().enumerate() {
1392
- s.push_str(&fill.repeat(w + 2));
1393
- if i < widths.len() - 1 {
1394
- s.push_str(mid);
1395
- }
1396
- }
1397
- s.push_str(right);
1398
- s.push('\n');
1399
- s
1400
- };
1401
-
1402
- rendered.push_str(&draw_border("┌", "┬", "┐", "─", &col_widths));
1403
-
1404
- if let Some(ref header) = header_row {
1405
- rendered.push_str("│");
1406
- for (i, col) in header.iter().enumerate() {
1407
- let width = unicode_width(col);
1408
- let padding = col_widths[i] - width;
1409
- rendered.push_str(&format!(
1410
- " \x1b[1;36m{}\x1b[0m{}",
1411
- col,
1412
- " ".repeat(padding + 1)
1413
- ));
1414
- rendered.push_str("│");
1415
- }
1416
- rendered.push('\n');
1417
- rendered.push_str(&draw_border("├", "┼", "┤", "─", &col_widths));
1418
- }
1419
-
1420
- for (r_idx, row) in data_rows.iter().enumerate() {
1421
- rendered.push_str("│");
1422
- for i in 0..num_cols {
1423
- let col_val = row.get(i).cloned().unwrap_or_default();
1424
- let width = unicode_width(&col_val);
1425
- let padding = col_widths[i] - width;
1426
- rendered.push_str(&format!(" {}{}", col_val, " ".repeat(padding + 1)));
1427
- rendered.push_str("│");
1428
- }
1429
- rendered.push('\n');
1430
-
1431
- if r_idx < data_rows.len() - 1 {
1432
- rendered.push_str(&draw_border("├", "┼", "┤", "─", &col_widths));
1433
- }
1434
- }
1435
-
1436
- rendered.push_str(&draw_border("└", "┴", "┘", "─", &col_widths).trim_end());
1437
- rendered
1438
- }
1439
-
1440
- struct CliStreamFormatter {
1441
- line_buffer: String,
1442
- table_buffer: Vec<String>,
1443
- in_code_block: bool,
1444
- }
1445
-
1446
- impl CliStreamFormatter {
1447
- fn new() -> Self {
1448
- Self {
1449
- line_buffer: String::new(),
1450
- table_buffer: Vec::new(),
1451
- in_code_block: false,
1452
- }
1453
- }
1454
-
1455
- fn process_char(&mut self, c: char, mut print_fn: impl FnMut(char)) {
1456
- self.line_buffer.push(c);
1457
- if c == '\n' {
1458
- let mut line = self.line_buffer.clone();
1459
- self.line_buffer.clear();
1460
- if line.ends_with('\n') {
1461
- line.pop();
1462
- }
1463
-
1464
- let trimmed = line.trim();
1465
- if trimmed.starts_with("```") {
1466
- self.in_code_block = !self.in_code_block;
1467
- }
1468
-
1469
- if self.in_code_block {
1470
- if !self.table_buffer.is_empty() {
1471
- self.flush_table(&mut print_fn);
1472
- }
1473
- for ch in line.chars() {
1474
- print_fn(ch);
1475
- }
1476
- print_fn('\n');
1477
- for ch in " ".chars() {
1478
- print_fn(ch);
1479
- }
1480
- } else {
1481
- if is_table_line(&line) {
1482
- self.table_buffer.push(line);
1483
- } else {
1484
- if !self.table_buffer.is_empty() {
1485
- self.flush_table(&mut print_fn);
1486
- }
1487
- let formatted = format_line(&line);
1488
- for ch in formatted.chars() {
1489
- print_fn(ch);
1490
- }
1491
- print_fn('\n');
1492
- for ch in " ".chars() {
1493
- print_fn(ch);
1494
- }
1495
- }
1496
- }
1497
- }
1498
- }
1499
-
1500
- fn finalize(&mut self, mut print_fn: impl FnMut(char)) {
1501
- if !self.line_buffer.is_empty() {
1502
- let mut line = self.line_buffer.clone();
1503
- self.line_buffer.clear();
1504
- if line.ends_with('\n') {
1505
- line.pop();
1506
- }
1507
- if self.in_code_block {
1508
- if !self.table_buffer.is_empty() {
1509
- self.flush_table(&mut print_fn);
1510
- }
1511
- for ch in line.chars() {
1512
- print_fn(ch);
1513
- }
1514
- } else if is_table_line(&line) {
1515
- self.table_buffer.push(line);
1516
- } else {
1517
- if !self.table_buffer.is_empty() {
1518
- self.flush_table(&mut print_fn);
1519
- }
1520
- let formatted = format_line(&line);
1521
- for ch in formatted.chars() {
1522
- print_fn(ch);
1523
- }
1464
+ println!("\n{MINT}✔ Mint Web is running!{RESET}\n");
1465
+ println!(" {BLUE}Web UI:{RESET} {MINT}http://localhost:9000{RESET}");
1466
+ if let Some(ip) = mint_core::api_server::get_local_ip() {
1467
+ println!(
1468
+ " {BLUE}Mobile:{RESET} {MINT}http://{}:9000{RESET}",
1469
+ ip
1470
+ );
1524
1471
  }
1525
- }
1526
- if !self.table_buffer.is_empty() {
1527
- self.flush_table(&mut print_fn);
1528
- }
1529
- }
1530
-
1531
- fn flush_table<F: FnMut(char)>(&mut self, mut print_fn: F) {
1532
- let rendered = render_markdown_table(&self.table_buffer);
1533
- for ch in rendered.chars() {
1534
- print_fn(ch);
1535
- }
1536
- print_fn('\n');
1537
- self.table_buffer.clear();
1538
- }
1539
- }
1540
-
1541
- struct ActionStreamFilter {
1542
- buffer: String,
1543
- in_action: bool,
1544
- action_text: String,
1545
- actions: Vec<String>,
1546
- }
1472
+ println!(" {BLUE}API Server:{RESET} {MINT}http://localhost:3000{RESET}\n");
1547
1473
 
1548
- impl ActionStreamFilter {
1549
- fn new() -> Self {
1550
- Self {
1551
- buffer: String::new(),
1552
- in_action: false,
1553
- action_text: String::new(),
1554
- actions: Vec::new(),
1555
- }
1556
- }
1474
+ println!("Point your browser to:");
1475
+ println!("{MINT}http://localhost:9000{RESET}\n");
1476
+ println!("{DIM}(API Requests & Error logs will be displayed below){RESET}\n");
1557
1477
 
1558
- fn process_chunk(&mut self, chunk: &str, mut print_fn: impl FnMut(&str)) {
1559
- for c in chunk.chars() {
1560
- if self.in_action {
1561
- if c == ']' {
1562
- self.in_action = false;
1563
- self.actions.push(self.action_text.clone());
1564
- self.action_text.clear();
1565
- } else {
1566
- self.action_text.push(c);
1567
- }
1568
- } else {
1569
- self.buffer.push(c);
1570
- let action_prefix = "[ACTION:";
1571
- if action_prefix.starts_with(&self.buffer) {
1572
- if self.buffer == action_prefix {
1573
- self.in_action = true;
1574
- self.buffer.clear();
1575
- }
1576
- } else {
1577
- if let Some(pos) = self.buffer.find('[') {
1578
- print_fn(&self.buffer[..pos]);
1579
- let new_buf = self.buffer[pos..].to_string();
1580
- self.buffer = new_buf;
1581
- if !action_prefix.starts_with(&self.buffer) {
1582
- print_fn(&self.buffer);
1583
- self.buffer.clear();
1584
- }
1585
- } else {
1586
- print_fn(&self.buffer);
1587
- self.buffer.clear();
1588
- }
1589
- }
1590
- }
1478
+ println!("{DIM}Press Ctrl+C to stop{RESET}\n");
1479
+ mint_core::start_api_server(3000).await?;
1591
1480
  }
1481
+ _ => {}
1592
1482
  }
1593
1483
 
1594
- fn finalize(&mut self, mut print_fn: impl FnMut(&str)) -> Vec<String> {
1595
- if !self.buffer.is_empty() {
1596
- print_fn(&self.buffer);
1597
- self.buffer.clear();
1598
- }
1599
- if self.in_action {
1600
- print_fn(&format!("[ACTION:{}", self.action_text));
1601
- self.action_text.clear();
1602
- self.in_action = false;
1603
- }
1604
- std::mem::take(&mut self.actions)
1605
- }
1484
+ Ok(())
1606
1485
  }
1607
1486
 
1608
1487
  async fn run_cli_agent_task(task: Option<String>) -> Result<()> {
@@ -1641,2007 +1520,6 @@ async fn run_cli_agent_task(task: Option<String>) -> Result<()> {
1641
1520
  }
1642
1521
  }
1643
1522
 
1644
- fn execute_action(action: &str, config: &MintConfig) -> Result<()> {
1645
- let trimmed = action.trim();
1646
- if let Some((cmd, args)) = trimmed.split_once(' ') {
1647
- let cmd = cmd.trim();
1648
- let args = args.trim();
1649
- match cmd {
1650
- "open" => {
1651
- println!("{WARN}System action:{RESET} opening {args}...\n");
1652
- open_system_handler(args)?;
1653
- }
1654
- "open-app" => {
1655
- println!("{WARN}System action:{RESET} launching app {args}...\n");
1656
- launch_desktop_app(args)?;
1657
- }
1658
- "read-file" => {
1659
- println!("{WARN}System action:{RESET} reading file {args}...\n");
1660
- let path = PathBuf::from(args);
1661
- read_file_content(&path)?;
1662
- }
1663
- "read-folder" => {
1664
- println!("{WARN}System action:{RESET} reading folder {args}...\n");
1665
- let path = PathBuf::from(args);
1666
- read_folder_content(&path)?;
1667
- }
1668
- "run-shell" => {
1669
- println!("{WARN}System action:{RESET} run local shell command");
1670
- println!(" {args}");
1671
- if confirm_shell_execution()? {
1672
- let output = run_shell_command(args, &std::env::current_dir()?, true, config)?;
1673
- print_shell_output(&output);
1674
- } else {
1675
- println!("Shell command cancelled.\n");
1676
- }
1677
- }
1678
- _ => {
1679
- println!("{ERROR}Unknown system action:{RESET} {cmd} with args {args}\n");
1680
- }
1681
- }
1682
- } else {
1683
- match trimmed {
1684
- "read-folder" => {
1685
- println!("{WARN}System action:{RESET} reading folder . ...\n");
1686
- read_folder_content(&PathBuf::from("."))?;
1687
- }
1688
- _ => {
1689
- println!("{ERROR}Invalid action format:{RESET} {trimmed}\n");
1690
- }
1691
- }
1692
- }
1693
- Ok(())
1694
- }
1695
-
1696
- /// Per-session mutable state for the interactive chat loop.
1697
- struct InteractiveSession {
1698
- config: MintConfig,
1699
- current_dir: PathBuf,
1700
- fast_mode: bool,
1701
- pending_image: Option<String>, // base64 data URI
1702
- }
1703
-
1704
- struct InteractiveInput {
1705
- text: String,
1706
- pasted_image: Option<String>,
1707
- }
1708
-
1709
- /// What the slash-command router wants the loop to do next.
1710
- enum SlashResult {
1711
- /// Command handled — continue loop without sending to agent.
1712
- Handled,
1713
- /// Pass this (possibly modified) query to the agent.
1714
- ForwardToAgent(String),
1715
- /// Break out of the loop.
1716
- Exit,
1717
- }
1718
-
1719
- /// Route `/…` commands. Returns `None` if the input is not a slash command.
1720
- async fn handle_slash_command(
1721
- session: &mut InteractiveSession,
1722
- query: &str,
1723
- ) -> Option<SlashResult> {
1724
- let trimmed = query.trim();
1725
- if !trimmed.starts_with('/') {
1726
- return None;
1727
- }
1728
-
1729
- // Split into command word and optional rest
1730
- let (cmd, rest) = trimmed
1731
- .split_once(char::is_whitespace)
1732
- .map(|(c, r)| (c, r.trim()))
1733
- .unwrap_or((trimmed, ""));
1734
-
1735
- match cmd {
1736
- "/help" => {
1737
- println!("\n{BLUE}────────────────────────────────────────────{RESET}");
1738
- println!("{MINT} Mint Interactive Commands{RESET}");
1739
- println!("{BLUE}────────────────────────────────────────────{RESET}");
1740
- let commands = [
1741
- ("/help", "Show this help"),
1742
- ("/fast [on|off]", "Toggle fast mode (hide thinking traces)"),
1743
- ("/models [name]", "List providers or switch provider"),
1744
- ("/clear", "Clear conversation history"),
1745
- ("/cd <path>", "Change workspace directory"),
1746
- ("/image <path> [prompt]", "Attach image from file"),
1747
- ("/paste [prompt]", "Attach image from clipboard"),
1748
- ("Ctrl+V", "Paste clipboard image as [Image #1]"),
1749
- ("/learn <path>", "Import a persistent .md or .txt skill"),
1750
- (
1751
- "/plugins <name> [prompt]",
1752
- "Generate a skill.md file for a plugin/skill",
1753
- ),
1754
- ("/memory list", "Show recent interactions"),
1755
- ("/memory clear", "Clear all interactions"),
1756
- ("/memory get <key>", "Read a profile value"),
1757
- ("/memory set <key> <val>", "Store a profile value"),
1758
- (
1759
- "/image-provider [name]",
1760
- "List image gen providers or switch default provider",
1761
- ),
1762
- ("/mcp list", "List configured MCP servers"),
1763
- ("/mcp allow <server> <tool>", "Allow an MCP tool"),
1764
- ("/stats", "Show session statistics"),
1765
- ("/exit", "Exit Mint"),
1766
- ("/code <task>", "Run in code-agent mode"),
1767
- (
1768
- "/multi-agent [on|off]",
1769
- "Show or toggle Multi-Agent Collaboration system",
1770
- ),
1771
- ];
1772
- for (cmd_name, desc) in &commands {
1773
- println!(" {MINT}{:<30}{RESET} {DIM}{}{RESET}", cmd_name, desc);
1774
- }
1775
- println!();
1776
- Some(SlashResult::Handled)
1777
- }
1778
-
1779
- "/fast" => {
1780
- session.fast_mode = match rest {
1781
- "off" => false,
1782
- "on" => true,
1783
- "" => !session.fast_mode,
1784
- _ => {
1785
- println!("{WARN}/fast usage: /fast [on|off]{RESET}");
1786
- return Some(SlashResult::Handled);
1787
- }
1788
- };
1789
- if session.fast_mode {
1790
- println!("{DIM}[Fast] mode ON — thinking traces hidden{RESET}\n");
1791
- } else {
1792
- println!("{DIM}[Fast] mode OFF{RESET}\n");
1793
- }
1794
- Some(SlashResult::Handled)
1795
- }
1796
-
1797
- "/models" => {
1798
- if rest.is_empty() {
1799
- println!("\n{BLUE}Configured providers:{RESET}");
1800
- for p in session.config.available_providers() {
1801
- let active = if p == session.config.ai_provider.as_str() {
1802
- format!(" {MINT}← active{RESET}")
1803
- } else {
1804
- String::new()
1805
- };
1806
- println!(" {p}{active}");
1807
- }
1808
- println!();
1809
- } else {
1810
- session.config.ai_provider = rest.to_owned();
1811
- match mint_core::save_config(&session.config) {
1812
- Ok(()) => {
1813
- let active_model = active_model(&session.config.ai_provider, &session.config);
1814
- let display_name = format!("Changed model to {} • {}", session.config.ai_provider, active_model);
1815
-
1816
- // Save system event interaction in memory
1817
- if let Ok(memory) = MemoryStore::open_default() {
1818
- let _ = memory.add_interaction_for_chat_with_fallback(
1819
- CHAT_CLI_ID,
1820
- &display_name,
1821
- "",
1822
- "system",
1823
- "provider_change",
1824
- None,
1825
- );
1826
- }
1827
-
1828
- println!(
1829
- "\n{DIM}───{RESET} {MINT}{}{RESET} {DIM}───{RESET}\n",
1830
- display_name
1831
- );
1832
- }
1833
- Err(error) => println!("{ERROR}Config error:{RESET} {error}"),
1834
- }
1835
- }
1836
- Some(SlashResult::Handled)
1837
- }
1838
-
1839
- "/multi-agent" => {
1840
- if rest == "on" || rest == "off" {
1841
- session.config.enable_agent_collaboration = rest == "on";
1842
- match mint_core::save_config(&session.config) {
1843
- Ok(()) => println!(
1844
- "{DIM}Multi-Agent collaboration set to: {}{RESET}\n",
1845
- if session.config.enable_agent_collaboration { "Enabled" } else { "Disabled" }
1846
- ),
1847
- Err(error) => println!("{ERROR}Config error:{RESET} {error}\n"),
1848
- }
1849
- } else if !rest.is_empty() {
1850
- println!("{WARN}Usage: /multi-agent [on|off]{RESET}\n");
1851
- } else {
1852
- println!("\n{BLUE}Multi-Agent System Settings:{RESET}");
1853
- let collab_status = if session.config.enable_agent_collaboration {
1854
- format!("{MINT}Enabled (on){RESET}")
1855
- } else {
1856
- format!("{DIM}Disabled (off){RESET}")
1857
- };
1858
- println!(" Global Collaboration: {collab_status}");
1859
- println!("\n{BLUE}Configured Agents:{RESET}");
1860
- if session.config.agents.is_empty() {
1861
- println!(" No agents configured.");
1862
- } else {
1863
- for agent in &session.config.agents {
1864
- let status_label = if agent.enabled {
1865
- format!("{MINT}[Enabled]{RESET}")
1866
- } else {
1867
- format!("{DIM}[Disabled]{RESET}")
1868
- };
1869
- println!(
1870
- " {:<15} {} Provider: {:<12} Model: {}",
1871
- agent.name, status_label, agent.provider, agent.model
1872
- );
1873
- let truncated_instruction = if agent.system_instruction.len() > 60 {
1874
- format!("{}...", &agent.system_instruction[..60].replace('\n', " "))
1875
- } else {
1876
- agent.system_instruction.replace('\n', " ")
1877
- };
1878
- println!(" {DIM}Instruction: {truncated_instruction}{RESET}");
1879
- }
1880
- }
1881
- println!();
1882
- }
1883
- Some(SlashResult::Handled)
1884
- }
1885
-
1886
- "/image-provider" => {
1887
- let mut available = Vec::new();
1888
- if !session.config.api_key.trim().is_empty() {
1889
- available.push("nanobanana");
1890
- }
1891
- if !session.config.openai_api_key.trim().is_empty() {
1892
- available.push("dalle");
1893
- }
1894
- if !session.config.stability_api_key.trim().is_empty() {
1895
- available.push("stability");
1896
- }
1897
- if !session.config.ideogram_api_key.trim().is_empty() {
1898
- available.push("ideogram");
1899
- }
1900
- if !session.config.replicate_api_key.trim().is_empty() {
1901
- available.push("replicate");
1902
- }
1903
- if available.is_empty() {
1904
- available.push("nanobanana");
1905
- }
1906
-
1907
- if rest.is_empty() {
1908
- println!("\n{BLUE}Configured image generation providers:{RESET}");
1909
- for p in &available {
1910
- let active = if p == &session.config.image_gen_provider.as_str() {
1911
- format!(" {MINT}← active{RESET}")
1912
- } else {
1913
- String::new()
1914
- };
1915
- println!(" {p}{active}");
1916
- }
1917
- println!();
1918
- } else {
1919
- if available.contains(&rest) {
1920
- session.config.image_gen_provider = rest.to_owned();
1921
- match mint_core::save_config(&session.config) {
1922
- Ok(()) => println!(
1923
- "{DIM}Switched default image provider to: {}{RESET}\n",
1924
- session.config.image_gen_provider
1925
- ),
1926
- Err(error) => println!("{ERROR}Config error:{RESET} {error}"),
1927
- }
1928
- } else {
1929
- println!("{ERROR}Provider '{rest}' is not configured or invalid.{RESET}\n");
1930
- }
1931
- }
1932
- Some(SlashResult::Handled)
1933
- }
1934
-
1935
- "/clear" | "/reset" => {
1936
- println!("Clear conversation history? [y/N] ");
1937
- if let Ok(true) = confirm("Clear conversation history? [y/N] ") {
1938
- if let Ok(memory) = MemoryStore::open_default() {
1939
- match memory.clear_interactions() {
1940
- Ok(count) => println!("{DIM}Cleared {count} interactions.{RESET}"),
1941
- Err(error) => println!("{ERROR}Memory error:{RESET} {error}"),
1942
- }
1943
- }
1944
- println!("{DIM}Conversation context cleared.{RESET}\n");
1945
- }
1946
- Some(SlashResult::Handled)
1947
- }
1948
-
1949
- "/cd" => {
1950
- if rest.is_empty() {
1951
- println!("{WARN}/cd requires a path{RESET}\n");
1952
- } else {
1953
- let new_dir = PathBuf::from(rest);
1954
- if new_dir.is_dir() {
1955
- session.current_dir = new_dir.canonicalize().unwrap_or(new_dir);
1956
- println!(
1957
- "{DIM}Workspace: {}{RESET}\n",
1958
- format_path_with_tilde(&session.current_dir)
1959
- );
1960
- } else {
1961
- println!("{ERROR}Directory not found:{RESET} {rest}\n");
1962
- }
1963
- }
1964
- Some(SlashResult::Handled)
1965
- }
1966
-
1967
- "/image" => {
1968
- let (img_path, prompt) = rest
1969
- .split_once(char::is_whitespace)
1970
- .map(|(p, r)| (p, r.trim()))
1971
- .unwrap_or((rest, ""));
1972
-
1973
- if img_path.is_empty() {
1974
- println!("{WARN}/image usage: /image <path> [prompt]{RESET}\n");
1975
- return Some(SlashResult::Handled);
1976
- }
1977
- match image::load_image_as_data_uri(std::path::Path::new(img_path)) {
1978
- Ok(uri) => {
1979
- if let Some(ref mut current) = session.pending_image {
1980
- current.push(' ');
1981
- current.push_str(&uri);
1982
- } else {
1983
- session.pending_image = Some(uri);
1984
- }
1985
- if prompt.is_empty() {
1986
- println!("{DIM}Image attached — type your prompt and press Enter{RESET}\n");
1987
- Some(SlashResult::Handled)
1988
- } else {
1989
- Some(SlashResult::ForwardToAgent(prompt.to_owned()))
1990
- }
1991
- }
1992
- Err(e) => {
1993
- println!("{ERROR}Failed to load image:{RESET} {e}\n");
1994
- Some(SlashResult::Handled)
1995
- }
1996
- }
1997
- }
1998
-
1999
- "/paste" => match image::read_clipboard_image() {
2000
- Ok(Some(uri)) => {
2001
- if let Some(ref mut current) = session.pending_image {
2002
- current.push(' ');
2003
- current.push_str(&uri);
2004
- } else {
2005
- session.pending_image = Some(uri);
2006
- }
2007
- if rest.is_empty() {
2008
- println!(
2009
- "{DIM}Clipboard image attached — type your prompt and press Enter{RESET}\n"
2010
- );
2011
- Some(SlashResult::Handled)
2012
- } else {
2013
- Some(SlashResult::ForwardToAgent(rest.to_owned()))
2014
- }
2015
- }
2016
- Ok(None) => {
2017
- println!("{WARN}No image found in clipboard.{RESET}\n");
2018
- Some(SlashResult::Handled)
2019
- }
2020
- Err(e) => {
2021
- println!("{ERROR}Clipboard error:{RESET} {e}\n");
2022
- Some(SlashResult::Handled)
2023
- }
2024
- },
2025
-
2026
- "/learn" => {
2027
- if rest.is_empty() {
2028
- let mut skills = match MemoryStore::open_default() {
2029
- Ok(m) => match m.learned_skills(100) {
2030
- Ok(s) => s,
2031
- Err(e) => {
2032
- println!("{ERROR}Error loading custom skills: {e}{RESET}\n");
2033
- return Some(SlashResult::Handled);
2034
- }
2035
- },
2036
- Err(e) => {
2037
- println!("{ERROR}Memory error: {e}{RESET}\n");
2038
- return Some(SlashResult::Handled);
2039
- }
2040
- };
2041
-
2042
- if let Some(home) = dirs::home_dir() {
2043
- let global_skills_path = home.join(".config").join("mint").join("mint-skills");
2044
- mint_core::skills::load_skills_from_dir(&global_skills_path, &mut skills);
2045
- }
2046
- let workspace_skills_path1 = session.current_dir.join(".agents").join("skills");
2047
- mint_core::skills::load_skills_from_dir(&workspace_skills_path1, &mut skills);
2048
- let workspace_skills_path2 = session.current_dir.join("skills");
2049
- mint_core::skills::load_skills_from_dir(&workspace_skills_path2, &mut skills);
2050
-
2051
- let mut unique_skills = std::collections::BTreeMap::new();
2052
- for skill in skills {
2053
- let loc = if skill.source_path.contains("/.config/mint/mint-skills") {
2054
- "Global"
2055
- } else if skill.source_path.contains("/skills")
2056
- || skill.source_path.contains("/.agents/skills")
2057
- {
2058
- "Workspace"
2059
- } else {
2060
- "Taught"
2061
- };
2062
- unique_skills.insert(skill.name.clone(), (skill, loc));
2063
- }
2064
-
2065
- if unique_skills.is_empty() {
2066
- println!("No learned skills found. Use '/learn <path>' to add one.\n");
2067
- } else {
2068
- println!("Learned AI Skills:");
2069
- for (name, (skill, loc)) in &unique_skills {
2070
- if *loc == "Taught" {
2071
- println!(" ● [{}] {}", loc, name);
2072
- } else {
2073
- println!(" ● [{}] {} (Source: {})", loc, name, skill.source_path);
2074
- }
2075
- }
2076
- println!();
2077
- }
2078
- } else {
2079
- let path = PathBuf::from(rest);
2080
- let path = if path.is_absolute() {
2081
- path
2082
- } else {
2083
- session.current_dir.join(path)
2084
- };
2085
- match skills::learn(&path) {
2086
- Ok(skill) => println!(
2087
- "{DIM}Learned skill: {} ({}){RESET}\n",
2088
- skill.name, skill.source_path
2089
- ),
2090
- Err(error) => println!("{ERROR}Learn error:{RESET} {error}\n"),
2091
- }
2092
- }
2093
- Some(SlashResult::Handled)
2094
- }
2095
-
2096
- "/memory" => {
2097
- let memory = match MemoryStore::open_default() {
2098
- Ok(m) => m,
2099
- Err(e) => {
2100
- println!("{ERROR}Memory error:{RESET} {e}\n");
2101
- return Some(SlashResult::Handled);
2102
- }
2103
- };
2104
- let (subcmd, args) = rest
2105
- .split_once(char::is_whitespace)
2106
- .map(|(c, a)| (c, a.trim()))
2107
- .unwrap_or((rest, ""));
2108
- match subcmd {
2109
- "list" | "" => match memory.recent_interactions_for_chat(CHAT_CLI_ID, 10) {
2110
- Ok(items) => {
2111
- if items.is_empty() {
2112
- println!("{DIM}No interactions yet.{RESET}\n");
2113
- } else {
2114
- println!("\n{BLUE}Recent interactions:{RESET}");
2115
- for item in items.iter().rev() {
2116
- println!(
2117
- " {DIM}[{}]{RESET} {BLUE}You:{RESET} {}",
2118
- &item.created_at[..16.min(item.created_at.len())],
2119
- if item.user_text.chars().count() > 80 {
2120
- let short: String =
2121
- item.user_text.chars().take(80).collect();
2122
- format!("{}…", short)
2123
- } else {
2124
- item.user_text.clone()
2125
- }
2126
- );
2127
- }
2128
- println!();
2129
- }
2130
- }
2131
- Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
2132
- },
2133
- "get" => {
2134
- if args.is_empty() {
2135
- println!("{WARN}/memory get <key>{RESET}\n");
2136
- } else {
2137
- match memory.get_profile(args) {
2138
- Ok(Some(val)) => println!("{val}\n"),
2139
- Ok(None) => println!("{DIM}(not set){RESET}\n"),
2140
- Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
2141
- }
2142
- }
2143
- }
2144
- "set" => {
2145
- let (key, val) = args
2146
- .split_once(char::is_whitespace)
2147
- .map(|(k, v)| (k, v.trim()))
2148
- .unwrap_or((args, ""));
2149
- if key.is_empty() {
2150
- println!("{WARN}/memory set <key> <value>{RESET}\n");
2151
- } else {
2152
- match memory.set_profile(key, val) {
2153
- Ok(()) => println!("{DIM}Stored {key}.{RESET}\n"),
2154
- Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
2155
- }
2156
- }
2157
- }
2158
- "skills" => match memory.learned_skills(20) {
2159
- Ok(skills) => {
2160
- if skills.is_empty() {
2161
- println!("{DIM}No learned skills.{RESET}\n");
2162
- } else {
2163
- println!("\n{BLUE}Learned skills:{RESET}");
2164
- for s in &skills {
2165
- println!(" [{}] {} — {}", s.id, s.name, s.source_path);
2166
- }
2167
- println!();
2168
- }
2169
- }
2170
- Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
2171
- },
2172
- "clear" => match memory.clear_interactions_for_chat(CHAT_CLI_ID) {
2173
- Ok(count) => println!("{DIM}Cleared {count} interactions.{RESET}\n"),
2174
- Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
2175
- },
2176
- _ => println!(
2177
- "{WARN}/memory usage: list | clear | get <key> | set <key> <val> | skills{RESET}\n"
2178
- ),
2179
- }
2180
- Some(SlashResult::Handled)
2181
- }
2182
-
2183
- "/mcp" => {
2184
- let (subcmd, args) = rest
2185
- .split_once(char::is_whitespace)
2186
- .map(|(c, a)| (c, a.trim()))
2187
- .unwrap_or((rest, ""));
2188
- match subcmd {
2189
- "list" | "" => match mcp::list() {
2190
- Ok(servers) => {
2191
- println!("\n{BLUE}MCP servers:{RESET}");
2192
- print_mcp_servers(&servers);
2193
- }
2194
- Err(e) => println!("{ERROR}MCP error:{RESET} {e}\n"),
2195
- },
2196
- "allow" => {
2197
- let mut parts = args.split_whitespace();
2198
- let server = parts.next();
2199
- let tool = parts.next();
2200
- if let (Some(server), Some(tool)) = (server, tool) {
2201
- match mcp::allow(server, tool) {
2202
- Ok(true) => println!("{DIM}Allowed MCP tool: {server}/{tool}{RESET}\n"),
2203
- Ok(false) => {
2204
- println!("{DIM}MCP tool already allowed: {server}/{tool}{RESET}\n")
2205
- }
2206
- Err(e) => println!("{ERROR}MCP error:{RESET} {e}\n"),
2207
- }
2208
- } else {
2209
- println!("{WARN}/mcp allow usage: <server> <tool>{RESET}\n");
2210
- }
2211
- }
2212
- _ => println!("{WARN}/mcp usage: list | allow <server> <tool>{RESET}\n"),
2213
- }
2214
- Some(SlashResult::Handled)
2215
- }
2216
-
2217
- "/stats" => {
2218
- let provider = &session.config.ai_provider;
2219
- let model = active_model(provider, &session.config);
2220
- let interactions = MemoryStore::open_default()
2221
- .and_then(|m| m.recent_interactions_for_chat(CHAT_CLI_ID, 1000))
2222
- .map(|v| v.len())
2223
- .unwrap_or(0);
2224
- println!("\n{BLUE}─ Session Stats ─────────────────────────{RESET}");
2225
- println!(" Provider : {MINT}{provider}{RESET}");
2226
- println!(" Model : {model}");
2227
- println!(
2228
- " Workspace: {}",
2229
- format_path_with_tilde(&session.current_dir)
2230
- );
2231
- println!(
2232
- " Fast mode: {}",
2233
- if session.fast_mode { "on" } else { "off" }
2234
- );
2235
- println!(" Memory : {interactions} interactions");
2236
- if let Some(ref img_data) = session.pending_image {
2237
- let count = img_data.split_whitespace().count();
2238
- if count > 1 {
2239
- println!(" Images : {WARN}{} images attached{RESET}", count);
2240
- } else {
2241
- println!(" Image : {WARN}attached{RESET}");
2242
- }
2243
- }
2244
- println!();
2245
- Some(SlashResult::Handled)
2246
- }
2247
-
2248
- "/exit" | "/quit" => Some(SlashResult::Exit),
2249
-
2250
- "/plugins" | "/plugin" => {
2251
- let (plugin_name, prompt) = rest
2252
- .split_once(char::is_whitespace)
2253
- .map(|(p, r)| (p, r.trim()))
2254
- .unwrap_or((rest, ""));
2255
-
2256
- if plugin_name.is_empty() {
2257
- println!("\n{BLUE}────────────────────────────────────────────{RESET}");
2258
- println!("{MINT} Available Plugins & Skills{RESET}");
2259
- println!("{BLUE}────────────────────────────────────────────{RESET}");
2260
-
2261
- println!("{MINT}Native Plugins:{RESET}");
2262
- for p in native_plugins() {
2263
- println!(" - {BLUE}{:<20}{RESET} {}", p.name, p.description);
2264
- }
2265
-
2266
- println!("\n{MINT}Custom Skills/Plugins (Active):{RESET}");
2267
- let mut active_skills = std::collections::HashSet::new();
2268
- if let Ok(memory) = MemoryStore::open_default() {
2269
- match memory.learned_skills(100) {
2270
- Ok(skills) => {
2271
- if skills.is_empty() {
2272
- println!(
2273
- " {DIM}(No custom skills/plugins learned yet. Use '/learn <path>' to add one.){RESET}"
2274
- );
2275
- } else {
2276
- for s in &skills {
2277
- println!(" - {BLUE}{:<20}{RESET} {}", s.name, s.source_path);
2278
-
2279
- // Try to store canonicalized active path
2280
- if let Ok(canon) = Path::new(&s.source_path).canonicalize() {
2281
- active_skills.insert(canon);
2282
- } else {
2283
- active_skills.insert(PathBuf::from(&s.source_path));
2284
- }
2285
- }
2286
- }
2287
- }
2288
- Err(e) => println!(" {ERROR}Error loading custom skills: {e}{RESET}"),
2289
- }
2290
- }
2291
-
2292
- // Scan local workspace skills directory
2293
- let local_skills_dir = session.current_dir.join("skills");
2294
- if local_skills_dir.is_dir() {
2295
- if let Ok(entries) = fs::read_dir(&local_skills_dir) {
2296
- let mut local_files = Vec::new();
2297
- for entry in entries.flatten() {
2298
- let path = entry.path();
2299
- if path.is_file() {
2300
- if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2301
- if matches!(ext.to_ascii_lowercase().as_str(), "md" | "txt") {
2302
- if let Some(name) =
2303
- path.file_name().and_then(|n| n.to_str())
2304
- {
2305
- if let Ok(canon_path) = path.canonicalize() {
2306
- local_files.push((name.to_owned(), canon_path));
2307
- } else {
2308
- local_files.push((name.to_owned(), path));
2309
- }
2310
- }
2311
- }
2312
- }
2313
- }
2314
- }
2315
-
2316
- let inactive_files: Vec<_> = local_files
2317
- .into_iter()
2318
- .filter(|(_, full_path)| !active_skills.contains(full_path))
2319
- .map(|(name, _)| name)
2320
- .collect();
2321
-
2322
- if !inactive_files.is_empty() {
2323
- println!(
2324
- "\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}"
2325
- );
2326
- for file in inactive_files {
2327
- println!(
2328
- " - {WARN}{:<20}{RESET} skills/{} {DIM}(Type '/learn skills/{}' to activate){RESET}",
2329
- file, file, file
2330
- );
2331
- }
2332
- }
2333
- }
2334
- }
2335
-
2336
- println!();
2337
- Some(SlashResult::Handled)
2338
- } else {
2339
- let agent_prompt = if prompt.is_empty() {
2340
- format!(
2341
- "Create a skill markdown file for the plugin '{}' at the path 'skills/{}.md' in the workspace. Write the appropriate instructions inside.",
2342
- plugin_name, plugin_name
2343
- )
2344
- } else {
2345
- format!(
2346
- "Create a skill markdown file for the plugin '{}' at the path 'skills/{}.md' in the workspace based on these instructions: {}",
2347
- plugin_name, plugin_name, prompt
2348
- )
2349
- };
2350
- Some(SlashResult::ForwardToAgent(agent_prompt))
2351
- }
2352
- }
2353
-
2354
- "/code" => {
2355
- if rest.is_empty() {
2356
- println!("{WARN}/code requires a task description{RESET}\n");
2357
- Some(SlashResult::Handled)
2358
- } else {
2359
- Some(SlashResult::ForwardToAgent(format!("[code] {rest}")))
2360
- }
2361
- }
2362
-
2363
- _ => {
2364
- // Unknown slash command — treat as normal message to the agent
2365
- None
2366
- }
2367
- }
2368
- }
2369
-
2370
- async fn run_interactive_chat() -> Result<()> {
2371
- let config = load_config()?;
2372
-
2373
- let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2374
-
2375
- let provider = &config.ai_provider.clone();
2376
- let model = active_model(provider, &config).to_owned();
2377
-
2378
- // Print startup banner
2379
- let now = chrono::Local::now();
2380
- let year = now.format("%Y").to_string().parse::<i32>().unwrap_or(2026) + 543;
2381
- let date_time = format!(
2382
- "{}/{:02}/{:02} {:02}:{:02}",
2383
- now.format("%d"),
2384
- now.format("%m"),
2385
- year,
2386
- now.format("%H"),
2387
- now.format("%M")
2388
- );
2389
- let version = env!("CARGO_PKG_VERSION");
2390
- let line1_text = format!("[Mint] v{} | Active AI: {}", version, provider);
2391
- let line2_text = format!("{} • {}", date_time, model);
2392
-
2393
- let len1 = line1_text.chars().count();
2394
- let len2 = line2_text.chars().count();
2395
- let content_width = std::cmp::max(len1, len2);
2396
- let border_len = content_width + 2;
2397
-
2398
- let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
2399
- let term_width = term_width as usize;
2400
- let ascii_width = 34;
2401
- let spacing = 3;
2402
- let box_width = border_len + 2;
2403
-
2404
- if term_width >= ascii_width + spacing + box_width {
2405
- println!(
2406
- "{MINT} __ __ _ _ ___ _ ___ {RESET} {DIM}╭{}╮{RESET}",
2407
- "─".repeat(border_len)
2408
- );
2409
- println!(
2410
- "{MINT}| \\/ (_)_ __ | |_ / __| | |_ _|{RESET} {DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
2411
- version,
2412
- provider,
2413
- " ".repeat(content_width - len1)
2414
- );
2415
- println!(
2416
- "{MINT}| |\\/| | | '_ \\| _| (__| |__ | | {RESET} {DIM}│{RESET} {DIM}{}{}{RESET} {DIM}│{RESET}",
2417
- line2_text,
2418
- " ".repeat(content_width - len2)
2419
- );
2420
- println!(
2421
- "{MINT}|_| |_|_|_| |_|\\__|\\___|\\___|___|{RESET} {DIM}╰{}╯{RESET}",
2422
- "─".repeat(border_len)
2423
- );
2424
- } else {
2425
- println!("{DIM}╭{}╮{RESET}", "─".repeat(border_len));
2426
- println!(
2427
- "{DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
2428
- version,
2429
- provider,
2430
- " ".repeat(content_width - len1)
2431
- );
2432
- println!(
2433
- "{DIM}│{RESET} {DIM}{}{}{RESET} {DIM}│{RESET}",
2434
- line2_text,
2435
- " ".repeat(content_width - len2)
2436
- );
2437
- println!("{DIM}╰{}╯{RESET}", "─".repeat(border_len));
2438
- println!("{MINT} __ __ _ _ ___ _ ___ {RESET}");
2439
- println!("{MINT}| \\/ (_)_ __ | |_ / __| | |_ _|{RESET}");
2440
- println!("{MINT}| |\\/| | | '_ \\| _| (__| |__ | | {RESET}");
2441
- println!("{MINT}|_| |_|_|_| |_|\\__|\\___|\\___|___|{RESET}");
2442
- }
2443
- println!("Type naturally or /help for commands. Ctrl+V pastes images. Ctrl+D exits.\n");
2444
-
2445
- let mut session = InteractiveSession {
2446
- config,
2447
- current_dir: current_dir.clone(),
2448
- fast_mode: false,
2449
- pending_image: None,
2450
- };
2451
-
2452
- let mut printed_update = false;
2453
- if let Some((current, latest)) = updater::get_cached_update_notice() {
2454
- updater::print_update_notice(&current, &latest);
2455
- printed_update = true;
2456
- }
2457
-
2458
- let mut update_handle = if updater::should_check_for_update() {
2459
- Some(tokio::task::spawn_blocking(
2460
- updater::check_for_update_quietly,
2461
- ))
2462
- } else {
2463
- None
2464
- };
2465
-
2466
- loop {
2467
- if let Some(handle) = update_handle.take() {
2468
- if handle.is_finished() {
2469
- if let Ok(Some((current, latest))) = handle.await {
2470
- if !printed_update {
2471
- updater::print_update_notice(&current, &latest);
2472
- printed_update = true;
2473
- }
2474
- }
2475
- } else {
2476
- update_handle = Some(handle);
2477
- }
2478
- }
2479
-
2480
- let path_str = format_path_with_tilde(&session.current_dir);
2481
- let model_str = active_model(&session.config.ai_provider, &session.config).to_owned();
2482
-
2483
- if let Some(input) =
2484
- read_line_interactive(&session.config.ai_provider, &model_str, &path_str)?
2485
- {
2486
- if let Some(uri) = input.pasted_image {
2487
- if let Some(ref mut current) = session.pending_image {
2488
- current.push(' ');
2489
- current.push_str(&uri);
2490
- } else {
2491
- session.pending_image = Some(uri);
2492
- }
2493
- }
2494
- let query_str = input.text.trim().to_owned();
2495
- if query_str.is_empty() {
2496
- continue;
2497
- }
2498
-
2499
- // Run slash-command router
2500
- match handle_slash_command(&mut session, &query_str).await {
2501
- Some(SlashResult::Handled) => continue,
2502
- Some(SlashResult::Exit) => {
2503
- print_exit_message(&session);
2504
- break;
2505
- }
2506
- Some(SlashResult::ForwardToAgent(task)) => {
2507
- // Force code agent for /code forwarded tasks
2508
- println!();
2509
- if let Err(error) = run_code_agent_with_saved_image(
2510
- &task,
2511
- &session.current_dir,
2512
- &session.config,
2513
- session.pending_image.take(),
2514
- agent::AgentOptions {
2515
- fast_mode: session.fast_mode,
2516
- },
2517
- )
2518
- .await
2519
- {
2520
- println!("{ERROR}Error:{RESET} {error}\n");
2521
- }
2522
- continue;
2523
- }
2524
- None => {} // Not a slash command, fall through
2525
- }
2526
-
2527
- // Check if it's a /code or regular agent request
2528
- if let Some(task) = query_str.strip_prefix("/code ") {
2529
- println!();
2530
- if let Err(error) = run_code_agent_with_saved_image(
2531
- task.trim(),
2532
- &session.current_dir,
2533
- &session.config,
2534
- session.pending_image.take(),
2535
- agent::AgentOptions {
2536
- fast_mode: session.fast_mode,
2537
- },
2538
- )
2539
- .await
2540
- {
2541
- println!("{ERROR}Error:{RESET} {error}\n");
2542
- }
2543
- continue;
2544
- }
2545
-
2546
- // Regular agent loop (handles both chat and coding)
2547
- if !query_str.starts_with("/chat ") {
2548
- if let Err(error) = run_code_agent_with_saved_image(
2549
- query_str.trim_start_matches("/chat "),
2550
- &session.current_dir,
2551
- &session.config,
2552
- session.pending_image.take(),
2553
- agent::AgentOptions {
2554
- fast_mode: session.fast_mode,
2555
- },
2556
- )
2557
- .await
2558
- {
2559
- println!("{ERROR}Error:{RESET} {error}\n");
2560
- }
2561
- continue;
2562
- }
2563
-
2564
- // /chat explicit: use streaming chat with fallback
2565
- let message = query_str
2566
- .strip_prefix("/chat ")
2567
- .unwrap_or(&query_str)
2568
- .trim()
2569
- .to_owned();
2570
-
2571
- println!();
2572
- print!(" {MINT}Mint:{RESET} {DIM}Thinking...{RESET}");
2573
- let _ = io::stdout().flush();
2574
-
2575
- let mut system_instruction = format!(
2576
- "You are Mint, a cute and helpful AI assistant. You speak in a polite, friendly, and sweet Thai tone (using \"คุณ\", \"ค่ะ\", \"นะคะ\"). \
2577
- You are running inside the Mint CLI interactive chat. \
2578
- You have access to native system actions to help the user! If the user asks you to open a website, launch an app, read a file, list a folder, run code, run tests, or execute a local shell command, you can execute these actions by writing a special block at the very end of your response: \
2579
- `[ACTION: <command> <arguments>]` \
2580
- The available actions are: \
2581
- - `[ACTION: open <url_or_path>]` to open a URL or a folder path. \
2582
- - `[ACTION: open-app <app_name>]` to launch a desktop application. \
2583
- - `[ACTION: read-file <file_path>]` to read the contents of a file. \
2584
- - `[ACTION: read-folder <path>]` to list files/folders in a directory. \
2585
- - `[ACTION: run-shell <command>]` to run a non-destructive local shell command after approval. \
2586
- You may discuss mature personal topics at a non-explicit level, including adult relationships, sex education, intimacy, emotions, and feelings. Keep the tone respectful and supportive, avoid graphic sexual detail, and do not engage with sexual content involving minors, coercion, exploitation, or sexual violence. \
2587
- Write the action block on a single line at the very end of your response."
2588
- );
2589
- if let Ok(memory) = MemoryStore::open_default() {
2590
- if let Ok(Some(name)) = memory.get_profile("name") {
2591
- system_instruction.push_str(&format!(
2592
- "\nThe user's name is {}. Refer to them by their name when appropriate.",
2593
- name
2594
- ));
2595
- }
2596
- }
2597
-
2598
- let image_uri = session.pending_image.take();
2599
- let sent_image = image_uri.clone();
2600
- let mut first_chunk = true;
2601
- let mut filter = ActionStreamFilter::new();
2602
- let mut formatter = CliStreamFormatter::new();
2603
-
2604
- let stream_result = orchestrate_chat_stream_with_fallback(
2605
- &session.config,
2606
- &ChatRequest {
2607
- message: message.clone(),
2608
- system_instruction,
2609
- chat_id: Some(CHAT_CLI_ID.to_owned()),
2610
- image_data_uri: image_uri,
2611
- audio_data_uri: None,
2612
- document_attachment: None,
2613
- workspace_path: None,
2614
- agent_id: None,
2615
- },
2616
- |chunk| {
2617
- if first_chunk {
2618
- first_chunk = false;
2619
- print!("\r\x1b[2K {MINT}Mint:{RESET} ");
2620
- }
2621
- filter.process_chunk(&chunk, |text| {
2622
- for c in text.chars() {
2623
- formatter.process_char(c, |formatted_c| {
2624
- print!("{}", formatted_c);
2625
- });
2626
- }
2627
- });
2628
- let _ = io::stdout().flush();
2629
- },
2630
- )
2631
- .await;
2632
-
2633
- let actions = filter.finalize(|text| {
2634
- for c in text.chars() {
2635
- formatter.process_char(c, |formatted_c| {
2636
- print!("{}", formatted_c);
2637
- });
2638
- }
2639
- });
2640
- formatter.finalize(|formatted_c| {
2641
- print!("{}", formatted_c);
2642
- });
2643
- let _ = io::stdout().flush();
2644
-
2645
- match stream_result {
2646
- Ok((response, fallback)) => {
2647
- image::save_sent_image_after_send(sent_image.as_deref(), &message);
2648
- if first_chunk {
2649
- print!("\r\x1b[2K");
2650
- let _ = io::stdout().flush();
2651
- } else {
2652
- println!("\n");
2653
- let (tw, _) = crossterm::terminal::size().unwrap_or((80, 24));
2654
- let width = tw as usize;
2655
- // Show provider badge (with fallback indicator if applicable)
2656
- let badge = if let Some(fb_provider) = &fallback {
2657
- format!(
2658
- "{DIM}{} • {} → fallback: {} • {}{RESET}",
2659
- session.config.ai_provider,
2660
- active_model(&session.config.ai_provider, &session.config),
2661
- fb_provider,
2662
- response.model
2663
- )
2664
- } else {
2665
- format!("{DIM}{} • {}{RESET}", response.provider, response.model)
2666
- };
2667
- println!("{badge}");
2668
- println!("{DIM}{}{RESET}\n", "─".repeat(width));
2669
- }
2670
- for action in actions {
2671
- if let Err(e) = execute_action(&action, &session.config) {
2672
- println!("{ERROR}Error executing action:{RESET} {e}\n");
2673
- }
2674
- }
2675
- }
2676
- Err(e) => {
2677
- if first_chunk {
2678
- print!("\r\x1b[2K");
2679
- }
2680
- println!("{ERROR}Error:{RESET} {e}\n");
2681
- }
2682
- }
2683
- } else {
2684
- print_exit_message(&session);
2685
- break;
2686
- }
2687
- }
2688
-
2689
- Ok(())
2690
- }
2691
-
2692
- fn print_exit_message(session: &InteractiveSession) {
2693
- println!("\n{MINT}──────────────── Mint session closed ────────────────{RESET}");
2694
- println!(
2695
- "{DIM}Provider:{RESET} {} {DIM}• Model:{RESET} {}",
2696
- session.config.ai_provider,
2697
- active_model(&session.config.ai_provider, &session.config)
2698
- );
2699
- println!(
2700
- "{DIM}Workspace:{RESET} {}",
2701
- format_path_with_tilde(&session.current_dir)
2702
- );
2703
- println!("{DIM}Saved config stays available for the next Mint run.{RESET}");
2704
- println!("{MINT}See you next time.{RESET}\n");
2705
- }
2706
-
2707
- const AUTOCOMPLETE_COMMANDS: &[(&str, &str)] = &[
2708
- ("/cd", "Change active workspace directory"),
2709
- ("/clear", "Clear conversation history"),
2710
- ("/code", "Run in code-agent mode"),
2711
- ("/exit", "Exit Mint CLI"),
2712
- ("/fast", "Toggle fast mode (hide thinking traces)"),
2713
- ("/help", "Show help menu"),
2714
- ("/image", "Attach image from disk"),
2715
- (
2716
- "/image-provider",
2717
- "List image gen providers or switch default provider",
2718
- ),
2719
- ("/learn", "Import persistent skill/instruction"),
2720
- ("/mcp", "List configured MCP servers"),
2721
- ("/memory", "Manage long-term memory store"),
2722
- ("/models", "List AI providers or switch active provider"),
2723
- ("/multi-agent", "Show or toggle Multi-Agent Collaboration system"),
2724
- ("/paste", "Attach image from clipboard"),
2725
- ("/plugins", "List or generate plugins/skills"),
2726
- ("/stats", "Show session statistics"),
2727
- ];
2728
-
2729
- fn draw_input_box(
2730
- input_chars: &[char],
2731
- cursor_pos: usize,
2732
- placeholder: &str,
2733
- model: &str,
2734
- path_str: &str,
2735
- tab_base_input: Option<&str>,
2736
- tab_index: Option<usize>,
2737
- ) -> (usize, usize) {
2738
- let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
2739
- let width = term_width as usize;
2740
- let prefix = "› ";
2741
- let input_width = width.saturating_sub(2);
2742
- let content_max_len = input_width.saturating_sub(prefix.chars().count());
2743
-
2744
- let mut start_idx = 0;
2745
- if input_chars.len() > content_max_len {
2746
- if cursor_pos >= content_max_len {
2747
- start_idx = (cursor_pos + 5).saturating_sub(content_max_len);
2748
- if start_idx + content_max_len > input_chars.len() {
2749
- start_idx = input_chars.len() - content_max_len;
2750
- }
2751
- }
2752
- }
2753
-
2754
- let slice_chars = if input_chars.len() > content_max_len {
2755
- &input_chars[start_idx..std::cmp::min(start_idx + content_max_len, input_chars.len())]
2756
- } else {
2757
- input_chars
2758
- };
2759
- let displayed_input: String = slice_chars.iter().collect();
2760
-
2761
- let display_str = if displayed_input.is_empty() {
2762
- format!("{DIM}{}\x1b[39m", placeholder)
2763
- } else {
2764
- format_placeholders(&displayed_input)
2765
- };
2766
-
2767
- let visible_len = if displayed_input.is_empty() {
2768
- placeholder.chars().count()
2769
- } else {
2770
- string_visual_width(&displayed_input)
2771
- };
2772
-
2773
- let pad_len = content_max_len.saturating_sub(visible_len);
2774
- let padding = " ".repeat(pad_len);
2775
- let blank_line = " ".repeat(input_width);
2776
-
2777
- println!(" {COMPOSER_BG}{blank_line}{RESET}");
2778
- println!(
2779
- " {COMPOSER_BG}{MINT}{}{RESET}{COMPOSER_BG}{}{}{RESET}",
2780
- prefix, display_str, padding
2781
- );
2782
- println!(" {COMPOSER_BG}{blank_line}{RESET}");
2783
-
2784
- let agent_str = format!(" {DIM}[Agent]{RESET} {MINT}{}{RESET}", model);
2785
- let path_label = format!("path: {}", path_str);
2786
- let agent_visible_len = " [Agent] ".len() + model.chars().count();
2787
- let path_visible_len = path_label.chars().count();
2788
-
2789
- let status_pad_len = (width - 1).saturating_sub(agent_visible_len + path_visible_len);
2790
- let status_padding = " ".repeat(status_pad_len);
2791
-
2792
- print!(
2793
- "{}{}{}{}{}",
2794
- agent_str, status_padding, DIM, path_label, RESET
2795
- );
2796
-
2797
- // Compute and draw suggestions
2798
- let raw_input: String = input_chars.iter().collect();
2799
- let search_query = tab_base_input.unwrap_or(&raw_input);
2800
- let mut match_count = 0;
2801
- if search_query.starts_with('/') {
2802
- let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
2803
- .iter()
2804
- .filter(|(cmd, _)| cmd.starts_with(search_query))
2805
- .collect();
2806
-
2807
- if !matches.is_empty() {
2808
- let total_pages = (matches.len() + 4) / 5;
2809
- let highlight_idx = tab_index.map(|idx| idx % matches.len());
2810
- let selected_idx = highlight_idx.unwrap_or(0);
2811
- let current_page = selected_idx / 5;
2812
- let start_idx = current_page * 5;
2813
- let end_idx = std::cmp::min(start_idx + 5, matches.len());
2814
- match_count = end_idx - start_idx;
2815
-
2816
- println!();
2817
- println!(" {BLUE}Suggestions ({}/{}){RESET}", current_page + 1, total_pages);
2818
- for i in start_idx..end_idx {
2819
- let (cmd, desc) = matches[i];
2820
- if Some(i) == highlight_idx {
2821
- println!(" {BLUE}▶ {:<16}{RESET} {DIM}- {}{RESET}", cmd, desc);
2822
- } else {
2823
- println!(" {DIM}{:<16} - {}{RESET}", cmd, desc);
2824
- }
2825
- }
2826
- }
2827
- }
2828
-
2829
- let _ = io::stdout().flush();
2830
- (match_count, start_idx)
2831
- }
2832
-
2833
- fn input_cursor_column(input_chars: &[char], cursor_pos: usize) -> usize {
2834
- let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
2835
- let width = term_width as usize;
2836
- let prefix = "› ";
2837
- let input_width = width.saturating_sub(2);
2838
- let content_max_len = input_width.saturating_sub(prefix.chars().count());
2839
-
2840
- let mut start_idx = 0;
2841
- if input_chars.len() > content_max_len {
2842
- if cursor_pos >= content_max_len {
2843
- start_idx = (cursor_pos + 5).saturating_sub(content_max_len);
2844
- if start_idx + content_max_len > input_chars.len() {
2845
- start_idx = input_chars.len() - content_max_len;
2846
- }
2847
- }
2848
- }
2849
-
2850
- let cursor_pos_clamped = std::cmp::min(cursor_pos, input_chars.len());
2851
- let start_idx_clamped = std::cmp::min(start_idx, cursor_pos_clamped);
2852
- let visual_cursor_pos: usize = input_chars[start_idx_clamped..cursor_pos_clamped]
2853
- .iter()
2854
- .copied()
2855
- .map(char_visual_width)
2856
- .sum();
2857
- 4 + visual_cursor_pos
2858
- }
2859
-
2860
- fn position_input_cursor(input_chars: &[char], cursor_pos: usize, match_count: usize) {
2861
- let up_lines = if match_count > 0 { 4 + match_count } else { 2 };
2862
- print!(
2863
- "\x1b[{}A\x1b[{}G",
2864
- up_lines,
2865
- input_cursor_column(input_chars, cursor_pos)
2866
- );
2867
- }
2868
-
2869
- fn clear_input_box() {
2870
- print!("\r\x1b[1A\x1b[J");
2871
- }
2872
-
2873
- fn redraw_input_box(
2874
- input_chars: &[char],
2875
- cursor_pos: usize,
2876
- placeholder: &str,
2877
- model: &str,
2878
- path_str: &str,
2879
- tab_base_input: Option<&str>,
2880
- tab_index: Option<usize>,
2881
- ) {
2882
- clear_input_box();
2883
- let (match_count, _) = draw_input_box(
2884
- input_chars,
2885
- cursor_pos,
2886
- placeholder,
2887
- model,
2888
- path_str,
2889
- tab_base_input,
2890
- tab_index,
2891
- );
2892
- position_input_cursor(input_chars, cursor_pos, match_count);
2893
- let _ = io::stdout().flush();
2894
- }
2895
-
2896
- fn read_line_interactive(
2897
- _provider: &str,
2898
- model: &str,
2899
- path_str: &str,
2900
- ) -> io::Result<Option<InteractiveInput>> {
2901
- use crossterm::event::{self, Event, KeyCode};
2902
- use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
2903
-
2904
- let mut input_chars: Vec<char> = Vec::new();
2905
- let mut cursor_pos = 0;
2906
- let placeholder = "Ask anything...";
2907
- let mut ctrl_d_pressed = false;
2908
- let mut pasted_image: Option<String> = None;
2909
- let mut paste_contents: Vec<(String, String)> = Vec::new();
2910
- let mut last_paste_time: Option<std::time::Instant> = None;
2911
-
2912
- // Track tab autocomplete state
2913
- let mut tab_base_input: Option<String> = None;
2914
- let mut tab_index: Option<usize> = None;
2915
-
2916
- let (match_count, _) = draw_input_box(&input_chars, cursor_pos, placeholder, model, path_str, None, None);
2917
- position_input_cursor(&input_chars, cursor_pos, match_count);
2918
- let _ = io::stdout().flush();
2919
-
2920
- enable_raw_mode()?;
2921
- let _ = crossterm::execute!(io::stdout(), crossterm::event::EnableBracketedPaste);
2922
-
2923
- let result = loop {
2924
- if event::poll(std::time::Duration::from_millis(100))? {
2925
- let ev = event::read()?;
2926
- if let Event::Paste(text) = &ev {
2927
- let clean_text = text.trim_end_matches(&['\r', '\n'][..]).to_string();
2928
- let lines_count = clean_text.lines().count();
2929
- if lines_count > 1 || clean_text.chars().count() > 100 {
2930
- let paste_id = paste_contents.len() + 1;
2931
- let placeholder_str = if lines_count > 1 {
2932
- format!("[Pasted text #{} +{} lines]", paste_id, lines_count - 1)
2933
- } else {
2934
- format!("[Pasted text #{}]", paste_id)
2935
- };
2936
- paste_contents.push((placeholder_str.clone(), clean_text));
2937
- for c in placeholder_str.chars() {
2938
- input_chars.insert(cursor_pos, c);
2939
- cursor_pos += 1;
2940
- }
2941
- } else {
2942
- for c in clean_text.chars() {
2943
- input_chars.insert(cursor_pos, c);
2944
- cursor_pos += 1;
2945
- }
2946
- }
2947
- disable_raw_mode()?;
2948
- redraw_input_box(
2949
- &input_chars,
2950
- cursor_pos,
2951
- placeholder,
2952
- model,
2953
- path_str,
2954
- None,
2955
- None,
2956
- );
2957
- enable_raw_mode()?;
2958
- last_paste_time = Some(std::time::Instant::now());
2959
- continue;
2960
- }
2961
- if let Event::Key(key_event) = ev {
2962
- if key_event.kind == event::KeyEventKind::Press {
2963
- let is_ctrl_d = matches!(key_event.code, KeyCode::Char('d'))
2964
- && key_event
2965
- .modifiers
2966
- .contains(crossterm::event::KeyModifiers::CONTROL);
2967
- if ctrl_d_pressed && !is_ctrl_d {
2968
- ctrl_d_pressed = false;
2969
- disable_raw_mode()?;
2970
- print!("\r\x1b[3B\r\x1b[2K\x1b[3A");
2971
- print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
2972
- let _ = io::stdout().flush();
2973
- enable_raw_mode()?;
2974
- }
2975
-
2976
- // Reset tab autocomplete state if any key other than Tab, Up, or Down is pressed
2977
- if key_event.code != KeyCode::Tab
2978
- && key_event.code != KeyCode::Up
2979
- && key_event.code != KeyCode::Down
2980
- {
2981
- tab_base_input = None;
2982
- tab_index = None;
2983
- }
2984
-
2985
- match key_event.code {
2986
- KeyCode::Char('d')
2987
- if key_event
2988
- .modifiers
2989
- .contains(crossterm::event::KeyModifiers::CONTROL) =>
2990
- {
2991
- if ctrl_d_pressed {
2992
- disable_raw_mode()?;
2993
- clear_input_box();
2994
- let _ = io::stdout().flush();
2995
- break None;
2996
- } else {
2997
- ctrl_d_pressed = true;
2998
- disable_raw_mode()?;
2999
- print!(
3000
- "\r\x1b[3B\r\x1b[2K{WARN}Press Ctrl+D again to exit{RESET}\x1b[3A"
3001
- );
3002
- print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
3003
- let _ = io::stdout().flush();
3004
- enable_raw_mode()?;
3005
- }
3006
- }
3007
- KeyCode::Char('v')
3008
- if key_event
3009
- .modifiers
3010
- .contains(crossterm::event::KeyModifiers::CONTROL) =>
3011
- {
3012
- if let Ok(Some(uri)) = image::read_clipboard_image() {
3013
- if let Some(ref mut current) = pasted_image {
3014
- current.push(' ');
3015
- current.push_str(&uri);
3016
- } else {
3017
- pasted_image = Some(uri);
3018
- }
3019
- insert_image_placeholder(&mut input_chars, &mut cursor_pos);
3020
-
3021
- disable_raw_mode()?;
3022
- redraw_input_box(
3023
- &input_chars,
3024
- cursor_pos,
3025
- placeholder,
3026
- model,
3027
- path_str,
3028
- None,
3029
- None,
3030
- );
3031
- enable_raw_mode()?;
3032
- }
3033
- }
3034
- KeyCode::Char(c) => {
3035
- if input_chars.len() < 10000 {
3036
- input_chars.insert(cursor_pos, c);
3037
- cursor_pos += 1;
3038
-
3039
- disable_raw_mode()?;
3040
- redraw_input_box(
3041
- &input_chars,
3042
- cursor_pos,
3043
- placeholder,
3044
- model,
3045
- path_str,
3046
- None,
3047
- None,
3048
- );
3049
- enable_raw_mode()?;
3050
- }
3051
- }
3052
- KeyCode::Backspace => {
3053
- if cursor_pos > 0 {
3054
- cursor_pos -= 1;
3055
- input_chars.remove(cursor_pos);
3056
-
3057
- disable_raw_mode()?;
3058
- redraw_input_box(
3059
- &input_chars,
3060
- cursor_pos,
3061
- placeholder,
3062
- model,
3063
- path_str,
3064
- None,
3065
- None,
3066
- );
3067
- enable_raw_mode()?;
3068
- }
3069
- }
3070
- KeyCode::Tab => {
3071
- let base = match &tab_base_input {
3072
- Some(b) => b.clone(),
3073
- None => {
3074
- let current_str: String = input_chars.iter().collect();
3075
- tab_base_input = Some(current_str.clone());
3076
- current_str
3077
- }
3078
- };
3079
-
3080
- if base.starts_with('/') {
3081
- let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
3082
- .iter()
3083
- .filter(|(cmd, _)| cmd.starts_with(&base))
3084
- .collect();
3085
-
3086
- if !matches.is_empty() {
3087
- let idx = tab_index.unwrap_or(0) % matches.len();
3088
- let completed = format!("{} ", matches[idx].0);
3089
- input_chars = completed.chars().collect();
3090
- cursor_pos = input_chars.len();
3091
-
3092
- // Highlight currently completed item in suggestions
3093
- let current_highlight = Some(idx);
3094
- tab_index = Some(idx + 1);
3095
-
3096
- disable_raw_mode()?;
3097
- redraw_input_box(
3098
- &input_chars,
3099
- cursor_pos,
3100
- placeholder,
3101
- model,
3102
- path_str,
3103
- Some(&base),
3104
- current_highlight,
3105
- );
3106
- enable_raw_mode()?;
3107
- }
3108
- }
3109
- }
3110
- KeyCode::Down => {
3111
- let base = match &tab_base_input {
3112
- Some(b) => b.clone(),
3113
- None => {
3114
- let current_str: String = input_chars.iter().collect();
3115
- tab_base_input = Some(current_str.clone());
3116
- current_str
3117
- }
3118
- };
3119
-
3120
- if base.starts_with('/') {
3121
- let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
3122
- .iter()
3123
- .filter(|(cmd, _)| cmd.starts_with(&base))
3124
- .collect();
3125
-
3126
- if !matches.is_empty() {
3127
- let new_idx = match tab_index {
3128
- Some(idx) => (idx + 1) % matches.len(),
3129
- None => 0,
3130
- };
3131
- tab_index = Some(new_idx);
3132
- let completed = format!("{} ", matches[new_idx].0);
3133
- input_chars = completed.chars().collect();
3134
- cursor_pos = input_chars.len();
3135
-
3136
- disable_raw_mode()?;
3137
- redraw_input_box(
3138
- &input_chars,
3139
- cursor_pos,
3140
- placeholder,
3141
- model,
3142
- path_str,
3143
- Some(&base),
3144
- Some(new_idx),
3145
- );
3146
- enable_raw_mode()?;
3147
- }
3148
- }
3149
- }
3150
- KeyCode::Up => {
3151
- let base = match &tab_base_input {
3152
- Some(b) => b.clone(),
3153
- None => {
3154
- let current_str: String = input_chars.iter().collect();
3155
- tab_base_input = Some(current_str.clone());
3156
- current_str
3157
- }
3158
- };
3159
-
3160
- if base.starts_with('/') {
3161
- let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
3162
- .iter()
3163
- .filter(|(cmd, _)| cmd.starts_with(&base))
3164
- .collect();
3165
-
3166
- if !matches.is_empty() {
3167
- let new_idx = match tab_index {
3168
- Some(idx) => {
3169
- if idx == 0 {
3170
- matches.len() - 1
3171
- } else {
3172
- idx - 1
3173
- }
3174
- }
3175
- None => matches.len() - 1,
3176
- };
3177
- tab_index = Some(new_idx);
3178
- let completed = format!("{} ", matches[new_idx].0);
3179
- input_chars = completed.chars().collect();
3180
- cursor_pos = input_chars.len();
3181
-
3182
- disable_raw_mode()?;
3183
- redraw_input_box(
3184
- &input_chars,
3185
- cursor_pos,
3186
- placeholder,
3187
- model,
3188
- path_str,
3189
- Some(&base),
3190
- Some(new_idx),
3191
- );
3192
- enable_raw_mode()?;
3193
- }
3194
- }
3195
- }
3196
- KeyCode::Left => {
3197
- while cursor_pos > 0 {
3198
- cursor_pos -= 1;
3199
- if cursor_pos == 0 || !is_thai_zero_width(input_chars[cursor_pos]) {
3200
- break;
3201
- }
3202
- }
3203
- disable_raw_mode()?;
3204
- redraw_input_box(
3205
- &input_chars,
3206
- cursor_pos,
3207
- placeholder,
3208
- model,
3209
- path_str,
3210
- None,
3211
- None,
3212
- );
3213
- enable_raw_mode()?;
3214
- }
3215
- KeyCode::Right => {
3216
- while cursor_pos < input_chars.len() {
3217
- cursor_pos += 1;
3218
- if cursor_pos == input_chars.len()
3219
- || !is_thai_zero_width(input_chars[cursor_pos])
3220
- {
3221
- break;
3222
- }
3223
- }
3224
- disable_raw_mode()?;
3225
- redraw_input_box(
3226
- &input_chars,
3227
- cursor_pos,
3228
- placeholder,
3229
- model,
3230
- path_str,
3231
- None,
3232
- None,
3233
- );
3234
- enable_raw_mode()?;
3235
- }
3236
- KeyCode::Enter => {
3237
- if let Some(time) = last_paste_time {
3238
- if time.elapsed() < std::time::Duration::from_millis(100) {
3239
- continue;
3240
- }
3241
- }
3242
- disable_raw_mode()?;
3243
- clear_input_box();
3244
- let input_str: String = input_chars.iter().collect();
3245
-
3246
- let mut expanded_str = input_str.clone();
3247
- for (placeholder_str, content) in &paste_contents {
3248
- expanded_str = expanded_str.replace(placeholder_str, content);
3249
- }
3250
-
3251
- println!(" {BLUE}You ›{RESET} {}", expanded_str);
3252
- let _ = io::stdout().flush();
3253
-
3254
- break Some(InteractiveInput {
3255
- text: expanded_str,
3256
- pasted_image,
3257
- });
3258
- }
3259
- KeyCode::Esc => {
3260
- disable_raw_mode()?;
3261
- clear_input_box();
3262
- let _ = io::stdout().flush();
3263
- break None;
3264
- }
3265
- _ => {}
3266
- }
3267
- }
3268
- }
3269
- }
3270
- };
3271
-
3272
- let _ = crossterm::execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
3273
- Ok(result)
3274
- }
3275
-
3276
- fn insert_image_placeholder(input_chars: &mut Vec<char>, cursor_pos: &mut usize) {
3277
- let input: String = input_chars.iter().collect();
3278
- let mut idx = 1;
3279
- while input.contains(&format!("[Image #{}]", idx)) {
3280
- idx += 1;
3281
- }
3282
- let placeholder = format!("[Image #{}]", idx);
3283
- let placeholder_chars = placeholder.chars().collect::<Vec<_>>();
3284
- input_chars.splice(*cursor_pos..*cursor_pos, placeholder_chars.iter().copied());
3285
- *cursor_pos += placeholder_chars.len();
3286
- }
3287
-
3288
- fn format_path_with_tilde(path: &std::path::Path) -> String {
3289
- let path_str = path.to_string_lossy().to_string();
3290
- if let Some(home) = dirs::home_dir() {
3291
- let home_str = home.to_string_lossy().to_string();
3292
- if path_str.starts_with(&home_str) {
3293
- return path_str.replacen(&home_str, "~", 1);
3294
- }
3295
- }
3296
- path_str
3297
- }
3298
-
3299
- fn format_placeholders(s: &str) -> String {
3300
- let mut result = String::new();
3301
- let chars = s.chars().collect::<Vec<_>>();
3302
- let mut i = 0;
3303
- while i < chars.len() {
3304
- if chars[i] == '[' && i + 12 < chars.len() {
3305
- let slice: String = chars[i..i + 13].iter().collect();
3306
- if slice == "[Pasted text #" {
3307
- if let Some(end_offset) = chars[i..].iter().position(|&c| c == ']') {
3308
- let end_idx = i + end_offset;
3309
- let inside: String = chars[i + 1..end_idx].iter().collect();
3310
- result.push('[');
3311
- result.push_str(BLUE);
3312
- result.push_str(&inside);
3313
- result.push_str("\x1b[39m");
3314
- result.push(']');
3315
- i = end_idx + 1;
3316
- continue;
3317
- }
3318
- }
3319
- }
3320
- result.push(chars[i]);
3321
- i += 1;
3322
- }
3323
- result
3324
- }
3325
-
3326
- fn is_thai_zero_width(c: char) -> bool {
3327
- let cp = c as u32;
3328
- cp == 0x0E31 || (0x0E34..=0x0E3A).contains(&cp) || (0x0E47..=0x0E4E).contains(&cp)
3329
- }
3330
-
3331
- fn char_visual_width(c: char) -> usize {
3332
- if is_thai_zero_width(c) { 0 } else { 1 }
3333
- }
3334
-
3335
- fn string_visual_width(s: &str) -> usize {
3336
- s.chars().map(char_visual_width).sum()
3337
- }
3338
-
3339
- fn active_model<'a>(provider: &str, config: &'a mint_core::MintConfig) -> &'a str {
3340
- match provider {
3341
- "anthropic" => &config.anthropic_model,
3342
- "openai" => &config.openai_model,
3343
- "openrouter" => &config.openrouter_model,
3344
- "deepseek" => &config.deepseek_model,
3345
- "huggingface" => &config.hf_model,
3346
- "local_openai" => &config.local_model_name,
3347
- "ollama" => &config.ollama_model,
3348
- _ => &config.gemini_model,
3349
- }
3350
- }
3351
-
3352
- fn configured(config: &mint_core::MintConfig, keys: &[&str]) -> bool {
3353
- keys.iter().all(|key| {
3354
- config
3355
- .extra
3356
- .get(*key)
3357
- .and_then(serde_json::Value::as_str)
3358
- .is_some_and(|value| !value.trim().is_empty())
3359
- })
3360
- }
3361
-
3362
- fn edit_content(
3363
- content: Option<String>,
3364
- from_file: Option<PathBuf>,
3365
- config: &MintConfig,
3366
- ) -> Result<String> {
3367
- match from_file {
3368
- Some(path) => {
3369
- let path = assert_path_capability(&path, Capability::Read, config)?;
3370
- Ok(fs::read_to_string(path)?)
3371
- }
3372
- None => Ok(content.unwrap_or_default()),
3373
- }
3374
- }
3375
-
3376
- fn file_edits(values: &[String], config: &MintConfig) -> Result<Vec<CodeEdit>> {
3377
- values
3378
- .iter()
3379
- .map(|value| {
3380
- let (target, source) = value
3381
- .split_once('=')
3382
- .ok_or_else(|| anyhow::anyhow!("edit must use TARGET=SOURCE format"))?;
3383
- Ok(CodeEdit {
3384
- path: PathBuf::from(target),
3385
- content: edit_content(None, Some(PathBuf::from(source)), config)?,
3386
- })
3387
- })
3388
- .collect()
3389
- }
3390
-
3391
- fn open_system_handler(target: &str) -> Result<()> {
3392
- #[cfg(target_os = "windows")]
3393
- {
3394
- std::process::Command::new("cmd")
3395
- .args(&["/c", "start", "", target])
3396
- .spawn()?;
3397
- }
3398
- #[cfg(target_os = "macos")]
3399
- {
3400
- std::process::Command::new("open").arg(target).spawn()?;
3401
- }
3402
- #[cfg(not(any(target_os = "windows", target_os = "macos")))]
3403
- {
3404
- std::process::Command::new("xdg-open").arg(target).spawn()?;
3405
- }
3406
- Ok(())
3407
- }
3408
-
3409
- fn launch_desktop_app(name: &str) -> Result<()> {
3410
- #[cfg(target_os = "windows")]
3411
- {
3412
- std::process::Command::new("cmd")
3413
- .args(&["/c", "start", "", name])
3414
- .spawn()?;
3415
- }
3416
- #[cfg(target_os = "macos")]
3417
- {
3418
- std::process::Command::new("open")
3419
- .args(&["-a", name])
3420
- .spawn()?;
3421
- }
3422
- #[cfg(not(any(target_os = "windows", target_os = "macos")))]
3423
- {
3424
- if std::process::Command::new(name)
3425
- .stdin(std::process::Stdio::null())
3426
- .stdout(std::process::Stdio::null())
3427
- .stderr(std::process::Stdio::null())
3428
- .spawn()
3429
- .is_ok()
3430
- {
3431
- return Ok(());
3432
- }
3433
- let lower = name.to_lowercase();
3434
- if std::process::Command::new("gtk-launch")
3435
- .arg(&lower)
3436
- .stdin(std::process::Stdio::null())
3437
- .stdout(std::process::Stdio::null())
3438
- .stderr(std::process::Stdio::null())
3439
- .spawn()
3440
- .is_ok()
3441
- {
3442
- return Ok(());
3443
- }
3444
- std::process::Command::new("xdg-open").arg(name).spawn()?;
3445
- }
3446
- Ok(())
3447
- }
3448
-
3449
- fn read_file_content(path: &std::path::Path) -> Result<()> {
3450
- let content = fs::read_to_string(path)?;
3451
- println!("{}", content);
3452
- Ok(())
3453
- }
3454
-
3455
- fn read_folder_content(path: &std::path::Path) -> Result<()> {
3456
- let entries = fs::read_dir(path)?;
3457
- for entry in entries {
3458
- let entry = entry?;
3459
- let file_name = entry.file_name();
3460
- let file_name_str = file_name.to_string_lossy();
3461
- let metadata = entry.metadata()?;
3462
- if metadata.is_dir() {
3463
- println!("{MINT}{}/{}", file_name_str, RESET);
3464
- } else {
3465
- println!("{}", file_name_str);
3466
- }
3467
- }
3468
- Ok(())
3469
- }
3470
-
3471
- pub static SESSION_APPROVED: std::sync::atomic::AtomicBool =
3472
- std::sync::atomic::AtomicBool::new(false);
3473
-
3474
- pub fn confirm(prompt: &str) -> Result<bool> {
3475
- let clean_prompt = prompt
3476
- .replace(" [y/N] ", "")
3477
- .replace(" [y/N]", "")
3478
- .trim()
3479
- .to_string();
3480
-
3481
- if SESSION_APPROVED.load(std::sync::atomic::Ordering::Relaxed) {
3482
- println!(" {} {MINT}Approve (session-wide){RESET}", clean_prompt);
3483
- return Ok(true);
3484
- }
3485
-
3486
- use crossterm::event::{self, Event, KeyCode};
3487
- use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
3488
- use crossterm::tty::IsTty;
3489
-
3490
- if !io::stdout().is_tty() || enable_raw_mode().is_err() {
3491
- print!(" {} [y/N] ", clean_prompt);
3492
- let _ = io::stdout().flush();
3493
- let mut answer = String::new();
3494
- io::stdin().read_line(&mut answer)?;
3495
- return Ok(matches!(
3496
- answer.trim().to_ascii_lowercase().as_str(),
3497
- "y" | "yes"
3498
- ));
3499
- }
3500
-
3501
- let _ = disable_raw_mode();
3502
- println!(" {}", clean_prompt);
3503
-
3504
- let options = ["Approve", "Approve this session", "No"];
3505
- let mut selected = 0;
3506
-
3507
- let print_choices = |selected: usize| -> Result<()> {
3508
- for (i, opt) in options.iter().enumerate() {
3509
- if i == selected {
3510
- println!(" {BLUE}❯ {}. {}{RESET}", i + 1, opt);
3511
- } else {
3512
- println!(" {DIM}{}. {}{RESET}", i + 1, opt);
3513
- }
3514
- }
3515
- io::stdout().flush()?;
3516
- Ok(())
3517
- };
3518
-
3519
- print_choices(selected)?;
3520
-
3521
- let _ = enable_raw_mode();
3522
-
3523
- let choice = loop {
3524
- match event::poll(std::time::Duration::from_millis(100)) {
3525
- Ok(true) => match event::read() {
3526
- Ok(Event::Key(key_event)) => {
3527
- if key_event.kind == event::KeyEventKind::Press {
3528
- let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
3529
- && key_event
3530
- .modifiers
3531
- .contains(crossterm::event::KeyModifiers::CONTROL);
3532
- if is_ctrl_c {
3533
- break 2;
3534
- }
3535
-
3536
- match key_event.code {
3537
- KeyCode::Up => {
3538
- if selected > 0 {
3539
- selected -= 1;
3540
- } else {
3541
- selected = options.len() - 1;
3542
- }
3543
- let _ = disable_raw_mode();
3544
- print!("\x1b[{}A\x1b[J", options.len());
3545
- let _ = print_choices(selected);
3546
- let _ = enable_raw_mode();
3547
- }
3548
- KeyCode::Down => {
3549
- if selected < options.len() - 1 {
3550
- selected += 1;
3551
- } else {
3552
- selected = 0;
3553
- }
3554
- let _ = disable_raw_mode();
3555
- print!("\x1b[{}A\x1b[J", options.len());
3556
- let _ = print_choices(selected);
3557
- let _ = enable_raw_mode();
3558
- }
3559
- KeyCode::Tab => {
3560
- if selected < options.len() - 1 {
3561
- selected += 1;
3562
- } else {
3563
- selected = 0;
3564
- }
3565
- let _ = disable_raw_mode();
3566
- print!("\x1b[{}A\x1b[J", options.len());
3567
- let _ = print_choices(selected);
3568
- let _ = enable_raw_mode();
3569
- }
3570
- KeyCode::Char('1') | KeyCode::Char('a') | KeyCode::Char('y') => {
3571
- break 0;
3572
- }
3573
- KeyCode::Char('2') | KeyCode::Char('s') => {
3574
- break 1;
3575
- }
3576
- KeyCode::Char('3') | KeyCode::Char('n') | KeyCode::Char('c') => {
3577
- break 2;
3578
- }
3579
- KeyCode::Enter => {
3580
- break selected;
3581
- }
3582
- KeyCode::Esc => {
3583
- break 2;
3584
- }
3585
- _ => {}
3586
- }
3587
- }
3588
- }
3589
- Ok(_) => {}
3590
- Err(_) => {
3591
- break 2;
3592
- }
3593
- },
3594
- Ok(false) => {}
3595
- Err(_) => {
3596
- break 2;
3597
- }
3598
- }
3599
- };
3600
-
3601
- let _ = disable_raw_mode();
3602
- print!("\x1b[{}A\x1b[J", options.len() + 1);
3603
-
3604
- let result_str = match choice {
3605
- 0 => format!("{MINT}Approve{RESET}"),
3606
- 1 => format!("{MINT}Approve this session{RESET}"),
3607
- _ => format!("{ERROR}No{RESET}"),
3608
- };
3609
- println!(" {} {}", clean_prompt, result_str);
3610
- let _ = io::stdout().flush();
3611
-
3612
- match choice {
3613
- 0 => Ok(true),
3614
- 1 => {
3615
- SESSION_APPROVED.store(true, std::sync::atomic::Ordering::Relaxed);
3616
- Ok(true)
3617
- }
3618
- _ => Ok(false),
3619
- }
3620
- }
3621
-
3622
- fn confirm_shell_execution() -> Result<bool> {
3623
- confirm("Approve local shell execution? [y/N]")
3624
- }
3625
-
3626
- fn print_shell_output(output: &mint_core::ShellOutput) {
3627
- if !output.stdout.is_empty() {
3628
- print!("{}", output.stdout);
3629
- }
3630
- if !output.stderr.is_empty() {
3631
- eprint!("{}", output.stderr);
3632
- }
3633
- if !output.stdout.ends_with('\n') && !output.stderr.ends_with('\n') {
3634
- println!();
3635
- }
3636
- println!(
3637
- "{DIM}[exit: {} | sandboxed: {}]{RESET}\n",
3638
- output
3639
- .status
3640
- .map_or_else(|| "unknown".into(), |status| status.to_string()),
3641
- output.sandboxed
3642
- );
3643
- }
3644
-
3645
1523
  async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
3646
1524
  let Some((owner, repo_name)) = parse_github_url(repo) else {
3647
1525
  anyhow::bail!(
@@ -3649,13 +1527,26 @@ async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
3649
1527
  );
3650
1528
  };
3651
1529
 
3652
- println!(
1530
+ use indicatif::{ProgressBar, ProgressStyle};
1531
+ use std::time::Duration;
1532
+
1533
+ let spinner = ProgressBar::new_spinner();
1534
+ spinner.set_style(
1535
+ ProgressStyle::default_spinner()
1536
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1537
+ .template("{spinner:.green} {msg}")
1538
+ .unwrap(),
1539
+ );
1540
+ spinner.set_message(format!(
3653
1541
  "Fetching information for {}/{} from GitHub...",
3654
1542
  owner, repo_name
3655
- );
1543
+ ));
1544
+ spinner.enable_steady_tick(Duration::from_millis(80));
1545
+
3656
1546
  let summary = match fetch_github_repo_summary(&owner, &repo_name).await {
3657
1547
  Ok(s) => s,
3658
1548
  Err(e) => {
1549
+ spinner.finish_and_clear();
3659
1550
  anyhow::bail!(
3660
1551
  "Failed to fetch repository summary: {}. Check that the repository is public and spelled correctly.",
3661
1552
  e
@@ -3663,13 +1554,13 @@ async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
3663
1554
  }
3664
1555
  };
3665
1556
 
3666
- println!("Analyzing repository with AI model...");
1557
+ spinner.set_message("Analyzing repository with AI model...");
3667
1558
  let prompt = format!(
3668
1559
  "Here is the metadata, top-level directory structure, and README.md content for the GitHub repository {}/{}:\n\n{}\n\nBased on this information, please provide a high-level overview of what this repository is about, what tech stack it uses, its overall architecture, and how it is organized.",
3669
1560
  owner, repo_name, summary
3670
1561
  );
3671
1562
 
3672
- let (response, _) = orchestrate_chat_with_fallback(
1563
+ let (response, _) = match orchestrate_chat_with_fallback(
3673
1564
  config,
3674
1565
  &ChatRequest {
3675
1566
  message: prompt,
@@ -3677,12 +1568,22 @@ async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
3677
1568
  chat_id: Some("github_review".to_string()),
3678
1569
  image_data_uri: None,
3679
1570
  audio_data_uri: None,
1571
+ video_data_uri: None,
3680
1572
  document_attachment: None,
3681
1573
  workspace_path: None,
3682
1574
  agent_id: None,
3683
1575
  },
3684
1576
  )
3685
- .await?;
1577
+ .await {
1578
+ Ok(res) => {
1579
+ spinner.finish_and_clear();
1580
+ res
1581
+ }
1582
+ Err(e) => {
1583
+ spinner.finish_and_clear();
1584
+ return Err(e.into());
1585
+ }
1586
+ };
3686
1587
 
3687
1588
  println!(
3688
1589
  "\n--- AI Repository Overview for {}/{} ---",
@@ -3692,19 +1593,3 @@ async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
3692
1593
  println!("--------------------------------------------------");
3693
1594
  Ok(())
3694
1595
  }
3695
-
3696
- #[cfg(test)]
3697
- mod tests {
3698
- use super::*;
3699
-
3700
- #[test]
3701
- fn inserts_one_image_placeholder_at_cursor() {
3702
- let mut chars = "ask ".chars().collect::<Vec<_>>();
3703
- let mut cursor = chars.len();
3704
-
3705
- insert_image_placeholder(&mut chars, &mut cursor);
3706
-
3707
- assert_eq!(chars.iter().collect::<String>(), "ask [Image #1]");
3708
- assert_eq!(cursor, "ask [Image #1]".chars().count());
3709
- }
3710
- }