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_agent::events::AgentEvent;
22use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason};
23use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
24use rpi_harness::events::{HarnessEvent, RunEndOutcome};
25
26use crate::args::Args;
27
28/// Extract the concatenated text content from an assistant message. Mirrors the
29/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
30pub fn assistant_text(msg: &AssistantMessage) -> String {
31    msg.content
32        .iter()
33        .filter_map(|c| match c {
34            Content::Text(t) => Some(t.text.clone()),
35            _ => None,
36        })
37        .collect()
38}
39
40/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
41/// ⇒ exit 1; everything else ⇒ 0.
42pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
43    match outcome {
44        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
45        _ => 0,
46    }
47}
48
49/// `print` mode: send the initial message (prompt text + inline `@file`
50/// expansions), then any follow-up messages, print the final assistant text,
51/// return the exit code. Mirrors TS `runPrintMode` (text).
52pub async fn print(
53    harness: &AgentHarness,
54    _args: &Args,
55    initial: Option<String>,
56    extra_messages: &[String],
57    initial_images: Vec<ImageContent>,
58) -> i32 {
59    let lane: Arc<dyn AgentLane> = harness.lane("main");
60
61    let mut last_exit = 0;
62    let mut last_msg: Option<AssistantMessage> = None;
63
64    // The initial prompt (and its `@file` attachments) go in one user message;
65    // extra positionals are separate prompts (mirrors the TS loop).
66    let mut prompts: Vec<String> = Vec::new();
67    if let Some(init) = initial {
68        prompts.push(init);
69    }
70    for m in extra_messages {
71        prompts.push(m.clone());
72    }
73
74    if prompts.is_empty() {
75        // Nothing to do — print mode with no prompt is a no-op success.
76        return 0;
77    }
78
79    let mut images = initial_images;
80    for prompt in prompts {
81        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
82            Ok(result) => {
83                last_exit = outcome_exit_code(&result.outcome);
84                match &result.outcome {
85                    HarnessRunOutcome::Completed { final_message, .. }
86                    | HarnessRunOutcome::Aborted { final_message, .. } => {
87                        last_msg = Some(final_message.clone());
88                    }
89                    HarnessRunOutcome::Failed {
90                        error,
91                        final_message,
92                        ..
93                    } => {
94                        if let Some(m) = final_message {
95                            if m.stop_reason == StopReason::Error {
96                                if let Some(em) = &m.error_message {
97                                    eprintln!("{em}");
98                                }
99                            }
100                        }
101                        eprintln!("run failed: {error:?}");
102                    }
103                    HarnessRunOutcome::Suspended { .. } => {
104                        eprintln!("run suspended (deferred) — resume is not supported in v1");
105                        last_exit = 1;
106                    }
107                }
108            }
109            Err(e) => {
110                eprintln!("prompt rejected: {e}");
111                return 1;
112            }
113        }
114    }
115
116    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
117    if let Some(m) = &last_msg {
118        match m.stop_reason {
119            StopReason::Error => {
120                if let Some(em) = &m.error_message {
121                    eprintln!("{em}");
122                }
123                last_exit = 1;
124            }
125            StopReason::Aborted => {
126                eprintln!("request aborted");
127                last_exit = 1;
128            }
129            _ => {
130                let text = assistant_text(m);
131                let mut out = std::io::stdout();
132                let _ = out.write_all(text.as_bytes());
133                if !text.ends_with('\n') {
134                    let _ = out.write_all(b"\n");
135                }
136                let _ = out.flush();
137            }
138        }
139    }
140
141    last_exit
142}
143
144/// `json` mode: emit each harness event as a JSON line on stdout, run the
145/// prompts, then emit a terminal `result` line carrying the outcome + final
146/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
147pub async fn json(
148    harness: &AgentHarness,
149    _args: &Args,
150    initial: Option<String>,
151    extra_messages: &[String],
152    initial_images: Vec<ImageContent>,
153    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
154) -> i32 {
155    let lane: Arc<dyn AgentLane> = harness.lane("main");
156    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
157    let collected_for_watch = collected.clone();
158
159    // A watch captures every event (RunStart fires inline during prompt_text,
160    // before a post-call listener could attach — same reason as the M5g test).
161    let mut watch = harness.events().watch(|| ());
162    watch.start(Arc::new(move |event: &HarnessEvent| {
163        // Emit each event live as JSON, and also buffer for the final summary.
164        emit_json_event(event);
165        collected_for_watch.lock().unwrap().push(event.clone());
166    }));
167    // Keep the watch alive for the whole run. Leaking is acceptable for a
168    // single-shot CLI process (the bus outlives this scope anyway).
169    std::mem::forget(watch);
170
171    // The harness bus carries run lifecycle events; the agent receiver carries
172    // the native fine-grained stream (turns, message deltas, and tools).
173    let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
174    let agent_event_task = agent_events.take().map(|mut rx| {
175        tokio::spawn(async move {
176            let done_tx = agent_done_tx;
177            loop {
178                match rx.recv().await {
179                    Ok(event) => {
180                        let terminal = event.is_terminal();
181                        emit_agent_event(&event);
182                        if terminal {
183                            let _ = done_tx.send(());
184                        }
185                    }
186                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
187                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
188                }
189            }
190        })
191    });
192
193    let mut prompts: Vec<String> = Vec::new();
194    if let Some(init) = initial {
195        prompts.push(init);
196    }
197    for m in extra_messages {
198        prompts.push(m.clone());
199    }
200
201    let mut last_exit = 0;
202    let mut final_outcome: Option<HarnessRunOutcome> = None;
203
204    let mut images = initial_images;
205    for prompt in prompts {
206        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
207            Ok(result) => {
208                // The harness resolves after its run outcome, while the
209                // broadcast listener may still be scheduling the terminal
210                // AgentEnd line. Wait briefly so JSON consumers see the full
211                // lifecycle before the final result summary. The timeout is
212                // deliberately bounded for custom/older harness emitters.
213                let _ = tokio::time::timeout(
214                    std::time::Duration::from_millis(250),
215                    agent_done_rx.recv(),
216                )
217                .await;
218                last_exit = outcome_exit_code(&result.outcome);
219                final_outcome = Some(result.outcome);
220            }
221            Err(e) => {
222                // Emit a structured error line + exit.
223                let line = serde_json::json!({
224                    "type": "error",
225                    "error": e.to_string(),
226                });
227                println!("{line}");
228                if let Some(task) = agent_event_task {
229                    task.abort();
230                }
231                return 1;
232            }
233        }
234    }
235
236    // Terminal result summary.
237    let (outcome_str, final_text) = match final_outcome {
238        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
239            ("completed", Some(assistant_text(&final_message)))
240        }
241        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
242            ("aborted", Some(assistant_text(&final_message)))
243        }
244        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
245            let t = final_message.as_ref().map(assistant_text);
246            ("failed", t)
247        }
248        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
249        None => ("idle", None),
250    };
251    let result_line = serde_json::json!({
252        "type": "result",
253        "outcome": outcome_str,
254        "finalText": final_text,
255    });
256    println!("{result_line}");
257    if let Some(task) = agent_event_task {
258        task.abort();
259    }
260    last_exit
261}
262
263fn emit_agent_event(event: &AgentEvent) {
264    println!("{}", agent_event_json(event));
265}
266
267/// Stable JSON projection for the fine-grained agent lifecycle stream.
268/// Complex payloads use their serde representation instead of being dropped,
269/// while convenience fields keep the stream easy to consume incrementally.
270fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
271    use rpi_ai::types::AssistantMessageEvent;
272    match event {
273        AgentEvent::AgentStart => serde_json::json!({"type":"agent_start"}),
274        AgentEvent::AgentEnd { messages } => serde_json::json!({
275            "type":"agent_end", "messageCount": messages.len(),
276            "messages": serde_json::to_value(messages).unwrap_or(serde_json::Value::Null)
277        }),
278        AgentEvent::TurnStart => serde_json::json!({"type":"turn_start"}),
279        AgentEvent::TurnEnd {
280            message,
281            tool_results,
282        } => serde_json::json!({
283            "type":"turn_end", "message": serde_json::to_value(message).ok(),
284            "toolResultCount": tool_results.len(),
285            "toolResults": serde_json::to_value(tool_results).unwrap_or(serde_json::Value::Null)
286        }),
287        AgentEvent::MessageStart { message } => serde_json::json!({
288            "type":"message_start", "message": serde_json::to_value(message).ok()
289        }),
290        AgentEvent::MessageEnd { message } => serde_json::json!({
291            "type":"message_end", "message": serde_json::to_value(message).ok()
292        }),
293        AgentEvent::MessageUpdate {
294            message,
295            assistant_message_event,
296        } => {
297            let mut value = serde_json::json!({
298                "type":"message_update",
299                "message": serde_json::to_value(message).unwrap_or(serde_json::Value::Null),
300                "assistantMessageEvent": serde_json::to_value(assistant_message_event)
301                    .unwrap_or(serde_json::Value::Null),
302                "eventType": assistant_message_event.type_tag(),
303            });
304            let object = value.as_object_mut().expect("json object");
305            match assistant_message_event {
306                AssistantMessageEvent::TextDelta {
307                    content_index,
308                    delta,
309                    ..
310                }
311                | AssistantMessageEvent::ThinkingDelta {
312                    content_index,
313                    delta,
314                    ..
315                }
316                | AssistantMessageEvent::ToolCallDelta {
317                    content_index,
318                    delta,
319                    ..
320                } => {
321                    object.insert("contentIndex".into(), (*content_index).into());
322                    object.insert("delta".into(), delta.clone().into());
323                }
324                _ => {}
325            }
326            value
327        }
328        AgentEvent::ToolExecutionStart {
329            tool_call_id,
330            tool_name,
331            args,
332        } => serde_json::json!({
333            "type":"tool_execution_start", "toolCallId":tool_call_id,
334            "toolName":tool_name, "args":args
335        }),
336        AgentEvent::ToolExecutionUpdate {
337            tool_call_id,
338            tool_name,
339            args,
340            partial_result,
341        } => serde_json::json!({
342            "type":"tool_execution_update", "toolCallId":tool_call_id, "toolName":tool_name,
343            "args": args,
344            "partialResult": tool_result_json(partial_result)
345        }),
346        AgentEvent::ToolExecutionEnd {
347            tool_call_id,
348            tool_name,
349            result,
350            is_error,
351        } => serde_json::json!({
352            "type":"tool_execution_end", "toolCallId":tool_call_id,
353            "toolName":tool_name, "isError":is_error,
354            "result": tool_result_json(result)
355        }),
356    }
357}
358
359fn tool_result_json(result: &rpi_agent::types::AgentToolResult) -> serde_json::Value {
360    let content: Vec<serde_json::Value> = result
361        .content
362        .iter()
363        .map(|item| match item {
364            rpi_agent::types::TextContentOrImage::Text(text) => serde_json::json!({
365                "type": "text",
366                "text": text.text,
367            }),
368            rpi_agent::types::TextContentOrImage::Image(image) => {
369                serde_json::to_value(image).unwrap_or(serde_json::Value::Null)
370            }
371        })
372        .collect();
373    serde_json::json!({
374        "content": content,
375        "details": result.details,
376        "usage": result.usage.as_ref().and_then(|usage| serde_json::to_value(usage).ok()),
377        "addedToolNames": result.added_tool_names,
378        "terminate": result.terminate,
379    })
380}
381
382/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
383/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
384/// payload's key fields).
385fn emit_json_event(event: &HarnessEvent) {
386    let line = match event {
387        HarnessEvent::RunStart(e) => serde_json::json!({
388            "type": "run_start",
389            "lane": e.lane,
390            "runId": e.run_id,
391        }),
392        HarnessEvent::RunEnd(e) => serde_json::json!({
393            "type": "run_end",
394            "lane": e.lane,
395            "runId": e.run_id,
396            "outcome": run_end_outcome_str(e.outcome),
397            "leafId": e.leaf_id,
398        }),
399    };
400    println!("{line}");
401}
402
403fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
404    match o {
405        RunEndOutcome::Completed => "completed",
406        RunEndOutcome::Aborted => "aborted",
407        RunEndOutcome::Failed => "failed",
408    }
409}
410
411/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
412///
413/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
414/// render streaming responses). The REPL fallback ignores it.
415///
416/// `model_catalog` is the resolved provider's full model list, passed through
417/// so the TUI's `/model` selector can display available models (read-only —
418/// v1 does not switch models mid-session; see `docs/m6-cli-open-questions.md`).
419pub async fn interactive(
420    harness: &AgentHarness,
421    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
422    args: &Args,
423    model_catalog: Vec<rpi_ai::Model>,
424    initial: Option<String>,
425    extra_messages: &[String],
426    initial_images: Vec<ImageContent>,
427    theme: Option<&str>,
428    no_themes: bool,
429    reload_context: &crate::session::ReloadContext,
430) -> i32 {
431    // Check if TUI is supported
432    let force_tui = std::env::var("RPI_FORCE_TUI")
433        .map(|v| v == "1")
434        .unwrap_or(false);
435    if force_tui || crate::interactive_tui::is_tui_supported() {
436        // Use TUI-based interactive mode
437        crate::interactive_tui::interactive_tui(
438            harness,
439            event_rx,
440            args,
441            model_catalog,
442            initial,
443            extra_messages,
444            initial_images,
445            theme,
446            no_themes,
447            reload_context,
448        )
449        .await
450    } else {
451        // Fall back to simple REPL
452        interactive_repl(harness, args, initial, extra_messages, initial_images).await
453    }
454}
455
456/// Simple REPL-based interactive mode (fallback for non-TTY environments).
457pub async fn interactive_repl(
458    harness: &AgentHarness,
459    #[allow(unused_variables)] args: &Args,
460    initial: Option<String>,
461    extra_messages: &[String],
462    initial_images: Vec<ImageContent>,
463) -> i32 {
464    // Debug: confirm we entered REPL mode
465    let lane: Arc<dyn AgentLane> = harness.lane("main");
466    let stdin = std::io::stdin();
467    let is_tty = stdin.is_terminal();
468
469    if is_tty {
470        println!(
471            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
472        );
473    }
474
475    // Run the initial prompt + extra messages first (same as print mode).
476    let mut prompts: Vec<String> = Vec::new();
477    if let Some(init) = initial {
478        prompts.push(init);
479    }
480    for m in extra_messages {
481        prompts.push(m.clone());
482    }
483    let mut images = initial_images;
484    for prompt in prompts {
485        if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
486            return code;
487        }
488    }
489
490    // Then read lines from stdin until EOF / `/exit`.
491    let mut line = String::new();
492    loop {
493        if is_tty {
494            print!("> ");
495            let _ = std::io::stdout().flush();
496        }
497        line.clear();
498        match stdin.lock().read_line(&mut line) {
499            Ok(0) => break, // EOF
500            Ok(_) => {}
501            Err(_) => break,
502        }
503        let trimmed = line.trim();
504        if trimmed.is_empty() {
505            continue;
506        }
507        if trimmed == "/exit" || trimmed == "/quit" {
508            break;
509        }
510        if trimmed == "/abort" {
511            let _ = lane.abort().await;
512            eprintln!("(aborted)");
513            continue;
514        }
515        if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
516            return code;
517        }
518    }
519    0
520}
521
522/// Run a single prompt in interactive mode, printing the assistant reply (or
523/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
524/// hard rejection.
525async fn run_one(
526    lane: &Arc<dyn AgentLane>,
527    prompt: &str,
528    images: Vec<ImageContent>,
529) -> Result<(), i32> {
530    match lane.prompt_text(prompt, images).await {
531        Ok(result) => {
532            match &result.outcome {
533                HarnessRunOutcome::Completed { final_message, .. }
534                | HarnessRunOutcome::Aborted { final_message, .. } => {
535                    let text = assistant_text(final_message);
536                    if !text.is_empty() {
537                        println!("{text}");
538                    }
539                }
540                HarnessRunOutcome::Failed {
541                    error,
542                    final_message,
543                    ..
544                } => {
545                    if let Some(m) = final_message {
546                        if let Some(em) = &m.error_message {
547                            eprintln!("error: {em}");
548                        }
549                    }
550                    eprintln!("run failed: {error:?}");
551                }
552                HarnessRunOutcome::Suspended { .. } => {
553                    eprintln!("run suspended (deferred) — resume not supported in v1");
554                }
555            }
556            Ok(())
557        }
558        Err(e) => {
559            eprintln!("prompt rejected: {e}");
560            Err(1)
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use rpi_agent::events::AgentEvent;
569    use rpi_agent::types::AgentToolResult;
570    use rpi_ai::types::{
571        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
572    };
573    use rpi_harness::session::types::OperationError;
574
575    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
576        AssistantMessage {
577            role: rpi_ai::types::AssistantRole,
578            content: vec![Content::Text(TextContent {
579                kind: TextContentType,
580                text: text.into(),
581                text_signature: None,
582            })],
583            api: rpi_ai::Api::AnthropicMessages,
584            provider: "anthropic".into(),
585            model: "claude-sonnet-5".into(),
586            response_model: None,
587            response_id: None,
588            usage: Usage::zero(),
589            stop_reason: stop,
590            deferred: None,
591            error_message: None,
592            raw_stop_reason: None,
593            end_turn: None,
594            timestamp: 0,
595        }
596    }
597
598    #[test]
599    fn assistant_text_concatenates_text_blocks() {
600        let m = assistant("hello", StopReason::Stop);
601        assert_eq!(assistant_text(&m), "hello");
602    }
603
604    #[test]
605    fn outcome_exit_code_maps_failed_aborted_to_1() {
606        let failed = HarnessRunOutcome::Failed {
607            leaf_id: "l".into(),
608            error: OperationError {
609                code: "boom".into(),
610                message: "boom".into(),
611            },
612            final_entry_id: None,
613            final_message: None,
614        };
615        assert_eq!(outcome_exit_code(&failed), 1);
616        let completed = HarnessRunOutcome::Completed {
617            leaf_id: "l".into(),
618            final_entry_id: "e".into(),
619            final_message: assistant("ok", StopReason::Stop),
620        };
621        assert_eq!(outcome_exit_code(&completed), 0);
622    }
623
624    #[test]
625    fn run_end_outcome_str_roundtrip() {
626        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
627        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
628        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
629    }
630
631    #[test]
632    fn agent_event_projection_keeps_terminal_and_tool_payloads() {
633        let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
634        assert_eq!(end["type"], "agent_end");
635        assert_eq!(end["messages"], serde_json::json!([]));
636
637        let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
638            tool_call_id: "call-1".into(),
639            tool_name: "read".into(),
640            result: AgentToolResult::text("hello"),
641            is_error: false,
642        });
643        assert_eq!(tool["type"], "tool_execution_end");
644        assert_eq!(tool["result"]["content"][0]["text"], "hello");
645        assert_eq!(tool["result"]["terminate"], false);
646    }
647}