返回 CodeWhale
setup.rs
根目录 / crates / tui / src / tui / hotbar / setup.rs
1 use std::collections::{BTreeMap, BTreeSet};
2
3 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4 use ratatui::{
5 buffer::Buffer,
6 layout::Rect,
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Paragraph, Widget, Wrap},
10 };
11
12 use crate::config::Config;
13 use crate::localization::{Locale, MessageId, tr};
14 use crate::palette;
15 use crate::tui::app::App;
16 use crate::tui::views::{
17 ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
18 centered_modal_area, render_modal_footer, render_modal_surface,
19 };
20
21 #[cfg(test)]
22 use super::actions::HotbarRecommendation;
23 use super::actions::{
24 HotbarActionCategory, HotbarActionMetadata, HotbarArgsBehavior, HotbarRecommendationOptions,
25 HotbarSafetyClass, recommend_hotbar_actions,
26 };
27
28 #[derive(Debug, Clone, PartialEq, Eq)]
29 pub struct HotbarSetupActionRow {
30 pub metadata: HotbarActionMetadata,
31 pub disabled_reason: Option<String>,
32 }
33
34 impl HotbarSetupActionRow {
35 fn status_label(&self, locale: Locale) -> String {
36 tr(
37 locale,
38 if self.disabled_reason.is_some() {
39 MessageId::HotbarSetupStatusDisabled
40 } else if matches!(self.metadata.args, HotbarArgsBehavior::Required) {
41 MessageId::HotbarSetupStatusPrefill
42 } else {
43 MessageId::HotbarSetupStatusReady
44 },
45 )
46 .into_owned()
47 }
48 }
49
50 fn hotbar_setup_source_label(locale: Locale, source: HotbarActionCategory) -> String {
51 let id = match source {
52 HotbarActionCategory::App => MessageId::HotbarSetupSourceApp,
53 HotbarActionCategory::Slash => MessageId::HotbarSetupSourceSlash,
54 HotbarActionCategory::Mcp => MessageId::HotbarSetupSourceMcp,
55 HotbarActionCategory::Skill => MessageId::HotbarSetupSourceSkill,
56 HotbarActionCategory::Plugin => MessageId::HotbarSetupSourcePlugin,
57 // `Route` is a source category introduced after PR #3785; it has no
58 // dedicated localization key, so fall back to its canonical English label.
59 HotbarActionCategory::Route => return source.as_str().to_string(),
60 };
61 tr(locale, id).into_owned()
62 }
63
64 fn tr_hotbar_setup(locale: Locale, id: MessageId, replacements: &[(&str, String)]) -> String {
65 let mut message = tr(locale, id).into_owned();
66 for (placeholder, value) in replacements {
67 message = message.replace(placeholder, value);
68 }
69 message
70 }
71
72 fn hotbar_setup_dirty_label(locale: Locale, is_dirty: bool) -> String {
73 tr(
74 locale,
75 if is_dirty {
76 MessageId::HotbarSetupDirtyModified
77 } else {
78 MessageId::HotbarSetupDirtyClean
79 },
80 )
81 .into_owned()
82 }
83
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub struct HotbarSetupView {
86 locale: Locale,
87 sources: Vec<HotbarActionCategory>,
88 actions: Vec<HotbarSetupActionRow>,
89 selected_source_idx: usize,
90 selected_action_idx_by_source: BTreeMap<HotbarActionCategory, usize>,
91 selected_slot: u8,
92 original_bindings: BTreeMap<u8, codewhale_config::HotbarBindingToml>,
93 draft_bindings: BTreeMap<u8, codewhale_config::HotbarBindingToml>,
94 recommended_action_ids: BTreeSet<String>,
95 validation_errors: Vec<String>,
96 query: String,
97 filter_focused: bool,
98 help_visible: bool,
99 }
100
101 impl HotbarSetupView {
102 #[must_use]
103 pub fn new(app: &App, config: &Config) -> Self {
104 let mut actions = app
105 .hotbar_actions
106 .iter()
107 .map(|action| {
108 let metadata = action.metadata(app.ui_locale);
109 let disabled_reason = action.disabled_reason(app);
110 HotbarSetupActionRow {
111 metadata,
112 disabled_reason,
113 }
114 })
115 .collect::<Vec<_>>();
116 actions.sort_by(|a, b| {
117 a.metadata
118 .category
119 .cmp(&b.metadata.category)
120 .then_with(|| {
121 a.metadata
122 .display_name
123 .to_ascii_lowercase()
124 .cmp(&b.metadata.display_name.to_ascii_lowercase())
125 })
126 .then_with(|| a.metadata.id.cmp(&b.metadata.id))
127 });
128
129 let sources = actions
130 .iter()
131 .map(|row| row.metadata.category)
132 .collect::<BTreeSet<_>>()
133 .into_iter()
134 .collect::<Vec<_>>();
135 let recommended_action_ids =
136 recommend_hotbar_actions(app, HotbarRecommendationOptions::for_setup_wizard())
137 .into_iter()
138 .map(|entry| entry.metadata.id)
139 .collect::<BTreeSet<_>>();
140
141 let known_action_ids = app
142 .hotbar_actions
143 .iter()
144 .map(|action| action.id())
145 .collect::<Vec<_>>();
146 let original_bindings = config
147 .resolve_hotbar_bindings(&known_action_ids)
148 .bindings
149 .into_iter()
150 .map(|binding| {
151 (
152 binding.slot,
153 codewhale_config::HotbarBindingToml {
154 slot: binding.slot,
155 action: binding.action,
156 label: binding.label,
157 },
158 )
159 })
160 .collect::<BTreeMap<_, _>>();
161
162 Self {
163 locale: app.ui_locale,
164 sources,
165 actions,
166 selected_source_idx: 0,
167 selected_action_idx_by_source: BTreeMap::new(),
168 selected_slot: 1,
169 draft_bindings: original_bindings.clone(),
170 original_bindings,
171 recommended_action_ids,
172 validation_errors: Vec::new(),
173 query: String::new(),
174 filter_focused: false,
175 help_visible: false,
176 }
177 }
178
179 #[must_use]
180 #[cfg(test)]
181 pub fn source_categories(&self) -> &[HotbarActionCategory] {
182 &self.sources
183 }
184
185 #[must_use]
186 pub fn selected_source(&self) -> Option<HotbarActionCategory> {
187 self.sources.get(self.selected_source_idx).copied()
188 }
189
190 #[must_use]
191 #[cfg(test)]
192 pub fn selected_slot(&self) -> u8 {
193 self.selected_slot
194 }
195
196 #[must_use]
197 pub fn selected_action(&self) -> Option<&HotbarSetupActionRow> {
198 let source = self.selected_source()?;
199 self.actions_for_source(source)
200 .get(self.selected_action_idx(source))
201 .copied()
202 }
203
204 #[must_use]
205 #[cfg(test)]
206 pub fn binding_for_slot(&self, slot: u8) -> Option<&codewhale_config::HotbarBindingToml> {
207 self.draft_bindings.get(&slot)
208 }
209
210 #[must_use]
211 #[cfg(test)]
212 pub fn checked_action_ids(&self) -> BTreeSet<String> {
213 self.draft_bindings
214 .values()
215 .map(|binding| binding.action.clone())
216 .collect()
217 }
218
219 #[must_use]
220 #[cfg(test)]
221 pub fn recommended_action_ids(&self) -> &BTreeSet<String> {
222 &self.recommended_action_ids
223 }
224
225 #[must_use]
226 pub fn is_dirty(&self) -> bool {
227 self.draft_bindings != self.original_bindings
228 }
229
230 #[must_use]
231 #[cfg(test)]
232 pub fn validation_errors(&self) -> &[String] {
233 &self.validation_errors
234 }
235
236 #[must_use]
237 #[cfg(test)]
238 pub fn query(&self) -> &str {
239 &self.query
240 }
241
242 #[must_use]
243 pub fn status_text(&self) -> String {
244 if let Some(error) = self.validation_errors.last() {
245 return error.clone();
246 }
247 let dirty = hotbar_setup_dirty_label(self.locale, self.is_dirty());
248 let action = self
249 .selected_action()
250 .map(|row| {
251 format!(
252 "{} ({})",
253 row.metadata.display_name,
254 row.status_label(self.locale)
255 )
256 })
257 .unwrap_or_else(|| tr(self.locale, MessageId::HotbarSetupNoAction).into_owned());
258 tr_hotbar_setup(
259 self.locale,
260 MessageId::HotbarSetupStatusLine,
261 &[
262 ("{slot}", self.selected_slot.to_string()),
263 ("{action}", action),
264 ("{dirty}", dirty),
265 ],
266 )
267 }
268
269 #[cfg(test)]
270 pub fn select_action_by_id(&mut self, action_id: &str) -> bool {
271 self.query.clear();
272 self.filter_focused = false;
273 let Some(row) = self
274 .actions
275 .iter()
276 .find(|row| row.metadata.id == action_id)
277 .cloned()
278 else {
279 return false;
280 };
281 let Some(source_idx) = self
282 .sources
283 .iter()
284 .position(|source| *source == row.metadata.category)
285 else {
286 return false;
287 };
288 self.selected_source_idx = source_idx;
289 let index = self
290 .actions_for_source(row.metadata.category)
291 .iter()
292 .position(|candidate| candidate.metadata.id == action_id)
293 .unwrap_or(0);
294 self.selected_action_idx_by_source
295 .insert(row.metadata.category, index);
296 self.validation_errors.clear();
297 true
298 }
299
300 pub fn select_slot(&mut self, slot: u8) -> bool {
301 if !(1..=codewhale_config::HOTBAR_SLOT_COUNT).contains(&slot) {
302 self.validation_errors = vec![tr_hotbar_setup(
303 self.locale,
304 MessageId::HotbarSetupSlotOutOfRange,
305 &[
306 ("{slot}", slot.to_string()),
307 ("{max}", codewhale_config::HOTBAR_SLOT_COUNT.to_string()),
308 ],
309 )];
310 return false;
311 }
312 self.selected_slot = slot;
313 self.validation_errors.clear();
314 true
315 }
316
317 pub fn assign_selected_action(&mut self) -> bool {
318 let Some(row) = self.selected_action().cloned() else {
319 self.validation_errors =
320 vec![tr(self.locale, MessageId::HotbarSetupNoActionSelected).into_owned()];
321 return false;
322 };
323 if let Some(reason) = row.disabled_reason {
324 self.validation_errors = vec![tr_hotbar_setup(
325 self.locale,
326 MessageId::HotbarSetupCannotAssign,
327 &[
328 ("{action}", row.metadata.display_name),
329 ("{reason}", reason),
330 ],
331 )];
332 return false;
333 }
334 self.draft_bindings.insert(
335 self.selected_slot,
336 codewhale_config::HotbarBindingToml {
337 slot: self.selected_slot,
338 action: row.metadata.id,
339 label: None,
340 },
341 );
342 self.validation_errors.clear();
343 true
344 }
345
346 pub fn toggle_selected_action(&mut self) -> bool {
347 let selected_id = self
348 .selected_action()
349 .map(|row| row.metadata.id.clone())
350 .unwrap_or_default();
351 if self
352 .draft_bindings
353 .get(&self.selected_slot)
354 .is_some_and(|binding| binding.action == selected_id)
355 {
356 self.clear_selected_slot();
357 true
358 } else {
359 self.assign_selected_action()
360 }
361 }
362
363 pub fn clear_selected_slot(&mut self) {
364 self.draft_bindings.remove(&self.selected_slot);
365 self.validation_errors.clear();
366 }
367
368 #[must_use]
369 pub fn save_bindings(&self) -> Vec<codewhale_config::HotbarBindingToml> {
370 self.draft_bindings.values().cloned().collect()
371 }
372
373 fn actions_for_source(&self, source: HotbarActionCategory) -> Vec<&HotbarSetupActionRow> {
374 let query = self.query.trim().to_ascii_lowercase();
375 self.actions
376 .iter()
377 .filter(|row| {
378 row.metadata.category == source
379 && (query.is_empty() || action_matches_query(row, self.locale, &query))
380 })
381 .collect()
382 }
383
384 fn unfiltered_actions_for_source(
385 &self,
386 source: HotbarActionCategory,
387 ) -> Vec<&HotbarSetupActionRow> {
388 self.actions
389 .iter()
390 .filter(|row| row.metadata.category == source)
391 .collect()
392 }
393
394 fn selected_action_idx(&self, source: HotbarActionCategory) -> usize {
395 let len = self.actions_for_source(source).len();
396 if len == 0 {
397 return 0;
398 }
399 self.selected_action_idx_by_source
400 .get(&source)
401 .copied()
402 .unwrap_or(0)
403 .min(len.saturating_sub(1))
404 }
405
406 fn set_selected_action_idx(&mut self, source: HotbarActionCategory, idx: usize) {
407 let len = self.actions_for_source(source).len();
408 if len == 0 {
409 self.selected_action_idx_by_source.insert(source, 0);
410 } else {
411 self.selected_action_idx_by_source
412 .insert(source, idx.min(len.saturating_sub(1)));
413 }
414 }
415
416 fn move_source(&mut self, delta: isize) {
417 if self.sources.is_empty() {
418 return;
419 }
420 self.selected_source_idx = wrap_index(self.selected_source_idx, self.sources.len(), delta);
421 self.validation_errors.clear();
422 }
423
424 fn move_action(&mut self, delta: isize) {
425 let Some(source) = self.selected_source() else {
426 return;
427 };
428 let len = self.actions_for_source(source).len();
429 if len == 0 {
430 return;
431 }
432 let next = wrap_index(self.selected_action_idx(source), len, delta);
433 self.set_selected_action_idx(source, next);
434 self.validation_errors.clear();
435 }
436
437 fn move_slot(&mut self, delta: isize) {
438 let len = usize::from(codewhale_config::HOTBAR_SLOT_COUNT);
439 let next = wrap_index(usize::from(self.selected_slot - 1), len, delta) + 1;
440 self.selected_slot = u8::try_from(next).expect("hotbar slot fits in u8");
441 self.validation_errors.clear();
442 }
443
444 fn save_action(&self) -> ViewAction {
445 ViewAction::EmitAndClose(ViewEvent::HotbarSetupSaved {
446 bindings: self.save_bindings(),
447 })
448 }
449
450 #[cfg(test)]
451 fn render_lines(&self) -> Vec<Line<'static>> {
452 let mut lines = Vec::new();
453 lines.extend(self.header_lines());
454
455 let Some(source) = self.selected_source() else {
456 lines.push(Line::from(
457 tr(self.locale, MessageId::HotbarSetupNoActions).into_owned(),
458 ));
459 return lines;
460 };
461
462 for (idx, row) in self.actions_for_source(source).iter().enumerate() {
463 lines.push(self.action_row_line(source, idx, row, 80));
464 }
465
466 lines.push(Line::from(""));
467 lines.push(self.slots_line());
468 lines.push(Line::from(self.status_text()));
469 lines
470 }
471
472 fn header_lines(&self) -> Vec<Line<'static>> {
473 let alt_prefix = crate::tui::widgets::key_hint::alt_prefix();
474 vec![
475 Line::from(Span::styled(
476 format!(
477 "Hotbar gives you {alt_prefix}1-8 shortcuts. Assign actions below; \
478 press 'd' or run `/hotbar off` to hide it."
479 ),
480 Style::default()
481 .fg(palette::TEXT_PRIMARY)
482 .add_modifier(Modifier::DIM),
483 )),
484 self.slots_line(),
485 self.source_tabs_line(),
486 self.filter_line(),
487 Line::from(self.status_text()),
488 ]
489 }
490
491 fn source_tabs_line(&self) -> Line<'static> {
492 let mut spans = Vec::new();
493 for (idx, source) in self.sources.iter().enumerate() {
494 if idx > 0 {
495 spans.push(Span::raw(" "));
496 }
497 let count = self.unfiltered_actions_for_source(*source).len();
498 let name = hotbar_setup_source_label(self.locale, *source);
499 let label = if Some(*source) == self.selected_source() {
500 format!("[{name} {count}]")
501 } else {
502 format!("{name} {count}")
503 };
504 spans.push(Span::styled(
505 label,
506 Style::default()
507 .fg(if Some(*source) == self.selected_source() {
508 Color::Cyan
509 } else {
510 palette::TEXT_MUTED
511 })
512 .add_modifier(if Some(*source) == self.selected_source() {
513 Modifier::BOLD
514 } else {
515 Modifier::empty()
516 }),
517 ));
518 }
519 Line::from(spans)
520 }
521
522 fn filter_line(&self) -> Line<'static> {
523 let value = if self.query.is_empty() {
524 if self.filter_focused {
525 "type to filter".to_string()
526 } else {
527 "press / or type to filter".to_string()
528 }
529 } else {
530 self.query.clone()
531 };
532 Line::from(vec![
533 Span::styled("Filter ", Style::default().fg(palette::TEXT_MUTED)),
534 Span::styled(
535 value,
536 Style::default().fg(if self.filter_focused {
537 palette::WHALE_INFO
538 } else {
539 palette::TEXT_PRIMARY
540 }),
541 ),
542 ])
543 }
544
545 fn slots_line(&self) -> Line<'static> {
546 let slots = (1..=codewhale_config::HOTBAR_SLOT_COUNT)
547 .map(|slot| {
548 let label = self
549 .draft_bindings
550 .get(&slot)
551 .map(|binding| compact_action_id(&binding.action))
552 .unwrap_or_else(|| {
553 tr(self.locale, MessageId::HotbarSetupEmptySlot).into_owned()
554 });
555 if slot == self.selected_slot {
556 format!("[{slot}:{label}]")
557 } else {
558 format!("{slot}:{label}")
559 }
560 })
561 .collect::<Vec<_>>()
562 .join(" ");
563 Line::from(slots)
564 }
565
566 fn action_row_line(
567 &self,
568 source: HotbarActionCategory,
569 idx: usize,
570 row: &HotbarSetupActionRow,
571 max_width: u16,
572 ) -> Line<'static> {
573 let selected = idx == self.selected_action_idx(source);
574 let marker = crate::tui::glyphs::selection_marker(selected);
575 let checked = if self
576 .draft_bindings
577 .values()
578 .any(|binding| binding.action == row.metadata.id)
579 {
580 "*"
581 } else {
582 " "
583 };
584 let recommended = if self.recommended_action_ids.contains(&row.metadata.id) {
585 tr(self.locale, MessageId::HotbarSetupRecommended).into_owned()
586 } else {
587 String::new()
588 };
589 let prefix = format!(
590 "{marker}{checked} {:<3} {:<22} {:<8} ",
591 recommended,
592 row.metadata.display_name,
593 row.status_label(self.locale)
594 );
595 let suffix = if let Some(reason) = row.disabled_reason.as_deref() {
596 format!(" ({reason})")
597 } else {
598 String::new()
599 };
600 let text = crate::tui::ui_text::semantic_truncate_with_affixes(
601 &prefix,
602 &row.metadata.description,
603 &suffix,
604 usize::from(max_width),
605 );
606 Line::from(Span::styled(
607 text,
608 Style::default()
609 .fg(if selected {
610 palette::WHALE_INFO
611 } else {
612 palette::TEXT_PRIMARY
613 })
614 .add_modifier(if selected {
615 Modifier::BOLD
616 } else {
617 Modifier::empty()
618 }),
619 ))
620 }
621
622 fn render_header(&self, area: Rect, buf: &mut Buffer) {
623 Paragraph::new(self.header_lines())
624 .style(Style::default().fg(palette::TEXT_PRIMARY))
625 .wrap(Wrap { trim: true })
626 .render(area, buf);
627 }
628
629 fn render_action_list(&self, area: Rect, buf: &mut Buffer) {
630 let Some(source) = self.selected_source() else {
631 EmptyState::new("No actions", "No hotbar action sources are available.")
632 .render(area, buf);
633 return;
634 };
635 let rows = self.actions_for_source(source);
636 if rows.is_empty() {
637 EmptyState::new(
638 "No matching actions",
639 "Clear the filter or switch categories to find another bindable action.",
640 )
641 .primary_action("/", "filter")
642 .secondary_action("Esc", "clear filter")
643 .render(area, buf);
644 return;
645 }
646 let mut lines = vec![Line::from(Span::styled(
647 format!("{} actions", source.as_str()),
648 Style::default()
649 .fg(palette::TEXT_MUTED)
650 .add_modifier(Modifier::BOLD),
651 ))];
652 // Keep the focused row inside the rendered viewport. The list used to
653 // render only its first rows, so keyboard selection could advance past
654 // `/export` while the highlight stayed behind (#4418).
655 let visible_rows = usize::from(area.height.saturating_sub(1));
656 let visible_range =
657 action_list_visible_range(self.selected_action_idx(source), rows.len(), visible_rows);
658 for idx in visible_range {
659 lines.push(self.action_row_line(source, idx, rows[idx], area.width));
660 }
661 Paragraph::new(lines)
662 .style(Style::default().fg(palette::TEXT_PRIMARY))
663 .render(area, buf);
664 }
665
666 fn render_action_detail(&self, area: Rect, buf: &mut Buffer) {
667 let Some(row) = self.selected_action() else {
668 EmptyState::new(
669 "Select an action",
670 "Move through the catalog to preview the selected slot binding.",
671 )
672 .primary_action("Tab", "category")
673 .secondary_action("/", "filter")
674 .render(area, buf);
675 return;
676 };
677 Paragraph::new(self.detail_lines(row))
678 .style(Style::default().fg(palette::TEXT_PRIMARY))
679 .wrap(Wrap { trim: true })
680 .render(area, buf);
681 }
682
683 fn detail_lines(&self, row: &HotbarSetupActionRow) -> Vec<Line<'static>> {
684 let mut lines = vec![
685 Line::from(Span::styled(
686 row.metadata.display_name.clone(),
687 Style::default()
688 .fg(palette::TEXT_PRIMARY)
689 .add_modifier(Modifier::BOLD),
690 )),
691 Line::from(Span::styled(
692 row.metadata.id.clone(),
693 Style::default().fg(palette::TEXT_MUTED),
694 )),
695 Line::from(""),
696 Line::from(format!("Category: {}", row.metadata.category.as_str())),
697 Line::from(format!("Status: {}", row.status_label(self.locale))),
698 Line::from(format!("Safety: {}", safety_label(row.metadata.safety))),
699 Line::from(format!("Arguments: {}", args_label(row.metadata.args))),
700 Line::from(format!(
701 "Slot {}: {}",
702 self.selected_slot,
703 self.selected_slot_binding_label()
704 )),
705 Line::from(""),
706 Line::from(row.metadata.description.clone()),
707 Line::from(""),
708 Line::from(preview_line(row)),
709 ];
710 if let Some(reason) = row.disabled_reason.as_deref() {
711 lines.push(Line::from(Span::styled(
712 format!("Unavailable: {reason}"),
713 Style::default().fg(palette::STATUS_WARNING),
714 )));
715 }
716 if self.help_visible {
717 lines.push(Line::from(""));
718 lines.push(Line::from(
719 "Save writes staged slots; Esc cancels staged changes unless a filter is active.",
720 ));
721 lines.push(Line::from(
722 "After save: Alt+1 through Alt+8 dispatch Hotbar slots. Bare 1-8 stay composer text outside setup.",
723 ));
724 }
725 lines
726 }
727
728 fn selected_slot_binding_label(&self) -> String {
729 let Some(binding) = self.draft_bindings.get(&self.selected_slot) else {
730 return tr(self.locale, MessageId::HotbarSetupEmptySlot).into_owned();
731 };
732 self.actions
733 .iter()
734 .find(|row| row.metadata.id == binding.action)
735 .map(|row| row.metadata.display_name.clone())
736 .unwrap_or_else(|| binding.action.clone())
737 }
738 }
739
740 impl ModalView for HotbarSetupView {
741 fn kind(&self) -> ModalKind {
742 ModalKind::HotbarSetup
743 }
744
745 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
746 match key.code {
747 KeyCode::Esc if self.filter_focused || !self.query.is_empty() => {
748 self.query.clear();
749 self.filter_focused = false;
750 self.validation_errors.clear();
751 ViewAction::None
752 }
753 KeyCode::Esc => ViewAction::Close,
754 KeyCode::Char('q') | KeyCode::Char('Q')
755 if key.modifiers.is_empty() && !self.filter_focused =>
756 {
757 ViewAction::Close
758 }
759 KeyCode::Tab => {
760 self.move_source(1);
761 ViewAction::None
762 }
763 KeyCode::BackTab => {
764 self.move_source(-1);
765 ViewAction::None
766 }
767 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => {
768 self.move_source(-1);
769 ViewAction::None
770 }
771 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => {
772 self.move_source(1);
773 ViewAction::None
774 }
775 KeyCode::Left => {
776 self.move_slot(-1);
777 ViewAction::None
778 }
779 KeyCode::Right => {
780 self.move_slot(1);
781 ViewAction::None
782 }
783 KeyCode::Up => {
784 self.move_action(-1);
785 ViewAction::None
786 }
787 KeyCode::Char('k') | KeyCode::Char('K')
788 if key.modifiers.is_empty() && !self.filter_focused =>
789 {
790 self.move_action(-1);
791 ViewAction::None
792 }
793 KeyCode::Down => {
794 self.move_action(1);
795 ViewAction::None
796 }
797 KeyCode::Char('j') | KeyCode::Char('J')
798 if key.modifiers.is_empty() && !self.filter_focused =>
799 {
800 self.move_action(1);
801 ViewAction::None
802 }
803 KeyCode::Enter => {
804 self.assign_selected_action();
805 ViewAction::None
806 }
807 KeyCode::Char('a') | KeyCode::Char('A')
808 if key.modifiers.is_empty() && !self.filter_focused =>
809 {
810 self.assign_selected_action();
811 ViewAction::None
812 }
813 KeyCode::Char(' ') => {
814 self.toggle_selected_action();
815 ViewAction::None
816 }
817 KeyCode::Backspace if self.filter_focused || !self.query.is_empty() => {
818 self.query.pop();
819 if self.query.is_empty() {
820 self.filter_focused = false;
821 }
822 self.validation_errors.clear();
823 ViewAction::None
824 }
825 KeyCode::Backspace | KeyCode::Delete => {
826 self.clear_selected_slot();
827 ViewAction::None
828 }
829 KeyCode::Char('c') | KeyCode::Char('C')
830 if key.modifiers.is_empty() && !self.filter_focused =>
831 {
832 self.clear_selected_slot();
833 ViewAction::None
834 }
835 KeyCode::Char(ch) if ('1'..='8').contains(&ch) => {
836 let slot = ch.to_digit(10).expect("digit") as u8;
837 self.select_slot(slot);
838 ViewAction::None
839 }
840 KeyCode::Char('s') | KeyCode::Char('S')
841 if key.modifiers.is_empty() && !self.filter_focused =>
842 {
843 self.save_action()
844 }
845 KeyCode::Char('d') | KeyCode::Char('D')
846 if key.modifiers.is_empty() && !self.filter_focused =>
847 {
848 // "Disable Hotbar" from inside the setup flow: hide it and
849 // persist `hotbar = []`. Mirrors `/hotbar off`.
850 ViewAction::EmitAndClose(ViewEvent::HotbarDisableRequested)
851 }
852 KeyCode::Char('/') if key.modifiers.is_empty() => {
853 self.filter_focused = true;
854 self.validation_errors.clear();
855 ViewAction::None
856 }
857 KeyCode::Char('?') => {
858 self.help_visible = !self.help_visible;
859 ViewAction::None
860 }
861 KeyCode::Char(ch) if key.modifiers.is_empty() => {
862 self.filter_focused = true;
863 self.query.push(ch);
864 self.validation_errors.clear();
865 if let Some(source) = self.selected_source() {
866 self.set_selected_action_idx(source, 0);
867 }
868 ViewAction::None
869 }
870 _ => ViewAction::None,
871 }
872 }
873
874 fn render(&self, area: Rect, buf: &mut Buffer) {
875 let popup_area = centered_modal_area(area, 118, 28, 72, 12);
876 render_modal_surface(area, popup_area, buf);
877 let block = Block::default()
878 .title(Line::from(Span::styled(
879 tr(self.locale, MessageId::HotbarSetupTitle),
880 Style::default()
881 .fg(palette::WHALE_INFO)
882 .add_modifier(Modifier::BOLD),
883 )))
884 .borders(Borders::ALL)
885 .border_style(Style::default().fg(palette::BORDER_COLOR))
886 .style(Style::default().bg(palette::WHALE_BG));
887 let inner = block.inner(popup_area);
888 block.render(popup_area, buf);
889
890 let content = render_modal_footer(
891 inner,
892 buf,
893 &[
894 ActionHint::new("Tab/Shift+Tab", "source"),
895 ActionHint::new("↑/↓", "action"),
896 ActionHint::new("1-8", "slot"),
897 ActionHint::new("/", "filter"),
898 ActionHint::new("Enter/A", "assign"),
899 ActionHint::new("Space", "toggle"),
900 ActionHint::new("C/Delete", "clear"),
901 ActionHint::new("s", "save"),
902 ActionHint::new("d", "disable"),
903 ActionHint::new("Esc", "cancel"),
904 ],
905 );
906 let header_height = content.height.min(5);
907 let header = Rect {
908 x: content.x,
909 y: content.y,
910 width: content.width,
911 height: header_height,
912 };
913 self.render_header(header, buf);
914 let body = Rect {
915 x: content.x,
916 y: content.y + header_height,
917 width: content.width,
918 height: content.height.saturating_sub(header_height),
919 };
920 let layout = ListDetailLayout::split(body, 34);
921 self.render_action_list(layout.list, buf);
922 self.render_action_detail(layout.detail, buf);
923 }
924
925 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
926 self
927 }
928 }
929
930 fn wrap_index(current: usize, len: usize, delta: isize) -> usize {
931 if len == 0 {
932 return 0;
933 }
934 let len = isize::try_from(len).expect("len fits in isize");
935 let current = isize::try_from(current).expect("current fits in isize");
936 usize::try_from((current + delta).rem_euclid(len)).expect("wrapped index fits")
937 }
938
939 fn action_list_visible_range(
940 selected_idx: usize,
941 row_count: usize,
942 visible_rows: usize,
943 ) -> std::ops::Range<usize> {
944 if row_count == 0 || visible_rows == 0 {
945 return 0..0;
946 }
947 let selected_idx = selected_idx.min(row_count.saturating_sub(1));
948 let start = selected_idx.saturating_add(1).saturating_sub(visible_rows);
949 let end = start.saturating_add(visible_rows).min(row_count);
950 start..end
951 }
952
953 fn action_matches_query(row: &HotbarSetupActionRow, locale: Locale, query: &str) -> bool {
954 let status = row.status_label(locale);
955 [
956 row.metadata.id.as_str(),
957 row.metadata.display_name.as_str(),
958 row.metadata.description.as_str(),
959 row.metadata.category.as_str(),
960 status.as_str(),
961 row.disabled_reason.as_deref().unwrap_or_default(),
962 ]
963 .into_iter()
964 .any(|value| value.to_ascii_lowercase().contains(query))
965 }
966
967 fn safety_label(safety: HotbarSafetyClass) -> &'static str {
968 match safety {
969 HotbarSafetyClass::LocalUi => "safe UI",
970 HotbarSafetyClass::LocalState => "local state",
971 HotbarSafetyClass::ConfigChange => "config change",
972 HotbarSafetyClass::ExternalInput => "external input",
973 HotbarSafetyClass::ExistingCommand => "existing command",
974 HotbarSafetyClass::RequiresApproval => "approval gated",
975 }
976 }
977
978 fn args_label(args: HotbarArgsBehavior) -> &'static str {
979 match args {
980 HotbarArgsBehavior::None => "none",
981 HotbarArgsBehavior::Optional => "optional",
982 HotbarArgsBehavior::Required => "prefill required arguments",
983 }
984 }
985
986 fn preview_line(row: &HotbarSetupActionRow) -> String {
987 match (row.metadata.category, row.metadata.args) {
988 (HotbarActionCategory::Route, _) => {
989 "Preview: switches provider/model through /model route logic.".to_string()
990 }
991 (_, HotbarArgsBehavior::Required) => {
992 "Preview: pre-fills the composer instead of running blindly.".to_string()
993 }
994 _ => "Preview: dispatches through the existing Hotbar action path.".to_string(),
995 }
996 }
997
998 fn compact_action_id(action_id: &str) -> String {
999 let suffix = action_id.rsplit('.').next().unwrap_or(action_id);
1000 crate::tui::ui_text::truncate_line_to_width(suffix, 7)
1001 }
1002
1003 #[cfg(test)]
1004 mod tests {
1005 use super::*;
1006 use crate::config::{ApiProvider, Config};
1007 use crate::localization::{Locale, MessageId, tr};
1008 use crate::tui::app::TuiOptions;
1009 use crate::tui::hotbar::HotbarActionRegistry;
1010 use crossterm::event::KeyModifiers;
1011 use std::path::PathBuf;
1012
1013 fn test_app_with_config(config: &Config) -> App {
1014 let options = TuiOptions {
1015 start_in_agent_mode: true,
1016 ..crate::test_support::test_tui_options(PathBuf::from("."))
1017 };
1018 let mut app = App::new(options, config);
1019 app.ui_locale = Locale::En;
1020 app
1021 }
1022
1023 fn test_app_with_locale(locale: Locale) -> App {
1024 let mut app = test_app();
1025 app.ui_locale = locale;
1026 app
1027 }
1028
1029 fn test_app() -> App {
1030 test_app_with_config(&Config::default())
1031 }
1032
1033 fn key(code: KeyCode) -> KeyEvent {
1034 KeyEvent::new(code, KeyModifiers::NONE)
1035 }
1036
1037 fn rendered_text_at(view: &HotbarSetupView, width: u16, height: u16) -> String {
1038 let area = Rect::new(0, 0, width, height);
1039 let mut buf = Buffer::empty(area);
1040 view.render(area, &mut buf);
1041
1042 let mut out = String::new();
1043 for y in area.top()..area.bottom() {
1044 for x in area.left()..area.right() {
1045 out.push_str(buf[(x, y)].symbol());
1046 }
1047 out.push('\n');
1048 }
1049 out
1050 }
1051
1052 fn rendered_text(view: &HotbarSetupView) -> String {
1053 rendered_text_at(view, 140, 36)
1054 }
1055
1056 #[test]
1057 fn wizard_sources_follow_registered_action_categories() {
1058 let app = test_app();
1059 let view = HotbarSetupView::new(&app, &Config::default());
1060
1061 // Skills are registered from whatever the startup skill cache
1062 // discovered, so only the always-present categories are asserted
1063 // in order here (see wizard_lists_skill_and_mcp_sources_when_registered
1064 // for the injected-source coverage).
1065 assert!(
1066 view.source_categories().starts_with(&[
1067 HotbarActionCategory::App,
1068 HotbarActionCategory::Route,
1069 HotbarActionCategory::Slash,
1070 ]),
1071 "unexpected wizard sources: {:?}",
1072 view.source_categories()
1073 );
1074 // MCP tools only appear after a live discovery snapshot lands, and
1075 // plugins stay a deferred source.
1076 assert!(
1077 !view
1078 .source_categories()
1079 .contains(&HotbarActionCategory::Mcp)
1080 );
1081 assert!(
1082 !view
1083 .source_categories()
1084 .contains(&HotbarActionCategory::Plugin)
1085 );
1086 assert_eq!(view.selected_source(), Some(HotbarActionCategory::App));
1087 assert!(view.recommended_action_ids().contains("mode.agent"));
1088 // #3807: a fresh config seeds no bindings, so the wizard opens with
1089 // nothing checked until the user opts in.
1090 assert!(view.checked_action_ids().is_empty());
1091 }
1092
1093 #[test]
1094 fn wizard_lists_skill_and_mcp_sources_when_registered() {
1095 let mut app = test_app();
1096 let mut registry = HotbarActionRegistry::with_builtins();
1097 registry.register_skills(&[("demo".to_string(), "Demo skill".to_string())]);
1098 registry.replace_mcp_tools(Some(&crate::mcp::McpManagerSnapshot {
1099 config_path: PathBuf::from("mcp.json"),
1100 config_exists: true,
1101 reload_required: false,
1102 servers: vec![crate::mcp::McpServerSnapshot {
1103 name: "search".to_string(),
1104 enabled: true,
1105 required: false,
1106 transport: "stdio".to_string(),
1107 command_or_url: "search-server".to_string(),
1108 connect_timeout: 5,
1109 execute_timeout: 5,
1110 read_timeout: 5,
1111 connected: true,
1112 error: None,
1113 tools: vec![crate::mcp::McpDiscoveredItem {
1114 name: "web_search".to_string(),
1115 model_name: "mcp_search_web_search".to_string(),
1116 description: Some("Search the web".to_string()),
1117 }],
1118 resources: Vec::new(),
1119 prompts: Vec::new(),
1120 }],
1121 }));
1122 app.hotbar_actions = registry;
1123 let mut view = HotbarSetupView::new(&app, &Config::default());
1124
1125 assert!(
1126 view.source_categories()
1127 .contains(&HotbarActionCategory::Skill)
1128 );
1129 assert!(
1130 view.source_categories()
1131 .contains(&HotbarActionCategory::Mcp)
1132 );
1133
1134 // Skills assign like any direct action; MCP tools stay assignable as
1135 // composer-prefill actions.
1136 assert!(view.select_slot(4));
1137 assert!(view.select_action_by_id("skill.demo"));
1138 assert!(view.assign_selected_action());
1139 assert_eq!(
1140 view.binding_for_slot(4)
1141 .map(|binding| binding.action.as_str()),
1142 Some("skill.demo")
1143 );
1144
1145 assert!(view.select_action_by_id("mcp.search.web_search"));
1146 assert!(
1147 view.status_text().contains("prefill"),
1148 "MCP tools must be labeled as prefill actions: {}",
1149 view.status_text()
1150 );
1151 assert!(view.select_slot(5));
1152 assert!(view.assign_selected_action());
1153 assert_eq!(
1154 view.binding_for_slot(5)
1155 .map(|binding| binding.action.as_str()),
1156 Some("mcp.search.web_search")
1157 );
1158 }
1159
1160 #[test]
1161 fn wizard_chrome_uses_non_english_locale() {
1162 let app = test_app_with_locale(Locale::ZhHant);
1163 let mut view = HotbarSetupView::new(&app, &Config::default());
1164 view.clear_selected_slot();
1165 view.handle_key(key(KeyCode::Char('?')));
1166
1167 let status = view.status_text();
1168 assert!(status.contains("槽位 1"), "status was {status:?}");
1169 // `Config::default()` ships no default bindings, so a freshly-cleared slot
1170 // is clean; assert the localized clean label (dirty localization is covered
1171 // by the wider render checks below) and that no English chrome leaks.
1172 assert!(
1173 status.contains(tr(Locale::ZhHant, MessageId::HotbarSetupDirtyClean).as_ref()),
1174 "status was {status:?}"
1175 );
1176 assert!(!status.contains("slot 1 |"), "status was {status:?}");
1177 assert!(!status.contains("clean"), "status was {status:?}");
1178
1179 let rendered = rendered_text(&view);
1180 let compact_rendered = rendered.replace(' ', "");
1181 // Localized chrome the PR routes through message IDs: title, source tabs
1182 // (the selected tab is bracketed and now carries a count from PR #3987),
1183 // status line, and localized built-in action names.
1184 for expected in [
1185 "Hotbar設定",
1186 "[應用",
1187 "命令",
1188 "就緒",
1189 "槽位",
1190 "Act模式",
1191 "命令面板",
1192 "切換側邊欄",
1193 ] {
1194 assert!(
1195 compact_rendered.contains(expected),
1196 "missing {expected:?} in render:\n{rendered}"
1197 );
1198 }
1199 assert!(
1200 compact_rendered.contains(":空"),
1201 "missing localized empty slot:\n{rendered}"
1202 );
1203
1204 // English must not leak on the surfaces the PR localizes. The keybinding
1205 // footer, filter row, and detail labels are English scaffolding added by
1206 // PR #3987 after this contribution and are intentionally out of scope.
1207 for leaked in [
1208 "Hotbar setup",
1209 "slot 1 |",
1210 "ready",
1211 "modified",
1212 "empty",
1213 "Agent mode",
1214 "Command palette",
1215 "Toggle sidebar",
1216 "Switch the conversation",
1217 ] {
1218 assert!(
1219 !rendered.contains(leaked),
1220 "leaked {leaked:?} in render:\n{rendered}"
1221 );
1222 }
1223 }
1224
1225 #[test]
1226 fn wizard_assigns_replaces_toggles_and_clears_slots() {
1227 let app = test_app();
1228 let mut view = HotbarSetupView::new(&app, &Config::default());
1229
1230 assert!(view.select_slot(1));
1231 assert!(view.select_action_by_id("mode.plan"));
1232 assert!(view.assign_selected_action());
1233 assert_eq!(
1234 view.binding_for_slot(1)
1235 .map(|binding| binding.action.as_str()),
1236 Some("mode.plan")
1237 );
1238
1239 assert!(view.select_action_by_id("mode.agent"));
1240 assert!(view.assign_selected_action());
1241 assert_eq!(
1242 view.binding_for_slot(1)
1243 .map(|binding| binding.action.as_str()),
1244 Some("mode.agent")
1245 );
1246 assert!(view.is_dirty());
1247
1248 assert!(view.toggle_selected_action());
1249 assert!(view.binding_for_slot(1).is_none());
1250 view.clear_selected_slot();
1251 assert!(view.binding_for_slot(1).is_none());
1252 }
1253
1254 #[test]
1255 fn wizard_save_emits_bindings_but_escape_only_closes() {
1256 let app = test_app();
1257 let mut view = HotbarSetupView::new(&app, &Config::default());
1258 assert!(view.select_slot(8));
1259 assert!(view.select_action_by_id("sidebar.toggle"));
1260 assert!(view.assign_selected_action());
1261
1262 match view.handle_key(key(KeyCode::Char('s'))) {
1263 ViewAction::EmitAndClose(ViewEvent::HotbarSetupSaved { bindings }) => {
1264 assert!(
1265 bindings
1266 .iter()
1267 .any(|binding| { binding.slot == 8 && binding.action == "sidebar.toggle" })
1268 );
1269 }
1270 other => panic!("expected HotbarSetupSaved, got {other:?}"),
1271 }
1272
1273 let mut view = HotbarSetupView::new(&app, &Config::default());
1274 assert!(view.select_slot(1));
1275 assert!(view.select_action_by_id("mode.agent"));
1276 assert!(view.assign_selected_action());
1277 assert!(matches!(
1278 view.handle_key(key(KeyCode::Esc)),
1279 ViewAction::Close
1280 ));
1281 }
1282
1283 #[test]
1284 fn wizard_disable_key_emits_disable_request_and_intro_mentions_it() {
1285 let app = test_app();
1286
1287 // 'd' and 'D' hide the Hotbar from inside the setup flow (mirrors /hotbar off).
1288 let mut view = HotbarSetupView::new(&app, &Config::default());
1289 assert!(matches!(
1290 view.handle_key(key(KeyCode::Char('d'))),
1291 ViewAction::EmitAndClose(ViewEvent::HotbarDisableRequested)
1292 ));
1293 let mut view = HotbarSetupView::new(&app, &Config::default());
1294 assert!(matches!(
1295 view.handle_key(key(KeyCode::Char('D'))),
1296 ViewAction::EmitAndClose(ViewEvent::HotbarDisableRequested)
1297 ));
1298
1299 // The always-visible intro explains what Hotbar is and the disable path.
1300 let joined: String = view
1301 .render_lines()
1302 .iter()
1303 .flat_map(|line| line.spans.iter())
1304 .map(|span| span.content.as_ref())
1305 .collect();
1306 assert!(
1307 joined.contains("shortcuts"),
1308 "intro should explain what Hotbar is: {joined:?}"
1309 );
1310 assert!(
1311 joined.contains("/hotbar off"),
1312 "intro should mention the disable path: {joined:?}"
1313 );
1314 }
1315
1316 #[test]
1317 fn disabled_actions_are_visible_but_not_assignable() {
1318 let app = test_app();
1319 let mut view = HotbarSetupView::new(&app, &Config::default());
1320 let reasoning = view
1321 .actions
1322 .iter_mut()
1323 .find(|row| row.metadata.id == "reasoning.cycle")
1324 .expect("reasoning action");
1325 reasoning.disabled_reason = Some("disabled by test policy".to_string());
1326
1327 assert!(view.select_slot(2));
1328 assert!(view.select_action_by_id("reasoning.cycle"));
1329 assert!(!view.assign_selected_action());
1330
1331 assert_ne!(
1332 view.binding_for_slot(2)
1333 .map(|binding| binding.action.as_str()),
1334 Some("reasoning.cycle")
1335 );
1336 assert!(
1337 view.validation_errors()
1338 .last()
1339 .is_some_and(|error| error.contains("cannot be assigned"))
1340 );
1341 assert!(view.status_text().contains("cannot be assigned"));
1342 }
1343
1344 #[test]
1345 fn args_required_slash_actions_are_visible_and_assignable_as_prefill() {
1346 let app = test_app();
1347 let mut view = HotbarSetupView::new(&app, &Config::default());
1348
1349 assert!(view.select_action_by_id("slash.rename"));
1350 assert!(
1351 view.status_text().contains("prefill"),
1352 "required-arg commands must be labeled as prefill actions"
1353 );
1354 assert!(view.select_slot(3));
1355 assert!(view.assign_selected_action());
1356
1357 assert_eq!(
1358 view.binding_for_slot(3)
1359 .map(|binding| binding.action.as_str()),
1360 Some("slash.rename")
1361 );
1362 }
1363
1364 #[test]
1365 fn wizard_help_documents_runtime_hotbar_shortcut() {
1366 let app = test_app();
1367 let mut view = HotbarSetupView::new(&app, &Config::default());
1368
1369 assert!(matches!(
1370 view.handle_key(key(KeyCode::Char('?'))),
1371 ViewAction::None
1372 ));
1373 let rendered = view
1374 .selected_action()
1375 .map(|row| view.detail_lines(row))
1376 .expect("selected action")
1377 .into_iter()
1378 .map(|line| line.to_string())
1379 .collect::<Vec<_>>()
1380 .join("\n");
1381
1382 assert!(rendered.contains("After save: Alt+1 through Alt+8 dispatch Hotbar slots"));
1383 assert!(rendered.contains("Bare 1-8 stay composer text outside setup"));
1384 }
1385
1386 #[test]
1387 fn action_rows_semantically_truncate_descriptions_at_narrow_width() {
1388 let app = test_app();
1389 let view = HotbarSetupView::new(&app, &Config::default());
1390 let row = HotbarSetupActionRow {
1391 metadata: HotbarActionMetadata {
1392 id: "test.long-description".to_string(),
1393 source_id: "test".to_string(),
1394 display_name: "Open settings row".to_string(),
1395 compact_label: "test".to_string(),
1396 description: "Open a detailed settings panel without clipping".to_string(),
1397 category: HotbarActionCategory::App,
1398 args: HotbarArgsBehavior::None,
1399 safety: HotbarSafetyClass::LocalUi,
1400 recommendation: HotbarRecommendation::Eligible,
1401 },
1402 disabled_reason: None,
1403 };
1404
1405 let text = view
1406 .action_row_line(HotbarActionCategory::App, 0, &row, 58)
1407 .to_string();
1408 assert!(crate::tui::ui_text::text_display_width(&text) <= 58);
1409 assert!(text.contains("Open a detailed…"), "{text:?}");
1410 assert!(!text.contains("Open a detailed s"), "{text:?}");
1411 }
1412
1413 #[test]
1414 fn keyboard_controls_navigate_source_action_and_slot() {
1415 let mut config = Config {
1416 provider: Some(ApiProvider::Deepseek.as_str().to_string()),
1417 ..Config::default()
1418 };
1419 config
1420 .provider_config_for_mut(ApiProvider::Openrouter)
1421 .model = Some("anthropic/claude-sonnet-4".to_string());
1422 let app = test_app_with_config(&config);
1423 let mut view = HotbarSetupView::new(&app, &config);
1424
1425 assert_eq!(view.selected_source(), Some(HotbarActionCategory::App));
1426 view.handle_key(key(KeyCode::Tab));
1427 assert_eq!(view.selected_source(), Some(HotbarActionCategory::Route));
1428 view.handle_key(key(KeyCode::Tab));
1429 assert_eq!(view.selected_source(), Some(HotbarActionCategory::Slash));
1430 view.handle_key(key(KeyCode::BackTab));
1431 assert_eq!(view.selected_source(), Some(HotbarActionCategory::Route));
1432
1433 let first = view
1434 .selected_action()
1435 .map(|row| row.metadata.id.clone())
1436 .expect("first action");
1437 view.handle_key(key(KeyCode::Down));
1438 let second = view
1439 .selected_action()
1440 .map(|row| row.metadata.id.clone())
1441 .expect("second action");
1442 assert_ne!(first, second);
1443
1444 view.handle_key(key(KeyCode::Char('8')));
1445 assert_eq!(view.selected_slot(), 8);
1446 view.handle_key(key(KeyCode::Left));
1447 assert_eq!(view.selected_slot(), 7);
1448 }
1449
1450 #[test]
1451 fn down_past_export_keeps_the_selected_action_visible() {
1452 let app = test_app();
1453 let mut view = HotbarSetupView::new(&app, &Config::default());
1454 assert!(view.select_action_by_id("slash.export"));
1455
1456 view.handle_key(key(KeyCode::Down));
1457 let selected = view.selected_action().expect("action after /export");
1458 assert_ne!(selected.metadata.id, "slash.export");
1459
1460 let rendered = rendered_text_at(&view, 80, 24);
1461 let marker = crate::tui::glyphs::selection_marker(true);
1462 assert!(
1463 rendered.lines().any(|line| {
1464 line.contains(marker) && line.contains(&selected.metadata.display_name)
1465 }),
1466 "focused action {} must remain visible after moving past /export:\n{rendered}",
1467 selected.metadata.id
1468 );
1469 }
1470
1471 #[test]
1472 fn keyboard_filter_searches_catalog_and_escape_clears_it() {
1473 let app = test_app();
1474 let mut view = HotbarSetupView::new(&app, &Config::default());
1475
1476 view.handle_key(key(KeyCode::Tab));
1477 assert_eq!(view.selected_source(), Some(HotbarActionCategory::Route));
1478 let route_label = view
1479 .selected_action()
1480 .map(|row| row.metadata.display_name.clone())
1481 .expect("route action");
1482 let route_query = route_label
1483 .chars()
1484 .take(4)
1485 .collect::<String>()
1486 .to_ascii_lowercase();
1487 view.handle_key(key(KeyCode::Char('/')));
1488 for ch in route_query.chars() {
1489 view.handle_key(key(KeyCode::Char(ch)));
1490 }
1491 assert_eq!(view.query(), route_query);
1492 assert!(view.status_text().contains(&route_label));
1493
1494 view.handle_key(key(KeyCode::Esc));
1495 assert_eq!(view.query(), "");
1496 assert!(matches!(
1497 view.handle_key(key(KeyCode::Esc)),
1498 ViewAction::Close
1499 ));
1500 }
1501
1502 #[test]
1503 fn hotbar_setup_is_usable_and_opaque_at_blocker_sizes() {
1504 use crate::tui::views::ViewStack;
1505 use unicode_width::UnicodeWidthStr;
1506
1507 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1508 let app = test_app();
1509 for (w, h) in BLOCKER_SIZES {
1510 let area = Rect::new(0, 0, w, h);
1511 let mut buf = Buffer::empty(area);
1512 for y in 0..h {
1513 for x in 0..w {
1514 buf[(x, y)].set_symbol("X");
1515 }
1516 }
1517 let mut stack = ViewStack::new();
1518 stack.push(HotbarSetupView::new(&app, &Config::default()));
1519 stack.render(area, &mut buf);
1520
1521 let rows: Vec<String> = (0..h)
1522 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
1523 .collect();
1524 let text = rows.join("\n");
1525
1526 // Footer keeps every action.
1527 for label in [
1528 "source", "action", "slot", "filter", "assign", "toggle", "clear", "save",
1529 "disable", "cancel",
1530 ] {
1531 assert!(text.contains(label), "{w}x{h}: footer missing '{label}'");
1532 }
1533
1534 // Composited frame is fully opaque.
1535 assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
1536 assert_eq!(
1537 buf[(w / 2, h / 2)].bg,
1538 palette::WHALE_BG,
1539 "{w}x{h}: modal interior must be opaque"
1540 );
1541
1542 // No horizontal overflow.
1543 for (y, row) in rows.iter().enumerate() {
1544 assert!(
1545 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1546 "{w}x{h}: row {y} overflows width: {row:?}"
1547 );
1548 }
1549 }
1550 }
1551 }
1552
1552 lines RUST