anveesa 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.5.2"
63
+ version = "0.6.0"
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.5.2"
3
+ version = "0.6.0"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/README.md CHANGED
@@ -1,302 +1,207 @@
1
- # Anveesa
1
+ # anveesa
2
2
 
3
- Anveesa is a Rust terminal wrapper for AI providers. It gives you one command,
4
- `anveesa`, for interactive prompts and one-shot terminal requests.
3
+ A fast, terminal-native AI coding assistant. One command interactive TUI or one-shot prompts — backed by any OpenAI-compatible provider.
5
4
 
6
- ## 📦 Publishing to npm
7
-
8
- Anveesa can be published as an npm package via a Node.js wrapper that invokes the Rust binary.
9
-
10
- ### Install from npm
11
-
12
- ```bash
13
- npm install -g anveesa
14
- ```
15
-
16
- ## Install locally
17
-
18
- ```sh
19
- cargo install --path . --force
20
5
  ```
21
-
22
- ## Quick start
23
-
24
- ```sh
25
- anveesa config init
26
- export SUMOPOD_API_KEY="..."
27
- anveesa config set-model "your-sumopod-model"
6
+ npm install -g anveesa
28
7
  anveesa
29
8
  ```
30
9
 
31
- Running `anveesa` with no prompt opens an interactive prompt for the default provider:
10
+ ---
32
11
 
33
- ```text
34
- anveesa | provider: sumopod | model: kimi-k2.6
35
- state | turns:0 | ctx:on | tools:on | writes:ask | memory:new
36
- commands| /clear reset | /exit quit
37
- approve | y once | a all for current turn | enter no
12
+ ## Features
38
13
 
39
- anveesa[0]>
40
- ```
14
+ - **Full TUI** — streaming output, diff previews on file edits, cost tracking, plan display
15
+ - **28 built-in tools** — file ops, git, web search, deep-fetch, screenshot, notes, run commands
16
+ - **Multi-provider** — Claude, GPT-4o, Gemini, DeepSeek, local Ollama, any OpenAI-compatible API
17
+ - **Model routing** — `fast_model` for read-only tool rounds, main model for synthesis
18
+ - **Parallel tools** — read-only tool calls run concurrently; write tools stay sequential for approval
19
+ - **Approval flow** — every write/run tool shows a full diff preview; approve once, for the turn, or deny
20
+ - **Multi-image paste** — ⌘V (macOS) / Ctrl+V to queue multiple clipboard images per turn
21
+ - **Project memory** — `.anveesa.md` in your repo root is auto-injected into every session
22
+ - **Path sandboxing** — write tools blocked outside the git root by default
23
+ - **Conversation search** — Ctrl+R to search through all messages
41
24
 
42
- Interactive mode keeps running after each answer. It also keeps the conversation
43
- context for the same provider/model/system in the same working directory, even
44
- after restarting Anveesa. Use `/clear` to reset that context and `/exit` to
45
- return to the shell.
25
+ ---
46
26
 
47
- The prompt has full line editing, and your input history is remembered across
48
- sessions (stored next to the config as `history`). The active conversation is
49
- stored next to it as `session.json`. Use the up/down arrows to recall previous
50
- prompts.
27
+ ## Install
51
28
 
52
- To include an image, copy it to the clipboard and run `/attach` before your
53
- question:
29
+ ```bash
30
+ # npm (recommended — downloads prebuilt binary)
31
+ npm install -g anveesa
54
32
 
55
- ```text
56
- /attach
57
- [image attached for the next message]
58
- ❯ what is in this screenshot?
33
+ # Cargo (builds from source)
34
+ cargo install --path .
59
35
  ```
60
36
 
61
- You can also attach an image file directly:
37
+ ---
62
38
 
63
- ```text
64
- ❯ /attach ./screenshot.png
65
- ❯ explain this UI
66
- ```
39
+ ## Quick start
67
40
 
68
- Image input works with OpenAI-compatible providers and models that support
69
- vision. Terminals do not paste image pixels into the text prompt itself, so use
70
- `/attach` instead of pressing paste and expecting the image to appear inline.
41
+ ```bash
42
+ # 1. Initialize config
43
+ anveesa config init
71
44
 
72
- `ctx:on` means Anveesa sends lightweight terminal context with each request:
73
- current directory, parent directory, git root/branch/status when available, and
74
- a capped directory listing. This lets the model answer questions like "where are
75
- you?" using the terminal workspace instead of guessing.
45
+ # 2. Set your API key
46
+ export ANTHROPIC_API_KEY="sk-ant-..."
47
+ export OPENAI_API_KEY="sk-..."
76
48
 
77
- `tools:on` means OpenAI-compatible providers can ask Anveesa to inspect the
78
- workspace: list directories, find files by name, search text, read capped file
79
- snippets, and do a basic web lookup. The tools can inspect paths outside the
80
- current project, but obvious secret files such as SSH keys and `.env` files are
81
- blocked.
49
+ # 3. Launch the TUI
50
+ anveesa
82
51
 
83
- `writes:ask` covers the workspace-modifying tools — `create_dir`, `write_file`,
84
- `edit_file`, and `run_command`. By default Anveesa asks for confirmation on the
85
- terminal before each one:
52
+ # 4. One-shot prompt
53
+ anveesa "explain the auth middleware in this repo"
86
54
 
87
- ```text
88
- allow run command `cargo test`? [y]es/[a]ll this turn/[N]o
55
+ # 5. Specific provider/model
56
+ anveesa --provider anthropic --model claude-sonnet-4-6 "refactor src/auth.ts"
89
57
  ```
90
58
 
91
- Answer `a` to approve the remaining write/run tools for the current assistant
92
- turn, which is useful when scaffolding several files.
59
+ ---
93
60
 
94
- The indicator reflects the active policy: `writes:ask` (confirm each action,
95
- the interactive default and the default for one-shot prompts typed directly in a
96
- terminal), `writes:auto` (run without asking, enabled with `--yes`), or
97
- `writes:off` (disabled for non-interactive stdin runs unless `--yes` is passed).
98
-
99
- Responses stream token-by-token as the model generates them. While Anveesa waits
100
- for the first token it shows a small status line such as:
101
-
102
- ```text
103
- - thinking... 2s
104
- ```
61
+ ## Configuration
105
62
 
