Skip to main content

walrus_cli/
repl.rs

1//! Interactive chat REPL with streaming output and persistent history.
2
3use crate::runner::Runner;
4use anyhow::Result;
5use compact_str::CompactString;
6use futures_core::Stream;
7use futures_util::StreamExt;
8use rustyline::error::ReadlineError;
9use std::{io::Write, path::PathBuf, pin::pin};
10
11/// Interactive chat REPL, generic over the execution backend.
12pub struct ChatRepl<R: Runner> {
13    runner: R,
14    agent: CompactString,
15    editor: rustyline::DefaultEditor,
16    history_path: Option<PathBuf>,
17}
18
19impl<R: Runner> ChatRepl<R> {
20    /// Create a new REPL with the given runner and agent name.
21    pub fn new(runner: R, agent: CompactString) -> Result<Self> {
22        let mut editor = rustyline::DefaultEditor::new()?;
23        let history_path = history_file_path();
24        if let Some(ref path) = history_path {
25            let _ = editor.load_history(path);
26        }
27        Ok(Self {
28            runner,
29            agent,
30            editor,
31            history_path,
32        })
33    }
34
35    /// Run the interactive REPL loop.
36    pub async fn run(&mut self) -> Result<()> {
37        println!("Walrus chat (Ctrl+D to exit, Ctrl+C to cancel)");
38        println!("---");
39
40        loop {
41            match self.editor.readline("> ") {
42                Ok(line) => {
43                    let line = line.trim().to_string();
44                    if line.is_empty() {
45                        continue;
46                    }
47                    let _ = self.editor.add_history_entry(&line);
48                    let stream = self.runner.stream(&self.agent, &line);
49                    stream_to_terminal(stream).await?;
50                }
51                Err(ReadlineError::Interrupted) => continue,
52                Err(ReadlineError::Eof) => break,
53                Err(e) => return Err(e.into()),
54            }
55        }
56
57        self.save_history();
58        Ok(())
59    }
60
61    /// Save readline history to disk.
62    fn save_history(&mut self) {
63        if let Some(ref path) = self.history_path {
64            if let Some(parent) = path.parent() {
65                let _ = std::fs::create_dir_all(parent);
66            }
67            let _ = self.editor.save_history(path);
68        }
69    }
70}
71
72/// Resolve the history file path at `~/.walrus/history`.
73fn history_file_path() -> Option<PathBuf> {
74    dirs::home_dir().map(|d| d.join(".walrus").join("history"))
75}
76
77/// Consume a stream of content chunks and print them to stdout in real time.
78///
79/// Handles Ctrl+C cancellation via `tokio::signal::ctrl_c()`.
80async fn stream_to_terminal(stream: impl Stream<Item = Result<String>>) -> Result<()> {
81    let mut stream = pin!(stream);
82
83    loop {
84        tokio::select! {
85            chunk = stream.next() => {
86                match chunk {
87                    Some(Ok(text)) => {
88                        print!("{text}");
89                        std::io::stdout().flush().ok();
90                    }
91                    Some(Err(e)) => {
92                        eprintln!("\nError: {e}");
93                        break;
94                    }
95                    None => break,
96                }
97            }
98            _ = tokio::signal::ctrl_c() => {
99                println!();
100                break;
101            }
102        }
103    }
104
105    println!();
106    Ok(())
107}