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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user