Skip to content

feature: Add "!cmd" user shell execution#2471

Merged
abhishek-oai merged 9 commits intomainfrom
bang-command-support
Oct 29, 2025
Merged

feature: Add "!cmd" user shell execution#2471
abhishek-oai merged 9 commits intomainfrom
bang-command-support

Conversation

@abhishek-oai
Copy link
Copy Markdown
Contributor

@abhishek-oai abhishek-oai commented Aug 19, 2025

feature: Add "!cmd" user shell execution

This change lets users run local shell commands directly from the TUI by prefixing their input with ! (e.g. !ls). Output is truncated to keep the exec cell usable, and Ctrl-C cleanly
interrupts long-running commands (e.g. !sleep 10000).

Summary of changes

  • Route Op::RunUserShellCommand through a dedicated UserShellCommandTask (core/src/tasks/user_shell.rs), keeping the task logic out of codex.rs.
  • Reuse the existing tool router: the task constructs a ToolCall for the local_shell tool and relies on ShellHandler, so no manual MCP tool lookup is required.
  • Emit exec lifecycle events (ExecCommandBegin/ExecCommandEnd) so the TUI can show command metadata, live output, and exit status.

End-to-end flow

TUI handling

  1. ChatWidget::submit_user_message (TUI) intercepts messages starting with !.
  2. Non-empty commands dispatch Op::RunUserShellCommand { command }; empty commands surface a help hint.
  3. No UserInput items are created, so nothing is enqueued for the model.

Core submission loop
4. The submission loop routes the op to handlers::run_user_shell_command (core/src/codex.rs).
5. A fresh TurnContext is created and Session::spawn_user_shell_command enqueues UserShellCommandTask.

Task execution
6. UserShellCommandTask::run emits TaskStartedEvent, formats the command, and prepares a ToolCall targeting local_shell.
7. ToolCallRuntime::handle_tool_call dispatches to ShellHandler.

Shell tool runtime
8. ShellHandler::run_exec_like launches the process via the unified exec runtime, honoring sandbox and shell policies, and emits ExecCommandBegin/End.
9. Stdout/stderr are captured for the UI, but the task does not turn the resulting ToolOutput into a model response.

Completion
10. After ExecCommandEnd, the task finishes without an assistant message; the session marks it complete and the exec cell displays the final output.

Conversation context

  • The command and its output never enter the conversation history or the model prompt; the flow is local-only.
  • Only exec/task events are emitted for UI rendering.

Demo video

bang-command-demo-video-1080p.mp4

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Aug 19, 2025

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@abhishek-oai abhishek-oai requested a review from gpeal August 19, 2025 21:51
Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Here are some suggestions.

Reply with @codex fix comments to fix any unresolved comments.

About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you open a pull request that is ready for review, or mark a draft as ready for review. You can also ask for a review by commenting "@codex review".

Codex can also answer questions or update the PR. Try commenting "@codex fix this CI failure" or "@codex address that feedback".

Comment thread codex-rs/tui/src/chatwidget.rs
Comment thread codex-rs/tui/src/chatwidget.rs Outdated
@easong-openai
Copy link
Copy Markdown
Collaborator

We need to make sure cancellation works, particularly ctr-c. Right now it doesn't actually cancel the command.

If possible, we should show the command we just ran + any output and delineate it as a user command instead of using the shortening UI we do for the model.

Otherwise nice work!

@easong-openai
Copy link
Copy Markdown
Collaborator

This also doesn't support windows, which isn't necessarily a blocker but we might need to look at.

@abhishek-oai
Copy link
Copy Markdown
Contributor Author

We need to make sure cancellation works, particularly ctr-c. Right now it doesn't actually cancel the command.

If possible, we should show the command we just ran + any output and delineate it as a user command instead of using the shortening UI we do for the model.

Otherwise nice work!

Happy to send a patch with both! Wanted to get something out to get buy-in first. Thanks for the prompt feedback.

@SebastianSzturo
Copy link
Copy Markdown

This is great! Would be also fantastic to have a way to run background commands like with Claude Code and have the output continuously attached to any new messages.

