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:
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user