Skip to main content

rpi_cli/
modes.rs

1//! Output modes. Mirrors the v1-relevant slice of the TS
2//! `packages/coding-agent/src/modes/{print-mode,json-event,rpc-mode}.ts` — the
3//! three run shapes a harness-backed CLI needs:
4//!
5//! - [`print`] — single-shot: send the prompt(s), print the final assistant
6//!   text (or the error) to stdout, exit. Mirrors TS `runPrintMode` (text mode).
7//! - [`json`] — single-shot streaming: emit each harness event as a JSON line
8//!   on stdout, then the final outcome. Mirrors TS `runPrintMode`
9//!   (`mode === "json"`) + [`json_event::toJsonEvent`].
10//! - [`interactive`] — a minimal line-oriented REPL: read prompts from stdin,
11//!   run each, print the assistant text, loop until EOF / `/exit`. v1 does NOT
12//!   port the TS `InteractiveMode` TUI (`modes/interactive/*` — a full terminal
13//!   UI with Ink/React components); this is a deliberately minimal replacement,
14//!   documented in `docs/m6-cli-open-questions.md`.
15//!
16//! All three drive the same `AgentHarness` via `AgentLane::prompt_text`.
17
18use std::io::{BufRead, IsTerminal, Write};
19use std::sync::{Arc, Mutex};
20
21use rpi_ai::types::{AssistantMessage, Content, StopReason};
22use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
23use rpi_harness::events::{HarnessEvent, RunEndOutcome};
24
25use crate::args::Args;
26
27/// Extract the concatenated text content from an assistant message. Mirrors the
28/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
29pub fn assistant_text(msg: &AssistantMessage) -> String {
30    msg.content
31        .iter()
32        .filter_map(|c| match c {
33            Content::Text(t) => Some(t.text.clone()),
34            _ => None,
35        })
36        .collect()
37}
38
39/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
40/// ⇒ exit 1; everything else ⇒ 0.
41pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
42    match outcome {
43        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
44        _ => 0,
45    }
46}
47
48/// `print` mode: send the initial message (prompt text + inline `@file`
49/// expansions), then any follow-up messages, print the final assistant text,
50/// return the exit code. Mirrors TS `runPrintMode` (text).
51pub async fn print(
52    harness: &AgentHarness,
53    _args: &Args,
54    initial: Option<String>,
55    extra_messages: &[String],
56) -> i32 {
57    let lane: Arc<dyn AgentLane> = harness.lane("main");
58
59    let mut last_exit = 0;
60    let mut last_msg: Option<AssistantMessage> = None;
61
62    // The initial prompt (and its `@file` attachments) go in one user message;
63    // extra positionals are separate prompts (mirrors the TS loop).
64    let mut prompts: Vec<String> = Vec::new();
65    if let Some(init) = initial {
66        prompts.push(init);
67    }
68    for m in extra_messages {
69        prompts.push(m.clone());
70    }
71
72    if prompts.is_empty() {
73        // Nothing to do — print mode with no prompt is a no-op success.
74        return 0;
75    }
76
77    for prompt in prompts {
78        match lane.prompt_text(&prompt, Vec::new()).await {
79            Ok(result) => {
80                last_exit = outcome_exit_code(&result.outcome);
81                match &result.outcome {
82                    HarnessRunOutcome::Completed { final_message, .. }
83                    | HarnessRunOutcome::Aborted { final_message, .. } => {
84                        last_msg = Some(final_message.clone());
85                    }
86                    HarnessRunOutcome::Failed {
87                        error,
88                        final_message,
89                        ..
90                    } => {
91                        if let Some(m) = final_message {
92                            if m.stop_reason == StopReason::Error {
93                                if let Some(em) = &m.error_message {
94                                    eprintln!("{em}");
95                                }
96                            }
97                        }
98                        eprintln!("run failed: {error:?}");
99                    }
100                    HarnessRunOutcome::Suspended { .. } => {
101                        eprintln!("run suspended (deferred) — resume is not supported in v1");
102                        last_exit = 1;
103                    }
104                }
105            }
106            Err(e) => {
107                eprintln!("prompt rejected: {e}");
108                return 1;
109            }
110        }
111    }
112
113    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
114    if let Some(m) = &last_msg {
115        match m.stop_reason {
116            StopReason::Error => {
117                if let Some(em) = &m.error_message {
118                    eprintln!("{em}");
119                }
120                last_exit = 1;
121            }
122            StopReason::Aborted => {
123                eprintln!("request aborted");
124                last_exit = 1;
125            }
126            _ => {
127                let text = assistant_text(m);
128                let mut out = std::io::stdout();
129                let _ = out.write_all(text.as_bytes());
130                if !text.ends_with('\n') {
131                    let _ = out.write_all(b"\n");
132                }
133                let _ = out.flush();
134            }
135        }
136    }
137
138    last_exit
139}
140
141/// `json` mode: emit each harness event as a JSON line on stdout, run the
142/// prompts, then emit a terminal `result` line carrying the outcome + final
143/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
144pub async fn json(
145    harness: &AgentHarness,
146    _args: &Args,
147    initial: Option<String>,
148    extra_messages: &[String],
149) -> i32 {
150    let lane: Arc<dyn AgentLane> = harness.lane("main");
151    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
152    let collected_for_watch = collected.clone();
153
154    // A watch captures every event (RunStart fires inline during prompt_text,
155    // before a post-call listener could attach — same reason as the M5g test).
156    let mut watch = harness.events().watch(|| ());
157    watch.start(Arc::new(move |event: &HarnessEvent| {
158        // Emit each event live as JSON, and also buffer for the final summary.
159        emit_json_event(event);
160        collected_for_watch.lock().unwrap().push(event.clone());
161    }));
162    // Keep the watch alive for the whole run. Leaking is acceptable for a
163    // single-shot CLI process (the bus outlives this scope anyway).
164    std::mem::forget(watch);
165
166    let mut prompts: Vec<String> = Vec::new();
167    if let Some(init) = initial {
168        prompts.push(init);
169    }
170    for m in extra_messages {
171        prompts.push(m.clone());
172    }
173
174    let mut last_exit = 0;
175    let mut final_outcome: Option<HarnessRunOutcome> = None;
176
177    for prompt in prompts {
178        match lane.prompt_text(&prompt, Vec::new()).await {
179            Ok(result) => {
180                last_exit = outcome_exit_code(&result.outcome);
181                final_outcome = Some(result.outcome);
182            }
183            Err(e) => {
184                // Emit a structured error line + exit.
185                let line = serde_json::json!({
186                    "type": "error",
187                    "error": e.to_string(),
188                });
189                println!("{line}");
190                return 1;
191            }
192        }
193    }
194
195    // Terminal result summary.
196    let (outcome_str, final_text) = match final_outcome {
197        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
198            ("completed", Some(assistant_text(&final_message)))
199        }
200        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
201            ("aborted", Some(assistant_text(&final_message)))
202        }
203        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
204            let t = final_message.as_ref().map(assistant_text);
205            ("failed", t)
206        }
207        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
208        None => ("idle", None),
209    };
210    let result_line = serde_json::json!({
211        "type": "result",
212        "outcome": outcome_str,
213        "finalText": final_text,
214    });
215    println!("{result_line}");
216    last_exit
217}
218
219/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
220/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
221/// payload's key fields).
222fn emit_json_event(event: &HarnessEvent) {
223    let line = match event {
224        HarnessEvent::RunStart(e) => serde_json::json!({
225            "type": "run_start",
226            "lane": e.lane,
227            "runId": e.run_id,
228        }),
229        HarnessEvent::RunEnd(e) => serde_json::json!({
230            "type": "run_end",
231            "lane": e.lane,
232            "runId": e.run_id,
233            "outcome": run_end_outcome_str(e.outcome),
234            "leafId": e.leaf_id,
235        }),
236    };
237    println!("{line}");
238}
239
240fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
241    match o {
242        RunEndOutcome::Completed => "completed",
243        RunEndOutcome::Aborted => "aborted",
244        RunEndOutcome::Failed => "failed",
245    }
246}
247
248/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
249///
250/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
251/// render streaming responses). The REPL fallback ignores it.
252///
253/// `model_catalog` is the resolved provider's full model list, passed through
254/// so the TUI's `/model` selector can display available models (read-only —
255/// v1 does not switch models mid-session; see `docs/m6-cli-open-questions.md`).
256pub async fn interactive(
257    harness: &AgentHarness,
258    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
259    args: &Args,
260    model_catalog: Vec<rpi_ai::Model>,
261    initial: Option<String>,
262    extra_messages: &[String],
263    theme: Option<&str>,
264    reload_context: &crate::session::ReloadContext,
265) -> i32 {
266    // Check if TUI is supported
267    let force_tui = std::env::var("RPI_FORCE_TUI").map(|v| v == "1").unwrap_or(false);
268    if force_tui || crate::interactive_tui::is_tui_supported() {
269        // Use TUI-based interactive mode
270        crate::interactive_tui::interactive_tui(
271            harness,
272            event_rx,
273            args,
274            model_catalog,
275            initial,
276            extra_messages,
277            theme,
278            reload_context,
279        )
280        .await
281    } else {
282        // Fall back to simple REPL
283        interactive_repl(harness, args, initial, extra_messages).await
284    }
285}
286
287/// Simple REPL-based interactive mode (fallback for non-TTY environments).
288pub async fn interactive_repl(
289    harness: &AgentHarness,
290    #[allow(unused_variables)] args: &Args,
291    initial: Option<String>,
292    extra_messages: &[String],
293) -> i32 {
294    // Debug: confirm we entered REPL mode
295    let lane: Arc<dyn AgentLane> = harness.lane("main");
296    let stdin = std::io::stdin();
297    let is_tty = stdin.is_terminal();
298
299    if is_tty {
300        println!("rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n");
301    }
302
303    // Run the initial prompt + extra messages first (same as print mode).
304    let mut prompts: Vec<String> = Vec::new();
305    if let Some(init) = initial {
306        prompts.push(init);
307    }
308    for m in extra_messages {
309        prompts.push(m.clone());
310    }
311    for prompt in prompts {
312        if let Err(code) = run_one(&lane, &prompt).await {
313            return code;
314        }
315    }
316
317    // Then read lines from stdin until EOF / `/exit`.
318    let mut line = String::new();
319    loop {
320        if is_tty {
321            print!("> ");
322            let _ = std::io::stdout().flush();
323        }
324        line.clear();
325        match stdin.lock().read_line(&mut line) {
326            Ok(0) => break, // EOF
327            Ok(_) => {}
328            Err(_) => break,
329        }
330        let trimmed = line.trim();
331        if trimmed.is_empty() {
332            continue;
333        }
334        if trimmed == "/exit" || trimmed == "/quit" {
335            break;
336        }
337        if trimmed == "/abort" {
338            let _ = lane.abort().await;
339            eprintln!("(aborted)");
340            continue;
341        }
342        if let Err(code) = run_one(&lane, trimmed).await {
343            return code;
344        }
345    }
346    0
347}
348
349/// Run a single prompt in interactive mode, printing the assistant reply (or
350/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
351/// hard rejection.
352async fn run_one(lane: &Arc<dyn AgentLane>, prompt: &str) -> Result<(), i32> {
353    match lane.prompt_text(prompt, Vec::new()).await {
354        Ok(result) => {
355            match &result.outcome {
356                HarnessRunOutcome::Completed { final_message, .. }
357                | HarnessRunOutcome::Aborted { final_message, .. } => {
358                    let text = assistant_text(final_message);
359                    if !text.is_empty() {
360                        println!("{text}");
361                    }
362                }
363                HarnessRunOutcome::Failed { error, final_message, .. } => {
364                    if let Some(m) = final_message {
365                        if let Some(em) = &m.error_message {
366                            eprintln!("error: {em}");
367                        }
368                    }
369                    eprintln!("run failed: {error:?}");
370                }
371                HarnessRunOutcome::Suspended { .. } => {
372                    eprintln!("run suspended (deferred) — resume not supported in v1");
373                }
374            }
375            Ok(())
376        }
377        Err(e) => {
378            eprintln!("prompt rejected: {e}");
379            Err(1)
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use rpi_ai::types::{AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage};
388    use rpi_harness::session::types::OperationError;
389
390    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
391        AssistantMessage {
392            role: rpi_ai::types::AssistantRole,
393            content: vec![Content::Text(TextContent {
394                kind: TextContentType,
395                text: text.into(),
396                text_signature: None,
397            })],
398            api: rpi_ai::Api::AnthropicMessages,
399            provider: "anthropic".into(),
400            model: "claude-sonnet-5".into(),
401            response_model: None,
402            response_id: None,
403            usage: Usage::zero(),
404            stop_reason: stop,
405            deferred: None,
406            error_message: None,
407            raw_stop_reason: None,
408            end_turn: None,
409            timestamp: 0,
410        }
411    }
412
413    #[test]
414    fn assistant_text_concatenates_text_blocks() {
415        let m = assistant("hello", StopReason::Stop);
416        assert_eq!(assistant_text(&m), "hello");
417    }
418
419    #[test]
420    fn outcome_exit_code_maps_failed_aborted_to_1() {
421        let failed = HarnessRunOutcome::Failed {
422            leaf_id: "l".into(),
423            error: OperationError { code: "boom".into(), message: "boom".into() },
424            final_entry_id: None,
425            final_message: None,
426        };
427        assert_eq!(outcome_exit_code(&failed), 1);
428        let completed = HarnessRunOutcome::Completed {
429            leaf_id: "l".into(),
430            final_entry_id: "e".into(),
431            final_message: assistant("ok", StopReason::Stop),
432        };
433        assert_eq!(outcome_exit_code(&completed), 0);
434    }
435
436    #[test]
437    fn run_end_outcome_str_roundtrip() {
438        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
439        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
440        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
441    }
442}