1use 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 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 #[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}