1use std::io::Write as _;
9
10use anyhow::{Context as _, Result, anyhow, bail};
11
12use crate::db::{Watch, stage_content};
13use crate::provider::{StreamEvent, Usage};
14
15use super::{App, AppCommand, AppEvent};
16
17#[derive(Default, Clone, Copy)]
19pub struct TurnOpts {
20 pub stream: bool,
22 pub quiet: bool,
24}
25
26pub struct AskOutcome {
29 pub answer: String,
30 pub session_id: String,
31 pub session_title: String,
32 pub usage: Option<Usage>,
33}
34
35pub struct ResearchOutcome {
37 pub report: String,
38 pub session_id: String,
39 pub session_title: String,
40}
41
42fn stream(s: &str) {
45 let mut stdout = std::io::stdout();
46 if stdout.write_all(s.as_bytes()).is_err() {
47 std::process::exit(0);
48 }
49}
50
51fn note(quiet: bool, line: impl std::fmt::Display) {
53 if !quiet {
54 eprintln!("{line}");
55 }
56}
57
58impl App {
59 pub fn switch_space_cli(&mut self, name: &str) -> Result<()> {
62 let row = self
63 .db
64 .list_spaces()
65 .context("listing spaces")?
66 .into_iter()
67 .find(|s| s.name == name)
68 .ok_or_else(|| anyhow!("no space named {name:?} — `nexus spaces` lists them"))?;
69 if row.id != self.active_space.id {
70 self.set_active_space(row);
71 }
72 Ok(())
73 }
74
75 pub async fn run_turn(
80 &mut self,
81 prompt: String,
82 opts: TurnOpts,
83 ) -> Result<(String, Option<Usage>)> {
84 if !self.backends.any() {
85 bail!(
86 "no API key configured — set one with /login in the TUI, or export \
87 $OPENROUTER_API_KEY / $OPENAI_API_KEY / $OPENCODE_API_KEY"
88 );
89 }
90 if self.current_model.is_none() {
91 bail!("no model selected — pass --model, or pick one in the TUI first");
92 }
93 self.execute(AppCommand::Send { text: prompt })?;
96
97 let mut usage: Option<Usage> = None;
98 let mut last_status = String::new();
99 let mut streamed_any = false;
100 let mut thought = false;
101 loop {
102 match self.next_event().await {
103 AppEvent::Stream(Some((task_id, ev))) => {
104 match &ev {
105 StreamEvent::Token(t) => {
106 streamed_any = true;
107 if opts.stream {
108 stream(t);
109 }
110 }
111 StreamEvent::Reasoning(_) if !thought => {
112 thought = true;
113 note(opts.quiet, "…thinking…");
114 }
115 StreamEvent::Status(s) if *s != last_status => {
116 note(opts.quiet, s);
117 last_status.clone_from(s);
118 }
119 _ => {}
120 }
121 self.on_chat_event(task_id, ev)?;
122 if self.chat_tasks.is_empty() {
123 break;
124 }
125 if let Some(u) = self.chat_tasks.get(&task_id).and_then(|t| t.usage) {
126 usage = Some(u);
127 }
128 }
129 AppEvent::Stream(None) => break,
132 AppEvent::Title(t) => self.on_title_result(t),
133 _ => {} }
135 }
136 if streamed_any && opts.stream {
137 stream("\n");
138 }
139
140 let mut answer = None;
144 for m in self.messages.iter().rev() {
145 match m.role.as_str() {
146 "assistant" if !m.content.is_empty() => {
147 answer = Some(m.content.clone());
148 break;
149 }
150 "error" => bail!("{}", m.content),
151 _ => {}
152 }
153 }
154 let Some(answer) = answer else {
155 bail!("response finished without text");
156 };
157 if let Some(u) = usage {
158 let cost = u.cost.map(|c| format!(" · ${c:.4}")).unwrap_or_default();
159 note(
160 opts.quiet,
161 format!(
162 "tokens: {} → {} ({} cached){}",
163 u.prompt_tokens, u.completion_tokens, u.cache_read_tokens, cost
164 ),
165 );
166 }
167 Ok((answer, usage))
168 }
169
170 pub async fn ask_headless(&mut self, prompt: String, opts: TurnOpts) -> Result<AskOutcome> {
174 let (answer, usage) = self.run_turn(prompt, opts).await?;
175
176 if self.title_rx.is_some() {
177 let timeout = tokio::time::sleep(std::time::Duration::from_secs(15));
178 tokio::pin!(timeout);
179 loop {
180 tokio::select! {
181 () = &mut timeout => break,
182 ev = self.next_event() => {
183 if let AppEvent::Title(t) = ev {
184 self.on_title_result(t);
185 break;
186 }
187 }
188 }
189 }
190 }
191
192 let session = self.session.clone().context("session vanished after ask")?;
193 Ok(AskOutcome {
194 answer,
195 session_id: session.id,
196 session_title: session.title,
197 usage,
198 })
199 }
200
201 pub async fn chat_headless(&mut self, quiet: bool) -> Result<()> {
204 if !self.backends.any() {
205 bail!(
206 "no API key configured — set one with /login in the TUI, or export \
207 $OPENROUTER_API_KEY / $OPENAI_API_KEY / $OPENCODE_API_KEY"
208 );
209 }
210 if self.current_model.is_none() {
211 bail!("no model selected — pass --model, or pick one in the TUI first");
212 }
213 loop {
214 eprint!("> ");
215 let _ = std::io::stderr().flush();
216 let mut line = String::new();
217 if std::io::stdin().read_line(&mut line)? == 0 {
218 eprintln!();
219 break;
220 }
221 let text = line.trim().to_string();
222 if text.is_empty() {
223 continue;
224 }
225 if matches!(text.as_str(), "/quit" | "/exit" | "/q") {
226 break;
227 }
228 self.run_turn(
229 text,
230 TurnOpts {
231 stream: true,
232 quiet,
233 },
234 )
235 .await?;
236 }
237 Ok(())
238 }
239
240 pub async fn research_headless(
249 &mut self,
250 topic: String,
251 approve: bool,
252 opts: TurnOpts,
253 ) -> Result<ResearchOutcome> {
254 if !self.backends.any() {
255 bail!(
256 "no API key configured — set one with /login in the TUI, or export \
257 $OPENROUTER_API_KEY / $OPENAI_API_KEY / $OPENCODE_API_KEY"
258 );
259 }
260 if self.current_model.is_none() {
261 bail!("no model selected — pass --model, or pick one in the TUI first");
262 }
263 let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());
264 self.execute(AppCommand::RunResearch {
265 topic,
266 gated: !approve,
267 })?;
268 let mut refusal = String::new();
271 while let Some(ev) = self.pop_pending_event() {
272 if let AppEvent::Status(s) = ev {
273 refusal = s;
274 }
275 }
276 if self.research_rx.is_none() {
277 bail!("research didn't start: {refusal}");
278 }
279
280 let mut report: Option<String> = None;
281 let mut error: Option<String> = None;
282 loop {
283 match self.next_event().await {
284 AppEvent::Research(Some((session_id, space_id, space_name, update))) => {
285 if let super::research::ResearchUpdate::Stage { label, detail } = &update {
286 note(opts.quiet, stage_content(label, detail));
287 }
288 match &update {
289 super::research::ResearchUpdate::Done(Ok(text)) => {
290 report = Some(text.clone());
291 }
292 super::research::ResearchUpdate::Done(Err(e)) => error = Some(e.clone()),
293 _ => {}
294 }
295 self.on_research_done(Some((session_id, space_id, space_name, update)));
296 if !approve && let Some(gate) = self.survey_gate.as_ref() {
300 if !interactive {
301 bail!(
302 "research needs your input at a gate ({}), but stdin isn't a \
303 terminal — re-run with --approve to skip the gates",
304 match &gate.phase {
305 super::SurveyPhase::Clarify { .. } => "survey questions",
306 super::SurveyPhase::Approve { .. } => "plan approval",
307 }
308 );
309 }
310 stream(&format!("\n{}\n", gate.prompt_content));
311 eprint!("> ");
312 let _ = std::io::stderr().flush();
313 let mut reply = String::new();
314 if std::io::stdin().read_line(&mut reply)? == 0 {
315 bail!("research needs your input — stdin closed");
316 }
317 self.execute(AppCommand::AnswerGate { text: reply })?;
318 }
319 }
320 AppEvent::Research(None) => break,
321 _ => {} }
323 }
324
325 let session = self.session.clone().context("research session vanished")?;
326 let Some(report) = report else {
327 bail!(
328 "{}",
329 error.unwrap_or_else(|| "research finished without a report".to_string())
330 );
331 };
332 Ok(ResearchOutcome {
333 report,
334 session_id: session.id,
335 session_title: session.title,
336 })
337 }
338
339 pub async fn watch_run_headless(
344 &mut self,
345 watch_ref: Option<&str>,
346 all: bool,
347 quiet: bool,
348 ) -> Result<Vec<(String, ResearchOutcome)>> {
349 let watches = self.db.list_all_watches().context("listing watches")?;
350 let targets: Vec<Watch> = match (watch_ref, all) {
351 (Some(r), _) => {
352 let w = watches
353 .iter()
354 .find(|w| w.id.starts_with(r) || w.topic.contains(r))
355 .ok_or_else(|| {
356 anyhow!("no watch matching {r:?} — `nexus watch list` shows them")
357 })?;
358 vec![w.clone()]
359 }
360 (None, true) => watches,
361 (None, false) => crate::app::watches::due_watches(&watches, chrono::Utc::now()),
362 };
363 if targets.is_empty() {
364 bail!("no watches to run — `nexus watch list` shows them");
365 }
366 let mut ran = Vec::new();
367 for w in targets {
368 note(quiet, format!("watch: {} …", w.topic));
369 if !self.run_one_watch(&w) {
370 note(
371 quiet,
372 " could not start (no session to run from?) — skipped",
373 );
374 continue;
375 }
376 let mut report: Option<String> = None;
380 let mut error: Option<String> = None;
381 loop {
382 match self.next_event().await {
383 AppEvent::Research(Some((session_id, space_id, space_name, update))) => {
384 if let super::research::ResearchUpdate::Stage { label, detail } = &update {
385 note(quiet, stage_content(label, detail));
386 }
387 match &update {
388 super::research::ResearchUpdate::Done(Ok(text)) => {
389 report = Some(text.clone());
390 }
391 super::research::ResearchUpdate::Done(Err(e)) => {
392 error = Some(e.clone());
393 }
394 _ => {}
395 }
396 self.on_research_done(Some((session_id, space_id, space_name, update)));
397 }
398 AppEvent::Research(None) => break,
399 _ => {}
400 }
401 }
402 let Some(report) = report else {
403 bail!(
404 "{}",
405 error.unwrap_or_else(|| "watch finished without a report".to_string())
406 );
407 };
408 let session = self
411 .db
412 .list_all_watches()
413 .ok()
414 .and_then(|ws| ws.into_iter().find(|x| x.id == w.id))
415 .and_then(|x| self.db.get_session(&x.session_id).ok().flatten())
416 .context("watch session vanished")?;
417 ran.push((
418 w.topic,
419 ResearchOutcome {
420 report,
421 session_id: session.id,
422 session_title: session.title,
423 },
424 ));
425 }
426 Ok(ran)
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::db::Db;
434 use crate::space::Space;
435
436 fn test_app() -> App {
437 let root = std::env::temp_dir().join(format!("nexus-cli-{}", uuid::Uuid::new_v4()));
438 App::new(Db::open_in_memory().unwrap(), Some("k"), Space { root })
439 }
440
441 #[test]
442 fn switch_space_by_name_switches_and_is_idempotent() {
443 let mut app = test_app();
444 let row = app.db.create_space("research").unwrap();
445 let active_before = app.active_space.id.clone();
446 assert_ne!(row.id, active_before);
447
448 app.switch_space_cli("research").unwrap();
449 assert_eq!(app.active_space.id, row.id);
450 app.switch_space_cli("research").unwrap();
452 assert_eq!(app.active_space.id, row.id);
453 }
454
455 #[test]
456 fn switch_space_unknown_name_bails() {
457 let mut app = test_app();
458 assert!(app.switch_space_cli("nope").is_err());
459 }
460}