Merge PR #3038 from Hmbown: make Ctrl+B directly background the active foreground shell

fix(tui): make Ctrl+B directly background the active foreground shell
This commit is contained in:
Hunter Bown
2026-06-10 22:20:27 -07:00
committed by GitHub
6 changed files with 42 additions and 198 deletions
+6 -6
View File
@@ -1410,7 +1410,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)",
@@ -1962,7 +1962,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 => {
@@ -2619,7 +2619,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
"メニューを閉じる、リクエストをキャンセル、下書きを破棄、または入力をクリア"
}
MessageId::KbCancelOrExit => "リクエストをキャンセル、またはアイドル時に終了",
MessageId::KbShellControls => "実行中のフォアグラウンドコマンドのシェル制御を開く",
MessageId::KbShellControls => "実行中のフォアグラウンドコマンドをバックグラウンドへ移す",
MessageId::KbExitEmpty => "入力が空の時に終了",
MessageId::KbCommandPalette => "コマンドパレットを開く",
MessageId::KbFuzzyFilePicker => "ファジーファイルピッカーを開く(Enter で @path を挿入)",
@@ -3109,7 +3109,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",
@@ -3615,7 +3615,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 => {
@@ -4177,7 +4177,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 => {
+2 -2
View File
@@ -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,
));
@@ -5087,7 +5087,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]
+28 -22
View File
@@ -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};
@@ -3372,7 +3372,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;
}
@@ -7966,15 +7971,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());
}
}
}
@@ -8821,19 +8817,29 @@ 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());
pub(crate) fn request_foreground_shell_background(app: &mut App) {
if !app.is_loading {
app.status_message = Some("No foreground shell command to background".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());
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;
}
+4 -167
View File
@@ -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 {
+1
View File
@@ -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 |
+1 -1
View File
@@ -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.