@pheem49/mint 1.5.5 → 1.6.1

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 (222) hide show
  1. package/.codex +0 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/.github/workflows/ci.yml +45 -0
  4. package/.github/workflows/release.yml +79 -0
  5. package/Cargo.lock +5792 -0
  6. package/Cargo.toml +32 -0
  7. package/README.md +387 -353
  8. package/assets/icon.png +0 -0
  9. package/bin/mint +0 -0
  10. package/crates/mint-cli/Cargo.toml +23 -0
  11. package/crates/mint-cli/src/agent.rs +851 -0
  12. package/crates/mint-cli/src/gmail.rs +216 -0
  13. package/crates/mint-cli/src/image.rs +142 -0
  14. package/crates/mint-cli/src/main.rs +2837 -0
  15. package/crates/mint-cli/src/mcp.rs +63 -0
  16. package/crates/mint-cli/src/onboard.rs +1149 -0
  17. package/crates/mint-cli/src/setup.rs +390 -0
  18. package/crates/mint-cli/src/skills.rs +8 -0
  19. package/crates/mint-cli/src/updater.rs +279 -0
  20. package/crates/mint-core/Cargo.toml +22 -0
  21. package/crates/mint-core/src/agent_loop.rs +94 -0
  22. package/crates/mint-core/src/api_server.rs +991 -0
  23. package/crates/mint-core/src/channels.rs +248 -0
  24. package/crates/mint-core/src/chat.rs +895 -0
  25. package/crates/mint-core/src/code_tools.rs +729 -0
  26. package/crates/mint-core/src/config.rs +368 -0
  27. package/crates/mint-core/src/files.rs +159 -0
  28. package/crates/mint-core/src/knowledge.rs +541 -0
  29. package/crates/mint-core/src/lib.rs +84 -0
  30. package/crates/mint-core/src/mcp.rs +273 -0
  31. package/crates/mint-core/src/memory.rs +673 -0
  32. package/crates/mint-core/src/orchestration.rs +2157 -0
  33. package/crates/mint-core/src/pictures.rs +314 -0
  34. package/crates/mint-core/src/plugins.rs +727 -0
  35. package/crates/mint-core/src/safety.rs +416 -0
  36. package/crates/mint-core/src/semantic.rs +254 -0
  37. package/crates/mint-core/src/shell.rs +317 -0
  38. package/crates/mint-core/src/skills.rs +71 -0
  39. package/crates/mint-core/src/symbols.rs +157 -0
  40. package/crates/mint-core/src/tasks.rs +308 -0
  41. package/crates/mint-core/src/tts.rs +92 -0
  42. package/crates/mint-core/src/weather.rs +93 -0
  43. package/crates/mint-core/src/web_search.rs +200 -0
  44. package/crates/mint-core/src/workflows.rs +81 -0
  45. package/crates/mint-core/tests/mcp_stdio.rs +45 -0
  46. package/crates/mint-core/tests/memory_persistence.rs +172 -0
  47. package/crates/mint-core/tests/pictures_storage.rs +14 -0
  48. package/crates/mint-core/tests/task_lifecycle.rs +87 -0
  49. package/package.json +35 -99
  50. package/src/bin/index.js +16 -0
  51. package/src/renderer/index-web.html +17 -0
  52. package/src/renderer/index.html +17 -0
  53. package/src/renderer/public/Live2DCubismCore.js +9 -0
  54. package/src/renderer/public/assets/icon.png +0 -0
  55. package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
  56. package/src/renderer/src/App.tsx +33 -0
  57. package/src/renderer/src/calculator.ts +47 -0
  58. package/src/renderer/src/components/ChatPanel.tsx +1598 -0
  59. package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
  60. package/src/renderer/src/components/Live2DStage.tsx +374 -0
  61. package/src/renderer/src/components/MintDashboard.tsx +950 -0
  62. package/src/renderer/src/components/ModelPanel.tsx +154 -0
  63. package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
  64. package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
  65. package/src/renderer/src/components/ScreenPicker.tsx +579 -0
  66. package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
  67. package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
  68. package/src/renderer/src/components/WidgetWindow.tsx +36 -0
  69. package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
  70. package/src/{UI → renderer/src/css}/settings.css +69 -16
  71. package/src/renderer/src/css/spotlight.css +113 -0
  72. package/src/renderer/src/css/styles.css +3722 -0
  73. package/src/renderer/src/css/widget.css +185 -0
  74. package/src/renderer/src/env.d.ts +116 -0
  75. package/src/renderer/src/index.css +379 -0
  76. package/src/renderer/src/main.tsx +13 -0
  77. package/src/renderer/src/tauri.ts +996 -0
  78. package/src/renderer/src-web/App.tsx +25 -0
  79. package/src/renderer/src-web/calculator.ts +47 -0
  80. package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
  81. package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
  82. package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
  83. package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
  84. package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
  85. package/src/renderer/src-web/css/settings.css +1100 -0
  86. package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
  87. package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
  88. package/src/{UI → renderer/src-web/css}/widget.css +2 -2
  89. package/src/renderer/src-web/env.d.ts +107 -0
  90. package/src/renderer/src-web/index.css +379 -0
  91. package/src/renderer/src-web/main.tsx +13 -0
  92. package/src/renderer/src-web/tauri.ts +983 -0
  93. package/tsconfig.json +30 -0
  94. package/vite.config.ts +33 -0
  95. package/vite.config.web.ts +51 -0
  96. package/GUIDE_TH.md +0 -125
  97. package/assets/Agent_Mint.png +0 -0
  98. package/assets/CLI_Screen.png +0 -0
  99. package/assets/Settings.png +0 -0
  100. package/benchmark_ai.js +0 -71
  101. package/install.ps1 +0 -64
  102. package/install.sh +0 -54
  103. package/main.js +0 -139
  104. package/mint-cli-logic.js +0 -3
  105. package/mint-cli.js +0 -410
  106. package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
  107. package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
  108. package/preload-picker.js +0 -11
  109. package/preload-settings.js +0 -11
  110. package/preload.js +0 -41
  111. package/scripts/install_linux_desktop_entry.js +0 -48
  112. package/src/AI_Brain/Gemini_API.js +0 -813
  113. package/src/AI_Brain/agent_orchestrator.js +0 -73
  114. package/src/AI_Brain/autonomous_brain.js +0 -179
  115. package/src/AI_Brain/behavior_memory.js +0 -135
  116. package/src/AI_Brain/headless_agent.js +0 -143
  117. package/src/AI_Brain/knowledge_base.js +0 -349
  118. package/src/AI_Brain/memory_store.js +0 -662
  119. package/src/AI_Brain/proactive_engine.js +0 -172
  120. package/src/AI_Brain/provider_adapter.js +0 -365
  121. package/src/Automation_Layer/browser_automation.js +0 -149
  122. package/src/Automation_Layer/file_operations.js +0 -286
  123. package/src/Automation_Layer/open_app.js +0 -85
  124. package/src/Automation_Layer/open_website.js +0 -38
  125. package/src/CLI/approval_handler.js +0 -47
  126. package/src/CLI/chat_router.js +0 -247
  127. package/src/CLI/chat_ui.js +0 -1159
  128. package/src/CLI/cli_colors.js +0 -115
  129. package/src/CLI/cli_formatters.js +0 -94
  130. package/src/CLI/code_agent.js +0 -1667
  131. package/src/CLI/code_session_memory.js +0 -62
  132. package/src/CLI/gmail_auth.js +0 -210
  133. package/src/CLI/image_input.js +0 -90
  134. package/src/CLI/intent_detectors.js +0 -181
  135. package/src/CLI/interactive_chat.js +0 -658
  136. package/src/CLI/list_features.js +0 -64
  137. package/src/CLI/onboarding.js +0 -416
  138. package/src/CLI/repo_summarizer.js +0 -282
  139. package/src/CLI/semantic_code_search.js +0 -312
  140. package/src/CLI/skill_manager.js +0 -41
  141. package/src/CLI/slash_command_handler.js +0 -418
  142. package/src/CLI/symbol_indexer.js +0 -231
  143. package/src/CLI/updater.js +0 -230
  144. package/src/CLI/workspace_manager.js +0 -90
  145. package/src/Channels/brave_search_bridge.js +0 -35
  146. package/src/Channels/discord_bridge.js +0 -66
  147. package/src/Channels/google_search_bridge.js +0 -38
  148. package/src/Channels/line_bridge.js +0 -60
  149. package/src/Channels/slack_bridge.js +0 -48
  150. package/src/Channels/telegram_bridge.js +0 -41
  151. package/src/Channels/whatsapp_bridge.js +0 -57
  152. package/src/Command_Parser/parser.js +0 -45
  153. package/src/Plugins/dev_tools.js +0 -41
  154. package/src/Plugins/discord.js +0 -20
  155. package/src/Plugins/docker.js +0 -47
  156. package/src/Plugins/gmail.js +0 -251
  157. package/src/Plugins/google_calendar.js +0 -252
  158. package/src/Plugins/mcp_manager.js +0 -95
  159. package/src/Plugins/notion.js +0 -256
  160. package/src/Plugins/obsidian.js +0 -54
  161. package/src/Plugins/plugin_manager.js +0 -81
  162. package/src/Plugins/spotify.js +0 -173
  163. package/src/Plugins/system_metrics.js +0 -31
  164. package/src/Plugins/system_monitor.js +0 -72
  165. package/src/System/action_executor.js +0 -178
  166. package/src/System/bridge_manager.js +0 -76
  167. package/src/System/chat_history_manager.js +0 -83
  168. package/src/System/config_manager.js +0 -194
  169. package/src/System/custom_workflows.js +0 -163
  170. package/src/System/daemon_manager.js +0 -67
  171. package/src/System/google_tts_urls.js +0 -51
  172. package/src/System/granular_automation.js +0 -157
  173. package/src/System/ipc_handlers.js +0 -332
  174. package/src/System/notifications.js +0 -23
  175. package/src/System/optional_require.js +0 -23
  176. package/src/System/picture_store.js +0 -109
  177. package/src/System/proactive_loop.js +0 -153
  178. package/src/System/safety_manager.js +0 -273
  179. package/src/System/sandbox_runner.js +0 -182
  180. package/src/System/screen_capture.js +0 -175
  181. package/src/System/smart_context.js +0 -227
  182. package/src/System/system_automation.js +0 -162
  183. package/src/System/system_events.js +0 -79
  184. package/src/System/system_info.js +0 -125
  185. package/src/System/task_manager.js +0 -222
  186. package/src/System/tool_registry.js +0 -293
  187. package/src/System/window_manager.js +0 -220
  188. package/src/UI/floating.css +0 -80
  189. package/src/UI/floating.html +0 -17
  190. package/src/UI/floating.js +0 -67
  191. package/src/UI/live2d_manager.js +0 -600
  192. package/src/UI/preload-floating.js +0 -7
  193. package/src/UI/preload-spotlight.js +0 -11
  194. package/src/UI/preload-widget.js +0 -5
  195. package/src/UI/proactive-glow.html +0 -42
  196. package/src/UI/renderer.js +0 -2127
  197. package/src/UI/screenPicker.html +0 -214
  198. package/src/UI/screenPicker.js +0 -262
  199. package/src/UI/settings.html +0 -577
  200. package/src/UI/settings.js +0 -770
  201. package/src/UI/spotlight.html +0 -23
  202. package/src/UI/spotlight.js +0 -185
  203. package/src/UI/widget.html +0 -29
  204. package/src/UI/widget.js +0 -10
  205. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
  206. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
  207. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
  208. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
  209. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
  210. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
  211. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
  212. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
  213. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
  214. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
  215. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
  216. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
  217. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
  218. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
  219. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
  220. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
  221. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
  222. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
