@pheem49/mint 1.8.2 → 1.9.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 (44) hide show
  1. package/Cargo.toml +1 -1
  2. package/Release_Note.md +48 -3
  3. package/crates/mint-cli/src/agent.rs +95 -84
  4. package/crates/mint-cli/src/image.rs +8 -8
  5. package/crates/mint-cli/src/main.rs +806 -403
  6. package/crates/mint-cli/src/onboard.rs +108 -108
  7. package/crates/mint-cli/src/setup.rs +143 -143
  8. package/crates/mint-core/src/api_server.rs +57 -54
  9. package/crates/mint-core/src/browser.rs +1 -1
  10. package/crates/mint-core/src/chat.rs +123 -8
  11. package/crates/mint-core/src/code_tools.rs +18 -20
  12. package/crates/mint-core/src/config.rs +184 -17
  13. package/crates/mint-core/src/image_gen.rs +5 -5
  14. package/crates/mint-core/src/lib.rs +5 -5
  15. package/crates/mint-core/src/memory.rs +6 -1
  16. package/crates/mint-core/src/orchestration.rs +104 -85
  17. package/crates/mint-core/src/pictures.rs +3 -4
  18. package/crates/mint-core/src/semantic.rs +1 -1
  19. package/crates/mint-core/src/shell.rs +1 -1
  20. package/crates/mint-core/src/skills.rs +46 -19
  21. package/package.json +1 -1
  22. package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
  23. package/shared-modules/linux-audio/fluidsynth2.json +8 -2
  24. package/shared-modules/linux-audio/lilv.json +26 -19
  25. package/shared-modules/linux-audio/lv2.json +0 -1
  26. package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
  27. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  28. package/src/renderer/src/components/ChatPanel.tsx +25 -5
  29. package/src/renderer/src/components/MintDashboard.tsx +45 -2
  30. package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
  31. package/src/renderer/src/components/Settings/GeneralTab.tsx +256 -2
  32. package/src/renderer/src/components/SettingsWindow.tsx +68 -1
  33. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  34. package/src/renderer/src/css/styles.css +16 -0
  35. package/src/renderer/src-web/components/ChatPanel.tsx +25 -5
  36. package/src/renderer/src-web/components/MintDashboard.tsx +19 -1
  37. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
  38. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +254 -3
  39. package/src/renderer/src-web/components/SettingsWindow.tsx +75 -1
  40. package/src/renderer/src-web/css/styles.css +16 -0
  41. package/src-tauri/src/desktop.rs +18 -16
  42. package/src-tauri/src/discord_rpc.rs +1 -1
  43. package/src-tauri/src/lib.rs +41 -17
  44. package/src-tauri/tauri.conf.json +1 -1
@@ -494,8 +494,8 @@ async fn main() -> Result<()> {
494
494
  println!("{}", serde_json::to_string_pretty(&load_config()?)?)
495
495
  }
496
496
  ConfigCommand::Set { key, value } => {
497
- let value = serde_json::from_str(&value)
498
- .unwrap_or_else(|_| serde_json::Value::String(value));
497
+ let value =
498
+ serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value));
499
499
  println!(
500
500
  "{}",
501
501
  serde_json::to_string_pretty(&set_config_value(&key, value)?)?
@@ -579,7 +579,7 @@ async fn main() -> Result<()> {
579
579
 
580
580
  // Start tailing the log file in a background task
581
581
  let log_dir = dirs::config_dir()
582
- .unwrap_or_else(|| std::env::temp_dir())
582
+ .unwrap_or_else(std::env::temp_dir)
583
583
  .join("mint");
584
584
  let log_file = log_dir.join("browser-automation.log");
585
585
 
@@ -591,40 +591,37 @@ async fn main() -> Result<()> {
591
591
  use std::io::{BufRead, BufReader, Seek, SeekFrom};
592
592
  let mut file_pos = 0;
593
593
  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
- }
594
+ if log_file_clone.exists()
595
+ && let Ok(mut file) = std::fs::File::open(&log_file_clone)
596
+ && file.seek(SeekFrom::Start(file_pos)).is_ok()
597
+ {
598
+ let reader = BufReader::new(file);
599
+ for line_str in reader.lines().flatten() {
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}");
626
620
  }
627
621
  }
622
+ if let Ok(pos) = log_file_clone.metadata().map(|m| m.len()) {
623
+ file_pos = pos;
624
+ }
628
625
  }
629
626
  tokio::time::sleep(std::time::Duration::from_millis(100)).await;
630
627
  }
@@ -705,20 +702,20 @@ async fn main() -> Result<()> {
705
702
  home.join(".config").join("mint").join("mint-skills");
706
703
  mint_core::skills::load_skills_from_dir(&global_skills_path, &mut skills);
707
704
  }
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
- );
705
+ if let Ok(root) = std::env::current_dir()
706
+ && let Ok(root) = root.canonicalize()
707
+ {
708
+ let workspace_skills_path1 = root.join(".agents").join("skills");
709
+ mint_core::skills::load_skills_from_dir(
710
+ &workspace_skills_path1,
711
+ &mut skills,
712
+ );
715
713
 
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
- }
714
+ let workspace_skills_path2 = root.join("skills");
715
+ mint_core::skills::load_skills_from_dir(
716
+ &workspace_skills_path2,
717
+ &mut skills,
718
+ );
722
719
  }
723
720
 
724
721
  let mut unique_skills = std::collections::BTreeMap::new();
@@ -1153,10 +1150,10 @@ async fn main() -> Result<()> {
1153
1150
  ),
1154
1151
  }
1155
1152
  }
1156
- if let Some(desc) = &result.description {
1157
- if !desc.is_empty() {
1158
- println!("\n{DIM}{desc}{RESET}");
1159
- }
1153
+ if let Some(desc) = &result.description
1154
+ && !desc.is_empty()
1155
+ {
1156
+ println!("\n{DIM}{desc}{RESET}");
1160
1157
  }
1161
1158
  }
1162
1159
  Err(e) => eprintln!("{ERROR}Failed to save images: {e}{RESET}"),
@@ -1201,16 +1198,16 @@ async fn launch_mint_target(target: String) -> Result<()> {
1201
1198
  path = p.parent();
1202
1199
  }
1203
1200
  }
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();
1201
+ if found.is_none()
1202
+ && let Ok(cwd) = std::env::current_dir()
1203
+ {
1204
+ let mut path = Some(cwd.as_path());
1205
+ while let Some(p) = path {
1206
+ if p.join("package.json").exists() {
1207
+ found = Some(p.to_path_buf());
1208
+ break;
1213
1209
  }
1210
+ path = p.parent();
1214
1211
  }
1215
1212
  }
