buildwithnexus 0.10.3 → 0.10.4
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/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +10 -4
- package/harness/src/checkpoint.rs +104 -0
- package/harness/src/config.rs +1 -1
- package/harness/src/lib.rs +296 -39
- package/harness/src/onboarding.rs +50 -8
- package/harness/src/report.rs +2 -2
- package/harness/src/tools.rs +218 -3
- package/harness/src/tui.rs +184 -56
- package/package.json +3 -3
- package/scripts/postinstall.js +6 -1
package/harness/src/lib.rs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// suites can reach the internals directly.
|
|
3
3
|
|
|
4
4
|
pub mod agent;
|
|
5
|
+
pub mod checkpoint;
|
|
5
6
|
pub mod config;
|
|
6
7
|
pub mod hooks;
|
|
7
8
|
pub mod local;
|
|
@@ -22,6 +23,35 @@ use provider::Msg;
|
|
|
22
23
|
use provider::Provider;
|
|
23
24
|
|
|
24
25
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
26
|
+
const MAX_ATTACHED_FILE_BYTES: u64 = 48 * 1024;
|
|
27
|
+
|
|
28
|
+
#[derive(Default, Clone)]
|
|
29
|
+
struct CliOptions {
|
|
30
|
+
model: Option<String>,
|
|
31
|
+
permission_mode: Option<String>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn parse_cli_options(args: Vec<String>) -> (CliOptions, Vec<String>) {
|
|
35
|
+
let mut opts = CliOptions::default();
|
|
36
|
+
let mut rest = Vec::new();
|
|
37
|
+
let mut it = args.into_iter();
|
|
38
|
+
while let Some(arg) = it.next() {
|
|
39
|
+
if let Some(v) = arg.strip_prefix("--model=") {
|
|
40
|
+
opts.model = Some(v.to_string());
|
|
41
|
+
} else if arg == "--model" {
|
|
42
|
+
opts.model = it.next();
|
|
43
|
+
} else if let Some(v) = arg.strip_prefix("--permission-mode=") {
|
|
44
|
+
opts.permission_mode = Some(v.to_string());
|
|
45
|
+
} else if let Some(v) = arg.strip_prefix("--permission=") {
|
|
46
|
+
opts.permission_mode = Some(v.to_string());
|
|
47
|
+
} else if arg == "--permission-mode" || arg == "--permission" {
|
|
48
|
+
opts.permission_mode = it.next();
|
|
49
|
+
} else {
|
|
50
|
+
rest.push(arg);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
(opts, rest)
|
|
54
|
+
}
|
|
25
55
|
|
|
26
56
|
pub fn run() {
|
|
27
57
|
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
|
@@ -29,11 +59,12 @@ pub fn run() {
|
|
|
29
59
|
args.retain(|a| a != "--json");
|
|
30
60
|
report::set(report::Mode::Json);
|
|
31
61
|
}
|
|
62
|
+
let (opts, args) = parse_cli_options(args);
|
|
32
63
|
let cmd = args.first().map(String::as_str).unwrap_or("");
|
|
33
64
|
let rest = || args[1..].join(" ");
|
|
34
65
|
|
|
35
66
|
match cmd {
|
|
36
|
-
"" => interactive(),
|
|
67
|
+
"" => interactive(None, opts),
|
|
37
68
|
"init" | "da-init" | "setup" => {
|
|
38
69
|
onboarding::run();
|
|
39
70
|
}
|
|
@@ -43,9 +74,9 @@ pub fn run() {
|
|
|
43
74
|
println!(" {:<12} {:<26} {}", p.id, p.label, tag);
|
|
44
75
|
}
|
|
45
76
|
}
|
|
46
|
-
"run" | "build" => headless(|p, perm, cwd| agent::run_build(p, perm, "engineer", &rest(), &cwd)),
|
|
47
|
-
"plan" => headless(|p, perm, cwd| agent::run_plan(p, perm, &rest(), &cwd)),
|
|
48
|
-
"brainstorm" => headless(|p, perm, cwd| {
|
|
77
|
+
"run" | "build" | "-p" | "--print" => headless(&opts, |p, perm, cwd| agent::run_build(p, perm, "engineer", &rest(), &cwd)),
|
|
78
|
+
"plan" => headless(&opts, |p, perm, cwd| agent::run_plan(p, perm, &rest(), &cwd)),
|
|
79
|
+
"brainstorm" => headless(&opts, |p, perm, cwd| {
|
|
49
80
|
agent::run_brainstorm(p, perm, &cwd, &rest()).map(|_| ())
|
|
50
81
|
}),
|
|
51
82
|
"sessions" => {
|
|
@@ -54,14 +85,14 @@ pub fn run() {
|
|
|
54
85
|
println!(" {} {:<48} {}", s.id, title, s.cwd);
|
|
55
86
|
}
|
|
56
87
|
}
|
|
57
|
-
"continue" => headless(|p, perm, cwd| match session::latest() {
|
|
88
|
+
"continue" | "-c" | "--continue" => headless(&opts, |p, perm, cwd| match session::latest() {
|
|
58
89
|
Some(s) => agent::run_build_resumed(p, perm, "engineer", &rest(), &cwd, s.msgs, &s.id),
|
|
59
90
|
None => Err("no sessions to continue".into()),
|
|
60
91
|
}),
|
|
61
|
-
"resume" => {
|
|
92
|
+
"resume" | "-r" | "--resume" => {
|
|
62
93
|
let id = args.get(1).cloned().unwrap_or_default();
|
|
63
94
|
let task = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
|
|
64
|
-
headless(|p, perm, cwd| match session::load(&id) {
|
|
95
|
+
headless(&opts, |p, perm, cwd| match session::load(&id) {
|
|
65
96
|
Some(s) => agent::run_build_resumed(p, perm, "engineer", &task, &cwd, s.msgs, &s.id),
|
|
66
97
|
None => Err(format!("no session '{id}'")),
|
|
67
98
|
})
|
|
@@ -69,6 +100,7 @@ pub fn run() {
|
|
|
69
100
|
"-v" | "-V" | "--version" | "version" => println!("buildwithnexus {VERSION}"),
|
|
70
101
|
"-h" | "--help" | "help" => usage(),
|
|
71
102
|
"doctor" => run_doctor(),
|
|
103
|
+
_ if !args.is_empty() => interactive(Some(args.join(" ")), opts),
|
|
72
104
|
other => {
|
|
73
105
|
eprintln!("unknown command: {other}\n");
|
|
74
106
|
usage();
|
|
@@ -77,12 +109,17 @@ pub fn run() {
|
|
|
77
109
|
}
|
|
78
110
|
}
|
|
79
111
|
|
|
80
|
-
fn provider_or_onboard() -> Result<(Provider, Permission), String> {
|
|
112
|
+
fn provider_or_onboard(opts: &CliOptions) -> Result<(Provider, Permission), String> {
|
|
81
113
|
let settings = match config::load_settings() {
|
|
82
114
|
Some(s) => s,
|
|
83
115
|
None => onboarding::run().ok_or("setup cancelled")?,
|
|
84
116
|
};
|
|
85
|
-
|
|
117
|
+
let mut provider = build_provider(&settings)?;
|
|
118
|
+
if let Some(model) = &opts.model {
|
|
119
|
+
provider.model = model.clone();
|
|
120
|
+
}
|
|
121
|
+
let perm_name = opts.permission_mode.as_deref().unwrap_or(&settings.permission);
|
|
122
|
+
Ok((provider, agent::permission(perm_name)))
|
|
86
123
|
}
|
|
87
124
|
|
|
88
125
|
pub fn build_provider(s: &Settings) -> Result<Provider, String> {
|
|
@@ -110,8 +147,8 @@ pub fn build_provider(s: &Settings) -> Result<Provider, String> {
|
|
|
110
147
|
Ok(Provider { protocol: preset.protocol, base_url, api_key, model, context_tokens })
|
|
111
148
|
}
|
|
112
149
|
|
|
113
|
-
fn headless(f: impl FnOnce(&Provider, Permission, PathBuf) -> Result<(), String>) {
|
|
114
|
-
let (provider, perm) = match provider_or_onboard() {
|
|
150
|
+
fn headless(opts: &CliOptions, f: impl FnOnce(&Provider, Permission, PathBuf) -> Result<(), String>) {
|
|
151
|
+
let (provider, perm) = match provider_or_onboard(opts) {
|
|
115
152
|
Ok(v) => v,
|
|
116
153
|
Err(e) => { eprintln!("{}", tui::red(&e)); std::process::exit(1); }
|
|
117
154
|
};
|
|
@@ -127,7 +164,7 @@ fn headless(f: impl FnOnce(&Provider, Permission, PathBuf) -> Result<(), String>
|
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
|
|
130
|
-
fn interactive() {
|
|
167
|
+
fn interactive(initial_prompt: Option<String>, opts: CliOptions) {
|
|
131
168
|
// Always scaffold on interactive launch so existing users also get the
|
|
132
169
|
// directory skeleton and starter Agents.md if they're missing.
|
|
133
170
|
config::scaffold_home();
|
|
@@ -135,7 +172,7 @@ fn interactive() {
|
|
|
135
172
|
if !onboarded && onboarding::run().is_none() {
|
|
136
173
|
return;
|
|
137
174
|
}
|
|
138
|
-
let (provider, perm) = match provider_or_onboard() {
|
|
175
|
+
let (provider, perm) = match provider_or_onboard(&opts) {
|
|
139
176
|
Ok(v) => v,
|
|
140
177
|
Err(e) => { eprintln!("{}", tui::red(&e)); std::process::exit(1); }
|
|
141
178
|
};
|
|
@@ -146,7 +183,7 @@ fn interactive() {
|
|
|
146
183
|
hooks::init(&cwd, raw);
|
|
147
184
|
hooks::notify("SessionStart", &cwd);
|
|
148
185
|
tui::enter_alt(raw);
|
|
149
|
-
let result = repl(provider, perm, &cwd, raw);
|
|
186
|
+
let result = repl(provider, perm, &cwd, raw, initial_prompt);
|
|
150
187
|
tui::leave_alt();
|
|
151
188
|
hooks::notify("SessionEnd", &cwd);
|
|
152
189
|
if let Err(e) = result {
|
|
@@ -155,7 +192,7 @@ fn interactive() {
|
|
|
155
192
|
}
|
|
156
193
|
|
|
157
194
|
// ── REPL ──────────────────────────────────────────────────────────────────────
|
|
158
|
-
fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw: bool) -> Result<(), String> {
|
|
195
|
+
fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw: bool, initial_prompt: Option<String>) -> Result<(), String> {
|
|
159
196
|
let settings = config::load_settings().unwrap_or_default();
|
|
160
197
|
|
|
161
198
|
// Show the full-screen header banner.
|
|
@@ -174,6 +211,7 @@ fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw
|
|
|
174
211
|
let mut last_suggested_mode: Option<&'static str> = None;
|
|
175
212
|
// /btw: extra context injected into the next task without interrupting.
|
|
176
213
|
let mut btw_ctx: Option<String> = None;
|
|
214
|
+
let mut pending_prompt = initial_prompt;
|
|
177
215
|
|
|
178
216
|
loop {
|
|
179
217
|
// Tick background workflows and surface any completion notifications.
|
|
@@ -189,17 +227,23 @@ fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw
|
|
|
189
227
|
tui::line(&tui::dim(&format!(" ⟳ {} workflow{} in queue — /workflows to manage", active, if active == 1 { "" } else { "s" })));
|
|
190
228
|
}
|
|
191
229
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
230
|
+
let task = if let Some(prompted) = pending_prompt.take() {
|
|
231
|
+
tui::line("");
|
|
232
|
+
tui::line(&format!("{} {} {}", tui::mode_badge(mode_label(&mode)), tui::accent("›"), prompted));
|
|
233
|
+
prompted
|
|
234
|
+
} else {
|
|
235
|
+
tui::line("");
|
|
236
|
+
let prompt = format!("{} {} ", tui::mode_badge(mode_label(&mode)), tui::accent("›"));
|
|
237
|
+
match tui::ask_task(&prompt) {
|
|
238
|
+
None => return Ok(()),
|
|
239
|
+
Some(tui::InputEvent::CycleMode) => {
|
|
240
|
+
mode = mode.next();
|
|
241
|
+
last_suggested_mode = None;
|
|
242
|
+
tui::show_mode_change(mode_label(&mode));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
Some(tui::InputEvent::Text(t)) => t,
|
|
201
246
|
}
|
|
202
|
-
Some(tui::InputEvent::Text(t)) => t,
|
|
203
247
|
};
|
|
204
248
|
let t = task.trim();
|
|
205
249
|
if t.is_empty() {
|
|
@@ -300,6 +344,31 @@ fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw
|
|
|
300
344
|
continue;
|
|
301
345
|
}
|
|
302
346
|
|
|
347
|
+
if let Some(task) = t.strip_prefix("/plan ") {
|
|
348
|
+
tui::line("");
|
|
349
|
+
if let Err(e) = agent::run_plan(&provider, perm, task.trim(), cwd) {
|
|
350
|
+
tui::line(&tui::red(&format!(" {e}")));
|
|
351
|
+
}
|
|
352
|
+
tui::bell();
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if let Some(task) = t.strip_prefix("/build ") {
|
|
356
|
+
tui::line("");
|
|
357
|
+
if let Err(e) = agent::run_build_session(&provider, perm, "engineer", task.trim(), cwd, &mut transcript, &sid) {
|
|
358
|
+
tui::line(&tui::red(&format!(" {e}")));
|
|
359
|
+
}
|
|
360
|
+
tui::bell();
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if let Some(task) = t.strip_prefix("/brainstorm ") {
|
|
364
|
+
tui::line("");
|
|
365
|
+
if let Err(e) = agent::run_brainstorm(&provider, perm, cwd, task.trim()).map(|_| ()) {
|
|
366
|
+
tui::line(&tui::red(&format!(" {e}")));
|
|
367
|
+
}
|
|
368
|
+
tui::bell();
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
303
372
|
match t {
|
|
304
373
|
"/exit" | "/quit" | "exit" => return Ok(()),
|
|
305
374
|
"/clear" => { tui::clear(); continue; }
|
|
@@ -367,10 +436,34 @@ fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw
|
|
|
367
436
|
tui::bell();
|
|
368
437
|
continue;
|
|
369
438
|
}
|
|
370
|
-
"/workflows" => {
|
|
439
|
+
"/workflows" | "/tasks" => {
|
|
371
440
|
handle_workflows();
|
|
372
441
|
continue;
|
|
373
442
|
}
|
|
443
|
+
"/doctor" | "/debug" => {
|
|
444
|
+
handle_doctor_tui();
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
"/diff" => {
|
|
448
|
+
handle_diff(cwd);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
"/context" => {
|
|
452
|
+
handle_context(&transcript, provider.context_tokens);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
"/agents" => {
|
|
456
|
+
handle_agents();
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
"/checkpoints" => {
|
|
460
|
+
handle_checkpoints(cwd);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
"/undo" | "/rewind" => {
|
|
464
|
+
handle_undo(cwd);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
374
467
|
"/mode" => {
|
|
375
468
|
tui::line(&format!(" Current mode: {}", tui::mode_badge(mode_label(&mode))));
|
|
376
469
|
tui::line(&tui::dim(" Tab-complete: /mode plan|build|brainstorm · Shift+Tab to cycle"));
|
|
@@ -472,9 +565,10 @@ fn repl(mut provider: Provider, mut perm: Permission, cwd: &std::path::Path, raw
|
|
|
472
565
|
// Mode suggestion: hint once per unique suggestion (don't repeat if user stays in mode).
|
|
473
566
|
suggest_mode_if_mismatch(t, &mode, &mut last_suggested_mode);
|
|
474
567
|
|
|
475
|
-
// Extract @
|
|
568
|
+
// Extract @path tokens. Images become multimodal attachments; text files
|
|
569
|
+
// are appended into the prompt with optional @file:start-end ranges.
|
|
476
570
|
// its own Msg::User push and uses this multimodal turn instead.
|
|
477
|
-
let (clean_task, image_data) =
|
|
571
|
+
let (clean_task, image_data) = extract_attachments(t, cwd);
|
|
478
572
|
let n_images = image_data.len();
|
|
479
573
|
if n_images > 0 {
|
|
480
574
|
transcript.push(Msg::UserImages { text: clean_task.clone(), images: image_data });
|
|
@@ -872,6 +966,78 @@ fn handle_permissions(perm: &mut Permission) {
|
|
|
872
966
|
}
|
|
873
967
|
}
|
|
874
968
|
|
|
969
|
+
fn handle_diff(cwd: &std::path::Path) {
|
|
970
|
+
let out = tools::run("run_command", &serde_json::json!({"command": "git diff --stat && git diff --shortstat"}), cwd);
|
|
971
|
+
for line in out.content.lines() {
|
|
972
|
+
tui::line(&tui::dim(&format!(" {line}")));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
fn msg_token_estimate(msgs: &[provider::Msg]) -> usize {
|
|
977
|
+
let chars: usize = msgs
|
|
978
|
+
.iter()
|
|
979
|
+
.map(|m| match m {
|
|
980
|
+
provider::Msg::System(s) | provider::Msg::User(s) => s.len(),
|
|
981
|
+
provider::Msg::UserImages { text, images } => text.len() + images.len() * 1024,
|
|
982
|
+
provider::Msg::Assistant { text, calls } => text.len() + calls.iter().map(|c| c.input.to_string().len()).sum::<usize>(),
|
|
983
|
+
provider::Msg::Tool(results) => results.iter().map(|r| r.content.len()).sum(),
|
|
984
|
+
})
|
|
985
|
+
.sum();
|
|
986
|
+
chars / 4
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
fn handle_context(transcript: &[provider::Msg], total: usize) {
|
|
990
|
+
let used = msg_token_estimate(transcript);
|
|
991
|
+
tui::context_meter(used, total);
|
|
992
|
+
tui::line(&tui::dim(&format!(" {} messages in session", transcript.len())));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
fn handle_checkpoints(cwd: &std::path::Path) {
|
|
996
|
+
let items = checkpoint::list(cwd);
|
|
997
|
+
if items.is_empty() {
|
|
998
|
+
tui::line(&tui::dim(" no checkpoints for this directory"));
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
for cp in items.iter().take(10) {
|
|
1002
|
+
tui::line(&format!(" {} {} {}", tui::bold(&cp.id), cp.action, cp.path.display()));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
fn handle_undo(cwd: &std::path::Path) {
|
|
1007
|
+
match checkpoint::undo_latest(cwd) {
|
|
1008
|
+
Ok(cp) => tui::line(&tui::green(&format!(" restored {}", cp.path.display()))),
|
|
1009
|
+
Err(e) => tui::line(&tui::red(&format!(" {e}"))),
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
fn handle_agents() {
|
|
1014
|
+
match config::load_agents() {
|
|
1015
|
+
Some(agents) => {
|
|
1016
|
+
for line in agents.lines().take(80) {
|
|
1017
|
+
tui::line(&format!(" {line}"));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
None => tui::line(&tui::dim(" no Agents.md found")),
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
fn handle_doctor_tui() {
|
|
1025
|
+
tui::line(&tui::accent(&format!(" buildwithnexus {VERSION} doctor")));
|
|
1026
|
+
match config::load_settings() {
|
|
1027
|
+
Some(s) => {
|
|
1028
|
+
tui::line(&format!(" provider: {}", s.provider));
|
|
1029
|
+
tui::line(&format!(" model: {}", s.model));
|
|
1030
|
+
tui::line(&format!(" permission: {}", s.permission));
|
|
1031
|
+
}
|
|
1032
|
+
None => tui::line(&tui::yellow(" settings: not configured")),
|
|
1033
|
+
}
|
|
1034
|
+
tui::line(&format!(" home: {}", config::home().display()));
|
|
1035
|
+
tui::line(&format!(" rust: {}", std::process::Command::new("rustc").arg("--version").output().ok()
|
|
1036
|
+
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
1037
|
+
.map(|s| s.trim().to_string())
|
|
1038
|
+
.unwrap_or_else(|| "not found".to_string())));
|
|
1039
|
+
}
|
|
1040
|
+
|
|
875
1041
|
fn print_help() {
|
|
876
1042
|
tui::line(&tui::accent(" buildwithnexus commands"));
|
|
877
1043
|
tui::line(&tui::dim(" ─────────────────────────────────────────────────────────────"));
|
|
@@ -881,16 +1047,22 @@ fn print_help() {
|
|
|
881
1047
|
tui::line(&tui::dim(" or just say: \"switch to build mode\" / \"use readonly\""));
|
|
882
1048
|
tui::line(&format!(" {} hot-swap the AI model mid-session", tui::bold("/model")));
|
|
883
1049
|
tui::line(&format!(" {} compact context {}", tui::bold("/compact"), tui::dim("(free up token budget)")));
|
|
1050
|
+
tui::line(&format!(" {} show current context usage", tui::bold("/context")));
|
|
1051
|
+
tui::line(&format!(" {} show current git diff summary", tui::bold("/diff")));
|
|
884
1052
|
tui::line(&format!(" {} AI code review of staged git diff", tui::bold("/review")));
|
|
885
1053
|
tui::line(&format!(" {} AI-drafted conventional commit message", tui::bold("/commit")));
|
|
886
1054
|
tui::line(&format!(" {} AI-drafted PR title + description", tui::bold("/pr")));
|
|
887
1055
|
tui::line(&format!(" {} schedule a one-shot workflow {}", tui::bold("/schedule"), tui::dim("<delay> <task>")));
|
|
888
1056
|
tui::line(&format!(" {} start a repeating workflow {}", tui::bold("/loop"), tui::dim("<interval> <task>")));
|
|
889
|
-
tui::line(&format!(" {} list and manage background workflows", tui::bold("/workflows")));
|
|
1057
|
+
tui::line(&format!(" {} list and manage background workflows {}", tui::bold("/workflows"), tui::dim("(/tasks)")));
|
|
890
1058
|
tui::line(&format!(" {} inject context into next agent turn {}", tui::bold("/btw"), tui::dim("<context>")));
|
|
891
1059
|
tui::line(&format!(" {} configure hooks, memory, commands via AI", tui::bold("/config")));
|
|
892
1060
|
tui::line(&format!(" {} view/edit session memory", tui::bold("/memory")));
|
|
893
1061
|
tui::line(&format!(" {} list available skills and custom commands", tui::bold("/skills")));
|
|
1062
|
+
tui::line(&format!(" {} show loaded Agents.md context", tui::bold("/agents")));
|
|
1063
|
+
tui::line(&format!(" {} list edit checkpoints", tui::bold("/checkpoints")));
|
|
1064
|
+
tui::line(&format!(" {} restore latest checkpoint {}", tui::bold("/undo"), tui::dim("(/rewind)")));
|
|
1065
|
+
tui::line(&format!(" {} diagnose setup", tui::bold("/doctor")));
|
|
894
1066
|
tui::line(&format!(" {} start a fresh session", tui::bold("/new")));
|
|
895
1067
|
tui::line(&format!(" {} pick a saved session to resume", tui::bold("/resume")));
|
|
896
1068
|
tui::line(&format!(" {} run setup (keys, providers, local models)", tui::bold("/init")));
|
|
@@ -923,23 +1095,26 @@ impl Mode {
|
|
|
923
1095
|
}
|
|
924
1096
|
}
|
|
925
1097
|
|
|
926
|
-
// Parse `@path`
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
fn extract_images(task: &str, cwd: &std::path::Path) -> (String, Vec<(String, String)>) {
|
|
1098
|
+
// Parse `@path` attachment tokens. Images become multimodal entries; readable
|
|
1099
|
+
// text files are appended to the prompt. Unreadable tokens are left unchanged.
|
|
1100
|
+
fn extract_attachments(task: &str, cwd: &std::path::Path) -> (String, Vec<(String, String)>) {
|
|
930
1101
|
use std::io::Read;
|
|
931
1102
|
let image_exts = ["png", "jpg", "jpeg", "gif", "webp"];
|
|
932
1103
|
let mut images: Vec<(String, String)> = Vec::new();
|
|
933
1104
|
let mut clean = String::new();
|
|
1105
|
+
let mut text_attachments = Vec::new();
|
|
934
1106
|
for word in task.split_whitespace() {
|
|
935
1107
|
if let Some(raw_path) = word.strip_prefix('@') {
|
|
1108
|
+
let (raw_path, range) = split_attachment_range(raw_path);
|
|
936
1109
|
let ext = raw_path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
1110
|
+
let p = if let Some(rest) = raw_path.strip_prefix("~/") {
|
|
1111
|
+
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| cwd.to_path_buf()).join(rest)
|
|
1112
|
+
} else if raw_path.starts_with('/') {
|
|
1113
|
+
PathBuf::from(raw_path)
|
|
1114
|
+
} else {
|
|
1115
|
+
cwd.join(raw_path)
|
|
1116
|
+
};
|
|
937
1117
|
if image_exts.contains(&ext.as_str()) {
|
|
938
|
-
let p = if raw_path.starts_with('/') || raw_path.starts_with('~') {
|
|
939
|
-
PathBuf::from(raw_path)
|
|
940
|
-
} else {
|
|
941
|
-
cwd.join(raw_path)
|
|
942
|
-
};
|
|
943
1118
|
if let Ok(mut f) = std::fs::File::open(&p) {
|
|
944
1119
|
let mut buf = Vec::new();
|
|
945
1120
|
if f.read_to_end(&mut buf).is_ok() {
|
|
@@ -955,14 +1130,57 @@ fn extract_images(task: &str, cwd: &std::path::Path) -> (String, Vec<(String, St
|
|
|
955
1130
|
continue;
|
|
956
1131
|
}
|
|
957
1132
|
}
|
|
1133
|
+
} else if let Some(text) = read_text_attachment(&p, range) {
|
|
1134
|
+
text_attachments.push(format!("[file: {}]\n{}", p.display(), text));
|
|
1135
|
+
if !clean.is_empty() { clean.push(' '); }
|
|
1136
|
+
clean.push_str(&format!("[file: {}]", p.display()));
|
|
1137
|
+
continue;
|
|
958
1138
|
}
|
|
959
1139
|
}
|
|
960
1140
|
if !clean.is_empty() { clean.push(' '); }
|
|
961
1141
|
clean.push_str(word);
|
|
962
1142
|
}
|
|
1143
|
+
if !text_attachments.is_empty() {
|
|
1144
|
+
clean.push_str("\n\n[attached files]\n");
|
|
1145
|
+
clean.push_str(&text_attachments.join("\n\n"));
|
|
1146
|
+
}
|
|
963
1147
|
(clean, images)
|
|
964
1148
|
}
|
|
965
1149
|
|
|
1150
|
+
fn split_attachment_range(raw: &str) -> (&str, Option<(usize, usize)>) {
|
|
1151
|
+
let Some((path, suffix)) = raw.rsplit_once(':') else {
|
|
1152
|
+
return (raw, None);
|
|
1153
|
+
};
|
|
1154
|
+
let parse_line = |s: &str| s.parse::<usize>().ok().filter(|n| *n > 0);
|
|
1155
|
+
if let Some((a, b)) = suffix.split_once('-') {
|
|
1156
|
+
if let (Some(start), Some(end)) = (parse_line(a), parse_line(b)) {
|
|
1157
|
+
return (path, Some((start, end.max(start))));
|
|
1158
|
+
}
|
|
1159
|
+
} else if let Some(line) = parse_line(suffix) {
|
|
1160
|
+
return (path, Some((line, line)));
|
|
1161
|
+
}
|
|
1162
|
+
(raw, None)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
fn read_text_attachment(path: &std::path::Path, range: Option<(usize, usize)>) -> Option<String> {
|
|
1166
|
+
let meta = std::fs::metadata(path).ok()?;
|
|
1167
|
+
if meta.len() > MAX_ATTACHED_FILE_BYTES {
|
|
1168
|
+
return None;
|
|
1169
|
+
}
|
|
1170
|
+
let text = std::fs::read_to_string(path).ok()?;
|
|
1171
|
+
let Some((start, end)) = range else {
|
|
1172
|
+
return Some(text);
|
|
1173
|
+
};
|
|
1174
|
+
Some(text.lines()
|
|
1175
|
+
.enumerate()
|
|
1176
|
+
.filter_map(|(i, line)| {
|
|
1177
|
+
let line_no = i + 1;
|
|
1178
|
+
if line_no >= start && line_no <= end { Some(line) } else { None }
|
|
1179
|
+
})
|
|
1180
|
+
.collect::<Vec<_>>()
|
|
1181
|
+
.join("\n"))
|
|
1182
|
+
}
|
|
1183
|
+
|
|
966
1184
|
fn base64_encode(data: &[u8]) -> String {
|
|
967
1185
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
968
1186
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
@@ -1016,16 +1234,19 @@ fn usage() {
|
|
|
1016
1234
|
\x20 /permissions [ask|auto|readonly] show or switch tool permission level\n\
|
|
1017
1235
|
\x20 or say: \"switch to build mode\" / \"use readonly\"\n\
|
|
1018
1236
|
\x20 /compact compress context to free up token budget\n\
|
|
1237
|
+
\x20 /context show current context usage\n\
|
|
1238
|
+
\x20 /diff show current git diff summary\n\
|
|
1019
1239
|
\x20 /review AI code review of staged git diff\n\
|
|
1020
1240
|
\x20 /commit AI-drafted conventional commit message\n\
|
|
1021
1241
|
\x20 /pr AI-drafted pull request title + description\n\
|
|
1022
1242
|
\x20 /schedule <delay> <t> one-shot workflow (e.g. /schedule 5m cargo test)\n\
|
|
1023
1243
|
\x20 /loop <interval> <t> repeating workflow (e.g. /loop 30m cargo test)\n\
|
|
1024
|
-
\x20 /workflows
|
|
1244
|
+
\x20 /workflows /tasks list and manage background workflows\n\
|
|
1025
1245
|
\x20 /btw <context> inject context into next agent turn\n\
|
|
1026
1246
|
\x20 /config configure hooks, memory, commands via AI\n\
|
|
1027
1247
|
\x20 /memory view and edit session memory\n\
|
|
1028
1248
|
\x20 /skills list available skills and custom commands\n\
|
|
1249
|
+
\x20 /agents /checkpoints /undo /doctor\n\
|
|
1029
1250
|
\x20 /help /clear /new /resume /init /exit\n\
|
|
1030
1251
|
\x20 !<cmd> run shell command directly\n\
|
|
1031
1252
|
\x20 @<path> Tab-complete a file path\n\
|
|
@@ -1185,6 +1406,42 @@ mod tests {
|
|
|
1185
1406
|
assert_eq!(detect_permission_switch("use readonly mode"), Some("readonly"));
|
|
1186
1407
|
}
|
|
1187
1408
|
|
|
1409
|
+
#[test]
|
|
1410
|
+
fn parse_cli_options_extracts_model_and_permission() {
|
|
1411
|
+
let (opts, rest) = parse_cli_options(vec![
|
|
1412
|
+
"--model".into(),
|
|
1413
|
+
"qwen3".into(),
|
|
1414
|
+
"--permission-mode=acceptEdits".into(),
|
|
1415
|
+
"fix".into(),
|
|
1416
|
+
"tests".into(),
|
|
1417
|
+
]);
|
|
1418
|
+
assert_eq!(opts.model.as_deref(), Some("qwen3"));
|
|
1419
|
+
assert_eq!(opts.permission_mode.as_deref(), Some("acceptEdits"));
|
|
1420
|
+
assert_eq!(rest, vec!["fix", "tests"]);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
#[test]
|
|
1424
|
+
fn attachment_range_parsing() {
|
|
1425
|
+
assert_eq!(split_attachment_range("src/lib.rs:10-12"), ("src/lib.rs", Some((10, 12))));
|
|
1426
|
+
assert_eq!(split_attachment_range("src/lib.rs:5"), ("src/lib.rs", Some((5, 5))));
|
|
1427
|
+
assert_eq!(split_attachment_range("src/lib.rs:nope"), ("src/lib.rs:nope", None));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
#[test]
|
|
1431
|
+
fn text_attachment_context_is_extracted() {
|
|
1432
|
+
let dir = std::env::temp_dir().join(format!("bwn-attach-test-{}", std::process::id()));
|
|
1433
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
1434
|
+
std::fs::create_dir_all(dir.join("src")).unwrap();
|
|
1435
|
+
std::fs::write(dir.join("src/lib.rs"), "one\ntwo\nthree\n").unwrap();
|
|
1436
|
+
|
|
1437
|
+
let (text, images) = extract_attachments("please read @src/lib.rs:2-3", &dir);
|
|
1438
|
+
assert!(images.is_empty());
|
|
1439
|
+
assert!(text.contains("[file:"));
|
|
1440
|
+
assert!(text.contains("two\nthree"));
|
|
1441
|
+
assert!(!text.contains("\none\n"));
|
|
1442
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1188
1445
|
#[test]
|
|
1189
1446
|
fn build_provider_rejects_http_for_keyed_preset() {
|
|
1190
1447
|
let s = Settings { provider: "openai".into(), model: String::new(),
|
|
@@ -26,23 +26,54 @@ pub fn run() -> Option<Settings> {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
tui::line("");
|
|
29
|
-
|
|
29
|
+
let default_ollama_url = config::PRESETS
|
|
30
|
+
.iter()
|
|
31
|
+
.find(|p| p.id == "ollama")
|
|
32
|
+
.map(|p| p.base_url)
|
|
33
|
+
.unwrap_or("http://localhost:11434/v1");
|
|
34
|
+
let ollama_models = provider::ollama_models(default_ollama_url);
|
|
35
|
+
tui::line(&tui::accent(" Local model check"));
|
|
36
|
+
if ollama_models.is_empty() {
|
|
37
|
+
tui::line(&tui::dim(" Ollama is reachable only after it is installed, running, and has a model pulled."));
|
|
38
|
+
} else {
|
|
39
|
+
tui::line(&tui::green(&format!(
|
|
40
|
+
" found Ollama with {} model{}",
|
|
41
|
+
ollama_models.len(),
|
|
42
|
+
if ollama_models.len() == 1 { "" } else { "s" }
|
|
43
|
+
)));
|
|
44
|
+
}
|
|
45
|
+
tui::line("");
|
|
46
|
+
tui::line(" Pick a model provider:");
|
|
30
47
|
tui::line("");
|
|
31
48
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
if !ollama_models.is_empty() {
|
|
50
|
+
tui::line(&format!(" {} {:<26} {}", tui::bold("0"), "Ollama", tui::green("recommended local")));
|
|
51
|
+
}
|
|
52
|
+
tui::line(&tui::dim(" Local"));
|
|
53
|
+
for (i, p) in PRESETS.iter().enumerate().filter(|(_, p)| p.local) {
|
|
54
|
+
tui::line(&format!(" {} {:<26} {}", tui::bold(&(i + 1).to_string()), p.label, tui::green("local")));
|
|
55
|
+
}
|
|
56
|
+
tui::line(&tui::dim(" Remote"));
|
|
57
|
+
for (i, p) in PRESETS.iter().enumerate().filter(|(_, p)| !p.local) {
|
|
58
|
+
tui::line(&format!(" {} {:<26} {}", tui::bold(&(i + 1).to_string()), p.label, tui::blue("remote")));
|
|
35
59
|
}
|
|
36
60
|
tui::line("");
|
|
37
61
|
|
|
38
62
|
let pick = loop {
|
|
39
|
-
let ans = tui::ask(" provider number: ")?;
|
|
40
|
-
|
|
63
|
+
let ans = tui::ask(" provider number or name: ")?;
|
|
64
|
+
let ans = ans.trim();
|
|
65
|
+
if ans == "0" && !ollama_models.is_empty() {
|
|
66
|
+
break PRESETS.iter().find(|p| p.id == "ollama").unwrap();
|
|
67
|
+
}
|
|
68
|
+
if let Ok(n) = ans.parse::<usize>() {
|
|
41
69
|
if n >= 1 && n <= PRESETS.len() {
|
|
42
70
|
break &PRESETS[n - 1];
|
|
43
71
|
}
|
|
44
72
|
}
|
|
45
|
-
|
|
73
|
+
if let Some(p) = PRESETS.iter().find(|p| p.id.eq_ignore_ascii_case(ans) || p.label.eq_ignore_ascii_case(ans)) {
|
|
74
|
+
break p;
|
|
75
|
+
}
|
|
76
|
+
tui::line(&tui::red(" enter a number or provider name from the list"));
|
|
46
77
|
};
|
|
47
78
|
|
|
48
79
|
// Local endpoints can move (custom host/port); offer an override.
|
|
@@ -59,7 +90,9 @@ pub fn run() -> Option<Settings> {
|
|
|
59
90
|
// prompt offers real choices: Ollama's API for Ollama, GGUF files on disk
|
|
60
91
|
// for llama.cpp / LM Studio.
|
|
61
92
|
let detected: Vec<String> = if pick.local {
|
|
62
|
-
let found = if pick.id == "ollama" {
|
|
93
|
+
let found = if pick.id == "ollama" && base_url.as_deref().unwrap_or(pick.base_url) == default_ollama_url {
|
|
94
|
+
ollama_models.clone()
|
|
95
|
+
} else if pick.id == "ollama" {
|
|
63
96
|
provider::ollama_models(base_url.as_deref().unwrap_or(pick.base_url))
|
|
64
97
|
} else {
|
|
65
98
|
local::scan_gguf()
|
|
@@ -71,6 +104,15 @@ pub fn run() -> Option<Settings> {
|
|
|
71
104
|
tui::line(&tui::dim(&format!(" • llama.cpp / LM Studio: drop a .gguf into {}", local::models_dir().display())));
|
|
72
105
|
tui::line(&tui::dim(" (or your LM Studio models folder), then start the server"));
|
|
73
106
|
tui::line(&tui::dim(" you can also just type a model name below, then re-run init once it's available"));
|
|
107
|
+
if pick.id == "ollama" {
|
|
108
|
+
let pull = tui::ask(" pull qwen2.5:3b now? [y/N]: ").unwrap_or_default();
|
|
109
|
+
if matches!(pull.trim(), "y" | "Y" | "yes" | "YES") {
|
|
110
|
+
let out = crate::tools::run("run_command", &serde_json::json!({"command": "ollama pull qwen2.5:3b"}), std::path::Path::new("."));
|
|
111
|
+
for line in out.content.lines() {
|
|
112
|
+
tui::line(&tui::dim(&format!(" {line}")));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
74
116
|
} else {
|
|
75
117
|
tui::line(&tui::dim(" detected local models:"));
|
|
76
118
|
for (i, m) in found.iter().take(20).enumerate() {
|
package/harness/src/report.rs
CHANGED
|
@@ -63,7 +63,7 @@ pub fn tool_call(name: &str, preview: &str, input: &Value) {
|
|
|
63
63
|
}
|
|
64
64
|
// A role-colored header line (icon + what it's about to do), opencode-style.
|
|
65
65
|
let (icon, head) = match name {
|
|
66
|
-
"read_file" | "list_dir" => ("◇", tui::cyan(preview)),
|
|
66
|
+
"read_file" | "list_dir" | "find_files" | "grep_files" => ("◇", tui::cyan(preview)),
|
|
67
67
|
"write_file" | "edit_file" => ("◆", tui::yellow(preview)),
|
|
68
68
|
"run_command" => ("»", tui::blue(preview)),
|
|
69
69
|
"spawn_subagent" => ("⊞", tui::accent(preview)),
|
|
@@ -125,7 +125,7 @@ pub fn tool_result(name: &str, content: &str, is_error: bool) {
|
|
|
125
125
|
tui::line(&tui::dim(&format!(" {l}")));
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
"read_file" | "list_dir" => {
|
|
128
|
+
"read_file" | "list_dir" | "find_files" | "grep_files" => {
|
|
129
129
|
let n = content.lines().count();
|
|
130
130
|
tui::line(&tui::dim(&format!(" ↳ {n} line{}", if n == 1 { "" } else { "s" })));
|
|
131
131
|
}
|