Skip to main content

nexus_core/app/
headless.rs

1//! Headless one-shot runs (`nexus ask`, `nexus chat`, `nexus research`,
2//! `nexus watch run`): the same streaming pipelines the TUI event loop
3//! drives, minus the terminal. Answers stream to stdout as they arrive,
4//! tool status goes to stderr, and every conversation is persisted as a
5//! normal session in the active space — reopen it in the TUI and it's just
6//! another chat.
7
8use 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/// Per-turn behavior switches for the headless drivers.
18#[derive(Default, Clone, Copy)]
19pub struct TurnOpts {
20    /// Stream answer tokens to stdout as they arrive (default true).
21    pub stream: bool,
22    /// Suppress stderr chatter (tool status, thinking, token summary).
23    pub quiet: bool,
24}
25
26/// What a finished `nexus ask` leaves behind — the answer already went to
27/// stdout unless `--json` asked for it structured.
28pub struct AskOutcome {
29    pub answer: String,
30    pub session_id: String,
31    pub session_title: String,
32    pub usage: Option<Usage>,
33}
34
35/// A finished headless research run (`nexus research` / a watch's run).
36pub struct ResearchOutcome {
37    pub report: String,
38    pub session_id: String,
39    pub session_title: String,
40}
41
42/// Stream one answer chunk to stdout; exit quietly if the reader closed the
43/// pipe (`nexus ask | head -c 50`) — coreutils behavior, not a panic.
44fn 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
51/// Print a line to stderr unless quiet mode is on.
52fn note(quiet: bool, line: impl std::fmt::Display) {
53    if !quiet {
54        eprintln!("{line}");
55    }
56}
57
58impl App {
59    /// Switch the active space by name (`--space`). Bails on an unknown
60    /// name; no-op when it's already the active space.
61    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    /// Drive one non-interactive turn: send `prompt`, drain stream events
76    /// until the response finishes, and (unless `opts.stream`) collect the
77    /// answer. Tool status lines and the token summary go to stderr unless
78    /// `opts.quiet`. Returns the final answer text and merged usage.
79    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        // Drive through the seam, like the host API will: boot → command →
94        // event drain. The guard checks above already validated the preconditions.
95        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                // The event channel closed without a Done — nothing more is
130                // coming; fall through to the summary below.
131                AppEvent::Stream(None) => break,
132                AppEvent::Title(t) => self.on_title_result(t),
133                _ => {} // models/memory/compact/etc: nothing pending for a fresh turn
134            }
135        }
136        if streamed_any && opts.stream {
137            stream("\n");
138        }
139
140        // The reply is already persisted (the turn's session is active, so
141        // finish_chat_task pushed it to self.messages too). Prefer the
142        // assistant row; surface an error row if the stream failed.
143        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    /// `nexus ask`: one turn, then a short wait for the model-generated
171    /// session title so `nexus sessions` shows a real name instead of the
172    /// prompt prefix. A slow title must never hold the ask hostage — capped.
173    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    /// `nexus chat`: a bare REPL — prompt on stderr, one turn per line,
202    /// all turns in the one session the first turn creates.
203    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    /// `nexus research <topic>`: run the full deep-research pipeline headless.
241    ///
242    /// Gate policy: with `approve` the pipeline runs ungated (survey and
243    /// plan-approval are skipped entirely, like `/research!`). Without it,
244    /// a gated run parks at each SurveyReady/PlanReady: when stdin is a
245    /// terminal the prompt is printed and a reply is read from the line
246    /// (empty reply skips the survey round; `approve` approves the plan);
247    /// otherwise the run bails rather than hang.
248    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        // The execute above may have refused with a status line (already
269        // running, no model, …) — surface the last one if no job started.
270        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                    // A gate armed (or re-armed) by the handler above: answer
297                    // it before draining anything else — the pipeline is
298                    // parked and will not move until it gets a reply.
299                    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                _ => {} // unrelated background events; keep draining
322            }
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    /// `nexus watch run`: run one watch (by id or topic prefix), all watches
340    /// (`--all`), or the due ones (default). Each run drives its research
341    /// job to completion before the next starts. Returns the reports, keyed
342    /// by watch topic, for the caller to print.
343    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            // Drain this watch's job to completion; its session is active
377            // during the run, so `research_headless`-style gate handling is
378            // not needed (watches are always ungated).
379            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            // The watch row was repointed at its fresh session during
409            // `run_one_watch` — re-read it for the closing line.
410            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        // Switching again (same space) is a no-op, not an error.
451        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}