Skip to content

Commit 31a20a2

Browse files
committed
[tui][core] ! commands passthrough to shell for execution
1 parent c6a52d6 commit 31a20a2

File tree

11 files changed

+378
-14
lines changed

11 files changed

+378
-14
lines changed

codex-rs/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ codex --sandbox danger-full-access
9191
```
9292

9393
The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`.
94+
95+
### Run a local command with `!`
96+
97+
Inside the TUI, prefix your input with `!` to run it locally in your shell instead of sending it to the model. For example:
98+
99+
```text
100+
!ls -la src
101+
```
102+
103+
- While running, Codex shows a “working” indicator with a spinner.
104+
- Press Ctrl-C to interrupt the running command (sends SIGINT on Unix).
105+
- Output is shown directly in the chat history with a sensible line cap (default 100 lines). You can change this via `tui.local_shell_max_lines` in `config.toml`. See [`config.md`](./config.md#tuilocal_shell_max_lines).
94106

95107
## Code Organization
96108

codex-rs/config.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,5 +560,7 @@ Options that are specific to the TUI.
560560

561561
```toml
562562
[tui]
563-
# More to come here
563+
# Maximum number of lines to show for locally executed `!` commands
564+
# in the TUI before summarizing with an ellipsis. Defaults to 100.
565+
local_shell_max_lines = 150
564566
```

codex-rs/core/src/codex.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ use crate::protocol::ExecCommandBeginEvent;
8686
use crate::protocol::ExecCommandEndEvent;
8787
use crate::protocol::FileChange;
8888
use crate::protocol::InputItem;
89+
use crate::protocol::LocalCommandBeginEvent;
90+
use crate::protocol::LocalCommandEndEvent;
8991
use crate::protocol::Op;
9092
use crate::protocol::PatchApplyBeginEvent;
9193
use crate::protocol::PatchApplyEndEvent;
@@ -280,6 +282,7 @@ pub(crate) struct Session {
280282
codex_linux_sandbox_exe: Option<PathBuf>,
281283
user_shell: shell::Shell,
282284
show_raw_agent_reasoning: bool,
285+
local_exec: crate::local_exec::LocalExecRuntime,
283286
}
284287

285288
/// The context needed for a single turn of the conversation.
@@ -533,6 +536,7 @@ impl Session {
533536
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
534537
user_shell: default_shell,
535538
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
539+
local_exec: crate::local_exec::LocalExecRuntime::new(),
536540
});
537541