Background commands: (Ctrl-b) to run any Bash command in the background so Claude can keep working (great for dev servers, tailing logs, etc.)

Here is also a good description of the feature: anomalyco/opencode#1970

@abhishek-oai abhishek-oai changed the title TUI: ! commands passthrough to shell for execution [tui][core] ! commands passthrough to shell for execution Aug 24, 2025
@abhishek-oai
Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

github-actions bot added a commit that referenced this pull request Aug 24, 2025
@abhishek-oai
Copy link
Copy Markdown
Contributor Author

We need to make sure cancellation works, particularly ctr-c. Right now it doesn't actually cancel the command.

If possible, we should show the command we just ran + any output and delineate it as a user command instead of using the shortening UI we do for the model.

Otherwise nice work!

Alright both those things should work.

Also, the Tui still remains like the "frontend" and forwards the request to the "core". This is in line with the existing archiecture.

@easong-openai easong-openai added the code-review Issues relating to code reviews performed by codex label Aug 25, 2025
@github-actions github-actions bot added codex-review-in-progress and removed code-review Issues relating to code reviews performed by codex labels Aug 25, 2025
@github-actions
Copy link
Copy Markdown
Contributor

Summary

  • Adds local “!” command execution with session events, env isolation, and TUI/CLI display. Updates docs and config to describe usage and line-capping.

Notes

  • codex-rs/README.md: New “Run a local command with !” section and usage tips.
  • codex-rs/config.md: Documents [tui] local_shell_max_lines (example 150; default described as 100).
  • codex-rs/core: Implements local exec runtime, process-group SIGINT on Unix, env derivation via policy, and LocalCommandBegin/End events.
  • codex-rs/tui: Wires local command begin/end into chat history; updates fixture.
  • codex-rs/exec,mcp-server: Handles/ignores new local-command events appropriately.

Review
Overall looks solid: process-group signaling, env hygiene, and event plumbing are well-done. A few small inconsistencies to address:

  • README link: “See config.md#tui.local_shell_max_lines” should point to config.md#tui (no anchor for the key).
  • Apply the documented line cap: local_shell_max_lines (core/src/config_types.rs, config.md) isn’t used; tui/src/history_cell.rs::new_local_command_output currently prints all lines. Consider using existing output_lines(...) with a default of 100 (configurable via Config.tui.local_shell_max_lines) and showing the “… +N lines” summary.
  • Non-Unix interrupt: core/src/local_exec.rs sets a flag but doesn’t actually signal/terminate the process. If acceptable for now, note the limitation; otherwise consider a Windows-friendly cancellation approach.

Follow-up (outside this PR scope)

  • Optionally align the CLI’s MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL with the TUI default or make it configurable for consistency.

View workflow run

@tibo-openai
Copy link
Copy Markdown
Collaborator

@codex review in depth

Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Here are some suggestions.

Reply with @codex fix comments to fix any unresolved comments.

About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you open a pull request for review, mark a draft as ready, or comment "@codex review". If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex fix this CI failure" or "@codex address that feedback".

Comment thread codex-rs/tui/src/history_cell.rs Outdated
Comment thread codex-rs/core/src/local_exec.rs Outdated
@easong-openai
Copy link
Copy Markdown
Collaborator

Seems worth addressing the codex review notes above ^

@abhishek-oai abhishek-oai enabled auto-merge (squash) August 25, 2025 23:19
@abhishek-oai
Copy link
Copy Markdown
Contributor Author

Seems worth addressing the codex review notes above ^

Fixed.

@abhishek-oai
Copy link
Copy Markdown
Contributor Author

@codex review in depth

Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Here are some suggestions.

Reply with @codex fix comments to fix any unresolved comments.

About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you open a pull request for review, mark a draft as ready, or comment "@codex review". If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex fix this CI failure" or "@codex address that feedback".

Comment thread codex-rs/core/src/local_exec.rs Outdated
@abhishek-oai abhishek-oai force-pushed the bang-command-support branch 2 times, most recently from 771ae55 to 8aaf9f0 Compare August 26, 2025 02:58
@abhishek-oai
Copy link
Copy Markdown
Contributor Author

Can you please add a video to the PR body?

Also done

