diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 81170c09e9..5e48662efb 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -482,7 +482,7 @@ impl RealtimeWebsocketWriter { SessionTool { kind: "function".to_string(), name: "manage_runtime_settings".to_string(), - description: "Inspect or update runtime settings for future Codex turns. Prefer this over codex when the user wants to inspect or change model, working_directory, reasoning_effort, fast_mode, personality, or collaboration_mode. Call with no setting fields to list current settings, possible settings, and allowed values. Supported setting keys: model, working_directory, reasoning_effort, fast_mode, personality, collaboration_mode. Changes are not persisted to disk.".to_string(), + description: "Inspect or update runtime settings for future Codex turns, and inspect quick local context like the current working_directory and git_branch. Prefer this over codex when the user wants to inspect or change model, working_directory, reasoning_effort, fast_mode, personality, or collaboration_mode, or asks quick local questions like which branch Codex is on. Call with no setting fields to list current settings, current local context, possible settings, and allowed values. Supported writable setting keys: model, working_directory, reasoning_effort, fast_mode, personality, collaboration_mode. git_branch is read-only context. Changes are not persisted to disk.".to_string(), parameters: SessionToolParameters { kind: "object".to_string(), properties: BTreeMap::from([ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 9eaeb94902..88bd07a577 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -638,35 +638,29 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option = self .pending_steers diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 265f5d5376..91b50deb4c 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -26,11 +26,14 @@ const REALTIME_CONVERSATION_PROMPT: &str = concat!( "Use codex whenever the user asks for codebase-specific facts, debugging, file changes, ", "command output, or anything that benefits from tools. ", "Do not use codex for pure control actions when a dedicated control function fits. ", + "Prefer dedicated client-side control functions over codex whenever the answer can come from current TUI state instead of a new Codex turn. ", "Use manage_message_queue to inspect or edit queued draft work. Supported actions are list, replace_last, remove_last, and clear. ", "If the user asks what is queued, or asks to replace, remove, or clear queued draft work, use manage_message_queue instead of codex. ", "Use manage_runtime_settings to inspect or change runtime settings for future turns, including model, working_directory, reasoning_effort, fast_mode, personality, and collaboration_mode. ", - "If the user asks to inspect or change those runtime settings, use manage_runtime_settings instead of codex. ", - "If you call manage_runtime_settings without any setting fields, it returns the current settings and the list of possible settings and allowed values. ", + "That same function also returns quick local context like the current working_directory and git_branch, without starting a new Codex turn. ", + "If the user asks to inspect or change those runtime settings, or asks quick local questions like what branch Codex is on, use manage_runtime_settings instead of codex. ", + "For questions like what model Codex is using, what working directory it is using, what branch it is on, or what reasoning effort is active, call manage_runtime_settings instead of codex. ", + "If you call manage_runtime_settings without any setting fields, it returns the current settings, current local context, and the list of possible settings and allowed values. ", "Use run_tui_command for built-in TUI actions. Supported commands are compact, review, plan, diff, and agent. ", "If the user asks to compact, review, switch to Plan mode, show the diff, or open the agent picker, use run_tui_command instead of codex. ", "When you speak to the user directly, be extremely concise. ", @@ -306,15 +309,15 @@ impl ChatWidget { fn realtime_status_label(&self) -> Option { match self.realtime_conversation.phase { RealtimeConversationPhase::Inactive => None, - RealtimeConversationPhase::Starting => Some("voice starting".to_string()), + RealtimeConversationPhase::Starting => Some("realtime starting".to_string()), RealtimeConversationPhase::Active => Some( self.realtime_conversation .meter_text .as_ref() - .map(|meter_text| format!("voice {meter_text}")) - .unwrap_or_else(|| "voice live".to_string()), + .map(|meter_text| format!("realtime {meter_text}")) + .unwrap_or_else(|| "realtime live".to_string()), ), - RealtimeConversationPhase::Stopping => Some("voice stopping".to_string()), + RealtimeConversationPhase::Stopping => Some("realtime stopping".to_string()), } } @@ -766,14 +769,16 @@ impl ChatWidget { json!({ "status": "ok", "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string() } fn current_runtime_settings_json(&self) -> serde_json::Value { + let working_directory = self.status_line_cwd().display().to_string(); json!({ - "working_directory": self.config.cwd.display().to_string(), + "working_directory": working_directory, "model": self.current_model(), "reasoning_effort": Self::status_line_reasoning_effort_label( self.effective_reasoning_effort(), @@ -786,6 +791,34 @@ impl ChatWidget { }) } + fn current_runtime_context_json(&self) -> serde_json::Value { + let cwd = self.status_line_cwd(); + let cached_git_branch = if self.status_line_branch_cwd.as_deref() == Some(cwd) { + self.status_line_branch.clone() + } else { + None + }; + let git_branch = cached_git_branch.or_else(|| { + let output = std::process::Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|stdout| stdout.trim().to_string()) + .filter(|branch| !branch.is_empty()) + }); + + json!({ + "working_directory": cwd.display().to_string(), + "git_branch": git_branch, + }) + } + fn possible_runtime_settings_json(&self) -> serde_json::Value { let available_models = self .models_manager @@ -815,6 +848,7 @@ impl ChatWidget { "reasoning_effort_values": ["default", "none", "minimal", "low", "medium", "high", "xhigh"], "fast_mode_values": [false, true], "personality_values": ["none", "friendly", "pragmatic"], + "read_only_context_keys": ["git_branch"], "collaboration_mode_values": if plan_available { json!(["default", "plan"]) } else { @@ -851,6 +885,7 @@ impl ChatWidget { "status": "error", "message": message, "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -869,6 +904,7 @@ impl ChatWidget { "status": "error", "message": message, "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -886,6 +922,7 @@ impl ChatWidget { "status": "error", "message": message, "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -903,6 +940,7 @@ impl ChatWidget { "status": "error", "message": message, "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -926,6 +964,7 @@ impl ChatWidget { "status": "error", "message": message, "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -964,6 +1003,7 @@ impl ChatWidget { Self::status_line_reasoning_effort_label(Some(effort)), ), "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -977,6 +1017,7 @@ impl ChatWidget { "status": "error", "message": "The personality feature is disabled in this session.", "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -995,6 +1036,7 @@ impl ChatWidget { target_model ), "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -1013,6 +1055,7 @@ impl ChatWidget { "status": "error", "message": "The requested collaboration mode is not available right now.", "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string(); @@ -1106,6 +1149,7 @@ impl ChatWidget { "status": "ok", "updated": updated_fields, "current_settings": self.current_runtime_settings_json(), + "current_context": self.current_runtime_context_json(), "possible_settings": self.possible_runtime_settings_json(), }) .to_string() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap index d4b74345ec..cc6d05f820 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_recording_meter_footer.snap @@ -9,4 +9,4 @@ expression: term.backend().vt100().screen().contents() › draft while realtime is live - gpt-5.4 · /Users/pbakkum/code/codex · voice ⡿⣷⣄⠄ + realtime ⡿⣷⣄⠄ · gpt-5.4 · /Users/pbakkum/code/codex diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 56fb6d4f7d..7825da3bc9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -10594,6 +10594,59 @@ async fn realtime_handoff_can_send_immediately_while_turn_is_running() { } } +#[tokio::test] +async fn realtime_interrupt_does_not_restore_pending_steer_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.realtime_conversation + .set_phase_for_test(super::realtime::RealtimeConversationPhase::Active); + chat.on_task_started(); + + chat.handle_codex_event(Event { + id: "rt-interrupt-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff-interrupt-1".to_string(), + item_id: "item-interrupt-1".to_string(), + input_transcript: "send via realtime".to_string(), + send_immediately: true, + active_transcript: Vec::new(), + }), + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => { + assert_matches!( + items.as_slice(), + [UserInput::Text { text, .. }] if text == "send via realtime" + ); + } + other => panic!("unexpected op: {other:?}"), + } + + assert_eq!(chat.pending_steers.len(), 1); + + chat.handle_codex_event(Event { + id: "turn-abort".into(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), ""); + + let inserted = drain_insert_history(&mut rx); + assert!( + inserted + .iter() + .all(|cell| !lines_to_single_string(cell).contains("send via realtime")) + ); +} + #[tokio::test] async fn realtime_close_request_interrupts_before_disconnect_when_turn_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -10779,6 +10832,10 @@ async fn realtime_manage_runtime_settings_updates_state_and_emits_follow_up_ops( output["current_settings"]["working_directory"], canonical_cwd.display().to_string() ); + assert_eq!( + output["current_context"]["working_directory"], + canonical_cwd.display().to_string() + ); assert_eq!(output["current_settings"]["model"], "gpt-5.3-codex"); assert_eq!(output["current_settings"]["reasoning_effort"], "low"); assert_eq!( @@ -10842,6 +10899,86 @@ async fn realtime_manage_runtime_settings_lists_possible_settings() { "default", "none", "minimal", "low", "medium", "high", "xhigh" ]) ); + assert_eq!( + output["possible_settings"]["read_only_context_keys"], + serde_json::json!(["git_branch"]) + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +#[tokio::test] +async fn realtime_manage_runtime_settings_reports_live_context_and_git_branch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let temp = tempdir().expect("tempdir"); + let repo = temp.path().join("repo"); + std::fs::create_dir_all(&repo).expect("create repo dir"); + + let git_env = [ + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_NOSYSTEM", "1"), + ]; + let init_output = std::process::Command::new("git") + .envs(git_env) + .args(["init"]) + .current_dir(&repo) + .output() + .expect("git init"); + assert!( + init_output.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&init_output.stderr) + ); + + let checkout_output = std::process::Command::new("git") + .envs(git_env) + .args(["checkout", "-b", "feature/realtime"]) + .current_dir(&repo) + .output() + .expect("git checkout -b"); + assert!( + checkout_output.status.success(), + "git checkout -b failed: {}", + String::from_utf8_lossy(&checkout_output.stderr) + ); + + chat.current_cwd = Some(repo.clone()); + chat.config.cwd = temp.path().join("stale-config-cwd"); + chat.status_line_branch = None; + chat.status_line_branch_cwd = None; + + chat.handle_codex_event(Event { + id: "rt-settings-context".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "settings-tool-context".to_string(), + action: RealtimeToolAction::ManageRuntimeSettings { + model: None, + working_directory: None, + reasoning_effort: None, + fast_mode: None, + personality: None, + collaboration_mode: None, + }, + }), + }), + }); + + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params)) => { + assert_eq!(params.call_id, "settings-tool-context"); + let output = + serde_json::from_str::(¶ms.output_text).expect("json"); + assert_eq!( + output["current_settings"]["working_directory"], + repo.display().to_string() + ); + assert_eq!( + output["current_context"]["working_directory"], + repo.display().to_string() + ); + assert_eq!(output["current_context"]["git_branch"], "feature/realtime"); } other => panic!("unexpected app event: {other:?}"), }