From 21865b39628f3b9684bb6317d95b2b78b5e6ec89 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 27 May 2026 19:08:49 +0530 Subject: [PATCH 1/5] refactor(editor): replace reedline with rustyline completer --- Cargo.lock | 77 +---- Cargo.toml | 1 - crates/forge_main/Cargo.toml | 2 +- crates/forge_main/src/completer/command.rs | 14 +- .../src/completer/input_completer.rs | 20 +- .../forge_main/src/completer/search_term.rs | 12 +- crates/forge_main/src/editor.rs | 320 ++++++++++-------- crates/forge_main/src/highlighter.rs | 19 +- crates/forge_main/src/prompt.rs | 47 ++- 9 files changed, 267 insertions(+), 245 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d38d50c345..fbde5eb336 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", ] @@ -1039,7 +1039,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1363,7 +1363,6 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.4", - "serde", "signal-hook 0.3.18", "signal-hook-mio", "winapi", @@ -1965,7 +1964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2057,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" @@ -2239,7 +2227,7 @@ dependencies = [ "serde_json", "serde_yml", "sha2 0.11.0", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "tempfile", "thiserror 2.0.18", @@ -2334,7 +2322,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "thiserror 2.0.18", "tokio", @@ -2512,14 +2500,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", @@ -2604,7 +2592,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "strum 0.28.0", + "strum", "tempfile", "thiserror 2.0.18", "tokio", @@ -2680,7 +2668,7 @@ dependencies = [ "serde_urlencoded", "serde_yml", "strip-ansi-escapes", - "strum 0.28.0", + "strum", "strum_macros 0.28.0", "tempfile", "thiserror 2.0.18", @@ -3442,7 +3430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19" dependencies = [ "bstr", - "hashbrown 0.17.0", + "hashbrown 0.16.1", ] [[package]] @@ -4582,7 +4570,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4878,7 +4866,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6460,7 +6448,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6641,26 +6629,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "reedline" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201e8e0160cbe7bb5eb2caccf281e178e77fac95115ab31a2c29edc5593603c8" -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" @@ -7057,7 +7025,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7070,7 +7038,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7150,7 +7118,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7917,15 +7885,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" @@ -8103,7 +8062,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9255,7 +9214,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 37e5b57df4..a766d5b368 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.48.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..1f427934e8 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -39,7 +39,7 @@ colored.workspace = true anyhow.workspace = true derive_setters.workspace = true lazy_static.workspace = true -reedline.workspace = true +rustyline.workspace = true crossterm = "0.29.0" nu-ansi-term.workspace = true tracing.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..6d2ad7680e 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::{ValidationContext, ValidationResult, Validator}; +use rustyline::{ + Cmd, Context as RustylineContext, Editor, EventHandler, Helper, KeyCode, KeyEvent, Modifiers, + Prompt as RustylinePrompt, }; use super::completer::InputCompleter; @@ -16,14 +23,16 @@ 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. pub enum ReadResult { Success(String), Empty, @@ -32,162 +41,205 @@ 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)), - ); - - 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 } + editor.set_helper(Some(helper)); + let _ = editor.load_history(&history_file); + Self { editor, history_file, pending_buffer: None } } + /// 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) => { + let buffer = normalize_input(buffer); + let trimmed = buffer.trim(); + if trimmed.is_empty() { + return Ok(ReadResult::Empty); + } + let _ = self.editor.add_history_entry(trimmed); + let _ = self.editor.save_history(&self.history_file); + Ok(ReadResult::Success(trimmed.to_string())) + } + 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 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 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_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 } } -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 normalize_input(input: String) -> String { + let stripped = input.replace("\x1b[200~", "").replace("\x1b[201~", ""); + wrap_pasted_text(&stripped) +} - if let Event::Paste(ref body) = raw { - let wrapped = wrap_pasted_text(body); - return ReedlineEvent::Edit(vec![EditCommand::InsertString(wrapped)]); - } +struct ForgeHelper { + completer: Mutex, + highlighter: ForgeHighlighter, + hinter: HistoryHinter, +} - // 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 ForgeHelper { + fn new(cwd: PathBuf, command_manager: Arc) -> Self { + Self { + completer: Mutex::new(InputCompleter::new(cwd, command_manager)), + highlighter: ForgeHighlighter, + hinter: HistoryHinter {}, } } +} - fn edit_mode(&self) -> PromptEditMode { - self.inner.edit_mode() +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 { + suggestion.value + }; + Pair { display: replacement.clone(), replacement } + }) + .collect(); + Ok((start, pairs)) } } -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 - } else { - ReadResult::Success(trimmed.to_string()) - } +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 mut rendered = String::with_capacity(line.len()); + for (style, text) in styled.buffer { + if style == Style::new() { + 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) + } +} + +impl Validator for ForgeHelper { + fn validate(&self, _ctx: &mut ValidationContext<'_>) -> rustyline::Result { + Ok(ValidationResult::Valid(None)) } } 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..038d0a2340 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 @@ -44,6 +40,18 @@ pub struct ForgePrompt { pub git_branch: Option, } +#[cfg(test)] +pub enum PromptHistorySearchStatus { + Passing, + Failing, +} + +#[cfg(test)] +pub struct PromptHistorySearch { + pub status: PromptHistorySearchStatus, + pub term: String, +} + impl ForgePrompt { /// Creates a new `ForgePrompt`, resolving the git branch once at /// construction time. @@ -64,10 +72,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 +125,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,17 +214,14 @@ 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( + #[cfg(test)] + pub fn render_prompt_history_search_indicator( &self, - history_search: reedline::PromptHistorySearch, + history_search: PromptHistorySearch, ) -> Cow<'_, str> { let prefix = match history_search.status { PromptHistorySearchStatus::Passing => "", @@ -347,18 +350,10 @@ 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(), }; @@ -373,7 +368,7 @@ 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(), }; @@ -388,7 +383,7 @@ 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(), }; From a8be6e19fe3ea21714e4a7e6e2d904d06f31c762 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Thu, 28 May 2026 10:43:40 +0530 Subject: [PATCH 2/5] fix(editor): add ctrl+k kill to end of line bindings --- crates/forge_main/src/editor.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 6d2ad7680e..f009f13fe1 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -14,7 +14,7 @@ use rustyline::history::DefaultHistory; use rustyline::validate::{ValidationContext, ValidationResult, Validator}; use rustyline::{ Cmd, Context as RustylineContext, Editor, EventHandler, Helper, KeyCode, KeyEvent, Modifiers, - Prompt as RustylinePrompt, + Movement, Prompt as RustylinePrompt, }; use super::completer::InputCompleter; @@ -64,6 +64,14 @@ impl ForgeEditor { KeyEvent(KeyCode::Enter, Modifiers::ALT), EventHandler::Simple(Cmd::Newline), ); + editor.bind_sequence( + KeyEvent(KeyCode::Char('k'), Modifiers::CTRL), + EventHandler::Simple(Cmd::Kill(Movement::EndOfLine)), + ); + editor.bind_sequence( + KeyEvent(KeyCode::Char('K'), Modifiers::CTRL), + EventHandler::Simple(Cmd::Kill(Movement::EndOfLine)), + ); editor.set_helper(Some(helper)); let _ = editor.load_history(&history_file); Self { editor, history_file, pending_buffer: None } @@ -236,6 +244,10 @@ impl Highlighter for ForgeHelper { } 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 { From 00f0fff1b5b9e3450a77943c8731d268278be970 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Thu, 28 May 2026 13:22:19 +0530 Subject: [PATCH 3/5] fix(editor): bind ctrl+k kill whole buffer instead of eol --- crates/forge_main/src/editor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index f009f13fe1..89474b2b6f 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -66,11 +66,11 @@ impl ForgeEditor { ); editor.bind_sequence( KeyEvent(KeyCode::Char('k'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Kill(Movement::EndOfLine)), + EventHandler::Simple(Cmd::Kill(Movement::WholeBuffer)), ); editor.bind_sequence( KeyEvent(KeyCode::Char('K'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Kill(Movement::EndOfLine)), + EventHandler::Simple(Cmd::Kill(Movement::WholeBuffer)), ); editor.set_helper(Some(helper)); let _ = editor.load_history(&history_file); From bb67616db40bfa921f95f2f0f9202efa99bf2d11 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Thu, 28 May 2026 20:19:36 +0530 Subject: [PATCH 4/5] refactor(editor): switch to rustyline validator and normalize result --- Cargo.lock | 1 - crates/forge_main/Cargo.toml | 1 - crates/forge_main/src/editor.rs | 65 ++++++++++++++++---------- crates/forge_main/src/prompt.rs | 81 +++++++++++++++------------------ 4 files changed, 78 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 796f2b3a68..3d4fc854ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2468,7 +2468,6 @@ dependencies = [ "colored", "console", "convert_case 0.11.0", - "crossterm 0.29.0", "derive_setters", "dirs", "enable-ansi-support", diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index 1f427934e8..ffc3fd859c 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -40,7 +40,6 @@ anyhow.workspace = true derive_setters.workspace = true lazy_static.workspace = true rustyline.workspace = true -crossterm = "0.29.0" nu-ansi-term.workspace = true tracing.workspace = true chrono.workspace = true diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 89474b2b6f..cbfbbfbfe4 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -11,10 +11,10 @@ use rustyline::error::ReadlineError as RustyReadlineError; use rustyline::highlight::Highlighter; use rustyline::hint::{Hinter, HistoryHinter}; use rustyline::history::DefaultHistory; -use rustyline::validate::{ValidationContext, ValidationResult, Validator}; +use rustyline::validate::Validator; use rustyline::{ Cmd, Context as RustylineContext, Editor, EventHandler, Helper, KeyCode, KeyEvent, Modifiers, - Movement, Prompt as RustylinePrompt, + Prompt as RustylinePrompt, }; use super::completer::InputCompleter; @@ -33,6 +33,7 @@ pub struct ForgeEditor { } /// Result of reading one prompt interaction from the terminal. +#[derive(Debug, PartialEq, Eq)] pub enum ReadResult { Success(String), Empty, @@ -66,17 +67,26 @@ impl ForgeEditor { ); editor.bind_sequence( KeyEvent(KeyCode::Char('k'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Kill(Movement::WholeBuffer)), + EventHandler::Simple(Cmd::ClearScreen), ); editor.bind_sequence( KeyEvent(KeyCode::Char('K'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Kill(Movement::WholeBuffer)), + 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 + } + /// Reads one logical input from the terminal. pub fn prompt(&mut self, prompt: &mut ForgePrompt) -> anyhow::Result { let prompt_text = render_prompt(prompt); @@ -90,16 +100,7 @@ impl ForgeEditor { prompt.refresh(); match readline { - Ok(buffer) => { - let buffer = normalize_input(buffer); - let trimmed = buffer.trim(); - if trimmed.is_empty() { - return Ok(ReadResult::Empty); - } - let _ = self.editor.add_history_entry(trimmed); - let _ = self.editor.save_history(&self.history_file); - Ok(ReadResult::Success(trimmed.to_string())) - } + 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))), @@ -116,6 +117,14 @@ impl ForgeEditor { #[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(); @@ -162,11 +171,6 @@ impl RustylinePrompt for ResponsivePrompt { } } -fn normalize_input(input: String) -> String { - let stripped = input.replace("\x1b[200~", "").replace("\x1b[201~", ""); - wrap_pasted_text(&stripped) -} - struct ForgeHelper { completer: Mutex, highlighter: ForgeHighlighter, @@ -234,9 +238,10 @@ impl Highlighter for ForgeHelper { 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 == Style::new() { + if style == default_style { rendered.push_str(&text); } else { rendered.push_str(&style.paint(text).to_string()); @@ -250,8 +255,22 @@ impl Highlighter for ForgeHelper { } } -impl Validator for ForgeHelper { - fn validate(&self, _ctx: &mut ValidationContext<'_>) -> rustyline::Result { - Ok(ValidationResult::Valid(None)) +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/prompt.rs b/crates/forge_main/src/prompt.rs index 038d0a2340..303bcfed15 100644 --- a/crates/forge_main/src/prompt.rs +++ b/crates/forge_main/src/prompt.rs @@ -40,18 +40,6 @@ pub struct ForgePrompt { pub git_branch: Option, } -#[cfg(test)] -pub enum PromptHistorySearchStatus { - Passing, - Failing, -} - -#[cfg(test)] -pub struct PromptHistorySearch { - pub status: PromptHistorySearchStatus, - pub term: String, -} - impl ForgePrompt { /// Creates a new `ForgePrompt`, resolving the git branch once at /// construction time. @@ -217,33 +205,6 @@ impl ForgePrompt { pub fn render_prompt_indicator(&self) -> Cow<'_, str> { Cow::Borrowed("") } - - #[cfg(test)] - pub fn render_prompt_history_search_indicator( - &self, - history_search: 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 @@ -294,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(); @@ -352,12 +346,11 @@ mod tests { #[test] fn test_render_prompt_history_search_indicator_passing() { - let prompt = ForgePrompt::default(); 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) ") @@ -367,12 +360,11 @@ mod tests { #[test] fn test_render_prompt_history_search_indicator_failing() { - let prompt = ForgePrompt::default(); 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) ") @@ -382,12 +374,11 @@ mod tests { #[test] fn test_render_prompt_history_search_indicator_empty_term() { - let prompt = ForgePrompt::default(); 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) ") From 178adc707eefdd5d123c58ad81eb97ebf295adab Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 14:51:46 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- crates/forge_main/src/editor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index cbfbbfbfe4..14da15deef 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -273,4 +273,3 @@ mod tests { assert_eq!(actual, expected); } } -