106
- When usage is reported by the provider, a token summary is printed to stderr
107
- after the answer:
63
+ Config lives at `~/.config/anveesa/config.toml`.
108
64
 
109
- ```text
110
- [tokens: 812 in / 144 out / 956 total]
111
- ```
65
+ ```toml
66
+ default_provider = "anthropic"
112
67
 
113
- OpenAI-compatible providers can use up to 32 tool rounds per answer by default.
114
- After that, Anveesa stops advertising tools and asks the model to produce a
115
- final answer from the gathered results. Override the cap with
116
- `ANVEESA_MAX_TOOL_ROUNDS`, up to 256.
68
+ [providers.anthropic]
69
+ api_base = "https://api.anthropic.com/v1"
70
+ api_key_env = "ANTHROPIC_API_KEY"
71
+ default_model = "claude-sonnet-4-6"
72
+ fast_model = "claude-haiku-4-5-20251001" # cheap model for read-only tool rounds
73
+ prompt_cache = true
117
74
 
118
- Use GLM/Z.ai:
75
+ [providers.openai]
76
+ api_base = "https://api.openai.com/v1"
77
+ api_key_env = "OPENAI_API_KEY"
78
+ default_model = "gpt-4o"
79
+ fast_model = "gpt-4o-mini"
119
80
 
120
- ```sh
121
- export ZAI_API_KEY="..."
122
- anveesa config set-provider glm
123
- anveesa config set-model "glm-5.1"
124
- anveesa "write a rust module outline"
81
+ [providers.local]
82
+ api_base = "http://localhost:11434/v1"
83
+ default_model = "qwen2.5-coder:7b"
125
84
  ```
126
85
 
127
- You can also use the default `ask` behavior:
128
-
129
- ```sh
130
- anveesa "write a git commit message"
131
- ```
86
+ ### Project instructions
132
87
 
133
- Pipe stdin into a prompt:
88
+ Drop `.anveesa.md` in your repo root — auto-injected every session:
134
89
 
135
- ```sh
136
- git diff | anveesa ask --stdin "review this diff"
137
- ```
90
+ ```markdown
91
+ # My Project
138
92
 
139
- Let the model make changes. In interactive mode and one-shot terminal prompts,
140
- it asks before each write or command. Pass `--yes` (`-y`) to allow file writes
141
- and command execution without prompting:
93
+ Stack: React 18, TypeScript, Tailwind, Prisma, PostgreSQL
142
94
 
143
- ```sh
144
- anveesa --provider sumopod --yes "add a Default impl for the Config struct"
95
+ Rules:
96
+ - Use named exports only
97
+ - Tests go in __tests__/ next to the source file
98
+ - Run `pnpm test` before committing
99
+ - DB migrations live in db/migrations/
145
100
  ```
146
101
 
147
- Run through Claude Code if the `claude` CLI is installed:
102
+ ---
148
103
 
149
- ```sh
150
- anveesa --provider claude-code "summarize this project"
151
- ```
104
+ ## TUI shortcuts
152
105
 
153
- Run through Codex if the `codex` CLI is installed:
106
+ | Key | Action |
107
+ |---|---|
108
+ | Enter | Submit prompt |
109
+ | Shift+Enter | Newline in input |
110
+ | ↑ / ↓ | Navigate input history |
111
+ | Tab | Complete `/command` or file path |
112
+ | Ctrl+R | Search conversation |
113
+ | `[` / `]` | Navigate file diffs |
114
+ | Enter (on diff) | Expand / collapse diff |
115
+ | ⌘V / Ctrl+V | Paste image or text (repeat to queue multiple) |
116
+ | j / k | Scroll (empty input) |
117
+ | PageUp / PageDn | Scroll |
118
+ | Ctrl+W | Delete word |
119
+ | Ctrl+U | Clear line |
120
+ | Ctrl+C | Cancel / quit |
154
121
 
155
- ```sh
156
- anveesa --provider codex --model "gpt-5.1-codex" "review this repository"
157
- ```
122
+ ---
158
123
 
159
- Run through GitHub Copilot CLI if the `copilot` CLI is installed:
124
+ ## Slash commands
160
125
 
161
- ```sh
162
- anveesa --provider copilot --model "gpt-5.1" "explain this function"
163
- ```
126
+ | Command | Description |
127
+ |---|---|
128
+ | `/help` | Show all shortcuts |
129
+ | `/clear` | Reset conversation |
130
+ | `/compact` | Drop old turns to free context |
131
+ | `/search` | Search conversation (or Ctrl+R) |
132
+ | `/undo` | Restore last AI-modified file |
133
+ | `/copy` | Copy last response to clipboard |
134
+ | `/export [path]` | Save as Markdown |
135
+ | `/model [name]` | Switch model |
136
+ | `/provider [name]` | Switch provider |
137
+ | `/status` | Token and cost info |
138
+ | `/exit` | Quit |
164
139
 
165
- Use Sumopod with its OpenAI-compatible API:
140
+ ---
166
141
 
167
- ```sh
168
- export SUMOPOD_API_KEY="..."
169
- anveesa --provider sumopod --model "your-sumopod-model" "explain this error"
170
- ```
142
+ ## Tools
171
143
 
172
- ## Built-in providers
144
+ **File ops:** `read_file` `write_file` `edit_file` `patch_file` `delete_file` `move_file` `copy_file` `create_dir` `list_dir` `find_files` `search_text`
173
145
 
174
- OpenAI-compatible API providers:
146
+ **Git:** `git_status` `git_diff` `git_log` `git_blame` `git_show` `git_commit` `git_stash` `git_branch`
175
147
 
176
- - `openai`
177
- - `sumopod`
148
+ **Web:** `web_search` `fetch_url` `screenshot_url`
178
149
 
179
- ### How to change the model
150
+ **Notes:** `save_note` `read_notes` `search_notes` `delete_note`
180
151
 
181
- You can change the model using these commands:
152
+ **Execution:** `run_command`
182
153
 
183
- **Via config file:**
184
- ```bash
185
- anveesa config set-model "your-model"
186
- ```
154
+ ### fetch_url modes
187
155
 
188
- **Via command line:**
189
- ```bash
190
- anveesa --model "your-model" "your prompt"
191
156
  ```
