TUI: collaboration mode UX + always submit UserTurn when enabled (#9461)

- 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 <plan|pair|pp|execute|exec>` 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`.
This commit is contained in:
Ahmed Ibrahim
2026-01-19 09:32:04 -08:00
committed by GitHub
Unverified
parent 3788e2cc0f
commit bf430ad9fe
24 changed files with 1407 additions and 123 deletions
+147 -41
View File
@@ -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<CustomPrompt>,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
footer_flash: Option<FooterFlash>,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
/// 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<u16> {
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<Item = (&'static str, SlashCommand)> {
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<CustomPrompt>) {
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::<AppEvent>();
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::<AppEvent>();
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<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
+54 -11
View File
@@ -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<CustomPrompt>, skills_enabled: bool) -> Self {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, 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<String> = 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<String> = 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:?}"),
}
}
}
+73 -3
View File
@@ -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<Line<'static>> {
// 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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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,
+23
View File
@@ -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()
}
@@ -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 "
+111 -6
View File
@@ -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<String>,
/// 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<AuthManager>,
models_manager: Arc<ModelsManager>,
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)
}
+114
View File
@@ -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>) -> 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;
+135
View File
@@ -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 <mode>` 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<Selection> {
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<CollaborationMode> {
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<ReasoningEffort>,
) -> 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(),
])
}
+1
View File
@@ -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;
+3
View File
@@ -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,
}
}
+12
View File
@@ -63,6 +63,7 @@ struct StatusHistoryCell {
approval: String,
sandbox: String,
agents_summary: String,
collaboration_mode: Option<String>,
model_provider: Option<String>,
account: Option<StatusAccountDisplay>,
session_id: Option<String>,
@@ -83,6 +84,7 @@ pub(crate) fn new_status_output(
plan_type: Option<PlanType>,
now: DateTime<Local>,
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<PlanType>,
now: DateTime<Local>,
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())]));
}
+15
View File
@@ -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
+147 -41
View File
@@ -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<CustomPrompt>,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
footer_flash: Option<FooterFlash>,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
transcript_scrolled: bool,
@@ -203,6 +206,13 @@ pub(crate) struct ChatComposer {
dismissed_skill_popup_token: Option<String>,
/// 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<u16> {
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<Item = (&'static str, SlashCommand)> {
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<CustomPrompt>) {
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::<AppEvent>();
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::<AppEvent>();
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<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
+54 -11
View File
@@ -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<CustomPrompt>, skills_enabled: bool) -> Self {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, 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<String> = 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<String> = 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:?}"),
}
}
}
+80 -3
View File
@@ -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<Line<'static>> {
fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option<TranscriptCopyFeedback>) {
let Some(line) = lines.first_mut() else {
@@ -176,6 +202,7 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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,
+23
View File
@@ -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()
}
@@ -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 "
+113 -6
View File
@@ -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<String>,
/// 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<AuthManager>,
models_manager: Arc<ModelsManager>,
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<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
+115 -1
View File
@@ -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>) -> 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;
+135
View File
@@ -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 <mode>` 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<Selection> {
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<CollaborationMode> {
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<ReasoningEffort>,
) -> 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(),
])
}
+1
View File
@@ -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;
+3
View File
@@ -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,
}
}
+12
View File
@@ -63,6 +63,7 @@ struct StatusHistoryCell {
approval: String,
sandbox: String,
agents_summary: String,
collaboration_mode: Option<String>,
model_provider: Option<String>,
account: Option<StatusAccountDisplay>,
session_id: Option<String>,
@@ -83,6 +84,7 @@ pub(crate) fn new_status_output(
plan_type: Option<PlanType>,
now: DateTime<Local>,
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<PlanType>,
now: DateTime<Local>,
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())]));
}
+15
View File
@@ -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