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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +140 -235
- package/package.json +1 -1
- package/src/lib.rs +44 -2
- package/src/provider/mod.rs +2 -2
- package/src/provider/openai_compatible.rs +106 -7
- package/src/tools.rs +82 -0
- package/src/tui.rs +184 -37
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -1,302 +1,207 @@
|
|
|
1
|
-
#
|
|
1
|
+
# anveesa
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
10
|
+
---
|
|
32
11
|
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
29
|
+
```bash
|
|
30
|
+
# npm (recommended — downloads prebuilt binary)
|
|
31
|
+
npm install -g anveesa
|
|
54
32
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
37
|
+
---
|
|
62
38
|
|
|
63
|
-
|
|
64
|
-
❯ /attach ./screenshot.png
|
|
65
|
-
❯ explain this UI
|
|
66
|
-
```
|
|
39
|
+
## Quick start
|
|
67
40
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
41
|
+
```bash
|
|
42
|
+
# 1. Initialize config
|
|
43
|
+
anveesa config init
|
|
71
44
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
terminal before each one:
|
|
52
|
+
# 4. One-shot prompt
|
|
53
|
+
anveesa "explain the auth middleware in this repo"
|
|
86
54
|
|
|
87
|
-
|
|
88
|
-
|
|
55
|
+
# 5. Specific provider/model
|
|
56
|
+
anveesa --provider anthropic --model claude-sonnet-4-6 "refactor src/auth.ts"
|
|
89
57
|
```
|
|
90
58
|
|
|
91
|
-
|
|
92
|
-
turn, which is useful when scaffolding several files.
|
|
59
|
+
---
|
|
93
60
|
|
|
94
|
-
|
|
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
|
-
|
|
107
|
-
after the answer:
|
|
63
|
+
Config lives at `~/.config/anveesa/config.toml`.
|
|
108
64
|
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
```
|
|
65
|
+
```toml
|
|
66
|
+
default_provider = "anthropic"
|
|
112
67
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
```sh
|
|
130
|
-
anveesa "write a git commit message"
|
|
131
|
-
```
|
|
86
|
+
### Project instructions
|
|
132
87
|
|
|
133
|
-
|
|
88
|
+
Drop `.anveesa.md` in your repo root — auto-injected every session:
|
|
134
89
|
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
```
|
|
90
|
+
```markdown
|
|
91
|
+
# My Project
|
|
138
92
|
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
102
|
+
---
|
|
148
103
|
|
|
149
|
-
|
|
150
|
-
anveesa --provider claude-code "summarize this project"
|
|
151
|
-
```
|
|
104
|
+
## TUI shortcuts
|
|
152
105
|
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
anveesa --provider codex --model "gpt-5.1-codex" "review this repository"
|
|
157
|
-
```
|
|
122
|
+
---
|
|
158
123
|
|
|
159
|
-
|
|
124
|
+
## Slash commands
|
|
160
125
|
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
140
|
+
---
|
|
166
141
|
|
|
167
|
-
|
|
168
|
-
export SUMOPOD_API_KEY="..."
|
|
169
|
-
anveesa --provider sumopod --model "your-sumopod-model" "explain this error"
|
|
170
|
-
```
|
|
142
|
+
## Tools
|
|
171
143
|
|
|
172
|
-
|
|
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
|
-
|
|
146
|
+
**Git:** `git_status` `git_diff` `git_log` `git_blame` `git_show` `git_commit` `git_stash` `git_branch`
|
|
175
147
|
|
|
176
|
-
|
|
177
|
-
- `sumopod`
|
|
148
|
+
**Web:** `web_search` `fetch_url` `screenshot_url`
|
|
178
149
|
|
|
179
|
-
|
|
150
|
+
**Notes:** `save_note` `read_notes` `search_notes` `delete_note`
|
|
180
151
|
|
|
181
|
-
|
|
152
|
+
**Execution:** `run_command`
|
|
182
153
|
|
|
183
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
168
|
+
Requires Playwright: `npm install -g playwright && npx playwright install chromium`
|
|
238
169
|
|
|
239
|
-
|
|
170
|
+
---
|
|
240
171
|
|
|
241
|
-
|
|
242
|
-
anveesa config path
|
|
243
|
-
```
|
|
172
|
+
## Security
|
|
244
173
|
|
|
245
|
-
|
|
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
|
-
|
|
179
|
+
---
|
|
248
180
|
|
|
249
|
-
|
|
250
|
-
anveesa config set-provider sumopod
|
|
251
|
-
anveesa config set-model "kimi-k2.6"
|
|
252
|
-
```
|
|
181
|
+
## Environment variables
|
|
253
182
|
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
anveesa
|
|
258
|
-
```
|
|
192
|
+
---
|
|
259
193
|
|
|
260
|
-
|
|
194
|
+
## Build from source
|
|
261
195
|
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
203
|
+
Requires Rust 1.85+ (2024 edition).
|
|
204
|
+
|
|
205
|
+
---
|
|
297
206
|
|
|
298
|
-
|
|
299
|
-
- `{model}`
|
|
300
|
-
- `{system}`
|
|
301
|
-
- `{model_args}`
|
|
302
|
-
- `{system_args}`
|
|
207
|
+
MIT License
|
package/package.json
CHANGED
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()
|
package/src/provider/mod.rs
CHANGED
|
@@ -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
|
-
///
|
|
53
|
-
pub
|
|
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
|
|
826
|
-
let user_content =
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
479
|
-
KeyCode::Char('v')
|
|
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.
|
|
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.
|
|
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
|
|
635
|
-
|
|
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
|
|
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();
|
|
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
|
-
//
|
|
878
|
-
let
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
let
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 ·
|
|
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 =
|
|
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" };
|