Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/forge_main/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ anyhow.workspace = true
derive_setters.workspace = true
lazy_static.workspace = true
reedline.workspace = true
crossterm = "0.29.0"
nu-ansi-term.workspace = true
nucleo.workspace = true
tracing.workspace = true
Expand Down
22 changes: 22 additions & 0 deletions crates/forge_main/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,16 @@ pub enum ZshCommandGroup {

/// Show keyboard shortcuts for ZSH line editor
Keyboard,

/// Format buffer text by wrapping file paths in @[...] syntax.
///
/// Used by the zsh plugin to delegate path detection and wrapping to
/// Rust where the logic is well-tested across all terminal environments.
Format {
/// The text buffer to format.
#[arg(long)]
buffer: String,
},
}

/// Command group for MCP server management.
Expand Down Expand Up @@ -1740,6 +1750,18 @@ mod tests {
assert_eq!(actual, true);
}

#[test]
fn test_zsh_format() {
let fixture = Cli::parse_from(["forge", "zsh", "format", "--buffer", "hello world"]);
let actual = match fixture.subcommands {
Some(TopLevelCommand::Zsh(ZshCommandGroup::Format { buffer })) => {
buffer == "hello world"
}
_ => false,
};
assert_eq!(actual, true);
}

#[test]
fn test_setup_alias() {
let fixture = Cli::parse_from(["forge", "setup"]);
Expand Down
51 changes: 48 additions & 3 deletions crates/forge_main/src/editor.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
use std::path::PathBuf;
use std::sync::Arc;

use crossterm::event::Event;
use forge_api::Environment;
use nu_ansi_term::{Color, Style};
use reedline::{
ColumnarMenu, DefaultHinter, EditCommand, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
MenuBuilder, Prompt, Reedline, ReedlineEvent, ReedlineMenu, Signal, default_emacs_keybindings,
ColumnarMenu, DefaultHinter, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode,
KeyModifiers, MenuBuilder, Prompt, PromptEditMode, Reedline, ReedlineEvent, ReedlineMenu,
ReedlineRawEvent, Signal, default_emacs_keybindings,
};

use super::completer::InputCompleter;
use super::zsh::paste::wrap_pasted_text;
use crate::model::ForgeCommandManager;

// TODO: Store the last `HISTORY_CAPACITY` commands in the history file
Expand Down Expand Up @@ -83,7 +86,7 @@ impl ForgeEditor {
.with_selected_text_style(Style::new().on(Color::White).fg(Color::Black)),
);

let edit_mode = Box::new(Emacs::new(Self::init()));
let edit_mode = Box::new(ForgeEditMode::new(Self::init()));

let editor = Reedline::create()
.with_completer(Box::new(InputCompleter::new(env.cwd, manager)))
Expand Down Expand Up @@ -117,6 +120,48 @@ impl ForgeEditor {
#[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,
}

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) }
}
}

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();

if let Event::Paste(ref body) = raw {
let wrapped = wrap_pasted_text(body);
return ReedlineEvent::Edit(vec![EditCommand::InsertString(wrapped)]);
}

// For every other event, delegate to the inner Emacs mode.
// We need to reconstruct a ReedlineRawEvent from the crossterm Event.
// ReedlineRawEvent implements TryFrom<Event>.
match ReedlineRawEvent::try_from(raw) {
Ok(raw_event) => self.inner.parse_event(raw_event),
Err(()) => ReedlineEvent::None,
}
}

fn edit_mode(&self) -> PromptEditMode {
self.inner.edit_mode()
}
}

impl From<Signal> for ReadResult {
fn from(signal: Signal) -> Self {
match signal {
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,10 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
crate::cli::ZshCommandGroup::Keyboard => {
self.on_zsh_keyboard().await?;
}
crate::cli::ZshCommandGroup::Format { buffer } => {
print!("{}", crate::zsh::paste::wrap_pasted_text(&buffer));
return Ok(());
}
}
return Ok(());
}
Expand Down
1 change: 1 addition & 0 deletions crates/forge_main/src/zsh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! - Right prompt (rprompt) display
//! - Prompt styling utilities

pub(crate) mod paste;
mod plugin;
mod rprompt;
mod style;
Expand Down
Loading
Loading