diff --git a/README.md b/README.md index df229b62..deb8d51f 100644 --- a/README.md +++ b/README.md @@ -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 ` copies diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 9483315a..8aebf4ae 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -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(®istry), + 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(®istry), + 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(®istry), + 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(); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 2de6e646..6470efa7 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -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"); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index ab61659e..4260cf0c 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -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>, }, + /// Execute a user-submitted composer shell 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, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 1093396f..54d94062 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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, &'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: ! "); + } + 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: ! ") + ); + assert_eq!( + shell_command_from_bang_input("! "), + Err("Usage: ! ") + ); + } + #[test] fn submit_input_records_absolute_slash_path_as_message_history() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3492be82..b8157015 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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 { + 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, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ed30ca18..3d7bf7f5 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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: ! ") + ); +} + +#[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() diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 3e7892ed..6782e4ee 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -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