1216
1213
  found.ok_or_else(|| {
@@ -1219,7 +1216,7 @@ async fn launch_mint_target(target: String) -> Result<()> {
1219
1216
  };
1220
1217
  std::process::Command::new("npm")
1221
1218
  .current_dir(&project_root)
1222
- .args(&["run", "dev:web"])
1219
+ .args(["run", "dev:web"])
1223
1220
  .stdout(std::process::Stdio::null())
1224
1221
  .stderr(std::process::Stdio::null())
1225
1222
  .spawn()
@@ -1402,7 +1399,7 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1402
1399
  rendered.push_str(&draw_border("┌", "┬", "┐", "─", &col_widths));
1403
1400
 
1404
1401
  if let Some(ref header) = header_row {
1405
- rendered.push_str("│");
1402
+ rendered.push('│');
1406
1403
  for (i, col) in header.iter().enumerate() {
1407
1404
  let width = unicode_width(col);
1408
1405
  let padding = col_widths[i] - width;
@@ -1411,20 +1408,20 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1411
1408
  col,
1412
1409
  " ".repeat(padding + 1)
1413
1410
  ));
1414
- rendered.push_str("│");
1411
+ rendered.push('│');
1415
1412
  }
1416
1413
  rendered.push('\n');
1417
1414
  rendered.push_str(&draw_border("├", "┼", "┤", "─", &col_widths));
1418
1415
  }
1419
1416
 
1420
1417
  for (r_idx, row) in data_rows.iter().enumerate() {
1421
- rendered.push_str("│");
1418
+ rendered.push('│');
1422
1419
  for i in 0..num_cols {
1423
1420
  let col_val = row.get(i).cloned().unwrap_or_default();
1424
1421
  let width = unicode_width(&col_val);
1425
1422
  let padding = col_widths[i] - width;
1426
1423
  rendered.push_str(&format!(" {}{}", col_val, " ".repeat(padding + 1)));
1427
- rendered.push_str("│");
1424
+ rendered.push('│');
1428
1425
  }
1429
1426
  rendered.push('\n');
1430
1427
 
@@ -1433,7 +1430,7 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1433
1430
  }
1434
1431
  }
1435
1432
 
1436
- rendered.push_str(&draw_border("└", "┴", "┘", "─", &col_widths).trim_end());
1433
+ rendered.push_str(draw_border("└", "┴", "┘", "─", &col_widths).trim_end());
1437
1434
  rendered
1438
1435
  }
1439
1436
 
