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.
@@ -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
- Ok((build_provider(&settings)?, agent::permission(&settings.permission)))
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
- tui::line("");
193
- let prompt = format!("{} {} ", tui::mode_badge(mode_label(&mode)), tui::accent(""));
194
- let task = match tui::ask_task(&prompt) {
195
- None => return Ok(()),
196
- Some(tui::InputEvent::CycleMode) => {
197
- mode = mode.next();
198
- last_suggested_mode = None;
199
- tui::show_mode_change(mode_label(&mode));
200
- continue;
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 @image.png tokens push a UserImages message so build_inner skips
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) = extract_images(t, cwd);
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` image tokens from a task string. Returns the cleaned text and
927
- // a list of `(media_type, base64_data)` pairs for any recognized image paths.
928
- // Unreadable or non-image @-tokens are left in the text unchanged.
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 list and manage background workflows\n\
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
- tui::line(" Let's get you set up. Pick a model provider:");
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
- for (i, p) in PRESETS.iter().enumerate() {
33
- let tag = if p.local { tui::green("local") } else { tui::blue("remote") };
34
- tui::line(&format!(" {} {:<26} {}", tui::bold(&(i + 1).to_string()), p.label, tag));
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
- if let Ok(n) = ans.trim().parse::<usize>() {
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
- tui::line(&tui::red(" enter a number from the list"));
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() {
@@ -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
  }