| 1 | //! `/mode` picker for the currently supported interactive modes. |
| 2 | |
| 3 | use std::cell::RefCell; |
| 4 | |
| 5 | use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; |
| 6 | use ratatui::{ |
| 7 | buffer::Buffer, |
| 8 | layout::Rect, |
| 9 | style::{Modifier, Style}, |
| 10 | text::{Line, Span}, |
| 11 | widgets::{Block, Borders, Padding, Paragraph, Widget}, |
| 12 | }; |
| 13 | use unicode_width::UnicodeWidthStr; |
| 14 | |
| 15 | use crate::localization::Locale; |
| 16 | use crate::palette; |
| 17 | use crate::tui::app::AppMode; |
| 18 | use crate::tui::menu_style; |
| 19 | use crate::tui::views::{ |
| 20 | ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area, |
| 21 | render_modal_footer, render_modal_surface, |
| 22 | }; |
| 23 | |
| 24 | // Operate is visible because the engine now enforces a coordinator/worker |
| 25 | // boundary while allowing ordinary conversation and asynchronous dispatch. |
| 26 | const VISIBLE_MODES: [AppMode; 3] = [AppMode::Agent, AppMode::Plan, AppMode::Operate]; |
| 27 | |
| 28 | pub struct ModePickerView { |
| 29 | cursor: usize, |
| 30 | locale: Locale, |
| 31 | row_hitboxes: RefCell<Vec<Rect>>, |
| 32 | } |
| 33 | |
| 34 | impl ModePickerView { |
| 35 | #[must_use] |
| 36 | pub fn new(current: AppMode, locale: Locale) -> Self { |
| 37 | let cursor = VISIBLE_MODES |
| 38 | .iter() |
| 39 | .position(|mode| *mode == current) |
| 40 | .unwrap_or(0); |
| 41 | Self { |
| 42 | cursor, |
| 43 | locale, |
| 44 | row_hitboxes: RefCell::new(Vec::new()), |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | fn selected_mode(&self) -> AppMode { |
| 49 | VISIBLE_MODES |
| 50 | .get(self.cursor) |
| 51 | .copied() |
| 52 | .unwrap_or(AppMode::Agent) |
| 53 | } |
| 54 | |
| 55 | fn move_up(&mut self) { |
| 56 | self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), -1); |
| 57 | } |
| 58 | |
| 59 | fn move_down(&mut self) { |
| 60 | self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), 1); |
| 61 | } |
| 62 | |
| 63 | fn select_by_number(&mut self, number: char) -> Option<ViewAction> { |
| 64 | let idx = VISIBLE_MODES |
| 65 | .iter() |
| 66 | .position(|mode| mode.number() == number)?; |
| 67 | self.cursor = idx; |
| 68 | Some(ViewAction::EmitAndClose(ViewEvent::ModeSelected { |
| 69 | mode: self.selected_mode(), |
| 70 | })) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | impl ModalView for ModePickerView { |
| 75 | fn kind(&self) -> ModalKind { |
| 76 | ModalKind::ModePicker |
| 77 | } |
| 78 | |
| 79 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 80 | self |
| 81 | } |
| 82 | |
| 83 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 84 | match key.code { |
| 85 | KeyCode::Esc => ViewAction::Close, |
| 86 | KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected { |
| 87 | mode: self.selected_mode(), |
| 88 | }), |
| 89 | KeyCode::Up | KeyCode::Char('k') => { |
| 90 | self.move_up(); |
| 91 | ViewAction::None |
| 92 | } |
| 93 | KeyCode::Down | KeyCode::Char('j') => { |
| 94 | self.move_down(); |
| 95 | ViewAction::None |
| 96 | } |
| 97 | KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None), |
| 98 | _ => ViewAction::None, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 103 | match mouse.kind { |
| 104 | MouseEventKind::ScrollUp => { |
| 105 | self.move_up(); |
| 106 | ViewAction::None |
| 107 | } |
| 108 | MouseEventKind::ScrollDown => { |
| 109 | self.move_down(); |
| 110 | ViewAction::None |
| 111 | } |
| 112 | MouseEventKind::Down(MouseButton::Left) => { |
| 113 | let clicked = self.row_hitboxes.borrow().iter().position(|rect| { |
| 114 | rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) |
| 115 | }); |
| 116 | if let Some(index) = clicked { |
| 117 | self.cursor = index; |
| 118 | return self.handle_key(KeyEvent::new(KeyCode::Enter, mouse.modifiers)); |
| 119 | } |
| 120 | ViewAction::None |
| 121 | } |
| 122 | _ => ViewAction::None, |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 127 | let popup_height = u16::try_from(VISIBLE_MODES.len()).unwrap_or(2) + 7; |
| 128 | let popup_area = centered_modal_area(area, 68, popup_height, 44, 8); |
| 129 | |
| 130 | render_modal_surface(area, popup_area, buf); |
| 131 | |
| 132 | let block = Block::default() |
| 133 | .title(Line::from(Span::styled( |
| 134 | " Mode ", |
| 135 | Style::default() |
| 136 | .fg(palette::WHALE_INFO) |
| 137 | .add_modifier(Modifier::BOLD), |
| 138 | ))) |
| 139 | .borders(Borders::ALL) |
| 140 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 141 | .style(Style::default().bg(palette::WHALE_BG)) |
| 142 | .padding(Padding::uniform(1)); |
| 143 | |
| 144 | let inner = block.inner(popup_area); |
| 145 | block.render(popup_area, buf); |
| 146 | |
| 147 | let content = render_modal_footer( |
| 148 | inner, |
| 149 | buf, |
| 150 | &[ |
| 151 | ActionHint::new("↑/↓", "move"), |
| 152 | ActionHint::new("Enter", "select"), |
| 153 | ActionHint::new("Esc", "cancel"), |
| 154 | ], |
| 155 | ); |
| 156 | |
| 157 | self.row_hitboxes.borrow_mut().clear(); |
| 158 | |
| 159 | let mut lines = Vec::with_capacity(VISIBLE_MODES.len()); |
| 160 | |
| 161 | for (idx, mode) in VISIBLE_MODES.iter().copied().enumerate() { |
| 162 | let is_cursor = idx == self.cursor; |
| 163 | let row_style = if is_cursor { |
| 164 | menu_style::selected_row_style() |
| 165 | } else { |
| 166 | Style::default().fg(palette::TEXT_PRIMARY) |
| 167 | }; |
| 168 | let hint_style = if is_cursor { |
| 169 | menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT) |
| 170 | } else { |
| 171 | Style::default().fg(palette::TEXT_MUTED) |
| 172 | }; |
| 173 | let pointer = crate::tui::glyphs::selection_marker(is_cursor); |
| 174 | let name = mode.display_name_localized(self.locale); |
| 175 | let hint = mode.picker_hint_localized(self.locale); |
| 176 | // Pad by terminal columns, not scalar count, so wide (CJK) mode |
| 177 | // names keep the hint column aligned. |
| 178 | let pad = " ".repeat(8usize.saturating_sub(UnicodeWidthStr::width(&*name))); |
| 179 | |
| 180 | lines.push(Line::from(vec![ |
| 181 | Span::styled( |
| 182 | format!("{pointer} {}. {name}{pad}", mode.number()), |
| 183 | row_style, |
| 184 | ), |
| 185 | Span::styled(hint, hint_style), |
| 186 | ])); |
| 187 | self.row_hitboxes.borrow_mut().push(Rect::new( |
| 188 | content.x, |
| 189 | content |
| 190 | .y |
| 191 | .saturating_add(u16::try_from(idx).unwrap_or(u16::MAX)), |
| 192 | content.width, |
| 193 | 1, |
| 194 | )); |
| 195 | } |
| 196 | |
| 197 | Paragraph::new(lines).render(content, buf); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | #[cfg(test)] |
| 202 | mod tests { |
| 203 | use super::*; |
| 204 | use crossterm::event::KeyModifiers; |
| 205 | use ratatui::{Terminal, backend::TestBackend}; |
| 206 | |
| 207 | #[test] |
| 208 | fn opens_on_current_mode() { |
| 209 | let view = ModePickerView::new(AppMode::Plan, Locale::En); |
| 210 | assert_eq!(view.selected_mode(), AppMode::Plan); |
| 211 | } |
| 212 | |
| 213 | #[test] |
| 214 | fn enter_emits_selected_mode() { |
| 215 | let mut view = ModePickerView::new(AppMode::Agent, Locale::En); |
| 216 | view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); |
| 217 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 218 | match action { |
| 219 | ViewAction::EmitAndClose(ViewEvent::ModeSelected { mode }) => { |
| 220 | assert_eq!(mode, AppMode::Plan); |
| 221 | } |
| 222 | other => panic!("expected ModeSelected, got {other:?}"), |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires |
| 227 | /// every overlay to remain readable and fully operable at. |
| 228 | const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)]; |
| 229 | |
| 230 | fn render_at(width: u16, height: u16) -> (Buffer, Rect) { |
| 231 | use crate::tui::views::ViewStack; |
| 232 | let area = Rect::new(0, 0, width, height); |
| 233 | let mut buf = Buffer::empty(area); |
| 234 | // Pre-fill with a sentinel so any cell the composited modal fails to |
| 235 | // paint (bleed-through) is detectable as a surviving 'X'. |
| 236 | for y in 0..height { |
| 237 | for x in 0..width { |
| 238 | buf[(x, y)].set_symbol("X"); |
| 239 | } |
| 240 | } |
| 241 | // Render through the ViewStack so the shared opaque backdrop is painted |
| 242 | // exactly as it is in production. |
| 243 | let mut stack = ViewStack::new(); |
| 244 | stack.push(ModePickerView::new(AppMode::Agent, Locale::En)); |
| 245 | stack.render(area, &mut buf); |
| 246 | (buf, area) |
| 247 | } |
| 248 | |
| 249 | fn rows(buf: &Buffer, area: Rect) -> Vec<String> { |
| 250 | (0..area.height) |
| 251 | .map(|y| { |
| 252 | (0..area.width) |
| 253 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 254 | .collect::<String>() |
| 255 | }) |
| 256 | .collect() |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn mode_picker_is_usable_and_opaque_at_blocker_sizes() { |
| 261 | for (w, h) in BLOCKER_SIZES { |
| 262 | let (buf, area) = render_at(w, h); |
| 263 | let text = rows(&buf, area).join("\n"); |
| 264 | |
| 265 | // Action labels are present (footer never drops an action). |
| 266 | assert!(text.contains("move"), "{w}x{h}: missing 'move' hint"); |
| 267 | assert!(text.contains("select"), "{w}x{h}: missing 'select' hint"); |
| 268 | assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint"); |
| 269 | |
| 270 | // The cursor row carries the charter selection pointer. |
| 271 | assert!( |
| 272 | text.contains(crate::tui::glyphs::SELECTION), |
| 273 | "{w}x{h}: missing charter selection pointer" |
| 274 | ); |
| 275 | |
| 276 | // Composited frame is fully opaque: no sentinel survives and every |
| 277 | // cell carries the modal/backdrop ink background. |
| 278 | assert!( |
| 279 | !text.contains('X'), |
| 280 | "{w}x{h}: background bleed-through into modal surface" |
| 281 | ); |
| 282 | let center = &buf[(w / 2, h / 2)]; |
| 283 | assert_eq!( |
| 284 | center.bg, |
| 285 | palette::WHALE_BG, |
| 286 | "{w}x{h}: modal interior must be opaque" |
| 287 | ); |
| 288 | |
| 289 | // No row exceeds the frame width (no horizontal overflow). |
| 290 | for (y, row) in rows(&buf, area).iter().enumerate() { |
| 291 | assert!( |
| 292 | UnicodeWidthStr::width(row.trim_end()) <= w as usize, |
| 293 | "{w}x{h}: row {y} overflows width: {row:?}" |
| 294 | ); |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn operate_is_advertised_as_a_visible_mode() { |
| 301 | let (buf, area) = render_at(80, 24); |
| 302 | let text = rows(&buf, area).join("\n"); |
| 303 | assert!(text.contains("Operate"), "{text}"); |
| 304 | } |
| 305 | |
| 306 | #[test] |
| 307 | fn number_keys_select_modes() { |
| 308 | // Visible roster: 1 Act, 2 Plan, 3 Operate. |
| 309 | let mut view = ModePickerView::new(AppMode::Agent, Locale::En); |
| 310 | let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); |
| 311 | assert!(matches!( |
| 312 | action, |
| 313 | ViewAction::EmitAndClose(ViewEvent::ModeSelected { |
| 314 | mode: AppMode::Operate |
| 315 | }) |
| 316 | )); |
| 317 | |
| 318 | // Legacy YOLO shorthand (4) is not offered by the picker. |
| 319 | let mut view = ModePickerView::new(AppMode::Agent, Locale::En); |
| 320 | let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)); |
| 321 | assert!(matches!(action, ViewAction::None)); |
| 322 | |
| 323 | // Old Operate number (5) is gone — no numeric gaps. |
| 324 | let mut view = ModePickerView::new(AppMode::Agent, Locale::En); |
| 325 | let action = view.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)); |
| 326 | assert!(matches!(action, ViewAction::None)); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn mouse_click_renders_and_selects_mode_row() { |
| 331 | let mut view = ModePickerView::new(AppMode::Agent, Locale::En); |
| 332 | let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal"); |
| 333 | terminal |
| 334 | .draw(|frame| view.render(frame.area(), frame.buffer_mut())) |
| 335 | .expect("render mode picker"); |
| 336 | let rect = view.row_hitboxes.borrow()[1]; |
| 337 | let action = view.handle_mouse(MouseEvent { |
| 338 | kind: MouseEventKind::Down(MouseButton::Left), |
| 339 | column: rect.x, |
| 340 | row: rect.y, |
| 341 | modifiers: KeyModifiers::NONE, |
| 342 | }); |
| 343 | assert!(matches!( |
| 344 | action, |
| 345 | ViewAction::EmitAndClose(ViewEvent::ModeSelected { |
| 346 | mode: AppMode::Plan |
| 347 | }) |
| 348 | )); |
| 349 | } |
| 350 | } |
| 351 |