Add thread/shellCommand to app server API surface (#14988)

This PR adds a new `thread/shellCommand` app server API so clients can
implement `!` shell commands. These commands are executed within the
sandbox, and the command text and output are visible to the model.

The internal implementation mirrors the current TUI `!` behavior.
- persist shell command execution as `CommandExecution` thread items,
including source and formatted output metadata
- bridge live and replayed app-server command execution events back into
the existing `tui_app_server` exec rendering path

This PR also wires `tui_app_server` to submit `!` commands through the
new API.
This commit is contained in:
Eric Traut
2026-03-18 23:42:40 -06:00
committed by GitHub
parent 10eb3ec7fc
commit 01df50cf42
43 changed files with 2580 additions and 86 deletions
+6
View File
@@ -2050,6 +2050,12 @@ impl App {
app_server.thread_realtime_stop(thread_id).await?;
Ok(true)
}
AppCommandView::RunUserShellCommand { command } => {
app_server
.thread_shell_command(thread_id, command.to_string())
.await?;
Ok(true)
}
AppCommandView::OverrideTurnContext { .. } => Ok(true),
_ => Ok(false),
}
File diff suppressed because it is too large Load Diff
@@ -35,6 +35,9 @@ pub(crate) enum AppCommandView<'a> {
RealtimeConversationAudio(&'a ConversationAudioParams),
RealtimeConversationText(&'a ConversationTextParams),
RealtimeConversationClose,
RunUserShellCommand {
command: &'a str,
},
UserTurn {
items: &'a [UserInput],
cwd: &'a PathBuf,
@@ -134,6 +137,10 @@ impl AppCommand {
Self(Op::RealtimeConversationClose)
}
pub(crate) fn run_user_shell_command(command: String) -> Self {
Self(Op::RunUserShellCommand { command })
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn user_turn(
items: Vec<UserInput>,
@@ -291,6 +298,7 @@ impl AppCommand {
AppCommandView::RealtimeConversationText(params)
}
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command },
Op::UserTurn {
items,
cwd,
@@ -42,6 +42,8 @@ use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
@@ -492,6 +494,26 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_shell_command(
&mut self,
thread_id: ThreadId,
command: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadShellCommandResponse = self
.client
.request_typed(ClientRequest::ThreadShellCommand {
request_id,
params: ThreadShellCommandParams {
thread_id: thread_id.to_string(),
command,
},
})
.await
.wrap_err("thread/shellCommand failed in app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,
+9 -12
View File
@@ -5134,13 +5134,7 @@ impl ChatWidget {
)));
return;
}
// TODO: Restore `!` support in app-server TUI once command execution can
// persist transcript-visible output into thread history with parity to the
// legacy TUI.
self.add_to_history(history_cell::new_error_event(
"`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history.".to_string(),
));
self.request_redraw();
self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()));
return;
}
@@ -5562,6 +5556,7 @@ impl ChatWidget {
command,
cwd,
process_id,
source,
status,
command_actions,
aggregated_output,
@@ -5582,10 +5577,11 @@ impl ChatWidget {
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source: ExecCommandSource::Agent,
source: source.to_core(),
interaction_input: None,
});
} else {
let aggregated_output = aggregated_output.unwrap_or_default();
self.on_exec_command_end(ExecCommandEndEvent {
call_id: id,
process_id,
@@ -5596,16 +5592,16 @@ impl ChatWidget {
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source: ExecCommandSource::Agent,
source: source.to_core(),
interaction_input: None,
stdout: String::new(),
stderr: String::new(),
aggregated_output: aggregated_output.unwrap_or_default(),
aggregated_output: aggregated_output.clone(),
exit_code: exit_code.unwrap_or_default(),
duration: Duration::from_millis(
duration_ms.unwrap_or_default().max(0) as u64
),
formatted_output: String::new(),
formatted_output: aggregated_output,
status: match status {
codex_app_server_protocol::CommandExecutionStatus::Completed => {
codex_protocol::protocol::ExecCommandStatus::Completed
@@ -6144,6 +6140,7 @@ impl ChatWidget {
command,
cwd,
process_id,
source,
command_actions,
..
} => {
@@ -6157,7 +6154,7 @@ impl ChatWidget {
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source: ExecCommandSource::Agent,
source: source.to_core(),
interaction_input: None,
});
}
@@ -8840,7 +8840,7 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn bang_shell_command_is_disabled_in_app_server_tui() {
async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
@@ -8873,22 +8873,11 @@ async fn bang_shell_command_is_disabled_in_app_server_tui() {
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let mut rendered = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered = Some(lines_to_single_string(&cell.display_lines(80)));
break;
}
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected RunUserShellCommand op, got {other:?}"),
}
let rendered = rendered.expect("expected disabled bang-shell error");
assert!(
rendered.contains(
"`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history."
),
"expected bang-shell disabled message, got: {rendered}"
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]