@pheem49/mint 1.8.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +55 -3
- package/Cargo.toml +2 -1
- package/Release_Note.md +297 -107
- package/bin/mint +0 -0
- package/crates/mint-cli/Cargo.toml +1 -0
- package/crates/mint-cli/src/actions.rs +233 -0
- package/crates/mint-cli/src/agent.rs +382 -127
- package/crates/mint-cli/src/gmail.rs +22 -2
- package/crates/mint-cli/src/image.rs +8 -8
- package/crates/mint-cli/src/interactive.rs +2888 -0
- package/crates/mint-cli/src/main.rs +391 -2506
- package/crates/mint-cli/src/markdown.rs +294 -0
- package/crates/mint-cli/src/onboard.rs +152 -109
- package/crates/mint-cli/src/setup.rs +143 -143
- package/crates/mint-cli/src/updater.rs +48 -6
- package/crates/mint-core/Cargo.toml +1 -0
- package/crates/mint-core/src/agent_loop.rs +1 -0
- package/crates/mint-core/src/api_server.rs +431 -65
- package/crates/mint-core/src/browser.rs +630 -102
- package/crates/mint-core/src/channels.rs +105 -6
- package/crates/mint-core/src/chat.rs +174 -11
- package/crates/mint-core/src/code_tools.rs +47 -31
- package/crates/mint-core/src/config.rs +302 -17
- package/crates/mint-core/src/image_gen.rs +299 -38
- package/crates/mint-core/src/lib.rs +10 -7
- package/crates/mint-core/src/mcp.rs +119 -6
- package/crates/mint-core/src/memory.rs +6 -1
- package/crates/mint-core/src/orchestration.rs +237 -96
- package/crates/mint-core/src/pictures.rs +67 -5
- package/crates/mint-core/src/semantic.rs +1 -1
- package/crates/mint-core/src/shell.rs +1 -1
- package/crates/mint-core/src/skills.rs +128 -25
- package/crates/mint-core/src/video_gen.rs +235 -0
- package/package.json +8 -2
- package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
- package/shared-modules/linux-audio/fluidsynth2.json +8 -2
- package/shared-modules/linux-audio/lilv.json +26 -19
- package/shared-modules/linux-audio/lv2.json +0 -1
- package/shared-modules/luajit/luajit.json +2 -2
- package/skills-lock.json +41 -0
- package/src/renderer/index-web.html +1 -1
- package/src/renderer/index.html +1 -1
- package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
- package/src/renderer/shared/constants/slashCommands.ts +27 -0
- package/src/renderer/shared/platform.ts +3 -0
- package/src/renderer/shared/types.ts +4 -0
- package/src/renderer/shared/utils/approval.tsx +86 -7
- package/src/renderer/shared/utils/fileIcons.ts +135 -0
- package/src/renderer/shared/utils/markdown.tsx +217 -88
- package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
- package/src/renderer/src/components/ChatPanel.tsx +486 -69
- package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
- package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
- package/src/renderer/src/components/MintDashboard.tsx +195 -7
- package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
- package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
- package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
- package/src/renderer/src/components/Settings/GeneralTab.tsx +795 -413
- package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
- package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
- package/src/renderer/src/components/SettingsWindow.tsx +106 -3
- package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
- package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
- package/src/renderer/src/css/image-studio.css +106 -0
- package/src/renderer/src/css/settings.css +158 -0
- package/src/renderer/src/css/styles.css +368 -1
- package/src/renderer/src/css/veo-studio.css +626 -0
- package/src/renderer/src/index.css +1 -0
- package/src/renderer/src/tauri.ts +144 -17
- package/src/renderer/src-web/components/ChatPanel.tsx +385 -93
- package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
- package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
- package/src/renderer/src-web/components/MintDashboard.tsx +156 -5
- package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
- package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
- package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
- package/src/renderer/src-web/components/Settings/GeneralTab.tsx +795 -411
- package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
- package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
- package/src/renderer/src-web/components/SettingsWindow.tsx +110 -3
- package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
- package/src/renderer/src-web/css/image-studio.css +106 -0
- package/src/renderer/src-web/css/settings.css +158 -0
- package/src/renderer/src-web/css/styles.css +369 -1
- package/src/renderer/src-web/css/veo-studio.css +650 -0
- package/src/renderer/src-web/index.css +1 -0
- package/src/renderer/src-web/tauri.ts +135 -16
- package/src-tauri/src/desktop.rs +18 -16
- package/src-tauri/src/discord_rpc.rs +1 -1
- package/src-tauri/src/lib.rs +155 -25
- package/src-tauri/src/webhooks.rs +45 -31
- package/src-tauri/src/workflows.rs +1 -0
- package/src-tauri/tauri.conf.json +1 -1
- package/temp_gridgeist/AGENTS.md +8 -0
- package/temp_gridgeist/CHANGELOG.md +130 -0
- package/temp_gridgeist/LICENSE +21 -0
- package/temp_gridgeist/README.md +323 -0
- package/temp_gridgeist/README.th.md +325 -0
- package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
- package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
- package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
- package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
- package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
- package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
- package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
- package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
|
@@ -0,0 +1,2888 @@
|
|
|
1
|
+
use crate::{BLUE, COMPOSER_BG, DIM, ERROR, MINT, RESET, WARN};
|
|
2
|
+
use crate::{actions, agent, image, markdown};
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
use mint_core::{
|
|
5
|
+
CHAT_CLI_ID, ChatRequest, MemoryStore, MintConfig, orchestrate_chat_stream_with_fallback,
|
|
6
|
+
};
|
|
7
|
+
use std::fs;
|
|
8
|
+
use std::io::{self, Write};
|
|
9
|
+
use std::path::{Path, PathBuf};
|
|
10
|
+
|
|
11
|
+
pub static SESSION_APPROVED: std::sync::atomic::AtomicBool =
|
|
12
|
+
std::sync::atomic::AtomicBool::new(false);
|
|
13
|
+
|
|
14
|
+
pub struct InteractiveSession {
|
|
15
|
+
pub config: MintConfig,
|
|
16
|
+
pub current_dir: PathBuf,
|
|
17
|
+
pub fast_mode: bool,
|
|
18
|
+
pub pending_image: Option<String>, // base64 data URI
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
pub struct InteractiveInput {
|
|
22
|
+
pub text: String,
|
|
23
|
+
pub pasted_image: Option<String>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// What the slash-command router wants the loop to do next.
|
|
27
|
+
pub enum SlashResult {
|
|
28
|
+
/// Command handled — continue loop without sending to agent.
|
|
29
|
+
Handled,
|
|
30
|
+
/// Pass this (possibly modified) query to the agent.
|
|
31
|
+
ForwardToAgent(String),
|
|
32
|
+
/// Break out of the loop.
|
|
33
|
+
Exit,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn parse_path_and_prompt(rest: &str) -> (String, String) {
|
|
37
|
+
let rest = rest.trim();
|
|
38
|
+
if rest.is_empty() {
|
|
39
|
+
return (String::new(), String::new());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let mut chars = rest.chars();
|
|
43
|
+
if let Some(first) = chars.next() {
|
|
44
|
+
if first == '"' || first == '\'' {
|
|
45
|
+
let quote = first;
|
|
46
|
+
if let Some(end_idx) = rest[1..].find(quote) {
|
|
47
|
+
let path = rest[1..1 + end_idx].to_string();
|
|
48
|
+
let prompt = rest[1 + end_idx + 1..].trim().to_string();
|
|
49
|
+
return (path, prompt);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if let Some((path, prompt)) = rest.split_once(char::is_whitespace) {
|
|
55
|
+
(path.to_string(), prompt.trim().to_string())
|
|
56
|
+
} else {
|
|
57
|
+
(rest.to_string(), String::new())
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Route `/…` commands. Returns `None` if the input is not a slash command.
|
|
62
|
+
pub async fn handle_slash_command(
|
|
63
|
+
session: &mut InteractiveSession,
|
|
64
|
+
query: &str,
|
|
65
|
+
) -> Option<SlashResult> {
|
|
66
|
+
let trimmed = query.trim();
|
|
67
|
+
if !trimmed.starts_with('/') {
|
|
68
|
+
return None;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Split into command word and optional rest
|
|
72
|
+
let (cmd, rest) = trimmed
|
|
73
|
+
.split_once(char::is_whitespace)
|
|
74
|
+
.map(|(c, r)| (c, r.trim()))
|
|
75
|
+
.unwrap_or((trimmed, ""));
|
|
76
|
+
|
|
77
|
+
match cmd {
|
|
78
|
+
"/help" => {
|
|
79
|
+
println!("\n{BLUE}────────────────────────────────────────────{RESET}");
|
|
80
|
+
println!("{MINT} Mint Interactive Commands{RESET}");
|
|
81
|
+
println!("{BLUE}────────────────────────────────────────────{RESET}");
|
|
82
|
+
let commands = [
|
|
83
|
+
("/help", "Show this help"),
|
|
84
|
+
("/fast [on|off]", "Toggle fast mode (hide thinking traces)"),
|
|
85
|
+
("/models [name]", "List providers or switch provider"),
|
|
86
|
+
("/clear", "Clear conversation history"),
|
|
87
|
+
("/cd <path>", "Change workspace directory"),
|
|
88
|
+
("/image <path> [prompt]", "Attach image from file"),
|
|
89
|
+
("/paste [prompt]", "Attach image from clipboard"),
|
|
90
|
+
("Ctrl+V", "Paste clipboard image as [Image #1]"),
|
|
91
|
+
("/learn <path>", "Import a persistent .md or .txt skill"),
|
|
92
|
+
(
|
|
93
|
+
"/skill add <path>",
|
|
94
|
+
"Copy/install skill file or folder to global config",
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"/plugins <name> [prompt]",
|
|
98
|
+
"Generate a skill.md file for a plugin/skill",
|
|
99
|
+
),
|
|
100
|
+
("/memory list", "Show recent interactions"),
|
|
101
|
+
("/memory clear", "Clear all interactions"),
|
|
102
|
+
("/memory get <key>", "Read a profile value"),
|
|
103
|
+
("/memory set <key> <val>", "Store a profile value"),
|
|
104
|
+
(
|
|
105
|
+
"/image-provider [name]",
|
|
106
|
+
"List image gen providers or switch default provider",
|
|
107
|
+
),
|
|
108
|
+
("/veo <prompt>", "Generate video using Google Veo"),
|
|
109
|
+
(
|
|
110
|
+
"/video-provider [name]",
|
|
111
|
+
"List video gen providers or switch default provider",
|
|
112
|
+
),
|
|
113
|
+
("/mcp list", "List configured MCP servers"),
|
|
114
|
+
("/mcp allow <server> <tool>", "Allow an MCP tool"),
|
|
115
|
+
("/stats", "Show session statistics"),
|
|
116
|
+
("/exit", "Exit Mint"),
|
|
117
|
+
("/code <task>", "Run in code-agent mode"),
|
|
118
|
+
(
|
|
119
|
+
"/multi-agent [on|off]",
|
|
120
|
+
"Show or toggle Multi-Agent Collaboration system",
|
|
121
|
+
),
|
|
122
|
+
];
|
|
123
|
+
for (cmd_name, desc) in &commands {
|
|
124
|
+
println!(" {MINT}{:<30}{RESET} {DIM}{}{RESET}", cmd_name, desc);
|
|
125
|
+
}
|
|
126
|
+
println!();
|
|
127
|
+
Some(SlashResult::Handled)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
"/fast" => {
|
|
131
|
+
let choice = if rest.is_empty() {
|
|
132
|
+
let options = vec![
|
|
133
|
+
"on (hide thinking traces)".to_string(),
|
|
134
|
+
"off (show thinking traces)".to_string(),
|
|
135
|
+
];
|
|
136
|
+
let current = if session.fast_mode {
|
|
137
|
+
&options[0]
|
|
138
|
+
} else {
|
|
139
|
+
&options[1]
|
|
140
|
+
};
|
|
141
|
+
match prompt_interactive_select("Select Fast Mode", &options, current) {
|
|
142
|
+
Ok(Some(sel)) => {
|
|
143
|
+
if sel.starts_with("on") {
|
|
144
|
+
Some(true)
|
|
145
|
+
} else {
|
|
146
|
+
Some(false)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
Ok(None) => None,
|
|
150
|
+
Err(e) => {
|
|
151
|
+
println!("{ERROR}Error selecting fast mode:{RESET} {e}\n");
|
|
152
|
+
None
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
match rest {
|
|
157
|
+
"on" => Some(true),
|
|
158
|
+
"off" => Some(false),
|
|
159
|
+
_ => {
|
|
160
|
+
println!("{WARN}/fast usage: /fast [on|off]{RESET}\n");
|
|
161
|
+
None
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if let Some(mode) = choice {
|
|
167
|
+
session.fast_mode = mode;
|
|
168
|
+
if session.fast_mode {
|
|
169
|
+
println!("{DIM}[Fast] mode ON — thinking traces hidden{RESET}\n");
|
|
170
|
+
} else {
|
|
171
|
+
println!("{DIM}[Fast] mode OFF{RESET}\n");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
Some(SlashResult::Handled)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
"/models" => {
|
|
178
|
+
let selected_provider = if rest.is_empty() {
|
|
179
|
+
let providers = session.config.available_providers();
|
|
180
|
+
match prompt_interactive_select(
|
|
181
|
+
"Select AI provider",
|
|
182
|
+
&providers,
|
|
183
|
+
&session.config.ai_provider,
|
|
184
|
+
) {
|
|
185
|
+
Ok(Some(p)) => Some(p),
|
|
186
|
+
Ok(None) => {
|
|
187
|
+
println!("Cancelled provider selection.\n");
|
|
188
|
+
None
|
|
189
|
+
}
|
|
190
|
+
Err(e) => {
|
|
191
|
+
println!("{ERROR}Error selecting provider:{RESET} {e}\n");
|
|
192
|
+
None
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
Some(rest.to_owned())
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if let Some(provider) = selected_provider {
|
|
200
|
+
match session.config.set_active_model(&provider, None) {
|
|
201
|
+
Ok(display_name) => {
|
|
202
|
+
println!(
|
|
203
|
+
"\n{DIM}───{RESET} {MINT}{}{RESET} {DIM}───{RESET}\n",
|
|
204
|
+
display_name
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
Err(error) => println!("{ERROR}Config error:{RESET} {error}"),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
Some(SlashResult::Handled)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
"/multi-agent" => {
|
|
214
|
+
if rest == "on" || rest == "off" {
|
|
215
|
+
session.config.enable_agent_collaboration = rest == "on";
|
|
216
|
+
match mint_core::save_config(&session.config) {
|
|
217
|
+
Ok(()) => println!(
|
|
218
|
+
"{DIM}Multi-Agent collaboration set to: {}{RESET}\n",
|
|
219
|
+
if session.config.enable_agent_collaboration {
|
|
220
|
+
"Enabled"
|
|
221
|
+
} else {
|
|
222
|
+
"Disabled"
|
|
223
|
+
}
|
|
224
|
+
),
|
|
225
|
+
Err(error) => println!("{ERROR}Config error:{RESET} {error}\n"),
|
|
226
|
+
}
|
|
227
|
+
} else if !rest.is_empty() {
|
|
228
|
+
println!("{WARN}Usage: /multi-agent [on|off]{RESET}\n");
|
|
229
|
+
} else {
|
|
230
|
+
println!("\n{BLUE}Multi-Agent System Settings:{RESET}");
|
|
231
|
+
let collab_status = if session.config.enable_agent_collaboration {
|
|
232
|
+
format!("{MINT}Enabled (on){RESET}")
|
|
233
|
+
} else {
|
|
234
|
+
format!("{DIM}Disabled (off){RESET}")
|
|
235
|
+
};
|
|
236
|
+
println!(" Global Collaboration: {collab_status}");
|
|
237
|
+
println!("\n{BLUE}Configured Agents:{RESET}");
|
|
238
|
+
if session.config.agents.is_empty() {
|
|
239
|
+
println!(" No agents configured.");
|
|
240
|
+
} else {
|
|
241
|
+
for agent in &session.config.agents {
|
|
242
|
+
let status_label = if agent.enabled {
|
|
243
|
+
format!("{MINT}[Enabled]{RESET}")
|
|
244
|
+
} else {
|
|
245
|
+
format!("{DIM}[Disabled]{RESET}")
|
|
246
|
+
};
|
|
247
|
+
println!(
|
|
248
|
+
" {:<15} {} Provider: {:<12} Model: {}",
|
|
249
|
+
agent.name, status_label, agent.provider, agent.model
|
|
250
|
+
);
|
|
251
|
+
let truncated_instruction = if agent.system_instruction.len() > 60 {
|
|
252
|
+
format!("{}...", &agent.system_instruction[..60].replace('\n', " "))
|
|
253
|
+
} else {
|
|
254
|
+
agent.system_instruction.replace('\n', " ")
|
|
255
|
+
};
|
|
256
|
+
println!(" {DIM}Instruction: {truncated_instruction}{RESET}");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
println!();
|
|
260
|
+
|
|
261
|
+
let options = vec![
|
|
262
|
+
"Keep current status".to_string(),
|
|
263
|
+
"on (enable collaboration)".to_string(),
|
|
264
|
+
"off (disable collaboration)".to_string(),
|
|
265
|
+
];
|
|
266
|
+
let current = &options[0];
|
|
267
|
+
match prompt_interactive_select("Toggle Collaboration Status?", &options, current) {
|
|
268
|
+
Ok(Some(sel)) => {
|
|
269
|
+
if sel.starts_with("on") {
|
|
270
|
+
session.config.enable_agent_collaboration = true;
|
|
271
|
+
match mint_core::save_config(&session.config) {
|
|
272
|
+
Ok(()) => println!(
|
|
273
|
+
"{DIM}Multi-Agent collaboration set to: Enabled{RESET}\n"
|
|
274
|
+
),
|
|
275
|
+
Err(error) => println!("{ERROR}Config error:{RESET} {error}\n"),
|
|
276
|
+
}
|
|
277
|
+
} else if sel.starts_with("off") {
|
|
278
|
+
session.config.enable_agent_collaboration = false;
|
|
279
|
+
match mint_core::save_config(&session.config) {
|
|
280
|
+
Ok(()) => println!(
|
|
281
|
+
"{DIM}Multi-Agent collaboration set to: Disabled{RESET}\n"
|
|
282
|
+
),
|
|
283
|
+
Err(error) => println!("{ERROR}Config error:{RESET} {error}\n"),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
_ => {}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
Some(SlashResult::Handled)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
"/image-provider" => {
|
|
294
|
+
let mut available = Vec::new();
|
|
295
|
+
if !session.config.api_key.trim().is_empty() {
|
|
296
|
+
available.push("nanobanana");
|
|
297
|
+
}
|
|
298
|
+
if !session.config.openai_api_key.trim().is_empty() {
|
|
299
|
+
available.push("dalle");
|
|
300
|
+
}
|
|
301
|
+
if !session.config.stability_api_key.trim().is_empty() {
|
|
302
|
+
available.push("stability");
|
|
303
|
+
}
|
|
304
|
+
if !session.config.ideogram_api_key.trim().is_empty() {
|
|
305
|
+
available.push("ideogram");
|
|
306
|
+
}
|
|
307
|
+
if !session.config.replicate_api_key.trim().is_empty() {
|
|
308
|
+
available.push("replicate");
|
|
309
|
+
}
|
|
310
|
+
if available.is_empty() {
|
|
311
|
+
available.push("nanobanana");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let selected_provider = if rest.is_empty() {
|
|
315
|
+
let options: Vec<String> = available.iter().map(|s| s.to_string()).collect();
|
|
316
|
+
match prompt_interactive_select(
|
|
317
|
+
"Select Image Generation provider",
|
|
318
|
+
&options,
|
|
319
|
+
&session.config.image_gen_provider,
|
|
320
|
+
) {
|
|
321
|
+
Ok(Some(p)) => Some(p),
|
|
322
|
+
Ok(None) => {
|
|
323
|
+
println!("Cancelled image provider selection.\n");
|
|
324
|
+
None
|
|
325
|
+
}
|
|
326
|
+
Err(e) => {
|
|
327
|
+
println!("{ERROR}Error selecting provider:{RESET} {e}\n");
|
|
328
|
+
None
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
if available.contains(&rest) {
|
|
333
|
+
Some(rest.to_owned())
|
|
334
|
+
} else {
|
|
335
|
+
println!("{ERROR}Provider '{rest}' is not configured or invalid.{RESET}\n");
|
|
336
|
+
None
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if let Some(provider) = selected_provider {
|
|
341
|
+
session.config.image_gen_provider = provider;
|
|
342
|
+
match mint_core::save_config(&session.config) {
|
|
343
|
+
Ok(()) => println!(
|
|
344
|
+
"{DIM}Switched default image provider to: {}{RESET}\n",
|
|
345
|
+
session.config.image_gen_provider
|
|
346
|
+
),
|
|
347
|
+
Err(error) => println!("{ERROR}Config error:{RESET} {error}"),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
Some(SlashResult::Handled)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
"/video-provider" => {
|
|
354
|
+
let mut available = Vec::new();
|
|
355
|
+
if !session.config.api_key.trim().is_empty() || std::env::var("GEMINI_API_KEY").is_ok()
|
|
356
|
+
{
|
|
357
|
+
available.push("veo");
|
|
358
|
+
}
|
|
359
|
+
if available.is_empty() {
|
|
360
|
+
available.push("veo");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let current_provider = session
|
|
364
|
+
.config
|
|
365
|
+
.extra
|
|
366
|
+
.get("videoGenProvider")
|
|
367
|
+
.and_then(|v| v.as_str())
|
|
368
|
+
.unwrap_or("veo")
|
|
369
|
+
.to_string();
|
|
370
|
+
|
|
371
|
+
let selected_provider = if rest.is_empty() {
|
|
372
|
+
let options = vec!["Google Veo (Gemini Videos)".to_string()];
|
|
373
|
+
let current_display = if current_provider == "veo" {
|
|
374
|
+
"Google Veo (Gemini Videos)"
|
|
375
|
+
} else {
|
|
376
|
+
¤t_provider
|
|
377
|
+
};
|
|
378
|
+
match prompt_interactive_select(
|
|
379
|
+
"Select Video Generation provider",
|
|
380
|
+
&options,
|
|
381
|
+
current_display,
|
|
382
|
+
) {
|
|
383
|
+
Ok(Some(p)) => {
|
|
384
|
+
if p == "Google Veo (Gemini Videos)" {
|
|
385
|
+
Some("veo".to_string())
|
|
386
|
+
} else {
|
|
387
|
+
Some(p)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
Ok(None) => {
|
|
391
|
+
println!("Cancelled video provider selection.\n");
|
|
392
|
+
None
|
|
393
|
+
}
|
|
394
|
+
Err(e) => {
|
|
395
|
+
println!("{ERROR}Error selecting provider:{RESET} {e}\n");
|
|
396
|
+
None
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
if rest == "veo" || rest == "Google Veo (Gemini Videos)" {
|
|
401
|
+
Some("veo".to_string())
|
|
402
|
+
} else {
|
|
403
|
+
println!("{ERROR}Provider '{rest}' is not configured or invalid.{RESET}\n");
|
|
404
|
+
None
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if let Some(provider) = selected_provider {
|
|
409
|
+
session.config.extra.insert(
|
|
410
|
+
"videoGenProvider".to_string(),
|
|
411
|
+
serde_json::Value::String(provider.clone()),
|
|
412
|
+
);
|
|
413
|
+
match mint_core::save_config(&session.config) {
|
|
414
|
+
Ok(()) => {
|
|
415
|
+
let display_name = if provider == "veo" {
|
|
416
|
+
"Google Veo (Gemini Videos)"
|
|
417
|
+
} else {
|
|
418
|
+
&provider
|
|
419
|
+
};
|
|
420
|
+
println!(
|
|
421
|
+
"{DIM}Switched default video provider to: {}{RESET}\n",
|
|
422
|
+
display_name
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
Err(error) => println!("{ERROR}Config error:{RESET} {error}"),
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
Some(SlashResult::Handled)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
"/clear" | "/reset" => {
|
|
432
|
+
let options = vec![
|
|
433
|
+
"No (keep history)".to_string(),
|
|
434
|
+
"Yes (clear history)".to_string(),
|
|
435
|
+
];
|
|
436
|
+
let choice = match prompt_interactive_select(
|
|
437
|
+
"Clear conversation history?",
|
|
438
|
+
&options,
|
|
439
|
+
&options[0],
|
|
440
|
+
) {
|
|
441
|
+
Ok(Some(sel)) => sel == options[1],
|
|
442
|
+
_ => false,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
if choice {
|
|
446
|
+
if let Ok(memory) = MemoryStore::open_default() {
|
|
447
|
+
match memory.clear_interactions() {
|
|
448
|
+
Ok(count) => println!("{DIM}Cleared {count} interactions.{RESET}"),
|
|
449
|
+
Err(error) => println!("{ERROR}Memory error:{RESET} {error}"),
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
println!("{DIM}Conversation context cleared.{RESET}\n");
|
|
453
|
+
} else {
|
|
454
|
+
println!("{DIM}Cancelled.{RESET}\n");
|
|
455
|
+
}
|
|
456
|
+
Some(SlashResult::Handled)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
"/cd" => {
|
|
460
|
+
if rest.is_empty() {
|
|
461
|
+
println!("{WARN}/cd requires a path{RESET}\n");
|
|
462
|
+
} else {
|
|
463
|
+
let new_dir = PathBuf::from(rest);
|
|
464
|
+
if new_dir.is_dir() {
|
|
465
|
+
session.current_dir = new_dir.canonicalize().unwrap_or(new_dir);
|
|
466
|
+
println!(
|
|
467
|
+
"{DIM}Workspace: {}{RESET}\n",
|
|
468
|
+
format_path_with_tilde(&session.current_dir)
|
|
469
|
+
);
|
|
470
|
+
} else {
|
|
471
|
+
println!("{ERROR}Directory not found:{RESET} {rest}\n");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
Some(SlashResult::Handled)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
"/veo" => {
|
|
478
|
+
if rest.is_empty() {
|
|
479
|
+
println!(
|
|
480
|
+
"{WARN}Usage: /veo <prompt> [--aspect <ratio>] [--duration <secs>]{RESET}\n"
|
|
481
|
+
);
|
|
482
|
+
} else {
|
|
483
|
+
let mut prompt = rest.to_string();
|
|
484
|
+
let mut aspect = "16:9".to_string();
|
|
485
|
+
let mut duration = 5;
|
|
486
|
+
|
|
487
|
+
// Simple flag parsing
|
|
488
|
+
if let Some(pos) = prompt.find("--aspect") {
|
|
489
|
+
let rest_str = prompt[pos..].to_string();
|
|
490
|
+
let mut parts = rest_str.split_whitespace();
|
|
491
|
+
parts.next(); // Skip --aspect
|
|
492
|
+
if let Some(val) = parts.next() {
|
|
493
|
+
aspect = val.to_string();
|
|
494
|
+
}
|
|
495
|
+
prompt = prompt[..pos].trim().to_string();
|
|
496
|
+
}
|
|
497
|
+
if let Some(pos) = prompt.find("--duration") {
|
|
498
|
+
let rest_str = prompt[pos..].to_string();
|
|
499
|
+
let mut parts = rest_str.split_whitespace();
|
|
500
|
+
parts.next(); // Skip --duration
|
|
501
|
+
if let Some(val) = parts.next() {
|
|
502
|
+
if let Ok(parsed) = val.parse::<u32>() {
|
|
503
|
+
duration = parsed;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
prompt = prompt[..pos].trim().to_string();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
use indicatif::{ProgressBar, ProgressStyle};
|
|
510
|
+
let spinner = ProgressBar::new_spinner();
|
|
511
|
+
spinner.set_style(
|
|
512
|
+
ProgressStyle::default_spinner()
|
|
513
|
+
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
|
|
514
|
+
.template("{spinner:.magenta} {msg}")
|
|
515
|
+
.unwrap(),
|
|
516
|
+
);
|
|
517
|
+
let gen_request = mint_core::VideoGenRequest {
|
|
518
|
+
prompt: prompt.clone(),
|
|
519
|
+
negative_prompt: None,
|
|
520
|
+
aspect_ratio: aspect.clone(),
|
|
521
|
+
duration,
|
|
522
|
+
model: None,
|
|
523
|
+
provider: "veo".to_string(),
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
match mint_core::generate_video(&session.config, &gen_request).await {
|
|
527
|
+
Ok(result) => {
|
|
528
|
+
spinner.finish_and_clear();
|
|
529
|
+
if let Some(video) = result.videos.first() {
|
|
530
|
+
println!("{MINT}✓ Video generated successfully!{RESET}");
|
|
531
|
+
println!("{MINT}Saved to: {}{RESET}\n", video.path.display());
|
|
532
|
+
} else {
|
|
533
|
+
println!("{ERROR}✗ Video generation returned no videos.{RESET}\n");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
Err(e) => {
|
|
537
|
+
spinner.finish_and_clear();
|
|
538
|
+
println!("{ERROR}✗ Video generation failed: {e}{RESET}\n");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
Some(SlashResult::Handled)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
"/image" => {
|
|
546
|
+
let (img_path, prompt) = parse_path_and_prompt(rest);
|
|
547
|
+
|
|
548
|
+
if img_path.is_empty() {
|
|
549
|
+
println!("{WARN}/image usage: /image <path> [prompt]{RESET}\n");
|
|
550
|
+
return Some(SlashResult::Handled);
|
|
551
|
+
}
|
|
552
|
+
match image::load_image_as_data_uri(std::path::Path::new(&img_path)) {
|
|
553
|
+
Ok(uri) => {
|
|
554
|
+
if let Some(ref mut current) = session.pending_image {
|
|
555
|
+
current.push(' ');
|
|
556
|
+
current.push_str(&uri);
|
|
557
|
+
} else {
|
|
558
|
+
session.pending_image = Some(uri);
|
|
559
|
+
}
|
|
560
|
+
if prompt.is_empty() {
|
|
561
|
+
println!("{DIM}Image attached — type your prompt and press Enter{RESET}\n");
|
|
562
|
+
Some(SlashResult::Handled)
|
|
563
|
+
} else {
|
|
564
|
+
Some(SlashResult::ForwardToAgent(prompt.to_owned()))
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
Err(e) => {
|
|
568
|
+
println!("{ERROR}Failed to load image:{RESET} {e}\n");
|
|
569
|
+
Some(SlashResult::Handled)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
"/edit-image" => {
|
|
575
|
+
let (img_path, instruction) = parse_path_and_prompt(rest);
|
|
576
|
+
|
|
577
|
+
if img_path.is_empty() || instruction.is_empty() {
|
|
578
|
+
println!("{WARN}Usage: /edit-image <image_path> <editing instruction>{RESET}\n");
|
|
579
|
+
return Some(SlashResult::Handled);
|
|
580
|
+
}
|
|
581
|
+
match image::load_image_as_data_uri(std::path::Path::new(&img_path)) {
|
|
582
|
+
Ok(uri) => {
|
|
583
|
+
println!(
|
|
584
|
+
"{DIM}Editing image with prompt: \"{}\"...{RESET}",
|
|
585
|
+
instruction
|
|
586
|
+
);
|
|
587
|
+
let req = mint_core::ImageGenRequest {
|
|
588
|
+
prompt: instruction.to_owned(),
|
|
589
|
+
negative_prompt: None,
|
|
590
|
+
aspect_ratio: None,
|
|
591
|
+
num_images: Some(1),
|
|
592
|
+
model: None,
|
|
593
|
+
provider: None,
|
|
594
|
+
image_data_uri: Some(uri),
|
|
595
|
+
mask_data_uri: None,
|
|
596
|
+
mode: Some("edit".to_owned()),
|
|
597
|
+
};
|
|
598
|
+
let rt = tokio::runtime::Handle::current();
|
|
599
|
+
let config = session.config.clone();
|
|
600
|
+
let handle = std::thread::spawn(move || {
|
|
601
|
+
rt.block_on(mint_core::generate_images(&config, &req))
|
|
602
|
+
});
|
|
603
|
+
match handle.join() {
|
|
604
|
+
Ok(Ok(resp)) => {
|
|
605
|
+
if let Some(first) = resp.images.first() {
|
|
606
|
+
println!(
|
|
607
|
+
"{DIM}Image edited successfully using {} ({}){RESET}\n",
|
|
608
|
+
resp.provider, resp.model
|
|
609
|
+
);
|
|
610
|
+
println!(
|
|
611
|
+
"{DIM}Image data URI prefix: {}{RESET}\n",
|
|
612
|
+
&first.data_uri[..first.data_uri.len().min(60)]
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
Ok(Err(e)) => {
|
|
617
|
+
println!("{ERROR}Image editing failed: {e}{RESET}\n");
|
|
618
|
+
}
|
|
619
|
+
Err(_) => {
|
|
620
|
+
println!("{ERROR}Image editing thread panicked.{RESET}\n");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
Err(e) => {
|
|
625
|
+
println!("{ERROR}Failed to load image: {e}{RESET}\n");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
Some(SlashResult::Handled)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
"/paste" => match image::read_clipboard_image() {
|
|
632
|
+
Ok(Some(uri)) => {
|
|
633
|
+
if let Some(ref mut current) = session.pending_image {
|
|
634
|
+
current.push(' ');
|
|
635
|
+
current.push_str(&uri);
|
|
636
|
+
} else {
|
|
637
|
+
session.pending_image = Some(uri);
|
|
638
|
+
}
|
|
639
|
+
if rest.is_empty() {
|
|
640
|
+
println!(
|
|
641
|
+
"{DIM}Clipboard image attached — type your prompt and press Enter{RESET}\n"
|
|
642
|
+
);
|
|
643
|
+
Some(SlashResult::Handled)
|
|
644
|
+
} else {
|
|
645
|
+
Some(SlashResult::ForwardToAgent(rest.to_owned()))
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
Ok(None) => {
|
|
649
|
+
println!("{WARN}No image found in clipboard.{RESET}\n");
|
|
650
|
+
Some(SlashResult::Handled)
|
|
651
|
+
}
|
|
652
|
+
Err(e) => {
|
|
653
|
+
println!("{ERROR}Clipboard error:{RESET} {e}\n");
|
|
654
|
+
Some(SlashResult::Handled)
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
"/learn" => {
|
|
659
|
+
if rest.is_empty() {
|
|
660
|
+
let mut skills = match MemoryStore::open_default() {
|
|
661
|
+
Ok(m) => match m.learned_skills(100) {
|
|
662
|
+
Ok(s) => s,
|
|
663
|
+
Err(e) => {
|
|
664
|
+
println!("{ERROR}Error loading custom skills: {e}{RESET}\n");
|
|
665
|
+
return Some(SlashResult::Handled);
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
Err(e) => {
|
|
669
|
+
println!("{ERROR}Memory error: {e}{RESET}\n");
|
|
670
|
+
return Some(SlashResult::Handled);
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
if let Some(home) = dirs::home_dir() {
|
|
675
|
+
let global_skills_path = home.join(".config").join("mint").join("mint-skills");
|
|
676
|
+
mint_core::skills::load_skills_from_dir(&global_skills_path, &mut skills);
|
|
677
|
+
}
|
|
678
|
+
let workspace_skills_path1 = session.current_dir.join(".agents").join("skills");
|
|
679
|
+
mint_core::skills::load_skills_from_dir(&workspace_skills_path1, &mut skills);
|
|
680
|
+
let workspace_skills_path2 = session.current_dir.join("skills");
|
|
681
|
+
mint_core::skills::load_skills_from_dir(&workspace_skills_path2, &mut skills);
|
|
682
|
+
|
|
683
|
+
let mut unique_skills = std::collections::BTreeMap::new();
|
|
684
|
+
for skill in skills {
|
|
685
|
+
let loc = if skill.source_path.contains("/.config/mint/mint-skills") {
|
|
686
|
+
"Global"
|
|
687
|
+
} else if skill.source_path.contains("/skills")
|
|
688
|
+
|| skill.source_path.contains("/.agents/skills")
|
|
689
|
+
{
|
|
690
|
+
"Workspace"
|
|
691
|
+
} else {
|
|
692
|
+
"Taught"
|
|
693
|
+
};
|
|
694
|
+
unique_skills.insert(skill.name.clone(), (skill, loc));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if unique_skills.is_empty() {
|
|
698
|
+
println!("No learned skills found. Use '/learn <path>' to add one.\n");
|
|
699
|
+
} else {
|
|
700
|
+
println!("Learned AI Skills:");
|
|
701
|
+
for (name, (skill, loc)) in &unique_skills {
|
|
702
|
+
if *loc == "Taught" {
|
|
703
|
+
println!(" ● [{}] {}", loc, name);
|
|
704
|
+
} else {
|
|
705
|
+
println!(" ● [{}] {} (Source: {})", loc, name, skill.source_path);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
println!();
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
let path = PathBuf::from(rest);
|
|
712
|
+
let path = if path.is_absolute() {
|
|
713
|
+
path
|
|
714
|
+
} else {
|
|
715
|
+
session.current_dir.join(path)
|
|
716
|
+
};
|
|
717
|
+
match crate::skills::learn(&path) {
|
|
718
|
+
Ok(skill) => println!(
|
|
719
|
+
"{DIM}Learned skill: {} ({}){RESET}\n",
|
|
720
|
+
skill.name, skill.source_path
|
|
721
|
+
),
|
|
722
|
+
Err(error) => println!("{ERROR}Learn error:{RESET} {error}\n"),
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
Some(SlashResult::Handled)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
"/skill" => {
|
|
729
|
+
let (subcmd, args) = rest
|
|
730
|
+
.split_once(char::is_whitespace)
|
|
731
|
+
.map(|(c, a)| (c, a.trim()))
|
|
732
|
+
.unwrap_or((rest, ""));
|
|
733
|
+
|
|
734
|
+
match subcmd {
|
|
735
|
+
"add" | "install" => {
|
|
736
|
+
if args.is_empty() {
|
|
737
|
+
println!(
|
|
738
|
+
"{WARN}/skill add <path> requires a path to a skill file or folder{RESET}\n"
|
|
739
|
+
);
|
|
740
|
+
} else {
|
|
741
|
+
let source_path = PathBuf::from(args);
|
|
742
|
+
let source_path = if source_path.is_absolute() {
|
|
743
|
+
source_path
|
|
744
|
+
} else {
|
|
745
|
+
session.current_dir.join(source_path)
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
if !source_path.exists() {
|
|
749
|
+
println!("{ERROR}Source skill path not found: {}{RESET}\n", args);
|
|
750
|
+
} else if let Some(home) = dirs::home_dir() {
|
|
751
|
+
let global_skills_path =
|
|
752
|
+
home.join(".config").join("mint").join("mint-skills");
|
|
753
|
+
if !global_skills_path.exists() {
|
|
754
|
+
let _ = std::fs::create_dir_all(&global_skills_path);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
let name = source_path
|
|
758
|
+
.file_name()
|
|
759
|
+
.and_then(|n| n.to_str())
|
|
760
|
+
.unwrap_or("skill");
|
|
761
|
+
|
|
762
|
+
let dest_path = global_skills_path.join(name);
|
|
763
|
+
let copy_res = if source_path.is_dir() {
|
|
764
|
+
copy_dir_all(&source_path, &dest_path)
|
|
765
|
+
} else {
|
|
766
|
+
std::fs::copy(&source_path, &dest_path)
|
|
767
|
+
.map(|_| ())
|
|
768
|
+
.map_err(|e| e)
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
match copy_res {
|
|
772
|
+
Ok(()) => println!(
|
|
773
|
+
"{DIM}Skill successfully copied to Global config: {}{RESET}\n",
|
|
774
|
+
dest_path.display()
|
|
775
|
+
),
|
|
776
|
+
Err(e) => println!(
|
|
777
|
+
"{ERROR}Failed to copy skill to Global: {}{RESET}\n",
|
|
778
|
+
e
|
|
779
|
+
),
|
|
780
|
+
}
|
|
781
|
+
} else {
|
|
782
|
+
println!(
|
|
783
|
+
"{ERROR}Unable to resolve home directory for Global config.{RESET}\n"
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
_ => {
|
|
789
|
+
println!("{WARN}Usage: /skill add <path> (or /skill install <path>){RESET}\n");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
Some(SlashResult::Handled)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
"/memory" => {
|
|
796
|
+
let memory = match MemoryStore::open_default() {
|
|
797
|
+
Ok(m) => m,
|
|
798
|
+
Err(e) => {
|
|
799
|
+
println!("{ERROR}Memory error:{RESET} {e}\n");
|
|
800
|
+
return Some(SlashResult::Handled);
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
let (subcmd, args) = rest
|
|
804
|
+
.split_once(char::is_whitespace)
|
|
805
|
+
.map(|(c, a)| (c, a.trim()))
|
|
806
|
+
.unwrap_or((rest, ""));
|
|
807
|
+
match subcmd {
|
|
808
|
+
"list" | "" => match memory.recent_interactions_for_chat(CHAT_CLI_ID, 10) {
|
|
809
|
+
Ok(items) => {
|
|
810
|
+
if items.is_empty() {
|
|
811
|
+
println!("{DIM}No interactions yet.{RESET}\n");
|
|
812
|
+
} else {
|
|
813
|
+
println!("\n{BLUE}Recent interactions:{RESET}");
|
|
814
|
+
for item in items.iter().rev() {
|
|
815
|
+
println!(
|
|
816
|
+
" {DIM}[{}]{RESET} {BLUE}You:{RESET} {}",
|
|
817
|
+
&item.created_at[..16.min(item.created_at.len())],
|
|
818
|
+
if item.user_text.chars().count() > 80 {
|
|
819
|
+
let short: String =
|
|
820
|
+
item.user_text.chars().take(80).collect();
|
|
821
|
+
format!("{}…", short)
|
|
822
|
+
} else {
|
|
823
|
+
item.user_text.clone()
|
|
824
|
+
}
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
println!();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
|
|
831
|
+
},
|
|
832
|
+
"get" => {
|
|
833
|
+
if args.is_empty() {
|
|
834
|
+
println!("{WARN}/memory get <key>{RESET}\n");
|
|
835
|
+
} else {
|
|
836
|
+
match memory.get_profile(args) {
|
|
837
|
+
Ok(Some(val)) => println!("{val}\n"),
|
|
838
|
+
Ok(None) => println!("{DIM}(not set){RESET}\n"),
|
|
839
|
+
Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
"set" => {
|
|
844
|
+
let (key, val) = args
|
|
845
|
+
.split_once(char::is_whitespace)
|
|
846
|
+
.map(|(k, v)| (k, v.trim()))
|
|
847
|
+
.unwrap_or((args, ""));
|
|
848
|
+
if key.is_empty() {
|
|
849
|
+
println!("{WARN}/memory set <key> <value>{RESET}\n");
|
|
850
|
+
} else {
|
|
851
|
+
match memory.set_profile(key, val) {
|
|
852
|
+
Ok(()) => println!("{DIM}Stored {key}.{RESET}\n"),
|
|
853
|
+
Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
"skills" => match memory.learned_skills(20) {
|
|
858
|
+
Ok(skills) => {
|
|
859
|
+
if skills.is_empty() {
|
|
860
|
+
println!("{DIM}No learned skills.{RESET}\n");
|
|
861
|
+
} else {
|
|
862
|
+
println!("\n{BLUE}Learned skills:{RESET}");
|
|
863
|
+
for s in &skills {
|
|
864
|
+
println!(" [{}] {} — {}", s.id, s.name, s.source_path);
|
|
865
|
+
}
|
|
866
|
+
println!();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
|
|
870
|
+
},
|
|
871
|
+
"clear" => match memory.clear_interactions_for_chat(CHAT_CLI_ID) {
|
|
872
|
+
Ok(count) => println!("{DIM}Cleared {count} interactions.{RESET}\n"),
|
|
873
|
+
Err(e) => println!("{ERROR}Error:{RESET} {e}\n"),
|
|
874
|
+
},
|
|
875
|
+
_ => println!(
|
|
876
|
+
"{WARN}/memory usage: list | clear | get <key> | set <key> <val> | skills{RESET}\n"
|
|
877
|
+
),
|
|
878
|
+
}
|
|
879
|
+
Some(SlashResult::Handled)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
"/mcp" => {
|
|
883
|
+
let (subcmd, args) = rest
|
|
884
|
+
.split_once(char::is_whitespace)
|
|
885
|
+
.map(|(c, a)| (c, a.trim()))
|
|
886
|
+
.unwrap_or((rest, ""));
|
|
887
|
+
match subcmd {
|
|
888
|
+
"list" | "" => match crate::mcp::list() {
|
|
889
|
+
Ok(servers) => {
|
|
890
|
+
if servers.is_empty() {
|
|
891
|
+
println!("{DIM}(No MCP servers configured.){RESET}\n");
|
|
892
|
+
} else {
|
|
893
|
+
let allowed_mcp = session
|
|
894
|
+
.config
|
|
895
|
+
.extra
|
|
896
|
+
.get("allowedMcpTools")
|
|
897
|
+
.and_then(|v| v.as_object());
|
|
898
|
+
|
|
899
|
+
let mut choices = vec!["Cancel / Keep current settings".to_string()];
|
|
900
|
+
let mut server_names = vec![];
|
|
901
|
+
|
|
902
|
+
let max_name_len = servers.keys().map(|k| k.len()).max().unwrap_or(10);
|
|
903
|
+
|
|
904
|
+
for (name, srv) in &servers {
|
|
905
|
+
let args_str = srv.args.join(" ");
|
|
906
|
+
let status_label = if let Some(allowed) =
|
|
907
|
+
allowed_mcp.and_then(|m| m.get(name))
|
|
908
|
+
{
|
|
909
|
+
if let Some(arr) = allowed.as_array() {
|
|
910
|
+
let tools: Vec<&str> =
|
|
911
|
+
arr.iter().filter_map(|v| v.as_str()).collect();
|
|
912
|
+
if tools.contains(&"*") {
|
|
913
|
+
format!("{MINT}[Allowed: *]{RESET}")
|
|
914
|
+
} else if tools.is_empty() {
|
|
915
|
+
format!("{DIM}[No tools allowed]{RESET}")
|
|
916
|
+
} else {
|
|
917
|
+
format!("{MINT}[Allowed: {}]{RESET}", tools.join(", "))
|
|
918
|
+
}
|
|
919
|
+
} else {
|
|
920
|
+
format!("{DIM}[No tools allowed]{RESET}")
|
|
921
|
+
}
|
|
922
|
+
} else {
|
|
923
|
+
format!("{DIM}[No tools allowed]{RESET}")
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
let padded_name =
|
|
927
|
+
format!("{:<width$}", name, width = max_name_len + 2);
|
|
928
|
+
choices.push(format!(
|
|
929
|
+
"{}{} ({} {})",
|
|
930
|
+
padded_name, status_label, srv.command, args_str
|
|
931
|
+
));
|
|
932
|
+
server_names.push(name.clone());
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
match prompt_interactive_select(
|
|
936
|
+
"Select MCP Server",
|
|
937
|
+
&choices,
|
|
938
|
+
&choices[0],
|
|
939
|
+
) {
|
|
940
|
+
Ok(Some(selected_choice)) => {
|
|
941
|
+
if selected_choice != choices[0] {
|
|
942
|
+
if let Some(pos) =
|
|
943
|
+
choices.iter().position(|c| c == &selected_choice)
|
|
944
|
+
{
|
|
945
|
+
let server_name = &server_names[pos - 1];
|
|
946
|
+
|
|
947
|
+
let auth_options = vec![
|
|
948
|
+
"Keep current settings".to_string(),
|
|
949
|
+
"Allow all tools (*)".to_string(),
|
|
950
|
+
];
|
|
951
|
+
|
|
952
|
+
let title =
|
|
953
|
+
format!("Authorize MCP Server '{}'?", server_name);
|
|
954
|
+
match prompt_interactive_select(
|
|
955
|
+
&title,
|
|
956
|
+
&auth_options,
|
|
957
|
+
&auth_options[0],
|
|
958
|
+
) {
|
|
959
|
+
Ok(Some(auth_choice)) => {
|
|
960
|
+
if auth_choice == auth_options[1] {
|
|
961
|
+
match crate::mcp::allow(server_name, "*") {
|
|
962
|
+
Ok(true) => {
|
|
963
|
+
println!(
|
|
964
|
+
"{DIM}Allowed MCP tool: {server_name}/*{RESET}"
|
|
965
|
+
);
|
|
966
|
+
if let Ok(updated_config) =
|
|
967
|
+
mint_core::load_config()
|
|
968
|
+
{
|
|
969
|
+
session.config = updated_config;
|
|
970
|
+
}
|
|
971
|
+
println!(
|
|
972
|
+
"{MINT}Successfully authorized all tools for: {server_name}{RESET}\n"
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
Ok(false) => {
|
|
976
|
+
println!(
|
|
977
|
+
"{DIM}MCP tools already allowed for {server_name}{RESET}\n"
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
Err(e) => println!(
|
|
981
|
+
"{ERROR}MCP error:{RESET} {e}\n"
|
|
982
|
+
),
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
_ => {}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
_ => {}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
Err(e) => println!("{ERROR}MCP error:{RESET} {e}\n"),
|
|
996
|
+
},
|
|
997
|
+
"allow" => {
|
|
998
|
+
let mut parts = args.split_whitespace();
|
|
999
|
+
let server = parts.next();
|
|
1000
|
+
let tool = parts.next();
|
|
1001
|
+
if let (Some(server), Some(tool)) = (server, tool) {
|
|
1002
|
+
match crate::mcp::allow(server, tool) {
|
|
1003
|
+
Ok(true) => {
|
|
1004
|
+
println!("{DIM}Allowed MCP tool: {server}/{tool}{RESET}\n");
|
|
1005
|
+
if let Ok(updated_config) = mint_core::load_config() {
|
|
1006
|
+
session.config = updated_config;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
Ok(false) => {
|
|
1010
|
+
println!("{DIM}MCP tool already allowed: {server}/{tool}{RESET}\n")
|
|
1011
|
+
}
|
|
1012
|
+
Err(e) => println!("{ERROR}MCP error:{RESET} {e}\n"),
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
println!("{WARN}/mcp allow usage: <server> <tool>{RESET}\n");
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
_ => println!(
|
|
1019
|
+
"{WARN}/mcp usage: list | allow <server> <tool> | remove <name> | clear{RESET}\n"
|
|
1020
|
+
),
|
|
1021
|
+
}
|
|
1022
|
+
Some(SlashResult::Handled)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
"/stats" => {
|
|
1026
|
+
let provider = &session.config.ai_provider;
|
|
1027
|
+
let model = active_model(provider, &session.config);
|
|
1028
|
+
let interactions = MemoryStore::open_default()
|
|
1029
|
+
.and_then(|m| m.recent_interactions_for_chat(CHAT_CLI_ID, 1000))
|
|
1030
|
+
.map(|v| v.len())
|
|
1031
|
+
.unwrap_or(0);
|
|
1032
|
+
println!("\n{BLUE}─ Session Stats ─────────────────────────{RESET}");
|
|
1033
|
+
println!(" Provider : {MINT}{provider}{RESET}");
|
|
1034
|
+
println!(" Model : {model}");
|
|
1035
|
+
println!(
|
|
1036
|
+
" Workspace: {}",
|
|
1037
|
+
format_path_with_tilde(&session.current_dir)
|
|
1038
|
+
);
|
|
1039
|
+
println!(
|
|
1040
|
+
" Fast mode: {}",
|
|
1041
|
+
if session.fast_mode { "on" } else { "off" }
|
|
1042
|
+
);
|
|
1043
|
+
println!(" Memory : {interactions} interactions");
|
|
1044
|
+
if let Some(ref img_data) = session.pending_image {
|
|
1045
|
+
let count = img_data.split_whitespace().count();
|
|
1046
|
+
if count > 1 {
|
|
1047
|
+
println!(" Images : {WARN}{} images attached{RESET}", count);
|
|
1048
|
+
} else {
|
|
1049
|
+
println!(" Image : {WARN}attached{RESET}");
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
println!();
|
|
1054
|
+
Some(SlashResult::Handled)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
"/exit" | "/quit" => Some(SlashResult::Exit),
|
|
1058
|
+
|
|
1059
|
+
"/plugins" | "/plugin" => {
|
|
1060
|
+
let (plugin_name, prompt) = rest
|
|
1061
|
+
.split_once(char::is_whitespace)
|
|
1062
|
+
.map(|(p, r)| (p, r.trim()))
|
|
1063
|
+
.unwrap_or((rest, ""));
|
|
1064
|
+
|
|
1065
|
+
if plugin_name.is_empty() {
|
|
1066
|
+
println!("\n{BLUE}────────────────────────────────────────────{RESET}");
|
|
1067
|
+
println!("{MINT} Available Plugins & Skills{RESET}");
|
|
1068
|
+
println!("{BLUE}────────────────────────────────────────────{RESET}");
|
|
1069
|
+
|
|
1070
|
+
println!("{MINT}Native Plugins:{RESET}");
|
|
1071
|
+
for p in mint_core::native_plugins() {
|
|
1072
|
+
println!(" - {BLUE}{:<20}{RESET} {}", p.name, p.description);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
println!("\n{MINT}Custom Skills/Plugins (Active):{RESET}");
|
|
1076
|
+
let mut active_skills = std::collections::HashSet::new();
|
|
1077
|
+
if let Ok(memory) = MemoryStore::open_default() {
|
|
1078
|
+
match memory.learned_skills(100) {
|
|
1079
|
+
Ok(skills) => {
|
|
1080
|
+
if skills.is_empty() {
|
|
1081
|
+
println!(
|
|
1082
|
+
" {DIM}(No custom skills/plugins learned yet. Use '/learn <path>' to add one.){RESET}"
|
|
1083
|
+
);
|
|
1084
|
+
} else {
|
|
1085
|
+
for s in &skills {
|
|
1086
|
+
println!(" - {BLUE}{:<20}{RESET} {}", s.name, s.source_path);
|
|
1087
|
+
|
|
1088
|
+
// Try to store canonicalized active path
|
|
1089
|
+
if let Ok(canon) = Path::new(&s.source_path).canonicalize() {
|
|
1090
|
+
active_skills.insert(canon);
|
|
1091
|
+
} else {
|
|
1092
|
+
active_skills.insert(PathBuf::from(&s.source_path));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
Err(e) => println!(" {ERROR}Error loading custom skills: {e}{RESET}"),
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Scan local workspace skills directory
|
|
1102
|
+
let local_skills_dir = session.current_dir.join("skills");
|
|
1103
|
+
if local_skills_dir.is_dir()
|
|
1104
|
+
&& let Ok(entries) = fs::read_dir(&local_skills_dir)
|
|
1105
|
+
{
|
|
1106
|
+
let mut local_files = Vec::new();
|
|
1107
|
+
for entry in entries.flatten() {
|
|
1108
|
+
let path = entry.path();
|
|
1109
|
+
if path.is_file()
|
|
1110
|
+
&& let Some(ext) = path.extension().and_then(|e| e.to_str())
|
|
1111
|
+
&& matches!(ext.to_ascii_lowercase().as_str(), "md" | "txt")
|
|
1112
|
+
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
|
|
1113
|
+
{
|
|
1114
|
+
if let Ok(canon_path) = path.canonicalize() {
|
|
1115
|
+
local_files.push((name.to_owned(), canon_path));
|
|
1116
|
+
} else {
|
|
1117
|
+
local_files.push((name.to_owned(), path));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
let inactive_files: Vec<_> = local_files
|
|
1123
|
+
.into_iter()
|
|
1124
|
+
.filter(|(_, full_path)| !active_skills.contains(full_path))
|
|
1125
|
+
.map(|(name, _)| name)
|
|
1126
|
+
.collect();
|
|
1127
|
+
|
|
1128
|
+
if !inactive_files.is_empty() {
|
|
1129
|
+
println!(
|
|
1130
|
+
"\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}"
|
|
1131
|
+
);
|
|
1132
|
+
for file in inactive_files {
|
|
1133
|
+
println!(
|
|
1134
|
+
" - {WARN}{:<20}{RESET} skills/{} {DIM}(Type '/learn skills/{}' to activate){RESET}",
|
|
1135
|
+
file, file, file
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
println!();
|
|
1142
|
+
Some(SlashResult::Handled)
|
|
1143
|
+
} else {
|
|
1144
|
+
let agent_prompt = if prompt.is_empty() {
|
|
1145
|
+
format!(
|
|
1146
|
+
"Create a skill markdown file for the plugin '{}' at the path 'skills/{}.md' in the workspace. Write the appropriate instructions inside.",
|
|
1147
|
+
plugin_name, plugin_name
|
|
1148
|
+
)
|
|
1149
|
+
} else {
|
|
1150
|
+
format!(
|
|
1151
|
+
"Create a skill markdown file for the plugin '{}' at the path 'skills/{}.md' in the workspace based on these instructions: {}",
|
|
1152
|
+
plugin_name, plugin_name, prompt
|
|
1153
|
+
)
|
|
1154
|
+
};
|
|
1155
|
+
Some(SlashResult::ForwardToAgent(agent_prompt))
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
"/code" => {
|
|
1160
|
+
if rest.is_empty() {
|
|
1161
|
+
println!("{WARN}/code requires a task description{RESET}\n");
|
|
1162
|
+
Some(SlashResult::Handled)
|
|
1163
|
+
} else {
|
|
1164
|
+
Some(SlashResult::ForwardToAgent(format!("[code] {rest}")))
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
_ => {
|
|
1169
|
+
// Unknown slash command — treat as normal message to the agent
|
|
1170
|
+
None
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
fn apply_welcome_gradient(text: &str) -> String {
|
|
1176
|
+
let chars: Vec<char> = text.chars().collect();
|
|
1177
|
+
let count = chars.len();
|
|
1178
|
+
if count == 0 {
|
|
1179
|
+
return String::new();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Gradient stops: Mint (105, 230, 166) -> Sky Blue (72, 202, 228) -> Deep Blue (0, 119, 182)
|
|
1183
|
+
let stops = [
|
|
1184
|
+
(105.0, 230.0, 166.0), // Mint Green
|
|
1185
|
+
(72.0, 202.0, 228.0), // Sky Blue
|
|
1186
|
+
(0.0, 119.0, 182.0), // Deep Blue
|
|
1187
|
+
];
|
|
1188
|
+
|
|
1189
|
+
let mut result = String::new();
|
|
1190
|
+
for (i, &c) in chars.iter().enumerate() {
|
|
1191
|
+
if c == ' ' {
|
|
1192
|
+
result.push(c);
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
let t = if count > 1 {
|
|
1196
|
+
i as f32 / (count - 1) as f32
|
|
1197
|
+
} else {
|
|
1198
|
+
0.0
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
let (r, g, b) = if t <= 0.5 {
|
|
1202
|
+
let local_t = t * 2.0;
|
|
1203
|
+
let r = stops[0].0 + (stops[1].0 - stops[0].0) * local_t;
|
|
1204
|
+
let g = stops[0].1 + (stops[1].1 - stops[0].1) * local_t;
|
|
1205
|
+
let b = stops[0].2 + (stops[1].2 - stops[0].2) * local_t;
|
|
1206
|
+
(r, g, b)
|
|
1207
|
+
} else {
|
|
1208
|
+
let local_t = (t - 0.5) * 2.0;
|
|
1209
|
+
let r = stops[1].0 + (stops[2].0 - stops[1].0) * local_t;
|
|
1210
|
+
let g = stops[1].1 + (stops[2].1 - stops[1].1) * local_t;
|
|
1211
|
+
let b = stops[1].2 + (stops[2].2 - stops[1].2) * local_t;
|
|
1212
|
+
(r, g, b)
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
result.push_str(&format!(
|
|
1216
|
+
"\x1b[38;2;{};{};{}m{}\x1b[0m",
|
|
1217
|
+
r.round() as u8,
|
|
1218
|
+
g.round() as u8,
|
|
1219
|
+
b.round() as u8,
|
|
1220
|
+
c
|
|
1221
|
+
));
|
|
1222
|
+
}
|
|
1223
|
+
result
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
pub fn print_welcome_banner(config: &MintConfig) {
|
|
1227
|
+
let provider = &config.ai_provider;
|
|
1228
|
+
let model = active_model(provider, config);
|
|
1229
|
+
|
|
1230
|
+
// Print startup banner
|
|
1231
|
+
let now = chrono::Local::now();
|
|
1232
|
+
let year = now.format("%Y").to_string().parse::<i32>().unwrap_or(2026) + 543;
|
|
1233
|
+
let date_time = format!(
|
|
1234
|
+
"{}/{:02}/{:02} {:02}:{:02}",
|
|
1235
|
+
now.format("%d"),
|
|
1236
|
+
now.format("%m"),
|
|
1237
|
+
year,
|
|
1238
|
+
now.format("%H"),
|
|
1239
|
+
now.format("%M")
|
|
1240
|
+
);
|
|
1241
|
+
let version = env!("CARGO_PKG_VERSION");
|
|
1242
|
+
let clean_provider_name = format_provider_display_name(provider, config);
|
|
1243
|
+
let line1_text = format!("[Mint] v{} | Active AI: {}", version, clean_provider_name);
|
|
1244
|
+
let line2_text = format!("{} • {}", date_time, model);
|
|
1245
|
+
|
|
1246
|
+
let len1 = line1_text.chars().count();
|
|
1247
|
+
let len2 = line2_text.chars().count();
|
|
1248
|
+
let content_width = std::cmp::max(len1, len2);
|
|
1249
|
+
let border_len = content_width + 2;
|
|
1250
|
+
|
|
1251
|
+
let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
|
|
1252
|
+
let term_width = term_width as usize;
|
|
1253
|
+
let ascii_width = 34;
|
|
1254
|
+
let spacing = 3;
|
|
1255
|
+
let box_width = border_len + 2;
|
|
1256
|
+
|
|
1257
|
+
if term_width >= ascii_width + spacing + box_width {
|
|
1258
|
+
println!(
|
|
1259
|
+
"{} {DIM}╭{}╮{RESET}",
|
|
1260
|
+
apply_welcome_gradient(" __ __ _ _ ___ _ ___ "),
|
|
1261
|
+
"─".repeat(border_len)
|
|
1262
|
+
);
|
|
1263
|
+
println!(
|
|
1264
|
+
"{} {DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
|
|
1265
|
+
apply_welcome_gradient("| \\/ (_)_ __ | |_ / __| | |_ _|"),
|
|
1266
|
+
version,
|
|
1267
|
+
clean_provider_name,
|
|
1268
|
+
" ".repeat(content_width - len1)
|
|
1269
|
+
);
|
|
1270
|
+
println!(
|
|
1271
|
+
"{} {DIM}│{RESET} {DIM}{}{}{RESET} {DIM}│{RESET}",
|
|
1272
|
+
apply_welcome_gradient("| |\\/| | | '_ \\| _| (__| |__ | | "),
|
|
1273
|
+
line2_text,
|
|
1274
|
+
" ".repeat(content_width - len2)
|
|
1275
|
+
);
|
|
1276
|
+
println!(
|
|
1277
|
+
"{} {DIM}╰{}╯{RESET}",
|
|
1278
|
+
apply_welcome_gradient("|_| |_|_|_| |_|\\__|\\___|\\___|___|"),
|
|
1279
|
+
"─".repeat(border_len)
|
|
1280
|
+
);
|
|
1281
|
+
} else {
|
|
1282
|
+
println!("{DIM}╭{}╮{RESET}", "─".repeat(border_len));
|
|
1283
|
+
println!(
|
|
1284
|
+
"{DIM}│{RESET} {MINT}[Mint]{RESET} v{} | Active AI: {}{} {DIM}│{RESET}",
|
|
1285
|
+
version,
|
|
1286
|
+
clean_provider_name,
|
|
1287
|
+
" ".repeat(content_width - len1)
|
|
1288
|
+
);
|
|
1289
|
+
println!(
|
|
1290
|
+
"{DIM}│{RESET} {DIM}{}{}{RESET} {DIM}│{RESET}",
|
|
1291
|
+
line2_text,
|
|
1292
|
+
" ".repeat(content_width - len2)
|
|
1293
|
+
);
|
|
1294
|
+
println!("{DIM}╰{}╯{RESET}", "─".repeat(border_len));
|
|
1295
|
+
println!(
|
|
1296
|
+
"{}",
|
|
1297
|
+
apply_welcome_gradient(" __ __ _ _ ___ _ ___ ")
|
|
1298
|
+
);
|
|
1299
|
+
println!(
|
|
1300
|
+
"{}",
|
|
1301
|
+
apply_welcome_gradient("| \\/ (_)_ __ | |_ / __| | |_ _|")
|
|
1302
|
+
);
|
|
1303
|
+
println!(
|
|
1304
|
+
"{}",
|
|
1305
|
+
apply_welcome_gradient("| |\\/| | | '_ \\| _| (__| |__ | | ")
|
|
1306
|
+
);
|
|
1307
|
+
println!(
|
|
1308
|
+
"{}",
|
|
1309
|
+
apply_welcome_gradient("|_| |_|_|_| |_|\\__|\\___|\\___|___|")
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
pub async fn run_interactive_chat() -> Result<()> {
|
|
1315
|
+
let config = mint_core::load_config()?;
|
|
1316
|
+
|
|
1317
|
+
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
1318
|
+
|
|
1319
|
+
print_welcome_banner(&config);
|
|
1320
|
+
println!("Type naturally or /help for commands. Ctrl+V pastes images. Ctrl+D exits.\n");
|
|
1321
|
+
|
|
1322
|
+
let mut session = InteractiveSession {
|
|
1323
|
+
config,
|
|
1324
|
+
current_dir: current_dir.clone(),
|
|
1325
|
+
fast_mode: false,
|
|
1326
|
+
pending_image: None,
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
let mut printed_update = false;
|
|
1330
|
+
if let Some((current, latest)) = crate::updater::get_cached_update_notice() {
|
|
1331
|
+
crate::updater::print_update_notice(¤t, &latest);
|
|
1332
|
+
printed_update = true;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let mut update_handle = if crate::updater::should_check_for_update() {
|
|
1336
|
+
Some(tokio::task::spawn_blocking(
|
|
1337
|
+
crate::updater::check_for_update_quietly,
|
|
1338
|
+
))
|
|
1339
|
+
} else {
|
|
1340
|
+
None
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
loop {
|
|
1344
|
+
if let Some(handle) = update_handle.take() {
|
|
1345
|
+
if handle.is_finished() {
|
|
1346
|
+
if let Ok(Some((current, latest))) = handle.await
|
|
1347
|
+
&& !printed_update
|
|
1348
|
+
{
|
|
1349
|
+
crate::updater::print_update_notice(¤t, &latest);
|
|
1350
|
+
printed_update = true;
|
|
1351
|
+
}
|
|
1352
|
+
} else {
|
|
1353
|
+
update_handle = Some(handle);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
let path_str = format_path_with_tilde(&session.current_dir);
|
|
1358
|
+
let model_str = active_model(&session.config.ai_provider, &session.config).to_owned();
|
|
1359
|
+
|
|
1360
|
+
if let Some(input) = read_line_interactive(
|
|
1361
|
+
&session.config.ai_provider,
|
|
1362
|
+
&model_str,
|
|
1363
|
+
&path_str,
|
|
1364
|
+
&session.current_dir,
|
|
1365
|
+
)? {
|
|
1366
|
+
if let Some(uri) = input.pasted_image {
|
|
1367
|
+
if let Some(ref mut current) = session.pending_image {
|
|
1368
|
+
current.push(' ');
|
|
1369
|
+
current.push_str(&uri);
|
|
1370
|
+
} else {
|
|
1371
|
+
session.pending_image = Some(uri);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
let query_str = input.text.trim().to_owned();
|
|
1375
|
+
if query_str.is_empty() {
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if query_str.starts_with('$') {
|
|
1380
|
+
let (skill_word, task_part) = query_str
|
|
1381
|
+
.split_once(char::is_whitespace)
|
|
1382
|
+
.map(|(s, t)| (s, t.trim()))
|
|
1383
|
+
.unwrap_or((&query_str, ""));
|
|
1384
|
+
|
|
1385
|
+
let skill_name = skill_word.trim_start_matches('$').to_lowercase();
|
|
1386
|
+
let skills = load_all_available_skills(&session.current_dir);
|
|
1387
|
+
let skill_opt = skills.iter().find(|s| s.name.to_lowercase() == skill_name);
|
|
1388
|
+
|
|
1389
|
+
if let Some(skill) = skill_opt {
|
|
1390
|
+
println!("\n{BLUE}Skill: {}{RESET}", skill.name);
|
|
1391
|
+
if let Some(ref desc) = skill.description {
|
|
1392
|
+
println!("{DIM}{}{RESET}", desc);
|
|
1393
|
+
}
|
|
1394
|
+
println!("{DIM}────────────────────────────────────────────{RESET}");
|
|
1395
|
+
println!("{}", skill.content);
|
|
1396
|
+
println!("{DIM}────────────────────────────────────────────{RESET}\n");
|
|
1397
|
+
|
|
1398
|
+
if confirm("ต้องการ activate skill นี้ไหม? [y/N] ")? {
|
|
1399
|
+
let final_task = if task_part.is_empty() {
|
|
1400
|
+
print!("พิมพ์ Task ที่ต้องการให้ทำงานด้วย Skill นี้: ");
|
|
1401
|
+
let _ = io::stdout().flush();
|
|
1402
|
+
let mut input = String::new();
|
|
1403
|
+
io::stdin().read_line(&mut input)?;
|
|
1404
|
+
let input = input.trim().to_owned();
|
|
1405
|
+
if input.is_empty() {
|
|
1406
|
+
println!("{WARN}Cancelled: Task cannot be empty.{RESET}\n");
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
input
|
|
1410
|
+
} else {
|
|
1411
|
+
task_part.to_owned()
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
let task_with_skill = format!(
|
|
1415
|
+
"=== ACTIVATED SKILL: {} ===\n\
|
|
1416
|
+
{}\n\
|
|
1417
|
+
===========================\n\n\
|
|
1418
|
+
Task: {}",
|
|
1419
|
+
skill.name, skill.content, final_task
|
|
1420
|
+
);
|
|
1421
|
+
|
|
1422
|
+
println!();
|
|
1423
|
+
if let Err(error) = crate::run_code_agent_with_saved_image(
|
|
1424
|
+
&task_with_skill,
|
|
1425
|
+
&session.current_dir,
|
|
1426
|
+
&session.config,
|
|
1427
|
+
session.pending_image.take(),
|
|
1428
|
+
None,
|
|
1429
|
+
agent::AgentOptions {
|
|
1430
|
+
fast_mode: session.fast_mode,
|
|
1431
|
+
},
|
|
1432
|
+
)
|
|
1433
|
+
.await
|
|
1434
|
+
{
|
|
1435
|
+
println!("{ERROR}Error:{RESET} {error}\n");
|
|
1436
|
+
}
|
|
1437
|
+
} else {
|
|
1438
|
+
println!("{DIM}Cancelled.{RESET}\n");
|
|
1439
|
+
}
|
|
1440
|
+
} else {
|
|
1441
|
+
println!("{ERROR}Skill '{}' not found.{RESET}\n", skill_name);
|
|
1442
|
+
}
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Run slash-command router
|
|
1447
|
+
match handle_slash_command(&mut session, &query_str).await {
|
|
1448
|
+
Some(SlashResult::Handled) => continue,
|
|
1449
|
+
Some(SlashResult::Exit) => {
|
|
1450
|
+
print_exit_message(&session);
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
Some(SlashResult::ForwardToAgent(task)) => {
|
|
1455
|
+
// Force code agent for /code forwarded tasks
|
|
1456
|
+
println!();
|
|
1457
|
+
if let Err(error) = crate::run_code_agent_with_saved_image(
|
|
1458
|
+
&task,
|
|
1459
|
+
&session.current_dir,
|
|
1460
|
+
&session.config,
|
|
1461
|
+
session.pending_image.take(),
|
|
1462
|
+
None,
|
|
1463
|
+
agent::AgentOptions {
|
|
1464
|
+
fast_mode: session.fast_mode,
|
|
1465
|
+
},
|
|
1466
|
+
)
|
|
1467
|
+
.await
|
|
1468
|
+
{
|
|
1469
|
+
println!("{ERROR}Error:{RESET} {error}\n");
|
|
1470
|
+
}
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
None => {} // Not a slash command, fall through
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Check if it's a /code or regular agent request
|
|
1477
|
+
if let Some(task) = query_str.strip_prefix("/code ") {
|
|
1478
|
+
println!();
|
|
1479
|
+
if let Err(error) = crate::run_code_agent_with_saved_image(
|
|
1480
|
+
task.trim(),
|
|
1481
|
+
&session.current_dir,
|
|
1482
|
+
&session.config,
|
|
1483
|
+
session.pending_image.take(),
|
|
1484
|
+
None,
|
|
1485
|
+
agent::AgentOptions {
|
|
1486
|
+
fast_mode: session.fast_mode,
|
|
1487
|
+
},
|
|
1488
|
+
)
|
|
1489
|
+
.await
|
|
1490
|
+
{
|
|
1491
|
+
println!("{ERROR}Error:{RESET} {error}\n");
|
|
1492
|
+
}
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// Regular agent loop (handles both chat and coding)
|
|
1497
|
+
if !query_str.starts_with("/chat ") {
|
|
1498
|
+
if let Err(error) = crate::run_code_agent_with_saved_image(
|
|
1499
|
+
query_str.trim_start_matches("/chat "),
|
|
1500
|
+
&session.current_dir,
|
|
1501
|
+
&session.config,
|
|
1502
|
+
session.pending_image.take(),
|
|
1503
|
+
None,
|
|
1504
|
+
agent::AgentOptions {
|
|
1505
|
+
fast_mode: session.fast_mode,
|
|
1506
|
+
},
|
|
1507
|
+
)
|
|
1508
|
+
.await
|
|
1509
|
+
{
|
|
1510
|
+
println!("{ERROR}Error:{RESET} {error}\n");
|
|
1511
|
+
}
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// /chat explicit: use streaming chat with fallback
|
|
1516
|
+
let message = query_str
|
|
1517
|
+
.strip_prefix("/chat ")
|
|
1518
|
+
.unwrap_or(&query_str)
|
|
1519
|
+
.trim()
|
|
1520
|
+
.to_owned();
|
|
1521
|
+
|
|
1522
|
+
println!();
|
|
1523
|
+
print!(" {MINT}Mint:{RESET} {DIM}Thinking...{RESET}");
|
|
1524
|
+
let _ = io::stdout().flush();
|
|
1525
|
+
|
|
1526
|
+
let mut system_instruction = "You are Mint, a cute and helpful AI assistant. You speak in a polite, friendly, and sweet Thai tone (using \"คุณ\", \"ค่ะ\", \"นะคะ\"). \
|
|
1527
|
+
You are running inside the Mint CLI interactive chat. \
|
|
1528
|
+
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: \
|
|
1529
|
+
`[ACTION: <command> <arguments>]` \
|
|
1530
|
+
The available actions are: \
|
|
1531
|
+
- `[ACTION: open <url_or_path>]` to open a URL or a folder path. \
|
|
1532
|
+
- `[ACTION: open-app <app_name>]` to launch a desktop application. \
|
|
1533
|
+
- `[ACTION: read-file <file_path>]` to read the contents of a file. \
|
|
1534
|
+
- `[ACTION: read-folder <path>]` to list files/folders in a directory. \
|
|
1535
|
+
- `[ACTION: run-shell <command>]` to run a non-destructive local shell command after approval. \
|
|
1536
|
+
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. \
|
|
1537
|
+
Write the action block on a single line at the very end of your response.".to_string();
|
|
1538
|
+
if let Ok(memory) = MemoryStore::open_default()
|
|
1539
|
+
&& let Ok(Some(name)) = memory.get_profile("name")
|
|
1540
|
+
{
|
|
1541
|
+
system_instruction.push_str(&format!(
|
|
1542
|
+
"\nThe user's name is {}. Refer to them by their name when appropriate.",
|
|
1543
|
+
name
|
|
1544
|
+
));
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
let image_uri = session.pending_image.take();
|
|
1548
|
+
let sent_image = image_uri.clone();
|
|
1549
|
+
let mut first_chunk = true;
|
|
1550
|
+
let mut filter = actions::ActionStreamFilter::new();
|
|
1551
|
+
let mut formatter = markdown::CliStreamFormatter::new();
|
|
1552
|
+
|
|
1553
|
+
let stream_result = orchestrate_chat_stream_with_fallback(
|
|
1554
|
+
&session.config,
|
|
1555
|
+
&ChatRequest {
|
|
1556
|
+
message: message.clone(),
|
|
1557
|
+
system_instruction,
|
|
1558
|
+
chat_id: Some(CHAT_CLI_ID.to_owned()),
|
|
1559
|
+
image_data_uri: image_uri,
|
|
1560
|
+
audio_data_uri: None,
|
|
1561
|
+
video_data_uri: None,
|
|
1562
|
+
document_attachment: None,
|
|
1563
|
+
workspace_path: None,
|
|
1564
|
+
agent_id: None,
|
|
1565
|
+
},
|
|
1566
|
+
|chunk| {
|
|
1567
|
+
if first_chunk {
|
|
1568
|
+
first_chunk = false;
|
|
1569
|
+
print!("\r\x1b[2K {MINT}Mint:{RESET} ");
|
|
1570
|
+
}
|
|
1571
|
+
filter.process_chunk(&chunk, |text| {
|
|
1572
|
+
for c in text.chars() {
|
|
1573
|
+
formatter.process_char(c, |formatted_c| {
|
|
1574
|
+
print!("{}", formatted_c);
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
let _ = io::stdout().flush();
|
|
1579
|
+
},
|
|
1580
|
+
)
|
|
1581
|
+
.await;
|
|
1582
|
+
|
|
1583
|
+
let actions = filter.finalize(|text| {
|
|
1584
|
+
for c in text.chars() {
|
|
1585
|
+
formatter.process_char(c, |formatted_c| {
|
|
1586
|
+
print!("{}", formatted_c);
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
formatter.finalize(|formatted_c| {
|
|
1591
|
+
print!("{}", formatted_c);
|
|
1592
|
+
});
|
|
1593
|
+
let _ = io::stdout().flush();
|
|
1594
|
+
|
|
1595
|
+
match stream_result {
|
|
1596
|
+
Ok((response, fallback)) => {
|
|
1597
|
+
image::save_sent_image_after_send(sent_image.as_deref(), &message);
|
|
1598
|
+
if first_chunk {
|
|
1599
|
+
print!("\r\x1b[2K");
|
|
1600
|
+
let _ = io::stdout().flush();
|
|
1601
|
+
} else {
|
|
1602
|
+
println!("\n");
|
|
1603
|
+
let (tw, _) = crossterm::terminal::size().unwrap_or((80, 24));
|
|
1604
|
+
let width = tw as usize;
|
|
1605
|
+
// Show provider badge (with fallback indicator if applicable)
|
|
1606
|
+
let badge = if let Some(fb_provider) = &fallback {
|
|
1607
|
+
format!(
|
|
1608
|
+
"{DIM}{} • {} → fallback: {} • {}{RESET}",
|
|
1609
|
+
session.config.ai_provider,
|
|
1610
|
+
active_model(&session.config.ai_provider, &session.config),
|
|
1611
|
+
fb_provider,
|
|
1612
|
+
response.model
|
|
1613
|
+
)
|
|
1614
|
+
} else {
|
|
1615
|
+
format!("{DIM}{} • {}{RESET}", response.provider, response.model)
|
|
1616
|
+
};
|
|
1617
|
+
println!("{badge}");
|
|
1618
|
+
println!("{DIM}{}{RESET}\n", "─".repeat(width));
|
|
1619
|
+
}
|
|
1620
|
+
for action in actions {
|
|
1621
|
+
if let Err(e) = actions::execute_action(&action, &session.config) {
|
|
1622
|
+
println!("{ERROR}Error executing action:{RESET} {e}\n");
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
Err(e) => {
|
|
1627
|
+
if first_chunk {
|
|
1628
|
+
print!("\r\x1b[2K");
|
|
1629
|
+
}
|
|
1630
|
+
println!("{ERROR}Error:{RESET} {e}\n");
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
} else {
|
|
1634
|
+
print_exit_message(&session);
|
|
1635
|
+
break;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
Ok(())
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
pub fn print_exit_message(session: &InteractiveSession) {
|
|
1643
|
+
println!("\n{MINT}──────────────── Mint session closed ────────────────{RESET}");
|
|
1644
|
+
let clean_provider = format_provider_display_name(&session.config.ai_provider, &session.config);
|
|
1645
|
+
println!(
|
|
1646
|
+
"{DIM}Provider:{RESET} {} {DIM}• Model:{RESET} {}",
|
|
1647
|
+
clean_provider,
|
|
1648
|
+
active_model(&session.config.ai_provider, &session.config)
|
|
1649
|
+
);
|
|
1650
|
+
println!(
|
|
1651
|
+
"{DIM}Workspace:{RESET} {}",
|
|
1652
|
+
format_path_with_tilde(&session.current_dir)
|
|
1653
|
+
);
|
|
1654
|
+
println!("{DIM}Saved config stays available for the next Mint run.{RESET}");
|
|
1655
|
+
println!("{MINT}See you next time.{RESET}\n");
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
pub fn format_provider_display_name(provider: &str, config: &mint_core::MintConfig) -> String {
|
|
1659
|
+
if let Some(custom) = config.resolve_custom_provider(provider) {
|
|
1660
|
+
custom.display_name.clone()
|
|
1661
|
+
} else {
|
|
1662
|
+
match provider {
|
|
1663
|
+
"gemini" => "Google Gemini".to_owned(),
|
|
1664
|
+
"anthropic" => "Anthropic Claude".to_owned(),
|
|
1665
|
+
"openai" => "OpenAI".to_owned(),
|
|
1666
|
+
"openrouter" => "OpenRouter".to_owned(),
|
|
1667
|
+
"deepseek" => "DeepSeek".to_owned(),
|
|
1668
|
+
"huggingface" => "Hugging Face".to_owned(),
|
|
1669
|
+
"local_openai" => "Local OpenAI".to_owned(),
|
|
1670
|
+
"ollama" => "Ollama".to_owned(),
|
|
1671
|
+
p => p.to_owned(),
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
pub fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
|
|
1677
|
+
fs::create_dir_all(&dst)?;
|
|
1678
|
+
for entry in fs::read_dir(src)? {
|
|
1679
|
+
let entry = entry?;
|
|
1680
|
+
let ty = entry.file_type()?;
|
|
1681
|
+
if ty.is_dir() {
|
|
1682
|
+
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
|
1683
|
+
} else {
|
|
1684
|
+
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
Ok(())
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
pub fn load_all_available_skills(current_dir: &Path) -> Vec<mint_core::LearnedSkill> {
|
|
1691
|
+
let mut skills = match MemoryStore::open_default() {
|
|
1692
|
+
Ok(m) => m.learned_skills(100).unwrap_or_default(),
|
|
1693
|
+
Err(_) => Vec::new(),
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
if let Some(home) = dirs::home_dir() {
|
|
1697
|
+
let global_agents_path = home.join(".gemini").join("config").join("AGENTS.md");
|
|
1698
|
+
mint_core::skills::load_agent_rules_file(&global_agents_path, &mut skills);
|
|
1699
|
+
|
|
1700
|
+
let global_skills_path = home.join(".config").join("mint").join("mint-skills");
|
|
1701
|
+
mint_core::skills::load_skills_from_dir(&global_skills_path, &mut skills);
|
|
1702
|
+
}
|
|
1703
|
+
let workspace_agents_path1 = current_dir.join(".agents").join("AGENTS.md");
|
|
1704
|
+
mint_core::skills::load_agent_rules_file(&workspace_agents_path1, &mut skills);
|
|
1705
|
+
let workspace_agents_path2 = current_dir.join("AGENTS.md");
|
|
1706
|
+
mint_core::skills::load_agent_rules_file(&workspace_agents_path2, &mut skills);
|
|
1707
|
+
|
|
1708
|
+
let workspace_skills_path1 = current_dir.join(".agents").join("skills");
|
|
1709
|
+
mint_core::skills::load_skills_from_dir(&workspace_skills_path1, &mut skills);
|
|
1710
|
+
let workspace_skills_path2 = current_dir.join("skills");
|
|
1711
|
+
mint_core::skills::load_skills_from_dir(&workspace_skills_path2, &mut skills);
|
|
1712
|
+
|
|
1713
|
+
let mut unique_skills = std::collections::BTreeMap::new();
|
|
1714
|
+
for skill in skills {
|
|
1715
|
+
unique_skills.insert(skill.name.clone(), skill);
|
|
1716
|
+
}
|
|
1717
|
+
unique_skills.into_values().collect()
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const AUTOCOMPLETE_COMMANDS: &[(&str, &str)] = &[
|
|
1721
|
+
("/cd", "Change active workspace directory"),
|
|
1722
|
+
("/clear", "Clear conversation history"),
|
|
1723
|
+
("/code", "Run in code-agent mode"),
|
|
1724
|
+
("/edit-image", "Edit attached image with prompt instruction"),
|
|
1725
|
+
("/exit", "Exit Mint CLI"),
|
|
1726
|
+
("/fast", "Toggle fast mode (hide thinking traces)"),
|
|
1727
|
+
("/help", "Show help menu"),
|
|
1728
|
+
("/image", "Attach image from disk"),
|
|
1729
|
+
(
|
|
1730
|
+
"/image-provider",
|
|
1731
|
+
"List image gen providers or switch default provider",
|
|
1732
|
+
),
|
|
1733
|
+
("/learn", "Import persistent skill/instruction"),
|
|
1734
|
+
("/mcp", "List configured MCP servers"),
|
|
1735
|
+
("/memory", "Manage long-term memory store"),
|
|
1736
|
+
("/models", "List AI providers or switch active provider"),
|
|
1737
|
+
("/multi-agent", "Toggle Multi-Agent Collaboration system"),
|
|
1738
|
+
("/paste", "Attach image from clipboard"),
|
|
1739
|
+
("/plugins", "List or generate plugins/skills"),
|
|
1740
|
+
("/skill add", "Add or install global skill file or folder"),
|
|
1741
|
+
("/stats", "Show session statistics"),
|
|
1742
|
+
("/veo", "Generate video using Google Veo"),
|
|
1743
|
+
(
|
|
1744
|
+
"/video-provider",
|
|
1745
|
+
"List video gen providers or switch default provider",
|
|
1746
|
+
),
|
|
1747
|
+
];
|
|
1748
|
+
|
|
1749
|
+
pub fn draw_input_box(
|
|
1750
|
+
input_chars: &[char],
|
|
1751
|
+
cursor_pos: usize,
|
|
1752
|
+
placeholder: &str,
|
|
1753
|
+
model: &str,
|
|
1754
|
+
path_str: &str,
|
|
1755
|
+
tab_base_input: Option<&str>,
|
|
1756
|
+
tab_index: Option<usize>,
|
|
1757
|
+
current_dir: &Path,
|
|
1758
|
+
) -> (usize, usize) {
|
|
1759
|
+
let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
|
|
1760
|
+
let width = term_width as usize;
|
|
1761
|
+
let prefix = "› ";
|
|
1762
|
+
let input_width = width.saturating_sub(2);
|
|
1763
|
+
let content_max_len = input_width.saturating_sub(prefix.chars().count());
|
|
1764
|
+
|
|
1765
|
+
let mut start_idx = 0;
|
|
1766
|
+
if input_chars.len() > content_max_len && cursor_pos >= content_max_len {
|
|
1767
|
+
start_idx = (cursor_pos + 5).saturating_sub(content_max_len);
|
|
1768
|
+
if start_idx + content_max_len > input_chars.len() {
|
|
1769
|
+
start_idx = input_chars.len() - content_max_len;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
let slice_chars = if input_chars.len() > content_max_len {
|
|
1774
|
+
&input_chars[start_idx..std::cmp::min(start_idx + content_max_len, input_chars.len())]
|
|
1775
|
+
} else {
|
|
1776
|
+
input_chars
|
|
1777
|
+
};
|
|
1778
|
+
let displayed_input: String = slice_chars.iter().collect();
|
|
1779
|
+
|
|
1780
|
+
let display_str = if displayed_input.is_empty() {
|
|
1781
|
+
format!("{DIM}{}\x1b[39m", placeholder)
|
|
1782
|
+
} else {
|
|
1783
|
+
format_placeholders(&displayed_input)
|
|
1784
|
+
};
|
|
1785
|
+
|
|
1786
|
+
let visible_len = if displayed_input.is_empty() {
|
|
1787
|
+
placeholder.chars().count()
|
|
1788
|
+
} else {
|
|
1789
|
+
string_visual_width(&displayed_input)
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
let pad_len = content_max_len.saturating_sub(visible_len);
|
|
1793
|
+
let padding = " ".repeat(pad_len);
|
|
1794
|
+
let blank_line = " ".repeat(input_width);
|
|
1795
|
+
|
|
1796
|
+
println!(" {COMPOSER_BG}{blank_line}{RESET}");
|
|
1797
|
+
println!(
|
|
1798
|
+
" {COMPOSER_BG}{MINT}{}{RESET}{COMPOSER_BG}{}{}{RESET}",
|
|
1799
|
+
prefix, display_str, padding
|
|
1800
|
+
);
|
|
1801
|
+
println!(" {COMPOSER_BG}{blank_line}{RESET}");
|
|
1802
|
+
|
|
1803
|
+
let agent_str = format!(" {DIM}[Agent]{RESET} {MINT}{}{RESET}", model);
|
|
1804
|
+
let path_label = format!("path: {}", path_str);
|
|
1805
|
+
let agent_visible_len = " [Agent] ".len() + model.chars().count();
|
|
1806
|
+
let path_visible_len = path_label.chars().count();
|
|
1807
|
+
|
|
1808
|
+
let status_pad_len = (width - 1).saturating_sub(agent_visible_len + path_visible_len);
|
|
1809
|
+
let status_padding = " ".repeat(status_pad_len);
|
|
1810
|
+
|
|
1811
|
+
print!(
|
|
1812
|
+
"{}{}{}{}{}",
|
|
1813
|
+
agent_str, status_padding, DIM, path_label, RESET
|
|
1814
|
+
);
|
|
1815
|
+
|
|
1816
|
+
// Compute and draw suggestions
|
|
1817
|
+
let raw_input: String = input_chars.iter().collect();
|
|
1818
|
+
let search_query = tab_base_input.unwrap_or(&raw_input);
|
|
1819
|
+
let mut match_count = 0;
|
|
1820
|
+
if search_query.starts_with('/') {
|
|
1821
|
+
let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
|
|
1822
|
+
.iter()
|
|
1823
|
+
.filter(|(cmd, _)| cmd.starts_with(search_query))
|
|
1824
|
+
.collect();
|
|
1825
|
+
|
|
1826
|
+
if !matches.is_empty() {
|
|
1827
|
+
let total_pages = matches.len().div_ceil(5);
|
|
1828
|
+
let highlight_idx = tab_index.map(|idx| idx % matches.len());
|
|
1829
|
+
let selected_idx = highlight_idx.unwrap_or(0);
|
|
1830
|
+
let current_page = selected_idx / 5;
|
|
1831
|
+
let s_start_idx = current_page * 5;
|
|
1832
|
+
let s_end_idx = std::cmp::min(s_start_idx + 5, matches.len());
|
|
1833
|
+
match_count = s_end_idx - s_start_idx;
|
|
1834
|
+
|
|
1835
|
+
println!();
|
|
1836
|
+
println!(
|
|
1837
|
+
" {BLUE}Suggestions ({}/{}){RESET}",
|
|
1838
|
+
current_page + 1,
|
|
1839
|
+
total_pages
|
|
1840
|
+
);
|
|
1841
|
+
for i in s_start_idx..s_end_idx {
|
|
1842
|
+
let (cmd, desc) = matches[i];
|
|
1843
|
+
if Some(i) == highlight_idx {
|
|
1844
|
+
println!(" {BLUE}▶ {:<16}{RESET} {DIM}- {}{RESET}", cmd, desc);
|
|
1845
|
+
} else {
|
|
1846
|
+
println!(" {DIM}{:<16} - {}{RESET}", cmd, desc);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
} else if search_query.starts_with('$') {
|
|
1851
|
+
// Parse skill name (excluding arguments)
|
|
1852
|
+
let skill_query = search_query
|
|
1853
|
+
.split_whitespace()
|
|
1854
|
+
.next()
|
|
1855
|
+
.unwrap_or(search_query);
|
|
1856
|
+
let prefix = &skill_query[1..].to_lowercase();
|
|
1857
|
+
let skills = load_all_available_skills(current_dir);
|
|
1858
|
+
let matches: Vec<_> = skills
|
|
1859
|
+
.iter()
|
|
1860
|
+
.filter(|skill| skill.name.to_lowercase().starts_with(prefix))
|
|
1861
|
+
.collect();
|
|
1862
|
+
|
|
1863
|
+
if !matches.is_empty() {
|
|
1864
|
+
let total_pages = matches.len().div_ceil(5);
|
|
1865
|
+
let highlight_idx = tab_index.map(|idx| idx % matches.len());
|
|
1866
|
+
let selected_idx = highlight_idx.unwrap_or(0);
|
|
1867
|
+
let current_page = selected_idx / 5;
|
|
1868
|
+
let s_start_idx = current_page * 5;
|
|
1869
|
+
let s_end_idx = std::cmp::min(s_start_idx + 5, matches.len());
|
|
1870
|
+
match_count = s_end_idx - s_start_idx;
|
|
1871
|
+
|
|
1872
|
+
println!();
|
|
1873
|
+
println!(
|
|
1874
|
+
" {BLUE}Suggestions ({}/{}){RESET}",
|
|
1875
|
+
current_page + 1,
|
|
1876
|
+
total_pages
|
|
1877
|
+
);
|
|
1878
|
+
for i in s_start_idx..s_end_idx {
|
|
1879
|
+
let skill = matches[i];
|
|
1880
|
+
let desc = skill
|
|
1881
|
+
.description
|
|
1882
|
+
.as_deref()
|
|
1883
|
+
.unwrap_or("No description provided");
|
|
1884
|
+
let max_desc_len = width.saturating_sub(35);
|
|
1885
|
+
let truncated_desc = if desc.chars().count() > max_desc_len {
|
|
1886
|
+
let mut s: String = desc.chars().take(max_desc_len.saturating_sub(3)).collect();
|
|
1887
|
+
s.push_str("...");
|
|
1888
|
+
s
|
|
1889
|
+
} else {
|
|
1890
|
+
desc.to_string()
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
if Some(i) == highlight_idx {
|
|
1894
|
+
println!(
|
|
1895
|
+
" {BLUE}▶ ${:<20}{RESET} {MINT}[Skill]{RESET} {DIM}{}{RESET}",
|
|
1896
|
+
skill.name, truncated_desc
|
|
1897
|
+
);
|
|
1898
|
+
} else {
|
|
1899
|
+
println!(
|
|
1900
|
+
" {DIM}${:<20} [Skill] {}{RESET}",
|
|
1901
|
+
skill.name, truncated_desc
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
(match_count, start_idx)
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
pub fn input_cursor_column(input_chars: &[char], cursor_pos: usize) -> usize {
|
|
1912
|
+
let (term_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
|
|
1913
|
+
let width = term_width as usize;
|
|
1914
|
+
let prefix = "› ";
|
|
1915
|
+
let input_width = width.saturating_sub(2);
|
|
1916
|
+
let content_max_len = input_width.saturating_sub(prefix.chars().count());
|
|
1917
|
+
|
|
1918
|
+
let mut start_idx = 0;
|
|
1919
|
+
if input_chars.len() > content_max_len && cursor_pos >= content_max_len {
|
|
1920
|
+
start_idx = (cursor_pos + 5).saturating_sub(content_max_len);
|
|
1921
|
+
if start_idx + content_max_len > input_chars.len() {
|
|
1922
|
+
start_idx = input_chars.len() - content_max_len;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
let cursor_pos_clamped = std::cmp::min(cursor_pos, input_chars.len());
|
|
1927
|
+
let start_idx_clamped = std::cmp::min(start_idx, cursor_pos_clamped);
|
|
1928
|
+
let visual_cursor_pos: usize = input_chars[start_idx_clamped..cursor_pos_clamped]
|
|
1929
|
+
.iter()
|
|
1930
|
+
.copied()
|
|
1931
|
+
.map(char_visual_width)
|
|
1932
|
+
.sum();
|
|
1933
|
+
4 + visual_cursor_pos
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
pub fn position_input_cursor(input_chars: &[char], cursor_pos: usize, match_count: usize) {
|
|
1937
|
+
let up_lines = if match_count > 0 { 4 + match_count } else { 2 };
|
|
1938
|
+
print!(
|
|
1939
|
+
"\x1b[{}A\x1b[{}G",
|
|
1940
|
+
up_lines,
|
|
1941
|
+
input_cursor_column(input_chars, cursor_pos)
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
pub fn clear_input_box() {
|
|
1946
|
+
print!("\r\x1b[1A\x1b[J");
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
pub fn redraw_input_box(
|
|
1950
|
+
input_chars: &[char],
|
|
1951
|
+
cursor_pos: usize,
|
|
1952
|
+
placeholder: &str,
|
|
1953
|
+
model: &str,
|
|
1954
|
+
path_str: &str,
|
|
1955
|
+
tab_base_input: Option<&str>,
|
|
1956
|
+
tab_index: Option<usize>,
|
|
1957
|
+
current_dir: &Path,
|
|
1958
|
+
) {
|
|
1959
|
+
clear_input_box();
|
|
1960
|
+
let (match_count, _) = draw_input_box(
|
|
1961
|
+
input_chars,
|
|
1962
|
+
cursor_pos,
|
|
1963
|
+
placeholder,
|
|
1964
|
+
model,
|
|
1965
|
+
path_str,
|
|
1966
|
+
tab_base_input,
|
|
1967
|
+
tab_index,
|
|
1968
|
+
current_dir,
|
|
1969
|
+
);
|
|
1970
|
+
position_input_cursor(input_chars, cursor_pos, match_count);
|
|
1971
|
+
let _ = io::stdout().flush();
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
pub fn read_line_interactive(
|
|
1975
|
+
_provider: &str,
|
|
1976
|
+
model: &str,
|
|
1977
|
+
path_str: &str,
|
|
1978
|
+
current_dir: &Path,
|
|
1979
|
+
) -> Result<Option<InteractiveInput>> {
|
|
1980
|
+
use crossterm::event::{self, Event, KeyCode};
|
|
1981
|
+
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
1982
|
+
use crossterm::tty::IsTty;
|
|
1983
|
+
|
|
1984
|
+
if !io::stdout().is_tty() {
|
|
1985
|
+
let mut input = String::new();
|
|
1986
|
+
io::stdin().read_line(&mut input)?;
|
|
1987
|
+
let trimmed = input.trim().to_owned();
|
|
1988
|
+
if trimmed.is_empty() {
|
|
1989
|
+
return Ok(None);
|
|
1990
|
+
}
|
|
1991
|
+
return Ok(Some(InteractiveInput {
|
|
1992
|
+
text: trimmed,
|
|
1993
|
+
pasted_image: None,
|
|
1994
|
+
}));
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
let mut input_chars: Vec<char> = Vec::new();
|
|
1998
|
+
let mut cursor_pos = 0;
|
|
1999
|
+
let placeholder = "Ask anything...";
|
|
2000
|
+
let mut ctrl_d_pressed = false;
|
|
2001
|
+
let mut pasted_image: Option<String> = None;
|
|
2002
|
+
let mut paste_contents: Vec<(String, String)> = Vec::new();
|
|
2003
|
+
let mut last_paste_time: Option<std::time::Instant> = None;
|
|
2004
|
+
|
|
2005
|
+
// Track tab autocomplete state
|
|
2006
|
+
let mut tab_base_input: Option<String> = None;
|
|
2007
|
+
let mut tab_index: Option<usize> = None;
|
|
2008
|
+
|
|
2009
|
+
let (match_count, _) = draw_input_box(
|
|
2010
|
+
&input_chars,
|
|
2011
|
+
cursor_pos,
|
|
2012
|
+
placeholder,
|
|
2013
|
+
model,
|
|
2014
|
+
path_str,
|
|
2015
|
+
None,
|
|
2016
|
+
None,
|
|
2017
|
+
current_dir,
|
|
2018
|
+
);
|
|
2019
|
+
position_input_cursor(&input_chars, cursor_pos, match_count);
|
|
2020
|
+
let _ = io::stdout().flush();
|
|
2021
|
+
|
|
2022
|
+
enable_raw_mode()?;
|
|
2023
|
+
let _ = crossterm::execute!(io::stdout(), crossterm::event::EnableBracketedPaste);
|
|
2024
|
+
|
|
2025
|
+
let result = loop {
|
|
2026
|
+
if event::poll(std::time::Duration::from_millis(100))? {
|
|
2027
|
+
let ev = event::read()?;
|
|
2028
|
+
if let Event::Paste(text) = &ev {
|
|
2029
|
+
let clean_text = text.trim_end_matches(&['\r', '\n'][..]).to_string();
|
|
2030
|
+
let lines_count = clean_text.lines().count();
|
|
2031
|
+
if lines_count > 1 || clean_text.chars().count() > 100 {
|
|
2032
|
+
let paste_id = paste_contents.len() + 1;
|
|
2033
|
+
let placeholder_str = if lines_count > 1 {
|
|
2034
|
+
format!("[Pasted text #{} +{} lines]", paste_id, lines_count - 1)
|
|
2035
|
+
} else {
|
|
2036
|
+
format!("[Pasted text #{}]", paste_id)
|
|
2037
|
+
};
|
|
2038
|
+
paste_contents.push((placeholder_str.clone(), clean_text));
|
|
2039
|
+
for c in placeholder_str.chars() {
|
|
2040
|
+
input_chars.insert(cursor_pos, c);
|
|
2041
|
+
cursor_pos += 1;
|
|
2042
|
+
}
|
|
2043
|
+
} else {
|
|
2044
|
+
for c in clean_text.chars() {
|
|
2045
|
+
input_chars.insert(cursor_pos, c);
|
|
2046
|
+
cursor_pos += 1;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
disable_raw_mode()?;
|
|
2050
|
+
redraw_input_box(
|
|
2051
|
+
&input_chars,
|
|
2052
|
+
cursor_pos,
|
|
2053
|
+
placeholder,
|
|
2054
|
+
model,
|
|
2055
|
+
path_str,
|
|
2056
|
+
None,
|
|
2057
|
+
None,
|
|
2058
|
+
current_dir,
|
|
2059
|
+
);
|
|
2060
|
+
enable_raw_mode()?;
|
|
2061
|
+
last_paste_time = Some(std::time::Instant::now());
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
if let Event::Key(key_event) = ev
|
|
2065
|
+
&& key_event.kind == event::KeyEventKind::Press
|
|
2066
|
+
{
|
|
2067
|
+
let is_ctrl_d = matches!(key_event.code, KeyCode::Char('d'))
|
|
2068
|
+
&& key_event
|
|
2069
|
+
.modifiers
|
|
2070
|
+
.contains(crossterm::event::KeyModifiers::CONTROL);
|
|
2071
|
+
|
|
2072
|
+
if !is_ctrl_d {
|
|
2073
|
+
ctrl_d_pressed = false;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// Reset tab autocomplete state if any key other than Tab, Up, or Down is pressed
|
|
2077
|
+
if key_event.code != KeyCode::Tab
|
|
2078
|
+
&& key_event.code != KeyCode::Up
|
|
2079
|
+
&& key_event.code != KeyCode::Down
|
|
2080
|
+
{
|
|
2081
|
+
tab_base_input = None;
|
|
2082
|
+
tab_index = None;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
match key_event.code {
|
|
2086
|
+
KeyCode::Char('c')
|
|
2087
|
+
if key_event
|
|
2088
|
+
.modifiers
|
|
2089
|
+
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
|
2090
|
+
{
|
|
2091
|
+
input_chars.clear();
|
|
2092
|
+
cursor_pos = 0;
|
|
2093
|
+
disable_raw_mode()?;
|
|
2094
|
+
redraw_input_box(
|
|
2095
|
+
&input_chars,
|
|
2096
|
+
cursor_pos,
|
|
2097
|
+
placeholder,
|
|
2098
|
+
model,
|
|
2099
|
+
path_str,
|
|
2100
|
+
None,
|
|
2101
|
+
None,
|
|
2102
|
+
current_dir,
|
|
2103
|
+
);
|
|
2104
|
+
enable_raw_mode()?;
|
|
2105
|
+
}
|
|
2106
|
+
KeyCode::Char('d')
|
|
2107
|
+
if key_event
|
|
2108
|
+
.modifiers
|
|
2109
|
+
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
|
2110
|
+
{
|
|
2111
|
+
if ctrl_d_pressed {
|
|
2112
|
+
disable_raw_mode()?;
|
|
2113
|
+
clear_input_box();
|
|
2114
|
+
let _ = io::stdout().flush();
|
|
2115
|
+
break Some(InteractiveInput {
|
|
2116
|
+
text: "/exit".to_string(),
|
|
2117
|
+
pasted_image: None,
|
|
2118
|
+
});
|
|
2119
|
+
} else {
|
|
2120
|
+
ctrl_d_pressed = true;
|
|
2121
|
+
disable_raw_mode()?;
|
|
2122
|
+
print!(
|
|
2123
|
+
"\r\x1b[3B\r\x1b[2K{WARN}Press Ctrl+D again to exit{RESET}\x1b[3A"
|
|
2124
|
+
);
|
|
2125
|
+
print!("\x1b[{}G", input_cursor_column(&input_chars, cursor_pos));
|
|
2126
|
+
let _ = io::stdout().flush();
|
|
2127
|
+
enable_raw_mode()?;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
KeyCode::Char('v')
|
|
2131
|
+
if key_event
|
|
2132
|
+
.modifiers
|
|
2133
|
+
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
|
2134
|
+
{
|
|
2135
|
+
if let Ok(Some(uri)) = image::read_clipboard_image() {
|
|
2136
|
+
if let Some(ref mut current) = pasted_image {
|
|
2137
|
+
current.push(' ');
|
|
2138
|
+
current.push_str(&uri);
|
|
2139
|
+
} else {
|
|
2140
|
+
pasted_image = Some(uri);
|
|
2141
|
+
}
|
|
2142
|
+
insert_image_placeholder(&mut input_chars, &mut cursor_pos);
|
|
2143
|
+
|
|
2144
|
+
disable_raw_mode()?;
|
|
2145
|
+
redraw_input_box(
|
|
2146
|
+
&input_chars,
|
|
2147
|
+
cursor_pos,
|
|
2148
|
+
placeholder,
|
|
2149
|
+
model,
|
|
2150
|
+
path_str,
|
|
2151
|
+
None,
|
|
2152
|
+
None,
|
|
2153
|
+
current_dir,
|
|
2154
|
+
);
|
|
2155
|
+
enable_raw_mode()?;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
KeyCode::Char(c) if input_chars.len() < 10000 => {
|
|
2159
|
+
input_chars.insert(cursor_pos, c);
|
|
2160
|
+
cursor_pos += 1;
|
|
2161
|
+
|
|
2162
|
+
disable_raw_mode()?;
|
|
2163
|
+
redraw_input_box(
|
|
2164
|
+
&input_chars,
|
|
2165
|
+
cursor_pos,
|
|
2166
|
+
placeholder,
|
|
2167
|
+
model,
|
|
2168
|
+
path_str,
|
|
2169
|
+
None,
|
|
2170
|
+
None,
|
|
2171
|
+
current_dir,
|
|
2172
|
+
);
|
|
2173
|
+
enable_raw_mode()?;
|
|
2174
|
+
}
|
|
2175
|
+
KeyCode::Backspace if cursor_pos > 0 => {
|
|
2176
|
+
cursor_pos -= 1;
|
|
2177
|
+
input_chars.remove(cursor_pos);
|
|
2178
|
+
|
|
2179
|
+
disable_raw_mode()?;
|
|
2180
|
+
redraw_input_box(
|
|
2181
|
+
&input_chars,
|
|
2182
|
+
cursor_pos,
|
|
2183
|
+
placeholder,
|
|
2184
|
+
model,
|
|
2185
|
+
path_str,
|
|
2186
|
+
None,
|
|
2187
|
+
None,
|
|
2188
|
+
current_dir,
|
|
2189
|
+
);
|
|
2190
|
+
enable_raw_mode()?;
|
|
2191
|
+
}
|
|
2192
|
+
KeyCode::Tab => {
|
|
2193
|
+
let base = match &tab_base_input {
|
|
2194
|
+
Some(b) => b.clone(),
|
|
2195
|
+
None => {
|
|
2196
|
+
let current_str: String = input_chars.iter().collect();
|
|
2197
|
+
tab_base_input = Some(current_str.clone());
|
|
2198
|
+
current_str
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
if base.starts_with('/') {
|
|
2203
|
+
let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
|
|
2204
|
+
.iter()
|
|
2205
|
+
.filter(|(cmd, _)| cmd.starts_with(&base))
|
|
2206
|
+
.collect();
|
|
2207
|
+
|
|
2208
|
+
if !matches.is_empty() {
|
|
2209
|
+
let idx = tab_index.unwrap_or(0) % matches.len();
|
|
2210
|
+
let completed = format!("{} ", matches[idx].0);
|
|
2211
|
+
input_chars = completed.chars().collect();
|
|
2212
|
+
cursor_pos = input_chars.len();
|
|
2213
|
+
|
|
2214
|
+
// Highlight currently completed item in suggestions
|
|
2215
|
+
let current_highlight = Some(idx);
|
|
2216
|
+
tab_index = Some(idx + 1);
|
|
2217
|
+
|
|
2218
|
+
disable_raw_mode()?;
|
|
2219
|
+
redraw_input_box(
|
|
2220
|
+
&input_chars,
|
|
2221
|
+
cursor_pos,
|
|
2222
|
+
placeholder,
|
|
2223
|
+
model,
|
|
2224
|
+
path_str,
|
|
2225
|
+
Some(&base),
|
|
2226
|
+
current_highlight,
|
|
2227
|
+
current_dir,
|
|
2228
|
+
);
|
|
2229
|
+
enable_raw_mode()?;
|
|
2230
|
+
}
|
|
2231
|
+
} else if base.starts_with('$') {
|
|
2232
|
+
let skill_query = base.split_whitespace().next().unwrap_or(&base);
|
|
2233
|
+
let prefix = &skill_query[1..].to_lowercase();
|
|
2234
|
+
let skills = load_all_available_skills(current_dir);
|
|
2235
|
+
let matches: Vec<_> = skills
|
|
2236
|
+
.iter()
|
|
2237
|
+
.filter(|s| s.name.to_lowercase().starts_with(prefix))
|
|
2238
|
+
.collect();
|
|
2239
|
+
|
|
2240
|
+
if !matches.is_empty() {
|
|
2241
|
+
let idx = tab_index.unwrap_or(0) % matches.len();
|
|
2242
|
+
let completed = format!("${} ", matches[idx].name);
|
|
2243
|
+
input_chars = completed.chars().collect();
|
|
2244
|
+
cursor_pos = input_chars.len();
|
|
2245
|
+
|
|
2246
|
+
let current_highlight = Some(idx);
|
|
2247
|
+
tab_index = Some(idx + 1);
|
|
2248
|
+
|
|
2249
|
+
disable_raw_mode()?;
|
|
2250
|
+
redraw_input_box(
|
|
2251
|
+
&input_chars,
|
|
2252
|
+
cursor_pos,
|
|
2253
|
+
placeholder,
|
|
2254
|
+
model,
|
|
2255
|
+
path_str,
|
|
2256
|
+
Some(&base),
|
|
2257
|
+
current_highlight,
|
|
2258
|
+
current_dir,
|
|
2259
|
+
);
|
|
2260
|
+
enable_raw_mode()?;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
KeyCode::Down => {
|
|
2265
|
+
let base = match &tab_base_input {
|
|
2266
|
+
Some(b) => b.clone(),
|
|
2267
|
+
None => {
|
|
2268
|
+
let current_str: String = input_chars.iter().collect();
|
|
2269
|
+
tab_base_input = Some(current_str.clone());
|
|
2270
|
+
current_str
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
if base.starts_with('/') {
|
|
2275
|
+
let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
|
|
2276
|
+
.iter()
|
|
2277
|
+
.filter(|(cmd, _)| cmd.starts_with(&base))
|
|
2278
|
+
.collect();
|
|
2279
|
+
|
|
2280
|
+
if !matches.is_empty() {
|
|
2281
|
+
let new_idx = match tab_index {
|
|
2282
|
+
Some(idx) => (idx + 1) % matches.len(),
|
|
2283
|
+
None => 0,
|
|
2284
|
+
};
|
|
2285
|
+
tab_index = Some(new_idx);
|
|
2286
|
+
let completed = format!("{} ", matches[new_idx].0);
|
|
2287
|
+
input_chars = completed.chars().collect();
|
|
2288
|
+
cursor_pos = input_chars.len();
|
|
2289
|
+
|
|
2290
|
+
disable_raw_mode()?;
|
|
2291
|
+
redraw_input_box(
|
|
2292
|
+
&input_chars,
|
|
2293
|
+
cursor_pos,
|
|
2294
|
+
placeholder,
|
|
2295
|
+
model,
|
|
2296
|
+
path_str,
|
|
2297
|
+
Some(&base),
|
|
2298
|
+
Some(new_idx),
|
|
2299
|
+
current_dir,
|
|
2300
|
+
);
|
|
2301
|
+
enable_raw_mode()?;
|
|
2302
|
+
}
|
|
2303
|
+
} else if base.starts_with('$') {
|
|
2304
|
+
let skill_query = base.split_whitespace().next().unwrap_or(&base);
|
|
2305
|
+
let prefix = &skill_query[1..].to_lowercase();
|
|
2306
|
+
let skills = load_all_available_skills(current_dir);
|
|
2307
|
+
let matches: Vec<_> = skills
|
|
2308
|
+
.iter()
|
|
2309
|
+
.filter(|s| s.name.to_lowercase().starts_with(prefix))
|
|
2310
|
+
.collect();
|
|
2311
|
+
|
|
2312
|
+
if !matches.is_empty() {
|
|
2313
|
+
let new_idx = match tab_index {
|
|
2314
|
+
Some(idx) => (idx + 1) % matches.len(),
|
|
2315
|
+
None => 0,
|
|
2316
|
+
};
|
|
2317
|
+
tab_index = Some(new_idx);
|
|
2318
|
+
let completed = format!("${} ", matches[new_idx].name);
|
|
2319
|
+
input_chars = completed.chars().collect();
|
|
2320
|
+
cursor_pos = input_chars.len();
|
|
2321
|
+
|
|
2322
|
+
disable_raw_mode()?;
|
|
2323
|
+
redraw_input_box(
|
|
2324
|
+
&input_chars,
|
|
2325
|
+
cursor_pos,
|
|
2326
|
+
placeholder,
|
|
2327
|
+
model,
|
|
2328
|
+
path_str,
|
|
2329
|
+
Some(&base),
|
|
2330
|
+
Some(new_idx),
|
|
2331
|
+
current_dir,
|
|
2332
|
+
);
|
|
2333
|
+
enable_raw_mode()?;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
KeyCode::Up => {
|
|
2338
|
+
let base = match &tab_base_input {
|
|
2339
|
+
Some(b) => b.clone(),
|
|
2340
|
+
None => {
|
|
2341
|
+
let current_str: String = input_chars.iter().collect();
|
|
2342
|
+
tab_base_input = Some(current_str.clone());
|
|
2343
|
+
current_str
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
|
|
2347
|
+
if base.starts_with('/') {
|
|
2348
|
+
let matches: Vec<_> = AUTOCOMPLETE_COMMANDS
|
|
2349
|
+
.iter()
|
|
2350
|
+
.filter(|(cmd, _)| cmd.starts_with(&base))
|
|
2351
|
+
.collect();
|
|
2352
|
+
|
|
2353
|
+
if !matches.is_empty() {
|
|
2354
|
+
let new_idx = match tab_index {
|
|
2355
|
+
Some(idx) => {
|
|
2356
|
+
if idx == 0 {
|
|
2357
|
+
matches.len() - 1
|
|
2358
|
+
} else {
|
|
2359
|
+
idx - 1
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
None => matches.len() - 1,
|
|
2363
|
+
};
|
|
2364
|
+
tab_index = Some(new_idx);
|
|
2365
|
+
let completed = format!("{} ", matches[new_idx].0);
|
|
2366
|
+
input_chars = completed.chars().collect();
|
|
2367
|
+
cursor_pos = input_chars.len();
|
|
2368
|
+
|
|
2369
|
+
disable_raw_mode()?;
|
|
2370
|
+
redraw_input_box(
|
|
2371
|
+
&input_chars,
|
|
2372
|
+
cursor_pos,
|
|
2373
|
+
placeholder,
|
|
2374
|
+
model,
|
|
2375
|
+
path_str,
|
|
2376
|
+
Some(&base),
|
|
2377
|
+
Some(new_idx),
|
|
2378
|
+
current_dir,
|
|
2379
|
+
);
|
|
2380
|
+
enable_raw_mode()?;
|
|
2381
|
+
}
|
|
2382
|
+
} else if base.starts_with('$') {
|
|
2383
|
+
let skill_query = base.split_whitespace().next().unwrap_or(&base);
|
|
2384
|
+
let prefix = &skill_query[1..].to_lowercase();
|
|
2385
|
+
let skills = load_all_available_skills(current_dir);
|
|
2386
|
+
let matches: Vec<_> = skills
|
|
2387
|
+
.iter()
|
|
2388
|
+
.filter(|s| s.name.to_lowercase().starts_with(prefix))
|
|
2389
|
+
.collect();
|
|
2390
|
+
|
|
2391
|
+
if !matches.is_empty() {
|
|
2392
|
+
let new_idx = match tab_index {
|
|
2393
|
+
Some(idx) => {
|
|
2394
|
+
if idx == 0 {
|
|
2395
|
+
matches.len() - 1
|
|
2396
|
+
} else {
|
|
2397
|
+
idx - 1
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
None => matches.len() - 1,
|
|
2401
|
+
};
|
|
2402
|
+
tab_index = Some(new_idx);
|
|
2403
|
+
let completed = format!("${} ", matches[new_idx].name);
|
|
2404
|
+
input_chars = completed.chars().collect();
|
|
2405
|
+
cursor_pos = input_chars.len();
|
|
2406
|
+
|
|
2407
|
+
disable_raw_mode()?;
|
|
2408
|
+
redraw_input_box(
|
|
2409
|
+
&input_chars,
|
|
2410
|
+
cursor_pos,
|
|
2411
|
+
placeholder,
|
|
2412
|
+
model,
|
|
2413
|
+
path_str,
|
|
2414
|
+
Some(&base),
|
|
2415
|
+
Some(new_idx),
|
|
2416
|
+
current_dir,
|
|
2417
|
+
);
|
|
2418
|
+
enable_raw_mode()?;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
KeyCode::Left => {
|
|
2423
|
+
while cursor_pos > 0 {
|
|
2424
|
+
cursor_pos -= 1;
|
|
2425
|
+
if cursor_pos == 0 || !is_thai_zero_width(input_chars[cursor_pos]) {
|
|
2426
|
+
break;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
disable_raw_mode()?;
|
|
2430
|
+
redraw_input_box(
|
|
2431
|
+
&input_chars,
|
|
2432
|
+
cursor_pos,
|
|
2433
|
+
placeholder,
|
|
2434
|
+
model,
|
|
2435
|
+
path_str,
|
|
2436
|
+
None,
|
|
2437
|
+
None,
|
|
2438
|
+
current_dir,
|
|
2439
|
+
);
|
|
2440
|
+
enable_raw_mode()?;
|
|
2441
|
+
}
|
|
2442
|
+
KeyCode::Right => {
|
|
2443
|
+
while cursor_pos < input_chars.len() {
|
|
2444
|
+
cursor_pos += 1;
|
|
2445
|
+
if cursor_pos == input_chars.len()
|
|
2446
|
+
|| !is_thai_zero_width(input_chars[cursor_pos])
|
|
2447
|
+
{
|
|
2448
|
+
break;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
disable_raw_mode()?;
|
|
2452
|
+
redraw_input_box(
|
|
2453
|
+
&input_chars,
|
|
2454
|
+
cursor_pos,
|
|
2455
|
+
placeholder,
|
|
2456
|
+
model,
|
|
2457
|
+
path_str,
|
|
2458
|
+
None,
|
|
2459
|
+
None,
|
|
2460
|
+
current_dir,
|
|
2461
|
+
);
|
|
2462
|
+
enable_raw_mode()?;
|
|
2463
|
+
}
|
|
2464
|
+
KeyCode::Enter => {
|
|
2465
|
+
if let Some(time) = last_paste_time
|
|
2466
|
+
&& time.elapsed() < std::time::Duration::from_millis(100)
|
|
2467
|
+
{
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
disable_raw_mode()?;
|
|
2471
|
+
clear_input_box();
|
|
2472
|
+
let input_str: String = input_chars.iter().collect();
|
|
2473
|
+
|
|
2474
|
+
let mut expanded_str = input_str.clone();
|
|
2475
|
+
for (placeholder_str, content) in &paste_contents {
|
|
2476
|
+
expanded_str = expanded_str.replace(placeholder_str, content);
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
let lines: Vec<&str> = expanded_str.lines().collect();
|
|
2480
|
+
if lines.len() <= 1 {
|
|
2481
|
+
println!(" {BLUE}You ›{RESET} {}", expanded_str);
|
|
2482
|
+
} else {
|
|
2483
|
+
for (idx, line) in lines.iter().enumerate() {
|
|
2484
|
+
if idx == 0 {
|
|
2485
|
+
println!(" {BLUE}You ›{RESET} {}", line);
|
|
2486
|
+
} else {
|
|
2487
|
+
println!(" {}", line);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
let _ = io::stdout().flush();
|
|
2492
|
+
|
|
2493
|
+
break Some(InteractiveInput {
|
|
2494
|
+
text: expanded_str,
|
|
2495
|
+
pasted_image,
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
KeyCode::Esc => {
|
|
2499
|
+
disable_raw_mode()?;
|
|
2500
|
+
clear_input_box();
|
|
2501
|
+
let _ = io::stdout().flush();
|
|
2502
|
+
break None;
|
|
2503
|
+
}
|
|
2504
|
+
_ => {}
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
let _ = crossterm::execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
|
2511
|
+
Ok(result)
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
pub fn insert_image_placeholder(input_chars: &mut Vec<char>, cursor_pos: &mut usize) {
|
|
2515
|
+
let input: String = input_chars.iter().collect();
|
|
2516
|
+
let mut idx = 1;
|
|
2517
|
+
while input.contains(&format!("[Image #{}]", idx)) {
|
|
2518
|
+
idx += 1;
|
|
2519
|
+
}
|
|
2520
|
+
let placeholder = format!("[Image #{}]", idx);
|
|
2521
|
+
let placeholder_chars = placeholder.chars().collect::<Vec<_>>();
|
|
2522
|
+
input_chars.splice(*cursor_pos..*cursor_pos, placeholder_chars.iter().copied());
|
|
2523
|
+
*cursor_pos += placeholder_chars.len();
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
pub fn format_path_with_tilde(path: &Path) -> String {
|
|
2527
|
+
let path_str = path.to_string_lossy().to_string();
|
|
2528
|
+
if let Some(home) = dirs::home_dir() {
|
|
2529
|
+
let home_str = home.to_string_lossy().to_string();
|
|
2530
|
+
if path_str.starts_with(&home_str) {
|
|
2531
|
+
return path_str.replacen(&home_str, "~", 1);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
path_str
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
pub fn format_placeholders(s: &str) -> String {
|
|
2538
|
+
let mut result = String::new();
|
|
2539
|
+
let chars = s.chars().collect::<Vec<_>>();
|
|
2540
|
+
let mut i = 0;
|
|
2541
|
+
while i < chars.len() {
|
|
2542
|
+
if chars[i] == '[' && i + 12 < chars.len() {
|
|
2543
|
+
let slice: String = chars[i..i + 13].iter().collect();
|
|
2544
|
+
if slice == "[Pasted text #"
|
|
2545
|
+
&& let Some(end_offset) = chars[i..].iter().position(|&c| c == ']')
|
|
2546
|
+
{
|
|
2547
|
+
let end_idx = i + end_offset;
|
|
2548
|
+
let inside: String = chars[i + 1..end_idx].iter().collect();
|
|
2549
|
+
result.push('[');
|
|
2550
|
+
result.push_str(BLUE);
|
|
2551
|
+
result.push_str(&inside);
|
|
2552
|
+
result.push_str("\x1b[39m");
|
|
2553
|
+
result.push(']');
|
|
2554
|
+
i = end_idx + 1;
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
result.push(chars[i]);
|
|
2559
|
+
i += 1;
|
|
2560
|
+
}
|
|
2561
|
+
result
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
pub fn is_thai_zero_width(c: char) -> bool {
|
|
2565
|
+
let cp = c as u32;
|
|
2566
|
+
cp == 0x0E31 || (0x0E34..=0x0E3A).contains(&cp) || (0x0E47..=0x0E4E).contains(&cp)
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
pub fn char_visual_width(c: char) -> usize {
|
|
2570
|
+
if is_thai_zero_width(c) { 0 } else { 1 }
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
pub fn string_visual_width(s: &str) -> usize {
|
|
2574
|
+
s.chars().map(char_visual_width).sum()
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
pub fn active_model<'a>(provider: &str, config: &'a mint_core::MintConfig) -> &'a str {
|
|
2578
|
+
match provider {
|
|
2579
|
+
"anthropic" => &config.anthropic_model,
|
|
2580
|
+
"openai" => &config.openai_model,
|
|
2581
|
+
"openrouter" => &config.openrouter_model,
|
|
2582
|
+
"deepseek" => &config.deepseek_model,
|
|
2583
|
+
"huggingface" => &config.hf_model,
|
|
2584
|
+
"local_openai" => &config.local_model_name,
|
|
2585
|
+
"ollama" => &config.ollama_model,
|
|
2586
|
+
p if p.starts_with("custom:") => {
|
|
2587
|
+
if let Some(id) = p.strip_prefix("custom:")
|
|
2588
|
+
&& let Some(selections) = config
|
|
2589
|
+
.extra
|
|
2590
|
+
.get("customModelSelections")
|
|
2591
|
+
.and_then(|v| v.as_object())
|
|
2592
|
+
&& let Some(m) = selections.get(id).and_then(|v| v.as_str())
|
|
2593
|
+
{
|
|
2594
|
+
return m;
|
|
2595
|
+
}
|
|
2596
|
+
config
|
|
2597
|
+
.resolve_custom_provider(p)
|
|
2598
|
+
.and_then(|cp| cp.models.first())
|
|
2599
|
+
.map(|m| m.model_id.as_str())
|
|
2600
|
+
.unwrap_or("")
|
|
2601
|
+
}
|
|
2602
|
+
_ => &config.gemini_model,
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
pub fn confirm(prompt: &str) -> Result<bool> {
|
|
2607
|
+
let clean_prompt = prompt
|
|
2608
|
+
.replace(" [y/N] ", "")
|
|
2609
|
+
.replace(" [y/N]", "")
|
|
2610
|
+
.trim()
|
|
2611
|
+
.to_string();
|
|
2612
|
+
|
|
2613
|
+
if SESSION_APPROVED.load(std::sync::atomic::Ordering::Relaxed) {
|
|
2614
|
+
println!(" {} {MINT}Approve (session-wide){RESET}", clean_prompt);
|
|
2615
|
+
return Ok(true);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
use crossterm::event::{self, Event, KeyCode};
|
|
2619
|
+
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
2620
|
+
use crossterm::tty::IsTty;
|
|
2621
|
+
|
|
2622
|
+
if !io::stdout().is_tty() || enable_raw_mode().is_err() {
|
|
2623
|
+
print!(" {} [y/N] ", clean_prompt);
|
|
2624
|
+
let _ = io::stdout().flush();
|
|
2625
|
+
let mut answer = String::new();
|
|
2626
|
+
io::stdin().read_line(&mut answer)?;
|
|
2627
|
+
return Ok(matches!(
|
|
2628
|
+
answer.trim().to_ascii_lowercase().as_str(),
|
|
2629
|
+
"y" | "yes"
|
|
2630
|
+
));
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
let _ = disable_raw_mode();
|
|
2634
|
+
println!(" \x1b[1;97m{}\x1b[0m", clean_prompt);
|
|
2635
|
+
|
|
2636
|
+
let options_with_desc = [
|
|
2637
|
+
("Approve (Once)", "Allow single execution"),
|
|
2638
|
+
(
|
|
2639
|
+
"Approve (Entire Session)",
|
|
2640
|
+
"Auto-approve throughout session",
|
|
2641
|
+
),
|
|
2642
|
+
("Deny", "Cancel action"),
|
|
2643
|
+
];
|
|
2644
|
+
let mut selected = 0;
|
|
2645
|
+
|
|
2646
|
+
let print_choices = |selected: usize| -> Result<()> {
|
|
2647
|
+
for (i, (opt, desc)) in options_with_desc.iter().enumerate() {
|
|
2648
|
+
if i == selected {
|
|
2649
|
+
println!(
|
|
2650
|
+
" {BLUE}❯ {}. {:<24}{RESET} {DIM}- {}{RESET}",
|
|
2651
|
+
i + 1,
|
|
2652
|
+
opt,
|
|
2653
|
+
desc
|
|
2654
|
+
);
|
|
2655
|
+
} else {
|
|
2656
|
+
println!(" {DIM}{}. {:<24} - {}{RESET}", i + 1, opt, desc);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
io::stdout().flush()?;
|
|
2660
|
+
Ok(())
|
|
2661
|
+
};
|
|
2662
|
+
|
|
2663
|
+
print_choices(selected)?;
|
|
2664
|
+
|
|
2665
|
+
let _ = enable_raw_mode();
|
|
2666
|
+
|
|
2667
|
+
let choice = loop {
|
|
2668
|
+
match event::poll(std::time::Duration::from_millis(100)) {
|
|
2669
|
+
Ok(true) => match event::read() {
|
|
2670
|
+
Ok(Event::Key(key_event)) => {
|
|
2671
|
+
if key_event.kind == event::KeyEventKind::Press {
|
|
2672
|
+
let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
|
|
2673
|
+
&& key_event
|
|
2674
|
+
.modifiers
|
|
2675
|
+
.contains(crossterm::event::KeyModifiers::CONTROL);
|
|
2676
|
+
if is_ctrl_c {
|
|
2677
|
+
break 2;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
match key_event.code {
|
|
2681
|
+
KeyCode::Up => {
|
|
2682
|
+
if selected > 0 {
|
|
2683
|
+
selected -= 1;
|
|
2684
|
+
} else {
|
|
2685
|
+
selected = options_with_desc.len() - 1;
|
|
2686
|
+
}
|
|
2687
|
+
let _ = disable_raw_mode();
|
|
2688
|
+
print!("\x1b[{}A\x1b[J", options_with_desc.len());
|
|
2689
|
+
let _ = print_choices(selected);
|
|
2690
|
+
let _ = enable_raw_mode();
|
|
2691
|
+
}
|
|
2692
|
+
KeyCode::Down => {
|
|
2693
|
+
if selected < options_with_desc.len() - 1 {
|
|
2694
|
+
selected += 1;
|
|
2695
|
+
} else {
|
|
2696
|
+
selected = 0;
|
|
2697
|
+
}
|
|
2698
|
+
let _ = disable_raw_mode();
|
|
2699
|
+
print!("\x1b[{}A\x1b[J", options_with_desc.len());
|
|
2700
|
+
let _ = print_choices(selected);
|
|
2701
|
+
let _ = enable_raw_mode();
|
|
2702
|
+
}
|
|
2703
|
+
KeyCode::Tab => {
|
|
2704
|
+
if selected < options_with_desc.len() - 1 {
|
|
2705
|
+
selected += 1;
|
|
2706
|
+
} else {
|
|
2707
|
+
selected = 0;
|
|
2708
|
+
}
|
|
2709
|
+
let _ = disable_raw_mode();
|
|
2710
|
+
print!("\x1b[{}A\x1b[J", options_with_desc.len());
|
|
2711
|
+
let _ = print_choices(selected);
|
|
2712
|
+
let _ = enable_raw_mode();
|
|
2713
|
+
}
|
|
2714
|
+
KeyCode::Char('1') | KeyCode::Char('a') | KeyCode::Char('y') => {
|
|
2715
|
+
break 0;
|
|
2716
|
+
}
|
|
2717
|
+
KeyCode::Char('2') | KeyCode::Char('s') => {
|
|
2718
|
+
break 1;
|
|
2719
|
+
}
|
|
2720
|
+
KeyCode::Char('3') | KeyCode::Char('n') | KeyCode::Char('c') => {
|
|
2721
|
+
break 2;
|
|
2722
|
+
}
|
|
2723
|
+
KeyCode::Enter => {
|
|
2724
|
+
break selected;
|
|
2725
|
+
}
|
|
2726
|
+
KeyCode::Esc => {
|
|
2727
|
+
break 2;
|
|
2728
|
+
}
|
|
2729
|
+
_ => {}
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
Ok(_) => {}
|
|
2734
|
+
Err(_) => {
|
|
2735
|
+
break 2;
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
Ok(false) => {}
|
|
2739
|
+
Err(_) => {
|
|
2740
|
+
break 2;
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
};
|
|
2744
|
+
|
|
2745
|
+
let _ = disable_raw_mode();
|
|
2746
|
+
print!("\x1b[{}A\x1b[J", options_with_desc.len() + 1);
|
|
2747
|
+
|
|
2748
|
+
let result_str = match choice {
|
|
2749
|
+
0 => format!("{MINT}Approve (Once){RESET}"),
|
|
2750
|
+
1 => format!("{MINT}Approve (Entire Session){RESET}"),
|
|
2751
|
+
_ => format!("{ERROR}Deny{RESET}"),
|
|
2752
|
+
};
|
|
2753
|
+
println!(" {} {}", clean_prompt, result_str);
|
|
2754
|
+
let _ = io::stdout().flush();
|
|
2755
|
+
|
|
2756
|
+
match choice {
|
|
2757
|
+
0 => Ok(true),
|
|
2758
|
+
1 => {
|
|
2759
|
+
SESSION_APPROVED.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
2760
|
+
Ok(true)
|
|
2761
|
+
}
|
|
2762
|
+
_ => Ok(false),
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
pub fn prompt_interactive_select(
|
|
2767
|
+
title: &str,
|
|
2768
|
+
options: &[String],
|
|
2769
|
+
current_selection: &str,
|
|
2770
|
+
) -> Result<Option<String>> {
|
|
2771
|
+
use crossterm::event::{self, Event, KeyCode};
|
|
2772
|
+
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
2773
|
+
use crossterm::tty::IsTty;
|
|
2774
|
+
|
|
2775
|
+
if !io::stdout().is_tty() || enable_raw_mode().is_err() {
|
|
2776
|
+
return Ok(None);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
let _ = disable_raw_mode();
|
|
2780
|
+
println!(
|
|
2781
|
+
" {BLUE}{} (Use ↑/↓ to navigate, Enter to select, Esc to cancel):{RESET}",
|
|
2782
|
+
title
|
|
2783
|
+
);
|
|
2784
|
+
|
|
2785
|
+
let mut selected = options
|
|
2786
|
+
.iter()
|
|
2787
|
+
.position(|p| p == current_selection)
|
|
2788
|
+
.unwrap_or(0);
|
|
2789
|
+
|
|
2790
|
+
let print_choices = |selected: usize| -> Result<()> {
|
|
2791
|
+
for (i, opt) in options.iter().enumerate() {
|
|
2792
|
+
if i == selected {
|
|
2793
|
+
println!(" {BLUE}❯ {}{RESET}", opt);
|
|
2794
|
+
} else {
|
|
2795
|
+
println!(" {DIM}{}{RESET}", opt);
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
io::stdout().flush()?;
|
|
2799
|
+
Ok(())
|
|
2800
|
+
};
|
|
2801
|
+
|
|
2802
|
+
print_choices(selected)?;
|
|
2803
|
+
|
|
2804
|
+
let _ = enable_raw_mode();
|
|
2805
|
+
|
|
2806
|
+
let choice = loop {
|
|
2807
|
+
match event::poll(std::time::Duration::from_millis(100)) {
|
|
2808
|
+
Ok(true) => match event::read() {
|
|
2809
|
+
Ok(Event::Key(key_event)) => {
|
|
2810
|
+
if key_event.kind == event::KeyEventKind::Press {
|
|
2811
|
+
let is_ctrl_c = matches!(key_event.code, KeyCode::Char('c'))
|
|
2812
|
+
&& key_event
|
|
2813
|
+
.modifiers
|
|
2814
|
+
.contains(crossterm::event::KeyModifiers::CONTROL);
|
|
2815
|
+
if is_ctrl_c {
|
|
2816
|
+
break None;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
match key_event.code {
|
|
2820
|
+
KeyCode::Up => {
|
|
2821
|
+
if selected > 0 {
|
|
2822
|
+
selected -= 1;
|
|
2823
|
+
} else {
|
|
2824
|
+
selected = options.len() - 1;
|
|
2825
|
+
}
|
|
2826
|
+
let _ = disable_raw_mode();
|
|
2827
|
+
print!("\x1b[{}A\x1b[J", options.len());
|
|
2828
|
+
let _ = print_choices(selected);
|
|
2829
|
+
let _ = enable_raw_mode();
|
|
2830
|
+
}
|
|
2831
|
+
KeyCode::Down | KeyCode::Tab => {
|
|
2832
|
+
if selected < options.len() - 1 {
|
|
2833
|
+
selected += 1;
|
|
2834
|
+
} else {
|
|
2835
|
+
selected = 0;
|
|
2836
|
+
}
|
|
2837
|
+
let _ = disable_raw_mode();
|
|
2838
|
+
print!("\x1b[{}A\x1b[J", options.len());
|
|
2839
|
+
let _ = print_choices(selected);
|
|
2840
|
+
let _ = enable_raw_mode();
|
|
2841
|
+
}
|
|
2842
|
+
KeyCode::Enter => {
|
|
2843
|
+
break Some(selected);
|
|
2844
|
+
}
|
|
2845
|
+
KeyCode::Esc => {
|
|
2846
|
+
break None;
|
|
2847
|
+
}
|
|
2848
|
+
_ => {}
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
Ok(_) => {}
|
|
2853
|
+
Err(_) => {
|
|
2854
|
+
break None;
|
|
2855
|
+
}
|
|
2856
|
+
},
|
|
2857
|
+
Ok(false) => {}
|
|
2858
|
+
Err(_) => {
|
|
2859
|
+
break None;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
|
|
2864
|
+
let _ = disable_raw_mode();
|
|
2865
|
+
print!("\x1b[{}A\x1b[J", options.len() + 1);
|
|
2866
|
+
let _ = io::stdout().flush();
|
|
2867
|
+
|
|
2868
|
+
match choice {
|
|
2869
|
+
Some(idx) => Ok(Some(options[idx].clone())),
|
|
2870
|
+
None => Ok(None),
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
#[cfg(test)]
|
|
2875
|
+
mod tests {
|
|
2876
|
+
use super::*;
|
|
2877
|
+
|
|
2878
|
+
#[test]
|
|
2879
|
+
fn inserts_one_image_placeholder_at_cursor() {
|
|
2880
|
+
let mut chars = "ask ".chars().collect::<Vec<_>>();
|
|
2881
|
+
let mut cursor = chars.len();
|
|
2882
|
+
|
|
2883
|
+
insert_image_placeholder(&mut chars, &mut cursor);
|
|
2884
|
+
|
|
2885
|
+
assert_eq!(chars.iter().collect::<String>(), "ask [Image #1]");
|
|
2886
|
+
assert_eq!(cursor, "ask [Image #1]".chars().count());
|
|
2887
|
+
}
|
|
2888
|
+
}
|