diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 22cf2ee54a..a6b0b4c445 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -14,6 +14,9 @@ use crate::model::ForgeCommandManager; // TODO: Store the last `HISTORY_CAPACITY` commands in the history file const HISTORY_CAPACITY: usize = 1024 * 1024; const COMPLETION_MENU: &str = "completion_menu"; +/// Zero-width space used as an invisible submit marker for Shift+Tab agent cycling. +/// This is an implementation detail — higher layers see `ReadResult::CycleAgent`. +const CYCLE_AGENT_MARKER: &str = "\u{200B}"; pub struct ForgeEditor { editor: Reedline, @@ -21,6 +24,7 @@ pub struct ForgeEditor { pub enum ReadResult { Success(String), + CycleAgent, Empty, Continue, Exit, @@ -61,6 +65,20 @@ impl ForgeEditor { ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), ); + // on Shift+Tab press cycles to the next agent. + // Uses a proper Submit (not ExecuteHostCommand) so reedline correctly + // commits the prompt line. The zero-width space is invisible to the user + // but survives trim(), letting us distinguish this from an empty Enter. + let cycle = ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::Clear, + EditCommand::InsertString(CYCLE_AGENT_MARKER.to_string()), + ]), + ReedlineEvent::Submit, + ]); + keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::BackTab, cycle.clone()); + keybindings.add_binding(KeyModifiers::NONE, KeyCode::BackTab, cycle); + keybindings } @@ -100,8 +118,11 @@ impl ForgeEditor { } pub fn prompt(&mut self, prompt: &dyn Prompt) -> anyhow::Result { - let signal = self.editor.read_line(prompt); - signal + // Discard any bytes buffered in stdin during agent execution + // (e.g. Shift+Tab presses) so they don't trigger actions. + drain_stdin(); + self.editor + .read_line(prompt) .map(Into::into) .map_err(|e| anyhow::anyhow!(ReadLineError(e))) } @@ -113,6 +134,46 @@ impl ForgeEditor { } } +/// Reads and discards any bytes pending in stdin by briefly setting it to +/// non-blocking mode. This prevents buffered keypresses from agent execution +/// (like Shift+Tab) from being interpreted by reedline. +#[cfg(unix)] +fn drain_stdin() { + use std::io::Read; + use std::os::unix::io::AsRawFd; + + unsafe extern "C" { + fn fcntl(fd: std::ffi::c_int, cmd: std::ffi::c_int, ...) -> std::ffi::c_int; + } + + const F_GETFL: std::ffi::c_int = 3; + const F_SETFL: std::ffi::c_int = 4; + #[cfg(target_os = "linux")] + const O_NONBLOCK: std::ffi::c_int = 0o4000; + #[cfg(not(target_os = "linux"))] + const O_NONBLOCK: std::ffi::c_int = 0x0004; + + let fd = std::io::stdin().as_raw_fd(); + let flags = unsafe { fcntl(fd, F_GETFL) }; + if flags < 0 { + return; + } + + if unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) } < 0 { + return; + } + + let mut buf = [0u8; 1024]; + let mut stdin = std::io::stdin().lock(); + while stdin.read(&mut buf).unwrap_or(0) > 0 {} + drop(stdin); + + unsafe { fcntl(fd, F_SETFL, flags) }; +} + +#[cfg(not(unix))] +fn drain_stdin() {} + #[derive(Debug, thiserror::Error)] #[error(transparent)] pub struct ReadLineError(std::io::Error); @@ -122,7 +183,9 @@ impl From for ReadResult { match signal { Signal::Success(buffer) => { let trimmed = buffer.trim(); - if trimmed.is_empty() { + if trimmed == CYCLE_AGENT_MARKER { + ReadResult::CycleAgent + } else if trimmed.is_empty() { ReadResult::Empty } else { ReadResult::Success(trimmed.to_string()) diff --git a/crates/forge_main/src/input.rs b/crates/forge_main/src/input.rs index 6ba9c8cb41..c41df2117b 100644 --- a/crates/forge_main/src/input.rs +++ b/crates/forge_main/src/input.rs @@ -8,6 +8,12 @@ use crate::model::{ForgeCommandManager, SlashCommand}; use crate::prompt::ForgePrompt; use crate::tracker; +/// What the user did at the prompt — either a command or a hotkey action. +pub enum PromptResult { + Command(SlashCommand), + CycleAgent, +} + /// Console implementation for handling user input via command line. pub struct Console { command: Arc, @@ -27,18 +33,18 @@ impl Console { } impl Console { - pub async fn prompt(&self, prompt: ForgePrompt) -> anyhow::Result { + pub async fn prompt(&self, prompt: ForgePrompt) -> anyhow::Result { loop { let mut forge_editor = self.editor.lock().unwrap(); let user_input = forge_editor.prompt(&prompt)?; drop(forge_editor); match user_input { - ReadResult::Continue => continue, - ReadResult::Exit => return Ok(SlashCommand::Exit), - ReadResult::Empty => continue, + ReadResult::Continue | ReadResult::Empty => continue, + ReadResult::Exit => return Ok(PromptResult::Command(SlashCommand::Exit)), + ReadResult::CycleAgent => return Ok(PromptResult::CycleAgent), ReadResult::Success(text) => { tracker::prompt(text.clone()); - return self.command.parse(&text); + return Ok(PromptResult::Command(self.command.parse(&text)?)); } } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 3d6b946bac..03530b4881 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -35,7 +35,7 @@ use crate::conversation_selector::ConversationSelector; use crate::display_constants::{CommandType, headers, markers, status}; use crate::editor::ReadLineError; use crate::info::Info; -use crate::input::Console; +use crate::input::{Console, PromptResult}; use crate::model::{ForgeCommandManager, SlashCommand}; use crate::porcelain::Porcelain; use crate::prompt::ForgePrompt; @@ -52,6 +52,34 @@ use crate::{TRACKER, banner, tracker}; // File-specific constants const MISSING_AGENT_TITLE: &str = ""; +/// RAII guard that suppresses terminal echo while alive. +/// Prevents keypresses during agent execution from disrupting spinner output. +#[cfg(unix)] +struct EchoGuard; + +#[cfg(unix)] +impl EchoGuard { + fn suppress() -> Self { + let _ = std::process::Command::new("stty") + .arg("-echo") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + Self + } +} + +#[cfg(unix)] +impl Drop for EchoGuard { + fn drop(&mut self) { + let _ = std::process::Command::new("stty") + .arg("echo") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } +} + /// Conversation dump format used by the /dump command #[derive(Debug, serde::Deserialize, serde::Serialize)] struct ConversationDump { @@ -208,6 +236,21 @@ impl A + Send + Sync> UI { Ok(()) } + async fn on_cycle_agent(&mut self) -> Result<()> { + let mut agents = self.api.get_agents().await?; + agents.retain(|a| a.id != AgentId::SAGE); + agents.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); + + if agents.is_empty() { + return Ok(()); + } + + let current = self.api.get_active_agent().await.unwrap_or_default(); + let idx = agents.iter().position(|a| a.id == current).unwrap_or(0); + let next = (idx + 1) % agents.len(); + self.on_agent_change(agents[next].id.clone()).await + } + pub fn init(cli: Cli, f: F) -> Result { // Parse CLI arguments first to get flags let api = Arc::new(f()); @@ -228,26 +271,39 @@ impl A + Send + Sync> UI { }) } - async fn prompt(&self) -> Result { - // Get usage from current conversation if available - let usage = if let Some(conversation_id) = &self.state.conversation_id { - self.api - .conversation(conversation_id) - .await - .ok() - .flatten() - .and_then(|conv| conv.accumulated_usage()) - } else { - None - }; + async fn prompt(&mut self) -> Result { + loop { + // Get usage from current conversation if available + let usage = if let Some(conversation_id) = &self.state.conversation_id { + self.api + .conversation(conversation_id) + .await + .ok() + .flatten() + .and_then(|conv| conv.accumulated_usage()) + } else { + None + }; - // Prompt the user for input - let agent_id = self.api.get_active_agent().await.unwrap_or_default(); - let model = self - .get_agent_model(self.api.get_active_agent().await) - .await; - let forge_prompt = ForgePrompt { cwd: self.state.cwd.clone(), usage, model, agent_id }; - self.console.prompt(forge_prompt).await + // Prompt the user for input + let agent_id = self.api.get_active_agent().await.unwrap_or_default(); + let model = self + .get_agent_model(self.api.get_active_agent().await) + .await; + let forge_prompt = ForgePrompt { + cwd: self.state.cwd.clone(), + usage, + model, + agent_id, + }; + + match self.console.prompt(forge_prompt).await? { + PromptResult::CycleAgent => { + self.on_cycle_agent().await?; + } + PromptResult::Command(command) => return Ok(command), + } + } } pub async fn run(&mut self) { @@ -314,6 +370,12 @@ impl A + Send + Sync> UI { loop { match command { Ok(command) => { + // Suppress terminal echo so keypresses during execution + // (like Shift+Tab) don't disrupt the spinner output. + // Echo is restored when the guard is dropped. + #[cfg(unix)] + let _echo_guard = EchoGuard::suppress(); + tokio::select! { _ = tokio::signal::ctrl_c() => { self.spinner.reset(); @@ -336,6 +398,7 @@ impl A + Send + Sync> UI { } } + drop(_echo_guard); self.spinner.stop(None)?; } Err(error) => { diff --git a/shell-plugin/lib/bindings.zsh b/shell-plugin/lib/bindings.zsh index 5100f3fb5b..13463205da 100644 --- a/shell-plugin/lib/bindings.zsh +++ b/shell-plugin/lib/bindings.zsh @@ -29,3 +29,6 @@ bindkey '^M' forge-accept-line bindkey '^J' forge-accept-line # Update the Tab binding to use the new completion widget bindkey '^I' forge-completion # Tab for both @ and :command completion +# Bind Shift+Tab to cycle through agents +zle -N forge-cycle-agent +bindkey '\e[Z' forge-cycle-agent diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index d0ec5a6e85..6b7731cfc3 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -2,6 +2,38 @@ # Main command dispatcher and widget registration +# Cycle to the next agent on Shift+Tab +function forge-cycle-agent() { + local commands_list=$(_forge_get_commands) + if [[ -z "$commands_list" ]]; then + return 0 + fi + + # Extract agent names (column 1) where type (column 2) is AGENT, excluding sage + local -a agents + agents=($(echo "$commands_list" | awk '$2 == "AGENT" && $1 != "sage" {print $1}' | sort)) + + if [[ ${#agents[@]} -le 0 ]]; then + return 0 + fi + + local current="${_FORGE_ACTIVE_AGENT:-forge}" + local next_agent="${agents[1]}" + + local i + for i in {1..${#agents[@]}}; do + if [[ "${agents[$i]}" == "$current" ]]; then + local next_i=$(( (i % ${#agents[@]}) + 1 )) + next_agent="${agents[$next_i]}" + break + fi + done + + _FORGE_ACTIVE_AGENT="$next_agent" + _forge_log info "\033[1;37m${_FORGE_ACTIVE_AGENT:u}\033[0m \033[90mis now the active agent\033[0m" + zle reset-prompt +} + # Action handler: Set active agent or execute command # Flow: # 1. Check if user_action is a CUSTOM command -> execute with `cmd` subcommand