anveesa 0.5.3 → 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 +42 -0
- package/src/provider/openai_compatible.rs +97 -0
- package/src/tools.rs +82 -0
- package/src/tui.rs +146 -11
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
|
@@ -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()
|
|
@@ -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(),
|
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 ─────────────────────────────────────────────────────────
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -395,6 +407,48 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
395
407
|
return Ok(());
|
|
396
408
|
}
|
|
397
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
|
+
|
|
398
452
|
// ── Input mode ────────────────────────────────────────────────────────────
|
|
399
453
|
match code {
|
|
400
454
|
// Submit (Enter) or newline (Shift+Enter)
|
|
@@ -497,6 +551,15 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
497
551
|
}
|
|
498
552
|
}
|
|
499
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
|
+
|
|
500
563
|
// Ctrl+M — toggle mouse capture (scroll mode ↔ select mode)
|
|
501
564
|
KeyCode::Char('m') if modifiers.contains(KeyModifiers::CONTROL) => {
|
|
502
565
|
app.mouse_capture = !app.mouse_capture;
|
|
@@ -633,8 +696,9 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
633
696
|
/model [name] · /provider [name] · /status · /exit\n\
|
|
634
697
|
\n\
|
|
635
698
|
Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
|
|
636
|
-
Tab
|
|
637
|
-
|
|
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\
|
|
638
702
|
j/k scroll (when input empty) PageUp/Dn scroll\n\
|
|
639
703
|
⌘V (macOS) / Ctrl+V paste image or text (repeat to queue multiple images)\n\
|
|
640
704
|
Ctrl+W delete-word Ctrl+U clear line\n\
|
|
@@ -700,7 +764,6 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
700
764
|
true
|
|
701
765
|
}
|
|
702
766
|
"/compact" => {
|
|
703
|
-
// Keep only the last 10 turns, drop older history to free context
|
|
704
767
|
let keep = 10usize;
|
|
705
768
|
let total_turns = app.history.len() / 2;
|
|
706
769
|
if total_turns <= keep {
|
|
@@ -710,16 +773,35 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
710
773
|
} else {
|
|
711
774
|
let drop_turns = total_turns - keep;
|
|
712
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(", ")) };
|
|
713
792
|
app.history.drain(..drop_msgs);
|
|
714
|
-
// Also remove older messages from the display (keep separators and last N turns)
|
|
715
793
|
let msg_count = app.messages.len();
|
|
716
794
|
if msg_count > keep * 3 {
|
|
717
795
|
app.messages.drain(..(msg_count - keep * 3));
|
|
718
796
|
}
|
|
719
|
-
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
|
+
)));
|
|
720
803
|
app.messages.insert(0, Msg::System(format!(
|
|
721
|
-
"Context compacted: dropped {drop_turns} older turn(s), keeping the last {keep}.
|
|
722
|
-
Use /clear to start fresh."
|
|
804
|
+
"Context compacted: dropped {drop_turns} older turn(s), keeping the last {keep}.{file_hint}"
|
|
723
805
|
)));
|
|
724
806
|
app.messages.push(Msg::Separator);
|
|
725
807
|
}
|
|
@@ -727,6 +809,16 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
727
809
|
app.input_cursor = 0;
|
|
728
810
|
true
|
|
729
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
|
+
}
|
|
730
822
|
s if s.starts_with("/export") => {
|
|
731
823
|
let arg = s.strip_prefix("/export").unwrap().trim();
|
|
732
824
|
let path = if arg.is_empty() {
|
|
@@ -786,11 +878,41 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
786
878
|
}
|
|
787
879
|
}
|
|
788
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
|
+
|
|
789
911
|
// ── Tab completion ────────────────────────────────────────────────────────────
|
|
790
912
|
|
|
791
913
|
const SLASH_COMMANDS: &[&str] = &[
|
|
792
914
|
"/clear", "/compact", "/copy", "/exit", "/export",
|
|
793
|
-
"/help", "/model", "/provider", "/quit", "/status", "/undo",
|
|
915
|
+
"/help", "/model", "/provider", "/quit", "/search", "/status", "/undo",
|
|
794
916
|
];
|
|
795
917
|
|
|
796
918
|
fn tab_complete(app: &mut App) {
|
|
@@ -1519,9 +1641,9 @@ fn assistant_header(model: &str) -> Line<'static> {
|
|
|
1519
1641
|
fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
1520
1642
|
// Border color reflects mode: ready=green, streaming=yellow, confirming=orange
|
|
1521
1643
|
let border_color = match app.mode {
|
|
1522
|
-
Mode::Input
|
|
1644
|
+
Mode::Input | Mode::Search => Color::Rgb(152, 195, 121), // green — "your turn"
|
|
1523
1645
|
Mode::Streaming => Color::Rgb(229, 192, 123), // yellow — "thinking"
|
|
1524
|
-
Mode::Confirming=> Color::Rgb(224, 108, 117), // red — "needs decision"
|
|
1646
|
+
Mode::Confirming => Color::Rgb(224, 108, 117), // red — "needs decision"
|
|
1525
1647
|
};
|
|
1526
1648
|
let block = Block::default()
|
|
1527
1649
|
.borders(Borders::TOP)
|
|
@@ -1529,7 +1651,7 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1529
1651
|
let inner = block.inner(area);
|
|
1530
1652
|
frame.render_widget(block, area);
|
|
1531
1653
|
|
|
1532
|
-
if app.mode != Mode::Input {
|
|
1654
|
+
if app.mode != Mode::Input && app.mode != Mode::Search {
|
|
1533
1655
|
// Don't show cursor or text while AI is working
|
|
1534
1656
|
return;
|
|
1535
1657
|
}
|
|
@@ -1611,6 +1733,19 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1611
1733
|
area,
|
|
1612
1734
|
);
|
|
1613
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
|
+
}
|
|
1614
1749
|
Mode::Input => {
|
|
1615
1750
|
let mode_icon = if app.mouse_capture { "⊙" } else { "⊕" };
|
|
1616
1751
|
let mode_label = if app.mouse_capture { "scroll" } else { "select" };
|