@@ -1747,6 +1744,10 @@ async fn handle_slash_command(
1747
1744
  ("/paste [prompt]", "Attach image from clipboard"),
1748
1745
  ("Ctrl+V", "Paste clipboard image as [Image #1]"),
1749
1746
  ("/learn <path>", "Import a persistent .md or .txt skill"),
1747
+ (
1748
+ "/skill add <path>",
1749
+ "Copy/install skill file or folder to global config",
1750
+ ),
1750
1751
  (
1751
1752
  "/plugins <name> [prompt]",
1752
1753
  "Generate a skill.md file for a plugin/skill",
@@ -1810,8 +1811,12 @@ async fn handle_slash_command(
1810
1811
  session.config.ai_provider = rest.to_owned();
1811
1812
  match mint_core::save_config(&session.config) {
1812
1813
  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);
1814
+ let active_model =
1815
+ active_model(&session.config.ai_provider, &session.config);
1816
+ let display_name = format!(
1817
+ "Changed model to {} • {}",
1818
+ session.config.ai_provider, active_model
1819
+ );
1815
1820
 
1816
1821
  // Save system event interaction in memory
1817
1822
  if let Ok(memory) = MemoryStore::open_default() {
@@ -1842,7 +1847,11 @@ async fn handle_slash_command(
1842
1847
  match mint_core::save_config(&session.config) {
1843
1848
  Ok(()) => println!(
1844
1849
  "{DIM}Multi-Agent collaboration set to: {}{RESET}\n",
1845
- if session.config.enable_agent_collaboration { "Enabled" } else { "Disabled" }
1850
+ if session.config.enable_agent_collaboration {
1851
+ "Enabled"
1852
+ } else {
1853
+ "Disabled"
1854
+ }
1846
1855
  ),
1847
1856
  Err(error) => println!("{ERROR}Config error:{RESET} {error}\n"),
1848
1857
  }
@@ -2093,6 +2102,73 @@ async fn handle_slash_command(
2093
2102
  Some(SlashResult::Handled)
2094
2103
  }
2095
2104
 
2105
+ "/skill" => {
2106
+ let (subcmd, args) = rest
2107
+ .split_once(char::is_whitespace)
2108
+ .map(|(c, a)| (c, a.trim()))
2109
+ .unwrap_or((rest, ""));
2110
+
2111
+ match subcmd {
2112
+ "add" | "install" => {
2113
+ if args.is_empty() {
2114
+ println!(
2115
+ "{WARN}/skill add <path> requires a path to a skill file or folder{RESET}\n"
2116
+ );
2117
+ } else {
2118
+ let source_path = PathBuf::from(args);
2119
+ let source_path = if source_path.is_absolute() {
2120
+ source_path
2121
+ } else {
2122
+ session.current_dir.join(source_path)
2123
+ };
2124
+
2125
+ if !source_path.exists() {
2126
+ println!("{ERROR}Source skill path not found: {}{RESET}\n", args);
2127
+ } else if let Some(home) = dirs::home_dir() {
2128
+ let global_skills_path =
2129
+ home.join(".config").join("mint").join("mint-skills");
2130
+ if !global_skills_path.exists() {
2131
+ let _ = std::fs::create_dir_all(&global_skills_path);
2132
+ }
2133
+
2134
+ let name = source_path
2135
+ .file_name()
2136
+ .and_then(|n| n.to_str())
2137
+ .unwrap_or("skill");
2138
+
2139
+ let dest_path = global_skills_path.join(name);
2140
+ let copy_res = if source_path.is_dir() {
2141
+ copy_dir_all(&source_path, &dest_path)
2142
+ } else {
2143
+ std::fs::copy(&source_path, &dest_path)
2144
+ .map(|_| ())
2145
+ .map_err(|e| e)
2146
+ };
2147
+
2148
+ match copy_res {
2149
+ Ok(()) => println!(
2150
+ "{DIM}Skill successfully copied to Global config: {}{RESET}\n",
2151
+ dest_path.display()
2152
+ ),
2153
+ Err(e) => println!(
2154
+ "{ERROR}Failed to copy skill to Global: {}{RESET}\n",
2155
+ e
2156
+ ),
2157
+ }
2158
+ } else {
2159
+ println!(
2160
+ "{ERROR}Unable to resolve home directory for Global config.{RESET}\n"
2161
+ );
2162
+ }
2163
+ }
2164
+ }
2165
+ _ => {
2166
+ println!("{WARN}Usage: /skill add <path> (or /skill install <path>){RESET}\n");
2167
+ }
2168
+ }
2169
+ Some(SlashResult::Handled)
2170
+ }
2171
+
2096
2172
  "/memory" => {
2097
2173
  let memory = match MemoryStore::open_default() {
2098
2174
  Ok(m) => m,
@@ -2291,44 +2367,40 @@ async fn handle_slash_command(
2291
2367
 
2292
2368
  // Scan local workspace skills directory
2293
2369
  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
- }
2370
+ if local_skills_dir.is_dir()
2371
+ && let Ok(entries) = fs::read_dir(&local_skills_dir)
2372
+ {
2373
+ let mut local_files = Vec::new();
2374
+ for entry in entries.flatten() {
2375
+ let path = entry.path();
2376
+ if path.is_file()
2377
+ && let Some(ext) = path.extension().and_then(|e| e.to_str())
2378
+ && matches!(ext.to_ascii_lowercase().as_str(), "md" | "txt")
2379
+ && let Some(name) = path.file_name().and_then(|n| n.to_str())
2380
+ {
2381
+ if let Ok(canon_path) = path.canonicalize() {
2382
+ local_files.push((name.to_owned(), canon_path));
2383
+ } else {
2384
+ local_files.push((name.to_owned(), path));
2313
2385
  }
2314
2386
  }
2387
+ }
2315
2388
 
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();
2389
+ let inactive_files: Vec<_> = local_files
2390
+ .into_iter()
2391
+ .filter(|(_, full_path)| !active_skills.contains(full_path))
2392
+ .map(|(name, _)| name)
2393
+ .collect();
2321
2394
 
2322
- if !inactive_files.is_empty() {
2395
+ if !inactive_files.is_empty() {
2396
+ println!(
2397
+ "\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}"
2398
+ );
2399
+ for file in inactive_files {
2323
2400
  println!(
2324
- "\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}"
2401
+ " - {WARN}{:<20}{RESET} skills/{} {DIM}(Type '/learn skills/{}' to activate){RESET}",
2402
+ file, file, file
2325
2403
  );
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
2404
  }
2333
2405
  }
2334
2406
  }
@@ -2387,7 +2459,8 @@ async fn run_interactive_chat() -> Result<()> {
2387
2459
  now.format("%M")
2388
2460
  );
2389
2461
  let version = env!("CARGO_PKG_VERSION");
2390
- let line1_text = format!("[Mint] v{} | Active AI: {}", version, provider);
2462
+ let clean_provider_name = format_provider_display_name(provider, &config);
2463
+ let line1_text = format!("[Mint] v{} | Active AI: {}", version, clean_provider_name);
2391
2464
  let line2_text = format!("{} • {}", date_time, model);
2392
2465
 
2393
2466
  let len1 = line1_text.chars().count();
@@ -2409,7 +2482,7 @@ async fn run_interactive_chat() -> Result<()> {
2409
2482
  println!(
2410
2483
  "{MINT}| \\/ (_)_ __ | |_ / __| | |_ _|{RESET} {DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
2411
2484
  version,
2412
- provider,
2485
+ clean_provider_name,
2413
2486
  " ".repeat(content_width - len1)
2414
2487
  );
2415
2488
  println!(
@@ -2426,7 +2499,7 @@ async fn run_interactive_chat() -> Result<()> {
2426
2499
  println!(
2427
2500
  "{DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
2428
2501
  version,
2429
- provider,
2502
+ clean_provider_name,
2430
2503
  " ".repeat(content_width - len1)
2431
2504
  );
2432
2505
  println!(
@@ -2466,11 +2539,11 @@ async fn run_interactive_chat() -> Result<()> {
2466
2539
  loop {
2467
2540
  if let Some(handle) = update_handle.take() {
2468
2541
  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
- }
2542
+ if let Ok(Some((current, latest))) = handle.await
2543
+ && !printed_update
2544
+ {
2545
+ updater::print_update_notice(&current, &latest);
2546
+ printed_update = true;
2474
2547
  }
2475
2548
  } else {
2476
2549
  update_handle = Some(handle);
@@ -2480,9 +2553,12 @@ async fn run_interactive_chat() -> Result<()> {
2480
2553
  let path_str = format_path_with_tilde(&session.current_dir);
2481
2554
  let model_str = active_model(&session.config.ai_provider, &session.config).to_owned();
2482
2555
 
2483
- if let Some(input) =
2484
- read_line_interactive(&session.config.ai_provider, &model_str, &path_str)?
2485
- {
2556
+ if let Some(input) = read_line_interactive(
2557
+ &session.config.ai_provider,
2558
+ &model_str,
2559
+ &path_str,
2560
+ &session.current_dir,
2561
+ )? {
2486
2562
  if let Some(uri) = input.pasted_image {
2487
2563
  if let Some(ref mut current) = session.pending_image {
2488
2564
  current.push(' ');
@@ -2496,6 +2572,72 @@ async fn run_interactive_chat() -> Result<()> {
2496
2572
  continue;
2497
2573
  }
2498
2574
 
2575
+ if query_str.starts_with('$') {
2576
+ let (skill_word, task_part) = query_str
2577
+ .split_once(char::is_whitespace)
2578
+ .map(|(s, t)| (s, t.trim()))
2579
+ .unwrap_or((&query_str, ""));
2580
+
2581
+ let skill_name = skill_word.trim_start_matches('$').to_lowercase();
2582
+ let skills = load_all_available_skills(&session.current_dir);
2583
+ let skill_opt = skills.iter().find(|s| s.name.to_lowercase() == skill_name);
2584
+
2585
+ if let Some(skill) = skill_opt {
2586
+ println!("\n{BLUE}Skill: {}{RESET}", skill.name);
2587
+ if let Some(ref desc) = skill.description {
2588
+ println!("{DIM}{}{RESET}", desc);
2589
+ }
2590
+ println!("{DIM}────────────────────────────────────────────{RESET}");
2591
+ println!("{}", skill.content);
2592
+ println!("{DIM}────────────────────────────────────────────{RESET}\n");
2593
+
2594
+ if confirm("ต้องการ activate skill นี้ไหม? [y/N] ")? {
2595
+ let final_task = if task_part.is_empty() {
2596
+ print!("พิมพ์ Task ที่ต้องการให้ทำงานด้วย Skill นี้: ");
2597
+ let _ = io::stdout().flush();
2598
+ let mut input = String::new();
2599
+ io::stdin().read_line(&mut input)?;
2600
+ let input = input.trim().to_owned();
2601
+ if input.is_empty() {
2602
+ println!("{WARN}Cancelled: Task cannot be empty.{RESET}\n");
2603
+ continue;
2604
+ }
2605
+ input
2606
+ } else {
2607
+ task_part.to_owned()
2608
+ };
2609
+
2610
+ let task_with_skill = format!(
2611
+ "=== ACTIVATED SKILL: {} ===\n\
2612
+ {}\n\
2613
+ ===========================\n\n\
2614
+ Task: {}",
2615
+ skill.name, skill.content, final_task
2616
+ );
2617
+
2618
+ println!();
2619
+ if let Err(error) = run_code_agent_with_saved_image(
2620
+ &task_with_skill,
2621
+ &session.current_dir,
2622
+ &session.config,
2623
+ session.pending_image.take(),
2624
+ agent::AgentOptions {
2625
+ fast_mode: session.fast_mode,
2626
+ },
2627
+ )
2628
+ .await
2629
+ {
2630
+ println!("{ERROR}Error:{RESET} {error}\n");
2631
+ }
2632
+ } else {
2633
+ println!("{DIM}Cancelled.{RESET}\n");
2634
+ }
2635
+ } else {
2636
+ println!("{ERROR}Skill '{}' not found.{RESET}\n", skill_name);
2637
+ }
2638
+ continue;
2639
+ }
2640
+
2499
2641
  // Run slash-command router
2500
2642
  match handle_slash_command(&mut session, &query_str).await {
2501
2643
  Some(SlashResult::Handled) => continue,
@@ -2572,8 +2714,7 @@ async fn run_interactive_chat() -> Result<()> {
2572
2714
  print!(" {MINT}Mint:{RESET} {DIM}Thinking...{RESET}");
2573
2715
  let _ = io::stdout().flush();
2574
2716
 
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 \"คุณ\", \"ค่ะ\", \"นะคะ\"). \
2717
+ let mut system_instruction = "You are Mint, a cute and helpful AI assistant. You speak in a polite, friendly, and sweet Thai tone (using \"คุณ\", \"ค่ะ\", \"นะคะ\"). \
2577
2718
  You are running inside the Mint CLI interactive chat. \
2578
2719
  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
2720
  `[ACTION: <command> <arguments>]` \
@@ -2584,15 +2725,14 @@ async fn run_interactive_chat() -> Result<()> {
2584
2725
  - `[ACTION: read-folder <path>]` to list files/folders in a directory. \
2585
2726
  - `[ACTION: run-shell <command>]` to run a non-destructive local shell command after approval. \
2586
2727
  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
- }
2728
+ Write the action block on a single line at the very end of your response.".to_string();
2729
+ if let Ok(memory) = MemoryStore::open_default()
2730
+ && let Ok(Some(name)) = memory.get_profile("name")
2731
+ {
2732
+ system_instruction.push_str(&format!(
2733
+ "\nThe user's name is {}. Refer to them by their name when appropriate.",
2734
+ name
2735
+ ));
2596
2736
  }
2597
2737
 
2598
2738
  let image_uri = session.pending_image.take();
@@ -2691,9 +2831,10 @@ async fn run_interactive_chat() -> Result<()> {
2691
2831
 
2692
2832
  fn print_exit_message(session: &InteractiveSession) {
2693
2833
  println!("\n{MINT}──────────────── Mint session closed ────────────────{RESET}");
2834
+ let clean_provider = format_provider_display_name(&session.config.ai_provider, &session.config);
2694
2835
  println!(
2695
2836
  "{DIM}Provider:{RESET} {} {DIM}• Model:{RESET} {}",
2696
- session.config.ai_provider,
2837
+ clean_provider,
2697
2838
  active_model(&session.config.ai_provider, &session.config)
2698
2839
  );
2699
2840
  println!(
@@ -2704,6 +2845,36 @@ fn print_exit_message(session: &InteractiveSession) {
2704
2845
  println!("{MINT}See you next time.{RESET}\n");
2705
2846
  }
2706
2847
 
2848
+ fn format_provider_display_name(provider: &str, config: &mint_core::MintConfig) -> String {
2849
+ if provider.starts_with("custom:") {
2850
+ let id = provider.strip_prefix("custom:").unwrap_or(provider);
2851
+ let cp = config.custom_providers.iter().find(|p| p.id == id);
2852
+ match cp {
2853
+ Some(p) if !p.display_name.is_empty() => format!("{} (Custom)", p.display_name),
2854
+ Some(p) => format!("{} (Custom)", p.id),
2855
+ None => format!("{} (Custom)", id),
2856
+ }
2857
+ } else {
2858
+ match provider {
2859
+ "gemini" => "Gemini".to_string(),
2860
+ "openai" => "OpenAI".to_string(),
2861
+ "openrouter" => "OpenRouter".to_string(),
2862
+ "deepseek" => "DeepSeek".to_string(),
2863
+ "anthropic" => "Claude".to_string(),
2864
+ "huggingface" => "HF".to_string(),
2865
+ "local_openai" => "Local".to_string(),
2866
+ "ollama" => "Ollama".to_string(),
2867
+ _ => {
2868
+ let mut chars = provider.chars();
2869
+ match chars.next() {
2870
+ None => String::new(),
2871
+ Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
2872
+ }
2873
+ }
2874
+ }
2875
+ }
2876
+ }
2877
+
2707
2878
  const AUTOCOMPLETE_COMMANDS: &[(&str, &str)] = &[
2708
2879
  ("/cd", "Change active workspace directory"),
2709
2880
  ("/clear", "Clear conversation history"),
@@ -2720,12 +2891,53 @@ const AUTOCOMPLETE_COMMANDS: &[(&str, &str)] = &[
2720
2891
  ("/mcp", "List configured MCP servers"),
2721
2892
  ("/memory", "Manage long-term memory store"),
2722
2893
  ("/models", "List AI providers or switch active provider"),
2723
- ("/multi-agent", "Show or toggle Multi-Agent Collaboration system"),
2894
+ (
2895
+ "/multi-agent",
2896
+ "Show or toggle Multi-Agent Collaboration system",
2897
+ ),
2724
2898
  ("/paste", "Attach image from clipboard"),
2725
2899
  ("/plugins", "List or generate plugins/skills"),
2900
+ ("/skill add", "Add or install global skill file or folder"),
2726
2901
  ("/stats", "Show session statistics"),
2727
2902
  ];
2728
2903
 
2904
+ fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
2905
+ std::fs::create_dir_all(&dst)?;
2906
+ for entry in std::fs::read_dir(src)? {
2907
+ let entry = entry?;
2908
+ let ty = entry.file_type()?;
2909
+ if ty.is_dir() {
2910
+ copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
2911
+ } else {
2912
+ std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
2913
+ }
2914
+ }
2915
+ Ok(())
2916
+ }
2917
+
2918
+ fn load_all_available_skills(current_dir: &Path) -> Vec<mint_core::LearnedSkill> {
2919
+ let mut skills = Vec::new();
2920
+ if let Ok(memory) = MemoryStore::open_default() {
2921
+ if let Ok(s) = memory.learned_skills(100) {
2922
+ skills = s;
2923
+ }
2924
+ }
2925
+ if let Some(home) = dirs::home_dir() {
2926
+ let global_skills_path = home.join(".config").join("mint").join("mint-skills");
2927
+ mint_core::skills::load_skills_from_dir(&global_skills_path, &mut skills);
2928
+ }
2929
+ let workspace_skills_path1 = current_dir.join(".agents").join("skills");
2930
+ mint_core::skills::load_skills_from_dir(&workspace_skills_path1, &mut skills);
2931
+ let workspace_skills_path2 = current_dir.join("skills");
2932
+ mint_core::skills::load_skills_from_dir(&workspace_skills_path2, &mut skills);
2933
+
2934
+ let mut unique_skills = std::collections::BTreeMap::new();
2935
+ for skill in skills {
2936
+ unique_skills.insert(skill.name.clone(), skill);
2937
+ }
2938
+ unique_skills.into_values().collect()
2939
+ }
2940
+
2729
2941
  fn draw_input_box(
2730
2942
  input_chars: &[char],
2731
2943
  cursor_pos: usize,
@@ -2734,6 +2946,7 @@ fn draw_input_box(
2734
2946
  path_str: &str,
2735
2947
  tab_base_input: Option<&str>,
2736
2948
  tab_index: Option<usize>,
2949
+ current_dir: &Path,
2737
2950
  ) -> (usize, usize) {
2738
2951
  let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
2739
2952
  let width = term_width as usize;
@@ -2742,12 +2955,10 @@ fn draw_input_box(
2742
2955
  let content_max_len = input_width.saturating_sub(prefix.chars().count());
2743
2956
 
2744
2957
  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
- }
2958
+ if input_chars.len() > content_max_len && cursor_pos >= content_max_len {
2959
+ start_idx = (cursor_pos + 5).saturating_sub(content_max_len);
2960
+ if start_idx + content_max_len > input_chars.len() {
2961
+ start_idx = input_chars.len() - content_max_len;
2751
2962
  }
2752
2963
  }
2753
2964
 
@@ -2805,17 +3016,21 @@ fn draw_input_box(
2805
3016
  .collect();
2806
3017
 
2807
3018
  if !matches.is_empty() {
2808
- let total_pages = (matches.len() + 4) / 5;
3019
+ let total_pages = matches.len().div_ceil(5);
2809
3020
  let highlight_idx = tab_index.map(|idx| idx % matches.len());
2810
3021
  let selected_idx = highlight_idx.unwrap_or(0);
2811
3022
  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;
3023
+ let s_start_idx = current_page * 5;
3024
+ let s_end_idx = std::cmp::min(s_start_idx + 5, matches.len());
3025
+ match_count = s_end_idx - s_start_idx;
2815
3026
 
2816
3027
  println!();
2817
- println!(" {BLUE}Suggestions ({}/{}){RESET}", current_page + 1, total_pages);
2818
- for i in start_idx..end_idx {
3028
+ println!(
3029
+ " {BLUE}Suggestions ({}/{}){RESET}",
3030
+ current_page + 1,
3031
+ total_pages
3032
+ );
3033
+ for i in s_start_idx..s_end_idx {
2819
3034
  let (cmd, desc) = matches[i];
2820
3035
  if Some(i) == highlight_idx {
2821
3036
  println!(" {BLUE}▶ {:<16}{RESET} {DIM}- {}{RESET}", cmd, desc);
@@ -2824,6 +3039,62 @@ fn draw_input_box(
2824
3039
  }
2825
3040
  }
2826
3041
  }
3042
+ } else if search_query.starts_with('$') {
3043
+ // Parse skill name (excluding arguments)
3044
+ let skill_query = search_query
3045
+ .split_whitespace()
3046
+ .next()
3047
+ .unwrap_or(search_query);
3048
+ let prefix = &skill_query[1..].to_lowercase();
3049
+ let skills = load_all_available_skills(current_dir);
3050
+ let matches: Vec<_> = skills
3051
+ .iter()
3052
+ .filter(|skill| skill.name.to_lowercase().starts_with(prefix))
3053
+ .collect();
3054
+
3055
+ if !matches.is_empty() {
3056
+ let total_pages = matches.len().div_ceil(5);
3057
+ let highlight_idx = tab_index.map(|idx| idx % matches.len());
3058
+ let selected_idx = highlight_idx.unwrap_or(0);
3059
+ let current_page = selected_idx / 5;
3060
+ let s_start_idx = current_page * 5;
3061
+ let s_end_idx = std::cmp::min(s_start_idx + 5, matches.len());
3062
+ match_count = s_end_idx - s_start_idx;
3063
+
3064
+ println!();
3065
+ println!(
3066
+ " {BLUE}Suggestions ({}/{}){RESET}",
3067
+ current_page + 1,
3068
+ total_pages
3069
+ );
3070
+ for i in s_start_idx..s_end_idx {
3071
+ let skill = matches[i];
3072
+ let desc = skill
3073
+ .description
3074
+ .as_deref()
3075
+ .unwrap_or("No description provided");
3076
+ let max_desc_len = width.saturating_sub(35);
3077
+ let truncated_desc = if desc.chars().count() > max_desc_len {
3078
+ let mut s: String = desc.chars().take(max_desc_len.saturating_sub(3)).collect();
3079
+ s.push_str("...");
3080
+ s
3081
+ } else {
3082
+ desc.to_string()
3083
+ };
3084
+
3085
+ if Some(i) == highlight_idx {
3086
+ println!(
3087
+ " {BLUE}▶ ${:<20}{RESET} {MINT}[Skill]{RESET} {DIM}{}{RESET}",
3088
+ skill.name, truncated_desc
3089
+ );
3090
+ } else {
3091
+ println!(
3092
+ " {DIM}${:<20} [Skill] {}{RESET}",
3093
+ skill.name, truncated_desc
3094
+ );
3095
+ }
3096
+ }
3097
+ }
2827
3098
  }
2828
3099
 
2829
3100
  let _ = io::stdout().flush();
@@ -2838,12 +3109,10 @@ fn input_cursor_column(input_chars: &[char], cursor_pos: usize) -> usize {
2838
3109
  let content_max_len = input_width.saturating_sub(prefix.chars().count());
2839
3110
 
2840
3111
  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
- }
3112
+ if input_chars.len() > content_max_len && cursor_pos >= content_max_len {
3113
+ start_idx = (cursor_pos + 5).saturating_sub(content_max_len);
3114
+ if start_idx + content_max_len > input_chars.len() {
3115
+ start_idx = input_chars.len() - content_max_len;
2847
3116
  }
2848
3117
  }
2849
3118
 
@@ -2878,6 +3147,7 @@ fn redraw_input_box(
2878
3147
  path_str: &str,
2879
3148
  tab_base_input: Option<&str>,
2880
3149
  tab_index: Option<usize>,
3150
+ current_dir: &Path,
2881
3151
  ) {
2882
3152
  clear_input_box();
2883
3153
  let (match_count, _) = draw_input_box(
@@ -2888,6 +3158,7 @@ fn redraw_input_box(
2888
3158
  path_str,
2889
3159
  tab_base_input,
2890
3160
  tab_index,
3161
+ current_dir,
2891
3162
  );
2892
3163
  position_input_cursor(input_chars, cursor_pos, match_count);
2893
3164
  let _ = io::stdout().flush();
@@ -2897,6 +3168,7 @@ fn read_line_interactive(
2897
3168
  _provider: &str,
2898
3169
  model: &str,
2899
3170
  path_str: &str,
3171
+ current_dir: &Path,
2900
3172
  ) -> io::Result<Option<InteractiveInput>> {
2901
3173
  use crossterm::event::{self, Event, KeyCode};
2902
3174
  use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
@@ -2913,7 +3185,16 @@ fn read_line_interactive(
2913
3185
  let mut tab_base_input: Option<String> = None;
2914
3186
  let mut tab_index: Option<usize> = None;
2915
3187
 
2916
- let (match_count, _) = draw_input_box(&input_chars, cursor_pos, placeholder, model, path_str, None, None);
3188
+ let (match_count, _) = draw_input_box(
3189
+ &input_chars,
3190
+ cursor_pos,
3191
+ placeholder,
3192
+ model,
3193
+ path_str,
3194
+ None,
3195
+ None,
3196
+ current_dir,
3197
+ );
2917
3198
  position_input_cursor(&input_chars, cursor_pos, match_count);
2918
3199
  let _ = io::stdout().flush();
2919
3200
 
@@ -2953,70 +3234,177 @@ fn read_line_interactive(
2953
3234
  path_str,
2954
3235
  None,
2955
3236
  None,
3237
+ current_dir,
2956
3238
  );
2957
3239
  enable_raw_mode()?;
2958
3240
  last_paste_time = Some(std::time::Instant::now());
2959
3241
  continue;
2960
3242
  }
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
3243
+ if let Event::Key(key_event) = ev
3244
+ && key_event.kind == event::KeyEventKind::Press
3245
+ {
3246
+ let is_ctrl_d = matches!(key_event.code, KeyCode::Char('d'))
3247
+ && key_event
3248
+ .modifiers
3249
+ .contains(crossterm::event::KeyModifiers::CONTROL);
3250
+ if ctrl_d_pressed && !is_ctrl_d {
3251
+ ctrl_d_pressed = false;
3252
+ disable_raw_mode()?;
3253
+ print!("\r\x1b[3B\r\x1b[2K\x1b[3A");
3254
+ print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
3255
+ let _ = io::stdout().flush();
3256
+ enable_raw_mode()?;
3257
+ }
3258
+
3259
+ // Reset tab autocomplete state if any key other than Tab, Up, or Down is pressed
3260
+ if key_event.code != KeyCode::Tab
3261
+ && key_event.code != KeyCode::Up
3262
+ && key_event.code != KeyCode::Down
3263
+ {
3264
+ tab_base_input = None;
3265
+ tab_index = None;
3266
+ }
3267
+
3268
+ match key_event.code {
3269
+ KeyCode::Char('d')
3270
+ if key_event
2965
3271
  .modifiers
2966
- .contains(crossterm::event::KeyModifiers::CONTROL);
2967
- if ctrl_d_pressed && !is_ctrl_d {
2968
- ctrl_d_pressed = false;
3272
+ .contains(crossterm::event::KeyModifiers::CONTROL) =>
3273
+ {
3274
+ if ctrl_d_pressed {
3275
+ disable_raw_mode()?;
3276
+ clear_input_box();
3277
+ let _ = io::stdout().flush();
3278
+ break None;
3279
+ } else {
3280
+ ctrl_d_pressed = true;
3281
+ disable_raw_mode()?;
3282
+ print!(
3283
+ "\r\x1b[3B\r\x1b[2K{WARN}Press Ctrl+D again to exit{RESET}\x1b[3A"
3284
+ );
3285
+ print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
3286
+ let _ = io::stdout().flush();
3287
+ enable_raw_mode()?;
3288
+ }
3289
+ }
3290
+ KeyCode::Char('v')
3291
+ if key_event
3292
+ .modifiers
3293
+ .contains(crossterm::event::KeyModifiers::CONTROL) =>
3294
+ {
3295
+ if let Ok(Some(uri)) = image::read_clipboard_image() {
3296
+ if let Some(ref mut current) = pasted_image {
3297
+ current.push(' ');
3298
+ current.push_str(&uri);
3299
+ } else {
3300
+ pasted_image = Some(uri);
3301
+ }
3302
+ insert_image_placeholder(&mut input_chars, &mut cursor_pos);
3303
+
3304
+ disable_raw_mode()?;
3305
+ redraw_input_box(
3306
+ &input_chars,
3307
+ cursor_pos,
3308
+ placeholder,
3309
+ model,
3310
+ path_str,
3311
+ None,
3312
+ None,
3313
+ current_dir,
3314
+ );
3315
+ enable_raw_mode()?;
3316
+ }
3317
+ }
3318
+ KeyCode::Char(c) if input_chars.len() < 10000 => {
3319
+ input_chars.insert(cursor_pos, c);
3320
+ cursor_pos += 1;
3321
+
2969
3322
  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();
3323
+ redraw_input_box(
3324
+ &input_chars,
3325
+ cursor_pos,
3326
+ placeholder,
3327
+ model,
3328
+ path_str,
3329
+ None,
3330
+ None,
3331
+ current_dir,
3332
+ );
2973
3333
  enable_raw_mode()?;
2974
3334
  }
3335
+ KeyCode::Backspace if cursor_pos > 0 => {
3336
+ cursor_pos -= 1;
3337
+ input_chars.remove(cursor_pos);
2975
3338
 
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;
3339
+ disable_raw_mode()?;
3340
+ redraw_input_box(
3341
+ &input_chars,
3342
+ cursor_pos,
3343
+ placeholder,
3344
+ model,
3345
+ path_str,
3346
+ None,
3347
+ None,
3348
+ current_dir,
3349
+ );
3350
+ enable_raw_mode()?;
2983
3351
  }
3352
+ KeyCode::Tab => {
3353
+ let base = match &tab_base_input {
3354
+ Some(b) => b.clone(),
3355
+ None => {
3356
+ let current_str: String = input_chars.iter().collect();
3357
+ tab_base_input = Some(current_str.clone());
3358
+ current_str
3359
+ }
3360
+ };
3361
+
3362
+ if base.starts_with('/') {
3363
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
3364
+ .iter()
3365
+ .filter(|(cmd, _)| cmd.starts_with(&base))
3366
+ .collect();
3367
+
3368
+ if !matches.is_empty() {
3369
+ let idx = tab_index.unwrap_or(0) % matches.len();
3370
+ let completed = format!("{} ", matches[idx].0);
3371
+ input_chars = completed.chars().collect();
3372
+ cursor_pos = input_chars.len();
3373
+
3374
+ // Highlight currently completed item in suggestions
3375
+ let current_highlight = Some(idx);
3376
+ tab_index = Some(idx + 1);
2984
3377
 
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
3378
  disable_raw_mode()?;
2999
- print!(
3000
- "\r\x1b[3B\r\x1b[2K{WARN}Press Ctrl+D again to exit{RESET}\x1b[3A"
3379
+ redraw_input_box(
3380
+ &input_chars,
3381
+ cursor_pos,
3382
+ placeholder,
3383
+ model,
3384
+ path_str,
3385
+ Some(&base),
3386
+ current_highlight,
3387
+ current_dir,
3001
3388
  );
3002
- print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
3003
- let _ = io::stdout().flush();
3004
3389
  enable_raw_mode()?;
3005
3390
  }
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);
3391
+ } else if base.starts_with('$') {
3392
+ let skill_query = base.split_whitespace().next().unwrap_or(&base);
3393
+ let prefix = &skill_query[1..].to_lowercase();
3394
+ let skills = load_all_available_skills(current_dir);
3395
+ let matches: Vec<_> = skills
3396
+ .iter()
3397
+ .filter(|s| s.name.to_lowercase().starts_with(prefix))
3398
+ .collect();
3399
+
3400
+ if !matches.is_empty() {
3401
+ let idx = tab_index.unwrap_or(0) % matches.len();
3402
+ let completed = format!("${} ", matches[idx].name);
3403
+ input_chars = completed.chars().collect();
3404
+ cursor_pos = input_chars.len();
3405
+
3406
+ let current_highlight = Some(idx);
3407
+ tab_index = Some(idx + 1);
3020
3408
 
3021
3409
  disable_raw_mode()?;
3022
3410
  redraw_input_box(
@@ -3025,16 +3413,39 @@ fn read_line_interactive(
3025
3413
  placeholder,
3026
3414
  model,
3027
3415
  path_str,
3028
- None,
3029
- None,
3416
+ Some(&base),
3417
+ current_highlight,
3418
+ current_dir,
3030
3419
  );
3031
3420
  enable_raw_mode()?;
3032
3421
  }
3033
3422
  }
3034
- KeyCode::Char(c) => {
3035
- if input_chars.len() < 10000 {
3036
- input_chars.insert(cursor_pos, c);
3037
- cursor_pos += 1;
3423
+ }
3424
+ KeyCode::Down => {
3425
+ let base = match &tab_base_input {
3426
+ Some(b) => b.clone(),
3427
+ None => {
3428
+ let current_str: String = input_chars.iter().collect();
3429
+ tab_base_input = Some(current_str.clone());
3430
+ current_str
3431
+ }
3432
+ };
3433
+
3434
+ if base.starts_with('/') {
3435
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
3436
+ .iter()
3437
+ .filter(|(cmd, _)| cmd.starts_with(&base))
3438
+ .collect();
3439
+
3440
+ if !matches.is_empty() {
3441
+ let new_idx = match tab_index {
3442
+ Some(idx) => (idx + 1) % matches.len(),
3443
+ None => 0,
3444
+ };
3445
+ tab_index = Some(new_idx);
3446
+ let completed = format!("{} ", matches[new_idx].0);
3447
+ input_chars = completed.chars().collect();
3448
+ cursor_pos = input_chars.len();
3038
3449
 
3039
3450
  disable_raw_mode()?;
3040
3451
  redraw_input_box(
@@ -3043,16 +3454,30 @@ fn read_line_interactive(
3043
3454
  placeholder,
3044
3455
  model,
3045
3456
  path_str,
3046
- None,
3047
- None,
3457
+ Some(&base),
3458
+ Some(new_idx),
3459
+ current_dir,
3048
3460
  );
3049
3461
  enable_raw_mode()?;
3050
3462
  }
3051
- }
3052
- KeyCode::Backspace => {
3053
- if cursor_pos > 0 {
3054
- cursor_pos -= 1;
3055
- input_chars.remove(cursor_pos);
3463
+ } else if base.starts_with('$') {
3464
+ let skill_query = base.split_whitespace().next().unwrap_or(&base);
3465
+ let prefix = &skill_query[1..].to_lowercase();
3466
+ let skills = load_all_available_skills(current_dir);
3467
+ let matches: Vec<_> = skills
3468
+ .iter()
3469
+ .filter(|s| s.name.to_lowercase().starts_with(prefix))
3470
+ .collect();
3471
+
3472
+ if !matches.is_empty() {
3473
+ let new_idx = match tab_index {
3474
+ Some(idx) => (idx + 1) % matches.len(),
3475
+ None => 0,
3476
+ };
3477
+ tab_index = Some(new_idx);
3478
+ let completed = format!("${} ", matches[new_idx].name);
3479
+ input_chars = completed.chars().collect();
3480
+ cursor_pos = input_chars.len();
3056
3481
 
3057
3482
  disable_raw_mode()?;
3058
3483
  redraw_input_box(
@@ -3061,209 +3486,171 @@ fn read_line_interactive(
3061
3486
  placeholder,
3062
3487
  model,
3063
3488
  path_str,
3064
- None,
3065
- None,
3489
+ Some(&base),
3490
+ Some(new_idx),
3491
+ current_dir,
3066
3492
  );
3067
3493
  enable_raw_mode()?;
3068
3494
  }
3069
3495
  }
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
- }
3496
+ }
3497
+ KeyCode::Up => {
3498
+ let base = match &tab_base_input {
3499
+ Some(b) => b.clone(),
3500
+ None => {
3501
+ let current_str: String = input_chars.iter().collect();
3502
+ tab_base_input = Some(current_str.clone());
3503
+ current_str
3108
3504
  }
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
- };
3505
+ };
3119
3506
 
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
- };
3507
+ if base.starts_with('/') {
3508
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
3509
+ .iter()
3510
+ .filter(|(cmd, _)| cmd.starts_with(&base))
3511
+ .collect();
3512
+
3513
+ if !matches.is_empty() {
3514
+ let new_idx = match tab_index {
3515
+ Some(idx) => {
3516
+ if idx == 0 {
3517
+ matches.len() - 1
3518
+ } else {
3519
+ idx - 1
3520
+ }
3521
+ }
3522
+ None => matches.len() - 1,
3523
+ };
3524
+ tab_index = Some(new_idx);
3525
+ let completed = format!("{} ", matches[new_idx].0);
3526
+ input_chars = completed.chars().collect();
3527
+ cursor_pos = input_chars.len();
3159
3528
 
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
- }
3529
+ disable_raw_mode()?;
3530
+ redraw_input_box(
3531
+ &input_chars,
3532
+ cursor_pos,
3533
+ placeholder,
3534
+ model,
3535
+ path_str,
3536
+ Some(&base),
3537
+ Some(new_idx),
3538
+ current_dir,
3539
+ );
3540
+ enable_raw_mode()?;
3541
+ }
3542
+ } else if base.starts_with('$') {
3543
+ let skill_query = base.split_whitespace().next().unwrap_or(&base);
3544
+ let prefix = &skill_query[1..].to_lowercase();
3545
+ let skills = load_all_available_skills(current_dir);
3546
+ let matches: Vec<_> = skills
3547
+ .iter()
3548
+ .filter(|s| s.name.to_lowercase().starts_with(prefix))
3549
+ .collect();
3550
+
3551
+ if !matches.is_empty() {
3552
+ let new_idx = match tab_index {
3553
+ Some(idx) => {
3554
+ if idx == 0 {
3555
+ matches.len() - 1
3556
+ } else {
3557
+ idx - 1
3174
3558
  }
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
- }
3559
+ }
3560
+ None => matches.len() - 1,
3561
+ };
3562
+ tab_index = Some(new_idx);
3563
+ let completed = format!("${} ", matches[new_idx].name);
3564
+ input_chars = completed.chars().collect();
3565
+ cursor_pos = input_chars.len();
3566
+
3567
+ disable_raw_mode()?;
3568
+ redraw_input_box(
3569
+ &input_chars,
3570
+ cursor_pos,
3571
+ placeholder,
3572
+ model,
3573
+ path_str,
3574
+ Some(&base),
3575
+ Some(new_idx),
3576
+ current_dir,
3577
+ );
3578
+ enable_raw_mode()?;
3194
3579
  }
3195
3580
  }
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
- }
3581
+ }
3582
+ KeyCode::Left => {
3583
+ while cursor_pos > 0 {
3584
+ cursor_pos -= 1;
3585
+ if cursor_pos == 0 || !is_thai_zero_width(input_chars[cursor_pos]) {
3586
+ break;
3202
3587
  }
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
3588
  }
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
- }
3589
+ disable_raw_mode()?;
3590
+ redraw_input_box(
3591
+ &input_chars,
3592
+ cursor_pos,
3593
+ placeholder,
3594
+ model,
3595
+ path_str,
3596
+ None,
3597
+ None,
3598
+ current_dir,
3599
+ );
3600
+ enable_raw_mode()?;
3601
+ }
3602
+ KeyCode::Right => {
3603
+ while cursor_pos < input_chars.len() {
3604
+ cursor_pos += 1;
3605
+ if cursor_pos == input_chars.len()
3606
+ || !is_thai_zero_width(input_chars[cursor_pos])
3607
+ {
3608
+ break;
3223
3609
  }
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
3610
  }
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();
3611
+ disable_raw_mode()?;
3612
+ redraw_input_box(
3613
+ &input_chars,
3614
+ cursor_pos,
3615
+ placeholder,
3616
+ model,
3617
+ path_str,
3618
+ None,
3619
+ None,
3620
+ current_dir,
3621
+ );
3622
+ enable_raw_mode()?;
3623
+ }
3624
+ KeyCode::Enter => {
3625
+ if let Some(time) = last_paste_time
3626
+ && time.elapsed() < std::time::Duration::from_millis(100)
3627
+ {
3628
+ continue;
3629
+ }
3630
+ disable_raw_mode()?;
3631
+ clear_input_box();
3632
+ let input_str: String = input_chars.iter().collect();
3245
3633
 
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
- }
3634
+ let mut expanded_str = input_str.clone();
3635
+ for (placeholder_str, content) in &paste_contents {
3636
+ expanded_str = expanded_str.replace(placeholder_str, content);
3637
+ }
3250
3638
 
3251
- println!(" {BLUE}You ›{RESET} {}", expanded_str);
3252
- let _ = io::stdout().flush();
3639
+ println!(" {BLUE}You ›{RESET} {}", expanded_str);
3640
+ let _ = io::stdout().flush();
3253
3641
 
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
- _ => {}
3642
+ break Some(InteractiveInput {
3643
+ text: expanded_str,
3644
+ pasted_image,
3645
+ });
3646
+ }
3647
+ KeyCode::Esc => {
3648
+ disable_raw_mode()?;
3649
+ clear_input_box();
3650
+ let _ = io::stdout().flush();
3651
+ break None;
3266
3652
  }
3653
+ _ => {}
3267
3654
  }
3268
3655
  }
3269
3656
  }
@@ -3303,18 +3690,18 @@ fn format_placeholders(s: &str) -> String {
3303
3690
  while i < chars.len() {
3304
3691
  if chars[i] == '[' && i + 12 < chars.len() {
3305
3692
  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
- }
3693
+ if slice == "[Pasted text #"
3694
+ && let Some(end_offset) = chars[i..].iter().position(|&c| c == ']')
3695
+ {
3696
+ let end_idx = i + end_offset;
3697
+ let inside: String = chars[i + 1..end_idx].iter().collect();
3698
+ result.push('[');
3699
+ result.push_str(BLUE);
3700
+ result.push_str(&inside);
3701
+ result.push_str("\x1b[39m");
3702
+ result.push(']');
3703
+ i = end_idx + 1;
3704
+ continue;
3318
3705
  }
3319
3706
  }
3320
3707
  result.push(chars[i]);
@@ -3345,6 +3732,22 @@ fn active_model<'a>(provider: &str, config: &'a mint_core::MintConfig) -> &'a st
3345
3732
  "huggingface" => &config.hf_model,
3346
3733
  "local_openai" => &config.local_model_name,
3347
3734
  "ollama" => &config.ollama_model,
3735
+ p if p.starts_with("custom:") => {
3736
+ if let Some(id) = p.strip_prefix("custom:")
3737
+ && let Some(selections) = config
3738
+ .extra
3739
+ .get("customModelSelections")
3740
+ .and_then(|v| v.as_object())
3741
+ && let Some(m) = selections.get(id).and_then(|v| v.as_str())
3742
+ {
3743
+ return m;
3744
+ }
3745
+ config
3746
+ .resolve_custom_provider(p)
3747
+ .and_then(|cp| cp.models.first())
3748
+ .map(|m| m.model_id.as_str())
3749
+ .unwrap_or("")
3750
+ }
3348
3751
  _ => &config.gemini_model,
3349
3752
  }
3350
3753
  }