use std::future::Future;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use crate::error::Result;
const HELP_TEXT: &str = "\
[outrig] slash commands:
/help show this help
/tools list registered tools
/reset clear conversation history
/quit exit the session
";
const INTERRUPT_NOTICE: &[u8] = b"\n[outrig] interrupted\n";
pub struct Repl;
impl Repl {
pub async fn run<P, PFut, T, TFut, R, RFut>(
banner: &str,
on_prompt: P,
on_tools: T,
on_reset: R,
) -> Result<()>
where
P: FnMut(String) -> PFut,
PFut: Future<Output = Result<String>>,
T: FnMut() -> TFut,
TFut: Future<Output = String>,
R: FnMut() -> RFut,
RFut: Future<Output = String>,
{
let stdin = BufReader::new(tokio::io::stdin());
let stdout = tokio::io::stdout();
let stderr = tokio::io::stderr();
Self::run_with(
stdin,
stdout,
stderr,
ctrl_c_signal,
banner,
on_prompt,
on_tools,
on_reset,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_with<RD, W, E, I, IFut, P, PFut, T, TFut, R, RFut>(
stdin: RD,
mut stdout: W,
mut stderr: E,
mut interrupt: I,
banner: &str,
mut on_prompt: P,
mut on_tools: T,
mut on_reset: R,
) -> Result<()>
where
RD: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
E: AsyncWrite + Unpin,
I: FnMut() -> IFut,
IFut: Future<Output = ()>,
P: FnMut(String) -> PFut,
PFut: Future<Output = Result<String>>,
T: FnMut() -> TFut,
TFut: Future<Output = String>,
R: FnMut() -> RFut,
RFut: Future<Output = String>,
{
if !banner.is_empty() {
stderr.write_all(banner.as_bytes()).await?;
if !banner.ends_with('\n') {
stderr.write_all(b"\n").await?;
}
stderr.flush().await?;
}
let mut lines = stdin.lines();
let mut last_was_interrupt = false;
loop {
stderr.write_all(b"> ").await?;
stderr.flush().await?;
let line_opt = tokio::select! {
res = lines.next_line() => res?,
_ = interrupt() => {
stderr.write_all(b"\n").await?;
stderr.flush().await?;
if last_was_interrupt {
return Ok(());
}
last_was_interrupt = true;
continue;
}
};
let Some(line) = line_opt else {
stderr.write_all(b"\n").await?;
stderr.flush().await?;
return Ok(());
};
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
last_was_interrupt = false;
if let Some(cmd) = trimmed.strip_prefix('/') {
match cmd {
"quit" => return Ok(()),
"help" => {
stderr.write_all(HELP_TEXT.as_bytes()).await?;
stderr.flush().await?;
}
"tools" => {
write_stderr_line(&mut stderr, &on_tools().await).await?;
}
"reset" => {
write_stderr_line(&mut stderr, &on_reset().await).await?;
}
other => {
stderr
.write_all(format!("[outrig] unknown command: /{other}\n").as_bytes())
.await?;
stderr.flush().await?;
}
}
continue;
}
tokio::select! {
res = on_prompt(trimmed.to_string()) => {
let reply = res?;
if !reply.is_empty() {
stdout.write_all(reply.as_bytes()).await?;
if !reply.ends_with('\n') {
stdout.write_all(b"\n").await?;
}
stdout.flush().await?;
}
}
_ = interrupt() => {
stderr.write_all(INTERRUPT_NOTICE).await?;
stderr.flush().await?;
last_was_interrupt = true;
}
}
}
}
}
async fn ctrl_c_signal() {
let _ = tokio::signal::ctrl_c().await;
}
async fn write_stderr_line<E>(stderr: &mut E, text: &str) -> Result<()>
where
E: AsyncWrite + Unpin,
{
stderr.write_all(text.as_bytes()).await?;
if !text.ends_with('\n') {
stderr.write_all(b"\n").await?;
}
stderr.flush().await?;
Ok(())
}