Claurst currently has NO ! batch command feature. Users cannot run shell
commands directly from the input prompt without going through the model (which
consumes tokens, takes time, and adds the command to the conversation context).
The user wants: typing ! <command> in the prompt should execute the command
directly via the Bash tool, WITHOUT sending anything to the model. The output
should be displayed inline. Zero token consumption.
src-rust/crates/tui/src/app.rs(~line 6730):take_input()returns the raw input string. If it's a slash command (is_slash_command()), it's intercepted. Otherwise, the input goes toclaurst_query→ model turn.src-rust/crates/tui/src/input.rs(is_slash_command): returns true for/-prefixed input. There's no!-prefix handling.src-rust/crates/tui/src/app.rs(~line 5396):bash_command_allowed_by_prefix()handles the bash prefix allowlist for theBashtool permission system — this is about tool permission, not direct execution.src-rust/crates/tools/src/hasBashtool, but it's invoked by the model during a turn, not directly by user input.
In src-rust/crates/tui/src/app.rs (~line 6720, the Enter/submit handler):
let input = self.take_input();
if input.starts_with('!') {
let command = input[1..].trim();
if !command.is_empty() {
return Ok(Some(format!("__BANG__{}", command)));
}
continue;
}Or better: intercept before take_input() returns to the query path, similar
to how slash commands are intercepted:
// Check for bang command BEFORE sending to model
if self.prompt_input.text.starts_with('!') {
let command = self.prompt_input.text[1..].trim().to_string();
if !command.is_empty() {
self.clear_prompt();
self.execute_bang_command(command).await;
continue;
}
}The bash tool in claurst is PtyBashTool (tools/src/lib.rs:569), a stateful
PTY-based tool. Using it for ! commands would bypass the PTY state machine and
is unnecessarily complex for a one-shot command. Instead, use
std::process::Command (or tokio::process::Command for async) directly:
async fn execute_bang_command(&mut self, command: String) {
// Display the command as a user-style message in the transcript
// (display_messages only — NOT self.messages, so model never sees it)
let display_msg = DisplayMessage::bang_command(format!("$ {}", command));
self.display_messages.push(display_msg);
// Execute via tokio::process::Command — NO model round-trip, NO PTY
let result = tokio::process::Command::new("bash")
.arg("-c")
.arg(&command)
.current_dir(&self.working_dir)
.output()
.await;
match result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let display = if stderr.is_empty() {
stdout.to_string()
} else {
format!("{}\n--- stderr ---\n{}", stdout, stderr)
};
let exit_code = output.status.code().unwrap_or(-1);
let full = if exit_code != 0 {
format!("{}\n[exit: {}]", display, exit_code)
} else {
display.to_string()
};
self.display_messages.push(DisplayMessage::bang_output(full));
}
Err(e) => {
self.display_messages.push(DisplayMessage::bang_error(
format!("Error: {}", e),
));
}
}
// NO token consumption — no query, no model call
// NO context pollution — command + output in display_messages only
}New DisplayMessage variants: bang_command, bang_output, bang_error —
rendered via the existing render_bash_input_line() pattern. These are
display-only and never enter the conversation sent to the model.
The ! command still goes through the normal Bash permission system:
- In
defaultmode: prompt the user before execution (existing permission dialog) - In
acceptEdits/bypassPermissionsmode: auto-approve - The
bash_prefix_allowlistapplies — previously-allowed prefixes skip the prompt - In
planmode:!commands are BLOCKED. Plan mode restricts to read-only operations (verified:PermissionMode::Planinlib.rs). Shell execution is not read-only. The!prefix should show: "Bang commands disabled in plan mode." and not execute. This prevents accidental writes during analysis sessions.
Claurst's input is single-line: Enter submits, Shift+Enter inserts a newline
(verified at app.rs:7246 shift_enter_inserts_newline_not_submit). The !!
multi-line design must work within this model:
!followed by a single command → execute directly (one line, Enter submits)!!(double bang) → enters multi-line bang mode: the prompt shows!!>prefix. Each Enter inserts a newline (does NOT submit). User pressesCtrl+Dor types a blank line then Enter to execute the full multi-line script asbash -c "<script>".- Multi-line mode is exited after execution, back to normal prompt.
- Output of
!/!!commands is NOT sent to the model (truly independent)
enabled: toggle the feature (defaultfalse—!is a surprising prefix for users who don't expect it; opt-in is safer)addToHistory: add!commands to a separate shell-command history (not the prompt history), recalled viaAlt+Up/Alt+Down(avoids accidentally re-submitting a shell command as a prompt)showInTranscript: display command + output in chat transcript (vs. just showing a brief confirmation)
Use render_bash_input_line() from src-rust/crates/tui/src/messages/mod.rs:1280
(which already exists!) to render the command. Output rendered as a code block
system message.
| File | Change |
|---|---|
src-rust/crates/tui/src/app.rs |
! prefix interception in submit handler, execute_bang_command() |
src-rust/crates/tui/src/input.rs |
is_bang_command() helper (parallel to is_slash_command()) |
src-rust/crates/tui/src/messages/mod.rs |
New DisplayMessage variants for bang output |
src-rust/crates/core/src/lib.rs |
Settings.bang_commands config field |
src-rust/docs/commands.md |
Document ! command syntax |
The critical design point: ! commands must NOT enter the claurst_query
pipeline at all. They execute locally and display output, but the model never
sees them. This means:
- No
ProviderRequestis built - No
provider.create_message_stream()call - No tokens consumed
- Command + output not added to
self.messages(the conversation sent to model) - Only added to
display_messages(what the user sees)
- Existing slash commands (
/...) unaffected —!checked separately PtyBashTool(model-invoked Bash tool) still works normally during a turnbash_prefix_allowlistapplies to!commands tooplanmode blocks!commands entirely
- Unit test:
is_bang_command()true for!ls,!! script; false forls,/help,!alone (empty) - Unit test:
execute_bang_command()runsecho hello→ stdout "hello\n" displayed, exit code 0 - Unit test:
execute_bang_command()runsfalse→ stderr shown, exit code 1 displayed - Unit test: command not found → error message displayed, no panic
- Integration test:
!command output appears indisplay_messages, NOT inself.messages(verify model never sees it) - Integration test:
planmode →!command blocked, message shown - Integration test:
bypassPermissionsmode →!auto-executes, no dialog - Error paths: command spawn fails (e.g.
bashnot on PATH) → error displayed; command takes >30s → timeout, kill process, show partial output
- Command spawn fails (
bashnot found) →DisplayMessage::bang_error("Error: command not found: bash")in display_messages - Command timeout (default 30s, configurable) → kill child process, display partial output + "[timeout after 30s]"
- Non-zero exit code → output shown with
[exit: N]marker, no error dialog (it's not a claurst error, just a command result) - Stderr present → shown after stdout with
--- stderr ---separator
{ "bangCommands": { "enabled": false, "addToHistory": false, "showInTranscript": true } }