Skip to content

Commit fec03bc

Browse files
committed
[tui][core] ! commands passthrough to shell for execution
1 parent 568d6f8 commit fec03bc

File tree

13 files changed

+559
-10
lines changed

13 files changed

+559
-10
lines changed

codex-rs/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ 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. This feature is supported on Unix platforms (macOS, Linux). On other platforms, input beginning with `!` is treated as a normal message. For example:
98+
99+
```text
100+
!ls -la src
101+
```
102+
103+
- Press Ctrl-C to interrupt the running command (sends SIGINT on Unix).
104+
- Output is shown directly in the chat history with a sensible line cap (default 150 lines). You can change this via `tui.local_shell_max_lines` in `config.toml`. See [`config.md`](./config.md#tui).
94105

95106
## Code Organization
96107

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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ use crate::protocol::ExecCommandBeginEvent;
8787
use crate::protocol::ExecCommandEndEvent;
8888
use crate::protocol::FileChange;
8989
use crate::protocol::InputItem;
90+
use crate::protocol::LocalCommandBeginEvent;
91+
use crate::protocol::LocalCommandEndEvent;
9092
use crate::protocol::Op;
9193
use crate::protocol::PatchApplyBeginEvent;
9294
use crate::protocol::PatchApplyEndEvent;
@@ -282,6 +284,7 @@ pub(crate) struct Session {
282284
codex_linux_sandbox_exe: Option<PathBuf>,
283285
user_shell: shell::Shell,
284286
show_raw_agent_reasoning: bool,
287+
local_exec: crate::local_exec::LocalExecRuntime,
285288
}
286289

287290
/// The context needed for a single turn of the conversation.
@@ -536,6 +539,7 @@ impl Session {
536539
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
537540
user_shell: default_shell,
538541
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
542+
local_exec: crate::local_exec::LocalExecRuntime::new(),
539543
});
540544

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

codex-rs/core/src/config_types.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,30 @@ pub enum HistoryPersistence {
7474
None,
7575
}
7676

77+
pub const LOCAL_SHELL_MAX_LINES_DEFAULT: usize = 150;
78+
79+
fn local_shell_max_lines_default() -> usize {
80+
LOCAL_SHELL_MAX_LINES_DEFAULT
81+
}
82+
7783
/// Collection of settings that are specific to the TUI.
78-
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
79-
pub struct Tui {}
84+
#[derive(Deserialize, Debug, Clone, PartialEq)]
85+
pub struct Tui {
86+
/// Maximum number of lines to display for the output of a locally executed
87+
/// shell command entered via a leading `!` in the composer. Output beyond
88+
/// this limit is summarized with an ellipsis in the UI. The full output is
89+
/// still retained in the transcript.
90+
#[serde(default = "local_shell_max_lines_default")]
91+
pub local_shell_max_lines: usize,
92+
}
93+
94+
impl Default for Tui {
95+
fn default() -> Self {
96+
Self {
97+
local_shell_max_lines: LOCAL_SHELL_MAX_LINES_DEFAULT,
98+
}
99+
}
100+
}
80101

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

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mod flags;
2626
pub mod git_info;
2727
mod is_safe_command;
2828
pub mod landlock;
29+
mod local_exec;
2930
mod mcp_connection_manager;
3031
mod mcp_tool_call;
3132
mod message_history;

codex-rs/core/src/local_exec.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
use std::sync::Mutex;
2+
3+
pub(crate) struct LocalExecRuntime {
4+
pgid: Mutex<Option<i32>>,
5+
}
6+
7+
impl LocalExecRuntime {
8+
pub(crate) fn new() -> Self {
9+
Self {
10+
pgid: Mutex::new(None),
11+
}
12+
}
13+
}
14+
15+
/// Configure child process before exec: on Unix, create a new process group so
16+
/// we can signal the entire tree later.
17+
pub(crate) fn configure_child(cmd: &mut tokio::process::Command) {
18+
unsafe {
19+
cmd.pre_exec(|| {
20+
libc::setpgid(0, 0);
21+
Ok(())
22+
});
23+
}
24+
}
25+
26+
/// Record the spawned child so future interrupts can target it.
27+
pub(crate) fn record_child(runtime: &LocalExecRuntime, pid_opt: Option<u32>) {
28+
if let Some(pid_u32) = pid_opt {
29+
let pid = pid_u32 as i32;
30+
// If getpgid fails, fall back to pid.
31+
let pgid = unsafe { libc::getpgid(pid) };
32+
let value = if pgid > 0 { pgid } else { pid };
33+
if let Ok(mut guard) = runtime.pgid.lock() {
34+
*guard = Some(value);
35+
}
36+
}
37+
}
38+
39+
/// Clear any recorded child state after it exits or upon spawn failure.
40+
pub(crate) fn clear(runtime: &LocalExecRuntime) {
41+
if let Ok(mut guard) = runtime.pgid.lock() {
42+
*guard = None;
43+
}
44+
}
45+
46+
/// Attempt to interrupt a recorded child process tree.
47+
pub(crate) fn interrupt(runtime: &LocalExecRuntime) {
48+
if let Ok(mut guard) = runtime.pgid.lock()
49+
&& let Some(pgid) = guard.take()
50+
{
51+
unsafe {
52+
let _ = libc::kill(-pgid, libc::SIGINT);
53+
}
54+
}
55+
}

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)