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 <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-06-10 16:49:30 -07:00
parent 502fb04c23
commit e1a61f445e
3 changed files with 11 additions and 193 deletions
+6 -6
View File
@@ -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 => {
+1 -20
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};
@@ -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());
+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 {