192
-
193
- **Interactive mode:**
194
- ```bash
195
- anveesa
196
- # Then select/change model in the prompt
157
+ fetch_url(url="...", mode="text") # default — plain text, HTML stripped
158
+ fetch_url(url="...", mode="raw") # full HTML source
159
+ fetch_url(url="...", mode="deep") # HTML + all linked CSS (+ JS if include_js=true)
197
160
  ```
198
161
 
199
- The model can be set per provider (e.g., `sumopod`, `openai`, `openrouter`, etc.) and can be overridden per command with `--model`.
200
- - `openrouter`
201
- - `glm`
202
- - `glm-coding`
203
- - `deepseek`
204
- - `gemini`
205
- - `github-models`
206
- - `groq`
207
- - `mistral`
208
- - `xai`
209
- - `together`
210
- - `fireworks`
211
- - `cerebras`
212
- - `sambanova`
213
- - `nvidia`
214
- - `dashscope`
215
- - `qwen`
216
- - `huggingface`
217
- - `vercel-ai-gateway`
218
- - `perplexity`
219
- - `ollama`
220
- - `lm-studio`
221
- - `vllm`
222
- - `litellm`
223
- - `localai`
224
-
225
- Terminal command providers:
226
-
227
- - `claude-code`
228
- - `codex`
229
- - `copilot`
230
-
231
- Check the effective list any time:
232
-
233
- ```sh
234
- anveesa providers
162
+ ### screenshot_url
163
+
164
+ ```
165
+ screenshot_url(url="https://localhost:3000", full_page=true)
235
166
  ```
236
167
 
237
- ## Config
168
+ Requires Playwright: `npm install -g playwright && npx playwright install chromium`
238
169
 
239
- Default path:
170
+ ---
240
171
 
241
- ```sh
242
- anveesa config path
243
- ```
172
+ ## Security
244
173
 
245
- The path can be overridden with `ANVEESA_CONFIG`.
174
+ - **Approval flow** file writes and commands show a diff preview before running; `--yes` to auto-approve
175
+ - **Path sandboxing** — writes outside the git root are refused
176
+ - **Dangerous commands** — `rm -rf /`, pipe-to-shell, and similar patterns are hard-blocked
177
+ - **Secret guard** — model is instructed never to expose API keys, `.env` files, or SSH keys
246
178
 
247
- Set defaults once:
179
+ ---
248
180
 
249
- ```sh
250
- anveesa config set-provider sumopod
251
- anveesa config set-model "kimi-k2.6"
252
- ```
181
+ ## Environment variables
253
182
 
254
- After that, just run:
183
+ | Variable | Purpose |
184
+ |---|---|
185
+ | `ANTHROPIC_API_KEY` | Anthropic |
186
+ | `OPENAI_API_KEY` | OpenAI |
187
+ | `GEMINI_API_KEY` | Google Gemini |
188
+ | `BRAVE_SEARCH_API_KEY` | Better web search |
189
+ | `SERPER_API_KEY` | Alternative web search |
190
+ | `ANVEESA_MAX_TOOL_ROUNDS` | Override tool round limit (default 32) |
255
191
 
256
- ```sh
257
- anveesa
258
- ```
192
+ ---
259
193
 
260
- Example provider config:
194
+ ## Build from source
261
195
 
262
- ```toml
263
- default_provider = "sumopod"
264
-
265
- [providers.sumopod]
266
- kind = "openai-compatible"
267
- base_url = "https://ai.sumopod.com/v1"
268
- api_key_env = "SUMOPOD_API_KEY"
269
- default_model = "your-sumopod-model"
270
-
271
- [providers.openrouter]
272
- kind = "openai-compatible"
273
- base_url = "https://openrouter.ai/api/v1"
274
- api_key_env = "OPENROUTER_API_KEY"
275
-
276
- [providers.glm]
277
- kind = "openai-compatible"
278
- base_url = "https://api.z.ai/api/paas/v4"
279
- api_key_env = "ZAI_API_KEY"
280
- default_model = "glm-5.1"
281
-
282
- [providers.codex]
283
- kind = "command"
284
- command = "codex"
285
- args = ["exec", "{model_args}", "{prompt}"]
286
- model_args = ["--model", "{model}"]
287
-
288
- [providers.claude-code]
289
- kind = "command"
290
- command = "claude"
291
- args = ["-p", "{system_args}", "{model_args}", "{prompt}"]
292
- model_args = ["--model", "{model}"]
293
- system_args = ["--system-prompt", "{system}"]
196
+ ```bash
197
+ git clone https://github.com/PandhuWibowo/anveesa-cli
198
+ cd anveesa-cli
199
+ cargo build --release
200
+ ./target/release/anveesa
294
201
  ```
295
202
 
296
- Command providers can use placeholders in args or env values:
203
+ Requires Rust 1.85+ (2024 edition).
204
+
205
+ ---
297
206
 
