| use std::borrow::Cow; |
| use std::path::PathBuf; |
| use std::sync::{Arc, Mutex}; |
|
|
| use console::{measure_text_width, strip_ansi_codes}; |
| use forge_api::Environment; |
| use nu_ansi_term::Style; |
| use rustyline::completion::{Completer, Pair}; |
| use rustyline::config::{ColorMode, CompletionType, Config}; |
| use rustyline::error::ReadlineError as RustyReadlineError; |
| use rustyline::highlight::Highlighter; |
| use rustyline::hint::{Hinter, HistoryHinter}; |
| use rustyline::history::DefaultHistory; |
| use rustyline::validate::Validator; |
| use rustyline::{ |
| Cmd, Context as RustylineContext, Editor, EventHandler, Helper, KeyCode, KeyEvent, Modifiers, |
| Prompt as RustylinePrompt, |
| }; |
|
|
| use super::completer::InputCompleter; |
| use super::zsh::paste::wrap_pasted_text; |
| use crate::highlighter::ForgeHighlighter; |
| use crate::model::ForgeCommandManager; |
| use crate::prompt::ForgePrompt; |
|
|
| const HISTORY_CAPACITY: usize = 1024 * 1024; |
|
|
| |
| pub struct ForgeEditor { |
| editor: Editor<ForgeHelper, DefaultHistory>, |
| history_file: PathBuf, |
| pending_buffer: Option<String>, |
| } |
|
|
| |
| #[derive(Debug, PartialEq, Eq)] |
| pub enum ReadResult { |
| Success(String), |
| Empty, |
| Continue, |
| Exit, |
| } |
|
|
| impl ForgeEditor { |
| |
| |
| pub fn new( |
| env: Environment, |
| custom_history_path: Option<PathBuf>, |
| manager: Arc<ForgeCommandManager>, |
| ) -> Self { |
| let history_file = env.history_path(custom_history_path.as_ref()); |
| let helper = ForgeHelper::new(env.cwd, manager); |
| let config = Config::builder() |
| .max_history_size(HISTORY_CAPACITY) |
| .expect("rustyline history capacity should be valid") |
| .completion_type(CompletionType::List) |
| .completion_show_all_if_ambiguous(true) |
| .color_mode(ColorMode::Forced) |
| .enable_signals(true) |
| .build(); |
| let mut editor = Editor::<ForgeHelper, DefaultHistory>::with_config(config) |
| .expect("rustyline editor should initialize for an interactive terminal"); |
| editor.bind_sequence( |
| KeyEvent(KeyCode::Enter, Modifiers::ALT), |
| EventHandler::Simple(Cmd::Newline), |
| ); |
| editor.bind_sequence( |
| KeyEvent(KeyCode::Char('k'), Modifiers::CTRL), |
| EventHandler::Simple(Cmd::ClearScreen), |
| ); |
| editor.bind_sequence( |
| KeyEvent(KeyCode::Char('K'), Modifiers::CTRL), |
| EventHandler::Simple(Cmd::ClearScreen), |
| ); |
| editor.set_helper(Some(helper)); |
| let _ = editor.load_history(&history_file); |
| Self { editor, history_file, pending_buffer: None } |
| } |
|
|
| fn normalize_result(&mut self, buffer: String) -> ReadResult { |
| let result = normalize_result_text(buffer); |
| if let ReadResult::Success(text) = &result { |
| let _ = self.editor.add_history_entry(text.as_str()); |
| let _ = self.editor.save_history(&self.history_file); |
| } |
| result |
| } |
|
|
| |
| pub fn prompt(&mut self, prompt: &mut ForgePrompt) -> anyhow::Result<ReadResult> { |
| let prompt_text = render_prompt(prompt); |
| let initial = self.pending_buffer.take().unwrap_or_default(); |
| let readline = if initial.is_empty() { |
| self.editor.readline(&prompt_text) |
| } else { |
| self.editor |
| .readline_with_initial(&prompt_text, (&initial, "")) |
| }; |
| prompt.refresh(); |
|
|
| match readline { |
| Ok(buffer) => Ok(self.normalize_result(buffer)), |
| Err(RustyReadlineError::Interrupted) => Ok(ReadResult::Continue), |
| Err(RustyReadlineError::Eof) => Ok(ReadResult::Exit), |
| Err(error) => Err(anyhow::anyhow!(ReadLineError(error))), |
| } |
| } |
|
|
| |
| pub fn set_buffer(&mut self, content: String) { |
| self.pending_buffer = Some(content); |
| } |
| } |
|
|
| #[derive(Debug, thiserror::Error)] |
| #[error("failed to read line from terminal: {0}")] |
| pub struct ReadLineError(RustyReadlineError); |
|
|
| fn normalize_result_text(buffer: String) -> ReadResult { |
| let trimmed = buffer.trim(); |
| if trimmed.is_empty() { |
| return ReadResult::Empty; |
| } |
| ReadResult::Success(wrap_pasted_text(trimmed)) |
| } |
|
|
| fn render_prompt(prompt: &ForgePrompt) -> ResponsivePrompt { |
| let left = prompt.render_prompt_left(); |
| let indicator = prompt.render_prompt_indicator(); |
| let right = prompt.render_prompt_right(); |
| let right = right.trim_start(); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if right.trim().is_empty() { |
| let styled = format!("{left}{indicator}"); |
| let raw = strip_ansi_codes(&styled).into_owned(); |
| return ResponsivePrompt { raw, styled }; |
| } |
|
|
| if let Some((first_line, remaining)) = left.split_once('\n') { |
| let right = render_right_prompt(right); |
| let raw = strip_ansi_codes(&format!("{first_line}\n{remaining}{indicator}")).into_owned(); |
| return ResponsivePrompt { |
| raw, |
| styled: format!("{first_line}{right}\n{remaining}{indicator}"), |
| }; |
| } |
|
|
| let right = render_right_prompt(right); |
| let raw = strip_ansi_codes(&format!("{left}{indicator}")).into_owned(); |
| ResponsivePrompt { raw, styled: format!("{left}{right}{indicator}") } |
| } |
|
|
| fn render_right_prompt(right: &str) -> String { |
| let width = measure_text_width(strip_ansi_codes(right).as_ref()); |
| format!("\x1b[s\x1b[999C\x1b[{width}D{right}\x1b[K\x1b[u") |
| } |
|
|
| struct ResponsivePrompt { |
| raw: String, |
| styled: String, |
| } |
|
|
| impl RustylinePrompt for ResponsivePrompt { |
| fn raw(&self) -> &str { |
| &self.raw |
| } |
|
|
| fn styled(&self) -> &str { |
| &self.styled |
| } |
| } |
|
|
| struct ForgeHelper { |
| completer: Mutex<InputCompleter>, |
| highlighter: ForgeHighlighter, |
| hinter: HistoryHinter, |
| } |
|
|
| impl ForgeHelper { |
| fn new(cwd: PathBuf, command_manager: Arc<ForgeCommandManager>) -> Self { |
| Self { |
| completer: Mutex::new(InputCompleter::new(cwd, command_manager)), |
| highlighter: ForgeHighlighter, |
| hinter: HistoryHinter {}, |
| } |
| } |
| } |
|
|
| impl Helper for ForgeHelper {} |
|
|
| impl Completer for ForgeHelper { |
| type Candidate = Pair; |
|
|
| fn complete( |
| &self, |
| line: &str, |
| pos: usize, |
| _ctx: &RustylineContext<'_>, |
| ) -> rustyline::Result<(usize, Vec<Pair>)> { |
| let mut completer = self |
| .completer |
| .lock() |
| .expect("input completer mutex poisoned"); |
| let suggestions = completer.complete(line, pos); |
| let start = suggestions |
| .iter() |
| .map(|suggestion| suggestion.span.start) |
| .min() |
| .unwrap_or(pos); |
| let pairs = suggestions |
| .into_iter() |
| .map(|suggestion| { |
| let replacement = if suggestion.append_whitespace { |
| format!("{} ", suggestion.value) |
| } else { |
| suggestion.value |
| }; |
| Pair { display: replacement.clone(), replacement } |
| }) |
| .collect(); |
| Ok((start, pairs)) |
| } |
| } |
|
|
| impl Hinter for ForgeHelper { |
| type Hint = String; |
|
|
| fn hint(&self, line: &str, pos: usize, ctx: &RustylineContext<'_>) -> Option<Self::Hint> { |
| self.hinter.hint(line, pos, ctx) |
| } |
| } |
|
|
| impl Highlighter for ForgeHelper { |
| fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { |
| let styled = self.highlighter.highlight(line, pos); |
| if styled.buffer.is_empty() { |
| return Cow::Borrowed(line); |
| } |
|
|
| let default_style = Style::new(); |
| let mut rendered = String::with_capacity(line.len()); |
| for (style, text) in styled.buffer { |
| if style == default_style { |
| rendered.push_str(&text); |
| } else { |
| rendered.push_str(&style.paint(text).to_string()); |
| } |
| } |
| Cow::Owned(rendered) |
| } |
|
|
| fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { |
| Cow::Owned(Style::new().dimmed().paint(hint).to_string()) |
| } |
| } |
|
|
| impl Validator for ForgeHelper {} |
|
|
| #[cfg(test)] |
| mod tests { |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| #[test] |
| fn test_normalize_result_wraps_existing_pasted_path() { |
| let fixture = "/usr/bin/env".to_string(); |
|
|
| let actual = normalize_result_text(fixture); |
|
|
| let expected = ReadResult::Success("@[/usr/bin/env]".to_string()); |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_render_prompt_raw_has_no_ansi_escapes() { |
| use std::path::PathBuf; |
|
|
| use forge_api::{AgentId, ModelId}; |
|
|
| |
| |
| |
| |
| let mut prompt = ForgePrompt::new(PathBuf::from("project"), AgentId::default()); |
| prompt.model(ModelId::new("anthropic/claude-opus-4")); |
|
|
| let rendered = render_prompt(&prompt); |
|
|
| assert!( |
| !rendered.raw.contains('\u{1b}'), |
| "raw prompt must not contain ANSI escape sequences: {:?}", |
| rendered.raw |
| ); |
| |
| assert!(rendered.styled.contains('\u{1b}')); |
| } |
| } |
|
|