@@ -0,0 +1,2837 @@
1
+ use anyhow::Result;
2
+ use clap::{Parser, Subcommand};
3
+ use std::{
4
+ fs,
5
+ io::{self, Write},
6
+ path::{Path, PathBuf},
7
+ };
8
+
9
+ use mint_core::{
10
+ CHAT_CLI_ID, Capability, ChatRequest, CodeEdit, CodePatchHunk, KnowledgeStore, MemoryStore,
11
+ MintConfig, TaskStore, apply_code_edits, assert_path_capability, build_code_patch,
12
+ build_symbol_index, classify_shell_command, config_path, create_folder, execute_native_plugin,
13
+ fetch_github_repo_summary, find_paths, index_semantic_code, initialize_config,
14
+ 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,
18
+ };
19
+
20
+ mod agent;
21
+ mod gmail;
22
+ mod image;
23
+ mod mcp;
24
+ mod onboard;
25
+ mod setup;
26
+ mod skills;
27
+ mod updater;
28
+
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";
36
+
37
+ async fn run_code_agent_with_saved_image(
38
+ task: &str,
39
+ current_dir: &Path,
40
+ config: &MintConfig,
41
+ image_data_uri: Option<String>,
42
+ options: agent::AgentOptions,
43
+ ) -> Result<()> {
44
+ let sent_image = image_data_uri.clone();
45
+ agent::run_code_agent_with_options(task, current_dir, config, image_data_uri, options).await?;
46
+ image::save_sent_image_after_send(sent_image.as_deref(), task);
47
+ Ok(())
48
+ }
49
+
50
+ #[derive(Debug, Parser)]
51
+ #[command(name = "mint", version, about = "Mint native CLI")]
52
+ struct Cli {
53
+ #[command(subcommand)]
54
+ command: Option<Command>,
55
+ }
56
+
57
+ #[derive(Debug, Subcommand)]
58
+ enum Command {
59
+ /// Display the current native runtime status.
60
+ Status,
61
+ /// Inspect the local Mint configuration.
62
+ Config {
63
+ #[command(subcommand)]
64
+ command: ConfigCommand,
65
+ },
66
+ /// List AI providers that are configured locally.
67
+ Providers,
68
+ /// Send one message through the configured Rust AI provider.
69
+ Chat {
70
+ message: String,
71
+ #[arg(long, default_value = "")]
72
+ system: String,
73
+ #[arg(long)]
74
+ image: Option<PathBuf>,
75
+ },
76
+ /// Inspect or update local long-term memory.
77
+ Memory {
78
+ #[command(subcommand)]
79
+ command: MemoryCommand,
80
+ },
81
+ /// Manage durable native tasks.
82
+ Task {
83
+ #[command(subcommand)]
84
+ command: TaskCommand,
85
+ },
86
+ /// Search and create local folders through the native safety policy.
87
+ Files {
88
+ #[command(subcommand)]
89
+ command: FilesCommand,
90
+ },
91
+ /// Run built-in native plugins.
92
+ Plugin {
93
+ #[command(subcommand)]
94
+ command: PluginCommand,
95
+ },
96
+ /// Index and search native local text knowledge.
97
+ Knowledge {
98
+ #[command(subcommand)]
99
+ command: KnowledgeCommand,
100
+ },
101
+ /// Inspect a code workspace through the native read-only code-agent tools.
102
+ Code {
103
+ #[command(subcommand)]
104
+ command: CodeCommand,
105
+ },
106
+ /// Inspect native safety policy decisions.
107
+ Safety {
108
+ #[command(subcommand)]
109
+ command: SafetyCommand,
110
+ },
111
+ /// Run one queued or supplied task through the native CLI agent.
112
+ Agent { task: Option<String> },
113
+ /// Launch the web UI and local API server.
114
+ Web,
115
+ /// Start only the local API server.
116
+ Api {
117
+ #[arg(long, default_value_t = 3000)]
118
+ port: u16,
119
+ },
120
+ /// Manage configured MCP stdio servers.
121
+ Mcp {
122
+ #[command(subcommand)]
123
+ command: McpCommand,
124
+ },
125
+ /// Configure Gmail OAuth.
126
+ Gmail {
127
+ #[command(subcommand)]
128
+ command: GmailCommand,
129
+ },
130
+ /// Check or install the latest npm-distributed CLI.
131
+ Update {
132
+ #[arg(long)]
133
+ check: bool,
134
+ #[arg(long)]
135
+ dry_run: bool,
136
+ #[arg(long)]
137
+ approve: bool,
138
+ },
139
+ /// Import, list, or delete persistent learned skill files.
140
+ Learn {
141
+ path: Option<PathBuf>,
142
+ #[arg(long)]
143
+ list: bool,
144
+ #[arg(long)]
145
+ delete: Option<String>,
146
+ },
147
+ /// Build a local source symbol index.
148
+ Symbols {
149
+ #[arg(default_value = ".")]
150
+ root: PathBuf,
151
+ #[arg(long, default_value_t = 100)]
152
+ limit: usize,
153
+ },
154
+ /// Build or search semantic source embeddings.
155
+ SemanticCode {
156
+ #[command(subcommand)]
157
+ command: SemanticCodeCommand,
158
+ },
159
+ /// Run a local shell command after explicit approval.
160
+ Run {
161
+ #[arg(long)]
162
+ approve: bool,
163
+ #[arg(long, default_value = ".")]
164
+ cwd: PathBuf,
165
+ #[arg(trailing_var_arg = true, required = true)]
166
+ command: Vec<String>,
167
+ },
168
+ /// Open a URL, file, or folder using the system default handler.
169
+ Open { target: String },
170
+ /// Launch a desktop program.
171
+ OpenApp { name: String },
172
+ /// Read the contents of a text file.
173
+ ReadFile { path: PathBuf },
174
+ /// List the contents of a directory.
175
+ ReadFolder {
176
+ #[arg(default_value = ".")]
177
+ path: PathBuf,
178
+ },
179
+ /// Configure Mint for first use.
180
+ Onboard,
181
+ /// Interactively manage enabled agent tools.
182
+ Setup,
183
+ }
184
+
185
+ #[derive(Debug, Subcommand)]
186
+ enum ConfigCommand {
187
+ /// Create the native config file and fill missing runtime defaults.
188
+ Init,
189
+ /// Print the config file path.
190
+ Path,
191
+ /// Print the config as JSON.
192
+ Show,
193
+ /// Set one JSON-compatible config value.
194
+ Set { key: String, value: String },
195
+ /// Show configured native providers and integrations.
196
+ Doctor,
197
+ }
198
+
199
+ #[derive(Debug, Subcommand)]
200
+ enum McpCommand {
201
+ Add {
202
+ name: String,
203
+ command: String,
204
+ #[arg(long, num_args = 0.., allow_hyphen_values = true)]
205
+ args: Vec<String>,
206
+ #[arg(long, num_args = 0..)]
207
+ env: Vec<String>,
208
+ },
209
+ List,
210
+ Remove {
211
+ name: String,
212
+ },
213
+ Allow {
214
+ server: String,
215
+ tool: String,
216
+ },
217
+ Clear,
218
+ Call {
219
+ server: String,
220
+ tool: String,
221
+ #[arg(long, default_value = "{}")]
222
+ arguments: String,
223
+ },
224
+ }
225
+
226
+ #[derive(Debug, Subcommand)]
227
+ enum GmailCommand {
228
+ Auth {
229
+ #[arg(long)]
230
+ no_open: bool,
231
+ #[arg(long, default_value_t = 0)]
232
+ port: u16,
233
+ },
234
+ }
235
+
236
+ #[derive(Debug, Subcommand)]
237
+ enum SemanticCodeCommand {
238
+ Index {
239
+ #[arg(default_value = ".")]
240
+ root: PathBuf,
241
+ },
242
+ Search {
243
+ query: String,
244
+ #[arg(default_value = ".")]
245
+ root: PathBuf,
246
+ #[arg(long, default_value_t = 5)]
247
+ limit: usize,
248
+ },
249
+ }
250
+
251
+ #[derive(Debug, Subcommand)]
252
+ enum TaskCommand {
253
+ Add {
254
+ description: String,
255
+ },
256
+ List,
257
+ Show {
258
+ id: String,
259
+ },
260
+ Pending,
261
+ Resume,
262
+ Update {
263
+ id: String,
264
+ status: String,
265
+ #[arg(long)]
266
+ result: Option<String>,
267
+ },
268
+ ClearCompleted,
269
+ }
270
+
271
+ #[derive(Debug, Subcommand)]
272
+ enum FilesCommand {
273
+ Find {
274
+ query: String,
275
+ #[arg(long, default_value_t = 20)]
276
+ limit: usize,
277
+ #[arg(long)]
278
+ root: Vec<PathBuf>,
279
+ },
280
+ CreateFolder {
281
+ path: PathBuf,
282
+ },
283
+ }
284
+
285
+ #[derive(Debug, Subcommand)]
286
+ enum PluginCommand {
287
+ List,
288
+ Run { name: String, instruction: String },
289
+ }
290
+
291
+ #[derive(Debug, Subcommand)]
292
+ enum KnowledgeCommand {
293
+ Add {
294
+ path: PathBuf,
295
+ },
296
+ List,
297
+ Search {
298
+ query: String,
299
+ #[arg(long, default_value_t = 5)]
300
+ limit: usize,
301
+ },
302
+ }
303
+
304
+ #[derive(Debug, Subcommand)]
305
+ enum CodeCommand {
306
+ /// Run the autonomous inspect, act, and verify code-agent loop.
307
+ Agent {
308
+ task: String,
309
+ #[arg(long, default_value = ".")]
310
+ root: PathBuf,
311
+ },
312
+ /// Summarize source files while skipping build and dependency directories.
313
+ Summary {
314
+ #[arg(default_value = ".")]
315
+ root: PathBuf,
316
+ },
317
+ /// List source files while skipping build and dependency directories.
318
+ List {
319
+ #[arg(default_value = ".")]
320
+ root: PathBuf,
321
+ #[arg(long, default_value_t = 100)]
322
+ limit: usize,
323
+ },
324
+ /// Read a numbered source range.
325
+ Read {
326
+ path: PathBuf,
327
+ #[arg(long, default_value_t = 1)]
328
+ start: usize,
329
+ #[arg(long, default_value_t = 200)]
330
+ end: usize,
331
+ },
332
+ /// Search source text without invoking a shell command.
333
+ Search {
334
+ query: String,
335
+ #[arg(default_value = ".")]
336
+ root: PathBuf,
337
+ #[arg(long, default_value_t = 20)]
338
+ limit: usize,
339
+ },
340
+ /// Print a bounded inspection-first plan. This never edits files or runs shell commands.
341
+ Plan {
342
+ task: String,
343
+ #[arg(default_value = ".")]
344
+ root: PathBuf,
345
+ #[arg(long)]
346
+ file: Vec<PathBuf>,
347
+ },
348
+ /// Preview a full file write and print its content-bound approval token.
349
+ ProposeWrite {
350
+ path: PathBuf,
351
+ #[arg(long, conflicts_with = "from_file")]
352
+ content: Option<String>,
353
+ #[arg(long)]
354
+ from_file: Option<PathBuf>,
355
+ #[arg(long, default_value = ".")]
356
+ root: PathBuf,
357
+ },
358
+ /// Apply exactly the full file write that was previously approved.
359
+ ApplyWrite {
360
+ path: PathBuf,
361
+ #[arg(long, conflicts_with = "from_file")]
362
+ content: Option<String>,
363
+ #[arg(long)]
364
+ from_file: Option<PathBuf>,
365
+ #[arg(long)]
366
+ approval_token: String,
367
+ #[arg(long, default_value = ".")]
368
+ root: PathBuf,
369
+ },
370
+ /// Preview an exact text replacement and print its content-bound approval token.
371
+ ProposePatch {
372
+ path: PathBuf,
373
+ old_text: String,
374
+ new_text: String,
375
+ #[arg(long, default_value = ".")]
376
+ root: PathBuf,
377
+ },
378
+ /// Apply exactly the text replacement that was previously approved.
379
+ ApplyPatch {
380
+ path: PathBuf,
381
+ old_text: String,
382
+ new_text: String,
383
+ #[arg(long)]
384
+ approval_token: String,
385
+ #[arg(long, default_value = ".")]
386
+ root: PathBuf,
387
+ },
388
+ /// Preview multiple full file writes. Use TARGET=SOURCE for each edit.
389
+ ProposeEdits {
390
+ #[arg(long, required = true)]
391
+ edit: Vec<String>,
392
+ #[arg(long, default_value = ".")]
393
+ root: PathBuf,
394
+ },
395
+ /// Apply exactly the multi-file write proposal that was previously approved.
396
+ ApplyEdits {
397
+ #[arg(long, required = true)]
398
+ edit: Vec<String>,
399
+ #[arg(long)]
400
+ approval_token: String,
401
+ #[arg(long, default_value = ".")]
402
+ root: PathBuf,
403
+ },
404
+ /// Fetch GitHub repository metadata and README, then get an overview of the repo.
405
+ GithubOverview {
406
+ /// The GitHub repository URL or name (e.g. "https://github.com/owner/repo" or "owner/repo").
407
+ repo: String,
408
+ },
409
+ }
410
+
411
+ #[derive(Debug, Subcommand)]
412
+ enum SafetyCommand {
413
+ /// Classify a shell command before execution.
414
+ Shell {
415
+ #[arg(trailing_var_arg = true, required = true)]
416
+ command: Vec<String>,
417
+ },
418
+ /// Check whether a path is readable or writable.
419
+ Path {
420
+ path: PathBuf,
421
+ #[arg(long)]
422
+ write: bool,
423
+ },
424
+ }
425
+
426
+ #[derive(Debug, Subcommand)]
427
+ enum MemoryCommand {
428
+ /// Read one profile value.
429
+ Get { key: String },
430
+ /// Store one profile value.
431
+ Set { key: String, value: String },
432
+ /// Show recent chat interactions.
433
+ Recent {
434
+ #[arg(long, default_value_t = 5)]
435
+ limit: usize,
436
+ },
437
+ }
438
+
439
+ #[tokio::main]
440
+ async fn main() -> Result<()> {
441
+ match Cli::parse().command {
442
+ None => {
443
+ run_interactive_chat().await?;
444
+ }
445
+ Some(cmd) => match cmd {
446
+ Command::Status => {
447
+ let config = load_config()?;
448
+ println!("Mint native CLI");
449
+ println!("provider: {}", config.ai_provider);
450
+ println!("model: {}", active_model(&config.ai_provider, &config));
451
+ println!("config: {}", config_path()?.display());
452
+ }
453
+ Command::Config { command } => match command {
454
+ ConfigCommand::Init => {
455
+ initialize_config()?;
456
+ println!("{}", config_path()?.display());
457
+ }
458
+ ConfigCommand::Path => println!("{}", config_path()?.display()),
459
+ ConfigCommand::Show => {
460
+ println!("{}", serde_json::to_string_pretty(&load_config()?)?)
461
+ }
462
+ ConfigCommand::Set { key, value } => {
463
+ let value = serde_json::from_str(&value)
464
+ .unwrap_or_else(|_| serde_json::Value::String(value));
465
+ println!(
466
+ "{}",
467
+ serde_json::to_string_pretty(&set_config_value(&key, value)?)?
468
+ );
469
+ }
470
+ ConfigCommand::Doctor => {
471
+ let config = load_config()?;
472
+ println!(
473
+ "{}",
474
+ serde_json::to_string_pretty(&serde_json::json!({
475
+ "configPath": config_path()?,
476
+ "activeProvider": config.ai_provider,
477
+ "availableProviders": config.available_providers(),
478
+ "headlessTaskQueue": config.extra["enableHeadlessTaskQueue"],
479
+ "updater": {
480
+ "enabled": config.extra["enableAutoUpdate"],
481
+ "endpointConfigured": configured(&config, &["updaterEndpoint"]),
482
+ "publicKeyConfigured": configured(&config, &["updaterPublicKey"]),
483
+ "automaticInstall": false,
484
+ },
485
+ "channels": {
486
+ "telegram": configured(&config, &["telegramBotToken"]),
487
+ "discord": configured(&config, &["discordBotToken"]),
488
+ "slack": configured(&config, &["slackBotToken", "slackAppToken"]),
489
+ "line": configured(&config, &["lineChannelAccessToken", "lineChannelSecret"]),
490
+ "whatsappCloud": configured(&config, &["whatsappCloudAccessToken", "whatsappPhoneNumberId", "whatsappVerifyToken"]),
491
+ },
492
+ "plugins": {
493
+ "gmail": configured(&config, &["gmailClientId", "gmailClientSecret", "gmailRefreshToken"]),
494
+ "googleCalendar": configured(&config, &["googleCalendarClientId", "googleCalendarClientSecret", "googleCalendarRefreshToken"]),
495
+ "notion": configured(&config, &["notionApiKey"]),
496
+ }
497
+ }))?
498
+ );
499
+ }
500
+ },
501
+ Command::Providers => {
502
+ for provider in load_config()?.available_providers() {
503
+ println!("{provider}");
504
+ }
505
+ }
506
+ Command::Agent { task } => {
507
+ run_cli_agent_task(task).await?;
508
+ }
509
+ Command::Web => {
510
+ launch_mint_target("web".into()).await?;
511
+ }
512
+ Command::Api { port } => {
513
+ mint_core::start_api_server(port).await?;
514
+ }
515
+ Command::Mcp { command } => match command {
516
+ McpCommand::Add {
517
+ name,
518
+ command,
519
+ args,
520
+ env,
521
+ } => {
522
+ mcp::add(&name, &command, args, env)?;
523
+ println!("Added MCP server: {name}");
524
+ }
525
+ McpCommand::List => println!("{}", serde_json::to_string_pretty(&mcp::list()?)?),
526
+ McpCommand::Remove { name } => {
527
+ println!(
528
+ "{}",
529
+ if mcp::remove(&name)? {
530
+ "removed"
531
+ } else {
532
+ "not found"
533
+ }
534
+ )
535
+ }
536
+ McpCommand::Allow { server, tool } => {
537
+ if mcp::allow(&server, &tool)? {
538
+ println!("allowed {server}/{tool}");
539
+ } else {
540
+ println!("already allowed {server}/{tool}");
541
+ }
542
+ }
543
+ McpCommand::Clear => {
544
+ mcp::clear()?;
545
+ println!("cleared");
546
+ }
547
+ McpCommand::Call {
548
+ server,
549
+ tool,
550
+ arguments,
551
+ } => println!(
552
+ "{}",
553
+ serde_json::to_string_pretty(&mcp::call(
554
+ &server,
555
+ &tool,
556
+ serde_json::from_str(&arguments)?
557
+ )?)?
558
+ ),
559
+ },
560
+ Command::Gmail { command } => match command {
561
+ GmailCommand::Auth { no_open, port } => gmail::auth(no_open, port).await?,
562
+ },
563
+ Command::Update {
564
+ check,
565
+ dry_run,
566
+ approve,
567
+ } => updater::run(check, dry_run, approve)?,
568
+ Command::Learn { path, list, delete } => {
569
+ let memory = MemoryStore::open_default()?;
570
+ if list {
571
+ println!(
572
+ "{}",
573
+ serde_json::to_string_pretty(&memory.learned_skills(100)?)?
574
+ );
575
+ } else if let Some(identifier) = delete {
576
+ println!("{}", memory.delete_learned_skill(&identifier)?);
577
+ } else if let Some(path) = path {
578
+ println!("{}", serde_json::to_string_pretty(&skills::learn(&path)?)?);
579
+ } else {
580
+ anyhow::bail!("use mint learn <path>, --list, or --delete <id|path|name>");
581
+ }
582
+ }
583
+ Command::Symbols { root, limit } => println!(
584
+ "{}",
585
+ serde_json::to_string_pretty(&build_symbol_index(&root, limit, &load_config()?)?)?
586
+ ),
587
+ Command::SemanticCode { command } => match command {
588
+ SemanticCodeCommand::Index { root } => println!(
589
+ "{}",
590
+ serde_json::to_string_pretty(
591
+ &index_semantic_code(&root, &load_config()?).await?
592
+ )?
593
+ ),
594
+ SemanticCodeCommand::Search { query, root, limit } => println!(
595
+ "{}",
596
+ serde_json::to_string_pretty(
597
+ &search_semantic_code(&root, &query, limit, &load_config()?).await?
598
+ )?
599
+ ),
600
+ },
601
+ Command::Chat {
602
+ message,
603
+ system,
604
+ image,
605
+ } => {
606
+ let image_data_uri = image
607
+ .as_deref()
608
+ .map(image::load_image_as_data_uri)
609
+ .transpose()?;
610
+ let sent_image = image_data_uri.clone();
611
+ if system.trim().is_empty() {
612
+ run_code_agent_with_saved_image(
613
+ &message,
614
+ &std::env::current_dir()?,
615
+ &load_config()?,
616
+ image_data_uri,
617
+ agent::AgentOptions::default(),
618
+ )
619
+ .await?;
620
+ } else {
621
+ let (response, _) = orchestrate_chat_with_fallback(
622
+ &load_config()?,
623
+ &ChatRequest {
624
+ message: message.clone(),
625
+ system_instruction: system,
626
+ chat_id: Some(CHAT_CLI_ID.to_owned()),
627
+ image_data_uri,
628
+ audio_data_uri: None,
629
+ document_attachment: None,
630
+ workspace_path: None,
631
+ },
632
+ )
633
+ .await?;
634
+ image::save_sent_image_after_send(sent_image.as_deref(), &message);
635
+ println!("{}", response.text);
636
+ }
637
+ }
638
+ Command::Memory { command } => {
639
+ let memory = MemoryStore::open_default()?;
640
+ match command {
641
+ MemoryCommand::Get { key } => {
642
+ println!("{}", memory.get_profile(&key)?.unwrap_or_default());
643
+ }
644
+ MemoryCommand::Set { key, value } => {
645
+ memory.set_profile(&key, &value)?;
646
+ println!("stored");
647
+ }
648
+ MemoryCommand::Recent { limit } => {
649
+ println!(
650
+ "{}",
651
+ serde_json::to_string_pretty(
652
+ &memory.recent_interactions_for_chat(CHAT_CLI_ID, limit)?
653
+ )?
654
+ );
655
+ }
656
+ }
657
+ }
658
+ Command::Safety { command } => match command {
659
+ SafetyCommand::Shell { command } => {
660
+ println!(
661
+ "{}",
662
+ serde_json::to_string_pretty(&classify_shell_command(&command.join(" ")))?
663
+ );
664
+ }
665
+ SafetyCommand::Path { path, write } => {
666
+ let capability = if write {
667
+ Capability::Write
668
+ } else {
669
+ Capability::Read
670
+ };
671
+ println!(
672
+ "{}",
673
+ assert_path_capability(&path, capability, &load_config()?)?.display()
674
+ );
675
+ }
676
+ },
677
+ Command::Run {
678
+ approve,
679
+ cwd,
680
+ command,
681
+ } => {
682
+ let output = run_shell_command(&command.join(" "), &cwd, approve, &load_config()?)?;
683
+ print_shell_output(&output);
684
+ if !output.success {
685
+ anyhow::bail!(
686
+ "shell command exited with status {}",
687
+ output
688
+ .status
689
+ .map_or_else(|| "unknown".into(), |status| status.to_string())
690
+ );
691
+ }
692
+ }
693
+ Command::Task { command } => {
694
+ let tasks = TaskStore::open_default()?;
695
+ match command {
696
+ TaskCommand::Add { description } => {
697
+ println!(
698
+ "{}",
699
+ serde_json::to_string_pretty(&tasks.add(description)?)?
700
+ );
701
+ }
702
+ TaskCommand::List => {
703
+ println!("{}", serde_json::to_string_pretty(&tasks.list()?)?)
704
+ }
705
+ TaskCommand::Show { id } => {
706
+ println!("{}", serde_json::to_string_pretty(&tasks.get(&id)?)?)
707
+ }
708
+ TaskCommand::Pending => {
709
+ println!("{}", serde_json::to_string_pretty(&tasks.pending()?)?)
710
+ }
711
+ TaskCommand::Resume => {
712
+ println!(
713
+ "{}",
714
+ serde_json::to_string_pretty(&tasks.resume_running()?)?
715
+ )
716
+ }
717
+ TaskCommand::Update { id, status, result } => {
718
+ println!(
719
+ "{}",
720
+ serde_json::to_string_pretty(&tasks.update_status(
721
+ &id,
722
+ &status,
723
+ result.map(serde_json::Value::String)
724
+ )?)?
725
+ )
726
+ }
727
+ TaskCommand::ClearCompleted => println!("{}", tasks.clear_completed()?),
728
+ }
729
+ }
730
+ Command::Files { command } => {
731
+ let config = load_config()?;
732
+ match command {
733
+ FilesCommand::Find {
734
+ query,
735
+ limit,
736
+ mut root,
737
+ } => {
738
+ if root.is_empty() {
739
+ root.push(std::env::current_dir()?);
740
+ if let Some(home) = dirs::home_dir() {
741
+ root.push(home);
742
+ }
743
+ }
744
+ println!(
745
+ "{}",
746
+ serde_json::to_string_pretty(&find_paths(
747
+ &query, &root, limit, &config
748
+ ))?
749
+ );
750
+ }
751
+ FilesCommand::CreateFolder { path } => {
752
+ println!("{}", create_folder(&path, &config)?.display())
753
+ }
754
+ }
755
+ }
756
+ Command::Plugin { command } => match command {
757
+ PluginCommand::List => {
758
+ println!("{}", serde_json::to_string_pretty(&native_plugins())?)
759
+ }
760
+ PluginCommand::Run { name, instruction } => {
761
+ println!(
762
+ "{}",
763
+ execute_native_plugin(&load_config()?, &name, &instruction).await?
764
+ )
765
+ }
766
+ },
767
+ Command::Knowledge { command } => {
768
+ let store = KnowledgeStore::open_default()?;
769
+ match command {
770
+ KnowledgeCommand::Add { path } => {
771
+ println!("{}", store.index_file(&path, &load_config()?)?)
772
+ }
773
+ KnowledgeCommand::List => {
774
+ println!("{}", serde_json::to_string_pretty(&store.list_sources()?)?)
775
+ }
776
+ KnowledgeCommand::Search { query, limit } => {
777
+ println!(
778
+ "{}",
779
+ serde_json::to_string_pretty(&store.search(&query, limit)?)?
780
+ )
781
+ }
782
+ }
783
+ }
784
+ Command::Code { command } => {
785
+ let config = load_config()?;
786
+ match command {
787
+ CodeCommand::Agent { task, root } => {
788
+ agent::run_code_agent(&task, &root, &config).await?;
789
+ }
790
+ CodeCommand::Summary { root } => println!(
791
+ "{}",
792
+ serde_json::to_string_pretty(&repository_summary(&root, &config)?)?
793
+ ),
794
+ CodeCommand::List { root, limit } => println!(
795
+ "{}",
796
+ serde_json::to_string_pretty(&list_code_files(&root, limit, &config)?)?
797
+ ),
798
+ CodeCommand::Read { path, start, end } => {
799
+ println!("{}", read_code_file(&path, start, end, &config)?)
800
+ }
801
+ CodeCommand::Search { query, root, limit } => println!(
802
+ "{}",
803
+ serde_json::to_string_pretty(&search_code(&root, &query, limit, &config)?)?
804
+ ),
805
+ CodeCommand::Plan { task, root, file } => println!(
806
+ "{}",
807
+ serde_json::to_string_pretty(&inspect_code_plan(
808
+ task, &root, file, &config
809
+ )?)?
810
+ ),
811
+ CodeCommand::ProposeWrite {
812
+ path,
813
+ content,
814
+ from_file,
815
+ root,
816
+ } => println!(
817
+ "{}",
818
+ serde_json::to_string_pretty(&propose_code_edits(
819
+ &root,
820
+ &[CodeEdit {
821
+ path,
822
+ content: edit_content(content, from_file, &config)?,
823
+ }],
824
+ &config,
825
+ )?)?
826
+ ),
827
+ CodeCommand::ApplyWrite {
828
+ path,
829
+ content,
830
+ from_file,
831
+ approval_token,
832
+ root,
833
+ } => println!(
834
+ "{}",
835
+ serde_json::to_string_pretty(&apply_code_edits(
836
+ &root,
837
+ &[CodeEdit {
838
+ path,
839
+ content: edit_content(content, from_file, &config)?,
840
+ }],
841
+ &approval_token,
842
+ &config,
843
+ )?)?
844
+ ),
845
+ CodeCommand::ProposePatch {
846
+ path,
847
+ old_text,
848
+ new_text,
849
+ root,
850
+ } => println!(
851
+ "{}",
852
+ serde_json::to_string_pretty(&propose_code_edits(
853
+ &root,
854
+ &[build_code_patch(
855
+ &root,
856
+ path,
857
+ &[CodePatchHunk { old_text, new_text }],
858
+ &config,
859
+ )?],
860
+ &config,
861
+ )?)?
862
+ ),
863
+ CodeCommand::ApplyPatch {
864
+ path,
865
+ old_text,
866
+ new_text,
867
+ approval_token,
868
+ root,
869
+ } => println!(
870
+ "{}",
871
+ serde_json::to_string_pretty(&apply_code_edits(
872
+ &root,
873
+ &[build_code_patch(
874
+ &root,
875
+ path,
876
+ &[CodePatchHunk { old_text, new_text }],
877
+ &config,
878
+ )?],
879
+ &approval_token,
880
+ &config,
881
+ )?)?
882
+ ),
883
+ CodeCommand::ProposeEdits { edit, root } => println!(
884
+ "{}",
885
+ serde_json::to_string_pretty(&propose_code_edits(
886
+ &root,
887
+ &file_edits(&edit, &config)?,
888
+ &config,
889
+ )?)?
890
+ ),
891
+ CodeCommand::ApplyEdits {
892
+ edit,
893
+ approval_token,
894
+ root,
895
+ } => println!(
896
+ "{}",
897
+ serde_json::to_string_pretty(&apply_code_edits(
898
+ &root,
899
+ &file_edits(&edit, &config)?,
900
+ &approval_token,
901
+ &config,
902
+ )?)?
903
+ ),
904
+ CodeCommand::GithubOverview { repo } => {
905
+ run_github_overview(&repo, &config).await?;
906
+ }
907
+ }
908
+ }
909
+ Command::Open { target } => {
910
+ open_system_handler(&target)?;
911
+ }
912
+ Command::OpenApp { name } => {
913
+ launch_desktop_app(&name)?;
914
+ }
915
+ Command::ReadFile { path } => {
916
+ read_file_content(&path)?;
917
+ }
918
+ Command::ReadFolder { path } => {
919
+ read_folder_content(&path)?;
920
+ }
921
+ Command::Onboard => {
922
+ onboard::run().await?;
923
+ }
924
+ Command::Setup => {
925
+ if let Some(target) = setup::run().await? {
926
+ launch_mint_target(target).await?;
927
+ }
928
+ }
929
+ },
930
+ }
931
+ Ok(())
932
+ }
933
+
934
+ async fn launch_mint_target(target: String) -> Result<()> {
935
+ match target.as_str() {
936
+ "cli" => {
937
+ println!("{MINT}Starting CLI Interactive Chat Assistant...{RESET}\n");
938
+ run_interactive_chat().await?;
939
+ }
940
+ "app_link" => {
941
+ const APP_URL: &str = "https://mint.aemeth.xyz";
942
+ println!("{MINT}Opening Mint App Link...{RESET}");
943
+ println!("{BLUE}Open app:{RESET} {APP_URL}\n");
944
+ open_system_handler(APP_URL)?;
945
+ }
946
+ "web" => {
947
+ println!(
948
+ "{MINT}Launching Web App (vite) in background...{RESET} {DIM}(Vite Dev UI at http://localhost:9000){RESET}"
949
+ );
950
+ let project_root = {
951
+ let mut found = None;
952
+ if let Ok(exe_path) = std::env::current_exe() {
953
+ let mut path = exe_path.parent();
954
+ while let Some(p) = path {
955
+ if p.join("package.json").exists() {
956
+ found = Some(p.to_path_buf());
957
+ break;
958
+ }
959
+ path = p.parent();
960
+ }
961
+ }
962
+ if found.is_none() {
963
+ if let Ok(cwd) = std::env::current_dir() {
964
+ let mut path = Some(cwd.as_path());
965
+ while let Some(p) = path {
966
+ if p.join("package.json").exists() {
967
+ found = Some(p.to_path_buf());
968
+ break;
969
+ }
970
+ path = p.parent();
971
+ }
972
+ }
973
+ }
974
+ found.ok_or_else(|| {
975
+ anyhow::anyhow!("Failed to find project root directory containing package.json")
976
+ })?
977
+ };
978
+ std::process::Command::new("npm")
979
+ .current_dir(&project_root)
980
+ .args(&["run", "dev:web"])
981
+ .stdout(std::process::Stdio::null())
982
+ .stderr(std::process::Stdio::null())
983
+ .spawn()
984
+ .map_err(|e| anyhow::anyhow!("Failed to launch web app: {e}"))?;
985
+
986
+ println!("{MINT}Starting local API server in foreground on port 3000...{RESET}\n");
987
+ let local_ip_msg = if let Some(ip) = mint_core::api_server::get_local_ip() {
988
+ format!("http://localhost:9000 (or http://{}:9000 from mobile)", ip)
989
+ } else {
990
+ "http://localhost:9000".to_string()
991
+ };
992
+ println!(
993
+ "{BLUE}Please open {RESET}{}{BLUE} in your web browser to access the Mint Web UI.{RESET}\n",
994
+ local_ip_msg
995
+ );
996
+ mint_core::start_api_server(3000).await?;
997
+ }
998
+ _ => {}
999
+ }
1000
+
1001
+ Ok(())
1002
+ }
1003
+
1004
+ struct ActionStreamFilter {
1005
+ buffer: String,
1006
+ in_action: bool,
1007
+ action_text: String,
1008
+ actions: Vec<String>,
1009
+ }
1010
+
1011
+ impl ActionStreamFilter {
1012
+ fn new() -> Self {
1013
+ Self {
1014
+ buffer: String::new(),
1015
+ in_action: false,
1016
+ action_text: String::new(),
1017
+ actions: Vec::new(),
1018
+ }
1019
+ }
1020
+
1021
+ fn process_chunk(&mut self, chunk: &str, mut print_fn: impl FnMut(&str)) {
1022
+ for c in chunk.chars() {
1023
+ if self.in_action {
1024
+ if c == ']' {
1025
+ self.in_action = false;
1026
+ self.actions.push(self.action_text.clone());
1027
+ self.action_text.clear();
1028
+ } else {
1029
+ self.action_text.push(c);
1030
+ }
1031
+ } else {
1032
+ self.buffer.push(c);
1033
+ let action_prefix = "[ACTION:";
1034
+ if action_prefix.starts_with(&self.buffer) {
1035
+ if self.buffer == action_prefix {
1036
+ self.in_action = true;
1037
+ self.buffer.clear();
1038
+ }
1039
+ } else {
1040
+ if let Some(pos) = self.buffer.find('[') {
1041
+ print_fn(&self.buffer[..pos]);
1042
+ let new_buf = self.buffer[pos..].to_string();
1043
+ self.buffer = new_buf;
1044
+ if !action_prefix.starts_with(&self.buffer) {
1045
+ print_fn(&self.buffer);
1046
+ self.buffer.clear();
1047
+ }
1048
+ } else {
1049
+ print_fn(&self.buffer);
1050
+ self.buffer.clear();
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ fn finalize(&mut self, mut print_fn: impl FnMut(&str)) -> Vec<String> {
1058
+ if !self.buffer.is_empty() {
1059
+ print_fn(&self.buffer);
1060
+ self.buffer.clear();
1061
+ }
1062
+ if self.in_action {
1063
+ print_fn(&format!("[ACTION:{}", self.action_text));
1064
+ self.action_text.clear();
1065
+ self.in_action = false;
1066
+ }
1067
+ std::mem::take(&mut self.actions)
1068
+ }
1069
+ }
1070
+
1071
+ async fn run_cli_agent_task(task: Option<String>) -> Result<()> {
1072
+ let store = TaskStore::open_default()?;
1073
+ let task = match task {
1074
+ Some(description) => store.add(description)?,
1075
+ None => store
1076
+ .pending()?
1077
+ .ok_or_else(|| anyhow::anyhow!("no pending task is available"))?,
1078
+ };
1079
+ store.update_status(&task.id, "running", None)?;
1080
+ println!("Running task {}: {}", task.id, task.description);
1081
+ match agent::run_code_agent(
1082
+ &task.description,
1083
+ &std::env::current_dir()?,
1084
+ &load_config()?,
1085
+ )
1086
+ .await
1087
+ {
1088
+ Ok(result) => {
1089
+ store.update_status(
1090
+ &task.id,
1091
+ "completed",
1092
+ Some(serde_json::json!({
1093
+ "summary": result.summary,
1094
+ "verification": result.verification,
1095
+ })),
1096
+ )?;
1097
+ println!("Task completed: {}", task.id);
1098
+ Ok(())
1099
+ }
1100
+ Err(error) => {
1101
+ store.fail_with_retry(&task.id, &error.to_string())?;
1102
+ Err(error)
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ fn execute_action(action: &str, config: &MintConfig) -> Result<()> {
1108
+ let trimmed = action.trim();
1109
+ if let Some((cmd, args)) = trimmed.split_once(' ') {
1110
+ let cmd = cmd.trim();
1111
+ let args = args.trim();
1112
+ match cmd {
1113
+ "open" => {
1114
+ println!("{WARN}System action:{RESET} opening {args}...\n");
1115
+ open_system_handler(args)?;
1116
+ }
1117
+ "open-app" => {
1118
+ println!("{WARN}System action:{RESET} launching app {args}...\n");
1119
+ launch_desktop_app(args)?;
1120
+ }
1121
+ "read-file" => {
1122
+ println!("{WARN}System action:{RESET} reading file {args}...\n");
1123
+ let path = PathBuf::from(args);
1124
+ read_file_content(&path)?;
1125
+ }
1126
+ "read-folder" => {
1127
+ println!("{WARN}System action:{RESET} reading folder {args}...\n");
1128
+ let path = PathBuf::from(args);
1129
+ read_folder_content(&path)?;
1130
+ }
1131
+ "run-shell" => {
1132
+ println!("{WARN}System action:{RESET} run local shell command");
1133
+ println!(" {args}");
1134
+ if confirm_shell_execution()? {
1135
+ let output = run_shell_command(args, &std::env::current_dir()?, true, config)?;
1136
+ print_shell_output(&output);
1137
+ } else {
1138
+ println!("Shell command cancelled.\n");
1139
+ }
1140
+ }
1141
+ _ => {
1142
+ println!("{ERROR}Unknown system action:{RESET} {cmd} with args {args}\n");
1143
+ }
1144
+ }
1145
+ } else {
1146
+ match trimmed {
1147
+ "read-folder" => {
1148
+ println!("{WARN}System action:{RESET} reading folder . ...\n");
1149
+ read_folder_content(&PathBuf::from("."))?;
1150
+ }
1151
+ _ => {
1152
+ println!("{ERROR}Invalid action format:{RESET} {trimmed}\n");
1153
+ }
1154
+ }
1155
+ }
1156
+ Ok(())
1157
+ }
1158
+
1159
+ /// Per-session mutable state for the interactive chat loop.
1160
+ struct InteractiveSession {
1161
+ config: MintConfig,
1162
+ current_dir: PathBuf,
1163
+ fast_mode: bool,
1164
+ pending_image: Option<String>, // base64 data URI
1165
+ }
1166
+
1167
+ struct InteractiveInput {
1168
+ text: String,
1169
+ pasted_image: Option<String>,
1170
+ }
1171
+
1172
+ /// What the slash-command router wants the loop to do next.
1173
+ enum SlashResult {
1174
+ /// Command handled — continue loop without sending to agent.
1175
+ Handled,
1176
+ /// Pass this (possibly modified) query to the agent.
1177
+ ForwardToAgent(String),
1178
+ /// Break out of the loop.
1179
+ Exit,
1180
+ }
1181
+
1182
+ /// Route `/…` commands. Returns `None` if the input is not a slash command.
1183
+ async fn handle_slash_command(
1184
+ session: &mut InteractiveSession,
1185
+ query: &str,
1186
+ ) -> Option<SlashResult> {
1187
+ let trimmed = query.trim();
1188
+ if !trimmed.starts_with('/') {
1189
+ return None;
1190
+ }
1191
+
1192
+ // Split into command word and optional rest
1193
+ let (cmd, rest) = trimmed
1194
+ .split_once(char::is_whitespace)
1195
+ .map(|(c, r)| (c, r.trim()))
1196
+ .unwrap_or((trimmed, ""));
1197
+
1198
+ match cmd {
1199
+ "/help" => {
1200
+ println!("\n{BLUE}────────────────────────────────────────────{RESET}");
1201
+ println!("{MINT} Mint Interactive Commands{RESET}");
1202
+ println!("{BLUE}────────────────────────────────────────────{RESET}");
1203
+ let commands = [
1204
+ ("/help", "Show this help"),
1205
+ ("/fast [on|off]", "Toggle fast mode (hide thinking traces)"),
1206
+ ("/models [name]", "List providers or switch provider"),
1207
+ ("/clear", "Clear conversation history"),
1208
+ ("/cd <path>", "Change workspace directory"),
1209
+ ("/image <path> [prompt]", "Attach image from file"),
1210
+ ("/paste [prompt]", "Attach image from clipboard"),
1211
+ ("Ctrl+V", "Paste clipboard image as [Image #1]"),
1212
+ ("/learn <path>", "Import a persistent .md or .txt skill"),
1213
+ ("/memory list", "Show recent interactions"),
1214
+ ("/memory clear", "Clear all interactions"),
1215
+ ("/memory get <key>", "Read a profile value"),
1216
+ ("/memory set <key> <val>", "Store a profile value"),
1217
+ ("/mcp list", "List configured MCP servers"),
1218
+ ("/mcp allow <server> <tool>", "Allow an MCP tool"),
1219
+ ("/stats", "Show session statistics"),
1220
+ ("/exit | /quit", "Exit Mint"),
1221
+ ("/code <task>", "Run in code-agent mode"),
1222
+ ];
1223
+ for (cmd_name, desc) in &commands {
1224
+ println!(" {MINT}{:<30}{RESET} {DIM}{}{RESET}", cmd_name, desc);
1225
+ }
1226
+ println!();
1227
+ Some(SlashResult::Handled)
1228
+ }
1229
+
1230
+ "/fast" => {
1231
+ session.fast_mode = match rest {
1232
+ "off" => false,
1233
+ "on" => true,
1234
+ "" => !session.fast_mode,
1235
+ _ => {
1236
+ println!("{WARN}/fast usage: /fast [on|off]{RESET}");
1237
+ return Some(SlashResult::Handled);
1238
+ }
1239
+ };
1240
+ if session.fast_mode {
1241
+ println!("{DIM}[Fast] mode ON — thinking traces hidden{RESET}\n");
1242
+ } else {
1243
+ println!("{DIM}[Fast] mode OFF{RESET}\n");
1244
+ }
1245
+ Some(SlashResult::Handled)
1246
+ }
1247
+
1248
+ "/models" => {
1249
+ if rest.is_empty() {
1250
+ println!("\n{BLUE}Configured providers:{RESET}");
1251
+ for p in session.config.available_providers() {
1252
+ let active = if p == session.config.ai_provider.as_str() {
1253
+ format!(" {MINT}← active{RESET}")
1254
+ } else {
1255
+ String::new()
1256
+ };
1257
+ println!(" {p}{active}");
1258
+ }
1259
+ println!();
1260
+ } else {
1261
+ session.config.ai_provider = rest.to_owned();
1262
+ match mint_core::save_config(&session.config) {
1263
+ Ok(()) => println!(
1264
+ "{DIM}Switched to provider: {}{RESET}\n",
1265
+ session.config.ai_provider
1266
+ ),
1267
+ Err(error) => println!("{ERROR}Config error:{RESET} {error}"),
1268
+ }
1269
+ }
1270
+ Some(SlashResult::Handled)
1271
+ }
1272
+
1273
+ "/clear" | "/reset" => {
1274
+ println!("Clear conversation history? [y/N] ");
1275
+ if let Ok(true) = confirm("Clear conversation history? [y/N] ") {
1276
+ if let Ok(memory) = MemoryStore::open_default() {
1277
+ match memory.clear_interactions() {
1278
+ Ok(count) => println!("{DIM}Cleared {count} interactions.{RESET}"),
1279
+ Err(error) => println!("{ERROR}Memory error:{RESET} {error}"),
1280
+ }
1281
+ }
1282
+ println!("{DIM}Conversation context cleared.{RESET}\n");
1283
+ }
1284
+ Some(SlashResult::Handled)
1285
+ }
1286
+
1287
+ "/cd" => {
1288
+ if rest.is_empty() {
1289
+ println!("{WARN}/cd requires a path{RESET}\n");
1290
+ } else {
1291
+ let new_dir = PathBuf::from(rest);
1292
+ if new_dir.is_dir() {
1293
+ session.current_dir = new_dir.canonicalize().unwrap_or(new_dir);
1294
+ println!(
1295
+ "{DIM}Workspace: {}{RESET}\n",
1296
+ format_path_with_tilde(&session.current_dir)
1297
+ );
1298
+ } else {
1299
+ println!("{ERROR}Directory not found:{RESET} {rest}\n");
1300
+ }
1301
+ }
1302
+ Some(SlashResult::Handled)
1303
+ }
1304
+
1305
+ "/image" => {
1306
+ let (img_path, prompt) = rest
1307
+ .split_once(char::is_whitespace)
1308
+ .map(|(p, r)| (p, r.trim()))
1309
+ .unwrap_or((rest, ""));
1310
+
1311
+ if img_path.is_empty() {
1312
+ println!("{WARN}/image usage: /image <path> [prompt]{RESET}\n");
1313
+ return Some(SlashResult::Handled);
1314
+ }
1315
+ match image::load_image_as_data_uri(std::path::Path::new(img_path)) {
1316
+ Ok(uri) => {
1317
+ if let Some(ref mut current) = session.pending_image {
1318
+ current.push(' ');
1319
+ current.push_str(&uri);
1320
+ } else {
1321
+ session.pending_image = Some(uri);
1322
+ }
1323
+ if prompt.is_empty() {
1324
+ println!("{DIM}Image attached — type your prompt and press Enter{RESET}\n");
1325
+ Some(SlashResult::Handled)
1326
+ } else {
1327
+ Some(SlashResult::ForwardToAgent(prompt.to_owned()))
1328
+ }
1329
+ }
1330
+ Err(e) => {
1331
+ println!("{ERROR}Failed to load image:{RESET} {e}\n");
1332
+ Some(SlashResult::Handled)
1333
+ }
1334
+ }
1335
+ }
1336
+
1337
+ "/paste" => match image::read_clipboard_image() {
1338
+ Ok(Some(uri)) => {
1339
+ if let Some(ref mut current) = session.pending_image {
1340
+ current.push(' ');
1341
+ current.push_str(&uri);
1342
+ } else {
1343
+ session.pending_image = Some(uri);
1344
+ }
1345
+ if rest.is_empty() {
1346
+ println!(
1347
+ "{DIM}Clipboard image attached — type your prompt and press Enter{RESET}\n"
1348
+ );
1349
+ Some(SlashResult::Handled)
1350
+ } else {
1351
+ Some(SlashResult::ForwardToAgent(rest.to_owned()))
1352
+ }
1353
+ }
1354
+ Ok(None) => {
1355
+ println!("{WARN}No image found in clipboard.{RESET}\n");
1356
+ Some(SlashResult::Handled)
1357
+ }
1358
+ Err(e) => {
1359
+ println!("{ERROR}Clipboard error:{RESET} {e}\n");
1360
+ Some(SlashResult::Handled)
1361
+ }
1362
+ },
1363
+
1364
+ "/learn" => {
1365
+ if rest.is_empty() {
1366
+ println!("{WARN}/learn usage: /learn <path>{RESET}\n");
1367
+ } else {
1368
+ let path = PathBuf::from(rest);
1369
+ let path = if path.is_absolute() {
1370
+ path
1371
+ } else {
1372
+ session.current_dir.join(path)
1373
+ };
1374
+ match skills::learn(&path) {
1375
+ Ok(skill) => println!(
1376
+ "{DIM}Learned skill: {} ({}){RESET}\n",
1377
+ skill.name, skill.source_path
1378
+ ),
1379
+ Err(error) => println!("{ERROR}Learn error:{RESET} {error}\n"),
1380
+ }
1381
+ }
1382
+ Some(SlashResult::Handled)
1383
+ }
1384
+
1385
+ "/memory" => {
1386
+ let memory = match MemoryStore::open_default() {
1387
+ Ok(m) => m,
1388
+ Err(e) => {
1389
+ println!("{ERROR}Memory error:{RESET} {e}\n");
1390
+ return Some(SlashResult::Handled);
1391
+ }
1392
+ };
1393
+ let (subcmd, args) = rest
1394
+ .split_once(char::is_whitespace)
1395
+ .map(|(c, a)| (c, a.trim()))
1396
+ .unwrap_or((rest, ""));
1397
+ match subcmd {
1398
+ "list" | "" => match memory.recent_interactions_for_chat(CHAT_CLI_ID, 10) {
1399
+ Ok(items) => {
1400
+ if items.is_empty() {
1401
+ println!("{DIM}No interactions yet.{RESET}\n");
1402
+ } else {
1403
+ println!("\n{BLUE}Recent interactions:{RESET}");
1404
+ for item in items.iter().rev() {
1405
+ println!(
1406
+ " {DIM}[{}]{RESET} {BLUE}You:{RESET} {}",
1407
+ &item.created_at[..16.min(item.created_at.len())],
1408
+ if item.user_text.len() > 80 {
1409
+ format!("{}…", &item.user_text[..80])
1410
+ } else {
1411
+ item.user_text.clone()
1412
+ }
1413
+ );
1414
+ }
1415
+ println!();
1416
+ }
1417
+ }
1418
+ Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
1419
+ },
1420
+ "get" => {
1421
+ if args.is_empty() {
1422
+ println!("{WARN}/memory get <key>{RESET}\n");
1423
+ } else {
1424
+ match memory.get_profile(args) {
1425
+ Ok(Some(val)) => println!("{val}\n"),
1426
+ Ok(None) => println!("{DIM}(not set){RESET}\n"),
1427
+ Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
1428
+ }
1429
+ }
1430
+ }
1431
+ "set" => {
1432
+ let (key, val) = args
1433
+ .split_once(char::is_whitespace)
1434
+ .map(|(k, v)| (k, v.trim()))
1435
+ .unwrap_or((args, ""));
1436
+ if key.is_empty() {
1437
+ println!("{WARN}/memory set <key> <value>{RESET}\n");
1438
+ } else {
1439
+ match memory.set_profile(key, val) {
1440
+ Ok(()) => println!("{DIM}Stored {key}.{RESET}\n"),
1441
+ Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
1442
+ }
1443
+ }
1444
+ }
1445
+ "skills" => match memory.learned_skills(20) {
1446
+ Ok(skills) => {
1447
+ if skills.is_empty() {
1448
+ println!("{DIM}No learned skills.{RESET}\n");
1449
+ } else {
1450
+ println!("\n{BLUE}Learned skills:{RESET}");
1451
+ for s in &skills {
1452
+ println!(" [{}] {} — {}", s.id, s.name, s.source_path);
1453
+ }
1454
+ println!();
1455
+ }
1456
+ }
1457
+ Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
1458
+ },
1459
+ "clear" => match memory.clear_interactions_for_chat(CHAT_CLI_ID) {
1460
+ Ok(count) => println!("{DIM}Cleared {count} interactions.{RESET}\n"),
1461
+ Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
1462
+ },
1463
+ _ => println!(
1464
+ "{WARN}/memory usage: list | clear | get <key> | set <key> <val> | skills{RESET}\n"
1465
+ ),
1466
+ }
1467
+ Some(SlashResult::Handled)
1468
+ }
1469
+
1470
+ "/mcp" => {
1471
+ let (subcmd, args) = rest
1472
+ .split_once(char::is_whitespace)
1473
+ .map(|(c, a)| (c, a.trim()))
1474
+ .unwrap_or((rest, ""));
1475
+ match subcmd {
1476
+ "list" | "" => match mcp::list() {
1477
+ Ok(servers) if servers.is_empty() => {
1478
+ println!("{DIM}No MCP servers configured.{RESET}\n");
1479
+ }
1480
+ Ok(servers) => {
1481
+ println!("\n{BLUE}MCP servers:{RESET}");
1482
+ match serde_json::to_string_pretty(&servers) {
1483
+ Ok(json) => println!("{json}\n"),
1484
+ Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
1485
+ }
1486
+ }
1487
+ Err(e) => println!("{ERROR}MCP error:{RESET} {e}\n"),
1488
+ },
1489
+ "allow" => {
1490
+ let mut parts = args.split_whitespace();
1491
+ let server = parts.next();
1492
+ let tool = parts.next();
1493
+ if let (Some(server), Some(tool)) = (server, tool) {
1494
+ match mcp::allow(server, tool) {
1495
+ Ok(true) => println!("{DIM}Allowed MCP tool: {server}/{tool}{RESET}\n"),
1496
+ Ok(false) => {
1497
+ println!("{DIM}MCP tool already allowed: {server}/{tool}{RESET}\n")
1498
+ }
1499
+ Err(e) => println!("{ERROR}MCP error:{RESET} {e}\n"),
1500
+ }
1501
+ } else {
1502
+ println!("{WARN}/mcp allow usage: <server> <tool>{RESET}\n");
1503
+ }
1504
+ }
1505
+ _ => println!("{WARN}/mcp usage: list | allow <server> <tool>{RESET}\n"),
1506
+ }
1507
+ Some(SlashResult::Handled)
1508
+ }
1509
+
1510
+ "/stats" => {
1511
+ let provider = &session.config.ai_provider;
1512
+ let model = active_model(provider, &session.config);
1513
+ let interactions = MemoryStore::open_default()
1514
+ .and_then(|m| m.recent_interactions_for_chat(CHAT_CLI_ID, 1000))
1515
+ .map(|v| v.len())
1516
+ .unwrap_or(0);
1517
+ println!("\n{BLUE}─ Session Stats ─────────────────────────{RESET}");
1518
+ println!(" Provider : {MINT}{provider}{RESET}");
1519
+ println!(" Model : {model}");
1520
+ println!(
1521
+ " Workspace: {}",
1522
+ format_path_with_tilde(&session.current_dir)
1523
+ );
1524
+ println!(
1525
+ " Fast mode: {}",
1526
+ if session.fast_mode { "on" } else { "off" }
1527
+ );
1528
+ println!(" Memory : {interactions} interactions");
1529
+ if let Some(ref img_data) = session.pending_image {
1530
+ let count = img_data.split_whitespace().count();
1531
+ if count > 1 {
1532
+ println!(" Images : {WARN}{} images attached{RESET}", count);
1533
+ } else {
1534
+ println!(" Image : {WARN}attached{RESET}");
1535
+ }
1536
+ }
1537
+ println!();
1538
+ Some(SlashResult::Handled)
1539
+ }
1540
+
1541
+ "/exit" | "/quit" => Some(SlashResult::Exit),
1542
+
1543
+ "/code" => {
1544
+ if rest.is_empty() {
1545
+ println!("{WARN}/code requires a task description{RESET}\n");
1546
+ Some(SlashResult::Handled)
1547
+ } else {
1548
+ Some(SlashResult::ForwardToAgent(format!("[code] {rest}")))
1549
+ }
1550
+ }
1551
+
1552
+ _ => {
1553
+ // Unknown slash command — treat as normal message to the agent
1554
+ None
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ async fn run_interactive_chat() -> Result<()> {
1560
+ let config = load_config()?;
1561
+
1562
+ let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1563
+
1564
+ let provider = &config.ai_provider.clone();
1565
+ let model = active_model(provider, &config).to_owned();
1566
+
1567
+ // Print startup banner
1568
+ let now = chrono::Local::now();
1569
+ let year = now.format("%Y").to_string().parse::<i32>().unwrap_or(2026) + 543;
1570
+ let date_time = format!(
1571
+ "{}/{:02}/{:02} {:02}:{:02}",
1572
+ now.format("%d"),
1573
+ now.format("%m"),
1574
+ year,
1575
+ now.format("%H"),
1576
+ now.format("%M")
1577
+ );
1578
+ let version = env!("CARGO_PKG_VERSION");
1579
+ let line1_text = format!("[Mint] v{} | Active AI: {}", version, provider);
1580
+ let line2_text = format!("{} • {}", date_time, model);
1581
+
1582
+ let len1 = line1_text.chars().count();
1583
+ let len2 = line2_text.chars().count();
1584
+ let content_width = std::cmp::max(len1, len2);
1585
+ let border_len = content_width + 2;
1586
+
1587
+ let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
1588
+ let term_width = term_width as usize;
1589
+ let ascii_width = 34;
1590
+ let spacing = 3;
1591
+ let box_width = border_len + 2;
1592
+
1593
+ if term_width >= ascii_width + spacing + box_width {
1594
+ println!(
1595
+ "{MINT} __ __ _ _ ___ _ ___ {RESET} {DIM}╭{}╮{RESET}",
1596
+ "─".repeat(border_len)
1597
+ );
1598
+ println!(
1599
+ "{MINT}| \\/ (_)_ __ | |_ / __| | |_ _|{RESET} {DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
1600
+ version,
1601
+ provider,
1602
+ " ".repeat(content_width - len1)
1603
+ );
1604
+ println!(
1605
+ "{MINT}| |\\/| | | '_ \\| _| (__| |__ | | {RESET} {DIM}│{RESET} {DIM}{}{}{RESET} {DIM}│{RESET}",
1606
+ line2_text,
1607
+ " ".repeat(content_width - len2)
1608
+ );
1609
+ println!(
1610
+ "{MINT}|_| |_|_|_| |_|\\__|\\___|\\___|___|{RESET} {DIM}╰{}╯{RESET}",
1611
+ "─".repeat(border_len)
1612
+ );
1613
+ } else {
1614
+ println!("{DIM}╭{}╮{RESET}", "─".repeat(border_len));
1615
+ println!(
1616
+ "{DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
1617
+ version,
1618
+ provider,
1619
+ " ".repeat(content_width - len1)
1620
+ );
1621
+ println!(
1622
+ "{DIM}│{RESET} {DIM}{}{}{RESET} {DIM}│{RESET}",
1623
+ line2_text,
1624
+ " ".repeat(content_width - len2)
1625
+ );
1626
+ println!("{DIM}╰{}╯{RESET}", "─".repeat(border_len));
1627
+ println!("{MINT} __ __ _ _ ___ _ ___ {RESET}");
1628
+ println!("{MINT}| \\/ (_)_ __ | |_ / __| | |_ _|{RESET}");
1629
+ println!("{MINT}| |\\/| | | '_ \\| _| (__| |__ | | {RESET}");
1630
+ println!("{MINT}|_| |_|_|_| |_|\\__|\\___|\\___|___|{RESET}");
1631
+ }
1632
+ println!("Type naturally or /help for commands. Ctrl+V pastes images. Ctrl+D exits.\n");
1633
+
1634
+ let mut session = InteractiveSession {
1635
+ config,
1636
+ current_dir: current_dir.clone(),
1637
+ fast_mode: false,
1638
+ pending_image: None,
1639
+ };
1640
+
1641
+ let mut printed_update = false;
1642
+ if let Some((current, latest)) = updater::get_cached_update_notice() {
1643
+ updater::print_update_notice(&current, &latest);
1644
+ printed_update = true;
1645
+ }
1646
+
1647
+ let mut update_handle = if updater::should_check_for_update() {
1648
+ Some(tokio::task::spawn_blocking(
1649
+ updater::check_for_update_quietly,
1650
+ ))
1651
+ } else {
1652
+ None
1653
+ };
1654
+
1655
+ loop {
1656
+ if let Some(handle) = update_handle.take() {
1657
+ if handle.is_finished() {
1658
+ if let Ok(Some((current, latest))) = handle.await {
1659
+ if !printed_update {
1660
+ updater::print_update_notice(&current, &latest);
1661
+ printed_update = true;
1662
+ }
1663
+ }
1664
+ } else {
1665
+ update_handle = Some(handle);
1666
+ }
1667
+ }
1668
+
1669
+ let path_str = format_path_with_tilde(&session.current_dir);
1670
+ let model_str = active_model(&session.config.ai_provider, &session.config).to_owned();
1671
+
1672
+ if let Some(input) =
1673
+ read_line_interactive(&session.config.ai_provider, &model_str, &path_str)?
1674
+ {
1675
+ if let Some(uri) = input.pasted_image {
1676
+ if let Some(ref mut current) = session.pending_image {
1677
+ current.push(' ');
1678
+ current.push_str(&uri);
1679
+ } else {
1680
+ session.pending_image = Some(uri);
1681
+ }
1682
+ }
1683
+ let query_str = input.text.trim().to_owned();
1684
+ if query_str.is_empty() {
1685
+ continue;
1686
+ }
1687
+
1688
+ // Run slash-command router
1689
+ match handle_slash_command(&mut session, &query_str).await {
1690
+ Some(SlashResult::Handled) => continue,
1691
+ Some(SlashResult::Exit) => {
1692
+ print_exit_message(&session);
1693
+ break;
1694
+ }
1695
+ Some(SlashResult::ForwardToAgent(task)) => {
1696
+ // Force code agent for /code forwarded tasks
1697
+ println!();
1698
+ if let Err(error) = run_code_agent_with_saved_image(
1699
+ &task,
1700
+ &session.current_dir,
1701
+ &session.config,
1702
+ session.pending_image.take(),
1703
+ agent::AgentOptions {
1704
+ fast_mode: session.fast_mode,
1705
+ },
1706
+ )
1707
+ .await
1708
+ {
1709
+ println!("{ERROR}Error:{RESET} {error}\n");
1710
+ }
1711
+ continue;
1712
+ }
1713
+ None => {} // Not a slash command, fall through
1714
+ }
1715
+
1716
+ // Check if it's a /code or regular agent request
1717
+ if let Some(task) = query_str.strip_prefix("/code ") {
1718
+ println!();
1719
+ if let Err(error) = run_code_agent_with_saved_image(
1720
+ task.trim(),
1721
+ &session.current_dir,
1722
+ &session.config,
1723
+ session.pending_image.take(),
1724
+ agent::AgentOptions {
1725
+ fast_mode: session.fast_mode,
1726
+ },
1727
+ )
1728
+ .await
1729
+ {
1730
+ println!("{ERROR}Error:{RESET} {error}\n");
1731
+ }
1732
+ continue;
1733
+ }
1734
+
1735
+ // Regular agent loop (handles both chat and coding)
1736
+ if !query_str.starts_with("/chat ") {
1737
+ if let Err(error) = run_code_agent_with_saved_image(
1738
+ query_str.trim_start_matches("/chat "),
1739
+ &session.current_dir,
1740
+ &session.config,
1741
+ session.pending_image.take(),
1742
+ agent::AgentOptions {
1743
+ fast_mode: session.fast_mode,
1744
+ },
1745
+ )
1746
+ .await
1747
+ {
1748
+ println!("{ERROR}Error:{RESET} {error}\n");
1749
+ }
1750
+ continue;
1751
+ }
1752
+
1753
+ // /chat explicit: use streaming chat with fallback
1754
+ let message = query_str
1755
+ .strip_prefix("/chat ")
1756
+ .unwrap_or(&query_str)
1757
+ .trim()
1758
+ .to_owned();
1759
+
1760
+ println!();
1761
+ print!("{MINT}Mint:{RESET} {DIM}Thinking...{RESET}");
1762
+ let _ = io::stdout().flush();
1763
+
1764
+ let mut system_instruction = format!(
1765
+ "You are Mint, a cute and helpful AI assistant. You speak in a polite, friendly, and sweet Thai tone (using \"คุณ\", \"ค่ะ\", \"นะคะ\"). \
1766
+ You are running inside the Mint CLI interactive chat. \
1767
+ 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: \
1768
+ `[ACTION: <command> <arguments>]` \
1769
+ The available actions are: \
1770
+ - `[ACTION: open <url_or_path>]` to open a URL or a folder path. \
1771
+ - `[ACTION: open-app <app_name>]` to launch a desktop application. \
1772
+ - `[ACTION: read-file <file_path>]` to read the contents of a file. \
1773
+ - `[ACTION: read-folder <path>]` to list files/folders in a directory. \
1774
+ - `[ACTION: run-shell <command>]` to run a non-destructive local shell command after approval. \
1775
+ 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. \
1776
+ Write the action block on a single line at the very end of your response."
1777
+ );
1778
+ if let Ok(memory) = MemoryStore::open_default() {
1779
+ if let Ok(Some(name)) = memory.get_profile("name") {
1780
+ system_instruction.push_str(&format!(
1781
+ "\nThe user's name is {}. Refer to them by their name when appropriate.",
1782
+ name
1783
+ ));
1784
+ }
1785
+ }
1786
+
1787
+ let image_uri = session.pending_image.take();
1788
+ let sent_image = image_uri.clone();
1789
+ let mut first_chunk = true;
1790
+ let mut filter = ActionStreamFilter::new();
1791
+
1792
+ let stream_result = orchestrate_chat_stream_with_fallback(
1793
+ &session.config,
1794
+ &ChatRequest {
1795
+ message: message.clone(),
1796
+ system_instruction,
1797
+ chat_id: Some(CHAT_CLI_ID.to_owned()),
1798
+ image_data_uri: image_uri,
1799
+ audio_data_uri: None,
1800
+ document_attachment: None,
1801
+ workspace_path: None,
1802
+ },
1803
+ |chunk| {
1804
+ if first_chunk {
1805
+ first_chunk = false;
1806
+ print!("\r\x1b[2K{MINT}Mint:{RESET} ");
1807
+ }
1808
+ filter.process_chunk(&chunk, |text| {
1809
+ print!("{}", text);
1810
+ });
1811
+ let _ = io::stdout().flush();
1812
+ },
1813
+ )
1814
+ .await;
1815
+
1816
+ let actions = filter.finalize(|text| {
1817
+ print!("{}", text);
1818
+ });
1819
+ let _ = io::stdout().flush();
1820
+
1821
+ match stream_result {
1822
+ Ok((response, fallback)) => {
1823
+ image::save_sent_image_after_send(sent_image.as_deref(), &message);
1824
+ if first_chunk {
1825
+ print!("\r\x1b[2K");
1826
+ let _ = io::stdout().flush();
1827
+ } else {
1828
+ println!("\n");
1829
+ let (tw, _) = crossterm::terminal::size().unwrap_or((80, 24));
1830
+ let width = tw as usize;
1831
+ // Show provider badge (with fallback indicator if applicable)
1832
+ let badge = if let Some(fb_provider) = &fallback {
1833
+ format!(
1834
+ "{DIM}{} • {} → fallback: {} • {}{RESET}",
1835
+ session.config.ai_provider,
1836
+ active_model(&session.config.ai_provider, &session.config),
1837
+ fb_provider,
1838
+ response.model
1839
+ )
1840
+ } else {
1841
+ format!("{DIM}{} • {}{RESET}", response.provider, response.model)
1842
+ };
1843
+ println!("{badge}");
1844
+ println!("{DIM}{}{RESET}\n", "─".repeat(width));
1845
+ }
1846
+ for action in actions {
1847
+ if let Err(e) = execute_action(&action, &session.config) {
1848
+ println!("{ERROR}Error executing action:{RESET} {e}\n");
1849
+ }
1850
+ }
1851
+ }
1852
+ Err(e) => {
1853
+ if first_chunk {
1854
+ print!("\r\x1b[2K");
1855
+ }
1856
+ println!("{ERROR}Error:{RESET} {e}\n");
1857
+ }
1858
+ }
1859
+ } else {
1860
+ print_exit_message(&session);
1861
+ break;
1862
+ }
1863
+ }
1864
+
1865
+ Ok(())
1866
+ }
1867
+
1868
+ fn print_exit_message(session: &InteractiveSession) {
1869
+ println!("\n{MINT}──────────────── Mint session closed ────────────────{RESET}");
1870
+ println!(
1871
+ "{DIM}Provider:{RESET} {} {DIM}• Model:{RESET} {}",
1872
+ session.config.ai_provider,
1873
+ active_model(&session.config.ai_provider, &session.config)
1874
+ );
1875
+ println!(
1876
+ "{DIM}Workspace:{RESET} {}",
1877
+ format_path_with_tilde(&session.current_dir)
1878
+ );
1879
+ println!("{DIM}Saved config stays available for the next Mint run.{RESET}");
1880
+ println!("{MINT}See you next time.{RESET}\n");
1881
+ }
1882
+
1883
+ const AUTOCOMPLETE_COMMANDS: &[(&str, &str)] = &[
1884
+ ("/help", "Show help menu"),
1885
+ ("/fast", "Toggle fast mode (hide thinking traces)"),
1886
+ ("/models", "List AI providers or switch active provider"),
1887
+ ("/clear", "Clear conversation history"),
1888
+ ("/cd", "Change active workspace directory"),
1889
+ ("/image", "Attach image from disk"),
1890
+ ("/paste", "Attach image from clipboard"),
1891
+ ("/learn", "Import persistent skill/instruction"),
1892
+ ("/memory", "Manage long-term memory store"),
1893
+ ("/mcp", "List configured MCP servers"),
1894
+ ("/stats", "Show session statistics"),
1895
+ ("/exit", "Exit Mint CLI"),
1896
+ ("/quit", "Exit Mint CLI"),
1897
+ ("/code", "Run in code-agent mode"),
1898
+ ];
1899
+
1900
+ fn draw_input_box(
1901
+ input: &str,
1902
+ placeholder: &str,
1903
+ model: &str,
1904
+ path_str: &str,
1905
+ tab_base_input: Option<&str>,
1906
+ tab_index: Option<usize>,
1907
+ ) -> usize {
1908
+ let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
1909
+ let width = term_width as usize;
1910
+ let prefix = "› ";
1911
+ let input_width = width.saturating_sub(2);
1912
+ let content_max_len = input_width.saturating_sub(prefix.chars().count());
1913
+
1914
+ let display_str = if input.is_empty() {
1915
+ format!("{DIM}{}\x1b[39m", placeholder)
1916
+ } else {
1917
+ format_placeholders(input)
1918
+ };
1919
+
1920
+ let visible_len = if input.is_empty() {
1921
+ placeholder.chars().count()
1922
+ } else {
1923
+ string_visual_width(input)
1924
+ };
1925
+
1926
+ let pad_len = content_max_len.saturating_sub(visible_len);
1927
+ let padding = " ".repeat(pad_len);
1928
+ let blank_line = " ".repeat(input_width);
1929
+
1930
+ println!(" {COMPOSER_BG}{blank_line}{RESET}");
1931
+ println!(
1932
+ " {COMPOSER_BG}{MINT}{}{RESET}{COMPOSER_BG}{}{}{RESET}",
1933
+ prefix, display_str, padding
1934
+ );
1935
+ println!(" {COMPOSER_BG}{blank_line}{RESET}");
1936
+
1937
+ let agent_str = format!(" {DIM}[Agent]{RESET} {MINT}{}{RESET}", model);
1938
+ let path_label = format!("path: {}", path_str);
1939
+ let agent_visible_len = " [Agent] ".len() + model.chars().count();
1940
+ let path_visible_len = path_label.chars().count();
1941
+
1942
+ let status_pad_len = (width - 1).saturating_sub(agent_visible_len + path_visible_len);
1943
+ let status_padding = " ".repeat(status_pad_len);
1944
+
1945
+ print!(
1946
+ "{}{}{}{}{}",
1947
+ agent_str, status_padding, DIM, path_label, RESET
1948
+ );
1949
+
1950
+ // Compute and draw suggestions
1951
+ let search_query = tab_base_input.unwrap_or(input);
1952
+ let mut match_count = 0;
1953
+ if search_query.starts_with('/') {
1954
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
1955
+ .iter()
1956
+ .filter(|(cmd, _)| cmd.starts_with(search_query))
1957
+ .collect();
1958
+
1959
+ if !matches.is_empty() {
1960
+ match_count = matches.len();
1961
+ println!();
1962
+ println!(" {BLUE}Suggestions{RESET}");
1963
+ let highlight_idx = tab_index.map(|idx| idx % matches.len());
1964
+ for (i, (cmd, desc)) in matches.iter().enumerate() {
1965
+ if Some(i) == highlight_idx {
1966
+ println!(" {BLUE}▶ {:<12}{RESET} {DIM}- {}{RESET}", cmd, desc);
1967
+ } else {
1968
+ println!(" {DIM}{:<12} - {}{RESET}", cmd, desc);
1969
+ }
1970
+ }
1971
+ }
1972
+ }
1973
+
1974
+ let _ = io::stdout().flush();
1975
+ match_count
1976
+ }
1977
+
1978
+ fn input_cursor_column(input_chars: &[char], cursor_pos: usize) -> usize {
1979
+ let visual_cursor_pos: usize = input_chars[..cursor_pos]
1980
+ .iter()
1981
+ .copied()
1982
+ .map(char_visual_width)
1983
+ .sum();
1984
+ 4 + visual_cursor_pos
1985
+ }
1986
+
1987
+ fn position_input_cursor(input_chars: &[char], cursor_pos: usize, match_count: usize) {
1988
+ let up_lines = if match_count > 0 { 4 + match_count } else { 2 };
1989
+ print!(
1990
+ "\x1b[{}A\x1b[{}G",
1991
+ up_lines,
1992
+ input_cursor_column(input_chars, cursor_pos)
1993
+ );
1994
+ }
1995
+
1996
+ fn clear_input_box() {
1997
+ print!("\r\x1b[1A\x1b[J");
1998
+ }
1999
+
2000
+ fn redraw_input_box(
2001
+ input_chars: &[char],
2002
+ cursor_pos: usize,
2003
+ placeholder: &str,
2004
+ model: &str,
2005
+ path_str: &str,
2006
+ tab_base_input: Option<&str>,
2007
+ tab_index: Option<usize>,
2008
+ ) {
2009
+ clear_input_box();
2010
+ let input: String = input_chars.iter().collect();
2011
+ let match_count = draw_input_box(
2012
+ &input,
2013
+ placeholder,
2014
+ model,
2015
+ path_str,
2016
+ tab_base_input,
2017
+ tab_index,
2018
+ );
2019
+ position_input_cursor(input_chars, cursor_pos, match_count);
2020
+ let _ = io::stdout().flush();
2021
+ }
2022
+
2023
+ fn read_line_interactive(
2024
+ _provider: &str,
2025
+ model: &str,
2026
+ path_str: &str,
2027
+ ) -> io::Result<Option<InteractiveInput>> {
2028
+ use crossterm::event::{self, Event, KeyCode};
2029
+ use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
2030
+
2031
+ let mut input_chars: Vec<char> = Vec::new();
2032
+ let mut cursor_pos = 0;
2033
+ let placeholder = "Ask anything...";
2034
+ let mut ctrl_d_pressed = false;
2035
+ let mut pasted_image: Option<String> = None;
2036
+ let mut paste_contents: Vec<(String, String)> = Vec::new();
2037
+ let mut last_paste_time: Option<std::time::Instant> = None;
2038
+
2039
+ // Track tab autocomplete state
2040
+ let mut tab_base_input: Option<String> = None;
2041
+ let mut tab_index: Option<usize> = None;
2042
+
2043
+ let match_count = draw_input_box("", placeholder, model, path_str, None, None);
2044
+ position_input_cursor(&input_chars, cursor_pos, match_count);
2045
+ let _ = io::stdout().flush();
2046
+
2047
+ enable_raw_mode()?;
2048
+ let _ = crossterm::execute!(io::stdout(), crossterm::event::EnableBracketedPaste);
2049
+
2050
+ let result = loop {
2051
+ if event::poll(std::time::Duration::from_millis(100))? {
2052
+ let ev = event::read()?;
2053
+ if let Event::Paste(text) = &ev {
2054
+ let clean_text = text.trim_end_matches(&['\r', '\n'][..]).to_string();
2055
+ let lines_count = clean_text.lines().count();
2056
+ if lines_count > 1 || clean_text.chars().count() > 100 {
2057
+ let paste_id = paste_contents.len() + 1;
2058
+ let placeholder_str = if lines_count > 1 {
2059
+ format!("[Pasted text #{} +{} lines]", paste_id, lines_count - 1)
2060
+ } else {
2061
+ format!("[Pasted text #{}]", paste_id)
2062
+ };
2063
+ paste_contents.push((placeholder_str.clone(), clean_text));
2064
+ for c in placeholder_str.chars() {
2065
+ input_chars.insert(cursor_pos, c);
2066
+ cursor_pos += 1;
2067
+ }
2068
+ } else {
2069
+ for c in clean_text.chars() {
2070
+ input_chars.insert(cursor_pos, c);
2071
+ cursor_pos += 1;
2072
+ }
2073
+ }
2074
+ disable_raw_mode()?;
2075
+ redraw_input_box(
2076
+ &input_chars,
2077
+ cursor_pos,
2078
+ placeholder,
2079
+ model,
2080
+ path_str,
2081
+ None,
2082
+ None,
2083
+ );
2084
+ enable_raw_mode()?;
2085
+ last_paste_time = Some(std::time::Instant::now());
2086
+ continue;
2087
+ }
2088
+ if let Event::Key(key_event) = ev {
2089
+ if key_event.kind == event::KeyEventKind::Press {
2090
+ let is_ctrl_d = matches!(key_event.code, KeyCode::Char('d'))
2091
+ && key_event
2092
+ .modifiers
2093
+ .contains(crossterm::event::KeyModifiers::CONTROL);
2094
+ if ctrl_d_pressed && !is_ctrl_d {
2095
+ ctrl_d_pressed = false;
2096
+ disable_raw_mode()?;
2097
+ print!("\r\x1b[3B\r\x1b[2K\x1b[3A");
2098
+ print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
2099
+ let _ = io::stdout().flush();
2100
+ enable_raw_mode()?;
2101
+ }
2102
+
2103
+ // Reset tab autocomplete state if any key other than Tab, Up, or Down is pressed
2104
+ if key_event.code != KeyCode::Tab
2105
+ && key_event.code != KeyCode::Up
2106
+ && key_event.code != KeyCode::Down
2107
+ {
2108
+ tab_base_input = None;
2109
+ tab_index = None;
2110
+ }
2111
+
2112
+ match key_event.code {
2113
+ KeyCode::Char('d')
2114
+ if key_event
2115
+ .modifiers
2116
+ .contains(crossterm::event::KeyModifiers::CONTROL) =>
2117
+ {
2118
+ if ctrl_d_pressed {
2119
+ disable_raw_mode()?;
2120
+ clear_input_box();
2121
+ let _ = io::stdout().flush();
2122
+ break None;
2123
+ } else {
2124
+ ctrl_d_pressed = true;
2125
+ disable_raw_mode()?;
2126
+ print!(
2127
+ "\r\x1b[3B\r\x1b[2K{WARN}Press Ctrl+D again to exit{RESET}\x1b[3A"
2128
+ );
2129
+ print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
2130
+ let _ = io::stdout().flush();
2131
+ enable_raw_mode()?;
2132
+ }
2133
+ }
2134
+ KeyCode::Char('v')
2135
+ if key_event
2136
+ .modifiers
2137
+ .contains(crossterm::event::KeyModifiers::CONTROL) =>
2138
+ {
2139
+ if let Ok(Some(uri)) = image::read_clipboard_image() {
2140
+ if let Some(ref mut current) = pasted_image {
2141
+ current.push(' ');
2142
+ current.push_str(&uri);
2143
+ } else {
2144
+ pasted_image = Some(uri);
2145
+ }
2146
+ insert_image_placeholder(&mut input_chars, &mut cursor_pos);
2147
+
2148
+ disable_raw_mode()?;
2149
+ redraw_input_box(
2150
+ &input_chars,
2151
+ cursor_pos,
2152
+ placeholder,
2153
+ model,
2154
+ path_str,
2155
+ None,
2156
+ None,
2157
+ );
2158
+ enable_raw_mode()?;
2159
+ }
2160
+ }
2161
+ KeyCode::Char(c) => {
2162
+ let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
2163
+ let max_width = (term_width as usize).saturating_sub(4);
2164
+ let current_visual_width: usize =
2165
+ input_chars.iter().copied().map(char_visual_width).sum();
2166
+
2167
+ if current_visual_width < max_width {
2168
+ input_chars.insert(cursor_pos, c);
2169
+ cursor_pos += 1;
2170
+
2171
+ disable_raw_mode()?;
2172
+ redraw_input_box(
2173
+ &input_chars,
2174
+ cursor_pos,
2175
+ placeholder,
2176
+ model,
2177
+ path_str,
2178
+ None,
2179
+ None,
2180
+ );
2181
+ enable_raw_mode()?;
2182
+ }
2183
+ }
2184
+ KeyCode::Backspace => {
2185
+ if cursor_pos > 0 {
2186
+ cursor_pos -= 1;
2187
+ input_chars.remove(cursor_pos);
2188
+
2189
+ disable_raw_mode()?;
2190
+ redraw_input_box(
2191
+ &input_chars,
2192
+ cursor_pos,
2193
+ placeholder,
2194
+ model,
2195
+ path_str,
2196
+ None,
2197
+ None,
2198
+ );
2199
+ enable_raw_mode()?;
2200
+ }
2201
+ }
2202
+ KeyCode::Tab => {
2203
+ let base = match &tab_base_input {
2204
+ Some(b) => b.clone(),
2205
+ None => {
2206
+ let current_str: String = input_chars.iter().collect();
2207
+ tab_base_input = Some(current_str.clone());
2208
+ current_str
2209
+ }
2210
+ };
2211
+
2212
+ if base.starts_with('/') {
2213
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
2214
+ .iter()
2215
+ .filter(|(cmd, _)| cmd.starts_with(&base))
2216
+ .collect();
2217
+
2218
+ if !matches.is_empty() {
2219
+ let idx = tab_index.unwrap_or(0) % matches.len();
2220
+ let completed = format!("{} ", matches[idx].0);
2221
+ input_chars = completed.chars().collect();
2222
+ cursor_pos = input_chars.len();
2223
+
2224
+ // Highlight currently completed item in suggestions
2225
+ let current_highlight = Some(idx);
2226
+ tab_index = Some(idx + 1);
2227
+
2228
+ disable_raw_mode()?;
2229
+ redraw_input_box(
2230
+ &input_chars,
2231
+ cursor_pos,
2232
+ placeholder,
2233
+ model,
2234
+ path_str,
2235
+ Some(&base),
2236
+ current_highlight,
2237
+ );
2238
+ enable_raw_mode()?;
2239
+ }
2240
+ }
2241
+ }
2242
+ KeyCode::Down => {
2243
+ let base = match &tab_base_input {
2244
+ Some(b) => b.clone(),
2245
+ None => {
2246
+ let current_str: String = input_chars.iter().collect();
2247
+ tab_base_input = Some(current_str.clone());
2248
+ current_str
2249
+ }
2250
+ };
2251
+
2252
+ if base.starts_with('/') {
2253
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
2254
+ .iter()
2255
+ .filter(|(cmd, _)| cmd.starts_with(&base))
2256
+ .collect();
2257
+
2258
+ if !matches.is_empty() {
2259
+ let new_idx = match tab_index {
2260
+ Some(idx) => (idx + 1) % matches.len(),
2261
+ None => 0,
2262
+ };
2263
+ tab_index = Some(new_idx);
2264
+ let completed = format!("{} ", matches[new_idx].0);
2265
+ input_chars = completed.chars().collect();
2266
+ cursor_pos = input_chars.len();
2267
+
2268
+ disable_raw_mode()?;
2269
+ redraw_input_box(
2270
+ &input_chars,
2271
+ cursor_pos,
2272
+ placeholder,
2273
+ model,
2274
+ path_str,
2275
+ Some(&base),
2276
+ Some(new_idx),
2277
+ );
2278
+ enable_raw_mode()?;
2279
+ }
2280
+ }
2281
+ }
2282
+ KeyCode::Up => {
2283
+ let base = match &tab_base_input {
2284
+ Some(b) => b.clone(),
2285
+ None => {
2286
+ let current_str: String = input_chars.iter().collect();
2287
+ tab_base_input = Some(current_str.clone());
2288
+ current_str
2289
+ }
2290
+ };
2291
+
2292
+ if base.starts_with('/') {
2293
+ let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
2294
+ .iter()
2295
+ .filter(|(cmd, _)| cmd.starts_with(&base))
2296
+ .collect();
2297
+
2298
+ if !matches.is_empty() {
2299
+ let new_idx = match tab_index {
2300
+ Some(idx) => {
2301
+ if idx == 0 {
2302
+ matches.len() - 1
2303
+ } else {
2304
+ idx - 1
2305
+ }
2306
+ }
2307
+ None => matches.len() - 1,
2308
+ };
2309
+ tab_index = Some(new_idx);
2310
+ let completed = format!("{} ", matches[new_idx].0);
2311
+ input_chars = completed.chars().collect();
2312
+ cursor_pos = input_chars.len();
2313
+
2314
+ disable_raw_mode()?;
2315
+ redraw_input_box(
2316
+ &input_chars,
2317
+ cursor_pos,
2318
+ placeholder,
2319
+ model,
2320
+ path_str,
2321
+ Some(&base),
2322
+ Some(new_idx),
2323
+ );
2324
+ enable_raw_mode()?;
2325
+ }
2326
+ }
2327
+ }
2328
+ KeyCode::Left => {
2329
+ while cursor_pos > 0 {
2330
+ cursor_pos -= 1;
2331
+ if cursor_pos == 0 || !is_thai_zero_width(input_chars[cursor_pos]) {
2332
+ break;
2333
+ }
2334
+ }
2335
+ disable_raw_mode()?;
2336
+ let visual_cursor_pos: usize = input_chars[..cursor_pos]
2337
+ .iter()
2338
+ .copied()
2339
+ .map(char_visual_width)
2340
+ .sum();
2341
+ print!("\x1b[{}G", 4 + visual_cursor_pos);
2342
+ let _ = io::stdout().flush();
2343
+ enable_raw_mode()?;
2344
+ }
2345
+ KeyCode::Right => {
2346
+ while cursor_pos < input_chars.len() {
2347
+ cursor_pos += 1;
2348
+ if cursor_pos == input_chars.len()
2349
+ || !is_thai_zero_width(input_chars[cursor_pos])
2350
+ {
2351
+ break;
2352
+ }
2353
+ }
2354
+ disable_raw_mode()?;
2355
+ let visual_cursor_pos: usize = input_chars[..cursor_pos]
2356
+ .iter()
2357
+ .copied()
2358
+ .map(char_visual_width)
2359
+ .sum();
2360
+ print!("\x1b[{}G", 4 + visual_cursor_pos);
2361
+ let _ = io::stdout().flush();
2362
+ enable_raw_mode()?;
2363
+ }
2364
+ KeyCode::Enter => {
2365
+ if let Some(time) = last_paste_time {
2366
+ if time.elapsed() < std::time::Duration::from_millis(100) {
2367
+ continue;
2368
+ }
2369
+ }
2370
+ disable_raw_mode()?;
2371
+ clear_input_box();
2372
+ let input_str: String = input_chars.iter().collect();
2373
+
2374
+ let mut expanded_str = input_str.clone();
2375
+ for (placeholder_str, content) in &paste_contents {
2376
+ expanded_str = expanded_str.replace(placeholder_str, content);
2377
+ }
2378
+
2379
+ println!("{BLUE}You ›{RESET} {}", expanded_str);
2380
+ let _ = io::stdout().flush();
2381
+
2382
+ break Some(InteractiveInput {
2383
+ text: expanded_str,
2384
+ pasted_image,
2385
+ });
2386
+ }
2387
+ KeyCode::Esc => {
2388
+ disable_raw_mode()?;
2389
+ clear_input_box();
2390
+ let _ = io::stdout().flush();
2391
+ break None;
2392
+ }
2393
+ _ => {}
2394
+ }
2395
+ }
2396
+ }
2397
+ }
2398
+ };
2399
+
2400
+ let _ = crossterm::execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
2401
+ Ok(result)
2402
+ }
2403
+
2404
+ fn insert_image_placeholder(input_chars: &mut Vec<char>, cursor_pos: &mut usize) {
2405
+ let input: String = input_chars.iter().collect();
2406
+ let mut idx = 1;
2407
+ while input.contains(&format!("[Image #{}]", idx)) {
2408
+ idx += 1;
2409
+ }
2410
+ let placeholder = format!("[Image #{}]", idx);
2411
+ let placeholder_chars = placeholder.chars().collect::<Vec<_>>();
2412
+ input_chars.splice(*cursor_pos..*cursor_pos, placeholder_chars.iter().copied());
2413
+ *cursor_pos += placeholder_chars.len();
2414
+ }
2415
+
2416
+ fn format_path_with_tilde(path: &std::path::Path) -> String {
2417
+ let path_str = path.to_string_lossy().to_string();
2418
+ if let Some(home) = dirs::home_dir() {
2419
+ let home_str = home.to_string_lossy().to_string();
2420
+ if path_str.starts_with(&home_str) {
2421
+ return path_str.replacen(&home_str, "~", 1);
2422
+ }
2423
+ }
2424
+ path_str
2425
+ }
2426
+
2427
+ fn format_placeholders(s: &str) -> String {
2428
+ let mut result = String::new();
2429
+ let chars = s.chars().collect::<Vec<_>>();
2430
+ let mut i = 0;
2431
+ while i < chars.len() {
2432
+ if chars[i] == '[' && i + 12 < chars.len() {
2433
+ let slice: String = chars[i..i + 13].iter().collect();
2434
+ if slice == "[Pasted text #" {
2435
+ if let Some(end_offset) = chars[i..].iter().position(|&c| c == ']') {
2436
+ let end_idx = i + end_offset;
2437
+ let inside: String = chars[i + 1..end_idx].iter().collect();
2438
+ result.push('[');
2439
+ result.push_str(BLUE);
2440
+ result.push_str(&inside);
2441
+ result.push_str("\x1b[39m");
2442
+ result.push(']');
2443
+ i = end_idx + 1;
2444
+ continue;
2445
+ }
2446
+ }
2447
+ }
2448
+ result.push(chars[i]);
2449
+ i += 1;
2450
+ }
2451
+ result
2452
+ }
2453
+
2454
+ fn is_thai_zero_width(c: char) -> bool {
2455
+ let cp = c as u32;
2456
+ cp == 0x0E31 || (0x0E34..=0x0E3A).contains(&cp) || (0x0E47..=0x0E4E).contains(&cp)
2457
+ }
2458
+
2459
+ fn char_visual_width(c: char) -> usize {
2460
+ if is_thai_zero_width(c) { 0 } else { 1 }
2461
+ }
2462
+
2463
+ fn string_visual_width(s: &str) -> usize {
2464
+ s.chars().map(char_visual_width).sum()
2465
+ }
2466
+
2467
+ fn active_model<'a>(provider: &str, config: &'a mint_core::MintConfig) -> &'a str {
2468
+ match provider {
2469
+ "anthropic" => &config.anthropic_model,
2470
+ "openai" => &config.openai_model,
2471
+ "openrouter" => &config.openrouter_model,
2472
+ "deepseek" => &config.deepseek_model,
2473
+ "huggingface" => &config.hf_model,
2474
+ "local_openai" => &config.local_model_name,
2475
+ "ollama" => &config.ollama_model,
2476
+ _ => &config.gemini_model,
2477
+ }
2478
+ }
2479
+
2480
+ fn configured(config: &mint_core::MintConfig, keys: &[&str]) -> bool {
2481
+ keys.iter().all(|key| {
2482
+ config
2483
+ .extra
2484
+ .get(*key)
2485
+ .and_then(serde_json::Value::as_str)
2486
+ .is_some_and(|value| !value.trim().is_empty())
2487
+ })
2488
+ }
2489
+
2490
+ fn edit_content(
2491
+ content: Option<String>,
2492
+ from_file: Option<PathBuf>,
2493
+ config: &MintConfig,
2494
+ ) -> Result<String> {
2495
+ match from_file {
2496
+ Some(path) => {
2497
+ let path = assert_path_capability(&path, Capability::Read, config)?;
2498
+ Ok(fs::read_to_string(path)?)
2499
+ }
2500
+ None => Ok(content.unwrap_or_default()),
2501
+ }
2502
+ }
2503
+
2504
+ fn file_edits(values: &[String], config: &MintConfig) -> Result<Vec<CodeEdit>> {
2505
+ values
2506
+ .iter()
2507
+ .map(|value| {
2508
+ let (target, source) = value
2509
+ .split_once('=')
2510
+ .ok_or_else(|| anyhow::anyhow!("edit must use TARGET=SOURCE format"))?;
2511
+ Ok(CodeEdit {
2512
+ path: PathBuf::from(target),
2513
+ content: edit_content(None, Some(PathBuf::from(source)), config)?,
2514
+ })
2515
+ })
2516
+ .collect()
2517
+ }
2518
+
2519
+ fn open_system_handler(target: &str) -> Result<()> {
2520
+ #[cfg(target_os = "windows")]
2521
+ {
2522
+ std::process::Command::new("cmd")
2523
+ .args(&["/c", "start", "", target])
2524
+ .spawn()?;
2525
+ }
2526
+ #[cfg(target_os = "macos")]
2527
+ {
2528
+ std::process::Command::new("open").arg(target).spawn()?;
2529
+ }
2530
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
2531
+ {
2532
+ std::process::Command::new("xdg-open").arg(target).spawn()?;
2533
+ }
2534
+ Ok(())
2535
+ }
2536
+
2537
+ fn launch_desktop_app(name: &str) -> Result<()> {
2538
+ #[cfg(target_os = "windows")]
2539
+ {
2540
+ std::process::Command::new("cmd")
2541
+ .args(&["/c", "start", "", name])
2542
+ .spawn()?;
2543
+ }
2544
+ #[cfg(target_os = "macos")]
2545
+ {
2546
+ std::process::Command::new("open")
2547
+ .args(&["-a", name])
2548
+ .spawn()?;
2549
+ }
2550
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
2551
+ {
2552
+ if std::process::Command::new(name)
2553
+ .stdin(std::process::Stdio::null())
2554
+ .stdout(std::process::Stdio::null())
2555
+ .stderr(std::process::Stdio::null())
2556
+ .spawn()
2557
+ .is_ok()
2558
+ {
2559
+ return Ok(());
2560
+ }
2561
+ let lower = name.to_lowercase();
2562
+ if std::process::Command::new("gtk-launch")
2563
+ .arg(&lower)
2564
+ .stdin(std::process::Stdio::null())
2565
+ .stdout(std::process::Stdio::null())
2566
+ .stderr(std::process::Stdio::null())
2567
+ .spawn()
2568
+ .is_ok()
2569
+ {
2570
+ return Ok(());
2571
+ }
2572
+ std::process::Command::new("xdg-open").arg(name).spawn()?;
2573
+ }
2574
+ Ok(())
2575
+ }
2576
+
2577
+ fn read_file_content(path: &std::path::Path) -> Result<()> {
2578
+ let content = fs::read_to_string(path)?;
2579
+ println!("{}", content);
2580
+ Ok(())
2581
+ }
2582
+
2583
+ fn read_folder_content(path: &std::path::Path) -> Result<()> {
2584
+ let entries = fs::read_dir(path)?;
2585
+ for entry in entries {
2586
+ let entry = entry?;
2587
+ let file_name = entry.file_name();
2588
+ let file_name_str = file_name.to_string_lossy();
2589
+ let metadata = entry.metadata()?;
2590
+ if metadata.is_dir() {
2591
+ println!("{MINT}{}/{}", file_name_str, RESET);
2592
+ } else {
2593
+ println!("{}", file_name_str);
2594
+ }
2595
+ }
2596
+ Ok(())
2597
+ }
2598
+
2599
+ pub static SESSION_APPROVED: std::sync::atomic::AtomicBool =
2600
+ std::sync::atomic::AtomicBool::new(false);
2601
+
2602
+ pub fn confirm(prompt: &str) -> Result<bool> {
2603
+ let clean_prompt = prompt
2604
+ .replace(" [y/N] ", "")
2605
+ .replace(" [y/N]", "")
2606
+ .trim()
2607
+ .to_string();
2608
+
2609
+ if SESSION_APPROVED.load(std::sync::atomic::Ordering::Relaxed) {
2610
+ println!("{} {MINT}Approve (session-wide){RESET}", clean_prompt);
2611
+ return Ok(true);
2612
+ }
2613
+
2614
+ use crossterm::event::{self, Event, KeyCode};
2615
+ use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
2616
+ use crossterm::tty::IsTty;
2617
+
2618
+ if !io::stdout().is_tty() || enable_raw_mode().is_err() {
2619
+ print!("{} [y/N] ", clean_prompt);
2620
+ let _ = io::stdout().flush();
2621
+ let mut answer = String::new();
2622
+ io::stdin().read_line(&mut answer)?;
2623
+ return Ok(matches!(
2624
+ answer.trim().to_ascii_lowercase().as_str(),
2625
+ "y" | "yes"
2626
+ ));
2627
+ }
2628
+
2629
+ let _ = disable_raw_mode();
2630
+ println!("{}", clean_prompt);
2631
+
2632
+ let options = ["Approve", "Approve this session", "No"];
2633
+ let mut selected = 0;
2634
+
2635
+ let print_choices = |selected: usize| -> Result<()> {
2636
+ for (i, opt) in options.iter().enumerate() {
2637
+ if i == selected {
2638
+ println!(" {BLUE}❯ {}. {}{RESET}", i + 1, opt);
2639
+ } else {
2640
+ println!(" {DIM}{}. {}{RESET}", i + 1, opt);
2641
+ }
2642
+ }
2643
+ io::stdout().flush()?;
2644
+ Ok(())
2645
+ };
2646
+
2647
+ print_choices(selected)?;
2648
+
2649
+ let _ = enable_raw_mode();
2650
+
2651
+ let choice = loop {
2652
+ match event::poll(std::time::Duration::from_millis(100)) {
2653
+ Ok(true) => match event::read() {
2654
+ Ok(Event::Key(key_event)) => {
2655
+ if key_event.kind == event::KeyEventKind::Press {
2656
+ let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
2657
+ && key_event
2658
+ .modifiers
2659
+ .contains(crossterm::event::KeyModifiers::CONTROL);
2660
+ if is_ctrl_c {
2661
+ break 2;
2662
+ }
2663
+
2664
+ match key_event.code {
2665
+ KeyCode::Up => {
2666
+ if selected > 0 {
2667
+ selected -= 1;
2668
+ } else {
2669
+ selected = options.len() - 1;
2670
+ }
2671
+ let _ = disable_raw_mode();
2672
+ print!("\x1b[{}A\x1b[J", options.len());
2673
+ let _ = print_choices(selected);
2674
+ let _ = enable_raw_mode();
2675
+ }
2676
+ KeyCode::Down => {
2677
+ if selected < options.len() - 1 {
2678
+ selected += 1;
2679
+ } else {
2680
+ selected = 0;
2681
+ }
2682
+ let _ = disable_raw_mode();
2683
+ print!("\x1b[{}A\x1b[J", options.len());
2684
+ let _ = print_choices(selected);
2685
+ let _ = enable_raw_mode();
2686
+ }
2687
+ KeyCode::Tab => {
2688
+ if selected < options.len() - 1 {
2689
+ selected += 1;
2690
+ } else {
2691
+ selected = 0;
2692
+ }
2693
+ let _ = disable_raw_mode();
2694
+ print!("\x1b[{}A\x1b[J", options.len());
2695
+ let _ = print_choices(selected);
2696
+ let _ = enable_raw_mode();
2697
+ }
2698
+ KeyCode::Char('1') | KeyCode::Char('a') | KeyCode::Char('y') => {
2699
+ break 0;
2700
+ }
2701
+ KeyCode::Char('2') | KeyCode::Char('s') => {
2702
+ break 1;
2703
+ }
2704
+ KeyCode::Char('3') | KeyCode::Char('n') | KeyCode::Char('c') => {
2705
+ break 2;
2706
+ }
2707
+ KeyCode::Enter => {
2708
+ break selected;
2709
+ }
2710
+ KeyCode::Esc => {
2711
+ break 2;
2712
+ }
2713
+ _ => {}
2714
+ }
2715
+ }
2716
+ }
2717
+ Ok(_) => {}
2718
+ Err(_) => {
2719
+ break 2;
2720
+ }
2721
+ },
2722
+ Ok(false) => {}
2723
+ Err(_) => {
2724
+ break 2;
2725
+ }
2726
+ }
2727
+ };
2728
+
2729
+ let _ = disable_raw_mode();
2730
+ print!("\x1b[{}A\x1b[J", options.len() + 1);
2731
+
2732
+ let result_str = match choice {
2733
+ 0 => format!("{MINT}Approve{RESET}"),
2734
+ 1 => format!("{MINT}Approve this session{RESET}"),
2735
+ _ => format!("{ERROR}No{RESET}"),
2736
+ };
2737
+ println!("{} {}", clean_prompt, result_str);
2738
+ let _ = io::stdout().flush();
2739
+
2740
+ match choice {
2741
+ 0 => Ok(true),
2742
+ 1 => {
2743
+ SESSION_APPROVED.store(true, std::sync::atomic::Ordering::Relaxed);
2744
+ Ok(true)
2745
+ }
2746
+ _ => Ok(false),
2747
+ }
2748
+ }
2749
+
2750
+ fn confirm_shell_execution() -> Result<bool> {
2751
+ confirm("Approve local shell execution? [y/N]")
2752
+ }
2753
+
2754
+ fn print_shell_output(output: &mint_core::ShellOutput) {
2755
+ if !output.stdout.is_empty() {
2756
+ print!("{}", output.stdout);
2757
+ }
2758
+ if !output.stderr.is_empty() {
2759
+ eprint!("{}", output.stderr);
2760
+ }
2761
+ if !output.stdout.ends_with('\n') && !output.stderr.ends_with('\n') {
2762
+ println!();
2763
+ }
2764
+ println!(
2765
+ "{DIM}[exit: {} | sandboxed: {}]{RESET}\n",
2766
+ output
2767
+ .status
2768
+ .map_or_else(|| "unknown".into(), |status| status.to_string()),
2769
+ output.sandboxed
2770
+ );
2771
+ }
2772
+
2773
+ async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
2774
+ let Some((owner, repo_name)) = parse_github_url(repo) else {
2775
+ anyhow::bail!(
2776
+ "Invalid GitHub repository URL/format. Please use 'owner/repo' or a full GitHub URL."
2777
+ );
2778
+ };
2779
+
2780
+ println!(
2781
+ "Fetching information for {}/{} from GitHub...",
2782
+ owner, repo_name
2783
+ );
2784
+ let summary = match fetch_github_repo_summary(&owner, &repo_name).await {
2785
+ Ok(s) => s,
2786
+ Err(e) => {
2787
+ anyhow::bail!(
2788
+ "Failed to fetch repository summary: {}. Check that the repository is public and spelled correctly.",
2789
+ e
2790
+ );
2791
+ }
2792
+ };
2793
+
2794
+ println!("Analyzing repository with AI model...");
2795
+ let prompt = format!(
2796
+ "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.",
2797
+ owner, repo_name, summary
2798
+ );
2799
+
2800
+ let (response, _) = orchestrate_chat_with_fallback(
2801
+ config,
2802
+ &ChatRequest {
2803
+ message: prompt,
2804
+ system_instruction: "You are a professional software architect providing a high-level overview of a code repository based on its metadata and README.".to_string(),
2805
+ chat_id: Some("github_review".to_string()),
2806
+ image_data_uri: None,
2807
+ audio_data_uri: None,
2808
+ document_attachment: None,
2809
+ workspace_path: None,
2810
+ },
2811
+ )
2812
+ .await?;
2813
+
2814
+ println!(
2815
+ "\n--- AI Repository Overview for {}/{} ---",
2816
+ owner, repo_name
2817
+ );
2818
+ println!("{}", response.text);
2819
+ println!("--------------------------------------------------");
2820
+ Ok(())
2821
+ }
2822
+
2823
+ #[cfg(test)]
2824
+ mod tests {
2825
+ use super::*;
2826
+
2827
+ #[test]
2828
+ fn inserts_one_image_placeholder_at_cursor() {
2829
+ let mut chars = "ask ".chars().collect::<Vec<_>>();
2830
+ let mut cursor = chars.len();
2831
+
2832
+ insert_image_placeholder(&mut chars, &mut cursor);
2833
+
2834
+ assert_eq!(chars.iter().collect::<String>(), "ask [Image #1]");
2835
+ assert_eq!(cursor, "ask [Image #1]".chars().count());
2836
+ }
2837
+ }