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