From bf430ad9fee5583afab2bd6641407d67383be297 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 19 Jan 2026 09:32:04 -0800 Subject: [PATCH] TUI: collaboration mode UX + always submit `UserTurn` when enabled (#9461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds experimental collaboration modes UX in TUI: Plan / Pair Programming / Execute. - Gated behind `Feature::CollaborationModes`; existing behavior remains unchanged when disabled. - Selection UX: - `Shift+Tab` cycles modes while idle (no task running, no modal/popup). - `/collab` cycles; `/collab ` sets explicitly. - Footer flash after changes + shortcut overlay shows `Shift+Tab` “to change mode”. - `/status` shows “Collaboration mode”. - Submission semantics: - When enabled: every submit uses `Op::UserTurn` and always includes `collaboration_mode: Some(...)` (default Pair Programming). - Removes the one-shot “pending collaboration mode” behavior. - Implementation: - New `tui/src/collaboration_modes.rs` (selection enum/cycle, `/collab` parsing, resolve to `CollaborationMode`, footer flash line). - Fallback: `resolve_mode_or_fallback` synthesizes a `CollaborationMode` when presets are missing (uses current model + reasoning effort; no `developer_instructions`) to avoid core falling back to `Custom`. - TODO: migrate TUI to use `Op::UserTurn`. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 188 ++++++++++++++---- codex-rs/tui/src/bottom_pane/command_popup.rs | 65 +++++- codex-rs/tui/src/bottom_pane/footer.rs | 76 ++++++- codex-rs/tui/src/bottom_pane/mod.rs | 23 +++ ...shortcuts_collaboration_modes_enabled.snap | 11 + codex-rs/tui/src/chatwidget.rs | 117 ++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 114 +++++++++++ codex-rs/tui/src/collaboration_modes.rs | 135 +++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 3 + codex-rs/tui/src/status/card.rs | 12 ++ codex-rs/tui/src/status/tests.rs | 15 ++ .../tui2/src/bottom_pane/chat_composer.rs | 188 ++++++++++++++---- .../tui2/src/bottom_pane/command_popup.rs | 65 +++++- codex-rs/tui2/src/bottom_pane/footer.rs | 83 +++++++- codex-rs/tui2/src/bottom_pane/mod.rs | 23 +++ ...shortcuts_collaboration_modes_enabled.snap | 10 + codex-rs/tui2/src/chatwidget.rs | 119 ++++++++++- codex-rs/tui2/src/chatwidget/tests.rs | 116 ++++++++++- codex-rs/tui2/src/collaboration_modes.rs | 135 +++++++++++++ codex-rs/tui2/src/lib.rs | 1 + codex-rs/tui2/src/slash_command.rs | 3 + codex-rs/tui2/src/status/card.rs | 12 ++ codex-rs/tui2/src/status/tests.rs | 15 ++ 24 files changed, 1407 insertions(+), 123 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap create mode 100644 codex-rs/tui/src/collaboration_modes.rs create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap create mode 100644 codex-rs/tui2/src/collaboration_modes.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 343403704..3ab907134 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -69,7 +69,6 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -80,12 +79,15 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; +use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::inset_footer_hint_area; use super::footer::render_footer; +use super::footer::render_footer_hint_items; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -191,12 +193,20 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, skills: Option>, dismissed_skill_popup_token: Option, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, + collaboration_modes_enabled: bool, +} + +#[derive(Clone, Debug)] +struct FooterFlash { + line: Line<'static>, + expires_at: Instant, } /// Popup state – at most one can be visible at any time. @@ -244,11 +254,13 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, + footer_flash: None, context_window_percent: None, context_window_used_tokens: None, skills: None, dismissed_skill_popup_token: None, steer_enabled: false, + collaboration_modes_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -269,6 +281,10 @@ impl ChatComposer { self.steer_enabled = enabled; } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -499,6 +515,19 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.footer_flash = Some(FooterFlash { line, expires_at }); + } + + pub(crate) fn footer_flash_visible(&self) -> bool { + self.footer_flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { // Clear any existing content, placeholders, and attachments first. @@ -1347,12 +1376,9 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); + let is_builtin = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1469,12 +1495,9 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(n, _)| *n == name) { self.textarea.set_text(""); Some(InputResult::Command(cmd)) @@ -1494,9 +1517,9 @@ impl ChatComposer { if let Some((name, rest)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text(""); @@ -1843,6 +1866,7 @@ impl ChatComposer { is_task_running: self.is_task_running, quit_shortcut_key: self.quit_shortcut_key, steer_enabled: self.steer_enabled, + collaboration_modes_enabled: self.collaboration_modes_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, } @@ -1865,6 +1889,9 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { + if self.footer_flash_visible() { + return Some(1); + } self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) @@ -1948,12 +1975,9 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -2006,8 +2030,14 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); - let mut command_popup = - CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + let collaboration_modes_enabled = self.collaboration_modes_enabled; + let mut command_popup = CommandPopup::new( + self.custom_prompts.clone(), + CommandPopupFlags { + skills_enabled, + collaboration_modes_enabled, + }, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -2015,6 +2045,16 @@ impl ChatComposer { } } + fn built_in_slash_commands_for_input( + collaboration_modes_enabled: bool, + ) -> impl Iterator { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); + built_in_slash_commands() + .into_iter() + .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2180,24 +2220,12 @@ impl Renderable for ChatComposer { } else { popup_rect }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); } else { render_footer(hint_rect, buf, footer_props); } @@ -2357,6 +2385,84 @@ mod tests { ); } + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer_flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 10a469c68..83afdca30 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -37,13 +37,20 @@ pub(crate) struct CommandPopup { state: ScrollState, } +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) skills_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, +} + impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -231,7 +238,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -251,7 +258,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -266,7 +273,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -280,7 +287,7 @@ mod tests { #[test] fn filtered_commands_keep_presentation_order() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup @@ -322,7 +329,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts, false); + let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -346,7 +353,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { @@ -369,7 +376,7 @@ mod tests { description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -389,7 +396,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -398,7 +405,7 @@ mod tests { #[test] fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup @@ -414,4 +421,40 @@ mod tests { "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" ); } + + #[test] + fn collab_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/coll".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert!( + !cmds.contains(&"collab"), + "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn collab_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + skills_enabled: false, + collaboration_modes_enabled: true, + }, + ); + popup.on_composer_text_change("/collab".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + other => panic!("expected collab to be selected for exact match, got {other:?}"), + } + } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 42c0392a6..5a54dd11d 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -37,6 +37,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) steer_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. @@ -103,6 +104,31 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans).render(inset_footer_hint_area(area), buf); +} + fn footer_lines(props: FooterProps) -> Vec> { // Show the context indicator on the left, appended after the primary hint // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when @@ -134,6 +160,7 @@ fn footer_lines(props: FooterProps) -> Vec> { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, }; shortcut_overlay_lines(state) } @@ -158,6 +185,7 @@ struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_wsl: bool, + collaboration_modes_enabled: bool, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -190,6 +218,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -204,11 +233,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, } } } - let ordered = vec![ + let mut ordered = vec![ commands, shell_commands, newline, @@ -218,9 +248,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { external_editor, edit_previous, quit, - Line::from(""), - show_transcript, ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(Line::from("")); + ordered.push(show_transcript); build_columns(ordered) } @@ -298,6 +331,7 @@ enum ShortcutId { EditPrevious, Quit, ShowTranscript, + ChangeMode, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -318,6 +352,7 @@ enum DisplayCondition { WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, + WhenCollaborationModesEnabled, } impl DisplayCondition { @@ -327,6 +362,7 @@ impl DisplayCondition { DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } } } @@ -469,6 +505,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, ]; #[cfg(test)] @@ -500,6 +545,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -514,6 +560,22 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -528,6 +590,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -542,6 +605,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -556,6 +620,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -570,6 +635,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -584,6 +650,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, @@ -598,6 +665,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), @@ -612,6 +680,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -626,6 +695,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: true, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f270145a0..088d8b4f6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -32,6 +32,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod approval_overlay; @@ -188,6 +189,11 @@ impl BottomPane { self.composer.set_steer_enabled(enabled); } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -508,6 +514,23 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(duration).await; + frame_requester.schedule_frame(); + }); + } else { + std::thread::spawn(move || { + std::thread::sleep(duration); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 000000000..b6d87789a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 535 +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" ctrl + j for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + c to exit shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bab27efd0..11a0ceac8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -136,6 +136,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; +use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -371,6 +372,8 @@ pub(crate) enum ExternalEditorState { Active, } +type CollaborationModeSelection = collaboration_modes::Selection; + /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -400,6 +403,11 @@ pub(crate) struct ChatWidget { active_cell_revision: u64, config: Config, model: Option, + /// Current UI selection for collaboration modes. + /// + /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the + /// feature is disabled, the value is effectively inert. + collaboration_mode: CollaborationModeSelection, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -1673,6 +1681,7 @@ impl ChatWidget { active_cell_revision: 0, config, model, + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1722,6 +1731,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1772,6 +1784,7 @@ impl ChatWidget { active_cell_revision: 0, config, model: Some(header_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1821,6 +1834,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1885,6 +1901,16 @@ impl ChatWidget { } match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, @@ -2022,6 +2048,11 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Collab => { + if self.collaboration_modes_enabled() { + self.cycle_collaboration_mode(); + } + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -2178,6 +2209,16 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Collab if !trimmed.is_empty() => { + if let Some(selection) = collaboration_modes::parse_selection(trimmed) { + self.set_collaboration_mode(selection); + } else { + self.add_error_message(format!( + "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." + )); + self.request_redraw(); + } + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -2302,14 +2343,41 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { + // TODO(aibrahim): migrate the TUI to submit `Op::UserTurn` by default (and rely less on + // `Op::UserInput`) so session-level settings like collaboration mode are consistently + // applied. + let op = if self.collaboration_modes_enabled() { + let model = self + .current_model() + .unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + .to_string(); + let collaboration_mode = collaboration_modes::resolve_mode_or_fallback( + self.models_manager.as_ref(), + self.collaboration_mode, + model.as_str(), + self.config.model_reasoning_effort, + ); + Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode: Some(collaboration_mode), + } + } else { + Op::UserInput { items, final_output_json_schema: None, - }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + } + }; + + self.codex_op_tx.send(op).unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); // Persist the text to cross-session message history. if !text.is_empty() { @@ -2633,6 +2701,11 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let collaboration_mode = if self.collaboration_modes_enabled() { + Some(self.collaboration_mode.label()) + } else { + None + }; self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2644,6 +2717,7 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), + collaboration_mode, )); } @@ -3893,6 +3967,8 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); + } else if feature == Feature::CollaborationModes { + self.bottom_pane.set_collaboration_modes_enabled(enabled); } } @@ -3930,10 +4006,39 @@ impl ChatWidget { self.model = Some(model.to_string()); } + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + let next = self.collaboration_mode.next(); + self.set_collaboration_mode(next); + } + + /// Update the selected collaboration mode. + /// + /// When collaboration modes are enabled, the current selection is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + if !self.collaboration_modes_enabled() { + return; + } + const FLASH_DURATION: Duration = Duration::from_secs(1); + + self.collaboration_mode = selection; + + let flash = collaboration_modes::flash_line(selection); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.request_redraw(); + } + fn current_model(&self) -> Option<&str> { self.model.as_deref() } + fn collaboration_modes_enabled(&self) -> bool { + self.config.features.enabled(Feature::CollaborationModes) + } + fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 19d10ad51..f9ec776ff 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -59,6 +59,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -404,6 +405,7 @@ async fn make_chatwidget_manual( active_cell_revision: 0, config: cfg, model: Some(resolved_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), session_header: SessionHeader::new(resolved_model), @@ -449,6 +451,20 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper +// filters until we see a submission op. +fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + loop { + match op_rx.try_recv() { + Ok(op @ Op::UserTurn { .. }) => return op, + Ok(op @ Op::UserInput { .. }) => return op, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + } + } +} + fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1506,6 +1522,104 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[test] +fn parse_collaboration_mode_selection_accepts_common_aliases() { + assert_eq!( + collaboration_modes::parse_selection("plan"), + Some(CollaborationModeSelection::Plan) + ); + assert_eq!( + collaboration_modes::parse_selection("PAIR"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pair_programming"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pp"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection(" exec "), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!( + collaboration_modes::parse_selection("execute"), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!(collaboration_modes::parse_selection("unknown"), None); +} + +#[tokio::test] +async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, false); + + let initial = chat.collaboration_mode; + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, initial); + + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.on_task_started(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); +} + +#[tokio::test] +async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } + + chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } +} + +#[tokio::test] +async fn collab_mode_defaults_to_pair_programming_when_enabled() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + } +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs new file mode 100644 index 000000000..bdd1d8b63 --- /dev/null +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -0,0 +1,135 @@ +//! Collaboration mode selection + rendering helpers for the TUI. +//! +//! This module is intentionally UI-focused: +//! - It owns the user-facing set of selectable collaboration modes and how they cycle. +//! - It parses `/collab ` arguments into a selection. +//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by +//! picking from the `ModelsManager` builtin collaboration presets. +//! - It builds the small footer "flash" line shown after changing modes. +//! +//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed +//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. + +use crate::key_hint; +use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use crossterm::event::KeyCode; +use ratatui::style::Stylize; +use ratatui::text::Line; + +/// The user-facing collaboration mode choices supported by the TUI. +/// +/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling +/// order, while `CollaborationMode` can carry nested settings/prompt configuration. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Selection { + Plan, + #[default] + PairProgramming, + Execute, +} + +impl Selection { + /// Cycle to the next selection. + /// + /// The TUI cycles through a small, fixed set of presets. + pub(crate) fn next(self) -> Self { + match self { + Self::Plan => Self::PairProgramming, + Self::PairProgramming => Self::Execute, + Self::Execute => Self::Plan, + } + } + + /// User-facing label used in UI surfaces like `/status` and the footer flash. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Plan => "Plan", + Self::PairProgramming => "Pair Programming", + Self::Execute => "Execute", + } + } +} + +/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. +/// +/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. +pub(crate) fn parse_selection(input: &str) -> Option { + let normalized: String = input + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') + .flat_map(char::to_lowercase) + .collect(); + + match normalized.as_str() { + "plan" => Some(Selection::Plan), + "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), + "execute" | "exec" => Some(Selection::Execute), + _ => None, + } +} + +/// Resolve a selection to a concrete collaboration mode preset. +/// +/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this +/// function selects the first preset of the desired variant. +pub(crate) fn resolve_mode( + models_manager: &ModelsManager, + selection: Selection, +) -> Option { + match selection { + Selection::Plan => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Plan(_))), + Selection::PairProgramming => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), + Selection::Execute => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + } +} + +/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode +/// when the desired preset is unavailable. +/// +/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when +/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries +/// an explicit collaboration mode so core doesn't fall back to `Custom`. +pub(crate) fn resolve_mode_or_fallback( + models_manager: &ModelsManager, + selection: Selection, + fallback_model: &str, + fallback_effort: Option, +) -> CollaborationMode { + resolve_mode(models_manager, selection).unwrap_or_else(|| { + let settings = Settings { + model: fallback_model.to_string(), + reasoning_effort: fallback_effort, + developer_instructions: None, + }; + + match selection { + Selection::Plan => CollaborationMode::Plan(settings), + Selection::PairProgramming => CollaborationMode::PairProgramming(settings), + Selection::Execute => CollaborationMode::Execute(settings), + } + }) +} + +/// Build a 1-line footer "flash" that is shown after switching modes. +/// +/// The `ChatWidget` controls when to show this and how long it should remain visible. +pub(crate) fn flash_line(selection: Selection) -> Line<'static> { + Line::from(vec![ + selection.label().bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 880ff1932..e57497505 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -47,6 +47,7 @@ mod chatwidget; mod cli; mod clipboard_paste; mod collab; +mod collaboration_modes; mod color; pub mod custom_terminal; mod diff_render; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d7cbd7fc1..ab63d6c7c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -24,6 +24,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Collab, // Undo, Diff, Mention, @@ -57,6 +58,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle beta features", @@ -99,6 +101,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Collab => true, } } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b860b0394..1f903c26b 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -63,6 +63,7 @@ struct StatusHistoryCell { approval: String, sandbox: String, agents_summary: String, + collaboration_mode: Option, model_provider: Option, account: Option, session_id: Option, @@ -83,6 +84,7 @@ pub(crate) fn new_status_output( plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -96,6 +98,7 @@ pub(crate) fn new_status_output( plan_type, now, model_name, + collaboration_mode, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -114,6 +117,7 @@ impl StatusHistoryCell { plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> Self { let config_entries = create_config_summary_entries(config, model_name); let (model_name, model_details) = compose_model_display(model_name, &config_entries); @@ -165,6 +169,7 @@ impl StatusHistoryCell { approval, sandbox, agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, session_id, @@ -360,6 +365,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() && self.forked_from.is_some() { push_label(&mut labels, &mut seen, "Forked from"); } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -409,6 +417,10 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b01a13952..bc421da42 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -152,6 +152,7 @@ async fn status_snapshot_includes_reasoning_details() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -203,6 +204,7 @@ async fn status_snapshot_includes_forked_from() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -305,6 +308,7 @@ async fn status_snapshot_shows_unlimited_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -349,6 +353,7 @@ async fn status_snapshot_shows_positive_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -393,6 +398,7 @@ async fn status_snapshot_hides_zero_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -435,6 +441,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -477,6 +484,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { None, now, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -534,6 +542,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -580,6 +589,7 @@ async fn status_snapshot_shows_missing_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -644,6 +654,7 @@ async fn status_snapshot_includes_credits_and_limits() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -696,6 +707,7 @@ async fn status_snapshot_shows_empty_limits_message() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -757,6 +769,7 @@ async fn status_snapshot_shows_stale_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -822,6 +835,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -877,6 +891,7 @@ async fn status_context_window_uses_last_usage() { None, now, &model_slug, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index c9058e015..541806ec4 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -70,7 +70,6 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -81,12 +80,15 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; +use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::inset_footer_hint_area; use super::footer::render_footer; +use super::footer::render_footer_hint_items; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -192,6 +194,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, transcript_scrolled: bool, @@ -203,6 +206,13 @@ pub(crate) struct ChatComposer { dismissed_skill_popup_token: Option, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, + collaboration_modes_enabled: bool, +} + +#[derive(Clone, Debug)] +struct FooterFlash { + line: Line<'static>, + expires_at: Instant, } /// Popup state – at most one can be visible at any time. @@ -250,6 +260,7 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, + footer_flash: None, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -260,6 +271,7 @@ impl ChatComposer { skills: None, dismissed_skill_popup_token: None, steer_enabled: false, + collaboration_modes_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -280,6 +292,10 @@ impl ChatComposer { self.steer_enabled = enabled; } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -431,6 +447,19 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.footer_flash = Some(FooterFlash { line, expires_at }); + } + + pub(crate) fn footer_flash_visible(&self) -> bool { + self.footer_flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { // Clear any existing content, placeholders, and attachments first. @@ -1280,12 +1309,9 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); + let is_builtin = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1402,12 +1428,9 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(n, _)| *n == name) { self.textarea.set_text(""); Some(InputResult::Command(cmd)) @@ -1427,9 +1450,9 @@ impl ChatComposer { if let Some((name, rest)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text(""); @@ -1776,6 +1799,7 @@ impl ChatComposer { is_task_running: self.is_task_running, quit_shortcut_key: self.quit_shortcut_key, steer_enabled: self.steer_enabled, + collaboration_modes_enabled: self.collaboration_modes_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, transcript_scrolled: self.transcript_scrolled, @@ -1803,6 +1827,9 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { + if self.footer_flash_visible() { + return Some(1); + } self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) @@ -1917,12 +1944,9 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -1975,8 +1999,14 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); - let mut command_popup = - CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + let collaboration_modes_enabled = self.collaboration_modes_enabled; + let mut command_popup = CommandPopup::new( + self.custom_prompts.clone(), + CommandPopupFlags { + skills_enabled, + collaboration_modes_enabled, + }, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1984,6 +2014,16 @@ impl ChatComposer { } } + fn built_in_slash_commands_for_input( + collaboration_modes_enabled: bool, + ) -> impl Iterator { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); + built_in_slash_commands() + .into_iter() + .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2149,24 +2189,12 @@ impl Renderable for ChatComposer { } else { popup_rect }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); } else { render_footer(hint_rect, buf, footer_props); } @@ -2326,6 +2354,84 @@ mod tests { ); } + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer_flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index ba49ab12c..000ce2e4e 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -37,13 +37,20 @@ pub(crate) struct CommandPopup { state: ScrollState, } +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) skills_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, +} + impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -230,7 +237,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -250,7 +257,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -265,7 +272,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -279,7 +286,7 @@ mod tests { #[test] fn filtered_commands_keep_presentation_order() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup @@ -311,7 +318,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts, false); + let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -335,7 +342,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { @@ -358,7 +365,7 @@ mod tests { description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -378,7 +385,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -387,7 +394,7 @@ mod tests { #[test] fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup @@ -403,4 +410,40 @@ mod tests { "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" ); } + + #[test] + fn collab_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/coll".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert!( + !cmds.contains(&"collab"), + "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn collab_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + skills_enabled: false, + collaboration_modes_enabled: true, + }, + ); + popup.on_composer_text_change("/collab".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + other => panic!("expected collab to be selected for exact match, got {other:?}"), + } + } } diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index bdfb5c8df..fe5a13dc4 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -38,6 +38,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) steer_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. @@ -109,6 +110,31 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans).render(inset_footer_hint_area(area), buf); +} + fn footer_lines(props: FooterProps) -> Vec> { fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) { let Some(line) = lines.first_mut() else { @@ -176,6 +202,7 @@ fn footer_lines(props: FooterProps) -> Vec> { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, }; shortcut_overlay_lines(state) } @@ -202,6 +229,7 @@ struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_wsl: bool, + collaboration_modes_enabled: bool, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -233,6 +261,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -246,11 +275,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, } } } - let ordered = vec![ + let mut ordered = vec![ commands, shell_commands, newline, @@ -259,9 +289,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, edit_previous, quit, - Line::from(""), - show_transcript, ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(Line::from("")); + ordered.push(show_transcript); build_columns(ordered) } @@ -338,6 +371,7 @@ enum ShortcutId { EditPrevious, Quit, ShowTranscript, + ChangeMode, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -358,6 +392,7 @@ enum DisplayCondition { WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, + WhenCollaborationModesEnabled, } impl DisplayCondition { @@ -367,6 +402,7 @@ impl DisplayCondition { DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } } } @@ -500,6 +536,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, ]; #[cfg(test)] @@ -531,6 +576,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -550,6 +596,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -569,6 +616,27 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -588,6 +656,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -607,6 +676,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -626,6 +696,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -645,6 +716,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -664,6 +736,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, @@ -683,6 +756,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), @@ -702,6 +776,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -721,6 +796,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: true, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -740,6 +816,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index df9c0d84c..331cf82fd 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -31,6 +31,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod approval_overlay; @@ -180,6 +181,11 @@ impl BottomPane { self.composer.set_steer_enabled(enabled); } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -500,6 +506,23 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(duration).await; + frame_requester.schedule_frame(); + }); + } else { + std::thread::spawn(move || { + std::thread::sleep(duration); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 000000000..78af40d9f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 117d320a0..5e9d6425f 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -130,6 +130,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; +use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -316,6 +317,8 @@ enum RateLimitSwitchPromptState { Shown, } +type CollaborationModeSelection = collaboration_modes::Selection; + /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -345,6 +348,11 @@ pub(crate) struct ChatWidget { active_cell_revision: u64, config: Config, model: Option, + /// Current UI selection for collaboration modes. + /// + /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the + /// feature is disabled, the value is effectively inert. + collaboration_mode: CollaborationModeSelection, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -1478,6 +1486,7 @@ impl ChatWidget { active_cell_revision: 0, config, model, + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1524,6 +1533,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1575,6 +1587,7 @@ impl ChatWidget { active_cell_revision: 0, config, model: Some(header_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1621,6 +1634,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1685,6 +1701,16 @@ impl ChatWidget { } match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, @@ -1797,6 +1823,11 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Collab => { + if self.collaboration_modes_enabled() { + self.cycle_collaboration_mode(); + } + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -1957,6 +1988,19 @@ impl ChatWidget { }, }); } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(selection) = collaboration_modes::parse_selection(trimmed) { + self.set_collaboration_mode(selection); + } else if !trimmed.is_empty() { + self.add_error_message(format!( + "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." + )); + } + } _ => self.dispatch_command(cmd), } } @@ -2069,14 +2113,43 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { + let op = if self.collaboration_modes_enabled() { + let model = self + .current_model() + .unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + .to_string(); + let collaboration_mode = collaboration_modes::resolve_mode_or_fallback( + self.models_manager.as_ref(), + self.collaboration_mode, + model.as_str(), + self.config.model_reasoning_effort, + ); + Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode: Some(collaboration_mode), + } + } else { + Op::UserInput { items, final_output_json_schema: None, - }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + } + }; + + if !self.agent_turn_running { + self.agent_turn_running = true; + self.update_task_running_state(); + } + + self.codex_op_tx.send(op).unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); // Persist the text to cross-session message history. if !text.is_empty() { @@ -2409,6 +2482,8 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), + self.collaboration_modes_enabled() + .then_some(self.collaboration_mode.label()), )); } fn stop_rate_limit_poller(&mut self) { @@ -3584,6 +3659,9 @@ impl ChatWidget { if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); } + if feature == Feature::CollaborationModes { + self.bottom_pane.set_collaboration_modes_enabled(enabled); + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { @@ -3624,10 +3702,39 @@ impl ChatWidget { self.model.as_deref() } + fn collaboration_modes_enabled(&self) -> bool { + self.config.features.enabled(Feature::CollaborationModes) + } + fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + let next = self.collaboration_mode.next(); + self.set_collaboration_mode(next); + } + + /// Update the selected collaboration mode. + /// + /// When collaboration modes are enabled, the current selection is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + if !self.collaboration_modes_enabled() { + return; + } + + self.collaboration_mode = selection; + let flash = collaboration_modes::flash_line(selection); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.request_redraw(); + } + /// Build a placeholder header cell while the session is configuring. fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index b3ad2114c..76b55e789 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -17,7 +17,6 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; -#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; @@ -57,6 +56,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -382,6 +382,7 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let widget = ChatWidget { @@ -392,6 +393,7 @@ async fn make_chatwidget_manual( active_cell_revision: 0, config: cfg, model: Some(resolved_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), session_header: SessionHeader::new(resolved_model), @@ -434,6 +436,20 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper +// filters until we see a submission op. +fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + loop { + match op_rx.try_recv() { + Ok(op @ Op::UserTurn { .. }) => return op, + Ok(op @ Op::UserInput { .. }) => return op, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + } + } +} + fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1312,6 +1328,104 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[test] +fn parse_collaboration_mode_selection_accepts_common_aliases() { + assert_eq!( + collaboration_modes::parse_selection("plan"), + Some(CollaborationModeSelection::Plan) + ); + assert_eq!( + collaboration_modes::parse_selection("PAIR"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pair_programming"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pp"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection(" exec "), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!( + collaboration_modes::parse_selection("execute"), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!(collaboration_modes::parse_selection("unknown"), None); +} + +#[tokio::test] +async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, false); + + let initial = chat.collaboration_mode; + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, initial); + + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.on_task_started(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); +} + +#[tokio::test] +async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } + + chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } +} + +#[tokio::test] +async fn collab_mode_defaults_to_pair_programming_when_enabled() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + } +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs new file mode 100644 index 000000000..bdd1d8b63 --- /dev/null +++ b/codex-rs/tui2/src/collaboration_modes.rs @@ -0,0 +1,135 @@ +//! Collaboration mode selection + rendering helpers for the TUI. +//! +//! This module is intentionally UI-focused: +//! - It owns the user-facing set of selectable collaboration modes and how they cycle. +//! - It parses `/collab ` arguments into a selection. +//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by +//! picking from the `ModelsManager` builtin collaboration presets. +//! - It builds the small footer "flash" line shown after changing modes. +//! +//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed +//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. + +use crate::key_hint; +use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use crossterm::event::KeyCode; +use ratatui::style::Stylize; +use ratatui::text::Line; + +/// The user-facing collaboration mode choices supported by the TUI. +/// +/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling +/// order, while `CollaborationMode` can carry nested settings/prompt configuration. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Selection { + Plan, + #[default] + PairProgramming, + Execute, +} + +impl Selection { + /// Cycle to the next selection. + /// + /// The TUI cycles through a small, fixed set of presets. + pub(crate) fn next(self) -> Self { + match self { + Self::Plan => Self::PairProgramming, + Self::PairProgramming => Self::Execute, + Self::Execute => Self::Plan, + } + } + + /// User-facing label used in UI surfaces like `/status` and the footer flash. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Plan => "Plan", + Self::PairProgramming => "Pair Programming", + Self::Execute => "Execute", + } + } +} + +/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. +/// +/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. +pub(crate) fn parse_selection(input: &str) -> Option { + let normalized: String = input + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') + .flat_map(char::to_lowercase) + .collect(); + + match normalized.as_str() { + "plan" => Some(Selection::Plan), + "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), + "execute" | "exec" => Some(Selection::Execute), + _ => None, + } +} + +/// Resolve a selection to a concrete collaboration mode preset. +/// +/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this +/// function selects the first preset of the desired variant. +pub(crate) fn resolve_mode( + models_manager: &ModelsManager, + selection: Selection, +) -> Option { + match selection { + Selection::Plan => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Plan(_))), + Selection::PairProgramming => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), + Selection::Execute => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + } +} + +/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode +/// when the desired preset is unavailable. +/// +/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when +/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries +/// an explicit collaboration mode so core doesn't fall back to `Custom`. +pub(crate) fn resolve_mode_or_fallback( + models_manager: &ModelsManager, + selection: Selection, + fallback_model: &str, + fallback_effort: Option, +) -> CollaborationMode { + resolve_mode(models_manager, selection).unwrap_or_else(|| { + let settings = Settings { + model: fallback_model.to_string(), + reasoning_effort: fallback_effort, + developer_instructions: None, + }; + + match selection { + Selection::Plan => CollaborationMode::Plan(settings), + Selection::PairProgramming => CollaborationMode::PairProgramming(settings), + Selection::Execute => CollaborationMode::Execute(settings), + } + }) +} + +/// Build a 1-line footer "flash" that is shown after switching modes. +/// +/// The `ChatWidget` controls when to show this and how long it should remain visible. +pub(crate) fn flash_line(selection: Selection) -> Line<'static> { + Line::from(vec![ + selection.label().bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]) +} diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 11a0f9f6b..27d649c4d 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -48,6 +48,7 @@ mod cli; mod clipboard_copy; mod clipboard_paste; mod collab; +mod collaboration_modes; mod color; pub mod custom_terminal; mod diff_render; diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index caac37b85..2e1e03298 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -23,6 +23,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Collab, // Undo, Diff, Mention, @@ -54,6 +55,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Mcp => "list configured MCP tools", @@ -93,6 +95,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Collab => true, } } diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 3e9e07d53..7988c7d0f 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -63,6 +63,7 @@ struct StatusHistoryCell { approval: String, sandbox: String, agents_summary: String, + collaboration_mode: Option, model_provider: Option, account: Option, session_id: Option, @@ -83,6 +84,7 @@ pub(crate) fn new_status_output( plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -96,6 +98,7 @@ pub(crate) fn new_status_output( plan_type, now, model_name, + collaboration_mode, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -114,6 +117,7 @@ impl StatusHistoryCell { plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> Self { let config_entries = create_config_summary_entries(config, model_name); let (model_name, model_details) = compose_model_display(model_name, &config_entries); @@ -165,6 +169,7 @@ impl StatusHistoryCell { approval, sandbox, agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, session_id, @@ -360,6 +365,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() && self.forked_from.is_some() { push_label(&mut labels, &mut seen, "Forked from"); } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -408,6 +416,10 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index e79688f44..9d0f6d083 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -152,6 +152,7 @@ async fn status_snapshot_includes_reasoning_details() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -203,6 +204,7 @@ async fn status_snapshot_includes_forked_from() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -304,6 +307,7 @@ async fn status_snapshot_shows_unlimited_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -348,6 +352,7 @@ async fn status_snapshot_shows_positive_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -392,6 +397,7 @@ async fn status_snapshot_hides_zero_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -434,6 +440,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -476,6 +483,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { None, now, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -533,6 +541,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -579,6 +588,7 @@ async fn status_snapshot_shows_missing_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -642,6 +652,7 @@ async fn status_snapshot_includes_credits_and_limits() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -694,6 +705,7 @@ async fn status_snapshot_shows_empty_limits_message() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -755,6 +767,7 @@ async fn status_snapshot_shows_stale_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -820,6 +833,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -875,6 +889,7 @@ async fn status_context_window_uses_last_usage() { None, now, &model_slug, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines