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

- Adds experimental collaboration modes UX in TUI: Plan / Pair
Programming / Execute.
- Gated behind `Feature::CollaborationModes`; existing behavior remains
unchanged when disabled.
- Selection UX:
- `Shift+Tab` cycles modes while idle (no task running, no modal/popup).
- `/collab` cycles; `/collab <plan|pair|pp|execute|exec>` sets
explicitly.
- Footer flash after changes + shortcut overlay shows `Shift+Tab` “to
change mode”.
  - `/status` shows “Collaboration mode”.
- Submission semantics:
- When enabled: every submit uses `Op::UserTurn` and always includes
`collaboration_mode: Some(...)` (default Pair Programming).
  - Removes the one-shot “pending collaboration mode” behavior.
- Implementation:
- New `tui/src/collaboration_modes.rs` (selection enum/cycle, `/collab`
parsing, resolve to `CollaborationMode`, footer flash line).
- Fallback: `resolve_mode_or_fallback` synthesizes a `CollaborationMode`
when presets are missing (uses current model + reasoning effort; no
`developer_instructions`) to avoid core falling back to `Custom`.
  - TODO: migrate TUI to use `Op::UserTurn`.
This commit is contained in:
Ahmed Ibrahim
2026-01-19 09:32:04 -08:00
committed by GitHub
parent 3788e2cc0f
commit bf430ad9fe
24 changed files with 1407 additions and 123 deletions

View File

@@ -37,13 +37,20 @@ pub(crate) struct CommandPopup {
state: ScrollState,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) skills_enabled: bool,
pub(crate) collaboration_modes_enabled: bool,
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills)
.filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills)
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
@@ -231,7 +238,7 @@ mod tests {
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new(Vec::new(), false);
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
@@ -251,7 +258,7 @@ mod tests {
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(Vec::new(), false);
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
@@ -266,7 +273,7 @@ mod tests {
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new(), false);
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
@@ -280,7 +287,7 @@ mod tests {
#[test]
fn filtered_commands_keep_presentation_order() {
let mut popup = CommandPopup::new(Vec::new(), false);
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/m".to_string());
let cmds: Vec<&str> = popup
@@ -322,7 +329,7 @@ mod tests {
argument_hint: None,
},
];
let popup = CommandPopup::new(prompts, false);
let popup = CommandPopup::new(prompts, CommandPopupFlags::default());
let items = popup.filtered_items();
let mut prompt_names: Vec<String> = items
.into_iter()
@@ -346,7 +353,7 @@ mod tests {
description: None,
argument_hint: None,
}],
false,
CommandPopupFlags::default(),
);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
@@ -369,7 +376,7 @@ mod tests {
description: Some("Create feature branch, commit and open draft PR.".to_string()),
argument_hint: None,
}],
false,
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
let description = rows.first().and_then(|row| row.description.as_deref());
@@ -389,7 +396,7 @@ mod tests {
description: None,
argument_hint: None,
}],
false,
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
let description = rows.first().and_then(|row| row.description.as_deref());
@@ -398,7 +405,7 @@ mod tests {
#[test]
fn fuzzy_filter_matches_subsequence_for_ac() {
let mut popup = CommandPopup::new(Vec::new(), false);
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/ac".to_string());
let cmds: Vec<&str> = popup
@@ -414,4 +421,40 @@ mod tests {
"expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}"
);
}
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/coll".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"collab"),
"expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
}
#[test]
fn collab_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
skills_enabled: false,
collaboration_modes_enabled: true,
},
);
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"),
other => panic!("expected collab to be selected for exact match, got {other:?}"),
}
}
}