feat(tui): add workspace slash commands

This commit is contained in:
Felipe Coury
2026-06-12 00:33:59 -07:00
parent d4a71337cc
commit 7cc15e96a2
18 changed files with 348 additions and 10 deletions
@@ -4416,6 +4416,13 @@
"mode"
],
"type": "object"
},
"WorkspaceMutationOperation": {
"enum": [
"setWorkingDirectory",
"addWorkspaceRoot"
],
"type": "string"
}
},
"description": "Request from the client to the server.",
@@ -546,6 +546,12 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadSettingsUpdateResponse,
},
#[experimental("thread/workspace/update")]
ThreadWorkspaceUpdate => "thread/workspace/update" {
params: v2::ThreadWorkspaceUpdateParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadWorkspaceUpdateResponse,
},
#[experimental("thread/memoryMode/set")]
ThreadMemoryModeSet => "thread/memoryMode/set" {
params: v2::ThreadMemoryModeSetParams,
@@ -3712,6 +3712,30 @@ fn thread_settings_update_params_preserve_explicit_null_service_tier() {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_workspace_update_params_round_trip() {
let params = ThreadWorkspaceUpdateParams {
thread_id: "thread_123".to_string(),
operation: WorkspaceMutationOperation::SetWorkingDirectory,
path: "../stack-layer".to_string(),
};
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized,
json!({
"threadId": "thread_123",
"operation": "setWorkingDirectory",
"path": "../stack-layer"
})
);
assert_eq!(
serde_json::from_value::<ThreadWorkspaceUpdateParams>(serialized)
.expect("params should deserialize"),
params
);
}
#[test]
fn thread_settings_update_params_preserve_field_level_experimental_gates() {
let permissions = ThreadSettingsUpdateParams {
@@ -9,6 +9,7 @@ use super::ThreadSource;
use super::Turn;
use super::TurnEnvironmentParams;
use super::TurnItemsView;
use super::WorkspaceMutationOperation;
use super::shared::v2_enum_from_core;
use codex_experimental_api_macros::ExperimentalApi;
pub use codex_protocol::capabilities::CapabilityRootLocation;
@@ -281,6 +282,24 @@ pub struct ThreadSettingsUpdateParams {
#[ts(export_to = "v2/")]
pub struct ThreadSettingsUpdateResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadWorkspaceUpdateParams {
pub thread_id: String,
pub operation: WorkspaceMutationOperation,
pub path: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadWorkspaceUpdateResponse {
pub changed: bool,
pub cwd: AbsolutePathBuf,
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -149,6 +149,7 @@ Example with notification opt-out:
- `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal.
- `thread/goal/cleared` — notification emitted whenever a thread goal is removed.
- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`. Experimental clients also receive `threadSettings.runtimeWorkspaceRoots`, including roots added by model workspace mutations.
- `thread/workspace/update` — experimental direct user workspace mutation; accepts `threadId`, `operation` (`setWorkingDirectory` or `addWorkspaceRoot`), and an absolute or cwd-relative `path`, then returns the authoritative runtime cwd and workspace roots. This method supports exactly one selected execution environment and does not request an additional approval because invoking it is an explicit user action.
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
- `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread.
- `thread/delete` — hard-delete an active or archived thread and any spawned descendant threads; returns `{}` on success and emits `thread/deleted` for each deleted thread.
@@ -1131,6 +1131,9 @@ impl MessageProcessor {
.thread_settings_update(&request_id, params)
.await
}
ClientRequest::ThreadWorkspaceUpdate { params, .. } => {
self.turn_processor.thread_workspace_update(params).await
}
ClientRequest::ThreadMemoryModeSet { params, .. } => {
self.thread_processor.thread_memory_mode_set(params).await
}
@@ -256,6 +256,8 @@ use codex_app_server_protocol::ThreadUnarchivedNotification;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::ThreadWorkspaceUpdateParams;
use codex_app_server_protocol::ThreadWorkspaceUpdateResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnError;
@@ -131,6 +131,15 @@ impl TurnRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_workspace_update(
&self,
params: ThreadWorkspaceUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_workspace_update_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn turn_steer(
&self,
request_id: &ConnectionRequestId,
@@ -734,6 +743,22 @@ impl TurnRequestProcessor {
Ok(ThreadSettingsUpdateResponse {})
}
async fn thread_workspace_update_inner(
&self,
params: ThreadWorkspaceUpdateParams,
) -> Result<ThreadWorkspaceUpdateResponse, JSONRPCErrorError> {
let (_, thread) = self.load_thread(&params.thread_id).await?;
let response = thread
.update_runtime_workspace(params.operation.to_core(), params.path)
.await
.map_err(|err| invalid_request(format!("failed to update thread workspace: {err}")))?;
Ok(ThreadWorkspaceUpdateResponse {
changed: response.changed,
cwd: response.cwd,
runtime_workspace_roots: response.workspace_roots,
})
}
async fn thread_inject_items_response_inner(
&self,
params: ThreadInjectItemsParams,
+53
View File
@@ -167,6 +167,13 @@ pub struct BackgroundTerminalInfo {
pub cwd: AbsolutePathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UserWorkspaceMutationResult {
pub changed: bool,
pub cwd: AbsolutePathBuf,
pub workspace_roots: Vec<AbsolutePathBuf>,
}
/// Conduit for the bidirectional stream of messages that compose a thread
/// (formerly called a conversation) in Codex.
impl CodexThread {
@@ -189,6 +196,52 @@ impl CodexThread {
self.codex.submit(op).await
}
pub async fn update_runtime_workspace(
&self,
operation: codex_protocol::request_permissions::WorkspaceMutationOperation,
path: String,
) -> anyhow::Result<UserWorkspaceMutationResult> {
if self.codex.session.active_turn.lock().await.is_some() {
anyhow::bail!("workspace mutation is unavailable while a task is in progress");
}
let turn = self.codex.session.new_default_turn().await;
let plan = crate::tools::handlers::workspace_mutation::plan_workspace_mutation(
self.codex.session.as_ref(),
&turn,
operation.clone(),
path,
)
.await
.map_err(|err| anyhow::anyhow!("{}: {}", err.code, err.message))?;
if plan.changed {
self.codex
.session
.update_runtime_workspace(
turn.as_ref(),
matches!(
operation,
codex_protocol::request_permissions::WorkspaceMutationOperation::SetWorkingDirectory
)
.then_some(plan.cwd.clone()),
plan.workspace_roots.clone(),
)
.await?;
self.codex
.session
.send_event(
turn.as_ref(),
crate::session::thread_settings_applied_event(self.codex.session.as_ref())
.await,
)
.await;
}
Ok(UserWorkspaceMutationResult {
changed: plan.changed,
cwd: plan.cwd,
workspace_roots: plan.workspace_roots,
})
}
/// Returns the session telemetry handle for thread-scoped production instrumentation.
pub fn session_telemetry(&self) -> SessionTelemetry {
self.codex.session.services.session_telemetry.clone()
+1
View File
@@ -28,6 +28,7 @@ pub use codex_thread::CodexThreadSettingsOverrides;
pub use codex_thread::ThreadConfigSnapshot;
pub use codex_thread::TryStartTurnIfIdleError;
pub use codex_thread::TryStartTurnIfIdleRejectionReason;
pub use codex_thread::UserWorkspaceMutationResult;
pub use session::turn_context::TurnContext;
mod agent;
mod attestation;
+1 -1
View File
@@ -33,7 +33,7 @@ pub(crate) mod tool_search_spec;
pub(crate) mod unified_exec;
mod view_image;
pub(crate) mod view_image_spec;
mod workspace_mutation;
pub(crate) mod workspace_mutation;
pub(crate) mod workspace_mutation_spec;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
@@ -61,10 +61,10 @@ struct WorkspaceMutationArgs {
path: String,
}
struct WorkspaceMutationPlan {
changed: bool,
cwd: AbsolutePathBuf,
workspace_roots: Vec<AbsolutePathBuf>,
pub(crate) struct WorkspaceMutationPlan {
pub(crate) changed: bool,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) workspace_roots: Vec<AbsolutePathBuf>,
}
struct WorkspacePermissionRequest {
@@ -87,15 +87,78 @@ struct WorkspaceMutationResult {
}
#[derive(Serialize)]
struct WorkspaceMutationError {
code: &'static str,
message: String,
cwd: AbsolutePathBuf,
workspace_roots: Vec<AbsolutePathBuf>,
pub(crate) struct WorkspaceMutationError {
pub(crate) code: &'static str,
pub(crate) message: String,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) workspace_roots: Vec<AbsolutePathBuf>,
#[serde(skip_serializing_if = "is_zero")]
omitted_workspace_roots: usize,
}
pub(crate) async fn plan_workspace_mutation(
session: &Session,
turn: &TurnContext,
operation: WorkspaceMutationOperation,
path: String,
) -> Result<WorkspaceMutationPlan, WorkspaceMutationError> {
let current = session.runtime_workspace_snapshot().await;
let Some(environment) = turn
.environments
.primary()
.filter(|_| turn.environments.turn_environments.len() == 1)
else {
return Err(WorkspaceMutationError {
code: "unsupported_environment_count",
message: "workspace mutation requires exactly one execution environment".to_string(),
cwd: current.cwd,
workspace_roots: current.workspace_roots,
omitted_workspace_roots: 0,
});
};
let requested = current.cwd.join(path);
let fs = environment.environment.get_filesystem();
let canonical = fs
.canonicalize(&requested, /*sandbox*/ None)
.await
.map_err(|err| WorkspaceMutationError {
code: io_error_code(&err),
message: err.to_string(),
cwd: current.cwd.clone(),
workspace_roots: current.workspace_roots.clone(),
omitted_workspace_roots: 0,
})?;
let metadata = fs
.get_metadata(&canonical, /*sandbox*/ None)
.await
.map_err(|err| WorkspaceMutationError {
code: io_error_code(&err),
message: err.to_string(),
cwd: current.cwd.clone(),
workspace_roots: current.workspace_roots.clone(),
omitted_workspace_roots: 0,
})?;
if !metadata.is_directory {
return Err(WorkspaceMutationError {
code: "not_a_directory",
message: format!(
"workspace mutation target is not a directory: {}",
canonical.as_path().display()
),
cwd: current.cwd,
workspace_roots: current.workspace_roots,
omitted_workspace_roots: 0,
});
}
Ok(workspace_mutation_plan(
operation,
&current.cwd,
&current.workspace_roots,
canonical,
))
}
impl ToolExecutor<ToolInvocation> for WorkspaceMutationHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(match self.operation {
+41
View File
@@ -7,6 +7,7 @@ use super::resize_reflow::trailing_run_start;
use super::*;
use crate::config_update::format_config_error;
use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome;
use codex_app_server_protocol::WorkspaceMutationOperation;
#[cfg(target_os = "windows")]
use codex_config::types::WindowsSandboxModeToml;
@@ -360,6 +361,46 @@ impl App {
AppEvent::RestoreCancelledTurn(prompt) => {
self.apply_cancelled_turn_edit(prompt);
}
AppEvent::UpdateThreadWorkspace { operation, path } => {
let Some(thread_id) = self.active_thread_id else {
self.chat_widget.add_error_message(
"Workspace commands are unavailable before the session starts.".to_string(),
);
return Ok(AppRunControl::Continue);
};
match app_server
.thread_workspace_update(thread_id, operation, path)
.await
{
Ok(response) => {
let message = match (operation, response.changed) {
(
WorkspaceMutationOperation::SetWorkingDirectory,
/*changed*/ true,
) => {
format!("Working directory: {}", response.cwd.as_path().display())
}
(
WorkspaceMutationOperation::AddWorkspaceRoot,
/*changed*/ true,
) => match response.runtime_workspace_roots.last() {
Some(root) => {
format!("Added workspace root: {}", root.as_path().display())
}
None => "Workspace roots updated.".to_string(),
},
(_, /*changed*/ false) => format!(
"Workspace unchanged. Working directory: {}",
response.cwd.as_path().display()
),
};
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => self
.chat_widget
.add_error_message(format!("Failed to update workspace: {err}")),
}
}
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
self.append_message_history_entry(thread_id, text);
}
+7
View File
@@ -26,6 +26,7 @@ use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::WorkspaceMutationOperation;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
@@ -258,6 +259,12 @@ pub(crate) enum AppEvent {
/// Restore an output-free interrupted turn into the composer and roll it back.
RestoreCancelledTurn(UserMessage),
/// Apply an explicit user-requested runtime workspace mutation.
UpdateThreadWorkspace {
operation: WorkspaceMutationOperation,
path: String,
},
/// Approve one retry of a recent auto-review denial selected in the TUI.
ApproveRecentAutoReviewDenial {
thread_id: ThreadId,
+23
View File
@@ -107,6 +107,8 @@ use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadWorkspaceUpdateParams;
use codex_app_server_protocol::ThreadWorkspaceUpdateResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
@@ -115,6 +117,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput;
use codex_app_server_protocol::WorkspaceMutationOperation;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent;
@@ -723,6 +726,26 @@ impl AppServerSession {
}
}
pub(crate) async fn thread_workspace_update(
&mut self,
thread_id: ThreadId,
operation: WorkspaceMutationOperation,
path: String,
) -> Result<ThreadWorkspaceUpdateResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadWorkspaceUpdate {
request_id,
params: ThreadWorkspaceUpdateParams {
thread_id: thread_id.to_string(),
operation,
path,
},
})
.await
.wrap_err("thread/workspace/update failed in TUI")
}
pub(crate) async fn thread_inject_items(
&mut self,
thread_id: ThreadId,
@@ -34,6 +34,7 @@ const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str =
"Press Ctrl+C to return to the main thread first.";
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
const RAW_USAGE: &str = "Usage: /raw [on|off]";
const ADD_DIR_USAGE: &str = "Usage: /add-dir <path>";
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
@@ -298,6 +299,15 @@ impl ChatWidget {
);
}
}
SlashCommand::Cwd => {
self.add_info_message(
format!("Working directory: {}", self.config.cwd.display()),
/*hint*/ None,
);
}
SlashCommand::AddDir => {
self.add_error_message(ADD_DIR_USAGE.to_string());
}
SlashCommand::Side | SlashCommand::Btw => {
self.request_empty_side_conversation(cmd);
}
@@ -829,6 +839,20 @@ impl ChatWidget {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
}
SlashCommand::Cwd if !trimmed.is_empty() => {
self.app_event_tx.send(AppEvent::UpdateThreadWorkspace {
operation:
codex_app_server_protocol::WorkspaceMutationOperation::SetWorkingDirectory,
path: args,
});
}
SlashCommand::AddDir if !trimmed.is_empty() => {
self.app_event_tx.send(AppEvent::UpdateThreadWorkspace {
operation:
codex_app_server_protocol::WorkspaceMutationOperation::AddWorkspaceRoot,
path: args,
});
}
SlashCommand::Pets
if matches!(
args.trim().to_ascii_lowercase().as_str(),
@@ -976,6 +1000,8 @@ impl ChatWidget {
match cmd {
SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::Cwd
| SlashCommand::AddDir
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
@@ -87,6 +87,36 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEv
}
}
#[tokio::test]
async fn slash_cwd_with_path_requests_working_directory_update() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.dispatch_command_with_args(SlashCommand::Cwd, "../stack-layer".to_string(), Vec::new());
assert_matches!(
rx.try_recv(),
Ok(AppEvent::UpdateThreadWorkspace {
operation: codex_app_server_protocol::WorkspaceMutationOperation::SetWorkingDirectory,
path,
}) if path == "../stack-layer"
);
}
#[tokio::test]
async fn slash_add_dir_with_path_requests_workspace_root_update() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.dispatch_command_with_args(SlashCommand::AddDir, "../shared".to_string(), Vec::new());
assert_matches!(
rx.try_recv(),
Ok(AppEvent::UpdateThreadWorkspace {
operation: codex_app_server_protocol::WorkspaceMutationOperation::AddWorkspaceRoot,
path,
}) if path == "../shared"
);
}
#[tokio::test]
async fn service_tier_commands_lowercase_catalog_names() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
+7
View File
@@ -48,6 +48,8 @@ pub enum SlashCommand {
Diff,
Mention,
Status,
Cwd,
AddDir,
DebugConfig,
Title,
Statusline,
@@ -106,6 +108,8 @@ impl SlashCommand {
}
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Cwd => "show or change the session working directory: /cwd [path]",
SlashCommand::AddDir => "add a directory to the session workspace: /add-dir <path>",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Title => "configure which items appear in the terminal title",
SlashCommand::Statusline => "configure which items appear in the status line",
@@ -170,6 +174,8 @@ impl SlashCommand {
| SlashCommand::Btw
| SlashCommand::Resume
| SlashCommand::SandboxReadRoot
| SlashCommand::Cwd
| SlashCommand::AddDir
)
}
@@ -212,6 +218,7 @@ impl SlashCommand {
| SlashCommand::Logout
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => false,
SlashCommand::Cwd | SlashCommand::AddDir => false,
SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Raw