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