This commit is contained in:
Ahmed Ibrahim
2026-01-18 22:23:22 -08:00
parent f6b21c86f0
commit 8e2c113906
8 changed files with 252 additions and 22 deletions

View File

@@ -2385,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),

View File

@@ -421,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:?}"),
}
}
}

View File

@@ -567,6 +567,21 @@ mod tests {
},
);
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,
},
);
snapshot_footer(
"footer_ctrl_c_quit_idle",
FooterProps {
@@ -686,20 +701,5 @@ mod tests {
context_window_used_tokens: None,
},
);
snapshot_footer(
"footer_context_only_collab_hint",
FooterProps {
mode: FooterMode::ContextOnly,
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,
},
);
}
}

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2492
expression: terminal.backend()
---
" "
@@ -15,4 +16,4 @@ expression: terminal.backend()
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" ctrl + t to view transcript "

View File

@@ -1,5 +0,0 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" 100% context left · shift + tab to change mode "

View File

@@ -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 "

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 535
expression: terminal.backend()
---
" / for commands ! for shell commands "
@@ -7,4 +8,4 @@ expression: terminal.backend()
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" ctrl + t to view transcript "

View File

@@ -1508,6 +1508,114 @@ async fn slash_init_skips_when_project_doc_exists() {
);
}
#[test]
fn parse_collaboration_mode_selection_accepts_common_aliases() {
assert_eq!(
parse_collaboration_mode_selection("plan"),
Some(CollaborationModeSelection::Plan)
);
assert_eq!(
parse_collaboration_mode_selection("PAIR"),
Some(CollaborationModeSelection::PairProgramming)
);
assert_eq!(
parse_collaboration_mode_selection("pair_programming"),
Some(CollaborationModeSelection::PairProgramming)
);
assert_eq!(
parse_collaboration_mode_selection("pp"),
Some(CollaborationModeSelection::PairProgramming)
);
assert_eq!(
parse_collaboration_mode_selection(" exec "),
Some(CollaborationModeSelection::Execute)
);
assert_eq!(
parse_collaboration_mode_selection("execute"),
Some(CollaborationModeSelection::Execute)
);
assert_eq!(parse_collaboration_mode_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);
assert_eq!(chat.pending_collaboration_mode, None);
chat.set_feature_enabled(Feature::CollaborationModes, true);
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute);
assert_eq!(
chat.pending_collaboration_mode,
Some(CollaborationModeSelection::Execute)
);
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
assert_eq!(
chat.pending_collaboration_mode,
Some(CollaborationModeSelection::Plan)
);
chat.on_task_started();
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
assert_eq!(
chat.pending_collaboration_mode,
Some(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);
assert_eq!(
chat.pending_collaboration_mode,
Some(CollaborationModeSelection::Plan)
);
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"),
}
}
}
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:?}"),
}
assert_eq!(chat.pending_collaboration_mode, None);
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::UserInput { .. } => {}
other => panic!("expected Op::UserInput after pending mode cleared, got {other:?}"),
}
}
#[tokio::test]
async fn slash_quit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;