Skip to content
Open
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
69 changes: 66 additions & 3 deletions crates/forge_main/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ 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,
}

pub enum ReadResult {
Success(String),
CycleAgent,
Empty,
Continue,
Exit,
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -100,8 +118,11 @@ impl ForgeEditor {
}

pub fn prompt(&mut self, prompt: &dyn Prompt) -> anyhow::Result<ReadResult> {
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)))
}
Expand All @@ -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);
Expand All @@ -122,7 +183,9 @@ impl From<Signal> 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())
Expand Down
16 changes: 11 additions & 5 deletions crates/forge_main/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ForgeCommandManager>,
Expand All @@ -27,18 +33,18 @@ impl Console {
}

impl Console {
pub async fn prompt(&self, prompt: ForgePrompt) -> anyhow::Result<SlashCommand> {
pub async fn prompt(&self, prompt: ForgePrompt) -> anyhow::Result<PromptResult> {
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)?));
}
}
}
Expand Down
103 changes: 83 additions & 20 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -52,6 +52,34 @@ use crate::{TRACKER, banner, tracker};
// File-specific constants
const MISSING_AGENT_TITLE: &str = "<missing agent.title>";

/// 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();
}
}
Comment on lines +55 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EchoGuard is not platform-guarded but uses Unix-specific stty command. On Windows, this will spawn a process that fails silently on every command execution, causing performance overhead.

Fix by adding platform guards:

#[cfg(unix)]
struct EchoGuard;

#[cfg(unix)]
impl EchoGuard {
    fn suppress() -> Self { /* ... */ }
}

#[cfg(unix)]
impl Drop for EchoGuard { /* ... */ }

#[cfg(not(unix))]
struct EchoGuard;

#[cfg(not(unix))]
impl EchoGuard {
    fn suppress() -> Self { Self }
}
Suggested change
/// RAII guard that suppresses terminal echo while alive.
/// Prevents keypresses during agent execution from disrupting spinner output.
struct EchoGuard;
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
}
}
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();
}
}
/// 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();
}
}
#[cfg(not(unix))]
struct EchoGuard;
#[cfg(not(unix))]
impl EchoGuard {
fn suppress() -> Self {
Self
}
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


/// Conversation dump format used by the /dump command
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ConversationDump {
Expand Down Expand Up @@ -208,6 +236,21 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
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<Self> {
// Parse CLI arguments first to get flags
let api = Arc::new(f());
Expand All @@ -228,26 +271,39 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
})
}

async fn prompt(&self) -> Result<SlashCommand> {
// 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<SlashCommand> {
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) {
Expand Down Expand Up @@ -314,6 +370,12 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
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();
Expand All @@ -336,6 +398,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
}
}

drop(_echo_guard);
self.spinner.stop(None)?;
}
Err(error) => {
Expand Down
3 changes: 3 additions & 0 deletions shell-plugin/lib/bindings.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions shell-plugin/lib/dispatcher.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading