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