Skip to main content

outrig_cli/
repl.rs

1//! Interactive stdin/stdout REPL with slash commands.
2//!
3//! `Repl::run` drives the I/O loop: print a banner on stderr, prompt with `> `,
4//! and feed each non-slash line to a caller-supplied async callback. Slash
5//! commands (`/help`, `/quit`, `/tools`, `/reset`) are handled here; `/tools`
6//! and `/reset` defer to caller-supplied callbacks for their text + side
7//! effects (history clearing, tool-list assembly). EOF (Ctrl-D) and `/quit`
8//! exit cleanly. SIGINT during a callback cancels the in-flight future,
9//! prints `[outrig] interrupted`, and returns to the prompt; a second
10//! consecutive SIGINT (no input typed in between) exits.
11//!
12//! Strict stream separation: assistant text goes to stdout; everything else --
13//! banner, prompt, slash-command output, interrupt notice -- goes to stderr.
14//! That way `outrig run > out.txt` captures only the model's replies.
15//!
16//! [`Repl::run_with`] is the generic form: it takes any `AsyncBufRead` /
17//! `AsyncWrite` streams plus an interrupt-future factory, so integration tests
18//! can substitute `tokio::io::duplex` halves and a `tokio::sync::Notify`-driven
19//! interrupt source.
20
21use std::future::Future;
22
23use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
24
25use crate::error::Result;
26
27const HELP_TEXT: &str = "\
28[outrig] slash commands:
29  /help    show this help
30  /tools   list registered tools
31  /reset   clear conversation history
32  /quit    exit the session
33";
34
35const INTERRUPT_NOTICE: &[u8] = b"\n[outrig] interrupted\n";
36
37pub struct Repl;
38
39impl Repl {
40    /// Run the REPL against real stdin/stdout/stderr, treating
41    /// `tokio::signal::ctrl_c()` as the interrupt source. The `banner` is
42    /// printed once to stderr before the first prompt; `on_prompt` is invoked
43    /// for every non-slash, non-empty input line and its non-empty returned
44    /// text is printed to stdout. Streaming callers may write incrementally
45    /// during the callback and return an empty string to suppress trailing
46    /// reprint. `on_tools` and `on_reset` produce the stderr text for `/tools`
47    /// and `/reset` respectively (and `on_reset` is the side-effect site for
48    /// clearing whatever conversation state the caller owns).
49    pub async fn run<P, PFut, T, TFut, R, RFut>(
50        banner: &str,
51        on_prompt: P,
52        on_tools: T,
53        on_reset: R,
54    ) -> Result<()>
55    where
56        P: FnMut(String) -> PFut,
57        PFut: Future<Output = Result<String>>,
58        T: FnMut() -> TFut,
59        TFut: Future<Output = String>,
60        R: FnMut() -> RFut,
61        RFut: Future<Output = String>,
62    {
63        let stdin = BufReader::new(tokio::io::stdin());
64        let stdout = tokio::io::stdout();
65        let stderr = tokio::io::stderr();
66        Self::run_with(
67            stdin,
68            stdout,
69            stderr,
70            ctrl_c_signal,
71            banner,
72            on_prompt,
73            on_tools,
74            on_reset,
75        )
76        .await
77    }
78
79    /// Generic form parameterized over the I/O streams and interrupt source.
80    /// Production calls this with real handles via [`Repl::run`]; integration
81    /// tests call it with `tokio::io::duplex` halves and a `Notify`-driven
82    /// interrupt closure to exercise EOF, slash commands, and SIGINT
83    /// handling without touching real signals or terminals.
84    #[allow(clippy::too_many_arguments)]
85    pub async fn run_with<RD, W, E, I, IFut, P, PFut, T, TFut, R, RFut>(
86        stdin: RD,
87        mut stdout: W,
88        mut stderr: E,
89        mut interrupt: I,
90        banner: &str,
91        mut on_prompt: P,
92        mut on_tools: T,
93        mut on_reset: R,
94    ) -> Result<()>
95    where
96        RD: AsyncBufRead + Unpin,
97        W: AsyncWrite + Unpin,
98        E: AsyncWrite + Unpin,
99        I: FnMut() -> IFut,
100        IFut: Future<Output = ()>,
101        P: FnMut(String) -> PFut,
102        PFut: Future<Output = Result<String>>,
103        T: FnMut() -> TFut,
104        TFut: Future<Output = String>,
105        R: FnMut() -> RFut,
106        RFut: Future<Output = String>,
107    {
108        if !banner.is_empty() {
109            stderr.write_all(banner.as_bytes()).await?;
110            if !banner.ends_with('\n') {
111                stderr.write_all(b"\n").await?;
112            }
113            stderr.flush().await?;
114        }
115
116        let mut lines = stdin.lines();
117        let mut last_was_interrupt = false;
118
119        loop {
120            stderr.write_all(b"> ").await?;
121            stderr.flush().await?;
122
123            let line_opt = tokio::select! {
124                res = lines.next_line() => res?,
125                _ = interrupt() => {
126                    stderr.write_all(b"\n").await?;
127                    stderr.flush().await?;
128                    if last_was_interrupt {
129                        return Ok(());
130                    }
131                    last_was_interrupt = true;
132                    continue;
133                }
134            };
135
136            let Some(line) = line_opt else {
137                stderr.write_all(b"\n").await?;
138                stderr.flush().await?;
139                return Ok(());
140            };
141
142            let trimmed = line.trim_end_matches(['\r', '\n']);
143
144            if trimmed.is_empty() {
145                continue;
146            }
147
148            last_was_interrupt = false;
149
150            if let Some(cmd) = trimmed.strip_prefix('/') {
151                match cmd {
152                    "quit" => return Ok(()),
153                    "help" => {
154                        stderr.write_all(HELP_TEXT.as_bytes()).await?;
155                        stderr.flush().await?;
156                    }
157                    "tools" => {
158                        write_stderr_line(&mut stderr, &on_tools().await).await?;
159                    }
160                    "reset" => {
161                        write_stderr_line(&mut stderr, &on_reset().await).await?;
162                    }
163                    other => {
164                        stderr
165                            .write_all(format!("[outrig] unknown command: /{other}\n").as_bytes())
166                            .await?;
167                        stderr.flush().await?;
168                    }
169                }
170                continue;
171            }
172
173            tokio::select! {
174                res = on_prompt(trimmed.to_string()) => {
175                    let reply = res?;
176                    if !reply.is_empty() {
177                        stdout.write_all(reply.as_bytes()).await?;
178                        if !reply.ends_with('\n') {
179                            stdout.write_all(b"\n").await?;
180                        }
181                        stdout.flush().await?;
182                    }
183                }
184                _ = interrupt() => {
185                    stderr.write_all(INTERRUPT_NOTICE).await?;
186                    stderr.flush().await?;
187                    last_was_interrupt = true;
188                }
189            }
190        }
191    }
192}
193
194async fn ctrl_c_signal() {
195    let _ = tokio::signal::ctrl_c().await;
196}
197
198async fn write_stderr_line<E>(stderr: &mut E, text: &str) -> Result<()>
199where
200    E: AsyncWrite + Unpin,
201{
202    stderr.write_all(text.as_bytes()).await?;
203    if !text.ends_with('\n') {
204        stderr.write_all(b"\n").await?;
205    }
206    stderr.flush().await?;
207    Ok(())
208}