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")
268        .map(|v| v == "1")
269        .unwrap_or(false);
270    if force_tui || crate::interactive_tui::is_tui_supported() {
271        // Use TUI-based interactive mode
272        crate::interactive_tui::interactive_tui(
273            harness,
274            event_rx,
275            args,
276            model_catalog,
277            initial,
278            extra_messages,
279            theme,
280            reload_context,
281        )
282        .await
283    } else {
284        // Fall back to simple REPL
285        interactive_repl(harness, args, initial, extra_messages).await
286    }
287}
288
289/// Simple REPL-based interactive mode (fallback for non-TTY environments).
290pub async fn interactive_repl(
291    harness: &AgentHarness,
292    #[allow(unused_variables)] args: &Args,
293    initial: Option<String>,
294    extra_messages: &[String],
295) -> i32 {
296    // Debug: confirm we entered REPL mode
297    let lane: Arc<dyn AgentLane> = harness.lane("main");
298    let stdin = std::io::stdin();
299    let is_tty = stdin.is_terminal();
300
301    if is_tty {
302        println!(
303            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
304        );
305    }
306
307    // Run the initial prompt + extra messages first (same as print mode).
308    let mut prompts: Vec<String> = Vec::new();
309    if let Some(init) = initial {
310        prompts.push(init);
311    }
312    for m in extra_messages {
313        prompts.push(m.clone());
314    }
315    for prompt in prompts {
316        if let Err(code) = run_one(&lane, &prompt).await {
317            return code;
318        }
319    }
320
321    // Then read lines from stdin until EOF / `/exit`.
322    let mut line = String::new();
323    loop {
324        if is_tty {
325            print!("> ");
326            let _ = std::io::stdout().flush();
327        }
328        line.clear();
329        match stdin.lock().read_line(&mut line) {
330            Ok(0) => break, // EOF
331            Ok(_) => {}
332            Err(_) => break,
333        }
334        let trimmed = line.trim();
335        if trimmed.is_empty() {
336            continue;
337        }
338        if trimmed == "/exit" || trimmed == "/quit" {
339            break;
340        }
341        if trimmed == "/abort" {
342            let _ = lane.abort().await;
343            eprintln!("(aborted)");
344            continue;
345        }
346        if let Err(code) = run_one(&lane, trimmed).await {
347            return code;
348        }
349    }
350    0
351}
352
353/// Run a single prompt in interactive mode, printing the assistant reply (or
354/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
355/// hard rejection.
356async fn run_one(lane: &Arc<dyn AgentLane>, prompt: &str) -> Result<(), i32> {
357    match lane.prompt_text(prompt, Vec::new()).await {
358        Ok(result) => {
359            match &result.outcome {
360                HarnessRunOutcome::Completed { final_message, .. }
361                | HarnessRunOutcome::Aborted { final_message, .. } => {
362                    let text = assistant_text(final_message);
363                    if !text.is_empty() {
364                        println!("{text}");
365                    }
366                }
367                HarnessRunOutcome::Failed {
368                    error,
369                    final_message,
370                    ..
371                } => {
372                    if let Some(m) = final_message {
373                        if let Some(em) = &m.error_message {
374                            eprintln!("error: {em}");
375                        }
376                    }
377                    eprintln!("run failed: {error:?}");
378                }
379                HarnessRunOutcome::Suspended { .. } => {
380                    eprintln!("run suspended (deferred) — resume not supported in v1");
381                }
382            }
383            Ok(())
384        }
385        Err(e) => {
386            eprintln!("prompt rejected: {e}");
387            Err(1)
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use rpi_ai::types::{
396        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
397    };
398    use rpi_harness::session::types::OperationError;
399
400    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
401        AssistantMessage {
402            role: rpi_ai::types::AssistantRole,
403            content: vec![Content::Text(TextContent {
404                kind: TextContentType,
405                text: text.into(),
406                text_signature: None,
407            })],
408            api: rpi_ai::Api::AnthropicMessages,
409            provider: "anthropic".into(),
410            model: "claude-sonnet-5".into(),
411            response_model: None,
412            response_id: None,
413            usage: Usage::zero(),
414            stop_reason: stop,
415            deferred: None,
416            error_message: None,
417            raw_stop_reason: None,
418            end_turn: None,
419            timestamp: 0,
420        }
421    }
422
423    #[test]
424    fn assistant_text_concatenates_text_blocks() {
425        let m = assistant("hello", StopReason::Stop);
426        assert_eq!(assistant_text(&m), "hello");
427    }
428
429    #[test]
430    fn outcome_exit_code_maps_failed_aborted_to_1() {
431        let failed = HarnessRunOutcome::Failed {
432            leaf_id: "l".into(),
433            error: OperationError {
434                code: "boom".into(),
435                message: "boom".into(),
436            },
437            final_entry_id: None,
438            final_message: None,
439        };
440        assert_eq!(outcome_exit_code(&failed), 1);
441        let completed = HarnessRunOutcome::Completed {
442            leaf_id: "l".into(),
443            final_entry_id: "e".into(),
444            final_message: assistant("ok", StopReason::Stop),
445        };
446        assert_eq!(outcome_exit_code(&completed), 0);
447    }
448
449    #[test]
450    fn run_end_outcome_str_roundtrip() {
451        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
452        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
453        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
454    }
455}