feat(tui): add bang shell command shortcut

Support `! <command>` and `!command` in the TUI composer to run shell commands through the existing exec_shell path.

  The shortcut keeps normal approval, sandbox, policy, transcript, and work-panel handling, while avoiding model context
  pollution from local-only tool results.

  Refs #1546
This commit is contained in:
reidliu41
2026-06-02 08:28:12 +08:00
committed by Hunter Bown
parent f185d46917
commit c81cdabc09
8 changed files with 607 additions and 18 deletions
+4
View File
@@ -402,6 +402,10 @@ codewhale mcp-server # run dispatcher MCP stdio ser
codewhale update # check for and apply binary updates
```
Inside the interactive TUI composer, prefix a line with `!` to run a shell
command through the normal approval, sandbox, and output surfaces, for example
`! cargo test -p codewhale-tui sidebar`.
### Branching Conversations
Saved sessions are intentionally branchable. `codewhale fork <SESSION_ID>` copies
+259 -1
View File
@@ -68,7 +68,7 @@ use super::capacity_memory::{
};
use super::coherence::{CoherenceSignal, CoherenceState, next_coherence_state};
use super::events::{Event, TurnOutcomeStatus};
use super::ops::Op;
use super::ops::{Op, USER_SHELL_TOOL_ID_PREFIX};
use super::session::Session;
use super::tool_parser;
use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot};
@@ -634,6 +634,248 @@ impl Engine {
(engine, handle)
}
async fn handle_run_shell_command(
&mut self,
command: String,
mode: AppMode,
trust_mode: bool,
auto_approve: bool,
approval_mode: crate::tui::approval::ApprovalMode,
) {
self.reset_cancel_token();
self.turn_counter = self.turn_counter.saturating_add(1);
self.capacity_controller.mark_turn_start(self.turn_counter);
let turn_id = format!(
"{}{seq}",
USER_SHELL_TOOL_ID_PREFIX,
seq = self.turn_counter
);
let tool_id = turn_id.clone();
let tool_name = "exec_shell".to_string();
let tool_input = json!({ "command": command, "source": "user" });
let snapshot_prompt = tool_input["command"]
.as_str()
.unwrap_or_default()
.to_string();
self.session.trust_mode = trust_mode;
self.config.trust_mode = trust_mode;
self.session.auto_approve = auto_approve;
self.session.approval_mode = if auto_approve {
crate::tui::approval::ApprovalMode::Auto
} else {
approval_mode
};
let _ = self
.tx_event
.send(Event::TurnStarted {
turn_id: turn_id.clone(),
})
.await;
if self.config.snapshots_enabled {
let pre_workspace = self.session.workspace.clone();
let pre_seq = self.turn_counter;
let pre_cap = self.config.snapshots_max_workspace_bytes;
let pre_prompt = snapshot_prompt.clone();
let _ = tokio::task::spawn_blocking(move || {
pre_turn_snapshot(&pre_workspace, pre_seq, pre_cap, Some(&pre_prompt))
})
.await;
}
let _ = self
.tx_event
.send(Event::ToolCallStarted {
id: tool_id.clone(),
name: tool_name.clone(),
input: tool_input.clone(),
})
.await;
let tool_context = self.build_tool_context(mode, auto_approve);
let registry = ToolRegistryBuilder::new()
.with_shell_tools()
.build(tool_context);
let result = if mode == AppMode::Plan {
Err(ToolError::permission_denied(
"Tool 'exec_shell' is unavailable in Plan mode".to_string(),
))
} else if !self.config.features.enabled(Feature::ShellTool) {
Err(ToolError::not_available(
"Tool 'exec_shell' is disabled by feature flag".to_string(),
))
} else if let Some(spec) = registry.get(&tool_name) {
let approval_required = spec.approval_requirement() != ApprovalRequirement::Auto
&& !registry.context().auto_approve;
if approval_required {
emit_tool_audit(json!({
"event": "tool.approval_required",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"source": "composer_bang",
}));
let approval_key =
crate::tools::approval_cache::build_approval_key(&tool_name, &tool_input).0;
let approval_grouping_key =
crate::tools::approval_cache::build_approval_grouping_key(
&tool_name,
&tool_input,
)
.0;
let _ = self
.tx_event
.send(Event::ApprovalRequired {
id: tool_id.clone(),
tool_name: tool_name.clone(),
input: tool_input.clone(),
description: spec.description().to_string(),
approval_key,
approval_grouping_key,
intent_summary: None,
})
.await;
match self.await_tool_approval(&tool_id).await {
Ok(ApprovalResult::Approved) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "approved",
"source": "composer_bang",
}));
Self::execute_tool_with_lock(
self.tool_exec_lock.clone(),
spec.supports_parallel(),
false,
self.tx_event.clone(),
tool_name.clone(),
tool_input.clone(),
Some(&registry),
None,
None,
)
.await
}
Ok(ApprovalResult::Denied) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "denied",
"source": "composer_bang",
}));
Err(ToolError::permission_denied(format!(
"Tool '{tool_name}' denied by user"
)))
}
Ok(ApprovalResult::RetryWithPolicy(policy)) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "retry_with_policy",
"policy": format!("{policy:?}"),
"source": "composer_bang",
}));
let elevated_context = registry
.context()
.clone()
.with_elevated_sandbox_policy(policy);
Self::execute_tool_with_lock(
self.tool_exec_lock.clone(),
spec.supports_parallel(),
false,
self.tx_event.clone(),
tool_name.clone(),
tool_input.clone(),
Some(&registry),
None,
Some(elevated_context),
)
.await
}
Err(err) => Err(err),
}
} else {
Self::execute_tool_with_lock(
self.tool_exec_lock.clone(),
spec.supports_parallel(),
false,
self.tx_event.clone(),
tool_name.clone(),
tool_input.clone(),
Some(&registry),
None,
None,
)
.await
}
} else {
Err(ToolError::not_available(
"tool 'exec_shell' is not registered".to_string(),
))
};
let mut result = result;
if let Ok(tool_result) = result.as_mut()
&& let Some(path) = crate::tools::truncate::apply_spillover_with_artifact(
tool_result,
&tool_id,
&tool_name,
&self.session.id,
)
{
emit_tool_audit(json!({
"event": "tool.spillover",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"path": path.display().to_string(),
"source": "composer_bang",
}));
}
let status = if result.is_err() {
TurnOutcomeStatus::Failed
} else {
TurnOutcomeStatus::Completed
};
let error = result.as_ref().err().map(ToString::to_string);
let _ = self
.tx_event
.send(Event::ToolCallComplete {
id: tool_id,
name: tool_name,
result,
})
.await;
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: Usage::default(),
status,
error,
tool_catalog: None,
base_url: None,
})
.await;
if self.config.snapshots_enabled {
let post_workspace = self.session.workspace.clone();
let post_seq = self.turn_counter;
let post_cap = self.config.snapshots_max_workspace_bytes;
crate::utils::spawn_blocking_supervised("post-shell-turn-snapshot", move || {
post_turn_snapshot(&post_workspace, post_seq, post_cap, Some(&snapshot_prompt));
});
}
}
/// Run the engine event loop
#[allow(clippy::too_many_lines)]
pub async fn run(mut self) {
@@ -675,6 +917,22 @@ impl Engine {
)
.await;
}
Op::RunShellCommand {
command,
mode,
trust_mode,
auto_approve,
approval_mode,
} => {
self.handle_run_shell_command(
command,
mode,
trust_mode,
auto_approve,
approval_mode,
)
.await;
}
Op::CancelRequest => {
self.cancel_token.cancel();
self.reset_cancel_token();
+150
View File
@@ -978,6 +978,156 @@ fn deferred_tool_preflight_guides_checklist_update_list_replacement() {
assert!(result.content.contains("Use checklist_write"));
}
#[tokio::test]
async fn run_shell_command_op_requests_approval_and_executes_shell() {
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
let handle_for_approval = handle.clone();
let task = tokio::spawn(async move {
engine
.handle_run_shell_command(
"echo bang-ok".to_string(),
AppMode::Agent,
false,
false,
crate::tui::approval::ApprovalMode::Suggest,
)
.await;
});
let mut saw_started = false;
let mut saw_approval = false;
let mut saw_complete = false;
let mut saw_turn_complete = false;
let mut rx = handle.rx_event.write().await;
while let Some(event) = rx.recv().await {
match event {
Event::TurnStarted { turn_id } => {
assert!(turn_id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
}
Event::ToolCallStarted { id, name, input } => {
saw_started = true;
assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
assert_eq!(name, "exec_shell");
assert_eq!(input["command"], json!("echo bang-ok"));
assert_eq!(input["source"], json!("user"));
}
Event::ApprovalRequired { id, tool_name, .. } => {
saw_approval = true;
assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
assert_eq!(tool_name, "exec_shell");
handle_for_approval
.approve_tool_call(id)
.await
.expect("approve shell");
}
Event::ToolCallComplete { id, name, result } => {
saw_complete = true;
assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
assert_eq!(name, "exec_shell");
let result = result.expect("shell result");
assert!(result.success, "{result:?}");
assert!(result.content.contains("bang-ok"), "{result:?}");
}
Event::TurnComplete { status, .. } => {
saw_turn_complete = true;
assert_eq!(status, TurnOutcomeStatus::Completed);
break;
}
_ => {}
}
}
drop(rx);
task.await.expect("shell op task");
assert!(saw_started);
assert!(saw_approval);
assert!(saw_complete);
assert!(saw_turn_complete);
}
#[tokio::test]
async fn run_shell_command_op_skips_approval_when_auto_approved() {
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
engine
.handle_run_shell_command(
"echo bang-yolo".to_string(),
AppMode::Yolo,
true,
true,
crate::tui::approval::ApprovalMode::Auto,
)
.await;
let mut saw_complete = false;
let mut rx = handle.rx_event.write().await;
while let Some(event) = rx.recv().await {
match event {
Event::ApprovalRequired { .. } => {
panic!("auto-approved shell shortcut should not request approval");
}
Event::ToolCallComplete { result, .. } => {
saw_complete = true;
let result = result.expect("shell result");
assert!(result.success, "{result:?}");
assert!(result.content.contains("bang-yolo"), "{result:?}");
}
Event::TurnComplete { status, .. } => {
assert_eq!(status, TurnOutcomeStatus::Completed);
break;
}
_ => {}
}
}
assert!(saw_complete);
}
#[tokio::test]
async fn run_shell_command_op_preserves_plan_mode_shell_block() {
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
engine
.handle_run_shell_command(
"echo blocked".to_string(),
AppMode::Plan,
false,
false,
crate::tui::approval::ApprovalMode::Suggest,
)
.await;
let mut saw_complete = false;
let mut saw_turn_complete = false;
let mut rx = handle.rx_event.write().await;
while let Some(event) = rx.recv().await {
match event {
Event::ApprovalRequired { .. } => {
panic!("Plan mode shell should be blocked before approval");
}
Event::ToolCallComplete { name, result, .. } => {
saw_complete = true;
assert_eq!(name, "exec_shell");
let err = result.expect_err("plan shell should fail");
assert!(
err.to_string().contains("unavailable in Plan mode"),
"{err}"
);
}
Event::TurnComplete { status, .. } => {
saw_turn_complete = true;
assert_eq!(status, TurnOutcomeStatus::Failed);
break;
}
_ => {}
}
}
assert!(saw_complete);
assert!(saw_turn_complete);
}
#[test]
fn deferred_tool_preflight_skips_already_active_tools() {
let mut tool = api_tool("deferred_tool");
+14
View File
@@ -9,6 +9,9 @@ use crate::tui::app::AppMode;
use crate::tui::approval::ApprovalMode;
use std::path::PathBuf;
/// Prefix used for tool-call ids created by local composer shell shortcuts.
pub const USER_SHELL_TOOL_ID_PREFIX: &str = "user_shell_";
/// Operations that can be submitted to the engine.
#[derive(Debug, Clone)]
pub enum Op {
@@ -40,6 +43,17 @@ pub enum Op {
hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
},
/// Execute a user-submitted composer shell command (`! <command>`) without
/// sending a model turn. This still routes through `exec_shell`, approval,
/// sandbox, and command-safety handling.
RunShellCommand {
command: String,
mode: AppMode,
trust_mode: bool,
auto_approve: bool,
approval_mode: ApprovalMode,
},
/// Cancel the current request
#[allow(dead_code)]
CancelRequest,
+34
View File
@@ -97,6 +97,17 @@ pub(crate) fn looks_like_slash_command_input(input: &str) -> bool {
!command.contains('/')
}
pub(crate) fn shell_command_from_bang_input(input: &str) -> Result<Option<&str>, &'static str> {
let Some(rest) = input.trim_start().strip_prefix('!') else {
return Ok(None);
};
let command = rest.trim();
if command.is_empty() {
return Err("Usage: ! <shell command>");
}
Ok(Some(command))
}
fn initial_onboarding_state(
skip_onboarding: bool,
was_onboarded: bool,
@@ -5126,6 +5137,29 @@ mod tests {
));
}
#[test]
fn bang_shell_prefix_parses_compact_and_spaced_forms() {
assert_eq!(shell_command_from_bang_input("!pwd"), Ok(Some("pwd")));
assert_eq!(shell_command_from_bang_input("! pwd"), Ok(Some("pwd")));
assert_eq!(
shell_command_from_bang_input(" ! cargo test -p codewhale-tui sidebar"),
Ok(Some("cargo test -p codewhale-tui sidebar"))
);
assert_eq!(shell_command_from_bang_input("normal message"), Ok(None));
}
#[test]
fn bang_shell_prefix_rejects_empty_command() {
assert_eq!(
shell_command_from_bang_input("!"),
Err("Usage: ! <shell command>")
);
assert_eq!(
shell_command_from_bang_input("! "),
Err("Usage: ! <shell command>")
);
}
#[test]
fn submit_input_records_absolute_slash_path_as_message_history() {
let mut app = App::new(test_options(false), &Config::default());
+63 -17
View File
@@ -48,7 +48,7 @@ use crate::config::{
use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
use crate::core::ops::Op;
use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX};
use crate::hooks::{HookEvent, HookExecutor};
use crate::llm_client::LlmClient;
use crate::models::{
@@ -115,7 +115,7 @@ use super::key_actions;
use super::app::{
App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus,
StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions,
looks_like_slash_command_input,
looks_like_slash_command_input, shell_command_from_bang_input,
};
use super::approval::{
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
@@ -1423,21 +1423,27 @@ async fn run_event_loop(
if name == "update_plan" {
app.plan_tool_used_in_turn = true;
}
let tool_content = match &result {
Ok(output) => sanitize_stream_chunk(
&tool_result_content_for_api_message(app, &id, &name, output).await,
),
Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
};
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: tool_content,
is_error: None,
content_blocks: None,
}],
});
if is_model_visible_tool_call(&id) {
let tool_content = match &result {
Ok(output) => sanitize_stream_chunk(
&tool_result_content_for_api_message(app, &id, &name, output)
.await,
),
Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
};
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: tool_content,
is_error: None,
content_blocks: None,
}],
});
} else {
app.pending_tool_uses
.retain(|(tool_id, _, _)| tool_id != &id);
}
handle_tool_call_complete(app, &id, &name, &result);
// Immediately refresh the task panel sidebar when a
@@ -3514,6 +3520,9 @@ async fn run_event_loop(
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
if let Some(input) = app.submit_input() {
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
}
if looks_like_slash_command_input(&input) {
if execute_command_input(
terminal,
@@ -3563,6 +3572,9 @@ async fn run_event_loop(
// #382: Ctrl+Enter forces a steer into the current turn.
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(input) = app.submit_input() {
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
}
if looks_like_slash_command_input(&input) {
if execute_command_input(
terminal,
@@ -3639,6 +3651,9 @@ async fn run_event_loop(
handle_memory_quick_add(app, &input, config);
continue;
}
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
}
if looks_like_slash_command_input(&input) {
if execute_command_input(
terminal,
@@ -4767,6 +4782,37 @@ async fn apply_mode_update(app: &mut App, engine_handle: &EngineHandle, mode: Ap
}
}
async fn handle_bang_shell_input(
app: &mut App,
engine_handle: &EngineHandle,
input: &str,
) -> Result<bool> {
let command = match shell_command_from_bang_input(input) {
Ok(Some(command)) => command,
Ok(None) => return Ok(false),
Err(message) => {
app.status_message = Some(format!("Error: {message}"));
return Ok(true);
}
};
engine_handle
.send(Op::RunShellCommand {
command: command.to_string(),
mode: app.mode,
trust_mode: app.trust_mode,
auto_approve: app.mode == AppMode::Yolo,
approval_mode: app.approval_mode,
})
.await?;
app.status_message = Some(format!("Shell command submitted: {command}"));
Ok(true)
}
fn is_model_visible_tool_call(id: &str) -> bool {
!id.starts_with(USER_SHELL_TOOL_ID_PREFIX)
}
async fn apply_model_and_compaction_update(
engine_handle: &EngineHandle,
compaction: crate::compaction::CompactionConfig,
+82
View File
@@ -2930,6 +2930,88 @@ fn event_poll_timeout_has_nonzero_floor() {
);
}
#[tokio::test]
async fn bang_shell_input_dispatches_shell_op_instead_of_model_message() {
let mut app = create_test_app();
app.mode = AppMode::Agent;
app.trust_mode = false;
let mut engine = mock_engine_handle();
let handled = handle_bang_shell_input(&mut app, &engine.handle, "! pwd")
.await
.expect("bang shell handler");
assert!(handled);
assert_eq!(
app.status_message.as_deref(),
Some("Shell command submitted: pwd")
);
let op = engine.rx_op.recv().await.expect("engine op");
match op {
Op::RunShellCommand {
command,
mode,
trust_mode,
auto_approve,
approval_mode,
} => {
assert_eq!(command, "pwd");
assert_eq!(mode, AppMode::Agent);
assert!(!trust_mode);
assert!(!auto_approve);
assert_eq!(approval_mode, ApprovalMode::Suggest);
}
other => panic!("expected RunShellCommand, got {other:?}"),
}
}
#[tokio::test]
async fn bang_shell_input_dispatches_even_while_turn_is_loading() {
let mut app = create_test_app();
app.mode = AppMode::Agent;
app.is_loading = true;
let mut engine = mock_engine_handle();
let handled = handle_bang_shell_input(&mut app, &engine.handle, "! echo steer-safe")
.await
.expect("bang shell handler");
assert!(handled);
let op = engine.rx_op.recv().await.expect("engine op");
match op {
Op::RunShellCommand { command, mode, .. } => {
assert_eq!(command, "echo steer-safe");
assert_eq!(mode, AppMode::Agent);
}
other => panic!("expected RunShellCommand, got {other:?}"),
}
}
#[tokio::test]
async fn empty_bang_shell_input_is_consumed_with_usage_error() {
let mut app = create_test_app();
let engine = mock_engine_handle();
let handled = handle_bang_shell_input(&mut app, &engine.handle, "! ")
.await
.expect("bang shell handler");
assert!(handled);
assert_eq!(
app.status_message.as_deref(),
Some("Error: Usage: ! <shell command>")
);
}
#[test]
fn local_bang_shell_tool_ids_are_not_model_visible() {
assert!(!is_model_visible_tool_call("user_shell_1"));
assert!(is_model_visible_tool_call("toolu_01abc"));
}
fn complete_release_json(tag: &str) -> serde_json::Value {
let assets = REQUIRED_RELEASE_ASSETS
.iter()
+1
View File
@@ -45,6 +45,7 @@ Editing the message you're about to send.
| `Alt-R` | Search prompt history (Alt-R to exit) |
| `Tab` | Slash-command / `@`-mention completion (popup-aware) |
| `Ctrl-O` | Open external editor for the composer draft when it has focus |
| `! command` | Run a shell command through normal approval, sandbox, and output surfaces |
### `@` mentions