538542
// record the initial user instructions and environment context,
@@ -1044,6 +1048,94 @@ async fn submission_loop(
10441048
match sub.op {
10451049
Op::Interrupt => {
10461050
sess.interrupt_task();
1051+
// Also interrupt any running local exec.
1052+
crate::local_exec::interrupt(&sess.local_exec);
1053+
}
1054+
Op::LocalExec { raw_cmd } => {
1055+
// Wrap into a default shell invocation (bash -lc)
1056+
let mut argv = vec!["bash".to_string(), "-lc".to_string(), raw_cmd];
1057+
// Allow shell translation (e.g., PowerShell, zsh profile) if applicable
1058+
if let Some(cmd) = sess
1059+
.user_shell
1060+
.format_default_shell_invocation(argv.clone())
1061+
{
1062+
argv = cmd;
1063+
}
1064+
1065+
// Emit LocalCommandBegin
1066+
let begin_event = Event {
1067+
id: sub.id.clone(),
1068+
msg: EventMsg::LocalCommandBegin(LocalCommandBeginEvent {
1069+
command: argv.clone(),
1070+
}),
1071+
};
1072+
let _ = sess.tx_event.send(begin_event).await;
1073+
1074+
// Spawn child process in background to keep submission loop responsive
1075+
let cwd_to_use = turn_context.cwd.clone();
1076+
let env = create_env(&turn_context.shell_environment_policy);
1077+
let program = argv.first().cloned().unwrap_or_default();
1078+
let args = argv.iter().skip(1).cloned().collect::<Vec<_>>();
1079+
let sess_for_local = sess.clone();
1080+
let tx_event = sess.tx_event.clone();
1081+
let sub_id = sub.id.clone();
1082+
tokio::spawn(async move {
1083+
let mut cmd = tokio::process::Command::new(&program);
1084+
cmd.args(&args)
1085+
.current_dir(&cwd_to_use)
1086+
.stdout(std::process::Stdio::piped())
1087+
.stderr(std::process::Stdio::piped());
1088+
1089+
crate::local_exec::configure_child(&mut cmd);
1090+
1091+
// Start from a clean environment to avoid inheriting unpredictable or
1092+
// sensitive variables from the parent process (PATH tweaks, proxies,
1093+
// credentials, locale, toolchain settings, etc.). We then repopulate
1094+
// only the approved set via `create_env(...)` for deterministic, safe
1095+
// command execution aligned with the session's policy.
1096+
cmd.env_clear();
1097+
for (k, v) in env.into_iter() {
1098+
cmd.env(k, v);
1099+
}
1100+
1101+
match cmd.spawn() {
1102+
Ok(child) => {
1103+
crate::local_exec::record_child(&sess_for_local.local_exec, child.id());
1104+
let out_res = child.wait_with_output().await;
1105+
crate::local_exec::clear(&sess_for_local.local_exec);
1106+
1107+
let (exit_code, stdout, stderr) = match out_res {
1108+
Ok(output) => (
1109+
output.status.code().unwrap_or(-1),
1110+
String::from_utf8_lossy(&output.stdout).to_string(),
1111+
String::from_utf8_lossy(&output.stderr).to_string(),
1112+
),
1113+
Err(e) => (1, String::new(), format!("failed to wait: {e}")),
1114+
};
1115+
let end_event = Event {
1116+
id: sub_id.clone(),
1117+
msg: EventMsg::LocalCommandEnd(LocalCommandEndEvent {
1118+
stdout,
1119+
stderr,
1120+
exit_code,
1121+
}),
1122+
};
1123+
let _ = tx_event.send(end_event).await;
1124+
}
1125+
Err(e) => {
1126+
crate::local_exec::clear(&sess_for_local.local_exec);
1127+
let end_event = Event {
1128+
id: sub_id.clone(),
1129+
msg: EventMsg::LocalCommandEnd(LocalCommandEndEvent {
1130+
stdout: String::new(),
1131+
stderr: format!("failed to spawn: {e}"),
1132+
exit_code: 1,
1133+
}),
1134+
};
1135+
let _ = tx_event.send(end_event).await;
1136+
}
1137+
}
1138+
});
10471139
}
10481140
Op::OverrideTurnContext {
10491141
cwd,

codex-rs/core/src/config_types.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ pub enum HistoryPersistence {
7676

7777
/// Collection of settings that are specific to the TUI.
7878
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
79-
pub struct Tui {}
79+
pub struct Tui {
80+
/// Maximum number of lines to display for the output of a locally executed
81+
/// shell command entered via a leading `!` in the composer. Output beyond
82+
/// this limit is summarized with an ellipsis in the UI. The full output is
83+
/// still retained in the transcript.
84+
#[serde(default)]
85+
pub local_shell_max_lines: Option<usize>,
86+
}
8087

8188
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
8289
pub struct SandboxWorkspaceWrite {

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub mod landlock;
2929
mod mcp_connection_manager;
3030
mod mcp_tool_call;
3131
mod message_history;
32+
mod local_exec;
3233
mod model_provider_info;
3334
pub mod parse_command;
3435
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;

codex-rs/core/src/local_exec.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
use std::sync::Mutex;
2+
3+
#[cfg(unix)]
4+
pub(crate) struct LocalExecRuntime {
5+
pgid: Mutex<Option<i32>>,
6+
}
7+
8+
#[cfg(not(unix))]
9+
pub(crate) struct LocalExecRuntime {
10+
running: Mutex<bool>,
11+
}
12+
13+
impl LocalExecRuntime {
14+
pub(crate) fn new() -> Self {
15+
#[cfg(unix)]
16+
{
17+
Self {
18+
pgid: Mutex::new(None),
19+
}
20+
}
21+
#[cfg(not(unix))]
22+
{
23+
Self {
24+
running: Mutex::new(false),
25+
}
26+
}
27+
}
28+
}
29+
30+
/// Configure child process before exec: on Unix, create a new process group so
31+
/// we can signal the entire tree later. No-op on other platforms.
32+
pub(crate) fn configure_child(cmd: &mut tokio::process::Command) {
33+
#[cfg(unix)]
34+
unsafe {
35+
cmd.pre_exec(|| {
36+
libc::setpgid(0, 0);
37+
Ok(())
38+
});
39+
}
40+
}
41+
42+
/// Record the spawned child so future interrupts can target it.
43+
pub(crate) fn record_child(runtime: &LocalExecRuntime, pid_opt: Option<u32>) {
44+
#[cfg(unix)]
45+
{
46+
if let Some(pid_u32) = pid_opt {
47+
let pid = pid_u32 as i32;
48+
// If getpgid fails, fall back to pid.
49+
let pgid = unsafe { libc::getpgid(pid) };
50+
let value = if pgid > 0 { pgid } else { pid };
51+
if let Ok(mut guard) = runtime.pgid.lock() {
52+
*guard = Some(value);
53+
}
54+
}
55+
}
56+
#[cfg(not(unix))]
57+
{
58+
if let Ok(mut guard) = runtime.running.lock() {
59+
*guard = true;
60+
}
61+
}
62+
}
63+
64+
/// Clear any recorded child state after it exits or upon spawn failure.
65+
pub(crate) fn clear(runtime: &LocalExecRuntime) {
66+
#[cfg(unix)]
67+
{
68+
if let Ok(mut guard) = runtime.pgid.lock() {
69+
*guard = None;
70+
}
71+
}
72+
#[cfg(not(unix))]
73+
{
74+
if let Ok(mut guard) = runtime.running.lock() {
75+
*guard = false;
76+
}
77+
}
78+
}
79+
80+
/// Attempt to interrupt a recorded child process tree.
81+
pub(crate) fn interrupt(runtime: &LocalExecRuntime) {
82+
#[cfg(unix)]
83+
{
84+
if let Ok(mut guard) = runtime.pgid.lock()
85+
&& let Some(pgid) = guard.take()
86+
{
87+
unsafe {
88+
let _ = libc::kill(-pgid, libc::SIGINT);
89+
}
90+
}
91+
}
92+
#[cfg(not(unix))]
93+
{
94+
if let Ok(mut guard) = runtime.running.lock() {
95+
*guard = false;
96+
}
97+
}
98+
}

codex-rs/exec/src/event_processor_with_human_output.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use codex_core::protocol::EventMsg;
1414
use codex_core::protocol::ExecCommandBeginEvent;
1515
use codex_core::protocol::ExecCommandEndEvent;
1616
use codex_core::protocol::FileChange;
17+
use codex_core::protocol::LocalCommandEndEvent;
1718
use codex_core::protocol::McpInvocation;
1819
use codex_core::protocol::McpToolCallBeginEvent;
1920
use codex_core::protocol::McpToolCallEndEvent;
@@ -269,7 +270,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
269270
call_id,
270271
command,
271272
cwd,
272-
parsed_cmd: _,
273+
..
273274
}) => {
274275
self.call_id_to_command.insert(
275276
call_id.clone(),
@@ -321,6 +322,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
321322
}
322323
println!("{}", truncated_output.style(self.dimmed));
323324
}
325+
EventMsg::LocalCommandBegin(_) => {
326+
// no-op; print only at end
327+
}
328+
EventMsg::LocalCommandEnd(LocalCommandEndEvent {
329+
stdout,
330+
stderr,
331+
exit_code,
332+
}) => match exit_code {
333+
0 => {
334+
for line in stdout.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
335+
println!("{}", line.style(self.dimmed));
336+
}
337+
}
338+
_ => {
339+
for line in stderr.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
340+
println!("{}", line.style(self.dimmed));
341+
}
342+
}
343+
},
324344
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
325345
call_id: _,
326346
invocation,

codex-rs/mcp-server/src/codex_tool_runner.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ async fn run_codex_tool_session_inner(
267267
| EventMsg::ExecCommandBegin(_)
268268
| EventMsg::ExecCommandOutputDelta(_)
269269
| EventMsg::ExecCommandEnd(_)
270+
| EventMsg::LocalCommandBegin(_)
271+
| EventMsg::LocalCommandEnd(_)
270272
| EventMsg::BackgroundEvent(_)
271273
| EventMsg::StreamError(_)
272274
| EventMsg::PatchApplyBegin(_)

codex-rs/protocol/src/protocol.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ pub enum Op {
4444
/// Abort current task.
4545
/// This server sends [`EventMsg::TurnAborted`] in response.
4646
Interrupt,
47+
/// User-initiated local command execution (non-model). Runs a shell
48+
/// command immediately without involving the model.
49+
LocalExec {
50+
/// Raw command following a "!". Only whitespace is stripped.
51+
raw_cmd: String,
52+
},
4753

4854
/// Input from the user
4955
UserInput {
@@ -447,6 +453,12 @@ pub enum EventMsg {
447453

448454
ExecCommandEnd(ExecCommandEndEvent),
449455

456+
/// Local user-initiated command (bang command) begin.
457+
LocalCommandBegin(LocalCommandBeginEvent),
458+
459+
/// Local user-initiated command (bang command) end.
460+
LocalCommandEnd(LocalCommandEndEvent),
461+
450462
ExecApprovalRequest(ExecApprovalRequestEvent),
451463

452464
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
@@ -704,6 +716,22 @@ pub struct ExecCommandEndEvent {
704716
pub formatted_output: String,
705717
}
706718

719+
#[derive(Debug, Clone, Deserialize, Serialize)]
720+
pub struct LocalCommandBeginEvent {
721+
/// The command to be executed (post shell-translation), for transparency.
722+
pub command: Vec<String>,
723+
}
724+
725+
#[derive(Debug, Clone, Deserialize, Serialize)]
726+
pub struct LocalCommandEndEvent {
727+
/// Captured stdout
728+
pub stdout: String,
729+
/// Captured stderr
730+
pub stderr: String,
731+
/// The command's exit code.
732+
pub exit_code: i32,
733+
}
734+
707735
#[derive(Debug, Clone, Deserialize, Serialize)]
708736
#[serde(rename_all = "snake_case")]
709737
pub enum ExecOutputStream {

0 commit comments

Comments
 (0)