Skip to content

feat(ui): add Shift+Tab shortcut to cycle between agents#2869

Open
Lokimorty wants to merge 2 commits intoantinomyhq:mainfrom
Lokimorty:shift-tab-agent-cycle
Open

feat(ui): add Shift+Tab shortcut to cycle between agents#2869
Lokimorty wants to merge 2 commits intoantinomyhq:mainfrom
Lokimorty:shift-tab-agent-cycle

Conversation

@Lokimorty
Copy link
Copy Markdown

Summary

Add a Shift+Tab keyboard shortcut that cycles between Forge and Muse agents in both the interactive CLI and the zsh shell plugin, matching the visual feedback of the existing /forge, /muse and :forge, :muse commands.

Context

Switching between planning (Muse) and implementing (Forge) requires typing a command each time. When going back and forth frequently, a single keypress is much smoother. Sage is excluded from the cycle since it's an internal agent; any future agents are included automatically.

Changes

  • crates/forge_main/src/editor.rs: Bound KeyCode::BackTab to a Clear → InsertString(ZWSP) → Submit sequence so reedline properly commits the prompt line. The zero-width space marker is detected in From<Signal> and converted to ReadResult::CycleAgent, keeping the mechanism encapsulated in the editor layer. Added drain_stdin() using inline POSIX FFI (fcntl non-blocking read) to discard any bytes buffered during agent execution.
  • crates/forge_main/src/input.rs: Introduced PromptResult enum that separates hotkey actions (CycleAgent) from commands (Command(SlashCommand)), so the cycle action never touches the SlashCommand type.
  • crates/forge_main/src/ui.rs: Added EchoGuard (RAII stty -echo / stty echo) around command execution to suppress terminal echo during agent work, preventing keypresses from disrupting the spinner. The prompt() method handles PromptResult::CycleAgent in a loop, calling on_cycle_agent() which filters out Sage, sorts agents by ID, and delegates to the existing on_agent_change().
  • shell-plugin/lib/bindings.zsh: Registered forge-cycle-agent widget and bound \e[Z (Shift+Tab).
  • shell-plugin/lib/dispatcher.zsh: Implemented forge-cycle-agent — extracts AGENT-type commands from the cached command list, excludes sage, cycles to the next agent, and updates the prompt.

Key Implementation Details

  • The agent cycle uses the existing get_agents() API and excludes AgentId::SAGE at runtime, so new agents are automatically included without code changes.
  • Terminal echo is suppressed during command execution via stty -echo (restored on drop) to prevent Shift+Tab presses from corrupting the spinner output. Buffered stdin bytes are drained before each prompt via non-blocking reads.
  • Both KeyModifiers::SHIFT and KeyModifiers::NONE are bound for BackTab since terminals differ in how they report the modifier.

Testing

cargo test -p forge_main --lib

All 258 tests pass.

Manual verification:

  1. Run cargo run --release, press Shift+Tab — agent switches with info line (● [time] MUSE ∙ Generate detailed implementation plans)
  2. Press Shift+Tab during agent execution — no visual artifacts, no unintended cycling
  3. In zsh mode, press Shift+Tab — agent switches with status message, prompt updates
  4. Cycling order: forge → muse → forge (sage excluded)

Closes #2798

Add a Shift+Tab keyboard shortcut that cycles between Forge and Muse
agents in both the interactive CLI and zsh shell plugin, matching the
visual feedback of the existing /forge, /muse and :forge, :muse commands.

Sage is excluded from the cycle; future agents are included automatically.
@github-actions github-actions bot added the type: feature Brand new functionality, features, pages, workflows, endpoints, etc. label Apr 6, 2026
Comment on lines +55 to +78
/// 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();
}
}
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.

return;
}

unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) };
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 fcntl call to set O_NONBLOCK doesn't check for failure. If this fails (e.g., due to permissions), the subsequent read loop will block indefinitely, freezing the application.

Fix by checking the return value:

if unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) } < 0 {
    return;
}
Suggested change
unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) };
if unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) } < 0 {
return;
}

Spotted by Graphite

Fix in Graphite


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

@Lokimorty
Copy link
Copy Markdown
Author

fixed issues highlighted by the bot:

EchoGuard and its usage are now #[cfg(unix)] — no silent process spawning on Windows
fcntl(F_SETFL) return is checked — if it fails, we bail early instead of blocking on a still-blocking stdin

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature Brand new functionality, features, pages, workflows, endpoints, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add Shift+Tab keyboard shortcut to cycle between Forge and Muse agents

1 participant