298
- - `{prompt}`
299
- - `{model}`
300
- - `{system}`
301
- - `{model_args}`
302
- - `{system_args}`
207
+ MIT License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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/lib.rs CHANGED
@@ -636,7 +636,7 @@ async fn ask_streaming(
636
636
  history: &[ChatMessage],
637
637
  workspace_context: Option<&str>,
638
638
  policy: ApprovalPolicy,
639
- image: Option<ImageAttachment>,
639
+ image: Option<ImageAttachment>, // single-image path kept for REPL compatibility
640
640
  mode: RenderMode,
641
641
  ) -> Result<TurnResult> {
642
642
  let provider_name = config
@@ -652,7 +652,7 @@ async fn ask_streaming(
652
652
  system: options.system.clone(),
653
653
  workspace_context: workspace_context.map(str::to_string),
654
654
  history: history.to_vec(),
655
- image,
655
+ images: image.into_iter().collect(),
656
656
  mcp: None, // REPL path: MCP not yet wired here
657
657
  };
658
658
 
@@ -2518,7 +2518,49 @@ fn workspace_context_for(cwd: &Path) -> Result<String> {
2518
2518
  context.push_str(&format!("- parent: {}\n", parent.display()));
2519
2519
  }
2520
2520
 
2521
+ // .anveesa.md — project-level instructions (highest priority context)
2522
+ let project_md_paths = [cwd.join(".anveesa.md"), cwd.join("ANVEESA.md")];
2523
+ for md_path in &project_md_paths {
2524
+ if let Ok(content) = fs::read_to_string(md_path) {
2525
+ if !content.trim().is_empty() {
2526
+ context.push_str("\nProject instructions (.anveesa.md):\n");
2527
+ let capped: String = content.chars().take(8_000).collect();
2528
+ context.push_str(&capped);
2529
+ context.push('\n');
2530
+ }
2531
+ break;
2532
+ }
2533
+ }
2534
+
2535
+ // README — auto-inject up to 3 000 chars for project overview
2536
+ for readme in &["README.md", "readme.md", "Readme.md"] {
2537
+ if let Ok(content) = fs::read_to_string(cwd.join(readme)) {
2538
+ if !content.trim().is_empty() {
2539
+ context.push_str("\nProject README (first 3000 chars):\n");
2540
+ let capped: String = content.chars().take(3_000).collect();
2541
+ context.push_str(&capped);
2542
+ context.push('\n');
2543
+ }
2544
+ break;
2545
+ }
2546
+ }
2547
+
2521
2548
  if let Some(git_root) = git_output(&cwd, ["rev-parse", "--show-toplevel"]) {
2549
+ // Also check git root for .anveesa.md if different from cwd
2550
+ let git_root_path = std::path::Path::new(&git_root);
2551
+ if git_root_path != cwd {
2552
+ for md_path in &[git_root_path.join(".anveesa.md"), git_root_path.join("ANVEESA.md")] {
2553
+ if let Ok(content) = fs::read_to_string(md_path) {
2554
+ if !content.trim().is_empty() {
2555
+ context.push_str("\nProject instructions (from git root):\n");
2556
+ let capped: String = content.chars().take(8_000).collect();
2557
+ context.push_str(&capped);
2558
+ context.push('\n');
2559
+ }
2560
+ break;
2561
+ }
2562
+ }
2563
+ }
2522
2564
  context.push_str(&format!("- git_root: {git_root}\n"));
2523
2565
  if let Some(branch) = git_output(&cwd, ["branch", "--show-current"])
2524
2566
  && !branch.is_empty()
@@ -49,8 +49,8 @@ pub struct PromptRequest {
49
49
  pub system: Option<String>,
50
50
  pub workspace_context: Option<String>,
51
51
  pub history: Vec<ChatMessage>,
52
- /// Optional image grabbed from the clipboard for the current turn only.
53
- pub image: Option<ImageAttachment>,
52
+ /// Images attached to the current turn (clipboard paste or explicit attach).
53
+ pub images: Vec<ImageAttachment>,
54
54
  /// Connected MCP servers (runtime only, not part of session history).
55
55
  pub mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
56
56
  }
@@ -250,6 +250,93 @@ struct ToolApprovalState {
250
250
  call_counts: std::collections::HashMap<(String, String), usize>,
251
251
  }
252
252
 
253
+ fn git_root() -> Option<std::path::PathBuf> {
254
+ let cwd = std::env::current_dir().ok()?;
255
+ let out = std::process::Command::new("git")
256
+ .args(["rev-parse", "--show-toplevel"])
257
+ .current_dir(&cwd)
258
+ .output()
259
+ .ok()?;
260
+ if out.status.success() {
261
+ let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
262
+ Some(std::path::PathBuf::from(s))
263
+ } else {
264
+ None
265
+ }
266
+ }
267
+
268
+ fn normalize_path(p: &std::path::Path) -> std::path::PathBuf {
269
+ let mut out = std::path::PathBuf::new();
270
+ for comp in p.components() {
271
+ match comp {
272
+ std::path::Component::ParentDir => { out.pop(); }
273
+ std::path::Component::CurDir => {}
274
+ _ => out.push(comp),
275
+ }
276
+ }
277
+ out
278
+ }
279
+
280
+ /// Returns Some(error) if `path_str` resolves to a location outside the sandbox root.
281
+ fn sandbox_check(tool_name: &str, arguments: &str) -> Option<String> {
282
+ // Tools that don't take a filesystem path — always allowed
283
+ if matches!(tool_name, "git_commit" | "git_stash" | "git_branch" | "save_note" | "delete_note" | "run_command") {
284
+ return None;
285
+ }
286
+ let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments) else { return None };
287
+ let path_str = args.get("path")
288
+ .or_else(|| args.get("destination"))
289
+ .or_else(|| args.get("dest"))
290
+ .and_then(|v| v.as_str())?;
291
+
292
+ let cwd = std::env::current_dir().unwrap_or_default();
293
+ let raw = if std::path::Path::new(path_str).is_absolute() {
294
+ std::path::PathBuf::from(path_str)
295
+ } else {
296
+ cwd.join(path_str)
297
+ };
298
+ let resolved = normalize_path(&raw);
299
+ let root = git_root().unwrap_or_else(|| cwd.clone());
300
+
301
+ if resolved.starts_with(&root) || resolved.starts_with(&cwd) {
302
+ None
303
+ } else {
304
+ Some(format!(
305
+ "Path '{}' is outside the project root '{}' — blocked for safety. \
306
+ Use a path inside the project.",
307
+ path_str,
308
+ root.display()
309
+ ))
310
+ }
311
+ }
312
+
313
+ const DANGEROUS_PATTERNS: &[&str] = &[
314
+ "rm -rf /",
315
+ "rm -rf ~",
316
+ "rm -rf $HOME",
317
+ "dd if=",
318
+ "mkfs",
319
+ "> /dev/sda",
320
+ ":(){ :|:& };:",
321
+ "chmod -R 777 /",
322
+ "chown -R",
323
+ "curl | sh",
324
+ "curl | bash",
325
+ "wget | sh",
326
+ "wget | bash",
327
+ ];
328
+
329
+ fn dangerous_command_check(arguments: &str) -> Option<String> {
330
+ let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments) else { return None };
331
+ let cmd = args["command"].as_str()?;
332
+ for pat in DANGEROUS_PATTERNS {
333
+ if cmd.contains(pat) {
334
+ return Some(format!("blocked: command matches dangerous pattern '{pat}'"));
335
+ }
336
+ }
337
+ None
338
+ }
339
+
253
340
  async fn dispatch_read_only_tool(
254
341
  call: PartialToolCall,
255
342
  events: UnboundedSender<StreamEvent>,
@@ -356,6 +443,16 @@ async fn dispatch_tool(
356
443
  if !policy.allows_write_tools() {
357
444
  return denied_message("write tools are disabled (pass --yes or run interactively)");
358
445
  }
446
+ // Path sandbox: refuse writes that escape the project root
447
+ if let Some(err) = sandbox_check(&call.name, &call.arguments) {
448
+ return json!({"ok": false, "error": err}).to_string();
449
+ }
450
+ // Dangerous command patterns
451
+ if call.name == "run_command" {
452
+ if let Some(err) = dangerous_command_check(&call.arguments) {
453
+ return json!({"ok": false, "error": err}).to_string();
454
+ }
455
+ }
359
456
  } else {
360
457
  let _ = events.send(StreamEvent::ToolCall {
361
458
  summary: summary.clone(),
@@ -822,13 +919,15 @@ fn build_messages(
822
919
  messages.push(json!({ "role": role, "content": message.content }));
823
920
  }
824
921
 
825
- // Current user turn — multimodal when a clipboard image is attached.
826
- let user_content = match &request.image {
827
- Some(img) => json!([
828
- { "type": "text", "text": &request.prompt },
829
- { "type": "image_url", "image_url": { "url": format!("data:{};base64,{}", img.mime, img.data) } }
830
- ]),
831
- None => json!(&request.prompt),
922
+ // Current user turn — multimodal when images are attached.
923
+ let user_content = if request.images.is_empty() {
924
+ json!(&request.prompt)
925
+ } else {
926
+ let mut parts = vec![json!({ "type": "text", "text": &request.prompt })];
927
+ for img in &request.images {
928
+ parts.push(json!({ "type": "image_url", "image_url": { "url": format!("data:{};base64,{}", img.mime, img.data) } }));
929
+ }
930
+ json!(parts)
832
931
  };
833
932
  messages.push(json!({ "role": "user", "content": user_content }));
834
933
 
package/src/tools.rs CHANGED
@@ -102,6 +102,7 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
102
102
  "read_file" => format!("read file {}", field("path")),
103
103
  "web_search" => format!("web search `{}`", field("query")),
104
104
  "fetch_url" => format!("fetch {}", field("url")),
105
+ "screenshot_url" => format!("screenshot {}", field("url")),
105
106
  "git_status" => "git status".to_string(),
106
107
  "git_diff" => {
107
108
  let path = field("path");
@@ -271,6 +272,24 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
271
272
  }
272
273
  }
273
274
  }),
275
+ json!({
276
+ "type": "function",
277
+ "function": {
278
+ "name": "screenshot_url",
279
+ "description": "Take a full-page or viewport screenshot of a URL using a headless browser (Playwright). Returns the saved file path and a note. Use when you need to visually inspect a web page, compare UI designs, or verify a running app.",
280
+ "parameters": {
281
+ "type": "object",
282
+ "properties": {
283
+ "url": { "type": "string", "description": "URL to screenshot." },
284
+ "output": { "type": "string", "description": "File path to save the PNG (default: /tmp/anveesa-screenshot-<timestamp>.png)." },
285
+ "width": { "type": "integer", "description": "Viewport width in pixels (default 1440)." },
286
+ "height": { "type": "integer", "description": "Viewport height in pixels (default 900)." },
287
+ "full_page": { "type": "boolean", "description": "Capture the full scrollable page (default false)." }
288
+ },
289
+ "required": ["url"]
290
+ }
291
+ }
292
+ }),
274
293
  json!({
275
294
  "type": "function",
276
295
  "function": {
@@ -607,6 +626,7 @@ pub async fn run(name: &str, arguments: &str) -> String {
607
626
  "write_file" => write_file(arguments).await,
608
627
  "edit_file" => edit_file(arguments).await,
609
628
  "run_command" => run_command(arguments).await,
629
+ "screenshot_url" => screenshot_url(arguments).await,
610
630
  _ => Err(anyhow!("unknown tool '{name}'")),
611
631
  };
612
632
 
@@ -2101,6 +2121,68 @@ fn percent_encode(value: &str) -> String {
2101
2121
  .collect()
2102
2122
  }
2103
2123
 
2124
+ // ── screenshot_url ─────────────────────────────────────────────────────────────
2125
+
2126
+ async fn screenshot_url(arguments: &str) -> Result<Value> {
2127
+ #[derive(Deserialize)]
2128
+ struct Args {
2129
+ url: String,
2130
+ #[serde(default)]
2131
+ output: Option<String>,
2132
+ #[serde(default)]
2133
+ width: Option<u32>,
2134
+ #[serde(default)]
2135
+ height: Option<u32>,
2136
+ #[serde(default)]
2137
+ full_page: Option<bool>,
2138
+ }
2139
+ let args: Args = parse_args(arguments)?;
2140
+ let url = args.url.trim().to_string();
2141
+ if url.is_empty() { bail!("url is required"); }
2142
+
2143
+ let ts = std::time::SystemTime::now()
2144
+ .duration_since(std::time::UNIX_EPOCH)
2145
+ .unwrap_or_default()
2146
+ .as_secs();
2147
+ let output = args.output.unwrap_or_else(|| format!("/tmp/anveesa-screenshot-{ts}.png"));
2148
+ let width = args.width.unwrap_or(1440);
2149
+ let height = args.height.unwrap_or(900);
2150
+ let viewport = format!("{width},{height}");
2151
+
2152
+ let mut cmd = tokio::process::Command::new("npx");
2153
+ cmd.args(["playwright", "screenshot", "--viewport-size", &viewport]);
2154
+ if args.full_page.unwrap_or(false) {
2155
+ cmd.arg("--full-page");
2156
+ }
2157
+ cmd.arg(&url).arg(&output);
2158
+
2159
+ let result = cmd.output().await;
2160
+ match result {
2161
+ Ok(out) if out.status.success() => {
2162
+ let size_kb = tokio::fs::metadata(&output).await
2163
+ .map(|m| m.len() / 1024)
2164
+ .unwrap_or(0);
2165
+ Ok(json!({
2166
+ "ok": true,
2167
+ "saved_to": output,
2168
+ "url": url,
2169
+ "width": width,
2170
+ "height": height,
2171
+ "size_kb": size_kb,
2172
+ "note": format!("Screenshot saved to {output}. Open it with: open {output}")
2173
+ }))
2174
+ }
2175
+ Ok(out) => {
2176
+ let stderr = String::from_utf8_lossy(&out.stderr);
2177
+ bail!("playwright failed (exit {}): {}", out.status, stderr.trim())
2178
+ }
2179
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2180
+ bail!("playwright not found — install with: npm install -g playwright && npx playwright install chromium")
2181
+ }
2182
+ Err(e) => bail!("failed to run playwright: {e}"),
2183
+ }
2184
+ }
2185
+
2104
2186
  #[cfg(test)]
2105
2187
  mod tests {
2106
2188
  use super::*;
package/src/tui.rs CHANGED
@@ -69,6 +69,7 @@ enum Mode {
69
69
  Input,
70
70
  Streaming,
71
71
  Confirming,
72
+ Search,
72
73
  }
73
74
 
74
75
  // ── Application state ─────────────────────────────────────────────────────────
@@ -98,7 +99,7 @@ pub struct App {
98
99
  input_history: Vec<String>,
99
100
  hist_idx: Option<usize>,
100
101
  hist_saved: String,
101
- pending_image: Option<ImageAttachment>,
102
+ pending_images: Vec<ImageAttachment>,
102
103
  last_image_fp: Option<String>,
103
104
  images_available: bool,
104
105
 
@@ -118,6 +119,12 @@ pub struct App {
118
119
  msg_focus: Option<usize>,
119
120
  msg_line_offsets: Vec<usize>,
120
121
 
122
+ // conversation search
123
+ search_query: String,
124
+ search_results: Vec<usize>,
125
+ search_idx: usize,
126
+ search_scroll_saved: usize,
127
+
121
128
  // tab completion state: (original input, candidates, current index)
122
129
  tab_state: Option<(String, Vec<String>, usize)>,
123
130
 
@@ -193,7 +200,7 @@ impl App {
193
200
  input_history,
194
201
  hist_idx: None,
195
202
  hist_saved: String::new(),
196
- pending_image: None,
203
+ pending_images: Vec::new(),
197
204
  last_image_fp: None,
198
205
  images_available,
199
206
 
@@ -211,6 +218,11 @@ impl App {
211
218
  msg_line_offsets: Vec::new(),
212
219
  tab_state: None,
213
220
 
221
+ search_query: String::new(),
222
+ search_results: Vec::new(),
223
+ search_idx: 0,
224
+ search_scroll_saved: 0,
225
+
214
226
  mode: Mode::Input,
215
227
  confirm: None,
216
228
  mouse_capture: true,
@@ -324,11 +336,9 @@ async fn handle_event(app: &mut App, event: Event) -> Result<()> {
324
336
  Event::Paste(text) => {
325
337
  if app.mode != Mode::Input { return Ok(()); }
326
338
  if text.trim().is_empty() {
327
- // Empty paste = user pasted an image (terminal can't forward it as text)
328
- // Try to grab it directly from the clipboard
329
339
  if app.images_available {
330
340
  if let Some(img) = crate::grab_clipboard_image() {
331
- app.pending_image = Some(img);
341
+ app.pending_images.push(img);
332
342
  app.last_image_fp = None;
333
343
  return Ok(());
334
344
  }
@@ -338,6 +348,7 @@ async fn handle_event(app: &mut App, event: Event) -> Result<()> {
338
348
  app.input.insert_str(app.input_cursor, &normalized);
339
349
  app.input_cursor += normalized.len();
340
350
  app.hist_idx = None;
351
+ app.tab_state = None;
341
352
  }
342
353
  }
343
354
  Event::Resize(_, _) => {}
@@ -396,6 +407,48 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
396
407
  return Ok(());
397
408
  }
398
409
 
410
+ // ── Search mode ───────────────────────────────────────────────────────────
411
+ if app.mode == Mode::Search {
412
+ match code {
413
+ KeyCode::Esc => {
414
+ app.mode = Mode::Input;
415
+ app.auto_scroll = false;
416
+ app.scroll = app.search_scroll_saved;
417
+ app.search_query.clear();
418
+ app.search_results.clear();
419
+ }
420
+ KeyCode::Enter | KeyCode::Down
421
+ | KeyCode::Char('n') => {
422
+ if !app.search_results.is_empty() {
423
+ app.search_idx = (app.search_idx + 1) % app.search_results.len();
424
+ let idx = app.search_results[app.search_idx];
425
+ if let Some(&off) = app.msg_line_offsets.get(idx) {
426
+ app.scroll = off.saturating_sub(2);
427
+ }
428
+ }
429
+ }
430
+ KeyCode::Up | KeyCode::Char('p') => {
431
+ if !app.search_results.is_empty() {
432
+ app.search_idx = app.search_idx.checked_sub(1).unwrap_or(app.search_results.len() - 1);
433
+ let idx = app.search_results[app.search_idx];
434
+ if let Some(&off) = app.msg_line_offsets.get(idx) {
435
+ app.scroll = off.saturating_sub(2);
436
+ }
437
+ }
438
+ }
439
+ KeyCode::Backspace => {
440
+ app.search_query.pop();
441
+ update_search(app);
442
+ }
443
+ KeyCode::Char(c) => {
444
+ app.search_query.push(c);
445
+ update_search(app);
446
+ }
447
+ _ => {}
448
+ }
449
+ return Ok(());
450
+ }
451
+
399
452
  // ── Input mode ────────────────────────────────────────────────────────────
400
453
  match code {
401
454
  // Submit (Enter) or newline (Shift+Enter)
@@ -475,26 +528,38 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
475
528
  delete_word_before(&mut app.input, &mut app.input_cursor);
476
529
  app.hist_idx = None;
477
530
  }
478
- // Ctrl+V universal paste: image first, then clipboard text
479
- KeyCode::Char('v') if modifiers.contains(KeyModifiers::CONTROL) => {
531
+ // Ctrl+V (all platforms) or Cmd+V (macOS) — image first, then text
532
+ KeyCode::Char('v')
533
+ if modifiers.contains(KeyModifiers::CONTROL)
534
+ || (cfg!(target_os = "macos") && modifiers.contains(KeyModifiers::SUPER)) =>
535
+ {
480
536
  if app.images_available {
481
537
  if let Some(img) = crate::grab_clipboard_image() {
482
- app.pending_image = Some(img);
538
+ app.pending_images.push(img);
483
539
  app.last_image_fp = None;
484
540
  return Ok(());
485
541
  }
486
542
  }
487
- // No image — fall back to clipboard text
488
543
  if let Some(text) = crate::read_clipboard_text() {
489
544
  if !text.is_empty() {
490
545
  let normalized = text.replace('\r', "\n");
491
546
  app.input.insert_str(app.input_cursor, &normalized);
492
547
  app.input_cursor += normalized.len();
493
548
  app.hist_idx = None;
549
+ app.tab_state = None;
494
550
  }
495
551
  }
496
552
  }
497
553
 
554
+ // Ctrl+R — activate conversation search
555
+ KeyCode::Char('r') if modifiers.contains(KeyModifiers::CONTROL) => {
556
+ app.search_scroll_saved = app.scroll;
557
+ app.search_query.clear();
558
+ app.search_results.clear();
559
+ app.search_idx = 0;
560
+ app.mode = Mode::Search;
561
+ }
562
+
498
563
  // Ctrl+M — toggle mouse capture (scroll mode ↔ select mode)
499
564
  KeyCode::Char('m') if modifiers.contains(KeyModifiers::CONTROL) => {
500
565
  app.mouse_capture = !app.mouse_capture;
@@ -610,7 +675,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
610
675
  app.streaming_buf.clear();
611
676
  app.accumulated_response.clear();
612
677
  app.usage = Usage::default();
613
- app.pending_image = None;
678
+ app.pending_images.clear();
614
679
  app.seen_paths.clear();
615
680
  app.undo_stack.clear();
616
681
  app.input.clear();
@@ -631,10 +696,11 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
631
696
  /model [name] · /provider [name] · /status · /exit\n\
632
697
  \n\
633
698
  Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
634
- Tab complete /command or file path (press again to cycle)\n\
635
- [ ] navigate between file diffs Enter expand/collapse focused diff\n\
699
+ Tab complete /command or file path (press again to cycle)\n\
700
+ Ctrl+R search conversation (or /search)\n\
701
+ [ ] navigate between file diffs Enter expand/collapse focused diff\n\
636
702
  j/k scroll (when input empty) PageUp/Dn scroll\n\
637
- Ctrl+V paste (image or text) Ctrl+M scroll/select mode\n\
703
+ ⌘V (macOS) / Ctrl+V paste image or text (repeat to queue multiple images)\n\
638
704
  Ctrl+W delete-word Ctrl+U clear line\n\
639
705
  \n\
640
706
  Search: set BRAVE_SEARCH_API_KEY or SERPER_API_KEY for better results".into(),
@@ -698,7 +764,6 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
698
764
  true
699
765
  }
700
766
  "/compact" => {
701
- // Keep only the last 10 turns, drop older history to free context
702
767
  let keep = 10usize;
703
768
  let total_turns = app.history.len() / 2;
704
769
  if total_turns <= keep {
@@ -708,16 +773,35 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
708
773
  } else {
709
774
  let drop_turns = total_turns - keep;
710
775
  let drop_msgs = drop_turns * 2;
776
+ // Summarise dropped content before removing it
777
+ let dropped_text: String = app.history[..drop_msgs].iter()
778
+ .map(|m| m.content.as_str())
779
+ .collect::<Vec<_>>()
780
+ .join(" ");
781
+ // Extract file paths mentioned in dropped turns
782
+ let mut files: Vec<&str> = dropped_text.split_whitespace()
783
+ .filter(|w| w.contains('/') || w.ends_with(".rs") || w.ends_with(".ts")
784
+ || w.ends_with(".py") || w.ends_with(".js") || w.ends_with(".go"))
785
+ .collect::<std::collections::HashSet<_>>()
786
+ .into_iter()
787
+ .collect();
788
+ files.sort();
789
+ files.truncate(12);
790
+ let file_hint = if files.is_empty() { String::new() }
791
+ else { format!(" Files discussed: {}.", files.join(", ")) };
711
792
  app.history.drain(..drop_msgs);
712
- // Also remove older messages from the display (keep separators and last N turns)
713
793
  let msg_count = app.messages.len();
714
794
  if msg_count > keep * 3 {
715
795
  app.messages.drain(..(msg_count - keep * 3));
716
796
  }
717
- app.seen_paths.clear(); // refresh seen paths for the new context window
797
+ app.seen_paths.clear();
798
+ // Inject a context breadcrumb so the model knows what was compacted
799
+ app.history.insert(0, ChatMessage::user(format!(
800
+ "[Context note: {drop_turns} earlier turn(s) were compacted to save context.{file_hint} \
801
+ Use read_file / list_dir to re-inspect any files if needed.]"
802
+ )));
718
803
  app.messages.insert(0, Msg::System(format!(
719
- "Context compacted: dropped {drop_turns} older turn(s), keeping the last {keep}. \
720
- Use /clear to start fresh."
804
+ "Context compacted: dropped {drop_turns} older turn(s), keeping the last {keep}.{file_hint}"
721
805
  )));
722
806
  app.messages.push(Msg::Separator);
723
807
  }
@@ -725,6 +809,16 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
725
809
  app.input_cursor = 0;
726
810
  true
727
811
  }
812
+ "/search" => {
813
+ app.search_scroll_saved = app.scroll;
814
+ app.search_query.clear();
815
+ app.search_results.clear();
816
+ app.search_idx = 0;
817
+ app.mode = Mode::Search;
818
+ app.input.clear();
819
+ app.input_cursor = 0;
820
+ true
821
+ }
728
822
  s if s.starts_with("/export") => {
729
823
  let arg = s.strip_prefix("/export").unwrap().trim();
730
824
  let path = if arg.is_empty() {
@@ -784,11 +878,41 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
784
878
  }
785
879
  }
786
880
 
881
+ // ── Conversation search ───────────────────────────────────────────────────────
882
+
883
+ fn msg_text(msg: &Msg) -> Option<&str> {
884
+ match msg {
885
+ Msg::User { text } | Msg::Assistant { text } | Msg::Error(text) | Msg::System(text) => Some(text),
886
+ Msg::Tool { text, .. } => Some(text),
887
+ Msg::FileOp { path, .. } => Some(path),
888
+ Msg::Separator => None,
889
+ }
890
+ }
891
+
892
+ fn update_search(app: &mut App) {
893
+ let q = app.search_query.to_lowercase();
894
+ app.search_results = if q.is_empty() {
895
+ vec![]
896
+ } else {
897
+ app.messages.iter().enumerate()
898
+ .filter(|(_, m)| msg_text(m).map(|t| t.to_lowercase().contains(&q)).unwrap_or(false))
899
+ .map(|(i, _)| i)
900
+ .collect()
901
+ };
902
+ app.search_idx = 0;
903
+ if let Some(&first) = app.search_results.first() {
904
+ if let Some(&off) = app.msg_line_offsets.get(first) {
905
+ app.auto_scroll = false;
906
+ app.scroll = off.saturating_sub(2);
907
+ }
908
+ }
909
+ }
910
+
787
911
  // ── Tab completion ────────────────────────────────────────────────────────────
788
912
 
789
913
  const SLASH_COMMANDS: &[&str] = &[
790
914
  "/clear", "/compact", "/copy", "/exit", "/export",
791
- "/help", "/model", "/provider", "/quit", "/status", "/undo",
915
+ "/help", "/model", "/provider", "/quit", "/search", "/status", "/undo",
792
916
  ];
793
917
 
794
918
  fn tab_complete(app: &mut App) {
@@ -874,17 +998,24 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
874
998
  app.pending_prompt = text.clone();
875
999
  app.accumulated_response.clear();
876
1000
 
877
- // Auto-attach clipboard image if nothing was explicitly Ctrl+V'd
878
- let image = app.pending_image.take().or_else(|| {
879
- if !app.images_available { return None; }
880
- let img = crate::grab_clipboard_image()?;
881
- let fp = crate::image_fingerprint(&img);
882
- if app.last_image_fp.as_deref() == Some(&fp) {
883
- return None; // same image as last time
1001
+ // Collect explicitly pasted images; if none, auto-attach a new clipboard image once.
1002
+ let images: Vec<crate::provider::ImageAttachment> = if !app.pending_images.is_empty() {
1003
+ std::mem::take(&mut app.pending_images)
1004
+ } else if app.images_available {
1005
+ if let Some(img) = crate::grab_clipboard_image() {
1006
+ let fp = crate::image_fingerprint(&img);
1007
+ if app.last_image_fp.as_deref() == Some(&fp) {
1008
+ vec![]
1009
+ } else {
1010
+ app.last_image_fp = Some(fp);
1011
+ vec![img]
1012
+ }
1013
+ } else {
1014
+ vec![]
884
1015
  }
885
- app.last_image_fp = Some(fp);
886
- Some(img)
887
- });
1016
+ } else {
1017
+ vec![]
1018
+ };
888
1019
 
889
1020
  app.messages.push(Msg::User { text: text.clone() });
890
1021
  app.input.clear();
@@ -924,7 +1055,7 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
924
1055
  system: options.system.clone(),
925
1056
  workspace_context,
926
1057
  history,
927
- image,
1058
+ images,
928
1059
  mcp: mcp_arc,
929
1060
  };
930
1061
  let result = crate::provider::ask(&config, &provider_name, request, policy, &stream_tx_inner).await;
@@ -1510,9 +1641,9 @@ fn assistant_header(model: &str) -> Line<'static> {
1510
1641
  fn render_input(frame: &mut Frame, area: Rect, app: &App) {
1511
1642
  // Border color reflects mode: ready=green, streaming=yellow, confirming=orange
1512
1643
  let border_color = match app.mode {
1513
- Mode::Input => Color::Rgb(152, 195, 121), // green — "your turn"
1644
+ Mode::Input | Mode::Search => Color::Rgb(152, 195, 121), // green — "your turn"
1514
1645
  Mode::Streaming => Color::Rgb(229, 192, 123), // yellow — "thinking"
1515
- Mode::Confirming=> Color::Rgb(224, 108, 117), // red — "needs decision"
1646
+ Mode::Confirming => Color::Rgb(224, 108, 117), // red — "needs decision"
1516
1647
  };
1517
1648
  let block = Block::default()
1518
1649
  .borders(Borders::TOP)
@@ -1520,15 +1651,14 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
1520
1651
  let inner = block.inner(area);
1521
1652
  frame.render_widget(block, area);
1522
1653
 
1523
- if app.mode != Mode::Input {
1654
+ if app.mode != Mode::Input && app.mode != Mode::Search {
1524
1655
  // Don't show cursor or text while AI is working
1525
1656
  return;
1526
1657
  }
1527
1658
 
1528
- if app.input.is_empty() && app.pending_image.is_none() {
1529
- // Placeholder hint
1659
+ if app.input.is_empty() && app.pending_images.is_empty() {
1530
1660
  frame.render_widget(
1531
- Paragraph::new(" ❯ Ask anything… (↑/↓ history · Ctrl+V paste image)")
1661
+ Paragraph::new(" ❯ Ask anything… (↑/↓ history · V paste image)")
1532
1662
  .style(Style::default().fg(Color::Rgb(60, 60, 80))),
1533
1663
  inner,
1534
1664
  );
@@ -1536,7 +1666,11 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
1536
1666
  return;
1537
1667
  }
1538
1668
 
1539
- let label = if app.pending_image.is_some() { " [📎] ❯ " } else { " ❯ " };
1669
+ let label = match app.pending_images.len() {
1670
+ 0 => " ❯ ".to_string(),
1671
+ 1 => " [📎] ❯ ".to_string(),
1672
+ n => format!(" [📎 ×{n}] ❯ "),
1673
+ };
1540
1674
  let label_w = label.chars().count();
1541
1675
  let display = format!("{label}{}", app.input);
1542
1676
 
@@ -1599,6 +1733,19 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) {
1599
1733
  area,
1600
1734
  );
1601
1735
  }
1736
+ Mode::Search => {
1737
+ let n = app.search_results.len();
1738
+ let pos = if n == 0 { String::new() } else { format!(" {}/{n}", app.search_idx + 1) };
1739
+ let left = format!(" 🔍 {}{pos}", app.search_query);
1740
+ let right = " ↑↓ navigate Esc close ";
1741
+ let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
1742
+ let text = format!("{left}{}{right}", " ".repeat(gap));
1743
+ frame.render_widget(
1744
+ Paragraph::new(text)
1745
+ .style(Style::default().fg(Color::White).bg(Color::Rgb(30, 20, 50))),
1746
+ area,
1747
+ );
1748
+ }
1602
1749
  Mode::Input => {
1603
1750
  let mode_icon = if app.mouse_capture { "⊙" } else { "⊕" };
1604
1751
  let mode_label = if app.mouse_capture { "scroll" } else { "select" };