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