anveesa 0.4.0 → 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 CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.0"
63
+ version = "0.4.2"
64
64
  dependencies = [
65
65
  "anyhow",
66
66
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
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 if images_available => {
1768
- // Ctrl+V — paste image from clipboard
1769
- match grab_clipboard_image() {
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
- None => {
1775
- print!("\x07");
1776
- let _ = io::stdout().flush();
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
+ }
@@ -222,6 +222,7 @@ mod tests {
222
222
  workspace_context: None,
223
223
  history: vec![],
224
224
  image: None,
225
+ mcp: None,
225
226
  }
226
227
  }
227
228
 
@@ -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
- body["tools"] = json!(tools::definitions(policy.allows_write_tools()));
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);
@@ -686,14 +715,23 @@ fn build_messages(
686
715
  prompt_cache: bool,
687
716
  ) -> Vec<Value> {
688
717
  let mut messages = Vec::new();
689
- if let Some(system) = &request.system {
690
- messages.push(json!({ "role": "system", "content": system }));
691
- }
692
- if let Some(workspace_context) = &request.workspace_context {
693
- messages.push(json!({ "role": "system", "content": workspace_context }));
694
- }
695
- messages
696
- .push(json!({ "role": "system", "content": tools::guidance(policy.allows_write_tools()) }));
718
+
719
+ // Merge all system-level content into a single system message.
720
+ // Many providers (Qwen3, etc.) only allow one system message and require
721
+ // it to be the very first message — multiple system messages cause HTTP 400.
722
+ let guidance = tools::guidance(policy.allows_write_tools());
723
+ let system_content = [
724
+ request.system.as_deref(),
725
+ request.workspace_context.as_deref(),
726
+ Some(guidance.as_str()),
727
+ ]
728
+ .into_iter()
729
+ .flatten()
730
+ .collect::<Vec<_>>()
731
+ .join("\n\n");
732
+
733
+ messages.push(json!({ "role": "system", "content": system_content }));
734
+
697
735
  for message in &request.history {
698
736
  let role = match message.role {
699
737
  ChatRole::User => "user",
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. Prefer tools over guessing. \
29
- If you need to inspect, read, list, search, or check something, call the relevant tool immediately; \
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" => list_dir(arguments).await,
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" => read_file(arguments).await,
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" => edit_file(arguments).await,
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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
663
+ .replace("&quot;", "\"").replace("&#39;", "'").replace("&nbsp;", " ")
664
+ .replace("&#x27;", "'").replace("&#x2F;", "/");
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
- FileOp { verb: String, path: String, added: usize, removed: usize },
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
- KeyCode::Char('v') if modifiers.contains(KeyModifiers::CONTROL) && app.images_available => {
343
- if let Some(img) = crate::grab_clipboard_image() {
344
- app.pending_image = Some(img);
345
- app.last_image_fp = None; // force re-attach
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
- PageUp/Dn or scroll wheel to scroll history".into(),
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
- TuiEvent::FileOp { verb, path, added, removed },
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 hints = "PageUp/Dn · scroll · /help";
925
- let left = format!(" {}", app.cwd);
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());