From 502fb04c231f8461b82098aafbcb62d696a5ba30 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 10 Jun 2026 15:59:40 -0700 Subject: [PATCH 1/4] fix(tui): make Ctrl+B directly background the active foreground shell (#3032) Previously Ctrl+B opened a two-step ShellControlView menu (Background / Cancel). Now it directly calls request_foreground_shell_background(), backgrounding the running foreground shell in one keystroke. When no foreground shell is running, the existing status message ("No foreground shell command to background") provides the hint. The ShellControlView and open_shell_control() remain available as a programmatic entry point for views/tests. --- crates/tui/src/tui/ui.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3481d43e..62df492b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3340,7 +3340,12 @@ async fn run_event_loop( && key.modifiers.contains(KeyModifiers::CONTROL) && app.view_stack.is_empty() { - open_shell_control(app); + // #3032: Ctrl+B directly backgrounds the active foreground + // shell command instead of opening a two-step shell-control + // menu. When nothing is backgroundable, the status message + // tells the user what's going on. + request_foreground_shell_background(app); + app.needs_redraw = true; continue; } From e1a61f445e5a71e0f41f35fd0f0d5da97de580c6 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 10 Jun 2026 16:49:30 -0700 Subject: [PATCH 2/4] fix(tui): remove ShellControlView menu now unreachable after direct Ctrl+B Ctrl+B backgrounds the foreground shell directly (#3032), leaving the two-step shell-control modal dead code that fails clippy -Dwarnings. Delete ShellControlView/ShellControlChoice, the ModalKind and ViewEvent variants, and open_shell_control; repoint the default-paste regression test at HelpView; update the Ctrl+B keybinding description in all locales to describe the new direct-background behavior. Co-Authored-By: Claude Fable 5 --- crates/tui/src/localization.rs | 12 +-- crates/tui/src/tui/ui.rs | 21 +--- crates/tui/src/tui/views/mod.rs | 171 +------------------------------- 3 files changed, 11 insertions(+), 193 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 3dfec9a9..97ffc950 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1373,7 +1373,7 @@ fn english(id: MessageId) -> &'static str { MessageId::KbSendDraft => "Send the current draft", MessageId::KbCloseMenu => "Close menu, cancel request, discard draft, or clear input", MessageId::KbCancelOrExit => "Cancel request, or exit when idle", - MessageId::KbShellControls => "Open shell controls for a running foreground command", + MessageId::KbShellControls => "Background the running foreground shell command", MessageId::KbExitEmpty => "Exit when input is empty", MessageId::KbCommandPalette => "Open the command palette", MessageId::KbFuzzyFilePicker => "Open the fuzzy file picker (insert @path on Enter)", @@ -1894,7 +1894,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::KbSendDraft => "Gửi bản nháp hiện tại", MessageId::KbCloseMenu => "Đóng menu, hủy yêu cầu, hủy bản nháp hoặc xóa sạch đầu vào", MessageId::KbCancelOrExit => "Hủy yêu cầu, hoặc thoát khi rảnh", - MessageId::KbShellControls => "Mở các điều khiển shell cho một lệnh đang chạy ở tiền cảnh", + MessageId::KbShellControls => "Chuyển lệnh shell đang chạy ở tiền cảnh xuống nền", MessageId::KbExitEmpty => "Thoát khi khung nhập trống", MessageId::KbCommandPalette => "Mở bảng lệnh (command palette)", MessageId::KbFuzzyFilePicker => { @@ -2495,7 +2495,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { "メニューを閉じる、リクエストをキャンセル、下書きを破棄、または入力をクリア" } MessageId::KbCancelOrExit => "リクエストをキャンセル、またはアイドル時に終了", - MessageId::KbShellControls => "実行中のフォアグラウンドコマンドのシェル制御を開く", + MessageId::KbShellControls => "実行中のフォアグラウンドコマンドをバックグラウンドへ移す", MessageId::KbExitEmpty => "入力が空の時に終了", MessageId::KbCommandPalette => "コマンドパレットを開く", MessageId::KbFuzzyFilePicker => "ファジーファイルピッカーを開く(Enter で @path を挿入)", @@ -2955,7 +2955,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::KbSendDraft => "发送当前草稿", MessageId::KbCloseMenu => "关闭菜单、取消请求、丢弃草稿或清空输入", MessageId::KbCancelOrExit => "取消请求,或空闲时退出", - MessageId::KbShellControls => "打开正在运行的前台命令的 shell 控制", + MessageId::KbShellControls => "将正在运行的前台命令转入后台", MessageId::KbExitEmpty => "输入框为空时退出", MessageId::KbCommandPalette => "打开命令面板", MessageId::KbFuzzyFilePicker => "打开模糊文件选择器(按 Enter 插入 @path)", @@ -3437,7 +3437,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Fechar menu, cancelar requisição, descartar rascunho ou limpar entrada" } MessageId::KbCancelOrExit => "Cancelar requisição ou sair quando ocioso", - MessageId::KbShellControls => "Abrir controles de shell para comando em primeiro plano", + MessageId::KbShellControls => "Enviar o comando em primeiro plano para segundo plano", MessageId::KbExitEmpty => "Sair quando entrada vazia", MessageId::KbCommandPalette => "Abrir paleta de comandos", MessageId::KbFuzzyFilePicker => { @@ -3967,7 +3967,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { "Cerrar menú, cancelar solicitud, descartar borrador o limpiar entrada" } MessageId::KbCancelOrExit => "Cancelar solicitud o salir cuando está inactivo", - MessageId::KbShellControls => "Abrir controles de shell para comando en primer plano", + MessageId::KbShellControls => "Enviar el comando en primer plano a segundo plano", MessageId::KbExitEmpty => "Salir cuando la entrada está vacía", MessageId::KbCommandPalette => "Abrir paleta de comandos", MessageId::KbFuzzyFilePicker => { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 62df492b..be17a2d9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -134,7 +134,7 @@ use super::slash_menu::{ apply_slash_menu_selection, partial_inline_skill_mention_at_cursor, try_autocomplete_slash_command, visible_slash_menu_entries, }; -use super::views::{ConfigView, HelpView, ModalKind, ShellControlView, ViewEvent}; +use super::views::{ConfigView, HelpView, ModalKind, ViewEvent}; use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview}; use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable}; @@ -7924,15 +7924,6 @@ async fn handle_view_events( ViewEvent::ContextMenuSelected { action } => { handle_context_menu_action(app, action); } - ViewEvent::ShellControlBackground => { - request_foreground_shell_background(app); - } - ViewEvent::ShellControlCancel => { - app.backtrack.reset(); - engine_handle.cancel(); - mark_active_turn_cancelled_locally(app); - app.status_message = Some("Request cancelled".to_string()); - } } } @@ -8779,16 +8770,6 @@ fn render_toast_stack_overlay( } } -pub(crate) fn open_shell_control(app: &mut App) { - if !app.is_loading || !active_foreground_shell_running(app) { - app.status_message = Some("No foreground shell command to control".to_string()); - return; - } - - app.view_stack.push(ShellControlView::new()); - app.status_message = Some("Shell control opened".to_string()); -} - pub(crate) fn request_foreground_shell_background(app: &mut App) { if !app.is_loading || !active_foreground_shell_running(app) { app.status_message = Some("No foreground shell command to background".to_string()); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d69ccf3f..ea8fafd5 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -38,7 +38,6 @@ pub enum ModalKind { FeedbackPicker, ThemePicker, ContextMenu, - ShellControl, } #[derive(Debug, Clone)] @@ -195,8 +194,6 @@ pub enum ViewEvent { ContextMenuSelected { action: ContextMenuAction, }, - ShellControlBackground, - ShellControlCancel, /// Emitted by the pager (`c` / `y`) to copy its body to the system /// clipboard. The host handler writes via `app.clipboard` and surfaces a /// status message — modal views cannot reach `app` directly. `label` is @@ -363,142 +360,6 @@ impl fmt::Debug for ViewStack { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShellControlChoice { - Background, - Cancel, -} - -impl ShellControlChoice { - fn event(self) -> ViewEvent { - match self { - ShellControlChoice::Background => ViewEvent::ShellControlBackground, - ShellControlChoice::Cancel => ViewEvent::ShellControlCancel, - } - } -} - -pub struct ShellControlView { - selected: ShellControlChoice, -} - -impl ShellControlView { - pub fn new() -> Self { - Self { - selected: ShellControlChoice::Background, - } - } - - fn toggle(&mut self) { - self.selected = match self.selected { - ShellControlChoice::Background => ShellControlChoice::Cancel, - ShellControlChoice::Cancel => ShellControlChoice::Background, - }; - } -} - -impl ModalView for ShellControlView { - fn kind(&self) -> ModalKind { - ModalKind::ShellControl - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - - fn handle_key(&mut self, key: KeyEvent) -> ViewAction { - match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => ViewAction::Close, - KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Tab => { - self.toggle(); - ViewAction::None - } - KeyCode::Char('b') | KeyCode::Char('B') => { - ViewAction::EmitAndClose(ViewEvent::ShellControlBackground) - } - KeyCode::Char('c') | KeyCode::Char('C') => { - ViewAction::EmitAndClose(ViewEvent::ShellControlCancel) - } - KeyCode::Enter => ViewAction::EmitAndClose(self.selected.event()), - _ => ViewAction::None, - } - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - use ratatui::{ - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, - }; - - let popup_width = 62.min(area.width.saturating_sub(4)); - let popup_height = 11.min(area.height.saturating_sub(2)); - - let popup_area = Rect { - x: (area.width - popup_width) / 2, - y: (area.height - popup_height) / 2, - width: popup_width, - height: popup_height, - }; - - Clear.render(popup_area, buf); - - let option_line = |choice: ShellControlChoice, key: &'static str, label: &'static str| { - let selected = self.selected == choice; - let style = if selected { - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - } else { - Style::default().fg(palette::TEXT_PRIMARY) - }; - Line::from(vec![ - Span::styled(if selected { "> " } else { " " }, style), - Span::styled(format!("{key:<3}"), style.bold()), - Span::styled(label, style), - ]) - }; - - let lines = vec![ - Line::from(Span::styled( - "Foreground shell command is still running.", - Style::default().fg(palette::TEXT_PRIMARY), - )), - Line::from(""), - option_line( - ShellControlChoice::Background, - "B", - "Background - detach and keep the command running", - ), - option_line( - ShellControlChoice::Cancel, - "C", - "Cancel - stop the command and interrupt this turn", - ), - ]; - - let view = Paragraph::new(lines) - .block( - Block::default() - .title(Line::from(vec![Span::styled( - " Shell command ", - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - )])) - .title_bottom(Line::from(Span::styled( - " Enter select | Esc close ", - Style::default().fg(palette::TEXT_MUTED), - ))) - .borders(Borders::ALL) - .border_style(Style::default().fg(palette::BORDER_COLOR)) - .style(Style::default().bg(palette::DEEPSEEK_INK)) - .padding(Padding::uniform(1)), - ) - .style(Style::default().fg(palette::TEXT_PRIMARY)); - - view.render(popup_area, buf); - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConfigScope { Session, @@ -2174,8 +2035,8 @@ fn truncate_view_text(text: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { use super::{ - ConfigListItem, ConfigSection, ConfigView, ModalKind, ModalView, ShellControlView, - ViewAction, ViewEvent, ViewStack, subagent_view_agents, truncate_view_text, + ConfigListItem, ConfigSection, ConfigView, HelpView, ModalKind, ModalView, ViewAction, + ViewEvent, ViewStack, subagent_view_agents, truncate_view_text, }; use crate::config::Config; use crate::localization::Locale; @@ -2811,30 +2672,6 @@ base_url = "https://api.xiaomimimo.com/v1" assert_eq!(view.status.as_deref(), Some("Edit cancelled")); } - #[test] - fn shell_control_view_defaults_to_background() { - let mut view = ShellControlView::new(); - - let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - action, - ViewAction::EmitAndClose(ViewEvent::ShellControlBackground) - )); - } - - #[test] - fn shell_control_view_can_select_cancel() { - let mut view = ShellControlView::new(); - - let action = view.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - - assert!(matches!( - action, - ViewAction::EmitAndClose(ViewEvent::ShellControlCancel) - )); - } - /// A modal that doesn't override `handle_paste` must report /// "not consumed" so the host can fall through to the composer. /// Regression: views/mod.rs previously inverted the boolean, swallowing @@ -2842,9 +2679,9 @@ base_url = "https://api.xiaomimimo.com/v1" #[test] fn default_modal_does_not_consume_paste() { let mut stack = ViewStack::new(); - stack.push(ShellControlView::new()); + stack.push(HelpView::new_for_locale(crate::localization::Locale::En)); assert!(!stack.handle_paste("hello")); - assert_eq!(stack.top_kind(), Some(ModalKind::ShellControl)); + assert_eq!(stack.top_kind(), Some(ModalKind::Help)); } fn buffer_text(buf: &Buffer, area: Rect) -> String { From 033132a7355e2825d5e42c66a09a82edc7d42f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 02:40:07 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(tui):=20#3032=20residuals=20=E2=80=94?= =?UTF-8?q?=20running-exec=20hint=20now=20says=20Ctrl+B=20backgrounds=20th?= =?UTF-8?q?e=20command;=20Ctrl+B=20documented=20in=20KEYBINDINGS.md=20and?= =?UTF-8?q?=20runbook=20updated=20for=20menu=20removal;=20Cannot-backgroun?= =?UTF-8?q?d=20message=20names=20the=20reason=20(interactive=20/=20non-she?= =?UTF-8?q?ll=20tool=20/=20nothing=20running)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5 --- crates/tui/src/tui/history.rs | 4 ++-- crates/tui/src/tui/ui.rs | 22 +++++++++++++++++++++- docs/KEYBINDINGS.md | 1 + docs/OPERATIONS_RUNBOOK.md | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 9ca7fb54..252e4288 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -957,7 +957,7 @@ impl ExecCell { )); } else if self.status == ToolStatus::Running && self.source == ExecSource::Assistant { lines.extend(wrap_plain_line( - " Ctrl+B opens shell controls.", + " Ctrl+B backgrounds this command.", Style::default().fg(palette::TEXT_MUTED), width, )); @@ -5075,7 +5075,7 @@ mod tests { assert!(text.contains("running line 1")); assert!(text.contains("running line 2")); - assert!(!text.contains("Ctrl+B opens shell controls")); + assert!(!text.contains("Ctrl+B backgrounds this command")); } #[test] diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index be17a2d9..ba25afb9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -8771,10 +8771,30 @@ fn render_toast_stack_overlay( } pub(crate) fn request_foreground_shell_background(app: &mut App) { - if !app.is_loading || !active_foreground_shell_running(app) { + if !app.is_loading { app.status_message = Some("No foreground shell command to background".to_string()); return; } + if !active_foreground_shell_running(app) { + // #3032 AC3: name the reason backgrounding is unavailable — + // interactive execs and non-shell blocking tools are visibly running + // but cannot be detached, and a generic shrug reads like a bug. + let reason = if terminal_pause_has_live_owner(app) { + "the running command is interactive" + } else if app + .active_cell + .as_ref() + .is_some_and(|active| !active.is_empty()) + { + "the running tool is not a foreground shell command" + } else { + "no foreground shell command is running" + }; + app.status_message = Some(format!( + "Cannot background: {reason}. Press Ctrl+C to cancel the turn, or wait for completion." + )); + return; + } let Some(shell_manager) = app.runtime_services.shell_manager.clone() else { app.status_message = Some("Shell manager is not attached".to_string()); diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 6782e4ee..e3710bc7 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -11,6 +11,7 @@ Bindings are not (yet) user-configurable — tracked for a future release (#436, | `F1` or `Ctrl-/` | Toggle the help overlay | | `Ctrl-K` | Open the command palette (slash-command finder) | | `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit | +| `Ctrl-B` | Background the running foreground shell command (turn continues; the command becomes a `/jobs` background job) | | `Ctrl-D` | Quit (only when the composer is empty) | | `Tab` | Cycle TUI mode: Plan → Agent → YOLO → Plan | | `Shift-Tab` | Cycle reasoning effort: off → high → max → off | diff --git a/docs/OPERATIONS_RUNBOOK.md b/docs/OPERATIONS_RUNBOOK.md index d53a8a45..8e98ac8a 100644 --- a/docs/OPERATIONS_RUNBOOK.md +++ b/docs/OPERATIONS_RUNBOOK.md @@ -28,7 +28,7 @@ Checks: 3. Confirm no local sandbox/permission deadlock in tool output Actions: -1. If a foreground shell command is running, press `Ctrl+B` and choose whether to background it or cancel the current turn. +1. If a foreground shell command is running, press `Ctrl+B` to move it to the background (the turn keeps running and the command becomes a background job under `/jobs`); use `Ctrl+C` instead if you want to cancel the turn. 2. If the command was started in the background, ask the assistant to cancel it with `exec_shell_cancel` and the returned task id. 3. Use `Esc` or `Ctrl+C` to interrupt the current turn when you want to stop the request itself. 4. Retry prompt; if still failing, restart TUI. From f55c54c4876560b9a3f07d5fe7be85768f9e4fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 04:46:55 +0000 Subject: [PATCH 4/4] ci: re-run after known Windows test flake (no code changes) Co-Authored-By: Claude https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5