anveesa 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/config.rs +20 -0
- package/src/lib.rs +47 -7
- package/src/mcp.rs +237 -0
- package/src/provider/command.rs +1 -0
- package/src/provider/mod.rs +3 -1
- package/src/provider/openai_compatible.rs +31 -2
- package/src/tools.rs +241 -5
- package/src/tui.rs +150 -15
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
package/src/config.rs
CHANGED
|
@@ -174,6 +174,23 @@ pub struct AppConfig {
|
|
|
174
174
|
|
|
175
175
|
#[serde(default)]
|
|
176
176
|
pub providers: BTreeMap<String, ProviderConfig>,
|
|
177
|
+
|
|
178
|
+
/// MCP servers to connect to on startup.
|
|
179
|
+
/// Example config:
|
|
180
|
+
/// [mcp.filesystem]
|
|
181
|
+
/// command = "npx"
|
|
182
|
+
/// args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
183
|
+
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
184
|
+
pub mcp: BTreeMap<String, McpServerConfig>,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
188
|
+
pub struct McpServerConfig {
|
|
189
|
+
pub command: String,
|
|
190
|
+
#[serde(default)]
|
|
191
|
+
pub args: Vec<String>,
|
|
192
|
+
#[serde(default)]
|
|
193
|
+
pub env: BTreeMap<String, String>,
|
|
177
194
|
}
|
|
178
195
|
|
|
179
196
|
impl AppConfig {
|
|
@@ -351,6 +368,7 @@ impl AppConfig {
|
|
|
351
368
|
Self {
|
|
352
369
|
default_provider: Some("sumopod".to_string()),
|
|
353
370
|
providers,
|
|
371
|
+
mcp: BTreeMap::new(),
|
|
354
372
|
}
|
|
355
373
|
}
|
|
356
374
|
|
|
@@ -375,6 +393,7 @@ impl AppConfig {
|
|
|
375
393
|
self.default_provider = user_config.default_provider;
|
|
376
394
|
}
|
|
377
395
|
self.providers.extend(user_config.providers);
|
|
396
|
+
self.mcp.extend(user_config.mcp);
|
|
378
397
|
}
|
|
379
398
|
|
|
380
399
|
pub fn provider_name<'a>(&'a self, requested: Option<&'a str>) -> Result<&'a str> {
|
|
@@ -620,6 +639,7 @@ fn load_user_config_for_write(path: &Path) -> Result<AppConfig> {
|
|
|
620
639
|
return Ok(AppConfig {
|
|
621
640
|
default_provider: Some("sumopod".to_string()),
|
|
622
641
|
providers: BTreeMap::new(),
|
|
642
|
+
mcp: BTreeMap::new(),
|
|
623
643
|
});
|
|
624
644
|
}
|
|
625
645
|
|
package/src/lib.rs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
pub mod cli;
|
|
2
2
|
pub mod config;
|
|
3
|
+
pub mod mcp;
|
|
3
4
|
pub mod provider;
|
|
4
5
|
pub mod tools;
|
|
5
6
|
pub mod tui;
|
|
@@ -154,6 +155,14 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
154
155
|
|
|
155
156
|
// ── TUI mode ──────────────────────────────────────────────────────────────
|
|
156
157
|
if is_tty {
|
|
158
|
+
// Connect to any configured MCP servers.
|
|
159
|
+
let mcp_manager = if !config.mcp.is_empty() {
|
|
160
|
+
let m = mcp::McpManager::connect(&config.mcp).await;
|
|
161
|
+
Some(std::sync::Arc::new(m))
|
|
162
|
+
} else {
|
|
163
|
+
None
|
|
164
|
+
};
|
|
165
|
+
|
|
157
166
|
// Spawn a background task to read keyboard events (crossterm::event::read is blocking).
|
|
158
167
|
let (key_tx, key_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
159
168
|
tokio::task::spawn_blocking(move || {
|
|
@@ -183,6 +192,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
183
192
|
workspace_context,
|
|
184
193
|
policy,
|
|
185
194
|
key_rx,
|
|
195
|
+
mcp_manager,
|
|
186
196
|
);
|
|
187
197
|
|
|
188
198
|
tui::run(app).await?;
|
|
@@ -624,6 +634,7 @@ async fn ask_streaming(
|
|
|
624
634
|
workspace_context: workspace_context.map(str::to_string),
|
|
625
635
|
history: history.to_vec(),
|
|
626
636
|
image,
|
|
637
|
+
mcp: None, // REPL path: MCP not yet wired here
|
|
627
638
|
};
|
|
628
639
|
|
|
629
640
|
let result = provider::ask(config, &provider_name, request, policy, &tx).await;
|
|
@@ -1764,16 +1775,20 @@ fn read_prompt_line(
|
|
|
1764
1775
|
buffer.clear_all();
|
|
1765
1776
|
display_rows = redraw!();
|
|
1766
1777
|
}
|
|
1767
|
-
22
|
|
1768
|
-
// Ctrl+V — paste image
|
|
1769
|
-
|
|
1770
|
-
Some(img)
|
|
1778
|
+
22 => {
|
|
1779
|
+
// Ctrl+V — universal paste: image first, then clipboard text
|
|
1780
|
+
if images_available {
|
|
1781
|
+
if let Some(img) = grab_clipboard_image() {
|
|
1771
1782
|
ctrl_v_image = Some(img);
|
|
1772
1783
|
display_rows = redraw!();
|
|
1784
|
+
continue;
|
|
1773
1785
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1786
|
+
}
|
|
1787
|
+
// Fall back to clipboard text via pbpaste / xclip
|
|
1788
|
+
if let Some(text) = read_clipboard_text() {
|
|
1789
|
+
if !text.is_empty() {
|
|
1790
|
+
buffer.push_text(&text.replace('\r', "\n"));
|
|
1791
|
+
display_rows = redraw!();
|
|
1777
1792
|
}
|
|
1778
1793
|
}
|
|
1779
1794
|
}
|
|
@@ -2313,6 +2328,31 @@ fn repl_history_path() -> Option<PathBuf> {
|
|
|
2313
2328
|
Some(dir.join("history"))
|
|
2314
2329
|
}
|
|
2315
2330
|
|
|
2331
|
+
pub fn read_clipboard_text() -> Option<String> {
|
|
2332
|
+
#[cfg(target_os = "macos")]
|
|
2333
|
+
{
|
|
2334
|
+
let out = std::process::Command::new("pbpaste").output().ok()?;
|
|
2335
|
+
if out.status.success() {
|
|
2336
|
+
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
2337
|
+
if !text.is_empty() { return Some(text); }
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
#[cfg(not(target_os = "macos"))]
|
|
2341
|
+
for (cmd, args) in &[
|
|
2342
|
+
("wl-paste", vec!["--no-newline"]),
|
|
2343
|
+
("xclip", vec!["-o", "-selection", "clipboard"]),
|
|
2344
|
+
("xsel", vec!["--clipboard", "--output"]),
|
|
2345
|
+
] {
|
|
2346
|
+
if let Ok(out) = std::process::Command::new(cmd).args(args).output() {
|
|
2347
|
+
if out.status.success() {
|
|
2348
|
+
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
2349
|
+
if !text.is_empty() { return Some(text); }
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
None
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2316
2356
|
pub fn unix_now() -> u64 {
|
|
2317
2357
|
std::time::SystemTime::now()
|
|
2318
2358
|
.duration_since(std::time::UNIX_EPOCH)
|
package/src/mcp.rs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
//! MCP (Model Context Protocol) client — connects to external tool servers
|
|
2
|
+
//! over JSON-RPC / stdio, discovers their tools, and routes calls to them.
|
|
3
|
+
|
|
4
|
+
use std::collections::BTreeMap;
|
|
5
|
+
|
|
6
|
+
use anyhow::{Context, Result, bail};
|
|
7
|
+
use serde_json::{Value, json};
|
|
8
|
+
use tokio::{
|
|
9
|
+
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
|
10
|
+
process::Child,
|
|
11
|
+
sync::Mutex,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
use crate::config::McpServerConfig;
|
|
15
|
+
|
|
16
|
+
// ── Tool definition exposed to the rest of anveesa ───────────────────────────
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Clone)]
|
|
19
|
+
pub struct McpTool {
|
|
20
|
+
/// Namespaced as mcp__{server}__{original_name}
|
|
21
|
+
pub name: String,
|
|
22
|
+
pub description: String,
|
|
23
|
+
pub input_schema: Value,
|
|
24
|
+
pub server: String,
|
|
25
|
+
pub original_name: String,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl McpTool {
|
|
29
|
+
/// Convert to an OpenAI-compatible function definition.
|
|
30
|
+
pub fn to_definition(&self) -> Value {
|
|
31
|
+
json!({
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {
|
|
34
|
+
"name": self.name,
|
|
35
|
+
"description": format!("[MCP:{}] {}", self.server, self.description),
|
|
36
|
+
"parameters": self.input_schema,
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Single MCP server connection ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
struct McpServer {
|
|
45
|
+
name: String,
|
|
46
|
+
stdin: Mutex<tokio::process::ChildStdin>,
|
|
47
|
+
stdout: Mutex<BufReader<tokio::process::ChildStdout>>,
|
|
48
|
+
next_id: Mutex<u64>,
|
|
49
|
+
_child: Child,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl McpServer {
|
|
53
|
+
async fn connect(name: &str, cfg: &McpServerConfig) -> Result<(Self, Vec<McpTool>)> {
|
|
54
|
+
let mut child = tokio::process::Command::new(&cfg.command)
|
|
55
|
+
.args(&cfg.args)
|
|
56
|
+
.envs(&cfg.env)
|
|
57
|
+
.stdin(std::process::Stdio::piped())
|
|
58
|
+
.stdout(std::process::Stdio::piped())
|
|
59
|
+
.stderr(std::process::Stdio::null())
|
|
60
|
+
.kill_on_drop(true)
|
|
61
|
+
.spawn()
|
|
62
|
+
.with_context(|| format!("failed to start MCP server '{name}' ({})", cfg.command))?;
|
|
63
|
+
|
|
64
|
+
let stdin = child.stdin.take().context("MCP server has no stdin")?;
|
|
65
|
+
let stdout = BufReader::new(child.stdout.take().context("MCP server has no stdout")?);
|
|
66
|
+
|
|
67
|
+
let server = Self {
|
|
68
|
+
name: name.to_string(),
|
|
69
|
+
stdin: Mutex::new(stdin),
|
|
70
|
+
stdout: Mutex::new(stdout),
|
|
71
|
+
next_id: Mutex::new(1),
|
|
72
|
+
_child: child,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
server.initialize().await?;
|
|
76
|
+
let tools = server.list_tools().await?;
|
|
77
|
+
Ok((server, tools))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async fn send_msg(&self, msg: Value) -> Result<()> {
|
|
81
|
+
let line = serde_json::to_string(&msg)? + "\n";
|
|
82
|
+
let mut stdin = self.stdin.lock().await;
|
|
83
|
+
stdin.write_all(line.as_bytes()).await?;
|
|
84
|
+
stdin.flush().await?;
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async fn recv_msg(&self) -> Result<Value> {
|
|
89
|
+
let mut stdout = self.stdout.lock().await;
|
|
90
|
+
let mut line = String::new();
|
|
91
|
+
stdout.read_line(&mut line).await.context("MCP server closed")?;
|
|
92
|
+
if line.is_empty() { bail!("MCP server closed connection"); }
|
|
93
|
+
Ok(serde_json::from_str(line.trim())?)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async fn request(&self, method: &str, params: Value) -> Result<Value> {
|
|
97
|
+
let id = {
|
|
98
|
+
let mut n = self.next_id.lock().await;
|
|
99
|
+
let v = *n;
|
|
100
|
+
*n += 1;
|
|
101
|
+
v
|
|
102
|
+
};
|
|
103
|
+
self.send_msg(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).await?;
|
|
104
|
+
|
|
105
|
+
// Drain notifications until we get our response id
|
|
106
|
+
loop {
|
|
107
|
+
let resp = self.recv_msg().await?;
|
|
108
|
+
if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
|
|
109
|
+
if let Some(err) = resp.get("error") {
|
|
110
|
+
bail!("MCP error from '{}': {}", self.name, err);
|
|
111
|
+
}
|
|
112
|
+
return Ok(resp["result"].clone());
|
|
113
|
+
}
|
|
114
|
+
// Drop unmatched messages (notifications, other ids)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async fn notify(&self, method: &str, params: Value) -> Result<()> {
|
|
119
|
+
self.send_msg(json!({ "jsonrpc": "2.0", "method": method, "params": params })).await
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async fn initialize(&self) -> Result<()> {
|
|
123
|
+
self.request("initialize", json!({
|
|
124
|
+
"protocolVersion": "2024-11-05",
|
|
125
|
+
"capabilities": {},
|
|
126
|
+
"clientInfo": { "name": "anveesa", "version": env!("CARGO_PKG_VERSION") }
|
|
127
|
+
})).await?;
|
|
128
|
+
self.notify("notifications/initialized", json!({})).await?;
|
|
129
|
+
Ok(())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async fn list_tools(&self) -> Result<Vec<McpTool>> {
|
|
133
|
+
let result = self.request("tools/list", json!({})).await?;
|
|
134
|
+
let raw = result["tools"].as_array().cloned().unwrap_or_default();
|
|
135
|
+
Ok(raw.into_iter().filter_map(|t| {
|
|
136
|
+
let original_name = t["name"].as_str()?.to_string();
|
|
137
|
+
let description = t["description"].as_str().unwrap_or("").to_string();
|
|
138
|
+
let input_schema = t.get("inputSchema").cloned().unwrap_or(json!({"type":"object","properties":{}}));
|
|
139
|
+
let safe_server = self.name.replace('-', "_").replace('.', "_");
|
|
140
|
+
Some(McpTool {
|
|
141
|
+
name: format!("mcp__{safe_server}__{original_name}"),
|
|
142
|
+
description,
|
|
143
|
+
input_schema,
|
|
144
|
+
server: self.name.clone(),
|
|
145
|
+
original_name,
|
|
146
|
+
})
|
|
147
|
+
}).collect())
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async fn call_tool(&self, original_name: &str, arguments: Value) -> Result<String> {
|
|
151
|
+
let result = self.request("tools/call", json!({
|
|
152
|
+
"name": original_name,
|
|
153
|
+
"arguments": arguments,
|
|
154
|
+
})).await?;
|
|
155
|
+
|
|
156
|
+
// MCP returns content as an array of typed blocks
|
|
157
|
+
let content = result["content"].as_array().cloned().unwrap_or_default();
|
|
158
|
+
let text = content.iter()
|
|
159
|
+
.filter_map(|c| {
|
|
160
|
+
match c["type"].as_str() {
|
|
161
|
+
Some("text") => c["text"].as_str().map(str::to_string),
|
|
162
|
+
_ => None,
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.collect::<Vec<_>>()
|
|
166
|
+
.join("\n");
|
|
167
|
+
|
|
168
|
+
let is_error = result["isError"].as_bool().unwrap_or(false);
|
|
169
|
+
Ok(json!({ "ok": !is_error, "result": text }).to_string())
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── McpManager — holds all connected servers ──────────────────────────────────
|
|
174
|
+
|
|
175
|
+
pub struct McpManager {
|
|
176
|
+
servers: Vec<(McpServer, Vec<McpTool>)>,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
impl std::fmt::Debug for McpManager {
|
|
180
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
181
|
+
let names: Vec<&str> = self.servers.iter().map(|(s, _)| s.name.as_str()).collect();
|
|
182
|
+
write!(f, "McpManager {{ servers: {:?} }}", names)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
impl McpManager {
|
|
187
|
+
/// Connect to all configured MCP servers. Errors for individual servers are
|
|
188
|
+
/// logged and skipped so one broken server doesn't block startup.
|
|
189
|
+
pub async fn connect(configs: &BTreeMap<String, McpServerConfig>) -> Self {
|
|
190
|
+
let mut servers = Vec::new();
|
|
191
|
+
for (name, cfg) in configs {
|
|
192
|
+
match McpServer::connect(name, cfg).await {
|
|
193
|
+
Ok(pair) => {
|
|
194
|
+
eprintln!("\x1b[2m MCP: connected to '{name}' ({} tools)\x1b[0m",
|
|
195
|
+
pair.1.len());
|
|
196
|
+
servers.push(pair);
|
|
197
|
+
}
|
|
198
|
+
Err(e) => {
|
|
199
|
+
eprintln!("\x1b[33m MCP: failed to connect to '{name}': {e:#}\x1b[0m");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
Self { servers }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// All tool definitions from all connected servers.
|
|
207
|
+
pub fn tool_definitions(&self) -> Vec<Value> {
|
|
208
|
+
self.servers.iter()
|
|
209
|
+
.flat_map(|(_, tools)| tools.iter().map(|t| t.to_definition()))
|
|
210
|
+
.collect()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// All tool names from all connected servers.
|
|
214
|
+
pub fn tool_names(&self) -> Vec<String> {
|
|
215
|
+
self.servers.iter()
|
|
216
|
+
.flat_map(|(_, tools)| tools.iter().map(|t| t.name.clone()))
|
|
217
|
+
.collect()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Dispatch a call to the appropriate MCP server.
|
|
221
|
+
pub async fn call(&self, tool_name: &str, arguments: &str) -> Option<String> {
|
|
222
|
+
let args: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
|
223
|
+
for (server, tools) in &self.servers {
|
|
224
|
+
if let Some(tool) = tools.iter().find(|t| t.name == tool_name) {
|
|
225
|
+
return Some(match server.call_tool(&tool.original_name, args).await {
|
|
226
|
+
Ok(r) => r,
|
|
227
|
+
Err(e) => json!({ "ok": false, "error": e.to_string() }).to_string(),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
None
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
pub fn is_empty(&self) -> bool {
|
|
235
|
+
self.servers.is_empty()
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/provider/command.rs
CHANGED
package/src/provider/mod.rs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
mod command;
|
|
2
|
-
mod openai_compatible;
|
|
2
|
+
pub mod openai_compatible;
|
|
3
3
|
|
|
4
4
|
use anyhow::{Result, anyhow};
|
|
5
5
|
use serde::{Deserialize, Serialize};
|
|
@@ -51,6 +51,8 @@ pub struct PromptRequest {
|
|
|
51
51
|
pub history: Vec<ChatMessage>,
|
|
52
52
|
/// Optional image grabbed from the clipboard for the current turn only.
|
|
53
53
|
pub image: Option<ImageAttachment>,
|
|
54
|
+
/// Connected MCP servers (runtime only, not part of session history).
|
|
55
|
+
pub mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/// How tool calls that modify the system (write/edit/run) should be handled.
|
|
@@ -86,7 +86,11 @@ pub async fn ask(
|
|
|
86
86
|
body["max_tokens"] = json!(max_tokens);
|
|
87
87
|
}
|
|
88
88
|
if tools_enabled {
|
|
89
|
-
|
|
89
|
+
let mut defs = tools::definitions(policy.allows_write_tools());
|
|
90
|
+
if let Some(m) = &request.mcp {
|
|
91
|
+
defs.extend(m.tool_definitions());
|
|
92
|
+
}
|
|
93
|
+
body["tools"] = json!(defs);
|
|
90
94
|
body["tool_choice"] = json!("auto");
|
|
91
95
|
}
|
|
92
96
|
|
|
@@ -168,7 +172,7 @@ pub async fn ask(
|
|
|
168
172
|
|
|
169
173
|
messages.push(assistant_tool_message(&state));
|
|
170
174
|
for call in &state.tool_calls {
|
|
171
|
-
let content = dispatch_tool(call, policy, &mut approval_state, events).await;
|
|
175
|
+
let content = dispatch_tool(call, policy, &mut approval_state, events, request.mcp.as_deref()).await;
|
|
172
176
|
messages.push(json!({
|
|
173
177
|
"role": "tool",
|
|
174
178
|
"tool_call_id": call.id,
|
|
@@ -209,7 +213,28 @@ async fn dispatch_tool(
|
|
|
209
213
|
policy: ApprovalPolicy,
|
|
210
214
|
approval_state: &mut ToolApprovalState,
|
|
211
215
|
events: &UnboundedSender<StreamEvent>,
|
|
216
|
+
mcp: Option<&crate::mcp::McpManager>,
|
|
212
217
|
) -> String {
|
|
218
|
+
// Route MCP tools directly — no approval policy, no filesystem restrictions.
|
|
219
|
+
if tools::is_mcp_tool(&call.name) {
|
|
220
|
+
let summary = format!("mcp {}", &call.name[5..]); // strip "mcp__"
|
|
221
|
+
let _ = events.send(StreamEvent::ToolCall { summary: summary.clone() });
|
|
222
|
+
let result = if let Some(m) = mcp {
|
|
223
|
+
m.call(&call.name, &call.arguments).await
|
|
224
|
+
.unwrap_or_else(|| serde_json::json!({ "ok": false, "error": "server not found" }).to_string())
|
|
225
|
+
} else {
|
|
226
|
+
serde_json::json!({ "ok": false, "error": "MCP not configured" }).to_string()
|
|
227
|
+
};
|
|
228
|
+
let (ok, err) = parse_tool_result_status(&result);
|
|
229
|
+
let _ = events.send(StreamEvent::ToolResult {
|
|
230
|
+
summary,
|
|
231
|
+
ok,
|
|
232
|
+
elapsed_ms: 0,
|
|
233
|
+
error: err,
|
|
234
|
+
});
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
213
238
|
let summary = tools::describe_call(&call.name, &call.arguments);
|
|
214
239
|
|
|
215
240
|
// Plan tools — display only, no approval or filesystem access needed.
|
|
@@ -316,6 +341,10 @@ async fn dispatch_tool(
|
|
|
316
341
|
result
|
|
317
342
|
}
|
|
318
343
|
|
|
344
|
+
pub fn parse_tool_result_status_pub(result: &str) -> (bool, Option<String>) {
|
|
345
|
+
parse_tool_result_status(result)
|
|
346
|
+
}
|
|
347
|
+
|
|
319
348
|
fn parse_tool_result_status(result: &str) -> (bool, Option<String>) {
|
|
320
349
|
let Ok(json) = serde_json::from_str::<Value>(result) else {
|
|
321
350
|
return (true, None);
|
package/src/tools.rs
CHANGED
|
@@ -25,8 +25,9 @@ const MAX_COMMAND_TIMEOUT_SECS: u64 = 300;
|
|
|
25
25
|
pub fn guidance(include_write: bool) -> String {
|
|
26
26
|
let mut text = String::from(
|
|
27
27
|
"You can use Anveesa tools to inspect the workspace: list directories, find files by name, \
|
|
28
|
-
search text, read capped file snippets, and do a basic public web lookup.
|
|
29
|
-
|
|
28
|
+
search text, read capped file snippets, fetch URLs, run git commands, and do a basic public web lookup. \
|
|
29
|
+
Prefer tools over guessing. \
|
|
30
|
+
If you need to inspect, read, list, search, fetch, or check something, call the relevant tool immediately; \
|
|
30
31
|
do not end a response by saying you will inspect something later.",
|
|
31
32
|
);
|
|
32
33
|
if include_write {
|
|
@@ -56,6 +57,11 @@ pub fn is_write_tool(name: &str) -> bool {
|
|
|
56
57
|
)
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/// Whether a tool name belongs to an MCP server (prefix mcp__).
|
|
61
|
+
pub fn is_mcp_tool(name: &str) -> bool {
|
|
62
|
+
name.starts_with("mcp__")
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
/// A short, human-readable summary of a tool call for confirmation prompts.
|
|
60
66
|
pub fn describe_call(name: &str, arguments: &str) -> String {
|
|
61
67
|
let args: Value = serde_json::from_str(arguments).unwrap_or(Value::Null);
|
|
@@ -74,6 +80,13 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
|
|
|
74
80
|
),
|
|
75
81
|
"read_file" => format!("read file {}", field("path")),
|
|
76
82
|
"web_search" => format!("web search `{}`", field("query")),
|
|
83
|
+
"fetch_url" => format!("fetch {}", field("url")),
|
|
84
|
+
"git_status" => "git status".to_string(),
|
|
85
|
+
"git_diff" => {
|
|
86
|
+
let path = field("path");
|
|
87
|
+
if path.is_empty() { "git diff".to_string() } else { format!("git diff {path}") }
|
|
88
|
+
}
|
|
89
|
+
"git_log" => "git log".to_string(),
|
|
77
90
|
"create_dir" => format!("create directory {}", field("path")),
|
|
78
91
|
"write_file" => format!("write file {}", field("path")),
|
|
79
92
|
"edit_file" => format!("edit file {}", field("path")),
|
|
@@ -202,6 +215,58 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
202
215
|
}
|
|
203
216
|
}
|
|
204
217
|
}),
|
|
218
|
+
json!({
|
|
219
|
+
"type": "function",
|
|
220
|
+
"function": {
|
|
221
|
+
"name": "fetch_url",
|
|
222
|
+
"description": "Fetch the content of a URL and return it as plain text. Strips HTML tags automatically.",
|
|
223
|
+
"parameters": {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"url": { "type": "string", "description": "URL to fetch." },
|
|
227
|
+
"max_chars": { "type": "integer", "description": "Max characters to return (default 40000)." }
|
|
228
|
+
},
|
|
229
|
+
"required": ["url"]
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}),
|
|
233
|
+
json!({
|
|
234
|
+
"type": "function",
|
|
235
|
+
"function": {
|
|
236
|
+
"name": "git_status",
|
|
237
|
+
"description": "Show the current git branch, staged/unstaged changes, and untracked files.",
|
|
238
|
+
"parameters": { "type": "object", "properties": {} }
|
|
239
|
+
}
|
|
240
|
+
}),
|
|
241
|
+
json!({
|
|
242
|
+
"type": "function",
|
|
243
|
+
"function": {
|
|
244
|
+
"name": "git_diff",
|
|
245
|
+
"description": "Show the git diff. Optionally limit to staged changes or a specific file path.",
|
|
246
|
+
"parameters": {
|
|
247
|
+
"type": "object",
|
|
248
|
+
"properties": {
|
|
249
|
+
"staged": { "type": "boolean", "description": "Show staged diff (git diff --staged). Default false." },
|
|
250
|
+
"path": { "type": "string", "description": "Limit diff to this file or directory." },
|
|
251
|
+
"ref": { "type": "string", "description": "Diff against this ref (e.g. HEAD~1, main)." }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}),
|
|
256
|
+
json!({
|
|
257
|
+
"type": "function",
|
|
258
|
+
"function": {
|
|
259
|
+
"name": "git_log",
|
|
260
|
+
"description": "Show recent git commit history.",
|
|
261
|
+
"parameters": {
|
|
262
|
+
"type": "object",
|
|
263
|
+
"properties": {
|
|
264
|
+
"n": { "type": "integer", "description": "Number of commits to show (default 20, max 100)." },
|
|
265
|
+
"path": { "type": "string", "description": "Limit log to commits touching this path." }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}),
|
|
205
270
|
];
|
|
206
271
|
|
|
207
272
|
if include_write {
|
|
@@ -274,14 +339,18 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
274
339
|
|
|
275
340
|
pub async fn run(name: &str, arguments: &str) -> String {
|
|
276
341
|
let result = match name {
|
|
277
|
-
"list_dir"
|
|
342
|
+
"list_dir" => list_dir(arguments).await,
|
|
278
343
|
"find_files" => find_files(arguments).await,
|
|
279
344
|
"search_text" => search_text(arguments).await,
|
|
280
|
-
"read_file"
|
|
345
|
+
"read_file" => read_file(arguments).await,
|
|
281
346
|
"web_search" => web_search(arguments).await,
|
|
347
|
+
"fetch_url" => fetch_url(arguments).await,
|
|
348
|
+
"git_status" => git_status(arguments).await,
|
|
349
|
+
"git_diff" => git_diff(arguments).await,
|
|
350
|
+
"git_log" => git_log(arguments).await,
|
|
282
351
|
"create_dir" => create_dir(arguments).await,
|
|
283
352
|
"write_file" => write_file(arguments).await,
|
|
284
|
-
"edit_file"
|
|
353
|
+
"edit_file" => edit_file(arguments).await,
|
|
285
354
|
"run_command" => run_command(arguments).await,
|
|
286
355
|
_ => Err(anyhow!("unknown tool '{name}'")),
|
|
287
356
|
};
|
|
@@ -503,6 +572,173 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
503
572
|
}))
|
|
504
573
|
}
|
|
505
574
|
|
|
575
|
+
// ── fetch_url ─────────────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
async fn fetch_url(arguments: &str) -> Result<Value> {
|
|
578
|
+
#[derive(Deserialize)]
|
|
579
|
+
struct Args {
|
|
580
|
+
url: String,
|
|
581
|
+
#[serde(default)]
|
|
582
|
+
max_chars: Option<usize>,
|
|
583
|
+
}
|
|
584
|
+
let args: Args = parse_args(arguments)?;
|
|
585
|
+
let url = args.url.trim();
|
|
586
|
+
if url.is_empty() { bail!("url is required"); }
|
|
587
|
+
let max_chars = args.max_chars.unwrap_or(40_000);
|
|
588
|
+
|
|
589
|
+
let response = http_client()
|
|
590
|
+
.get(url)
|
|
591
|
+
.send()
|
|
592
|
+
.await
|
|
593
|
+
.with_context(|| format!("failed to fetch {url}"))?;
|
|
594
|
+
|
|
595
|
+
let status = response.status();
|
|
596
|
+
if !status.is_success() { bail!("HTTP {status}"); }
|
|
597
|
+
|
|
598
|
+
let content_type = response
|
|
599
|
+
.headers()
|
|
600
|
+
.get("content-type")
|
|
601
|
+
.and_then(|v| v.to_str().ok())
|
|
602
|
+
.unwrap_or("text/plain")
|
|
603
|
+
.to_string();
|
|
604
|
+
|
|
605
|
+
let body = response.text().await.context("failed to read response body")?;
|
|
606
|
+
let text = if content_type.contains("html") || content_type.contains("xml") {
|
|
607
|
+
html_to_text(&body)
|
|
608
|
+
} else {
|
|
609
|
+
body
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
let char_count = text.chars().count();
|
|
613
|
+
let truncated = char_count > max_chars;
|
|
614
|
+
let text: String = text.chars().take(max_chars).collect();
|
|
615
|
+
|
|
616
|
+
Ok(json!({
|
|
617
|
+
"ok": true,
|
|
618
|
+
"url": url,
|
|
619
|
+
"content_type": content_type,
|
|
620
|
+
"text": text,
|
|
621
|
+
"truncated": truncated,
|
|
622
|
+
}))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
fn html_to_text(html: &str) -> String {
|
|
626
|
+
let mut out = String::new();
|
|
627
|
+
let mut in_tag = false;
|
|
628
|
+
let mut tag_buf = String::new();
|
|
629
|
+
let mut skip_close: Option<String> = None;
|
|
630
|
+
|
|
631
|
+
for c in html.chars() {
|
|
632
|
+
if let Some(ref close) = skip_close {
|
|
633
|
+
tag_buf.push(c);
|
|
634
|
+
if tag_buf.to_lowercase().ends_with(&format!("</{}>", close)) {
|
|
635
|
+
skip_close = None;
|
|
636
|
+
tag_buf.clear();
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if c == '<' {
|
|
641
|
+
in_tag = true;
|
|
642
|
+
tag_buf.clear();
|
|
643
|
+
} else if c == '>' {
|
|
644
|
+
in_tag = false;
|
|
645
|
+
let raw = tag_buf.trim().to_lowercase();
|
|
646
|
+
let name = raw.trim_start_matches('/').split_whitespace().next().unwrap_or("");
|
|
647
|
+
if matches!(name, "script" | "style") && !raw.starts_with('/') {
|
|
648
|
+
skip_close = Some(name.to_string());
|
|
649
|
+
}
|
|
650
|
+
if matches!(name, "p"|"div"|"h1"|"h2"|"h3"|"h4"|"h5"|"h6"|"br"|"li"|"tr"|"section"|"article") {
|
|
651
|
+
out.push('\n');
|
|
652
|
+
}
|
|
653
|
+
tag_buf.clear();
|
|
654
|
+
} else if in_tag {
|
|
655
|
+
tag_buf.push(c);
|
|
656
|
+
} else {
|
|
657
|
+
out.push(c);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let out = out
|
|
662
|
+
.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
663
|
+
.replace(""", "\"").replace("'", "'").replace(" ", " ")
|
|
664
|
+
.replace("'", "'").replace("/", "/");
|
|
665
|
+
|
|
666
|
+
// Collapse blank lines
|
|
667
|
+
let mut result = String::new();
|
|
668
|
+
let mut blank = 0usize;
|
|
669
|
+
for line in out.lines() {
|
|
670
|
+
let t = line.trim();
|
|
671
|
+
if t.is_empty() {
|
|
672
|
+
blank += 1;
|
|
673
|
+
if blank <= 1 { result.push('\n'); }
|
|
674
|
+
} else {
|
|
675
|
+
blank = 0;
|
|
676
|
+
result.push_str(t);
|
|
677
|
+
result.push('\n');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
result.trim().to_string()
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── git tools ─────────────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
async fn git_status(_arguments: &str) -> Result<Value> {
|
|
686
|
+
let out = tokio::process::Command::new("git")
|
|
687
|
+
.args(["status", "-sb"])
|
|
688
|
+
.kill_on_drop(true)
|
|
689
|
+
.output()
|
|
690
|
+
.await
|
|
691
|
+
.context("failed to run git")?;
|
|
692
|
+
Ok(json!({
|
|
693
|
+
"ok": out.status.success(),
|
|
694
|
+
"output": String::from_utf8_lossy(&out.stdout).trim().to_string(),
|
|
695
|
+
"error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
|
|
696
|
+
}))
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async fn git_diff(arguments: &str) -> Result<Value> {
|
|
700
|
+
#[derive(Deserialize, Default)]
|
|
701
|
+
struct Args {
|
|
702
|
+
#[serde(default)] staged: bool,
|
|
703
|
+
#[serde(default)] path: Option<String>,
|
|
704
|
+
#[serde(rename = "ref", default)] refspec: Option<String>,
|
|
705
|
+
}
|
|
706
|
+
let args: Args = serde_json::from_str(arguments).unwrap_or_default();
|
|
707
|
+
let mut cmd = tokio::process::Command::new("git");
|
|
708
|
+
cmd.arg("diff").kill_on_drop(true);
|
|
709
|
+
if args.staged { cmd.arg("--staged"); }
|
|
710
|
+
if let Some(r) = &args.refspec { cmd.arg(r); }
|
|
711
|
+
if let Some(p) = &args.path { cmd.arg("--").arg(p); }
|
|
712
|
+
let out = cmd.output().await.context("failed to run git diff")?;
|
|
713
|
+
let diff = String::from_utf8_lossy(&out.stdout).to_string();
|
|
714
|
+
let truncated = diff.len() > 30_000;
|
|
715
|
+
Ok(json!({
|
|
716
|
+
"ok": true,
|
|
717
|
+
"diff": if truncated { &diff[..30_000] } else { &diff },
|
|
718
|
+
"truncated": truncated,
|
|
719
|
+
}))
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async fn git_log(arguments: &str) -> Result<Value> {
|
|
723
|
+
#[derive(Deserialize)]
|
|
724
|
+
struct Args {
|
|
725
|
+
#[serde(default = "default_n")] n: usize,
|
|
726
|
+
#[serde(default)] path: Option<String>,
|
|
727
|
+
}
|
|
728
|
+
fn default_n() -> usize { 20 }
|
|
729
|
+
let args: Args = serde_json::from_str(arguments).unwrap_or(Args { n: 20, path: None });
|
|
730
|
+
let n = args.n.clamp(1, 100);
|
|
731
|
+
let mut cmd = tokio::process::Command::new("git");
|
|
732
|
+
cmd.args(["log", "--oneline", "--decorate", &format!("-{n}")]).kill_on_drop(true);
|
|
733
|
+
if let Some(p) = &args.path { cmd.arg("--").arg(p); }
|
|
734
|
+
let out = cmd.output().await.context("failed to run git log")?;
|
|
735
|
+
Ok(json!({
|
|
736
|
+
"ok": out.status.success(),
|
|
737
|
+
"log": String::from_utf8_lossy(&out.stdout).trim().to_string(),
|
|
738
|
+
"error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
|
|
739
|
+
}))
|
|
740
|
+
}
|
|
741
|
+
|
|
506
742
|
async fn create_dir(arguments: &str) -> Result<Value> {
|
|
507
743
|
let args: CreateDirArgs = parse_args(arguments)?;
|
|
508
744
|
let path = resolve_writable_path(&args.path)?;
|
package/src/tui.rs
CHANGED
|
@@ -30,7 +30,8 @@ pub enum TuiEvent {
|
|
|
30
30
|
Status(String),
|
|
31
31
|
ToolCall(String),
|
|
32
32
|
ToolDone { summary: String, ok: bool },
|
|
33
|
-
|
|
33
|
+
// diff: Vec<(is_add, line)>
|
|
34
|
+
FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)> },
|
|
34
35
|
Confirm { summary: String, reply: oneshot::Sender<ApprovalDecision> },
|
|
35
36
|
Usage(Usage),
|
|
36
37
|
Error(String),
|
|
@@ -45,7 +46,7 @@ enum Msg {
|
|
|
45
46
|
User { text: String },
|
|
46
47
|
Assistant { text: String },
|
|
47
48
|
Tool { done: bool, ok: bool, text: String },
|
|
48
|
-
FileOp { verb: String, path: String, added: usize, removed: usize },
|
|
49
|
+
FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)> },
|
|
49
50
|
Error(String),
|
|
50
51
|
System(String),
|
|
51
52
|
}
|
|
@@ -107,6 +108,7 @@ pub struct App {
|
|
|
107
108
|
// mode
|
|
108
109
|
mode: Mode,
|
|
109
110
|
confirm: Option<PendingConfirm>,
|
|
111
|
+
mouse_capture: bool, // when false, terminal native text selection works
|
|
110
112
|
|
|
111
113
|
// history & session
|
|
112
114
|
history: Vec<ChatMessage>,
|
|
@@ -118,6 +120,7 @@ pub struct App {
|
|
|
118
120
|
pub options: AskOptions,
|
|
119
121
|
pub workspace_context: Option<String>,
|
|
120
122
|
pub policy: ApprovalPolicy,
|
|
123
|
+
pub mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
|
|
121
124
|
|
|
122
125
|
// channels
|
|
123
126
|
stream_rx: mpsc::UnboundedReceiver<TuiEvent>,
|
|
@@ -143,6 +146,7 @@ impl App {
|
|
|
143
146
|
workspace_context: Option<String>,
|
|
144
147
|
policy: ApprovalPolicy,
|
|
145
148
|
key_rx: mpsc::UnboundedReceiver<Event>,
|
|
149
|
+
mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
|
|
146
150
|
) -> Self {
|
|
147
151
|
let (stream_tx, stream_rx) = mpsc::unbounded_channel();
|
|
148
152
|
let messages = history
|
|
@@ -183,6 +187,7 @@ impl App {
|
|
|
183
187
|
|
|
184
188
|
mode: Mode::Input,
|
|
185
189
|
confirm: None,
|
|
190
|
+
mouse_capture: true,
|
|
186
191
|
|
|
187
192
|
history,
|
|
188
193
|
session_path,
|
|
@@ -192,6 +197,7 @@ impl App {
|
|
|
192
197
|
options,
|
|
193
198
|
workspace_context,
|
|
194
199
|
policy,
|
|
200
|
+
mcp,
|
|
195
201
|
|
|
196
202
|
stream_rx,
|
|
197
203
|
stream_tx,
|
|
@@ -211,10 +217,54 @@ pub async fn run(mut app: App) -> Result<Vec<ChatMessage>> {
|
|
|
211
217
|
terminal.clear()?;
|
|
212
218
|
let result = event_loop(&mut terminal, &mut app).await;
|
|
213
219
|
ratatui::restore();
|
|
220
|
+
// Always release mouse capture on exit so the terminal works normally.
|
|
214
221
|
crossterm::execute!(std::io::stdout(), DisableMouseCapture)?;
|
|
215
222
|
result
|
|
216
223
|
}
|
|
217
224
|
|
|
225
|
+
fn set_mouse_capture(enabled: bool) {
|
|
226
|
+
if enabled {
|
|
227
|
+
let _ = crossterm::execute!(std::io::stdout(), EnableMouseCapture);
|
|
228
|
+
} else {
|
|
229
|
+
let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
fn write_to_clipboard(text: &str) -> bool {
|
|
235
|
+
// macOS
|
|
236
|
+
if cfg!(target_os = "macos") {
|
|
237
|
+
if let Ok(mut child) = std::process::Command::new("pbcopy").stdin(std::process::Stdio::piped()).spawn() {
|
|
238
|
+
use std::io::Write;
|
|
239
|
+
if let Some(stdin) = child.stdin.as_mut() {
|
|
240
|
+
let _ = stdin.write_all(text.as_bytes());
|
|
241
|
+
}
|
|
242
|
+
return child.wait().map(|s| s.success()).unwrap_or(false);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Linux — try wl-copy (Wayland) then xclip (X11) then xsel
|
|
246
|
+
for cmd in &[
|
|
247
|
+
("wl-copy", vec![]),
|
|
248
|
+
("xclip", vec!["-selection", "clipboard"]),
|
|
249
|
+
("xsel", vec!["--clipboard", "--input"]),
|
|
250
|
+
] {
|
|
251
|
+
if let Ok(mut child) = std::process::Command::new(cmd.0)
|
|
252
|
+
.args(&cmd.1)
|
|
253
|
+
.stdin(std::process::Stdio::piped())
|
|
254
|
+
.spawn()
|
|
255
|
+
{
|
|
256
|
+
use std::io::Write;
|
|
257
|
+
if let Some(stdin) = child.stdin.as_mut() {
|
|
258
|
+
let _ = stdin.write_all(text.as_bytes());
|
|
259
|
+
}
|
|
260
|
+
if child.wait().map(|s| s.success()).unwrap_or(false) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
false
|
|
266
|
+
}
|
|
267
|
+
|
|
218
268
|
async fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> Result<Vec<ChatMessage>> {
|
|
219
269
|
loop {
|
|
220
270
|
terminal.draw(|f| render(f, app))?;
|
|
@@ -244,6 +294,26 @@ async fn handle_event(app: &mut App, event: Event) -> Result<()> {
|
|
|
244
294
|
match event {
|
|
245
295
|
Event::Mouse(MouseEvent { kind, .. }) => handle_mouse(app, kind),
|
|
246
296
|
Event::Key(key) => handle_key(app, key).await?,
|
|
297
|
+
// Cmd+V / terminal paste — insert text, or attach image if paste is empty
|
|
298
|
+
Event::Paste(text) => {
|
|
299
|
+
if app.mode != Mode::Input { return Ok(()); }
|
|
300
|
+
if text.trim().is_empty() {
|
|
301
|
+
// Empty paste = user pasted an image (terminal can't forward it as text)
|
|
302
|
+
// Try to grab it directly from the clipboard
|
|
303
|
+
if app.images_available {
|
|
304
|
+
if let Some(img) = crate::grab_clipboard_image() {
|
|
305
|
+
app.pending_image = Some(img);
|
|
306
|
+
app.last_image_fp = None;
|
|
307
|
+
return Ok(());
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
let normalized = text.replace('\r', "\n");
|
|
312
|
+
app.input.insert_str(app.input_cursor, &normalized);
|
|
313
|
+
app.input_cursor += normalized.len();
|
|
314
|
+
app.hist_idx = None;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
247
317
|
Event::Resize(_, _) => {}
|
|
248
318
|
_ => {}
|
|
249
319
|
}
|
|
@@ -339,13 +409,32 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
339
409
|
delete_word_before(&mut app.input, &mut app.input_cursor);
|
|
340
410
|
app.hist_idx = None;
|
|
341
411
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
412
|
+
// Ctrl+V — universal paste: image first, then clipboard text
|
|
413
|
+
KeyCode::Char('v') if modifiers.contains(KeyModifiers::CONTROL) => {
|
|
414
|
+
if app.images_available {
|
|
415
|
+
if let Some(img) = crate::grab_clipboard_image() {
|
|
416
|
+
app.pending_image = Some(img);
|
|
417
|
+
app.last_image_fp = None;
|
|
418
|
+
return Ok(());
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// No image — fall back to clipboard text
|
|
422
|
+
if let Some(text) = crate::read_clipboard_text() {
|
|
423
|
+
if !text.is_empty() {
|
|
424
|
+
let normalized = text.replace('\r', "\n");
|
|
425
|
+
app.input.insert_str(app.input_cursor, &normalized);
|
|
426
|
+
app.input_cursor += normalized.len();
|
|
427
|
+
app.hist_idx = None;
|
|
428
|
+
}
|
|
346
429
|
}
|
|
347
430
|
}
|
|
348
431
|
|
|
432
|
+
// Ctrl+M — toggle mouse capture (scroll mode ↔ select mode)
|
|
433
|
+
KeyCode::Char('m') if modifiers.contains(KeyModifiers::CONTROL) => {
|
|
434
|
+
app.mouse_capture = !app.mouse_capture;
|
|
435
|
+
set_mouse_capture(app.mouse_capture);
|
|
436
|
+
}
|
|
437
|
+
|
|
349
438
|
// Editing
|
|
350
439
|
KeyCode::Backspace => {
|
|
351
440
|
if app.input_cursor > 0 {
|
|
@@ -450,10 +539,11 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
450
539
|
}
|
|
451
540
|
"/help" => {
|
|
452
541
|
app.messages.push(Msg::System(
|
|
453
|
-
"Commands: /clear /export [path] /model [name] /provider [name] /status /exit\n\
|
|
542
|
+
"Commands: /clear /copy /export [path] /model [name] /provider [name] /status /exit\n\
|
|
454
543
|
Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
|
|
455
544
|
Ctrl+W delete-word Ctrl+U clear-line Ctrl+V paste image\n\
|
|
456
|
-
|
|
545
|
+
Ctrl+M toggle scroll/select mode PageUp/Dn scroll\n\
|
|
546
|
+
[scroll] = mouse wheel scrolls [select] = mouse selects text to copy".into(),
|
|
457
547
|
));
|
|
458
548
|
app.input.clear();
|
|
459
549
|
app.input_cursor = 0;
|
|
@@ -474,6 +564,24 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
474
564
|
app.input_cursor = 0;
|
|
475
565
|
true
|
|
476
566
|
}
|
|
567
|
+
"/copy" => {
|
|
568
|
+
let last = app.messages.iter().rev().find_map(|m| {
|
|
569
|
+
if let Msg::Assistant { text } = m { Some(text.clone()) } else { None }
|
|
570
|
+
});
|
|
571
|
+
match last {
|
|
572
|
+
Some(text) => {
|
|
573
|
+
if write_to_clipboard(&text) {
|
|
574
|
+
app.messages.push(Msg::System("Last response copied to clipboard.".into()));
|
|
575
|
+
} else {
|
|
576
|
+
app.messages.push(Msg::Error("Could not write to clipboard (pbcopy/xclip/wl-copy not found).".into()));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
None => app.messages.push(Msg::System("No assistant response to copy yet.".into())),
|
|
580
|
+
}
|
|
581
|
+
app.input.clear();
|
|
582
|
+
app.input_cursor = 0;
|
|
583
|
+
true
|
|
584
|
+
}
|
|
477
585
|
s if s.starts_with("/export") => {
|
|
478
586
|
let arg = s.strip_prefix("/export").unwrap().trim();
|
|
479
587
|
let path = if arg.is_empty() {
|
|
@@ -576,6 +684,7 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
576
684
|
let history = app.history.clone();
|
|
577
685
|
let workspace_context = app.workspace_context.clone();
|
|
578
686
|
let policy = app.policy;
|
|
687
|
+
let mcp_arc = app.mcp.clone();
|
|
579
688
|
let tui_tx = app.stream_tx.clone();
|
|
580
689
|
let tui_tx2 = app.stream_tx.clone();
|
|
581
690
|
|
|
@@ -588,6 +697,7 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
588
697
|
workspace_context,
|
|
589
698
|
history,
|
|
590
699
|
image,
|
|
700
|
+
mcp: mcp_arc,
|
|
591
701
|
};
|
|
592
702
|
let result = crate::provider::ask(&config, &provider_name, request, policy, &stream_tx_inner).await;
|
|
593
703
|
drop(stream_tx_inner);
|
|
@@ -610,8 +720,13 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
610
720
|
StreamEvent::Status { message } => TuiEvent::Status(message),
|
|
611
721
|
StreamEvent::ToolCall { summary } => TuiEvent::ToolCall(summary),
|
|
612
722
|
StreamEvent::ToolResult { summary, ok, .. } => TuiEvent::ToolDone { summary, ok },
|
|
613
|
-
StreamEvent::FileOp { verb, path, added, removed, .. } =>
|
|
614
|
-
|
|
723
|
+
StreamEvent::FileOp { verb, path, added, removed, preview, .. } => {
|
|
724
|
+
let diff = preview.into_iter().map(|dl| {
|
|
725
|
+
let is_add = matches!(dl.kind, crate::provider::DiffKind::Add);
|
|
726
|
+
(is_add, dl.text)
|
|
727
|
+
}).collect();
|
|
728
|
+
TuiEvent::FileOp { verb, path, added, removed, diff }
|
|
729
|
+
}
|
|
615
730
|
StreamEvent::Confirm { preview, reply } => {
|
|
616
731
|
let summary = match &preview {
|
|
617
732
|
ToolConfirmPreview::FileOp { verb, path, added, removed, .. } =>
|
|
@@ -654,10 +769,10 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
654
769
|
commit_pending_tool(app, ok);
|
|
655
770
|
app.tool_status = "Thinking".to_string();
|
|
656
771
|
}
|
|
657
|
-
TuiEvent::FileOp { verb, path, added, removed } => {
|
|
772
|
+
TuiEvent::FileOp { verb, path, added, removed, diff } => {
|
|
658
773
|
flush_streaming_buf(app);
|
|
659
774
|
commit_pending_tool(app, true);
|
|
660
|
-
app.messages.push(Msg::FileOp { verb, path, added, removed });
|
|
775
|
+
app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
|
|
661
776
|
}
|
|
662
777
|
TuiEvent::Confirm { summary, reply } => {
|
|
663
778
|
flush_streaming_buf(app);
|
|
@@ -799,7 +914,7 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
799
914
|
)));
|
|
800
915
|
lines.push(Line::from(""));
|
|
801
916
|
}
|
|
802
|
-
Msg::FileOp { verb, path, added, removed } => {
|
|
917
|
+
Msg::FileOp { verb, path, added, removed, diff } => {
|
|
803
918
|
lines.push(Line::from(vec![
|
|
804
919
|
Span::styled(" 📄 ", Style::default().fg(Color::Rgb(229, 192, 123))),
|
|
805
920
|
Span::styled(format!("{verb} "), Style::default().fg(Color::White)),
|
|
@@ -807,6 +922,25 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
807
922
|
Span::styled(format!(" +{added}"), Style::default().fg(Color::Rgb(152, 195, 121))),
|
|
808
923
|
Span::styled(format!(" -{removed}"), Style::default().fg(Color::Rgb(224, 108, 117))),
|
|
809
924
|
]));
|
|
925
|
+
// Show inline diff (up to 40 lines)
|
|
926
|
+
for (is_add, line) in diff.iter().take(40) {
|
|
927
|
+
let (prefix, color) = if *is_add {
|
|
928
|
+
(" + ", Color::Rgb(152, 195, 121))
|
|
929
|
+
} else {
|
|
930
|
+
(" - ", Color::Rgb(224, 108, 117))
|
|
931
|
+
};
|
|
932
|
+
let bg = if *is_add { Color::Rgb(20, 35, 20) } else { Color::Rgb(35, 20, 20) };
|
|
933
|
+
lines.push(Line::from(Span::styled(
|
|
934
|
+
format!("{prefix}{}", &line.trim_end().chars().take(width.saturating_sub(6)).collect::<String>()),
|
|
935
|
+
Style::default().fg(color).bg(bg),
|
|
936
|
+
)));
|
|
937
|
+
}
|
|
938
|
+
if diff.len() > 40 {
|
|
939
|
+
lines.push(Line::from(Span::styled(
|
|
940
|
+
format!(" … {} more lines", diff.len() - 40),
|
|
941
|
+
Style::default().fg(Color::DarkGray),
|
|
942
|
+
)));
|
|
943
|
+
}
|
|
810
944
|
lines.push(Line::from(""));
|
|
811
945
|
}
|
|
812
946
|
Msg::Error(msg) => {
|
|
@@ -921,8 +1055,9 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
921
1055
|
Style::default().fg(Color::Black).bg(Color::Rgb(229, 192, 123)),
|
|
922
1056
|
)
|
|
923
1057
|
} else {
|
|
924
|
-
let
|
|
925
|
-
let
|
|
1058
|
+
let mode_tag = if app.mouse_capture { "[scroll]" } else { "[select]" };
|
|
1059
|
+
let hints = "Ctrl+M mode · /copy · /help";
|
|
1060
|
+
let left = format!(" {} {mode_tag}", app.cwd);
|
|
926
1061
|
let right = format!("{hints} ");
|
|
927
1062
|
let gap = (area.width as usize)
|
|
928
1063
|
.saturating_sub(left.chars().count() + right.chars().count());
|