From 3f0669dbd65c7cd369c84fd44b5696f871142fea Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 16 Jul 2026 22:15:08 +0000 Subject: [PATCH] Make parent-owned sub-agent threads read-only in the TUI (#33841) ## Why Multi-agent V2 spawned threads are controlled by their parent and reject direct app-server input. The TUI should expose that ownership instead of presenting an input flow that the server will reject. ## What changed - Add the experimental `Thread.canAcceptDirectInput` capability for loaded threads and populate it from the same policy used to validate `turn/start` and `turn/steer`. Stored, unloaded threads report `null`. - Carry the capability through TUI thread discovery, selection, startup, and resume flows, with a compatibility fallback for older app servers. - Present parent-owned threads as view-only, preserve drafts and queued input, block agent-directed commands and settings shortcuts, and keep local navigation and inspection commands available. ## Testing - Cover V1 writable and V2 view-only spawned threads through selection and resume. - Cover composer submission paths, queued and initial input preservation, allowed navigation commands, settings shortcuts, and the view-only UI. GitOrigin-RevId: 495ad051249e5577fb0b0c1cc8c40a0998d04918 --- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../src/protocol/common.rs | 2 + .../src/protocol/v2/tests.rs | 1 + .../src/protocol/v2/thread_data.rs | 4 + codex-rs/app-server/README.md | 2 +- .../request_processors/thread_processor.rs | 44 ++- .../thread_resume_redaction.rs | 1 + .../src/request_processors/thread_summary.rs | 1 + .../src/request_processors/turn_processor.rs | 23 +- codex-rs/app-server/src/thread_status.rs | 1 + codex-rs/exec/src/lib_tests.rs | 2 + codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app/agent_navigation.rs | 29 ++ codex-rs/tui/src/app/loaded_threads.rs | 9 +- codex-rs/tui/src/app/session_lifecycle.rs | 36 ++- codex-rs/tui/src/app/tests.rs | 152 ++++++++++ codex-rs/tui/src/app/tests/startup.rs | 2 + codex-rs/tui/src/app/thread_routing.rs | 5 +- codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 28 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 284 +++++++++++++----- .../bottom_pane/chat_composer/slash_input.rs | 16 +- codex-rs/tui/src/bottom_pane/mod.rs | 5 + ...ests__parent_owned_thread_placeholder.snap | 13 + codex-rs/tui/src/chatwidget.rs | 12 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/input_flow.rs | 15 + codex-rs/tui/src/chatwidget/input_restore.rs | 6 + codex-rs/tui/src/chatwidget/interaction.rs | 8 +- .../tui/src/chatwidget/reasoning_shortcuts.rs | 6 + codex-rs/tui/src/chatwidget/side.rs | 3 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 4 + ...ts__parent_owned_thread_rejects_input.snap | 5 + ...ned_thread_rejects_settings_shortcuts.snap | 7 + .../chatwidget/tests/composer_submission.rs | 143 +++++++++ codex-rs/tui/src/resume_picker.rs | 4 + 37 files changed, 781 insertions(+), 99 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_input.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_settings_shortcuts.snap diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eeeadd4634..20354610ba 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -212,6 +212,7 @@ fn sample_thread_with_metadata( cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + can_accept_direct_input: None, thread_source, agent_nickname: None, agent_role: None, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 935d56fceb..443167820c 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -470,6 +470,7 @@ fn sample_thread(thread_id: &str) -> Thread { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: AppServerSessionSource::Exec, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ab7609912c..c2e8e051d8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2639,6 +2639,7 @@ mod tests { cwd: cwd.clone(), cli_version: "0.0.0".to_string(), source: v2::SessionSource::Exec, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -2692,6 +2693,7 @@ mod tests { "cwd": absolute_path_string("tmp"), "cliVersion": "0.0.0", "source": "exec", + "canAcceptDirectInput": null, "threadSource": null, "agentNickname": null, "agentRole": null, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 9389147513..e61f7ed13e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -195,6 +195,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { cwd: absolute_path("tmp"), cli_version: "0.0.0".to_string(), source: SessionSource::Exec, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 2393efb25c..77d4831ede 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -208,6 +208,10 @@ pub struct Thread { pub cli_version: String, /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). pub source: SessionSource, + /// Whether the app server accepts direct turn input for this loaded thread. + /// `None` means the capability is unavailable, such as for an unloaded stored thread. + #[experimental("thread.canAcceptDirectInput")] + pub can_accept_direct_input: Option, /// Optional analytics source classification for this thread. pub thread_source: Option, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f00c9d051b..aa2c7fec96 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -143,7 +143,7 @@ Example with notification opt-out: - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. -- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. For loaded threads, experimental clients can use `canAcceptDirectInput` to determine whether `turn/start` and `turn/steer` are accepted; unloaded stored threads report `null` when that capability is unavailable. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index f57224f2e9..b141657df9 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1,4 +1,5 @@ use super::thread_fork_goal::inherit_thread_goal_snapshot; +use super::turn_processor::can_accept_direct_input; use super::*; use crate::error_code::method_not_found; use codex_app_server_protocol::SelectedCapabilityRoot; @@ -1281,6 +1282,7 @@ impl ThreadRequestProcessor { let mut thread = build_thread_from_snapshot( thread_id, session_configured.session_id.to_string(), + thread.multi_agent_version(), &config_snapshot, session_configured.rollout_path.clone(), ); @@ -2297,8 +2299,12 @@ impl ThreadRequestProcessor { .load_persisted_thread_for_read(thread_id, include_turns) .await? { - // Persisted metadata-only read: no live thread state is needed. - thread + if let Some(loaded_thread) = loaded_thread.as_ref() { + self.load_live_thread_view(thread_id, include_turns, loaded_thread, Some(thread)) + .await? + } else { + thread + } } else if let Some(loaded_thread) = loaded_thread.as_ref() { // Loaded metadata-only read before persistence is materialized: build // the response from the live thread snapshot. @@ -2426,6 +2432,7 @@ impl ThreadRequestProcessor { } thread.session_id.clone_from(&fallback_thread.session_id); thread.ephemeral = fallback_thread.ephemeral; + thread.can_accept_direct_input = fallback_thread.can_accept_direct_input; thread } else { fallback_thread @@ -2803,6 +2810,7 @@ impl ThreadRequestProcessor { let loaded_thread = build_thread_from_snapshot( thread_id, thread.session_configured().session_id.to_string(), + thread.multi_agent_version(), &config_snapshot, thread.rollout_path(), ); @@ -3383,6 +3391,10 @@ impl ThreadRequestProcessor { ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); thread_summary.thread_source = config_snapshot.thread_source.clone().map(Into::into); + thread_summary.can_accept_direct_input = Some(can_accept_direct_input( + existing_thread.multi_agent_version(), + &config_snapshot.session_source, + )); let instruction_sources = existing_thread.legacy_instruction_sources().await; let listener_command_tx = { @@ -3594,6 +3606,10 @@ impl ThreadRequestProcessor { ) -> std::result::Result { let config_snapshot = thread.config_snapshot().await; let session_id = thread.session_configured().session_id.to_string(); + let can_accept_direct_input = can_accept_direct_input( + thread.multi_agent_version(), + &config_snapshot.session_source, + ); let thread = match thread_history { InitialHistory::Resumed(resumed) => { let fallback_provider = config_snapshot.model_provider_id.as_str(); @@ -3656,6 +3672,7 @@ impl ThreadRequestProcessor { let mut thread = build_thread_from_snapshot( thread_id, session_id.clone(), + thread.multi_agent_version(), &config_snapshot, Some(rollout_path.into()), ); @@ -3667,6 +3684,7 @@ impl ThreadRequestProcessor { )), }; let mut thread = thread?; + thread.can_accept_direct_input = Some(can_accept_direct_input); thread.id = thread_id.to_string(); thread.session_id = session_id; thread.path = Some(rollout_path.to_path_buf()); @@ -3926,6 +3944,8 @@ impl ThreadRequestProcessor { "thread", ); + let config_snapshot = forked_thread.config_snapshot().await; + // Persistent forks materialize their own rollout immediately. Ephemeral forks stay // pathless, so they rebuild their visible history from the copied source history instead. let mut thread = if session_configured.rollout_path.is_some() { @@ -3938,10 +3958,10 @@ impl ThreadRequestProcessor { include_turns, ) } else { - let config_snapshot = forked_thread.config_snapshot().await; let mut thread = build_thread_from_snapshot( thread_id, session_configured.session_id.to_string(), + forked_thread.multi_agent_version(), &config_snapshot, /*path*/ None, ); @@ -3959,12 +3979,12 @@ impl ThreadRequestProcessor { if let Some(name) = source_thread_name { set_thread_name_from_title(&mut thread, name); } + thread.can_accept_direct_input = Some(can_accept_direct_input( + forked_thread.multi_agent_version(), + &config_snapshot.session_source, + )); thread.session_id = session_configured.session_id.to_string(); - thread.thread_source = forked_thread - .config_snapshot() - .await - .thread_source - .map(Into::into); + thread.thread_source = config_snapshot.thread_source.clone().map(Into::into); self.thread_watch_manager .upsert_thread_silently(thread.clone()) @@ -3976,7 +3996,6 @@ impl ThreadRequestProcessor { .await, /*has_in_progress_turn*/ false, ); - let config_snapshot = forked_thread.config_snapshot().await; let sandbox = config_snapshot.sandbox_policy().into(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -4746,6 +4765,7 @@ pub(crate) fn thread_from_stored_thread( agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), + can_accept_direct_input: None, thread_source: thread.thread_source.map(Into::into), git_info, name: thread.name, @@ -4927,6 +4947,7 @@ fn permission_profile_trusts_project( fn build_thread_from_snapshot( thread_id: ThreadId, session_id: String, + multi_agent_version: Option, config_snapshot: &ThreadConfigSnapshot, path: Option, ) -> Thread { @@ -4951,6 +4972,10 @@ fn build_thread_from_snapshot( agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), + can_accept_direct_input: Some(can_accept_direct_input( + multi_agent_version, + &config_snapshot.session_source, + )), thread_source: config_snapshot.thread_source.clone().map(Into::into), git_info: None, name: None, @@ -4994,6 +5019,7 @@ fn build_thread_from_loaded_snapshot( build_thread_from_snapshot( thread_id, loaded_thread.session_configured().session_id.to_string(), + loaded_thread.multi_agent_version(), config_snapshot, loaded_thread.rollout_path(), ) diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 36986e329b..9cff8bfb69 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -204,6 +204,7 @@ mod tests { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index caf662d642..e62ca6876f 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -319,6 +319,7 @@ pub(crate) fn summary_to_thread( agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), + can_accept_direct_input: None, thread_source: None, git_info, name: None, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 2771cf5410..d48ff7649f 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -18,6 +18,18 @@ use crate::image_url::is_remote_image_url; const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = "direct app-server input is not allowed for multi-agent v2 sub-agents"; +/// Mirrors the direct-input policy in both request validation and thread capability responses. +pub(super) fn can_accept_direct_input( + multi_agent_version: Option, + session_source: &SessionSource, +) -> bool { + multi_agent_version != Some(MultiAgentVersion::V2) + || !matches!( + session_source, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + ) +} + fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { if input.iter().any(|item| { matches!( @@ -326,12 +338,11 @@ impl TurnRequestProcessor { request_id: &ConnectionRequestId, thread: &CodexThread, ) -> Result<(), JSONRPCErrorError> { - if thread.multi_agent_version() == Some(MultiAgentVersion::V2) - && matches!( - thread.config_snapshot().await.session_source, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) - ) - { + let config_snapshot = thread.config_snapshot().await; + if !can_accept_direct_input( + thread.multi_agent_version(), + &config_snapshot.session_source, + ) { let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR); self.track_error_response(request_id, &error, /*error_type*/ None); return Err(error); diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index b7e450e0ab..738340c762 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -907,6 +907,7 @@ mod tests { agent_nickname: None, agent_role: None, source, + can_accept_direct_input: None, thread_source: None, git_info: None, name: None, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index fc1b713c92..3919b39b39 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -350,6 +350,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0-test".to_string(), source: codex_app_server_protocol::SessionSource::Exec, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -812,6 +813,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, + can_accept_direct_input: None, thread_source: Some(codex_app_server_protocol::ThreadSource::User), agent_nickname: None, agent_role: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 19b175f030..dc5ed3f9ad 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1082,6 +1082,9 @@ See the Codex keymap documentation for supported actions and examples." let initial_session_started_at = Instant::now(); if let Some(started) = initial_started_thread { let thread_id = started.session.thread_id; + if started.blocks_direct_input { + app.mark_primary_thread_parent_owned(thread_id); + } app.enqueue_primary_thread_session(started.session, started.turns) .await?; if should_prompt_for_paused_goal_after_startup_resume { diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index e329de90ab..42b8414401 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -6,6 +6,7 @@ //! //! Responsibilities here are intentionally narrow: //! - remember picker entries and their first-seen order +//! - remember which V2 child threads are owned by their parent agent //! - answer traversal questions like "what is the next thread?" //! - derive user-facing picker/footer text from cached thread metadata //! @@ -26,6 +27,7 @@ use crate::multi_agents::previous_agent_shortcut; use codex_protocol::ThreadId; use ratatui::text::Span; use std::collections::HashMap; +use std::collections::HashSet; /// Small state container for multi-agent picker ordering and labeling. /// @@ -42,6 +44,8 @@ pub(crate) struct AgentNavigationState { threads: HashMap, /// Stable first-seen traversal order for picker rows and keyboard cycling. order: Vec, + /// Spawned child threads whose instructions are owned by their parent agent. + parent_owned_threads: HashSet, } /// Direction of keyboard traversal through the stable picker order. @@ -64,6 +68,15 @@ impl AgentNavigationState { self.threads.get(thread_id) } + pub(crate) fn is_parent_owned(&self, thread_id: ThreadId) -> bool { + self.parent_owned_threads.contains(&thread_id) + } + + /// Marks a spawned child thread as view-only for direct user instructions. + pub(crate) fn mark_parent_owned(&mut self, thread_id: ThreadId) { + self.parent_owned_threads.insert(thread_id); + } + /// Returns whether the picker cache currently knows about any threads. /// /// This is the cheapest way for `App` to decide whether opening the picker should show "No @@ -162,6 +175,7 @@ impl AgentNavigationState { pub(crate) fn clear(&mut self) { self.threads.clear(); self.order.clear(); + self.parent_owned_threads.clear(); } /// Removes a tracked thread entirely from picker metadata and traversal order. @@ -172,6 +186,7 @@ impl AgentNavigationState { pub(crate) fn remove(&mut self, thread_id: ThreadId) { self.threads.remove(&thread_id); self.order.retain(|candidate| *candidate != thread_id); + self.parent_owned_threads.remove(&thread_id); } /// Returns whether there is at least one tracked thread other than the primary one. @@ -376,6 +391,20 @@ mod tests { ); } + #[test] + fn parent_owned_state_is_removed_with_thread_metadata() { + let (mut state, _main_thread_id, first_agent_id, second_agent_id) = populated_state(); + + state.mark_parent_owned(first_agent_id); + assert!(state.is_parent_owned(first_agent_id)); + state.remove(first_agent_id); + assert!(!state.is_parent_owned(first_agent_id)); + + state.mark_parent_owned(second_agent_id); + state.clear(); + assert!(!state.is_parent_owned(second_agent_id)); + } + #[test] fn adjacent_thread_id_wraps_in_spawn_order() { let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state(); diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 9482453e92..7be4df391e 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -14,6 +14,7 @@ //! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are //! found. The primary thread itself is never included in the output. +use crate::app_server_session::thread_blocks_direct_input; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; @@ -29,6 +30,7 @@ pub(crate) struct LoadedSubagentThread { pub(crate) agent_nickname: Option, pub(crate) agent_role: Option, pub(crate) agent_path: Option, + pub(crate) blocks_direct_input: bool, } /// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent. @@ -84,6 +86,7 @@ pub(crate) fn find_loaded_subagent_threads_for_primary( threads_by_id .remove(&thread_id) .map(|thread| LoadedSubagentThread { + blocks_direct_input: thread_blocks_direct_input(&thread), thread_id, agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, @@ -144,6 +147,7 @@ mod tests { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -191,6 +195,7 @@ mod tests { ); child.agent_nickname = Some("Scout".to_string()); child.agent_role = Some("explorer".to_string()); + child.can_accept_direct_input = Some(true); let mut grandchild = test_thread( grandchild_thread_id, @@ -198,7 +203,7 @@ mod tests { ); grandchild.agent_nickname = Some("Atlas".to_string()); grandchild.agent_role = Some("worker".to_string()); - + grandchild.can_accept_direct_input = Some(false); let unrelated_child = test_thread( unrelated_child_id, thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"), @@ -218,12 +223,14 @@ mod tests { loaded, vec![ LoadedSubagentThread { + blocks_direct_input: false, thread_id: child_thread_id, agent_nickname: Some("Scout".to_string()), agent_role: Some("explorer".to_string()), agent_path: None, }, LoadedSubagentThread { + blocks_direct_input: true, thread_id: grandchild_thread_id, agent_nickname: Some("Atlas".to_string()), agent_role: Some("worker".to_string()), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index a64628f80c..f115b2cd95 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -5,6 +5,8 @@ //! cache used for multi-agent navigation. use super::*; +use crate::app_server_session::source_agent_path; +use crate::app_server_session::thread_blocks_direct_input; #[derive(Clone, Copy)] pub(super) enum ThreadAttachPresentation { @@ -184,6 +186,12 @@ impl App { self.sync_active_agent_label(); } + /// Persists the app-server's authoritative ownership flag and updates the active composer. + pub(super) fn mark_primary_thread_parent_owned(&mut self, thread_id: ThreadId) { + self.agent_navigation.mark_parent_owned(thread_id); + self.chat_widget.set_parent_owned_thread(); + } + /// Marks a cached picker thread closed and recomputes the contextual footer label. /// /// Closing a thread is not the same as removing it: users can still inspect finished agent @@ -205,6 +213,8 @@ impl App { .await { Ok(thread) => { + let is_parent_owned = thread_blocks_direct_input(&thread); + let agent_path = source_agent_path(&thread.source); let is_running = matches!( thread.status, codex_app_server_protocol::ThreadStatus::Active { .. } @@ -227,6 +237,10 @@ impl App { }), is_closed, ); + if is_parent_owned { + self.agent_navigation.mark_parent_owned(thread_id); + } + self.agent_navigation.set_agent_path(thread_id, agent_path); self.agent_navigation.set_running(thread_id, is_running); true } @@ -278,7 +292,12 @@ impl App { .resume_thread(self.config.clone(), thread_id, self.resume_model_settings()) .await { - Ok(started) => (started.session, started.turns, true), + Ok(started) => { + if started.blocks_direct_input { + self.agent_navigation.mark_parent_owned(thread_id); + } + (started.session, started.turns, true) + } Err(resume_err) => { tracing::warn!( thread_id = %thread_id, @@ -369,7 +388,6 @@ impl App { .add_error_message(format!("Agent thread {thread_id} is no longer available.")); return Ok(()); } - let mut is_replay_only = self .agent_navigation .get(&thread_id) @@ -398,7 +416,6 @@ impl App { .add_error_message(format!("Agent thread {thread_id} is no longer available.")); return Ok(()); } - let previous_thread_id = self.active_thread_id; self.store_active_thread_receiver().await; self.active_thread_id = None; @@ -419,6 +436,7 @@ impl App { &mut snapshot, ) .await; + let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id); self.active_thread_id = Some(thread_id); self.active_thread_rx = Some(receiver); @@ -429,6 +447,9 @@ impl App { /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + if blocks_direct_input { + self.chat_widget.set_parent_owned_thread(); + } self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -519,6 +540,9 @@ impl App { .set_queue_submissions_until_session_configured(/*queue*/ false); match result { Ok(started) => { + if started.blocks_direct_input { + self.mark_primary_thread_parent_owned(started.session.thread_id); + } self.enqueue_primary_thread_session(started.session, started.turns) .await?; self.chat_widget.maybe_send_next_queued_input(); @@ -625,6 +649,9 @@ impl App { initial_user_message, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + if started.blocks_direct_input { + self.mark_primary_thread_parent_owned(started.session.thread_id); + } self.enqueue_primary_thread_session_with_presentation( started.session, started.turns, @@ -694,6 +721,9 @@ impl App { for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { let agent_path = thread.agent_path; + if thread.blocks_direct_input { + self.agent_navigation.mark_parent_owned(thread.thread_id); + } self.upsert_agent_picker_thread( thread.thread_id, thread.agent_nickname, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 764733fd0e..407a34c180 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -99,7 +99,11 @@ use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionSource as RolloutSessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; @@ -1372,6 +1376,7 @@ async fn open_agent_picker_clears_completed_path_backed_agent_running_state() -> agent_path: "/root/child".to_string(), is_running_hint: true, }); + assert!(!app.agent_navigation.is_parent_owned(thread_id)); Box::pin(app.open_agent_picker(&mut app_server)).await; @@ -1523,6 +1528,147 @@ fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { }) } +#[test] +fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -> Result<()> { + const WORKER_THREADS: usize = 1; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?; + + runtime.block_on(async { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let root = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let root_thread_id = root.session.thread_id; + app.enqueue_primary_thread_session(root.session, root.turns) + .await?; + + let rollout_dir = app + .config + .codex_home + .join("sessions") + .join("2026") + .join("01") + .join("01"); + std::fs::create_dir_all(&rollout_dir)?; + let mut child_thread_ids = Vec::new(); + for (index, multi_agent_version) in [MultiAgentVersion::V1, MultiAgentVersion::V2] + .into_iter() + .enumerate() + { + let child_thread_id = ThreadId::new(); + let timestamp = format!("2026-01-01T00:00:0{index}Z"); + let session_meta = SessionMeta { + session_id: child_thread_id.into(), + id: child_thread_id, + parent_thread_id: Some(root_thread_id), + timestamp: timestamp.clone(), + cwd: app.config.cwd.to_path_buf(), + originator: "codex-tui-test".to_string(), + cli_version: "0.0.0".to_string(), + source: RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some(format!("child-{index}")), + agent_role: Some("worker".to_string()), + }), + model_provider: Some(app.config.model_provider_id.clone()), + multi_agent_version: Some(multi_agent_version), + ..SessionMeta::default() + }; + let rollout_path = rollout_dir.join(format!( + "rollout-2026-01-01T00-00-0{index}-{child_thread_id}.jsonl" + )); + let session_meta_line = serde_json::json!({ + "timestamp": timestamp, + "type": "session_meta", + "payload": serde_json::to_value(session_meta)?, + }); + std::fs::write(rollout_path, format!("{session_meta_line}\n"))?; + + assert!( + app.attach_live_thread_for_selection(&mut app_server, child_thread_id) + .await? + ); + assert_eq!( + app.agent_navigation.is_parent_owned(child_thread_id), + multi_agent_version == MultiAgentVersion::V2 + ); + child_thread_ids.push(child_thread_id); + } + + assert!(app.backfill_loaded_subagent_threads(&mut app_server).await); + assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0])); + assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1])); + + let mut tui = crate::tui::test_support::make_test_tui()?; + app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[0]) + .await?; + while app_event_rx.try_recv().is_ok() {} + app.chat_widget + .restore_user_message_to_composer("v1 remains writable".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. }))) + ); + + app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[1]) + .await?; + while app_event_rx.try_recv().is_ok() {} + app.chat_widget + .restore_user_message_to_composer("v2 stays view-only".into()); + let draft = app.chat_widget.composer_text_with_pending(); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.chat_widget.composer_text_with_pending(), draft); + assert!( + !std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. }))) + ); + + let resumed = app_server + .resume_thread( + app.config.clone(), + child_thread_ids[1], + app.resume_model_settings(), + ) + .await?; + assert!(resumed.blocks_direct_input); + app.replace_chat_widget_with_app_server_thread( + &mut tui, + &mut app_server, + resumed, + crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + app.chat_widget + .restore_user_message_to_composer("direct resume stays view-only".into()); + let draft = app.chat_widget.composer_text_with_pending(); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.chat_widget.composer_text_with_pending(), draft); + assert!( + !std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. }))) + ); + Ok(()) + }) +} + #[test] fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> { const WORKER_THREADS: usize = 1; @@ -2947,6 +3093,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + can_accept_direct_input: None, thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), @@ -3042,6 +3189,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + can_accept_direct_input: None, thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), @@ -3104,6 +3252,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -3586,6 +3735,7 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() { AppServerStartedThread { session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")), turns: Vec::new(), + blocks_direct_input: false, }, &mut child_snapshot, ) @@ -5778,11 +5928,13 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { AppServerStartedThread { session: resumed_session.clone(), turns: resumed_turns.clone(), + blocks_direct_input: true, }, &mut snapshot, ) .await; + assert!(app.agent_navigation.is_parent_owned(thread_id)); assert_eq!(snapshot.session, Some(resumed_session.clone())); assert_eq!(snapshot.turns, resumed_turns); diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 51d6622e16..193ea5aa12 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -162,6 +162,7 @@ async fn startup_thread_started_submits_queued_startup_input() { Ok(AppServerStartedThread { session: test_thread_session(thread_id, test_path_buf("/tmp/project")), turns: Vec::new(), + blocks_direct_input: false, }), ) .await @@ -239,6 +240,7 @@ fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { Ok(AppServerStartedThread { session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")), turns: Vec::new(), + blocks_direct_input: false, }), ) .await?; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 671a3ce560..d4043ba36c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1245,7 +1245,10 @@ impl App { started: AppServerStartedThread, snapshot: &mut ThreadEventSnapshot, ) { - let AppServerStartedThread { session, turns } = started; + if started.blocks_direct_input { + self.agent_navigation.mark_parent_owned(thread_id); + } + let AppServerStartedThread { session, turns, .. } = started; if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; store.set_session(session.clone(), turns.clone()); diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 602c5b2101..b867b95354 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -425,6 +425,7 @@ mod tests { cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f7a37e1f60..99c25db404 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::ReviewDelivery; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; @@ -119,6 +120,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::protocol::SubAgentSource; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use color_eyre::eyre::ContextCompat; @@ -215,6 +217,24 @@ impl ThreadParamsMode { pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, + pub(crate) blocks_direct_input: bool, +} + +pub(crate) fn source_agent_path(source: &SessionSource) -> Option { + match source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => { + agent_path.clone().map(String::from) + } + _ => None, + } +} + +/// Uses the server capability when available and preserves compatibility with older servers. +pub(crate) fn thread_blocks_direct_input(thread: &Thread) -> bool { + thread + .can_accept_direct_input + .map(|can_accept| !can_accept) + .unwrap_or_else(|| source_agent_path(&thread.source).is_some()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1568,6 +1588,7 @@ async fn started_thread_from_start_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { + let blocks_direct_input = thread_blocks_direct_input(&response.thread); let session = thread_session_state_from_thread_start_response(&response, config, thread_params_mode) .await @@ -1575,6 +1596,7 @@ async fn started_thread_from_start_response( Ok(AppServerStartedThread { session, turns: response.thread.turns, + blocks_direct_input, }) } @@ -1583,6 +1605,7 @@ async fn started_thread_from_resume_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { + let blocks_direct_input = thread_blocks_direct_input(&response.thread); let session = thread_session_state_from_thread_resume_response(&response, config, thread_params_mode) .await @@ -1590,6 +1613,7 @@ async fn started_thread_from_resume_response( Ok(AppServerStartedThread { session, turns: response.thread.turns, + blocks_direct_input, }) } @@ -1598,6 +1622,7 @@ async fn started_thread_from_fork_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { + let blocks_direct_input = thread_blocks_direct_input(&response.thread); let session = thread_session_state_from_thread_fork_response(&response, config, thread_params_mode) .await @@ -1605,6 +1630,7 @@ async fn started_thread_from_fork_response( Ok(AppServerStartedThread { session, turns: response.thread.turns, + blocks_direct_input, }) } @@ -2493,6 +2519,7 @@ mod tests { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -2568,6 +2595,7 @@ mod tests { assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); + assert!(!started.blocks_direct_input); let embedded_config = ConfigBuilder::default() .codex_home(temp_dir.path().join("embedded-codex-home")) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index eec2b7d765..33ba7d2052 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -80,6 +80,13 @@ //! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so //! pasted content and text elements are preserved when extracting args. //! +//! # Parent-Owned Thread Mode +//! +//! Parent-owned subagent threads keep the draft editable while blocking agent-directed submission. +//! On the `Enter` and `Tab` submission paths, normal prompts, disallowed slash commands, and `!` +//! shell commands return `ParentOwnedInputBlocked` without clearing the draft. Bare local and +//! navigation slash commands remain available so users can leave or manage the view. +//! //! # Large Paste Placeholders //! //! Large pastes insert an element placeholder in the buffer and store the full text in @@ -320,9 +327,59 @@ pub enum InputResult { /// command-history entry still represents the original command invocation that should be /// committed only if dispatch accepts it. CommandWithArgs(SlashCommand, String, Vec), + /// Agent-directed input was attempted while viewing a parent-owned spawned child thread. + ParentOwnedInputBlocked, None, } +fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool { + args.is_empty() + && matches!( + command, + SlashCommand::Feedback + | SlashCommand::New + | SlashCommand::Clear + | SlashCommand::Resume + | SlashCommand::App + | SlashCommand::Side + | SlashCommand::Btw + | SlashCommand::Agent + | SlashCommand::MultiAgents + | SlashCommand::Vim + | SlashCommand::Keymap + | SlashCommand::ElevateSandbox + | SlashCommand::SandboxReadRoot + | SlashCommand::Experimental + | SlashCommand::Memories + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Logout + | SlashCommand::Copy + | SlashCommand::Raw + | SlashCommand::Diff + | SlashCommand::Mention + | SlashCommand::Skills + | SlashCommand::Import + | SlashCommand::Hooks + | SlashCommand::Status + | SlashCommand::Usage + | SlashCommand::Ide + | SlashCommand::DebugConfig + | SlashCommand::Title + | SlashCommand::Statusline + | SlashCommand::Theme + | SlashCommand::Pets + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Plugins + | SlashCommand::Rollout + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QueuedInputAction { Plain, @@ -384,6 +441,7 @@ pub(crate) struct ChatComposer { frame_requester: Option, attachments: AttachmentState, placeholder_text: String, + blocks_direct_input: bool, is_task_running: bool, queue_submissions: bool, /// Slash-command draft staged for local recall after application-level dispatch. @@ -434,7 +492,7 @@ struct ComposerDraft { cursor: usize, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct ComposerDraftSnapshot { pub(crate) text: String, pub(crate) text_elements: Vec, @@ -559,6 +617,7 @@ impl ChatComposer { frame_requester: None, attachments: AttachmentState::default(), placeholder_text, + blocks_direct_input: false, is_task_running: false, queue_submissions: false, pending_slash_command_history: None, @@ -1368,6 +1427,11 @@ impl ChatComposer { self.placeholder_text = placeholder; } + pub(crate) fn set_parent_owned_thread(&mut self) { + self.blocks_direct_input = true; + self.placeholder_text = "Viewing sub-agent — direct input is disabled".to_string(); + } + /// Move the cursor to the end of the current text buffer. pub(crate) fn move_cursor_to_end(&mut self) { self.draft @@ -2776,6 +2840,43 @@ impl ChatComposer { should_queue: bool, now: Instant, ) -> (InputResult, bool) { + // Preserve newlines that are part of a paste before applying parent-owned submission + // policy. Queued startup input still flushes the burst into its queued message below. + let in_slash_context = self.slash_commands_enabled() + && !self.draft.is_bash_mode + && (matches!(self.popups.active, ActivePopup::Command(_)) + || self + .draft + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/')); + if !should_queue + && !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && !in_slash_context + && self.draft.paste_burst.append_newline_if_active(now) + { + return (InputResult::None, true); + } + if !should_queue + && !in_slash_context + && !self.draft.disable_paste_burst + && self + .draft + .paste_burst + .newline_should_insert_instead_of_submit(now) + { + self.draft.textarea.insert_str("\n"); + self.draft.paste_burst.extend_window(now); + return (InputResult::None, true); + } + + if let Some(result) = self.handle_parent_owned_submission() { + return result; + } if should_queue { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); @@ -2829,41 +2930,6 @@ impl ChatComposer { return (result, true); } - // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat as paste inside a slash-command context. - let in_slash_context = self.slash_commands_enabled() - && !self.draft.is_bash_mode - && (matches!(self.popups.active, ActivePopup::Command(_)) - || self - .draft - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/')); - if !self.draft.disable_paste_burst - && self.draft.paste_burst.is_active() - && !in_slash_context - && self.draft.paste_burst.append_newline_if_active(now) - { - return (InputResult::None, true); - } - - // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. - if !in_slash_context - && !self.draft.disable_paste_burst - && self - .draft - .paste_burst - .newline_should_insert_instead_of_submit(now) - { - self.draft.textarea.insert_str("\n"); - self.draft.paste_burst.extend_window(now); - return (InputResult::None, true); - } - let original_input = self.current_text(); let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); @@ -2910,6 +2976,27 @@ impl ChatComposer { } } + fn handle_parent_owned_submission(&mut self) -> Option<(InputResult, bool)> { + if !self.blocks_direct_input { + return None; + } + + let text = self.current_text(); + let allowed_slash_command = parse_slash_name(&text).is_some_and(|(name, args, _)| { + matches!( + self.slash_input().command(name), + Some(SlashCommandItem::Builtin(command)) + if name == command.command() + && parent_owned_command_is_allowed(command, args) + ) + }); + if text.starts_with('/') && allowed_slash_command { + return None; + } + + Some((InputResult::ParentOwnedInputBlocked, true)) + } + /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { @@ -4565,6 +4652,47 @@ mod tests { ) } + #[test] + fn parent_owned_thread_allows_bare_navigation_commands() { + for (command, expected) in [ + ("/agent", SlashCommand::Agent), + ("/side", SlashCommand::Side), + ("/btw", SlashCommand::Btw), + ("/diff ", SlashCommand::Diff), + ] { + let (mut composer, _rx) = new_test_composer(); + composer.set_parent_owned_thread(); + composer.set_text_content(command.to_string(), Vec::new(), Vec::new()); + + assert_eq!( + composer.handle_submission(/*should_queue*/ false).0, + InputResult::Command(expected) + ); + } + } + + #[test] + fn parent_owned_thread_allows_safe_command_selected_from_prefix() { + let (mut composer, _rx) = new_test_composer(); + composer.set_parent_owned_thread(); + type_chars_humanlike(&mut composer, &['/', 'a', 'g']); + + let result = composer + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .0; + + assert_eq!(result, InputResult::Command(SlashCommand::Agent)); + } + + #[test] + fn parent_owned_thread_placeholder_snapshot() { + snapshot_composer_state( + "parent_owned_thread_placeholder", + /*enhanced_keys_supported*/ false, + ChatComposer::set_parent_owned_thread, + ); + } + #[test] fn footer_hint_row_is_separated_from_composer() { let (tx, _rx) = unbounded_channel::(); @@ -7984,54 +8112,51 @@ mod tests { /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a /// newline into the buffered payload and flush as a single paste later. #[test] - fn ascii_burst_treats_enter_as_newline() { + fn ascii_burst_treats_enter_as_newline_even_when_parent_owned() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); + for parent_owned in [false, true] { + let (mut composer, _rx) = new_test_composer(); + if parent_owned { + composer.set_parent_owned_thread(); + } + let mut now = Instant::now(); + let step = Duration::from_millis(1); - let mut now = Instant::now(); - let step = Duration::from_millis(1); - - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - now, - ); - now += step; - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - now, - ); - now += step; - - let (result, _) = composer.handle_submission_with_time(/*should_queue*/ false, now); - assert!( - matches!(result, InputResult::None), - "Enter during a burst should insert newline, not submit" - ); - - for ch in ['t', 'h', 'e', 'r', 'e'] { - now += step; let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), now, ); - } + now += step; + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + now, + ); + now += step; - assert!(composer.draft.textarea.text().is_empty()); - let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; - let flushed = composer.handle_paste_burst_flush(flush_time); - assert!(flushed, "expected paste burst to flush"); - assert_eq!(composer.draft.textarea.text(), "hi\nthere"); + let (result, _) = + composer.handle_submission_with_time(/*should_queue*/ false, now); + assert!( + matches!(result, InputResult::None), + "Enter during a burst should insert newline, not submit" + ); + + for ch in ['t', 'h', 'e', 'r', 'e'] { + now += step; + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + } + + assert!(composer.draft.textarea.text().is_empty()); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); + assert!(flushed, "expected paste burst to flush"); + assert_eq!(composer.draft.textarea.text(), "hi\nthere"); + } } /// Behavior: startup-pending submissions are queued immediately, so Enter should flush any @@ -8947,6 +9072,9 @@ mod tests { InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } + InputResult::ParentOwnedInputBlocked => { + panic!("expected command dispatch, but parent-owned input was blocked") + } InputResult::None => panic!("expected Command result for '/init'"), } assert!( @@ -9454,6 +9582,9 @@ mod tests { InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } + InputResult::ParentOwnedInputBlocked => { + panic!("expected command dispatch, but parent-owned input was blocked") + } InputResult::None => panic!("expected Command result for '/diff'"), } assert!(composer.draft.textarea.is_empty()); @@ -9651,6 +9782,9 @@ mod tests { InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } + InputResult::ParentOwnedInputBlocked => { + panic!("expected command dispatch, but parent-owned input was blocked") + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs index 623a1a6bc5..d8a6201946 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -24,6 +24,7 @@ use super::ActivePopup; use super::ChatComposer; use super::InputResult; use super::QueuedInputAction; +use super::parent_owned_command_is_allowed; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SlashValidation { @@ -186,7 +187,7 @@ impl<'a> SlashInput<'a> { command_popup } - fn command(&self, name: &str) -> Option { + pub(super) fn command(&self, name: &str) -> Option { find_slash_command(name, self.command_flags, self.service_tier_commands) } } @@ -342,6 +343,19 @@ impl ChatComposer { .. } => { if let Some(sel) = popup.selected_item() { + if self.blocks_direct_input { + let command_is_allowed = match &sel { + CommandItem::Builtin(cmd) => { + parse_slash_name(self.draft.textarea.text()).is_some_and( + |(_, args, _)| parent_owned_command_is_allowed(*cmd, args), + ) + } + CommandItem::ServiceTier(_) => false, + }; + if !command_is_allowed { + return (InputResult::ParentOwnedInputBlocked, true); + } + } if self .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( &sel, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f8f2936d1e..2e657f5067 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -448,6 +448,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_parent_owned_thread(&mut self) { + self.composer.set_parent_owned_thread(); + self.request_redraw(); + } + /// Update the key hint shown next to queued messages so it matches the /// binding that `ChatWidget` actually listens for. pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option) { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap new file mode 100644 index 0000000000..bf5ec9f16a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Viewing sub-agent — direct input is disabled " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index becd52eaf1..cd5a53ddb6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -202,6 +202,8 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; const PET_SELECTION_LOADING_VIEW_ID: &str = "pet-selection-loading"; const AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; +const PARENT_OWNED_INPUT_MESSAGE: &str = + "This sub-agent is controlled by its parent. Direct input is disabled."; /// Choose the keybinding used to edit the most-recently queued message. /// @@ -650,6 +652,7 @@ pub(crate) struct ChatWidget { thread_name: Option, thread_rename_block_message: Option, active_side_conversation: bool, + blocks_direct_input: bool, normal_placeholder_text: String, side_placeholder_text: String, forked_from: Option, @@ -1732,6 +1735,15 @@ impl ChatWidget { T: Into, { let op: AppCommand = op.into(); + if self.blocks_direct_input + && matches!( + &op, + AppCommand::UserTurn { .. } | AppCommand::Review { .. } | AppCommand::Compact + ) + { + self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + return false; + } self.prepare_local_op_submission(&op); if op.is_review() && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 3cc80ce885..51a667dbde 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -194,6 +194,7 @@ impl ChatWidget { thread_name: None, thread_rename_block_message: None, active_side_conversation: false, + blocks_direct_input: false, normal_placeholder_text: placeholder, side_placeholder_text: side_placeholder, forked_from: None, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index eea54352ba..77ad911346 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -7,6 +7,11 @@ use super::*; impl ChatWidget { + pub(crate) fn set_parent_owned_thread(&mut self) { + self.blocks_direct_input = true; + self.bottom_pane.set_parent_owned_thread(); + } + pub(super) fn handle_composer_input_result( &mut self, input_result: InputResult, @@ -62,6 +67,9 @@ impl ChatWidget { InputResult::CommandWithArgs(cmd, args, text_elements) => { self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); } + InputResult::ParentOwnedInputBlocked => { + self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + } InputResult::None => {} } if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { @@ -124,6 +132,9 @@ impl ChatWidget { if self.input_queue.suppress_queue_autosend { return false; } + if self.blocks_direct_input { + return false; + } if self.is_user_turn_pending_or_running() { return false; } @@ -189,6 +200,10 @@ impl ChatWidget { text: String, mut collaboration_mode: CollaborationModeMask, ) { + if self.blocks_direct_input { + self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + return; + } if collaboration_mode.mode == Some(ModeKind::Plan) && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index faa1622b0a..1f1e963f59 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -19,6 +19,12 @@ impl ChatWidget { if self.elevated_windows_sandbox_setup_required() { return; } + if self.blocks_direct_input { + if let Some(user_message) = self.initial_user_message.take() { + self.restore_user_message_to_composer(user_message); + } + return; + } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index c95e821789..c59a818851 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -168,8 +168,12 @@ impl ChatWidget { && !self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() => { - self.cycle_collaboration_mode(); - self.refresh_plan_mode_nudge(); + if self.blocks_direct_input { + self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + } else { + self.cycle_collaboration_mode(); + self.refresh_plan_mode_nudge(); + } } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index b247ca9e65..bcccb74b0b 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -19,6 +19,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use crossterm::event::KeyEvent; use super::ChatWidget; +use super::PARENT_OWNED_INPUT_MESSAGE; use crate::app_event::AppEvent; use crate::key_hint::KeyBindingListExt; @@ -72,6 +73,11 @@ impl ChatWidget { return false; } + if self.blocks_direct_input { + self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + return true; + } + if !self.is_session_configured() { self.add_info_message( "Reasoning shortcuts are disabled until startup completes.".to_string(), diff --git a/codex-rs/tui/src/chatwidget/side.rs b/codex-rs/tui/src/chatwidget/side.rs index e379fe3606..7e0726430d 100644 --- a/codex-rs/tui/src/chatwidget/side.rs +++ b/codex-rs/tui/src/chatwidget/side.rs @@ -23,6 +23,9 @@ impl ChatWidget { }; self.bottom_pane.set_placeholder_text(placeholder); self.bottom_pane.set_side_conversation_active(active); + if self.blocks_direct_input && !active { + self.bottom_pane.set_parent_owned_thread(); + } } pub(crate) fn side_conversation_active(&self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index f729a52547..0f725d6999 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -254,6 +254,10 @@ impl ChatWidget { self.submit_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { + if self.blocks_direct_input { + self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + return; + } self.clear_token_usage(); if !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_input.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_input.snap new file mode 100644 index 0000000000..f68f851bc6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_input.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/composer_submission.rs +expression: rendered +--- +■ This sub-agent is controlled by its parent. Direct input is disabled. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_settings_shortcuts.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_settings_shortcuts.snap new file mode 100644 index 0000000000..8d637fdcd3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__parent_owned_thread_rejects_settings_shortcuts.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/composer_submission.rs +expression: rendered +--- +■ This sub-agent is controlled by its parent. Direct input is disabled. +■ This sub-agent is controlled by its parent. Direct input is disabled. +■ This sub-agent is controlled by its parent. Direct input is disabled. diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 8ccf34569d..2b3a78eb25 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -9,6 +9,149 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::collections::VecDeque; +#[tokio::test] +async fn parent_owned_thread_blocks_all_direct_input_entry_points() { + let (mut chat, mut rx, mut op_rx) = + make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + drain_insert_history(&mut rx); + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + chat.set_parent_owned_thread(); + chat.set_side_conversation_active(/*active*/ false); + chat.bottom_pane + .set_composer_text("keep this draft".to_string(), Vec::new(), Vec::new()); + let before = chat.bottom_pane.composer_draft_snapshot(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); + assert_no_submit_op(&mut op_rx); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .flatten() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_chatwidget_snapshot!("parent_owned_thread_rejects_input", rendered); + + let collaboration_mode_before = chat.active_collaboration_mask.clone(); + let plan_mode = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mode"); + chat.submit_user_message_with_mode("Implement the plan.".to_string(), plan_mode); + assert_eq!(chat.active_collaboration_mask, collaboration_mode_before); + assert_no_submit_op(&mut op_rx); + + for command in [ + "/init", + "/review check this", + "/side inspect this", + "/archive", + "/rename", + "/agent parent", + "/diff now", + "!echo blocked", + " !echo blocked", + ] { + chat.bottom_pane + .set_composer_text(command.to_string(), Vec::new(), Vec::new()); + let before = chat.bottom_pane.composer_draft_snapshot(); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); + assert_no_submit_op(&mut op_rx); + } + + assert!(!chat.submit_op(AppCommand::compact())); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn parent_owned_thread_blocks_settings_shortcuts() { + let (mut chat, mut rx, _op_rx) = + make_chatwidget_manual(/*model_override*/ Some("gpt-5.4")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + chat.set_parent_owned_thread(); + drain_insert_history(&mut rx); + + let collaboration_mode_before = chat.active_collaboration_mask.clone(); + let reasoning_effort_before = chat.current_reasoning_effort(); + + for key_event in [ + KeyEvent::from(KeyCode::BackTab), + KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT), + ] { + chat.handle_key_event(key_event); + } + + assert_eq!(chat.active_collaboration_mask, collaboration_mode_before); + assert_eq!(chat.current_reasoning_effort(), reasoning_effort_before); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().all(|event| !matches!( + event, + AppEvent::SubmitThreadOp { .. } + | AppEvent::UpdateModel(_) + | AppEvent::UpdateReasoningEffort(_) + | AppEvent::UpdatePlanModeReasoningEffort(_) + ))); + + let rendered = events + .into_iter() + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell.display_lines(/*width*/ 80)), + _ => None, + }) + .flatten() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_chatwidget_snapshot!("parent_owned_thread_rejects_settings_shortcuts", rendered); +} + +#[tokio::test] +async fn parent_owned_thread_restores_pending_initial_prompt() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await; + let pending_prompt = "keep this startup prompt".to_string(); + chat.initial_user_message = Some(pending_prompt.clone().into()); + chat.set_parent_owned_thread(); + + chat.submit_initial_user_message_if_pending(); + + assert_eq!(chat.bottom_pane.composer_text(), pending_prompt); + assert!(chat.initial_user_message.is_none()); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn parent_owned_thread_preserves_queued_input_before_draining() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await; + let queued_message = QueuedUserMessage { + user_message: UserMessage::from("keep this queued prompt"), + action: QueuedInputAction::Plain, + pending_pastes: vec![("[Image 1]".to_string(), "pasted contents".to_string())], + }; + let history_record = UserMessageHistoryRecord::UserMessageText; + chat.input_queue + .queued_user_messages + .push_back(queued_message.clone()); + chat.input_queue + .queued_user_message_history_records + .push_back(history_record.clone()); + chat.set_parent_owned_thread(); + + assert!(!chat.maybe_send_next_queued_input()); + assert_eq!( + chat.input_queue.queued_user_messages, + VecDeque::from([queued_message]) + ); + assert_eq!( + chat.input_queue.queued_user_message_history_records, + VecDeque::from([history_record]) + ); + assert_no_submit_op(&mut op_rx); +} + #[tokio::test] async fn submission_preserves_text_elements_and_local_images() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e0e3907246..98e770e045 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5737,6 +5737,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -5775,6 +5776,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -5847,6 +5849,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, @@ -5908,6 +5911,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None,