返回 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 footer items the user can toggle on/off with Space (or
5 //! Enter), reordered by ↑/↓, applied immediately so the live footer
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 //! The picker enumerates [`StatusItem::all`] so adding a new variant in
10 //! `crates/tui/src/config.rs` automatically surfaces a new row here.
11
12 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13 use ratatui::{
14 buffer::Buffer,
15 layout::Rect,
16 style::{Modifier, Style},
17 text::{Line, Span},
18 widgets::{Block, Borders, Padding, Paragraph, Widget},
19 };
20
21 use crate::config::{ApiProvider, StatusItem};
22 use crate::localization::{Locale, MessageId, tr};
23 use crate::palette;
24 use crate::tui::menu_style;
25 use crate::tui::views::{
26 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
27 render_modal_footer, render_modal_surface,
28 };
29 use unicode_width::UnicodeWidthStr;
30
31 /// Picker state. We hold both the user's working selection AND the original
32 /// snapshot so Esc can perfectly revert the live preview.
33 pub struct StatusPickerView {
34 /// Every available item, in the order shown to the user. We keep this
35 /// list ordered so toggles produce a stable on-screen layout that
36 /// doesn't shuffle as items flip.
37 rows: Vec<StatusItem>,
38 /// Indices in `rows` currently checked on (the user's working set).
39 selected: Vec<bool>,
40 /// Highlighted row.
41 cursor: usize,
42 /// Snapshot of `app.status_items` at open time so Esc reverts cleanly.
43 original: Vec<StatusItem>,
44 locale: Locale,
45 }
46
47 impl StatusPickerView {
48 #[must_use]
49 pub fn new(active: &[StatusItem], provider: ApiProvider, locale: Locale) -> Self {
50 let rows: Vec<StatusItem> = StatusItem::all()
51 .iter()
52 .filter(|item| item.is_available_for(provider))
53 .copied()
54 .collect();
55 let selected: Vec<bool> = rows.iter().map(|item| active.contains(item)).collect();
56 Self {
57 rows,
58 selected,
59 cursor: 0,
60 original: active.to_vec(),
61 locale,
62 }
63 }
64
65 /// Build the current selection in the same order the user sees it.
66 /// Preserves `StatusItem::all()` order so toggling produces deterministic
67 /// `tui.status_items` output (no churn-induced diffs in config.toml).
68 fn current_selection(&self) -> Vec<StatusItem> {
69 self.rows
70 .iter()
71 .zip(self.selected.iter())
72 .filter_map(|(item, on)| if *on { Some(*item) } else { None })
73 .collect()
74 }
75
76 fn move_up(&mut self) {
77 if self.rows.is_empty() {
78 return;
79 }
80 if self.cursor == 0 {
81 self.cursor = self.rows.len() - 1;
82 } else {
83 self.cursor -= 1;
84 }
85 }
86
87 fn move_down(&mut self) {
88 if self.rows.is_empty() {
89 return;
90 }
91 self.cursor = (self.cursor + 1) % self.rows.len();
92 }
93
94 fn toggle_current(&mut self) {
95 if let Some(slot) = self.selected.get_mut(self.cursor) {
96 *slot = !*slot;
97 }
98 }
99
100 fn live_preview_event(&self) -> ViewEvent {
101 ViewEvent::StatusItemsUpdated {
102 items: self.current_selection(),
103 final_save: false,
104 }
105 }
106
107 fn final_event(&self) -> ViewEvent {
108 ViewEvent::StatusItemsUpdated {
109 items: self.current_selection(),
110 final_save: true,
111 }
112 }
113
114 fn revert_event(&self) -> ViewEvent {
115 ViewEvent::StatusItemsUpdated {
116 items: self.original.clone(),
117 final_save: false,
118 }
119 }
120 }
121
122 impl ModalView for StatusPickerView {
123 fn kind(&self) -> ModalKind {
124 ModalKind::StatusPicker
125 }
126
127 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
128 self
129 }
130
131 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
132 match key.code {
133 KeyCode::Esc => {
134 // Roll the live preview back to the snapshot so Esc means
135 // "take me back to where I was."
136 ViewAction::EmitAndClose(self.revert_event())
137 }
138 KeyCode::Enter => ViewAction::EmitAndClose(self.final_event()),
139 KeyCode::Up | KeyCode::Char('k') => {
140 self.move_up();
141 ViewAction::None
142 }
143 KeyCode::Down | KeyCode::Char('j') => {
144 self.move_down();
145 ViewAction::None
146 }
147 KeyCode::Char(' ') | KeyCode::Char('x') | KeyCode::Char('X') => {
148 self.toggle_current();
149 ViewAction::Emit(self.live_preview_event())
150 }
151 KeyCode::Char('a') | KeyCode::Char('A')
152 if !key.modifiers.contains(KeyModifiers::CONTROL) =>
153 {
154 // Quality-of-life: 'a' selects all so the user can quickly
155 // see every chip available before paring back.
156 for slot in &mut self.selected {
157 *slot = true;
158 }
159 ViewAction::Emit(self.live_preview_event())
160 }
161 KeyCode::Char('n') | KeyCode::Char('N') => {
162 // 'n' clears all so the user can build up from scratch.
163 for slot in &mut self.selected {
164 *slot = false;
165 }
166 ViewAction::Emit(self.live_preview_event())
167 }
168 _ => ViewAction::None,
169 }
170 }
171
172 fn render(&self, area: Rect, buf: &mut Buffer) {
173 // Two header lines + one row per StatusItem + the wrapping action
174 // footer that now lives inside the body (one row more than the old
175 // border footer). centered_modal_area clamps this to the frame and
176 // lets the scroll offset absorb any remaining overflow.
177 let needed_height = (self.rows.len() as u16).saturating_add(5);
178 let popup_area = centered_modal_area(area, 64, needed_height, 40, 8);
179
180 render_modal_surface(area, popup_area, buf);
181
182 let block = Block::default()
183 .title(Line::from(Span::styled(
184 tr(self.locale, MessageId::StatusPickerTitle),
185 Style::default()
186 .fg(palette::WHALE_INFO)
187 .add_modifier(Modifier::BOLD),
188 )))
189 .borders(Borders::ALL)
190 .border_style(Style::default().fg(palette::BORDER_COLOR))
191 .style(Style::default().bg(palette::WHALE_BG))
192 .padding(Padding::uniform(1));
193
194 let inner = block.inner(popup_area);
195 block.render(popup_area, buf);
196
197 let content = render_modal_footer(
198 inner,
199 buf,
200 &[
201 ActionHint::new(
202 "Space",
203 tr(self.locale, MessageId::StatusPickerActionToggle),
204 ),
205 ActionHint::new("a", tr(self.locale, MessageId::StatusPickerActionAll)),
206 ActionHint::new("n", tr(self.locale, MessageId::StatusPickerActionNone)),
207 ActionHint::new("Enter", tr(self.locale, MessageId::StatusPickerActionSave)),
208 ActionHint::new("Esc", tr(self.locale, MessageId::StatusPickerActionCancel)),
209 ],
210 );
211
212 let visible_rows = content.height.saturating_sub(2) as usize;
213 let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
214
215 let mut lines: Vec<Line> = Vec::with_capacity(visible_rows + 2);
216 lines.push(Line::from(Span::styled(
217 tr(self.locale, MessageId::StatusPickerInstruction),
218 Style::default().fg(palette::TEXT_MUTED),
219 )));
220 lines.push(Line::from(""));
221
222 for (idx, item) in self
223 .rows
224 .iter()
225 .enumerate()
226 .skip(row_start)
227 .take(visible_rows)
228 {
229 let checked = *self.selected.get(idx).unwrap_or(&false);
230 let is_cursor = idx == self.cursor;
231 let mark = if checked { "[✓]" } else { "[ ]" };
232
233 let row_style = if is_cursor {
234 menu_style::selected_row_style()
235 } else if checked {
236 Style::default().fg(palette::TEXT_PRIMARY)
237 } else {
238 Style::default().fg(palette::TEXT_MUTED)
239 };
240 let hint_style = if is_cursor {
241 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
242 } else {
243 Style::default().fg(palette::TEXT_DIM)
244 };
245 let pointer = crate::tui::glyphs::selection_marker(is_cursor);
246
247 if is_cursor {
248 let selected_style = menu_style::selected_row_style();
249 let line = status_row_text(pointer, mark, item, content.width as usize);
250 lines.push(Line::from(Span::styled(line, selected_style)));
251 } else {
252 let label = item.label();
253 let hint = item.hint();
254 let prefix = format!(" {pointer} {mark} {label} (");
255 let truncated_hint = crate::tui::ui_text::semantic_truncate_between_affixes(
256 &prefix,
257 hint,
258 ")",
259 usize::from(content.width),
260 );
261 lines.push(Line::from(vec![
262 Span::styled(format!(" {pointer} "), row_style),
263 Span::styled(mark.to_string(), row_style),
264 Span::styled(" ", row_style),
265 Span::styled(label.to_string(), row_style),
266 Span::styled(" ", row_style),
267 Span::styled(format!("({})", truncated_hint), hint_style),
268 ]));
269 }
270 }
271
272 Paragraph::new(lines).render(content, buf);
273 }
274 }
275
276 fn visible_row_start(total_rows: usize, cursor: usize, visible_rows: usize) -> usize {
277 if total_rows == 0 || visible_rows == 0 || total_rows <= visible_rows {
278 return 0;
279 }
280 let max_start = total_rows - visible_rows;
281 cursor
282 .saturating_add(1)
283 .saturating_sub(visible_rows)
284 .min(max_start)
285 }
286
287 fn status_row_text(pointer: &str, mark: &str, item: &StatusItem, width: usize) -> String {
288 let prefix = format!(" {pointer} {mark} {} (", item.label());
289 let mut text =
290 crate::tui::ui_text::semantic_truncate_with_affixes(&prefix, item.hint(), ")", width);
291 let current_width = text.width();
292 if current_width < width {
293 text.push_str(&" ".repeat(width - current_width));
294 }
295 text
296 }
297
298 #[cfg(test)]
299 mod tests {
300 use super::*;
301 use crate::localization::Locale;
302
303 #[test]
304 fn opens_with_active_items_pre_selected() {
305 let active = StatusItem::default_footer();
306 let view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
307 assert_eq!(view.current_selection(), active);
308 }
309
310 #[test]
311 fn space_toggles_current_row_and_emits_live_preview() {
312 let active = StatusItem::default_footer();
313 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
314 let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
315 match action {
316 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, final_save }) => {
317 assert!(!final_save);
318 assert!(!items.contains(&StatusItem::Mode));
319 }
320 other => panic!("expected live preview emit, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn enter_emits_final_save() {
326 let active = StatusItem::default_footer();
327 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
328 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
329 match action {
330 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => {
331 assert!(final_save);
332 }
333 other => panic!("expected final save EmitAndClose, got {other:?}"),
334 }
335 }
336
337 #[test]
338 fn esc_reverts_to_snapshot() {
339 let active = StatusItem::default_footer();
340 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
341 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
342 view.move_down();
343 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
344 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
345 match action {
346 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { items, final_save }) => {
347 assert!(!final_save);
348 assert_eq!(items, active);
349 }
350 other => panic!("expected revert EmitAndClose, got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn select_all_and_select_none_keys_work() {
356 let active: Vec<StatusItem> = Vec::new();
357 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
358 let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
359 match action {
360 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
361 assert_eq!(items.len(), StatusItem::all().len());
362 }
363 other => panic!("expected select-all emit, got {other:?}"),
364 }
365 let action = view.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
366 match action {
367 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
368 assert!(items.is_empty());
369 }
370 other => panic!("expected select-none emit, got {other:?}"),
371 }
372 }
373
374 #[test]
375 fn arrow_keys_wrap_cursor_at_edges() {
376 let active = StatusItem::default_footer();
377 let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En);
378 assert_eq!(view.cursor, 0);
379 view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
380 assert_eq!(view.cursor, StatusItem::all().len() - 1);
381 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
382 assert_eq!(view.cursor, 0);
383 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
384 assert_eq!(view.cursor, 1);
385 view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
386 assert_eq!(view.cursor, 0);
387 }
388
389 #[test]
390 fn visible_row_start_keeps_cursor_in_view() {
391 assert_eq!(visible_row_start(14, 0, 8), 0);
392 assert_eq!(visible_row_start(14, 7, 8), 0);
393 assert_eq!(visible_row_start(14, 8, 8), 1);
394 assert_eq!(visible_row_start(14, 13, 8), 6);
395 }
396
397 #[test]
398 fn selected_row_text_fills_available_width() {
399 let text = status_row_text("▸", "[ ]", &StatusItem::LastToolElapsed, 40);
400 assert_eq!(text.width(), 40);
401 assert!(text.starts_with(" ▸ [ ] Last tool elapsed"));
402 }
403
404 #[test]
405 fn selected_row_text_semantically_truncates_hint_at_narrow_width() {
406 let text = status_row_text("▸", "[ ]", &StatusItem::LastToolElapsed, 49);
407 assert_eq!(text.width(), 49);
408 assert!(text.contains("ms of the most…"), "{text:?}");
409 assert!(!text.contains("ms of the most r"), "{text:?}");
410 }
411
412 #[test]
413 fn balance_excluded_for_non_deepseek_provider() {
414 let active = StatusItem::default_footer();
415 let view = StatusPickerView::new(&active, ApiProvider::Openrouter, Locale::En);
416 assert!(!view.rows.contains(&StatusItem::Balance));
417 assert!(view.rows.contains(&StatusItem::Mode));
418 }
419
420 #[test]
421 fn status_picker_displays_localized_title_for_zh_hans() {
422 assert_eq!(tr(Locale::ZhHans, MessageId::StatusPickerTitle), " 状态行 ");
423 }
424
425 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
426 /// every overlay to remain readable and fully operable at.
427 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
428
429 #[test]
430 fn status_picker_is_usable_and_opaque_at_blocker_sizes() {
431 use crate::tui::views::ViewStack;
432 let active = StatusItem::default_footer();
433 for (w, h) in BLOCKER_SIZES {
434 let area = Rect::new(0, 0, w, h);
435 let mut buf = Buffer::empty(area);
436 for y in 0..h {
437 for x in 0..w {
438 buf[(x, y)].set_symbol("X");
439 }
440 }
441 let mut stack = ViewStack::new();
442 stack.push(StatusPickerView::new(
443 &active,
444 ApiProvider::Deepseek,
445 Locale::En,
446 ));
447 stack.render(area, &mut buf);
448
449 let rows: Vec<String> = (0..h)
450 .map(|y| {
451 (0..w)
452 .map(|x| buf[(x, y)].symbol().to_string())
453 .collect::<String>()
454 })
455 .collect();
456 let text = rows.join("\n");
457
458 for label in ["toggle", "all", "none", "save", "cancel"] {
459 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
460 }
461 assert!(
462 !text.contains('X'),
463 "{w}x{h}: background bleed-through into modal surface"
464 );
465 assert_eq!(
466 buf[(w / 2, h / 2)].bg,
467 palette::WHALE_BG,
468 "{w}x{h}: modal interior must be opaque"
469 );
470 for (y, row) in rows.iter().enumerate() {
471 assert!(
472 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
473 "{w}x{h}: row {y} overflows width: {row:?}"
474 );
475 }
476 }
477 }
478
479 #[test]
480 fn status_picker_no_english_leak_in_non_en_locales() {
481 for locale in [
482 Locale::Ja,
483 Locale::ZhHans,
484 Locale::ZhHant,
485 Locale::PtBr,
486 Locale::Es419,
487 Locale::Vi,
488 Locale::Ca,
489 Locale::De,
490 Locale::Fr,
491 Locale::Id,
492 Locale::Hi,
493 Locale::Ru,
494 Locale::Uk,
495 ] {
496 let title = tr(locale, MessageId::StatusPickerTitle);
497 if locale == Locale::De {
498 // German "Statuszeile" is the correct native term — "Status"
499 // is a German word, not an English leak.
500 assert_eq!(title, " Statuszeile ");
501 } else {
502 assert!(
503 !title.contains("Status"),
504 "{} leaks English in title: {title}",
505 locale.tag()
506 );
507 }
508 let instruction = tr(locale, MessageId::StatusPickerInstruction);
509 assert!(
510 !instruction.contains("footer"),
511 "{} leaks English in instruction: {instruction}",
512 locale.tag()
513 );
514 }
515 }
516 }
517
517 lines RUST