feat(ui): add Shift+Tab shortcut to cycle between agents#2869
feat(ui): add Shift+Tab shortcut to cycle between agents#2869Lokimorty wants to merge 2 commits intoantinomyhq:mainfrom
Conversation
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.
| /// 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(); | ||
| } | ||
| } |
There was a problem hiding this comment.
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 }
}| /// 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
Is this helpful? React 👍 or 👎 to let us know.
crates/forge_main/src/editor.rs
Outdated
| return; | ||
| } | ||
|
|
||
| unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) }; |
There was a problem hiding this comment.
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;
}| unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) }; | |
| if unsafe { fcntl(fd, F_SETFL, flags | O_NONBLOCK) } < 0 { | |
| return; | |
| } |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
|
fixed issues highlighted by the bot: EchoGuard and its usage are now #[cfg(unix)] — no silent process spawning on Windows |
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,/museand:forge,:musecommands.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: BoundKeyCode::BackTabto aClear → InsertString(ZWSP) → Submitsequence so reedline properly commits the prompt line. The zero-width space marker is detected inFrom<Signal>and converted toReadResult::CycleAgent, keeping the mechanism encapsulated in the editor layer. Addeddrain_stdin()using inline POSIX FFI (fcntlnon-blocking read) to discard any bytes buffered during agent execution.crates/forge_main/src/input.rs: IntroducedPromptResultenum that separates hotkey actions (CycleAgent) from commands (Command(SlashCommand)), so the cycle action never touches theSlashCommandtype.crates/forge_main/src/ui.rs: AddedEchoGuard(RAIIstty -echo/stty echo) around command execution to suppress terminal echo during agent work, preventing keypresses from disrupting the spinner. Theprompt()method handlesPromptResult::CycleAgentin a loop, callingon_cycle_agent()which filters out Sage, sorts agents by ID, and delegates to the existingon_agent_change().shell-plugin/lib/bindings.zsh: Registeredforge-cycle-agentwidget and bound\e[Z(Shift+Tab).shell-plugin/lib/dispatcher.zsh: Implementedforge-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
get_agents()API and excludesAgentId::SAGEat runtime, so new agents are automatically included without code changes.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.KeyModifiers::SHIFTandKeyModifiers::NONEare bound forBackTabsince terminals differ in how they report the modifier.Testing
cargo test -p forge_main --libAll 258 tests pass.
Manual verification:
cargo run --release, press Shift+Tab — agent switches with info line (● [time] MUSE ∙ Generate detailed implementation plans)Closes #2798