Copy link
Copy Markdown
Collaborator

@bolinfest bolinfest left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is generally good, but there are some things I'd like you to look at!

Comment thread codex-rs/protocol/src/protocol.rs Outdated
assert_eq!(deserialized, event);
}

#[test]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can do without this test: it doesn't provide much in the way of additional code correctness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/protocol/src/protocol.rs Outdated
/// True when this exec was initiated directly by the user (e.g. bang command),
/// not by the agent/model. Defaults to false for backwards compatibility.
#[serde(default)]
pub user_initiated_shell_command: bool,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about: is_user_shell_command to mirror the name of the Op?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/tui/src/chatwidget.rs Outdated
struct RunningCommand {
command: Vec<String>,
parsed_cmd: Vec<ParsedCommand>,
user_initiated_shell_command: bool,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/tui/src/chatwidget.rs Outdated
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
if let Some(stripped) = text.strip_prefix('!') {
let cmd = stripped.trim().to_string();
if !cmd.is_empty() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if the user types ! by itself, we'll send the message "!" to the agent, correct?

I guess this is fine, though we could take this opportunity to tell the user about the ! feature instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/tui/src/history_cell.rs Outdated
body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));

if let Some(output) = call.output.as_ref() {
let is_shell_exec_cell = self.calls.iter().any(|c| c.user_initiated_shell_command);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we believe we could have a mix of exec calls in flight, only some of which are user initiated shell commands?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just check for that call itself now.

Comment thread codex-rs/core/src/codex.rs Outdated
sub_id: String,
command: String,
) {
let spawn_sub_id = sub_id.clone();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do this inside let handle, as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +16 to +18
unsafe {
std::env::set_var("CODEX_HERMETIC_TEST_SHELL", "1");
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please find another way to do this: this is unsafe for a reason.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

use std::path::PathBuf;
use tempfile::TempDir;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please omit worker_threads unless you have a reason to believe you need them.

Empirically, tests that only pass with more than one worker thread imply bugs in our code, so I would prefer to try to catch them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -0,0 +1,119 @@
#![cfg(unix)]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given these tests are limited to unix for now (which I would prefer not to do), do we really need CODEX_HERMETIC_TEST_SHELL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// 1) ls should list the file
codex
.submit(Op::RunUserShellCommand {
command: "ls".to_string(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I often use python3 -c as an example for cross-platform shell calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@rtl-ai
Copy link
Copy Markdown

rtl-ai commented Oct 7, 2025

Any update on the merge timeline for this PR?

Comment thread codex-rs/core/src/codex.rs Outdated
Comment thread codex-rs/core/src/codex.rs Outdated
Copy link
Copy Markdown
Contributor Author

@abhishek-oai abhishek-oai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing old comments that I had replied to

@@ -0,0 +1,119 @@
#![cfg(unix)]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

use std::path::PathBuf;
use tempfile::TempDir;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +16 to +18
unsafe {
std::env::set_var("CODEX_HERMETIC_TEST_SHELL", "1");
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/core/src/codex.rs Outdated
sub_id: String,
command: String,
) {
let spawn_sub_id = sub_id.clone();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/tui/src/chatwidget.rs Outdated
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
if let Some(stripped) = text.strip_prefix('!') {
let cmd = stripped.trim().to_string();
if !cmd.is_empty() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/tui/src/history_cell.rs Outdated
body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));

if let Some(output) = call.output.as_ref() {
let is_shell_exec_cell = self.calls.iter().any(|c| c.user_initiated_shell_command);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just check for that call itself now.

Comment thread codex-rs/core/src/codex.rs Outdated
Op::RunUserShellCommand { command } => {
spawn_user_shell_command_task(
sess.clone(),
Arc::clone(&turn_context),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/core/src/codex.rs Outdated
) {
let spawn_sub_id = sub_id.clone();
let handle = {
let sess = Arc::clone(&sess);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

command,
cwd,
parsed_cmd: _,
..
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Copy Markdown
Collaborator

@jif-oai jif-oai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After my comments, lgtm

Comment thread codex-rs/core/src/tasks/user_shell.rs Outdated
turn: Arc<TurnContext>,
tracker: crate::tools::context::SharedTurnDiffTracker,
call_id: String,
is_user_shell_command: bool,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a huge fan of this being added everywhere... this just create some low-value wiring everywhere
Out of scope for this PR but can you add a todo to get rid of it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/core/src/codex.rs Outdated
Comment thread codex-rs/core/src/tasks/user_shell.rs Outdated
});
session.send_event(turn_context.as_ref(), event).await;

let shell_invocation = session
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need this? The use should just answer a valid command for his shell. This should not be our responsibility to format it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread codex-rs/core/src/tasks/user_shell.rs Outdated

let shell_invocation = session
.user_shell()
.format_user_shell_script(&command)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • this just got dropped because it was actually useless. And I don't think this PR justify adding this back

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Bash(BashShell),
PowerShell(PowerShellConfig),
Unknown,
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we should revert all this file (per my previous comments)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@jif-oai jif-oai disabled auto-merge October 28, 2025 17:05
@jif-oai jif-oai dismissed bolinfest’s stale review October 28, 2025 17:06

Everything updated

- This feature lets users execute a command using their shell when they type "!cmd" in the TUI for e.g. "!ls"
- It takes care to bound the output line limit to not flood the TUI
- Being able to signal (Ctrl-C) commands spawned. For e.g. "!sleep 10000" killed via SIGINT (Ctrl-C)
- E2E flow of the feature is detailed below

1. The composer hands the submitted text to `ChatWidget::submit_user_message` (`codex-rs/tui/src/chatwidget.rs`).
2. If the message starts with `!`, the prefix is stripped. A non-empty command triggers `ChatWidget::submit_op(Op::RunUserShellCommand { command })`, while empty input shows a help hint instead of contacting the model.
3. Because no `UserInput` items are emitted, the command never joins the conversation payload that the model sees.

4. The submission loop in `codex-rs/core/src/codex.rs` routes the op to `handlers::run_user_shell_command`.
5. The handler creates a new `TurnContext` (with default per-turn settings) and calls `Session::spawn_user_shell_command`, which enqueues a `UserShellCommandTask` without any model-facing input.

6. `UserShellCommandTask::run` (defined in `codex-rs/core/src/tasks/user_shell.rs`) emits a `TaskStartedEvent`, formats the command using the session’s shell wrapper, and creates a `ToolCall` for the built-in `local_shell` tool.
7. `ToolCallRuntime::handle_tool_call` dispatches this call to the `ShellHandler` implementation in `codex-rs/core/src/tools/handlers/shell.rs`.

8. `ShellHandler::run_exec_like` launches the command through the unified exec runtime, honoring sandbox policy and shell environment settings. It uses `ToolEmitter::shell` to publish `ExecCommandBegin` and `ExecCommandEnd` events so the TUI can render the command, streaming output, exit code, and duration.
9. The command output is captured locally for the UI; the handler returns a `ToolOutput::Function`, but the owning task does not turn that into a follow-up assistant message.

- Neither the command nor its stdout/stderr are appended to the conversation history supplied to the model. The bang command is purely a local exec flow; the model receives no additional context from it.
- The only protocol traffic is the pair of exec events and task lifecycle notifications needed by the UI.

10. After `ExecCommandEnd` fires, `UserShellCommandTask` finishes with `None` as the final agent message. The session marks the task complete, and the UI shows the finished exec cell result.

In short: `!cmd` spins up a local shell execution pipeline, streams the output to the user interface, and keeps the model prompt untouched.
Copy link
Copy Markdown
Contributor Author

@abhishek-oai abhishek-oai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 2 comments

Comment thread codex-rs/core/src/tasks/user_shell.rs Outdated
Comment thread codex-rs/core/src/codex.rs Outdated
@abhishek-oai abhishek-oai merged commit 89591e4 into main Oct 29, 2025
25 checks passed
@abhishek-oai abhishek-oai deleted the bang-command-support branch October 29, 2025 07:31
@github-actions github-actions bot locked and limited conversation to collaborators Oct 29, 2025
@abhishek-oai
Copy link
Copy Markdown
Contributor Author

Any update on the merge timeline for this PR?

Merged!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants