diff --git a/Cargo.lock b/Cargo.lock index 8e3e90ba12..bbd5b0f37d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.52.0", "x11rb", ] @@ -334,11 +334,10 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.132.0" +version = "1.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a2940faeb61f4f579a434bc3a546e9ab49a89596e94527d329281ef55fd44d" +checksum = "e494025b4c578bfefd025aada69c51ab1db6b7589f61cb78ae681f3115269209" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-sigv4", @@ -1040,7 +1039,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1364,7 +1363,6 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.4", - "serde", "signal-hook 0.3.18", "signal-hook-mio", "winapi", @@ -1793,7 +1791,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1966,7 +1964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2058,17 +2056,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix 1.1.4", - "windows-sys 0.59.0", -] - [[package]] name = "fdeflate" version = "0.3.7" @@ -2240,7 +2227,7 @@ dependencies = [ "serde_json", "serde_yml 0.0.13", "sha2 0.11.0", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "tempfile", "thiserror 2.0.18", @@ -2335,7 +2322,7 @@ dependencies = [ "serde", "serde_json", "serde_yml 0.0.13", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "thiserror 2.0.18", "tokio", @@ -2481,7 +2468,6 @@ dependencies = [ "colored", "console", "convert_case 0.11.0", - "crossterm 0.29.0", "derive_setters", "dirs", "enable-ansi-support", @@ -2513,14 +2499,14 @@ dependencies = [ "num-format", "open", "pretty_assertions", - "reedline", "regex", "rustls 0.23.40", + "rustyline", "serde", "serde_json", "serial_test", "strip-ansi-escapes", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "tempfile", "terminal_size", @@ -2605,7 +2591,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "strum 0.28.0", + "strum", "tempfile", "thiserror 2.0.18", "tokio", @@ -2681,7 +2667,7 @@ dependencies = [ "serde_urlencoded", "serde_yml 0.0.13", "strip-ansi-escapes", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "tempfile", "thiserror 2.0.18", @@ -4879,7 +4865,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5618,7 +5604,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6474,7 +6460,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6655,26 +6641,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "reedline" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2066729dce9fecd28d1c6850a159ee68719130f149b22467c362353e16994e90" -dependencies = [ - "chrono", - "crossterm 0.29.0", - "fd-lock", - "itertools", - "nu-ansi-term", - "serde", - "strip-ansi-escapes", - "strum 0.27.2", - "thiserror 2.0.18", - "unicase", - "unicode-segmentation", - "unicode-width 0.2.2", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -7071,7 +7037,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7084,7 +7050,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7164,7 +7130,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7941,15 +7907,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum" version = "0.28.0" @@ -8127,7 +8084,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8190,7 +8147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9279,7 +9236,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 74ce489076..092735ff4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,6 @@ posthog-rs = "0.7.0" pretty_assertions = "1.4.1" proc-macro2 = "1.0" quote = "1.0" -reedline = "=0.47.0" rustyline = "18.0.0" regex = "1.12.3" reqwest = { version = "0.12.23", features = [ diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index 57d0124d03..ffc3fd859c 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -39,8 +39,7 @@ colored.workspace = true anyhow.workspace = true derive_setters.workspace = true lazy_static.workspace = true -reedline.workspace = true -crossterm = "0.29.0" +rustyline.workspace = true nu-ansi-term.workspace = true tracing.workspace = true chrono.workspace = true diff --git a/crates/forge_main/src/completer/command.rs b/crates/forge_main/src/completer/command.rs index 7e6287ff0d..04daab5ec3 100644 --- a/crates/forge_main/src/completer/command.rs +++ b/crates/forge_main/src/completer/command.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use forge_select::ForgeWidget; -use reedline::{Completer, Span, Suggestion}; +use crate::completer::input_completer::InputSuggestion; +use crate::completer::search_term::Span; use crate::model::{ForgeCommand, ForgeCommandManager}; /// A display wrapper for `ForgeCommand` that renders the name and description @@ -25,8 +26,8 @@ impl CommandCompleter { } } -impl Completer for CommandCompleter { - fn complete(&mut self, line: &str, _: usize) -> Vec { +impl CommandCompleter { + pub fn complete(&mut self, line: &str, _: usize) -> Vec { // Determine which sentinel the user typed (`:` or `/`), defaulting to `/`. let sentinel = if line.starts_with(':') { ':' } else { '/' }; @@ -74,15 +75,10 @@ impl Completer for CommandCompleter { match builder.prompt() { Ok(Some(row)) => { - vec![Suggestion { + vec![InputSuggestion { value: row.0.name, - description: None, - style: None, - extra: None, span: Span::new(0, line.len()), append_whitespace: true, - match_indices: None, - display_override: None, }] } _ => vec![], diff --git a/crates/forge_main/src/completer/input_completer.rs b/crates/forge_main/src/completer/input_completer.rs index d5548f5868..d1df03d84b 100644 --- a/crates/forge_main/src/completer/input_completer.rs +++ b/crates/forge_main/src/completer/input_completer.rs @@ -3,10 +3,9 @@ use std::sync::Arc; use forge_select::{ForgeWidget, PreviewLayout, PreviewPlacement, SelectRow}; use forge_walker::Walker; -use reedline::{Completer, Span, Suggestion}; use crate::completer::CommandCompleter; -use crate::completer::search_term::SearchTerm; +use crate::completer::search_term::{SearchTerm, Span}; use crate::model::ForgeCommandManager; pub fn select_workspace_file(cwd: &Path, query: Option) -> anyhow::Result> { @@ -60,14 +59,18 @@ pub struct InputCompleter { command: CommandCompleter, } +pub struct InputSuggestion { + pub value: String, + pub span: Span, + pub append_whitespace: bool, +} + impl InputCompleter { pub fn new(cwd: PathBuf, command_manager: Arc) -> Self { Self { cwd, command: CommandCompleter::new(command_manager) } } -} -impl Completer for InputCompleter { - fn complete(&mut self, line: &str, pos: usize) -> Vec { + pub fn complete(&mut self, line: &str, pos: usize) -> Vec { if line.starts_with('/') || line.starts_with(':') { // if the line starts with '/' or ':' it's probably a command, so we delegate to // the command completer. @@ -86,15 +89,10 @@ impl Completer for InputCompleter { if let Ok(Some(selected)) = select_workspace_file(&self.cwd, initial_text) { let value = format!("[{}]", selected); - return vec![Suggestion { - description: None, + return vec![InputSuggestion { value, - style: None, - extra: None, span: Span::new(query.span.start, query.span.end), append_whitespace: true, - match_indices: None, - display_override: None, }]; } } diff --git a/crates/forge_main/src/completer/search_term.rs b/crates/forge_main/src/completer/search_term.rs index a3c7559446..f3e38f7980 100644 --- a/crates/forge_main/src/completer/search_term.rs +++ b/crates/forge_main/src/completer/search_term.rs @@ -1,4 +1,14 @@ -use reedline::Span; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } +} pub struct SearchTerm { line: String, diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 9718b8cc72..14da15deef 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -1,13 +1,20 @@ +use std::borrow::Cow; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; -use crossterm::event::Event; +use console::{measure_text_width, strip_ansi_codes}; use forge_api::Environment; -use nu_ansi_term::{Color, Style}; -use reedline::{ - ColumnarMenu, DefaultHinter, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode, - KeyModifiers, MenuBuilder, PromptEditMode, Reedline, ReedlineEvent, ReedlineMenu, - ReedlineRawEvent, Signal, default_emacs_keybindings, +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; @@ -16,14 +23,17 @@ use crate::highlighter::ForgeHighlighter; use crate::model::ForgeCommandManager; use crate::prompt::ForgePrompt; -// TODO: Store the last `HISTORY_CAPACITY` commands in the history file const HISTORY_CAPACITY: usize = 1024 * 1024; -const COMPLETION_MENU: &str = "completion_menu"; +/// Interactive terminal editor used by the Forge prompt. pub struct ForgeEditor { - editor: Reedline, + editor: Editor, + history_file: PathBuf, + pending_buffer: Option, } +/// Result of reading one prompt interaction from the terminal. +#[derive(Debug, PartialEq, Eq)] pub enum ReadResult { Success(String), Empty, @@ -32,162 +42,234 @@ pub enum ReadResult { } impl ForgeEditor { - fn init() -> reedline::Keybindings { - let mut keybindings = default_emacs_keybindings(); - // on TAB press shows the completion menu, and if we've exact match it will - // insert it - keybindings.add_binding( - KeyModifiers::NONE, - KeyCode::Tab, - ReedlineEvent::UntilFound(vec![ - ReedlineEvent::Menu(COMPLETION_MENU.to_string()), - ReedlineEvent::Edit(vec![EditCommand::Complete]), - ]), - ); - - // on CTRL + k press clears the screen - keybindings.add_binding( - KeyModifiers::CONTROL, - KeyCode::Char('k'), - ReedlineEvent::ClearScreen, - ); - - // on CTRL + r press searches the history - keybindings.add_binding( - KeyModifiers::CONTROL, - KeyCode::Char('r'), - ReedlineEvent::SearchHistory, - ); - - // on ALT + Enter press inserts a newline - keybindings.add_binding( - KeyModifiers::ALT, - KeyCode::Enter, - ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), - ); - - keybindings - } - + /// Creates a new interactive editor with history, completion, and + /// highlighting. pub fn new( env: Environment, custom_history_path: Option, manager: Arc, ) -> Self { - // Store file history in system config directory let history_file = env.history_path(custom_history_path.as_ref()); - - let history = Box::new( - FileBackedHistory::with_file(HISTORY_CAPACITY, history_file).unwrap_or_default(), + 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::::with_config(config) + .expect("rustyline editor should initialize for an interactive terminal"); + editor.bind_sequence( + KeyEvent(KeyCode::Enter, Modifiers::ALT), + EventHandler::Simple(Cmd::Newline), ); - let completion_menu = Box::new( - ColumnarMenu::default() - .with_name(COMPLETION_MENU) - .with_marker("") - .with_text_style(Style::new().bold().fg(Color::Cyan)) - .with_selected_text_style(Style::new().on(Color::White).fg(Color::Black)), + 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 } + } - let edit_mode = Box::new(ForgeEditMode::new(Self::init())); - - let editor = Reedline::create() - .with_completer(Box::new(InputCompleter::new(env.cwd, manager))) - .with_history(history) - .with_highlighter(Box::new(ForgeHighlighter)) - .with_hinter(Box::new( - DefaultHinter::default().with_style(Style::new().fg(Color::DarkGray)), - )) - .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) - .with_edit_mode(edit_mode) - .with_quick_completions(true) - .with_ansi_colors(true) - .use_bracketed_paste(true); - Self { editor } + 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 } + /// Reads one logical input from the terminal. pub fn prompt(&mut self, prompt: &mut ForgePrompt) -> anyhow::Result { - let signal = self.editor.read_line(prompt); + 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(); - signal - .map(Into::into) - .map_err(|e| anyhow::anyhow!(ReadLineError(e))) + + 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))), + } } - /// Sets the buffer content to be pre-filled on the next prompt + /// Sets the buffer content to be pre-filled on the next prompt. pub fn set_buffer(&mut self, content: String) { - self.editor - .run_edit_commands(&[EditCommand::InsertString(content)]); + self.pending_buffer = Some(content); } } #[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct ReadLineError(std::io::Error); - -/// Custom edit mode that wraps Emacs and intercepts paste events. -/// -/// When the terminal sends a bracketed-paste (e.g. from a drag-and-drop), -/// this mode checks whether the pasted text is an existing file path and, -/// if so, wraps it in `@[...]` before it reaches the reedline buffer. This -/// gives the user immediate visual feedback in the input field. -struct ForgeEditMode { - inner: Emacs, +#[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)) } -impl ForgeEditMode { - /// Creates a new `ForgeEditMode` wrapping an Emacs mode with the given - /// keybindings. - fn new(keybindings: reedline::Keybindings) -> Self { - Self { inner: Emacs::new(keybindings) } +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 prompt = format!("{left}{indicator}"); + return ResponsivePrompt { raw: prompt.clone(), styled: prompt }; + } + + if let Some((first_line, remaining)) = left.split_once('\n') { + let right = render_right_prompt(right); + return ResponsivePrompt { + raw: format!("{first_line}\n{remaining}{indicator}"), + styled: format!("{first_line}{right}\n{remaining}{indicator}"), + }; + } + + let right = render_right_prompt(right); + ResponsivePrompt { + raw: format!("{left}{indicator}"), + styled: format!("{left}{right}{indicator}"), } } -impl EditMode for ForgeEditMode { - fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { - // Convert to the underlying crossterm event so we can inspect it - let raw: Event = event.into(); +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") +} - if let Event::Paste(ref body) = raw { - let wrapped = wrap_pasted_text(body); - return ReedlineEvent::Edit(vec![EditCommand::InsertString(wrapped)]); - } +struct ResponsivePrompt { + raw: String, + styled: String, +} - // For every other event, delegate to the inner Emacs mode. - // We need to reconstruct a ReedlineRawEvent from the crossterm Event. - // ReedlineRawEvent implements TryFrom. - match ReedlineRawEvent::try_from(raw) { - Ok(raw_event) => self.inner.parse_event(raw_event), - Err(()) => ReedlineEvent::None, - } +impl RustylinePrompt for ResponsivePrompt { + fn raw(&self) -> &str { + &self.raw } - fn edit_mode(&self) -> PromptEditMode { - self.inner.edit_mode() + fn styled(&self) -> &str { + &self.styled } } -impl From for ReadResult { - fn from(signal: Signal) -> Self { - match signal { - Signal::Success(buffer) => { - let trimmed = buffer.trim(); - if trimmed.is_empty() { - ReadResult::Empty - } else { - ReadResult::Success(trimmed.to_string()) - } - } - Signal::ExternalBreak(buffer) => { - let trimmed = buffer.trim(); - if trimmed.is_empty() { - ReadResult::Empty +struct ForgeHelper { + completer: Mutex, + highlighter: ForgeHighlighter, + hinter: HistoryHinter, +} + +impl ForgeHelper { + fn new(cwd: PathBuf, command_manager: Arc) -> 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)> { + 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 { - ReadResult::Success(trimmed.to_string()) - } + 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.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()); } - Signal::CtrlC => ReadResult::Continue, - Signal::CtrlD => ReadResult::Exit, - _ => ReadResult::Continue, } + 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); } } diff --git a/crates/forge_main/src/highlighter.rs b/crates/forge_main/src/highlighter.rs index f82e2cdb83..10919c3c75 100644 --- a/crates/forge_main/src/highlighter.rs +++ b/crates/forge_main/src/highlighter.rs @@ -1,5 +1,18 @@ use nu_ansi_term::{Color, Style}; -use reedline::{Highlighter, StyledText}; + +pub(crate) struct StyledText { + pub(crate) buffer: Vec<(Style, String)>, +} + +impl StyledText { + pub fn new() -> Self { + Self { buffer: Vec::new() } + } + + pub fn push(&mut self, value: (Style, String)) { + self.buffer.push(value); + } +} /// Syntax highlighter for the forge readline prompt. /// @@ -11,8 +24,8 @@ use reedline::{Highlighter, StyledText}; /// - All other text is rendered in the default terminal style. pub struct ForgeHighlighter; -impl Highlighter for ForgeHighlighter { - fn highlight(&self, line: &str, _cursor: usize) -> StyledText { +impl ForgeHighlighter { + pub(crate) fn highlight(&self, line: &str, _cursor: usize) -> StyledText { let mut styled = StyledText::new(); if line.is_empty() { diff --git a/crates/forge_main/src/prompt.rs b/crates/forge_main/src/prompt.rs index fce8ba4388..303bcfed15 100644 --- a/crates/forge_main/src/prompt.rs +++ b/crates/forge_main/src/prompt.rs @@ -6,14 +6,10 @@ use convert_case::{Case, Casing}; use derive_setters::Setters; use forge_api::{AgentId, Effort, ModelId, Usage}; use nu_ansi_term::{Color, Style}; -use reedline::{Prompt, PromptHistorySearchStatus}; use crate::display_constants::markers; use crate::utils::humanize_number; -// Constants -const MULTILINE_INDICATOR: &str = "::: "; - // Nerd font symbols — left prompt const DIR_SYMBOL: &str = "\u{ea83}"; // 󪃃 folder icon const BRANCH_SYMBOL: &str = "\u{f418}"; // branch icon @@ -64,10 +60,8 @@ impl ForgePrompt { self.git_branch = git_branch; self } -} -impl Prompt for ForgePrompt { - fn render_prompt_left(&self) -> Cow<'_, str> { + pub fn render_prompt_left(&self) -> Cow<'_, str> { // Left prompt layout: // // AGENT_NAME 󪃃 dir branch @@ -119,7 +113,7 @@ impl Prompt for ForgePrompt { Cow::Owned(result) } - fn render_prompt_right(&self) -> Cow<'_, str> { + pub fn render_prompt_right(&self) -> Cow<'_, str> { // Right prompt layout: agent · tokens · cost · model // Active (tokens > 0): bright white for agent/tokens, green for cost // Inactive (no tokens): all segments dimmed @@ -208,39 +202,9 @@ impl Prompt for ForgePrompt { Cow::Owned(result) } - fn render_prompt_indicator(&self, _prompt_mode: reedline::PromptEditMode) -> Cow<'_, str> { + pub fn render_prompt_indicator(&self) -> Cow<'_, str> { Cow::Borrowed("") } - - fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { - Cow::Borrowed(MULTILINE_INDICATOR) - } - - fn render_prompt_history_search_indicator( - &self, - history_search: reedline::PromptHistorySearch, - ) -> Cow<'_, str> { - let prefix = match history_search.status { - PromptHistorySearchStatus::Passing => "", - PromptHistorySearchStatus::Failing => "failing ", - }; - - let mut result = String::with_capacity(32); - - // Handle empty search term more elegantly - if history_search.term.is_empty() { - write!(result, "({prefix}reverse-search) ").unwrap(); - } else { - write!( - result, - "({}reverse-search: {}) ", - prefix, history_search.term - ) - .unwrap(); - } - - Cow::Owned(Style::new().fg(Color::White).paint(&result).to_string()) - } } /// Gets the current git branch name if available @@ -291,6 +255,39 @@ mod tests { } } + enum PromptHistorySearchStatus { + Passing, + Failing, + } + + struct PromptHistorySearch { + status: PromptHistorySearchStatus, + term: String, + } + + fn render_prompt_history_search_indicator( + history_search: PromptHistorySearch, + ) -> Cow<'static, str> { + let prefix = match history_search.status { + PromptHistorySearchStatus::Passing => "", + PromptHistorySearchStatus::Failing => "failing ", + }; + + let mut result = String::with_capacity(32); + if history_search.term.is_empty() { + write!(result, "({prefix}reverse-search) ").unwrap(); + } else { + write!( + result, + "({}reverse-search: {}) ", + prefix, history_search.term + ) + .unwrap(); + } + + Cow::Owned(Style::new().fg(Color::White).paint(&result).to_string()) + } + #[test] fn test_render_prompt_left() { let prompt = ForgePrompt::default(); @@ -347,22 +344,13 @@ mod tests { assert!(actual.contains(AGENT_SYMBOL)); } - #[test] - fn test_render_prompt_multiline_indicator() { - let prompt = ForgePrompt::default(); - let actual = prompt.render_prompt_multiline_indicator(); - let expected = MULTILINE_INDICATOR; - assert_eq!(actual, expected); - } - #[test] fn test_render_prompt_history_search_indicator_passing() { - let prompt = ForgePrompt::default(); - let history_search = reedline::PromptHistorySearch { + let history_search = PromptHistorySearch { status: PromptHistorySearchStatus::Passing, term: "test".to_string(), }; - let actual = prompt.render_prompt_history_search_indicator(history_search); + let actual = render_prompt_history_search_indicator(history_search); let expected = Style::new() .fg(Color::White) .paint("(reverse-search: test) ") @@ -372,12 +360,11 @@ mod tests { #[test] fn test_render_prompt_history_search_indicator_failing() { - let prompt = ForgePrompt::default(); - let history_search = reedline::PromptHistorySearch { + let history_search = PromptHistorySearch { status: PromptHistorySearchStatus::Failing, term: "test".to_string(), }; - let actual = prompt.render_prompt_history_search_indicator(history_search); + let actual = render_prompt_history_search_indicator(history_search); let expected = Style::new() .fg(Color::White) .paint("(failing reverse-search: test) ") @@ -387,12 +374,11 @@ mod tests { #[test] fn test_render_prompt_history_search_indicator_empty_term() { - let prompt = ForgePrompt::default(); - let history_search = reedline::PromptHistorySearch { + let history_search = PromptHistorySearch { status: PromptHistorySearchStatus::Passing, term: "".to_string(), }; - let actual = prompt.render_prompt_history_search_indicator(history_search); + let actual = render_prompt_history_search_indicator(history_search); let expected = Style::new() .fg(Color::White) .paint("(reverse-search) ")