返回 CodeWhale
status_picker.rs
根目录 / crates / tui / src / tui / views / status_picker.rs
1 //! `/statusline` multi-select picker.
2 //!
3 //! Mirrors codex-rs's `bottom_pane::status_line_setup` ergonomically: a
4 //! checklist of bottom-chrome items the user can toggle on/off with Space (or
5 //! Enter), moved through with ↑/↓, applied immediately so the live chrome
6 //! reflects every change. Enter saves to `~/.deepseek/config.toml` under
7 //! `tui.status_items`; Esc reverts to the snapshot taken on open.
8 //!
9 //! Every row here changes what is on screen (#5950): the metrics line's
10 //! segments ([`crate::tui::ui::frame::info_segments`]) and the posture bar's
11 //! mode chip. Between 0.9.12 and that fix the list was persisted and never
12 //! read, so the checklist was decoration — a new variant belongs in
13 //! `crates/tui/src/config.rs` only once something paints it.
14
15 use std::cell::RefCell;
16
17 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
18 use ratatui::{
19 buffer::Buffer,
20 layout::Rect,
21 style::{Modifier, Style},
22 text::{Line, Span},
23 widgets::{Block, Borders, Padding, Paragraph, Widget},
24 };
25
26 use crate::config::{ApiProvider, StatusItem};
27 use crate::tui::menu_style;
28 use crate::tui::views::{
29 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
30 render_modal_footer, render_modal_surface,
31 };
32 use codewhale_localization::{Locale, MessageId, tr};
33 use codewhale_palette as palette;
34 use unicode_width::UnicodeWidthStr;
35
36 /// Picker state. We hold both the user's working selection AND the original
37 /// snapshot so Esc can perfectly revert the live preview.
38 pub struct StatusPickerView {
39 /// Every available item, in the order shown to the user. We keep this
40 /// list ordered so toggles produce a stable on-screen layout that
41 /// doesn't shuffle as items flip.
42 rows: Vec<StatusItem>,
43 /// Indices in `rows` currently checked on (the user's working set).
44 selected: Vec<bool>,
45 /// Highlighted row.
46 cursor: usize,
47 /// Snapshot of `app.status_items` at open time so Esc reverts cleanly.
48 original: Vec<StatusItem>,
49 locale: Locale,
50 row_hitboxes: RefCell<Vec<(usize, Rect)>>,
51 }
52
53 impl StatusPickerView {
54 #[must_use]
55 pub fn new(active: &[StatusItem], provider: ApiProvider, locale: Locale) -> Self {
56 let rows: Vec<StatusItem> = StatusItem::all()
57 .iter()
58 .filter(|item| item.is_available_for(provider))
59 .copied()
60 .collect();
61 let selected: Vec<bool> = rows
62 .iter()
63 .map(|item| {
64 active.contains(item)
65 || (active.contains(&StatusItem::SessionMetrics)
66 && matches!(item, StatusItem::Ttft | StatusItem::OutputRate))
67 })
68 .collect();
69 Self {
70 rows,
71 selected,
72 cursor: 0,
73 original: active.to_vec(),
74 locale,
75 row_hitboxes: RefCell::new(Vec::new()),
76 }
77 }
78
79 /// Build the current selection in the same order the user sees it.
80 /// Preserves `StatusItem::all()` order so toggling produces deterministic
81 /// `tui.status_items` output (no churn-induced diffs in config.toml).
82 fn current_selection(&self) -> Vec<StatusItem> {
83 self.rows
84 .iter()
85 .zip(self.selected.iter())
86 .filter_map(|(item, on)| if *on { Some(*item) } else { None })
87 .collect()
88 }
89
90 /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning
91 /// whether it was consumed. Vertical motions wrap at the ends for
92 /// Prev/Next and clamp for paging and Home/End; the horizontal axis does
93 /// not exist on this single-column checklist. The checklist fits on one
94 /// screen, so a page is the whole list.
95 fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool {
96 let Some(next) = crate::tui::list_nav::apply(
97 self.cursor,
98 self.rows.len(),
99 self.rows.len().max(1),
100 motion,
101 ) else {
102 return false;
103 };
104 self.cursor = next;
105 true
106 }
107
108 fn toggle_current(&mut self) {
109 if let Some(slot) = self.selected.get_mut(self.cursor) {
110 *slot = !*slot;
111 }
112 }
113
114 fn live_preview_event(&self) -> ViewEvent {
115 ViewEvent::StatusItemsUpdated {
116 items: self.current_selection(),
117 final_save: false,
118 }
119 }
120
121 fn final_event(&self) -> ViewEvent {
122 ViewEvent::StatusItemsUpdated {
123 items: self.current_selection(),
124 final_save: true,
125 }
126 }
127
128 fn revert_event(&self) -> ViewEvent {
129 ViewEvent::StatusItemsUpdated {
130 items: self.original.clone(),
131 final_save: false,
132 }
133 }
134 }
135
136 impl ModalView for StatusPickerView {
137 fn kind(&self) -> ModalKind {
138 ModalKind::StatusPicker
139 }
140
141 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
142 self
143 }
144
145 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
146 // Movement keys come from the shared vocabulary (#6290): `j`/`k`,
147 // Home/End and the page keys mean here what they mean on every other
148 // list. This match owns only the checklist's own verbs.
149 if let Some(motion) = crate::tui::list_nav::motion(&key)
150 && self.apply_motion(motion)
151 {
152 return ViewAction::None;
153 }
154 match key.code {
155 KeyCode::Esc => {
156 // Roll the live preview back to the snapshot so Esc means
157 // "take me back to where I was."
158 ViewAction::EmitAndClose(self.revert_event())
159 }
160 KeyCode::Enter => ViewAction::EmitAndClose(self.final_event()),
161 KeyCode::Char(' ') | KeyCode::Char('x') | KeyCode::Char('X') => {
162 self.toggle_current();
163 ViewAction::Emit(self.live_preview_event())
164 }
165 KeyCode::Char('a') | KeyCode::Char('A')
166 if !key.modifiers.contains(KeyModifiers::CONTROL) =>
167 {
168 // Quality-of-life: 'a' selects all so the user can quickly
169 // see every chip available before paring back.
170 self.selected.fill(true);
171 ViewAction::Emit(self.live_preview_event())
172 }
173 KeyCode::Char('n') | KeyCode::Char('N') => {
174 // 'n' clears all so the user can build up from scratch.
175 self.selected.fill(false);
176 ViewAction::Emit(self.live_preview_event())
177 }
178 _ => ViewAction::None,
179 }
180 }
181
182 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
183 match mouse.kind {
184 MouseEventKind::ScrollUp => {
185 self.apply_motion(crate::tui::list_nav::Motion::Prev);
186 }
187 MouseEventKind::ScrollDown => {
188 self.apply_motion(crate::tui::list_nav::Motion::Next);
189 }
190 MouseEventKind::Down(MouseButton::Left) => {
191 let clicked = self.row_hitboxes.borrow().iter().find_map(|(index, rect)| {
192 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
193 .then_some(*index)
194 });
195 if let Some(index) = clicked {
196 self.cursor = index;
197 self.toggle_current();
198 return ViewAction::Emit(self.live_preview_event());
199 }
200 }
201 _ => {}
202 }
203 ViewAction::None
204 }
205
206 fn render(&self, area: Rect, buf: &mut Buffer) {
207 // Two header lines + one row per StatusItem + the wrapping action
208 // footer that now lives inside the body (one row more than the old
209 // border footer). centered_modal_area clamps this to the frame and
210 // lets the scroll offset absorb any remaining overflow.
211 let needed_height = (self.rows.len() as u16).saturating_add(5);
212 let popup_area = centered_modal_area(area, 64, needed_height, 40, 8);
213
214 render_modal_surface(area, popup_area, buf);
215
216 let block = Block::default()
217 .title(Line::from(Span::styled(
218 tr(self.locale, MessageId::StatusPickerTitle),
219 Style::default()
220 .fg(palette::WHALE_ACTION)
221 .add_modifier(Modifier::BOLD),
222 )))
223 .borders(Borders::ALL)
224 .border_style(Style::default().fg(palette::BORDER_COLOR))
225 .style(Style::default().bg(palette::WHALE_BG))
226 .padding(Padding::uniform(1));
227
228 let inner = block.inner(popup_area);
229 block.render(popup_area, buf);
230
231 let content = render_modal_footer(
232 inner,
233 buf,
234 &[
235 ActionHint::new(
236 "Space",
237 tr(self.locale, MessageId::StatusPickerActionToggle),
238 ),
239 ActionHint::new("a", tr(self.locale, MessageId::StatusPickerActionAll)),
240 ActionHint::new("n", tr(self.locale, MessageId::StatusPickerActionNone)),
241 ActionHint::new("Enter", tr(self.locale, MessageId::StatusPickerActionSave)),
242 ActionHint::new("Esc", tr(self.locale, MessageId::StatusPickerActionCancel)),
243 ],
244 );
245
246 self.row_hitboxes.borrow_mut().clear();
247 let visible_rows = content.height.saturating_sub(2) as usize;
248 let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
249
250 let mut lines: Vec<Line> = Vec::with_capacity(visible_rows + 2);
251 lines.push(Line::from(Span::styled(
252 tr(self.locale, MessageId::StatusPickerInstruction),
253 Style::default().fg(palette::TEXT_MUTED),
254 )));
255 lines.push(Line::from(""));
256
257 for (idx, item) in self
258 .rows
259 .iter()
260 .enumerate()
261 .skip(row_start)
262 .take(visible_rows)
263 {
264 self.row_hitboxes.borrow_mut().push((
265 idx,
266 Rect::new(
267 content.x,
268 content.y + 2 + (idx - row_start) as u16,
269 content.width,
270 1,
271 ),
272 ));
273 let checked = *self.selected.get(idx).unwrap_or(&false);
274 let is_cursor = idx == self.cursor;
275 let mark = if checked { "[✓]" } else { "[ ]" };
276
277 let row_style = if is_cursor {
278 menu_style::selected_row_style()
279 } else if checked {
280 Style::default().fg(palette::TEXT_PRIMARY)
281 } else {
282 Style::default().fg(palette::TEXT_MUTED)
283 };
284 let hint_style = if is_cursor {
285 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
286 } else {
287 Style::default().fg(palette::TEXT_DIM)
288 };
289 let pointer = crate::tui::glyphs::selection_marker(is_cursor);
290
291 if is_cursor {
292 let selected_style = menu_style::selected_row_style();
293 let line = status_row_text(pointer, mark, item, content.width as usize);
294 lines.push(Line::from(Span::styled(line, selected_style)));
295 } else {
296 let label = item.label();
297 let hint = item.hint();
298 let prefix = format!(" {pointer} {mark} {label} (");
299 let truncated_hint = crate::tui::ui_text::semantic_truncate_between_affixes(
300 &prefix,
301 hint,
302 ")",
303 usize::from(content.width),
304 );
305 lines.push(Line::from(vec![
306 Span::styled(format!(" {pointer} "), row_style),
307 Span::styled(mark.to_string(), row_style),
308 Span::styled(" ", row_style),
309 Span::styled(label.to_string(), row_style),
310 Span::styled(" ", row_style),
311 Span::styled(format!("({})", truncated_hint), hint_style),
312 ]));
313 }
314 }
315
316 Paragraph::new(lines).render(content, buf);
317 }
318 }
319
320 fn visible_row_start(total_rows: usize, cursor: usize, visible_rows: usize) -> usize {
321 if total_rows == 0 || visible_rows == 0 || total_rows <= visible_rows {
322 return 0;
323 }
324 let max_start = total_rows - visible_rows;
325 cursor
326 .saturating_add(1)
327 .saturating_sub(visible_rows)
328 .min(max_start)
329 }
330
331 fn status_row_text(pointer: &str, mark: &str, item: &StatusItem, width: usize) -> String {
332 let prefix = format!(" {pointer} {mark} {} (", item.label());
333 let mut text =
334 crate::tui::ui_text::semantic_truncate_with_affixes(&prefix, item.hint(), ")", width);
335 let current_width = text.width();
336 if current_width < width {
337 text.push_str(&" ".repeat(width - current_width));
338 }
339 text
340 }
341
342 #[cfg(test)]
343 mod tests {
344 use super::*;
345 use codewhale_localization::Locale;
346
347 #[test]
348 fn opens_with_active_items_pre_selected() {
349 let active = StatusItem::default_footer();
350 let view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
351 assert_eq!(view.current_selection(), active);
352 }
353
354 #[test]
355 fn legacy_metrics_can_be_split_and_cancel_restores_the_saved_pair() {
356 let original = vec![StatusItem::SessionMetrics];
357 let mut view = StatusPickerView::new(&original, ApiProvider::Stepfun, Locale::En);
358 assert_eq!(
359 view.current_selection(),
360 vec![StatusItem::Ttft, StatusItem::OutputRate]
361 );
362 view.cursor = view
363 .rows
364 .iter()
365 .position(|item| *item == StatusItem::OutputRate)
366 .unwrap();
367 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
368 assert_eq!(view.current_selection(), vec![StatusItem::Ttft]);
369 match view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) {
370 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { items, final_save }) => {
371 assert_eq!(items, original);
372 assert!(!final_save);
373 }
374 action => panic!("unexpected cancel: {action:?}"),
375 }
376 }
377
378 #[test]
379 fn mouse_toggles_the_painted_row_after_scrolling_a_short_picker() {
380 let mut view = StatusPickerView::new(
381 &StatusItem::default_footer(),
382 ApiProvider::Stepfun,
383 Locale::En,
384 );
385 view.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
386 let area = Rect::new(0, 0, 40, 12);
387 view.render(area, &mut Buffer::empty(area));
388 let (index, rect) = *view.row_hitboxes.borrow().last().expect("visible row");
389 let was_selected = view.selected[index];
390 let action = view.handle_mouse(MouseEvent {
391 kind: MouseEventKind::Down(MouseButton::Left),
392 column: rect.x + 2,
393 row: rect.y,
394 modifiers: KeyModifiers::NONE,
395 });
396 assert!(matches!(
397 action,
398 ViewAction::Emit(ViewEvent::StatusItemsUpdated {
399 final_save: false,
400 ..
401 })
402 ));
403 assert_eq!(view.cursor, index);
404 assert_eq!(view.selected[index], !was_selected);
405 }
406
407 #[test]
408 fn space_toggles_current_row_and_emits_live_preview() {
409 let active = StatusItem::default_footer();
410 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
411 let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
412 match action {
413 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, final_save }) => {
414 assert!(!final_save);
415 assert!(!items.contains(&StatusItem::Mode));
416 }
417 other => panic!("expected live preview emit, got {other:?}"),
418 }
419 }
420
421 #[test]
422 fn enter_emits_final_save() {
423 let active = StatusItem::default_footer();
424 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
425 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
426 match action {
427 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => {
428 assert!(final_save);
429 }
430 other => panic!("expected final save EmitAndClose, got {other:?}"),
431 }
432 }
433
434 #[test]
435 fn esc_reverts_to_snapshot() {
436 let active = StatusItem::default_footer();
437 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
438 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
439 // Move through the shared vocabulary, the same path a key takes.
440 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
441 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
442 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
443 match action {
444 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { items, final_save }) => {
445 assert!(!final_save);
446 assert_eq!(items, active);
447 }
448 other => panic!("expected revert EmitAndClose, got {other:?}"),
449 }
450 }
451
452 #[test]
453 fn select_all_and_select_none_keys_work() {
454 let active: Vec<StatusItem> = Vec::new();
455 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
456 let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
457 match action {
458 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
459 assert_eq!(items.len(), StatusItem::all().len());
460 }
461 other => panic!("expected select-all emit, got {other:?}"),
462 }
463 let action = view.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
464 match action {
465 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
466 assert!(items.is_empty());
467 }
468 other => panic!("expected select-none emit, got {other:?}"),
469 }
470 }
471
472 #[test]
473 fn arrow_keys_wrap_cursor_at_edges() {
474 let active = StatusItem::default_footer();
475 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
476 assert_eq!(view.cursor, 0);
477 view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
478 assert_eq!(view.cursor, StatusItem::all().len() - 1);
479 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
480 assert_eq!(view.cursor, 0);
481 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
482 assert_eq!(view.cursor, 1);
483 view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
484 assert_eq!(view.cursor, 0);
485 }
486
487 #[test]
488 fn visible_row_start_keeps_cursor_in_view() {
489 assert_eq!(visible_row_start(14, 0, 8), 0);
490 assert_eq!(visible_row_start(14, 7, 8), 0);
491 assert_eq!(visible_row_start(14, 8, 8), 1);
492 assert_eq!(visible_row_start(14, 13, 8), 6);
493 }
494
495 #[test]
496 fn selected_row_text_fills_available_width() {
497 let text = status_row_text("▸", "[ ]", &StatusItem::Cache, 40);
498 assert_eq!(text.width(), 40);
499 assert!(text.starts_with(" ▸ [ ] Prompt cache hit rate"));
500 }
501
502 #[test]
503 fn selected_row_text_semantically_truncates_hint_at_narrow_width() {
504 let text = status_row_text("▸", "[ ]", &StatusItem::Cache, 44);
505 assert_eq!(text.width(), 44);
506 // Cut at a word boundary, never mid-word.
507 assert!(text.contains("% of…"), "{text:?}");
508 assert!(!text.contains("% of p"), "{text:?}");
509 }
510
511 #[test]
512 fn balance_offered_for_prepaid_providers_and_hidden_for_local() {
513 let active = StatusItem::default_footer();
514 let openrouter = StatusPickerView::new(&active, ApiProvider::Openrouter, Locale::En);
515 assert!(openrouter.rows.contains(&StatusItem::Balance));
516 let ollama = StatusPickerView::new(&active, ApiProvider::Ollama, Locale::En);
517 assert!(!ollama.rows.contains(&StatusItem::Balance));
518 assert!(ollama.rows.contains(&StatusItem::Mode));
519 }
520
521 #[test]
522 fn status_picker_displays_localized_title_for_zh_hans() {
523 assert_eq!(tr(Locale::ZhHans, MessageId::StatusPickerTitle), " 状态行 ");
524 }
525
526 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
527 /// every overlay to remain readable and fully operable at.
528 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
529
530 #[test]
531 fn status_picker_is_usable_and_opaque_at_blocker_sizes() {
532 use crate::tui::views::ViewStack;
533 let active = StatusItem::default_footer();
534 for (w, h) in BLOCKER_SIZES {
535 let area = Rect::new(0, 0, w, h);
536 let mut buf = Buffer::empty(area);
537 for y in 0..h {
538 for x in 0..w {
539 buf[(x, y)].set_symbol("X");
540 }
541 }
542 let mut stack = ViewStack::new();
543 stack.push(StatusPickerView::new(
544 &active,
545 ApiProvider::Deepseek,
546 Locale::En,
547 ));
548 stack.render(area, &mut buf);
549
550 let rows: Vec<String> = (0..h)
551 .map(|y| {
552 (0..w)
553 .map(|x| buf[(x, y)].symbol().to_string())
554 .collect::<String>()
555 })
556 .collect();
557 let text = rows.join("\n");
558
559 for label in ["toggle", "all", "none", "save", "cancel"] {
560 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
561 }
562 assert!(
563 !text.contains('X'),
564 "{w}x{h}: background bleed-through into modal surface"
565 );
566 assert_eq!(
567 buf[(w / 2, h / 2)].bg,
568 palette::WHALE_BG,
569 "{w}x{h}: modal interior must be opaque"
570 );
571 for (y, row) in rows.iter().enumerate() {
572 assert!(
573 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
574 "{w}x{h}: row {y} overflows width: {row:?}"
575 );
576 }
577 }
578 }
579
580 #[test]
581 fn status_picker_no_english_leak_in_non_en_locales() {
582 for locale in [
583 Locale::Ja,
584 Locale::ZhHans,
585 Locale::ZhHant,
586 Locale::PtBr,
587 Locale::Es419,
588 Locale::Vi,
589 Locale::Ca,
590 Locale::De,
591 Locale::Fr,
592 Locale::Id,
593 Locale::Hi,
594 Locale::Ru,
595 Locale::Uk,
596 ] {
597 let title = tr(locale, MessageId::StatusPickerTitle);
598 if locale == Locale::De {
599 // German "Statuszeile" is the correct native term — "Status"
600 // is a German word, not an English leak.
601 assert_eq!(title, " Statuszeile ");
602 } else {
603 assert!(
604 !title.contains("Status"),
605 "{} leaks English in title: {title}",
606 locale.tag()
607 );
608 }
609 let instruction = tr(locale, MessageId::StatusPickerInstruction);
610 assert!(
611 !instruction.contains("footer"),
612 "{} leaks English in instruction: {instruction}",
613 locale.tag()
614 );
615 }
616 }
617 }
618
618 lines RUST