Skip to content

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

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

feat(ui): add Shift+Tab shortcut to cycle between agents#2869
Lokimorty wants to merge 2 commits intotailcallhq: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

@github-actions
Copy link
Copy Markdown

Action required: PR inactive for 5 days.
Status update or closure in 10 days.

@github-actions github-actions bot added the state: inactive No current action needed/possible; issue fixed, out of scope, or superseded. label Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

state: inactive No current action needed/possible; issue fixed, out of scope, or superseded. 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