diff options
Diffstat (limited to 'src/repl')
| -rw-r--r-- | src/repl/handler.rs | 161 | ||||
| -rw-r--r-- | src/repl/init.rs | 121 | ||||
| -rw-r--r-- | src/repl/mod.rs | 173 |
3 files changed, 455 insertions, 0 deletions
diff --git a/src/repl/handler.rs b/src/repl/handler.rs new file mode 100644 index 0000000..23fa609 --- /dev/null +++ b/src/repl/handler.rs @@ -0,0 +1,161 @@ +use crate::client::ChatGptClient; +use crate::config::SharedConfig; +use crate::render::{self, MarkdownRender}; +use crate::utils::dump; + +use anyhow::Result; +use crossbeam::channel::{unbounded, Sender}; +use crossbeam::sync::WaitGroup; +use std::cell::RefCell; +use std::fs::File; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::thread::spawn; + +pub enum ReplCmd { + Submit(String), + SetRole(String), + UpdateConfig(String), + Prompt(String), + ClearRole, + Info, +} + +pub struct ReplCmdHandler { + client: ChatGptClient, + config: SharedConfig, + state: RefCell<ReplCmdHandlerState>, + ctrlc: Arc<AtomicBool>, + render: Arc<MarkdownRender>, +} + +pub struct ReplCmdHandlerState { + reply: String, + save_file: Option<File>, +} + +impl ReplCmdHandler { + pub fn init(client: ChatGptClient, config: SharedConfig) -> Result<Self> { + let render = Arc::new(MarkdownRender::init()?); + let save_file = config.as_ref().borrow().open_message_file()?; + let ctrlc = Arc::new(AtomicBool::new(false)); + let state = RefCell::new(ReplCmdHandlerState { + save_file, + reply: String::new(), + }); + Ok(Self { + client, + config, + state, + ctrlc, + render, + }) + } + + pub fn handle(&self, cmd: ReplCmd) -> Result<()> { + match cmd { + ReplCmd::Submit(input) => { + if input.is_empty() { + self.state.borrow_mut().reply.clear(); + return Ok(()); + } + let prompt = self.config.borrow().get_prompt(); + let wg = WaitGroup::new(); + let highlight = self.config.borrow().highlight; + let mut receiver = if highlight { + let (tx, rx) = unbounded(); + let ctrlc = self.ctrlc.clone(); + let wg = wg.clone(); + let render = self.render.clone(); + spawn(move || { + let _ = render::render_stream(rx, ctrlc, render); + drop(wg); + }); + ReplyReceiver::new(Some(tx)) + } else { + ReplyReceiver::new(None) + }; + self.client + .acquire_stream(&input, prompt, &mut receiver, self.ctrlc.clone())?; + self.config.borrow().save_message( + self.state.borrow_mut().save_file.as_mut(), + &input, + &receiver.output, + )?; + wg.wait(); + self.state.borrow_mut().reply = receiver.output; + } + ReplCmd::SetRole(name) => { + let output = self.config.borrow_mut().change_role(&name); + dump(output.trim(), 2); + } + ReplCmd::ClearRole => { + self.config.borrow_mut().role = None; + dump("Done", 2); + } + ReplCmd::Prompt(prompt) => { + let output = self.config.borrow_mut().create_temp_role(&prompt); + dump(output.trim(), 2); + } + ReplCmd::Info => { + let output = self.config.borrow().info()?; + dump(output.trim(), 2); + } + ReplCmd::UpdateConfig(input) => { + let output = self.config.borrow_mut().update(&input)?; + dump(output.trim(), 2); + } + } + Ok(()) + } + + pub fn get_reply(&self) -> String { + self.state.borrow().reply.to_string() + } + + pub fn get_ctrlc(&self) -> Arc<AtomicBool> { + self.ctrlc.clone() + } +} + +pub struct ReplyReceiver { + output: String, + sender: Option<Sender<RenderStreamEvent>>, +} + +impl ReplyReceiver { + pub fn new(sender: Option<Sender<RenderStreamEvent>>) -> Self { + Self { + output: String::new(), + sender, + } + } + + pub fn text(&mut self, text: &str) { + match self.sender.as_ref() { + Some(tx) => { + let _ = tx.send(RenderStreamEvent::Text(text.to_string())); + } + None => { + dump(text, 0); + } + } + self.output.push_str(text); + } + + pub fn done(&mut self) { + match self.sender.as_ref() { + Some(tx) => { + let _ = tx.send(RenderStreamEvent::Done); + } + None => { + dump("", 2); + } + } + } +} + +pub enum RenderStreamEvent { + Text(String), + Done, +} diff --git a/src/repl/init.rs b/src/repl/init.rs new file mode 100644 index 0000000..f640deb --- /dev/null +++ b/src/repl/init.rs @@ -0,0 +1,121 @@ +use super::Repl; + +use crate::config::{Config, SharedConfig}; + +use anyhow::{Context, Result}; +use reedline::{ + default_emacs_keybindings, ColumnarMenu, DefaultCompleter, DefaultPrompt, DefaultPromptSegment, + Emacs, FileBackedHistory, KeyCode, KeyModifiers, Keybindings, Reedline, ReedlineEvent, + ReedlineMenu, ValidationResult, Validator, +}; + +use super::REPL_COMMANDS; + +const MENU_NAME: &str = "completion_menu"; + +impl Repl { + pub fn init(config: SharedConfig) -> Result<Self> { + let multiline_commands: Vec<&'static str> = REPL_COMMANDS + .iter() + .filter(|(_, _, v)| *v) + .map(|(v, _, _)| *v) + .collect(); + let completer = Self::create_completer(config); + let keybindings = Self::create_keybindings(); + let history = Self::create_history()?; + let menu = Self::create_menu(); + let edit_mode = Box::new(Emacs::new(keybindings)); + let editor = Reedline::create() + .with_completer(Box::new(completer)) + .with_history(history) + .with_menu(menu) + .with_edit_mode(edit_mode) + .with_quick_completions(true) + .with_partial_completions(true) + .with_validator(Box::new(ReplValidator { multiline_commands })) + .with_ansi_colors(true); + let prompt = Self::create_prompt(); + Ok(Self { editor, prompt }) + } + + fn create_prompt() -> DefaultPrompt { + DefaultPrompt::new(DefaultPromptSegment::Empty, DefaultPromptSegment::Empty) + } + + fn create_completer(config: SharedConfig) -> DefaultCompleter { + let mut completion: Vec<String> = REPL_COMMANDS + .into_iter() + .map(|(v, _, _)| v.to_string()) + .collect(); + completion.extend(config.borrow().repl_completions()); + let mut completer = DefaultCompleter::with_inclusions(&['.', '-', '_']).set_min_word_len(2); + completer.insert(completion.clone()); + completer + } + + fn create_keybindings() -> Keybindings { + let mut keybindings = default_emacs_keybindings(); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu(MENU_NAME.to_string()), + ReedlineEvent::MenuNext, + ]), + ); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('l'), + ReedlineEvent::ExecuteHostCommand(".clear screen".into()), + ); + keybindings + } + + fn create_menu() -> ReedlineMenu { + let completion_menu = ColumnarMenu::default().with_name(MENU_NAME); + ReedlineMenu::EngineCompleter(Box::new(completion_menu)) + } + + fn create_history() -> Result<Box<FileBackedHistory>> { + Ok(Box::new( + FileBackedHistory::with_file(1000, Config::history_file()?) + .with_context(|| "Failed to setup history file")?, + )) + } +} + +struct ReplValidator { + multiline_commands: Vec<&'static str>, +} + +impl Validator for ReplValidator { + fn validate(&self, line: &str) -> ValidationResult { + if line.split('"').count() % 2 == 0 || incomplete_brackets(line, &self.multiline_commands) { + ValidationResult::Incomplete + } else { + ValidationResult::Complete + } + } +} + +fn incomplete_brackets(line: &str, multiline_commands: &[&str]) -> bool { + let mut balance: Vec<char> = Vec::new(); + let line = line.trim_start(); + if !multiline_commands.iter().any(|v| line.starts_with(v)) { + return false; + } + + for c in line.chars() { + if c == '{' { + balance.push('}'); + } else if c == '}' { + if let Some(last) = balance.last() { + if last == &c { + balance.pop(); + } + } + } + } + + !balance.is_empty() +} diff --git a/src/repl/mod.rs b/src/repl/mod.rs new file mode 100644 index 0000000..6aeb233 --- /dev/null +++ b/src/repl/mod.rs @@ -0,0 +1,173 @@ +mod handler; +mod init; + +use crate::term; +use crate::utils::{copy, dump}; + +use anyhow::{Context, Result}; +use reedline::{DefaultPrompt, Reedline, Signal}; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +pub use self::handler::*; + +pub const REPL_COMMANDS: [(&str, &str, bool); 12] = [ + (".info", "Print the information", false), + (".set", "Modify the configuration temporarily", false), + (".role", "Specifies the role the AI will play", false), + (".clear role", "Clear the currently selected role", false), + (".prompt", "Add prompt, aka create a temporary role", true), + (".history", "Print the history", false), + (".clear history", "Clear the history", false), + (".clear screen", "Clear the screen", false), + (".multiline", "Enter multiline editor mode", true), + (".copy", "Copy last reply message", false), + (".help", "Print this help message", false), + (".exit", "Exit the REPL", false), +]; + +pub struct Repl { + editor: Reedline, + prompt: DefaultPrompt, +} + +impl Repl { + pub fn run(&mut self, handler: ReplCmdHandler) -> Result<()> { + dump( + format!("Welcome to aichat {}", env!("CARGO_PKG_VERSION")), + 1, + ); + dump("Type \".help\" for more information.", 1); + let mut current_ctrlc = false; + let handler = Arc::new(handler); + loop { + let handler_ctrlc = handler.get_ctrlc(); + if handler_ctrlc.load(Ordering::SeqCst) { + handler_ctrlc.store(false, Ordering::SeqCst); + current_ctrlc = true + } + match self.editor.read_line(&self.prompt) { + Ok(Signal::Success(line)) => { + current_ctrlc = false; + match self.handle_line(handler.clone(), line) { + Ok(quit) => { + if quit { + break; + } + } + Err(err) => { + let err = format!("{err:?}"); + dump(err.trim(), 2); + } + } + } + Ok(Signal::CtrlC) => { + if !current_ctrlc { + current_ctrlc = true; + dump("(To exit, press Ctrl+C again or Ctrl+D or type .exit)", 2); + } else { + break; + } + } + Ok(Signal::CtrlD) => { + break; + } + _ => {} + } + } + Ok(()) + } + + fn handle_line(&mut self, handler: Arc<ReplCmdHandler>, line: String) -> Result<bool> { + if line.starts_with('.') { + let (cmd, args) = match line.split_once(' ') { + Some((head, tail)) => (head, Some(tail.trim())), + None => (line.as_str(), None), + }; + match cmd { + ".exit" => { + return Ok(true); + } + ".help" => { + dump_repl_help(); + } + ".clear" => match args { + Some("screen") => term::clear_screen(0)?, + Some("history") => { + let history = Box::new(self.editor.history_mut()); + history.clear().with_context(|| "Failed to clear history")?; + dump("", 1); + } + Some("role") => handler.handle(ReplCmd::ClearRole)?, + _ => dump_unknown_command(), + }, + ".history" => { + self.editor.print_history()?; + dump("", 1); + } + ".role" => match args { + Some(name) => handler.handle(ReplCmd::SetRole(name.to_string()))?, + None => dump("Usage: .role <name>", 2), + }, + ".info" => { + handler.handle(ReplCmd::Info)?; + } + ".multiline" => { + let mut text = args.unwrap_or_default().to_string(); + if text.is_empty() { + dump("Usage: .multiline { <your multiline content> }", 2); + } else { + if text.starts_with('{') && text.ends_with('}') { + text = text[1..text.len() - 1].to_string() + } + handler.handle(ReplCmd::Submit(text))?; + } + } + ".copy" => { + let reply = handler.get_reply(); + if reply.is_empty() { + dump("No reply messages that can be copied", 1) + } else { + copy(&reply)?; + dump("Copied", 1); + } + } + ".set" => { + handler.handle(ReplCmd::UpdateConfig(args.unwrap_or_default().to_string()))? + } + ".prompt" => { + let mut text = args.unwrap_or_default().to_string(); + if text.is_empty() { + dump("Usage: .prompt { <your multiline content> }.", 2); + } else { + if text.starts_with('{') && text.ends_with('}') { + text = text[1..text.len() - 1].to_string() + } + handler.handle(ReplCmd::Prompt(text))?; + } + } + _ => dump_unknown_command(), + } + } else { + handler.handle(ReplCmd::Submit(line))?; + } + + Ok(false) + } +} + +fn dump_unknown_command() { + dump("Unknown command. Type \".help\" for more information.", 2); +} + +fn dump_repl_help() { + let head = REPL_COMMANDS + .iter() + .map(|(name, desc, _)| format!("{name:<15} {desc}")) + .collect::<Vec<String>>() + .join("\n"); + dump( + format!("{head}\n\nPress Ctrl+C to abort session, Ctrl+D to exit the REPL"), + 2, + ); +} |
