mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
3788e2cc0f
commit
bf430ad9fe
@@ -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),
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+11
@@ -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 "
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())]));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+10
@@ -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 "
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())]));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user