返回 CodeWhale
actions.rs
根目录 / crates / tui / src / tui / hotbar / actions.rs
1 use std::cmp::Ordering;
2 use std::collections::{BTreeMap, BTreeSet, HashMap};
3 use std::sync::Arc;
4
5 use anyhow::Result;
6 use codewhale_config::AppMode;
7
8 use crate::commands::{self, CommandInfo, CommandResult};
9 use crate::config::{ApiProvider, Config};
10 use crate::provider_lake::all_catalog_models_for_provider;
11 use crate::tui::app::{App, AppAction};
12 use crate::tui::command_palette::{
13 CommandPaletteView, build_entries as build_command_palette_entries,
14 };
15 use codewhale_localization::{Locale, MessageId, tr};
16
17 pub const HOTBAR_COMPACT_LABEL_MAX_WIDTH: usize = 7;
18
19 /// Result of firing a hotbar action.
20 #[allow(dead_code, clippy::large_enum_variant)] // AppAction is intentionally large; boxing would force clone churn on the hot path
21 #[derive(Debug, Clone, PartialEq)]
22 pub enum HotbarDispatch {
23 /// The action was fully handled by mutating [`App`].
24 Handled,
25 /// The event loop must handle an existing application action.
26 AppAction(AppAction),
27 }
28
29 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30 pub enum HotbarActionCategory {
31 App,
32 Route,
33 Slash,
34 Mcp,
35 Skill,
36 Plugin,
37 }
38
39 impl HotbarActionCategory {
40 #[must_use]
41 pub const fn as_str(self) -> &'static str {
42 match self {
43 Self::App => "app",
44 Self::Route => "route",
45 Self::Slash => "slash",
46 Self::Mcp => "mcp",
47 Self::Skill => "skill",
48 Self::Plugin => "plugin",
49 }
50 }
51
52 #[must_use]
53 #[cfg_attr(not(test), expect(dead_code))]
54 pub fn parse(value: &str) -> Option<Self> {
55 match value {
56 "app" => Some(Self::App),
57 "route" => Some(Self::Route),
58 "slash" => Some(Self::Slash),
59 "mcp" => Some(Self::Mcp),
60 "skill" => Some(Self::Skill),
61 "plugin" => Some(Self::Plugin),
62 _ => None,
63 }
64 }
65 }
66
67 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68 pub enum HotbarArgsBehavior {
69 None,
70 Optional,
71 Required,
72 }
73
74 impl HotbarArgsBehavior {
75 #[must_use]
76 fn for_command(info: &CommandInfo) -> Self {
77 if info.requires_required_argument() {
78 Self::Required
79 } else if info.requires_argument() {
80 Self::Optional
81 } else {
82 Self::None
83 }
84 }
85 }
86
87 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
88 pub enum HotbarSafetyClass {
89 LocalUi,
90 LocalState,
91 ConfigChange,
92 ExternalInput,
93 ExistingCommand,
94 RequiresApproval,
95 }
96
97 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
98 pub enum HotbarRecommendation {
99 Default,
100 Eligible,
101 Advanced,
102 }
103
104 impl HotbarRecommendation {
105 #[must_use]
106 pub const fn is_recommendable(self) -> bool {
107 matches!(self, Self::Default | Self::Eligible)
108 }
109 }
110
111 #[derive(Debug, Clone, PartialEq, Eq)]
112 pub struct HotbarActionMetadata {
113 pub id: String,
114 pub source_id: String,
115 pub display_name: String,
116 pub compact_label: String,
117 pub description: String,
118 pub category: HotbarActionCategory,
119 pub args: HotbarArgsBehavior,
120 pub safety: HotbarSafetyClass,
121 pub recommendation: HotbarRecommendation,
122 }
123
124 impl HotbarActionMetadata {
125 #[must_use]
126 pub fn validation_errors(&self) -> Vec<String> {
127 let mut errors = Vec::new();
128 if self.id.trim().is_empty() {
129 errors.push("id must not be empty".to_string());
130 }
131 if self.source_id.trim().is_empty() {
132 errors.push(format!("{} source_id must not be empty", self.id));
133 }
134 if self.display_name.trim().is_empty() {
135 errors.push(format!("{} display_name must not be empty", self.id));
136 }
137 if self.compact_label.trim().is_empty() {
138 errors.push(format!("{} compact_label must not be empty", self.id));
139 }
140 if unicode_width::UnicodeWidthStr::width(self.compact_label.as_str())
141 > HOTBAR_COMPACT_LABEL_MAX_WIDTH
142 {
143 errors.push(format!(
144 "{} compact_label {:?} exceeds {} display cells",
145 self.id, self.compact_label, HOTBAR_COMPACT_LABEL_MAX_WIDTH
146 ));
147 }
148 if self.description.trim().is_empty() {
149 errors.push(format!("{} description must not be empty", self.id));
150 }
151 errors
152 }
153 }
154
155 #[derive(Debug, Clone, PartialEq, Eq)]
156 pub struct HotbarRecommendationEntry {
157 pub metadata: HotbarActionMetadata,
158 pub disabled_reason: Option<String>,
159 }
160
161 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
162 pub struct HotbarRecommendationOptions {
163 pub max_total: usize,
164 pub max_eligible_per_category: usize,
165 pub include_required_args: bool,
166 }
167
168 impl HotbarRecommendationOptions {
169 #[must_use]
170 pub const fn for_setup_wizard() -> Self {
171 Self {
172 max_total: usize::MAX,
173 max_eligible_per_category: usize::MAX,
174 include_required_args: false,
175 }
176 }
177 }
178
179 impl Default for HotbarRecommendationOptions {
180 fn default() -> Self {
181 Self {
182 max_total: usize::from(codewhale_config::HOTBAR_SLOT_COUNT),
183 max_eligible_per_category: usize::MAX,
184 include_required_args: false,
185 }
186 }
187 }
188
189 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
190 pub enum HotbarSourceDispatchBoundary {
191 /// The action is handled directly by existing in-app state mutation.
192 DirectApp,
193 /// The action routes through the existing provider/model picker apply path.
194 ModelRoute,
195 /// The action routes through the slash command registry/dispatcher.
196 SlashCommand,
197 /// The action only prefills the composer with a reference; nothing
198 /// executes until the user reviews and sends the message themselves.
199 ComposerPrefill,
200 /// The source is visible as a future hotbar source, but binding/dispatch is
201 /// intentionally deferred until its safety contract is wired.
202 Deferred,
203 }
204
205 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
206 pub enum HotbarSourceSafetyMode {
207 /// Pressing the bound hotbar slot directly fires the existing action path.
208 DirectFire,
209 /// Pressing the bound hotbar slot opens/prefills the composer for arguments.
210 ComposerPrefill,
211 /// The source must not register bindable actions until its gates are wired.
212 Disabled,
213 /// The source may dispatch only through an approval/trust-enforced path.
214 ApprovalGated,
215 }
216
217 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
218 pub struct HotbarSourceDescriptor {
219 pub category: HotbarActionCategory,
220 pub boundary: HotbarSourceDispatchBoundary,
221 pub safety_modes: &'static [HotbarSourceSafetyMode],
222 pub dispatch_path: &'static str,
223 pub status: &'static str,
224 }
225
226 impl HotbarSourceDescriptor {
227 #[must_use]
228 pub fn registers_dispatchable_actions(self) -> bool {
229 self.boundary != HotbarSourceDispatchBoundary::Deferred
230 && !self
231 .safety_modes
232 .contains(&HotbarSourceSafetyMode::Disabled)
233 }
234 }
235
236 const HOTBAR_DIRECT_APP_SAFETY: &[HotbarSourceSafetyMode] = &[HotbarSourceSafetyMode::DirectFire];
237 const HOTBAR_ROUTE_SAFETY: &[HotbarSourceSafetyMode] = &[HotbarSourceSafetyMode::DirectFire];
238 const HOTBAR_SLASH_SAFETY: &[HotbarSourceSafetyMode] = &[
239 HotbarSourceSafetyMode::DirectFire,
240 HotbarSourceSafetyMode::ComposerPrefill,
241 ];
242 const HOTBAR_MCP_SAFETY: &[HotbarSourceSafetyMode] = &[HotbarSourceSafetyMode::ComposerPrefill];
243 const HOTBAR_SKILL_SAFETY: &[HotbarSourceSafetyMode] = &[HotbarSourceSafetyMode::DirectFire];
244 const HOTBAR_DEFERRED_SAFETY: &[HotbarSourceSafetyMode] = &[
245 HotbarSourceSafetyMode::Disabled,
246 HotbarSourceSafetyMode::ApprovalGated,
247 ];
248
249 const HOTBAR_SOURCE_DESCRIPTORS: &[HotbarSourceDescriptor] = &[
250 HotbarSourceDescriptor {
251 category: HotbarActionCategory::App,
252 boundary: HotbarSourceDispatchBoundary::DirectApp,
253 safety_modes: HOTBAR_DIRECT_APP_SAFETY,
254 dispatch_path: "AppHotbarAction::dispatch",
255 status: "dispatchable",
256 },
257 HotbarSourceDescriptor {
258 category: HotbarActionCategory::Route,
259 boundary: HotbarSourceDispatchBoundary::ModelRoute,
260 safety_modes: HOTBAR_ROUTE_SAFETY,
261 dispatch_path: "AppAction::SwitchModelRoute -> apply_model_picker_choice",
262 status: "dispatchable",
263 },
264 HotbarSourceDescriptor {
265 category: HotbarActionCategory::Slash,
266 boundary: HotbarSourceDispatchBoundary::SlashCommand,
267 safety_modes: HOTBAR_SLASH_SAFETY,
268 dispatch_path: "commands::execute or composer prefill for required arguments",
269 status: "dispatchable",
270 },
271 HotbarSourceDescriptor {
272 category: HotbarActionCategory::Mcp,
273 boundary: HotbarSourceDispatchBoundary::ComposerPrefill,
274 safety_modes: HOTBAR_MCP_SAFETY,
275 dispatch_path: "composer prefill of the MCP tool reference; execution stays behind the \
276 existing tool approval flow",
277 status: "dispatchable",
278 },
279 HotbarSourceDescriptor {
280 category: HotbarActionCategory::Skill,
281 boundary: HotbarSourceDispatchBoundary::SlashCommand,
282 safety_modes: HOTBAR_SKILL_SAFETY,
283 dispatch_path: "commands::execute via the $<skill> alias (local activation with a \
284 visible receipt cell)",
285 status: "dispatchable",
286 },
287 HotbarSourceDescriptor {
288 category: HotbarActionCategory::Plugin,
289 boundary: HotbarSourceDispatchBoundary::Deferred,
290 safety_modes: HOTBAR_DEFERRED_SAFETY,
291 dispatch_path: "plugin command/tool registry until plugin approval gates are wired",
292 status: "exploratory",
293 },
294 ];
295
296 #[must_use]
297 pub const fn hotbar_source_descriptors() -> &'static [HotbarSourceDescriptor] {
298 HOTBAR_SOURCE_DESCRIPTORS
299 }
300
301 /// Adapter for one source of bindable hotbar actions.
302 pub trait HotbarActionSource {
303 fn descriptor(&self) -> HotbarSourceDescriptor;
304 fn register_actions(&self, registry: &mut HotbarActionRegistry);
305 }
306
307 /// Uniform interface for actions that can be bound to a hotbar slot.
308 #[cfg_attr(not(test), expect(dead_code))]
309 pub trait HotbarAction: Send + Sync {
310 /// Stable action id used in config and dispatch.
311 fn id(&self) -> &str;
312
313 /// Complete metadata used by renderers, setup wizard recommendations, and
314 /// future source adapters.
315 fn metadata(&self, locale: Locale) -> HotbarActionMetadata;
316
317 /// Compact cell label. Built-ins keep this at seven characters or less.
318 fn short_label(&self) -> &str;
319
320 /// Source category, such as `app`, `route`, `slash`, `mcp`, `skill`, or
321 /// `plugin`.
322 fn category(&self) -> &str;
323
324 /// Whether the action is currently active in the supplied app state.
325 fn is_active(&self, app: &App) -> bool;
326
327 /// Dynamic unavailable reason. `None` means the action is dispatchable
328 /// through its normal safety path.
329 fn disabled_reason(&self, _app: &App) -> Option<String> {
330 None
331 }
332
333 /// Fire the action.
334 fn dispatch(&self, app: &mut App) -> Result<HotbarDispatch>;
335 }
336
337 #[must_use]
338 pub fn recommend_hotbar_actions(
339 app: &App,
340 options: HotbarRecommendationOptions,
341 ) -> Vec<HotbarRecommendationEntry> {
342 let mut entries = app
343 .hotbar_actions
344 .iter()
345 .filter_map(|action| {
346 let metadata = action.metadata(app.ui_locale);
347 if !metadata.recommendation.is_recommendable() {
348 return None;
349 }
350 if matches!(metadata.args, HotbarArgsBehavior::Required)
351 && !options.include_required_args
352 {
353 return None;
354 }
355 let disabled_reason = action.disabled_reason(app);
356 if disabled_reason.is_some() {
357 return None;
358 }
359 Some(HotbarRecommendationEntry {
360 metadata,
361 disabled_reason,
362 })
363 })
364 .collect::<Vec<_>>();
365
366 entries.sort_by(|a, b| compare_recommendation_metadata(&a.metadata, &b.metadata));
367
368 let mut selected = Vec::new();
369 let mut eligible_by_category: BTreeMap<HotbarActionCategory, usize> = BTreeMap::new();
370 for entry in entries {
371 if selected.len() >= options.max_total {
372 break;
373 }
374 if !matches!(entry.metadata.recommendation, HotbarRecommendation::Default) {
375 let count = eligible_by_category
376 .entry(entry.metadata.category)
377 .or_insert(0);
378 if *count >= options.max_eligible_per_category {
379 continue;
380 }
381 *count += 1;
382 }
383 selected.push(entry);
384 }
385 selected
386 }
387
388 #[must_use]
389 #[cfg_attr(not(test), expect(dead_code))]
390 pub fn recommended_hotbar_bindings(
391 app: &App,
392 options: HotbarRecommendationOptions,
393 ) -> Vec<codewhale_config::HotbarBindingToml> {
394 recommend_hotbar_actions(app, options)
395 .into_iter()
396 .take(usize::from(codewhale_config::HOTBAR_SLOT_COUNT))
397 .enumerate()
398 .map(|(idx, entry)| codewhale_config::HotbarBindingToml {
399 slot: u8::try_from(idx + 1).expect("recommended hotbar slot fits in u8"),
400 action: entry.metadata.id,
401 label: Some(entry.metadata.compact_label),
402 })
403 .collect()
404 }
405
406 fn default_hotbar_position(action_id: &str) -> Option<usize> {
407 codewhale_config::DEFAULT_HOTBAR_ACTIONS
408 .iter()
409 .position(|default_id| *default_id == action_id)
410 }
411
412 fn compare_recommendation_metadata(a: &HotbarActionMetadata, b: &HotbarActionMetadata) -> Ordering {
413 match (
414 default_hotbar_position(&a.id),
415 default_hotbar_position(&b.id),
416 ) {
417 (Some(a_pos), Some(b_pos)) => return a_pos.cmp(&b_pos),
418 (Some(_), None) => return Ordering::Less,
419 (None, Some(_)) => return Ordering::Greater,
420 (None, None) => {}
421 }
422
423 a.category
424 .cmp(&b.category)
425 .then_with(|| {
426 a.display_name
427 .to_ascii_lowercase()
428 .cmp(&b.display_name.to_ascii_lowercase())
429 })
430 .then_with(|| a.id.cmp(&b.id))
431 }
432
433 #[derive(Default, Clone)]
434 pub struct HotbarActionRegistry {
435 actions: BTreeMap<String, Arc<dyn HotbarAction>>,
436 }
437
438 impl HotbarActionRegistry {
439 #[must_use]
440 pub fn new() -> Self {
441 Self::default()
442 }
443
444 #[must_use]
445 pub fn with_builtins() -> Self {
446 let mut registry = Self::new();
447 registry.register_builtins();
448 registry.register_slash_commands();
449 registry
450 }
451
452 #[must_use]
453 pub fn with_configured_routes(
454 config: &Config,
455 active_provider: ApiProvider,
456 active_model: &str,
457 provider_models: &HashMap<String, String>,
458 ) -> Self {
459 let mut registry = Self::with_builtins();
460 registry.register_configured_routes(config, active_provider, active_model, provider_models);
461 registry
462 }
463
464 pub fn register(&mut self, action: impl HotbarAction + 'static) {
465 let id = action.id().to_string();
466 assert!(!id.trim().is_empty(), "hotbar action id must not be empty");
467 assert!(
468 self.actions.insert(id.clone(), Arc::new(action)).is_none(),
469 "duplicate hotbar action id {id}"
470 );
471 }
472
473 pub fn register_source(&mut self, source: &dyn HotbarActionSource) {
474 let descriptor = source.descriptor();
475 debug_assert!(
476 hotbar_source_descriptors()
477 .iter()
478 .any(|registered| registered.category == descriptor.category
479 && registered.boundary == descriptor.boundary),
480 "hotbar source descriptor must be registered: {descriptor:?}"
481 );
482 debug_assert!(!descriptor.dispatch_path.trim().is_empty());
483 debug_assert!(!descriptor.status.trim().is_empty());
484 debug_assert!(!descriptor.safety_modes.is_empty());
485 let before = self.actions.len();
486 source.register_actions(self);
487 if !descriptor.registers_dispatchable_actions() {
488 assert_eq!(
489 self.actions.len(),
490 before,
491 "deferred hotbar source {:?} must not register dispatchable actions before safety gates are wired",
492 descriptor.category
493 );
494 }
495 }
496
497 pub(crate) fn register_builtins(&mut self) {
498 self.register_source(&BuiltinHotbarActionSource);
499 }
500
501 pub(crate) fn register_slash_commands(&mut self) {
502 self.register_source(&SlashCommandHotbarActionSource);
503 }
504
505 pub(crate) fn register_configured_routes(
506 &mut self,
507 config: &Config,
508 active_provider: ApiProvider,
509 active_model: &str,
510 provider_models: &HashMap<String, String>,
511 ) {
512 let source = ConfiguredRouteHotbarActionSource {
513 config,
514 active_provider,
515 active_model,
516 provider_models,
517 };
518 self.register_source(&source);
519 }
520
521 /// Register the already-discovered skills (name, description pairs from
522 /// `App::cached_skills`) as bindable hotbar actions. No filesystem I/O
523 /// happens here; the hotbar only lists skills the app already knows.
524 pub(crate) fn register_skills(&mut self, skills: &[(String, String)]) {
525 self.register_source(&SkillHotbarActionSource { skills });
526 }
527
528 /// Atomically replace the Skill-derived action source while retaining
529 /// built-ins, configured routes, slash commands, and live MCP actions.
530 /// Plugin lifecycle changes call this from the same cache refresh that
531 /// updates command dispatch, preventing stale revoked bindings.
532 pub(crate) fn replace_skills(&mut self, skills: &[(String, String)]) {
533 self.actions
534 .retain(|_, action| action.category() != HotbarActionCategory::Skill.as_str());
535 self.register_skills(skills);
536 }
537
538 /// Replace the MCP-tool hotbar actions with the tools in `snapshot`.
539 ///
540 /// Called when a live MCP discovery snapshot lands (or is refreshed) so
541 /// the hotbar only ever lists tools that are already loaded; the hotbar
542 /// itself never triggers server connections.
543 pub fn replace_mcp_tools(&mut self, snapshot: Option<&crate::mcp::McpManagerSnapshot>) {
544 self.actions
545 .retain(|_, action| action.category() != HotbarActionCategory::Mcp.as_str());
546 if let Some(snapshot) = snapshot {
547 self.register_source(&McpToolHotbarActionSource { snapshot });
548 }
549 }
550 }
551
552 struct BuiltinHotbarActionSource;
553
554 impl HotbarActionSource for BuiltinHotbarActionSource {
555 fn descriptor(&self) -> HotbarSourceDescriptor {
556 HOTBAR_SOURCE_DESCRIPTORS
557 .iter()
558 .copied()
559 .find(|descriptor| descriptor.category == HotbarActionCategory::App)
560 .expect("app hotbar source descriptor exists")
561 }
562
563 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
564 registry.register(AppHotbarAction::new(
565 "voice.toggle",
566 "voice",
567 "Voice input",
568 "Toggle voice capture from the terminal microphone.",
569 AppHotbarKind::VoiceToggle,
570 ));
571 registry.register(AppHotbarAction::new(
572 "session.compact",
573 "compact",
574 "Compact session",
575 "Shrink this conversation to free context.",
576 AppHotbarKind::SessionCompact,
577 ));
578 registry.register(AppHotbarAction::new(
579 "mode.plan",
580 "plan",
581 "Plan mode",
582 "Think through a plan before acting.",
583 AppHotbarKind::Mode(AppMode::Plan),
584 ));
585 registry.register(AppHotbarAction::new(
586 "mode.agent",
587 "agent",
588 "Act mode",
589 "Do direct work in the current session.",
590 AppHotbarKind::Mode(AppMode::Agent),
591 ));
592 registry.register(AppHotbarAction::new(
593 "mode.operate",
594 "operate",
595 "Operate mode",
596 "Send tasks while Fleet workers run in parallel.",
597 AppHotbarKind::Mode(AppMode::Operate),
598 ));
599 registry.register(AppHotbarAction::new(
600 "reasoning.cycle",
601 "reason",
602 "Cycle reasoning",
603 "Step through reasoning levels for the active provider.",
604 AppHotbarKind::ReasoningCycle,
605 ));
606 registry.register(AppHotbarAction::new(
607 "sidebar.toggle",
608 "side",
609 "Toggle workbar",
610 "Show or hide the workbar.",
611 AppHotbarKind::SidebarToggle,
612 ));
613 registry.register(AppHotbarAction::new(
614 "filetree.toggle",
615 "files",
616 "Toggle file tree",
617 "Show or hide the workspace file tree.",
618 AppHotbarKind::FileTreeToggle,
619 ));
620 registry.register(AppHotbarAction::new(
621 "palette.open",
622 "palette",
623 "Command palette",
624 "Open the command palette.",
625 AppHotbarKind::PaletteOpen,
626 ));
627 registry.register(AppHotbarAction::new(
628 "trust.toggle",
629 "trust",
630 "Toggle trust",
631 "Turn workspace trust on or off.",
632 AppHotbarKind::TrustToggle,
633 ));
634 }
635 }
636
637 struct SlashCommandHotbarActionSource;
638
639 impl HotbarActionSource for SlashCommandHotbarActionSource {
640 fn descriptor(&self) -> HotbarSourceDescriptor {
641 HOTBAR_SOURCE_DESCRIPTORS
642 .iter()
643 .copied()
644 .find(|descriptor| descriptor.category == HotbarActionCategory::Slash)
645 .expect("slash hotbar source descriptor exists")
646 }
647
648 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
649 // Every command registers, including unlisted ones. The hotbar is a
650 // binding substrate, not a discovery surface: `codewhale-lane`'s
651 // control-plane descriptors resolve their `slash.<verb>` action id
652 // through this registry, so dropping an unlisted command here breaks
653 // a real contract (`control_plane_commands_are_bound_and_bare_dispatch_is_read_only`).
654 // Unlisted governs what is *advertised* — the slash menu, `/help`,
655 // and the command palette.
656 for info in commands::command_infos() {
657 registry.register(SlashHotbarAction::new(info));
658 }
659 }
660 }
661
662 /// Adapter exposing already-discovered skills as hotbar actions (#2069).
663 ///
664 /// Follows the slash-command source pattern: the source only lists entries an
665 /// existing registry already knows about (`App::cached_skills`), and dispatch
666 /// reuses the existing `$<skill>` alias through `commands::execute`, which
667 /// activates the skill locally and posts a visible receipt cell.
668 struct SkillHotbarActionSource<'a> {
669 skills: &'a [(String, String)],
670 }
671
672 impl HotbarActionSource for SkillHotbarActionSource<'_> {
673 fn descriptor(&self) -> HotbarSourceDescriptor {
674 HOTBAR_SOURCE_DESCRIPTORS
675 .iter()
676 .copied()
677 .find(|descriptor| descriptor.category == HotbarActionCategory::Skill)
678 .expect("skill hotbar source descriptor exists")
679 }
680
681 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
682 let mut seen = BTreeSet::new();
683 for (name, description) in self.skills {
684 let name = name.trim();
685 // Guard against duplicate names across skill roots: the registry
686 // asserts unique action ids, and the first discovery wins (the
687 // same shadowing order the skill registry itself uses).
688 if name.is_empty() || !seen.insert(name.to_string()) {
689 continue;
690 }
691 registry.register(SkillHotbarAction::new(name, description));
692 }
693 }
694 }
695
696 /// Adapter exposing already-discovered MCP tools as hotbar actions (#2068).
697 ///
698 /// Deferred-source safety: the source only lists tools from an existing
699 /// discovery snapshot (enabled servers), and dispatch never executes a tool —
700 /// it prefills the composer with the tool's model-visible name so the actual
701 /// call still goes through the agent and the tool approval flow.
702 struct McpToolHotbarActionSource<'a> {
703 snapshot: &'a crate::mcp::McpManagerSnapshot,
704 }
705
706 impl HotbarActionSource for McpToolHotbarActionSource<'_> {
707 fn descriptor(&self) -> HotbarSourceDescriptor {
708 HOTBAR_SOURCE_DESCRIPTORS
709 .iter()
710 .copied()
711 .find(|descriptor| descriptor.category == HotbarActionCategory::Mcp)
712 .expect("mcp hotbar source descriptor exists")
713 }
714
715 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
716 let mut seen = BTreeSet::new();
717 for server in &self.snapshot.servers {
718 if !server.enabled {
719 continue;
720 }
721 for tool in &server.tools {
722 if tool.model_name.trim().is_empty() {
723 continue;
724 }
725 let action = McpToolHotbarAction::new(&server.name, tool);
726 if !seen.insert(action.id.clone()) {
727 continue;
728 }
729 registry.register(action);
730 }
731 }
732 }
733 }
734
735 struct ConfiguredRouteHotbarActionSource<'a> {
736 config: &'a Config,
737 active_provider: ApiProvider,
738 active_model: &'a str,
739 provider_models: &'a HashMap<String, String>,
740 }
741
742 impl HotbarActionSource for ConfiguredRouteHotbarActionSource<'_> {
743 fn descriptor(&self) -> HotbarSourceDescriptor {
744 HOTBAR_SOURCE_DESCRIPTORS
745 .iter()
746 .copied()
747 .find(|descriptor| descriptor.category == HotbarActionCategory::Route)
748 .expect("route hotbar source descriptor exists")
749 }
750
751 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
752 for provider in ApiProvider::sorted_for_display() {
753 if !crate::config::provider_is_configured_for_active(
754 self.config,
755 provider,
756 self.active_provider,
757 ) {
758 continue;
759 }
760 for model in configured_route_models_for_provider(
761 self.config,
762 provider,
763 self.active_provider,
764 self.active_model,
765 self.provider_models,
766 ) {
767 registry.register(RouteHotbarAction::new(provider, model));
768 }
769 }
770 }
771 }
772
773 impl HotbarActionRegistry {
774 #[must_use]
775 pub fn get(&self, id: &str) -> Option<Arc<dyn HotbarAction>> {
776 self.actions.get(id).cloned()
777 }
778
779 #[must_use]
780 pub fn len(&self) -> usize {
781 self.actions.len()
782 }
783
784 #[expect(dead_code)]
785 #[must_use]
786 pub fn is_empty(&self) -> bool {
787 self.actions.is_empty()
788 }
789
790 pub fn iter(&self) -> impl Iterator<Item = &dyn HotbarAction> {
791 self.actions.values().map(Arc::as_ref)
792 }
793
794 #[cfg_attr(not(test), expect(dead_code))]
795 #[must_use]
796 pub fn metadata(&self, locale: Locale) -> Vec<HotbarActionMetadata> {
797 self.iter().map(|action| action.metadata(locale)).collect()
798 }
799
800 #[cfg_attr(not(test), expect(dead_code))]
801 #[must_use]
802 pub fn metadata_validation_errors(&self, locale: Locale) -> Vec<String> {
803 let mut errors = Vec::new();
804 for action in self.iter() {
805 let metadata = action.metadata(locale);
806 if metadata.id != action.id() {
807 errors.push(format!(
808 "{} metadata id {:?} does not match action id",
809 action.id(),
810 metadata.id
811 ));
812 }
813 if metadata.compact_label != action.short_label() {
814 errors.push(format!(
815 "{} metadata compact_label {:?} does not match short_label {:?}",
816 action.id(),
817 metadata.compact_label,
818 action.short_label()
819 ));
820 }
821 if metadata.category.as_str() != action.category() {
822 errors.push(format!(
823 "{} metadata category {:?} does not match category {:?}",
824 action.id(),
825 metadata.category.as_str(),
826 action.category()
827 ));
828 }
829 errors.extend(metadata.validation_errors());
830 }
831 errors
832 }
833 }
834
835 fn dispatch_command_result(app: &mut App, result: CommandResult) -> HotbarDispatch {
836 app.status_message = result.message;
837 result
838 .action
839 .map_or(HotbarDispatch::Handled, HotbarDispatch::AppAction)
840 }
841
842 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
843 enum AppHotbarKind {
844 VoiceToggle,
845 SessionCompact,
846 Mode(AppMode),
847 ReasoningCycle,
848 SidebarToggle,
849 FileTreeToggle,
850 PaletteOpen,
851 TrustToggle,
852 }
853
854 struct AppHotbarAction {
855 id: &'static str,
856 short_label: &'static str,
857 display_name: &'static str,
858 description: &'static str,
859 kind: AppHotbarKind,
860 }
861
862 impl AppHotbarAction {
863 const fn new(
864 id: &'static str,
865 short_label: &'static str,
866 display_name: &'static str,
867 description: &'static str,
868 kind: AppHotbarKind,
869 ) -> Self {
870 Self {
871 id,
872 short_label,
873 display_name,
874 description,
875 kind,
876 }
877 }
878
879 fn safety(&self) -> HotbarSafetyClass {
880 match self.kind {
881 AppHotbarKind::VoiceToggle => HotbarSafetyClass::ExternalInput,
882 AppHotbarKind::TrustToggle => HotbarSafetyClass::LocalState,
883 AppHotbarKind::SessionCompact | AppHotbarKind::ReasoningCycle => {
884 HotbarSafetyClass::LocalState
885 }
886 AppHotbarKind::Mode(_)
887 | AppHotbarKind::SidebarToggle
888 | AppHotbarKind::FileTreeToggle
889 | AppHotbarKind::PaletteOpen => HotbarSafetyClass::LocalUi,
890 }
891 }
892
893 fn recommendation(&self) -> HotbarRecommendation {
894 if codewhale_config::DEFAULT_HOTBAR_ACTIONS.contains(&self.id) {
895 HotbarRecommendation::Default
896 } else {
897 HotbarRecommendation::Eligible
898 }
899 }
900
901 fn localized_display_name(&self, locale: Locale) -> String {
902 let Some(id) = self.display_name_id() else {
903 return self.display_name.to_string();
904 };
905 tr(locale, id).into_owned()
906 }
907
908 fn localized_description(&self, locale: Locale) -> String {
909 let Some(id) = self.description_id() else {
910 return self.description.to_string();
911 };
912 tr(locale, id).into_owned()
913 }
914
915 fn display_name_id(&self) -> Option<MessageId> {
916 Some(match self.kind {
917 AppHotbarKind::VoiceToggle => MessageId::HotbarActionVoiceToggleName,
918 AppHotbarKind::SessionCompact => MessageId::HotbarActionSessionCompactName,
919 AppHotbarKind::Mode(AppMode::Plan) => MessageId::HotbarActionModePlanName,
920 AppHotbarKind::Mode(AppMode::Agent) => MessageId::HotbarActionModeAgentName,
921 AppHotbarKind::Mode(AppMode::Operate) => MessageId::HotbarActionModeOperateName,
922 AppHotbarKind::ReasoningCycle => MessageId::HotbarActionReasoningCycleName,
923 AppHotbarKind::SidebarToggle => MessageId::HotbarActionSidebarToggleName,
924 AppHotbarKind::FileTreeToggle => MessageId::HotbarActionFileTreeToggleName,
925 AppHotbarKind::PaletteOpen => MessageId::HotbarActionPaletteOpenName,
926 AppHotbarKind::TrustToggle => MessageId::HotbarActionTrustToggleName,
927 })
928 }
929
930 fn description_id(&self) -> Option<MessageId> {
931 Some(match self.kind {
932 AppHotbarKind::VoiceToggle => MessageId::HotbarActionVoiceToggleDescription,
933 AppHotbarKind::SessionCompact => MessageId::HotbarActionSessionCompactDescription,
934 AppHotbarKind::Mode(AppMode::Plan) => MessageId::HotbarActionModePlanDescription,
935 AppHotbarKind::Mode(AppMode::Agent) => MessageId::HotbarActionModeAgentDescription,
936 AppHotbarKind::Mode(AppMode::Operate) => MessageId::HotbarActionModeOperateDescription,
937 AppHotbarKind::ReasoningCycle => MessageId::HotbarActionReasoningCycleDescription,
938 AppHotbarKind::SidebarToggle => MessageId::HotbarActionSidebarToggleDescription,
939 AppHotbarKind::FileTreeToggle => MessageId::HotbarActionFileTreeToggleDescription,
940 AppHotbarKind::PaletteOpen => MessageId::HotbarActionPaletteOpenDescription,
941 AppHotbarKind::TrustToggle => MessageId::HotbarActionTrustToggleDescription,
942 })
943 }
944 }
945
946 impl HotbarAction for AppHotbarAction {
947 fn id(&self) -> &str {
948 self.id
949 }
950
951 fn metadata(&self, locale: Locale) -> HotbarActionMetadata {
952 HotbarActionMetadata {
953 id: self.id.to_string(),
954 source_id: "builtin".to_string(),
955 display_name: self.localized_display_name(locale),
956 compact_label: self.short_label.to_string(),
957 description: self.localized_description(locale),
958 category: HotbarActionCategory::App,
959 args: HotbarArgsBehavior::None,
960 safety: self.safety(),
961 recommendation: self.recommendation(),
962 }
963 }
964
965 fn short_label(&self) -> &str {
966 self.short_label
967 }
968
969 fn category(&self) -> &str {
970 "app"
971 }
972
973 fn is_active(&self, app: &App) -> bool {
974 match self.kind {
975 AppHotbarKind::VoiceToggle => app.voice_enabled,
976 AppHotbarKind::SessionCompact => app.is_compacting || app.manual_compaction_queued,
977 AppHotbarKind::Mode(mode) => app.mode == mode,
978 AppHotbarKind::ReasoningCycle => {
979 app.reasoning_effort != crate::reasoning_preference::ReasoningEffort::Off
980 }
981 AppHotbarKind::SidebarToggle => {
982 app.work_surface.placement != crate::tui::work_surface::WorkSurfacePlacement::Off
983 }
984 AppHotbarKind::FileTreeToggle => app.file_tree.is_some(),
985 AppHotbarKind::PaletteOpen => false,
986 AppHotbarKind::TrustToggle => app.trust_mode,
987 }
988 }
989
990 fn dispatch(&self, app: &mut App) -> Result<HotbarDispatch> {
991 match self.kind {
992 AppHotbarKind::VoiceToggle => {
993 let result = crate::commands::voice::voice(app);
994 Ok(dispatch_command_result(app, result))
995 }
996 AppHotbarKind::SessionCompact => {
997 Ok(HotbarDispatch::AppAction(AppAction::CompactContext {
998 focus: None,
999 }))
1000 }
1001 AppHotbarKind::Mode(mode) => {
1002 // User-facing selection: persists the startup default too.
1003 let outcome = app.select_mode(mode);
1004 // Only a live change needs an `AppAction`; a persisted-same
1005 // selection still gets its own receipt so the row does not look
1006 // inert when it actually wrote the startup default.
1007 app.report_mode_selection(mode, outcome);
1008 if outcome.changed_live_state() {
1009 Ok(HotbarDispatch::AppAction(AppAction::ModeChanged(mode)))
1010 } else {
1011 Ok(HotbarDispatch::Handled)
1012 }
1013 }
1014 AppHotbarKind::ReasoningCycle => {
1015 if app.cycle_effort().changed_live_state() {
1016 Ok(HotbarDispatch::AppAction(AppAction::UpdateCompaction(
1017 app.compaction_config(),
1018 )))
1019 } else {
1020 Ok(HotbarDispatch::Handled)
1021 }
1022 }
1023 AppHotbarKind::SidebarToggle => {
1024 if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off
1025 {
1026 app.work_surface.placement =
1027 crate::tui::work_surface::WorkSurfacePlacement::Bottom;
1028 app.status_message = Some("Workbar: bottom placement".to_string());
1029 } else {
1030 app.work_surface.placement =
1031 crate::tui::work_surface::WorkSurfacePlacement::Off;
1032 app.status_message = Some("Workbar is off".to_string());
1033 }
1034 app.needs_redraw = true;
1035 Ok(HotbarDispatch::Handled)
1036 }
1037 AppHotbarKind::FileTreeToggle => {
1038 if app.file_tree.is_some() {
1039 app.file_tree = None;
1040 app.status_message = Some("File tree closed".to_string());
1041 } else {
1042 app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(&app.workspace));
1043 app.status_message =
1044 Some("File tree: ↑/↓ navigate Enter select Esc close".to_string());
1045 }
1046 app.needs_redraw = true;
1047 Ok(HotbarDispatch::Handled)
1048 }
1049 AppHotbarKind::PaletteOpen => {
1050 app.view_stack.push(CommandPaletteView::new_for_locale(
1051 app.ui_locale,
1052 build_command_palette_entries(
1053 app.ui_locale,
1054 &app.skills_dir,
1055 app.skills_scan_codewhale_only,
1056 &app.workspace,
1057 &app.mcp_config_path,
1058 app.mcp_snapshot.as_ref(),
1059 ),
1060 ));
1061 Ok(HotbarDispatch::Handled)
1062 }
1063 AppHotbarKind::TrustToggle => {
1064 app.trust_mode = !app.trust_mode;
1065 app.status_message = Some(if app.trust_mode {
1066 "Workspace trust mode enabled.".to_string()
1067 } else {
1068 "Workspace trust mode disabled.".to_string()
1069 });
1070 Ok(HotbarDispatch::Handled)
1071 }
1072 }
1073 }
1074 }
1075
1076 struct SlashHotbarAction {
1077 info: &'static CommandInfo,
1078 id: String,
1079 short_label: String,
1080 }
1081
1082 impl SlashHotbarAction {
1083 fn new(info: &'static CommandInfo) -> Self {
1084 let short_label = match info.name {
1085 "workflow" => "wf".to_string(),
1086 other => other.chars().take(7).collect(),
1087 };
1088 Self {
1089 info,
1090 id: format!("slash.{}", info.name),
1091 short_label,
1092 }
1093 }
1094
1095 fn prefill_composer(&self, app: &mut App) {
1096 app.clear_input_recoverable();
1097 app.input = format!("/{} ", self.info.name);
1098 app.cursor_position = app.input.chars().count();
1099 app.slash_menu_hidden = false;
1100 app.needs_redraw = true;
1101 app.status_message = Some(format!(
1102 "Command needs arguments; complete {}",
1103 app.input.trim_end()
1104 ));
1105 }
1106 }
1107
1108 impl HotbarAction for SlashHotbarAction {
1109 fn id(&self) -> &str {
1110 &self.id
1111 }
1112
1113 fn metadata(&self, locale: Locale) -> HotbarActionMetadata {
1114 let recommendation = if codewhale_config::DEFAULT_HOTBAR_ACTIONS.contains(&self.id.as_str())
1115 {
1116 HotbarRecommendation::Default
1117 } else {
1118 match self.info.discovery() {
1119 crate::commands::traits::CommandDiscovery::Primary => {
1120 HotbarRecommendation::Eligible
1121 }
1122 crate::commands::traits::CommandDiscovery::Advanced
1123 | crate::commands::traits::CommandDiscovery::Compatibility => {
1124 HotbarRecommendation::Advanced
1125 }
1126 }
1127 };
1128 HotbarActionMetadata {
1129 id: self.id.clone(),
1130 source_id: format!("command:{}", self.info.name),
1131 display_name: format!("/{}", self.info.name),
1132 compact_label: self.short_label.clone(),
1133 description: self.info.description_for(locale).to_string(),
1134 category: HotbarActionCategory::Slash,
1135 args: HotbarArgsBehavior::for_command(self.info),
1136 safety: HotbarSafetyClass::ExistingCommand,
1137 recommendation,
1138 }
1139 }
1140
1141 fn short_label(&self) -> &str {
1142 &self.short_label
1143 }
1144
1145 fn category(&self) -> &str {
1146 "slash"
1147 }
1148
1149 fn is_active(&self, _app: &App) -> bool {
1150 false
1151 }
1152
1153 fn dispatch(&self, app: &mut App) -> Result<HotbarDispatch> {
1154 if self.info.requires_required_argument() {
1155 self.prefill_composer(app);
1156 return Ok(HotbarDispatch::Handled);
1157 }
1158
1159 let input = format!("/{}", self.info.name);
1160 let result = commands::execute(&input, app);
1161 Ok(dispatch_command_result(app, result))
1162 }
1163 }
1164
1165 struct RouteHotbarAction {
1166 provider: ApiProvider,
1167 model: String,
1168 id: String,
1169 short_label: String,
1170 }
1171
1172 impl RouteHotbarAction {
1173 fn new(provider: ApiProvider, model: String) -> Self {
1174 let trimmed_model = model.trim().to_string();
1175 Self {
1176 provider,
1177 id: route_action_id(provider, &trimmed_model),
1178 short_label: crate::tui::ui_text::truncate_line_to_width(
1179 provider.as_str(),
1180 HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1181 ),
1182 model: trimmed_model,
1183 }
1184 }
1185 }
1186
1187 impl HotbarAction for RouteHotbarAction {
1188 fn id(&self) -> &str {
1189 &self.id
1190 }
1191
1192 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1193 HotbarActionMetadata {
1194 id: self.id.clone(),
1195 source_id: format!("route:{}", self.provider.as_str()),
1196 display_name: format!("{} · {}", self.provider.display_name(), self.model),
1197 compact_label: self.short_label.clone(),
1198 description: format!(
1199 "Switch to {} on {} through the existing /model route path.",
1200 self.model,
1201 self.provider.display_name()
1202 ),
1203 category: HotbarActionCategory::Route,
1204 args: HotbarArgsBehavior::None,
1205 safety: HotbarSafetyClass::ConfigChange,
1206 recommendation: HotbarRecommendation::Eligible,
1207 }
1208 }
1209
1210 fn short_label(&self) -> &str {
1211 &self.short_label
1212 }
1213
1214 fn category(&self) -> &str {
1215 "route"
1216 }
1217
1218 fn is_active(&self, app: &App) -> bool {
1219 !app.auto_model
1220 && app.api_provider == self.provider
1221 && app.model.trim().eq_ignore_ascii_case(self.model.trim())
1222 }
1223
1224 fn dispatch(&self, _app: &mut App) -> Result<HotbarDispatch> {
1225 Ok(HotbarDispatch::AppAction(AppAction::SwitchModelRoute {
1226 provider: self.provider,
1227 model: self.model.clone(),
1228 }))
1229 }
1230 }
1231
1232 struct SkillHotbarAction {
1233 name: String,
1234 id: String,
1235 short_label: String,
1236 description: String,
1237 }
1238
1239 impl SkillHotbarAction {
1240 fn new(name: &str, description: &str) -> Self {
1241 let description = description.trim();
1242 Self {
1243 name: name.to_string(),
1244 id: format!("skill.{name}"),
1245 short_label: crate::tui::ui_text::truncate_line_to_width(
1246 name,
1247 HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1248 ),
1249 description: if description.is_empty() {
1250 format!("Activate the {name} skill for the next message.")
1251 } else {
1252 description.to_string()
1253 },
1254 }
1255 }
1256 }
1257
1258 impl HotbarAction for SkillHotbarAction {
1259 fn id(&self) -> &str {
1260 &self.id
1261 }
1262
1263 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1264 HotbarActionMetadata {
1265 id: self.id.clone(),
1266 source_id: format!("skill:{}", self.name),
1267 display_name: format!("${}", self.name),
1268 compact_label: self.short_label.clone(),
1269 description: self.description.clone(),
1270 category: HotbarActionCategory::Skill,
1271 args: HotbarArgsBehavior::None,
1272 safety: HotbarSafetyClass::ExistingCommand,
1273 recommendation: HotbarRecommendation::Eligible,
1274 }
1275 }
1276
1277 fn short_label(&self) -> &str {
1278 &self.short_label
1279 }
1280
1281 fn category(&self) -> &str {
1282 "skill"
1283 }
1284
1285 fn is_active(&self, app: &App) -> bool {
1286 // `activate_skill` stores the full instruction block; the heading line
1287 // inside it is the stable marker for which skill is armed.
1288 app.active_skill
1289 .as_deref()
1290 .is_some_and(|instruction| instruction.contains(&format!("# Skill: {}\n", self.name)))
1291 }
1292
1293 fn dispatch(&self, app: &mut App) -> Result<HotbarDispatch> {
1294 // Same path as typing `$<name>`: activates the skill for the next
1295 // message (local state plus a visible receipt cell); nothing is sent
1296 // to the model until the user submits a message.
1297 let input = format!("${}", self.name);
1298 let result = commands::execute(&input, app);
1299 Ok(dispatch_command_result(app, result))
1300 }
1301 }
1302
1303 struct McpToolHotbarAction {
1304 server: String,
1305 tool_name: String,
1306 model_name: String,
1307 description: Option<String>,
1308 id: String,
1309 short_label: String,
1310 }
1311
1312 impl McpToolHotbarAction {
1313 fn new(server: &str, tool: &crate::mcp::McpDiscoveredItem) -> Self {
1314 Self {
1315 server: server.to_string(),
1316 tool_name: tool.name.clone(),
1317 model_name: tool.model_name.trim().to_string(),
1318 description: tool.description.clone(),
1319 id: format!("mcp.{server}.{}", tool.name),
1320 short_label: crate::tui::ui_text::truncate_line_to_width(
1321 &tool.name,
1322 HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1323 ),
1324 }
1325 }
1326
1327 fn prefill_composer(&self, app: &mut App) {
1328 app.clear_input_recoverable();
1329 app.input = format!("{} ", self.model_name);
1330 app.cursor_position = app.input.chars().count();
1331 app.needs_redraw = true;
1332 app.status_message = Some(format!(
1333 "MCP tool needs a request; complete {}",
1334 app.input.trim_end()
1335 ));
1336 }
1337 }
1338
1339 impl HotbarAction for McpToolHotbarAction {
1340 fn id(&self) -> &str {
1341 &self.id
1342 }
1343
1344 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1345 let description = match self.description.as_deref().map(str::trim) {
1346 Some(desc) if !desc.is_empty() => {
1347 format!("Prefill the composer with {} — {desc}", self.model_name)
1348 }
1349 _ => format!(
1350 "Prefill the composer with {}; the call still runs through tool approval.",
1351 self.model_name
1352 ),
1353 };
1354 HotbarActionMetadata {
1355 id: self.id.clone(),
1356 source_id: format!("mcp:{}", self.server),
1357 display_name: format!("mcp:{}:{}", self.server, self.tool_name),
1358 compact_label: self.short_label.clone(),
1359 description,
1360 category: HotbarActionCategory::Mcp,
1361 args: HotbarArgsBehavior::Required,
1362 safety: HotbarSafetyClass::RequiresApproval,
1363 recommendation: HotbarRecommendation::Advanced,
1364 }
1365 }
1366
1367 fn short_label(&self) -> &str {
1368 &self.short_label
1369 }
1370
1371 fn category(&self) -> &str {
1372 "mcp"
1373 }
1374
1375 fn is_active(&self, _app: &App) -> bool {
1376 false
1377 }
1378
1379 fn dispatch(&self, app: &mut App) -> Result<HotbarDispatch> {
1380 // Never execute the tool from the hotbar: prefill the composer with
1381 // the model-visible tool name (same text the command palette's
1382 // "> use" entry inserts) and let the user describe the call. The
1383 // eventual invocation stays behind the normal tool approval flow.
1384 self.prefill_composer(app);
1385 Ok(HotbarDispatch::Handled)
1386 }
1387 }
1388
1389 fn configured_route_models_for_provider(
1390 config: &Config,
1391 provider: ApiProvider,
1392 active_provider: ApiProvider,
1393 active_model: &str,
1394 provider_models: &HashMap<String, String>,
1395 ) -> Vec<String> {
1396 let mut models = Vec::new();
1397 if provider == active_provider {
1398 push_route_model(&mut models, active_model);
1399 }
1400 if let Some(model) = provider_models.get(provider.as_str()) {
1401 push_route_model(&mut models, model);
1402 }
1403 if let Some(model) = config
1404 .provider_config_for(provider)
1405 .and_then(|provider| provider.model.as_deref())
1406 {
1407 push_route_model(&mut models, model);
1408 }
1409 for model in all_catalog_models_for_provider(provider)
1410 .into_iter()
1411 .filter(|model| !model.trim().eq_ignore_ascii_case("auto"))
1412 .take(1)
1413 {
1414 push_route_model(&mut models, &model);
1415 }
1416 models
1417 }
1418
1419 fn push_route_model(models: &mut Vec<String>, model: &str) {
1420 let trimmed = model.trim();
1421 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
1422 return;
1423 }
1424 if models
1425 .iter()
1426 .any(|existing| existing.eq_ignore_ascii_case(trimmed))
1427 {
1428 return;
1429 }
1430 models.push(trimmed.to_string());
1431 }
1432
1433 fn route_action_id(provider: ApiProvider, model: &str) -> String {
1434 format!("route.{}.{}", provider.as_str(), model.trim())
1435 }
1436
1437 #[cfg(test)]
1438 mod tests {
1439 use std::collections::{BTreeSet, HashMap};
1440 use std::path::PathBuf;
1441
1442 use crate::config::{ApiProvider, Config};
1443 use crate::reasoning_preference::ReasoningEffort;
1444 use crate::tui::app::TuiOptions;
1445 use crate::tui::views::ModalKind;
1446
1447 use super::*;
1448
1449 fn test_app_with_paths_and_config(
1450 workspace: PathBuf,
1451 skills_dir: PathBuf,
1452 config: &Config,
1453 ) -> App {
1454 let options = TuiOptions {
1455 skills_dir,
1456 start_in_agent_mode: true,
1457 ..crate::test_support::test_tui_options(workspace)
1458 };
1459 let mut app = App::new(options, config);
1460 app.ui_locale = codewhale_localization::Locale::En;
1461 app
1462 }
1463
1464 fn test_app_with_paths(workspace: PathBuf, skills_dir: PathBuf) -> App {
1465 test_app_with_paths_and_config(workspace, skills_dir, &Config::default())
1466 }
1467
1468 fn test_app() -> App {
1469 test_app_with_paths(PathBuf::from("."), PathBuf::from("."))
1470 }
1471
1472 fn test_mcp_snapshot() -> crate::mcp::McpManagerSnapshot {
1473 use crate::mcp::{McpDiscoveredItem, McpManagerSnapshot, McpServerSnapshot};
1474 let server = |name: &str, enabled: bool, tools: Vec<McpDiscoveredItem>| McpServerSnapshot {
1475 name: name.to_string(),
1476 enabled,
1477 required: false,
1478 transport: "stdio".to_string(),
1479 command_or_url: format!("{name}-server"),
1480 connect_timeout: 5,
1481 execute_timeout: 5,
1482 read_timeout: 5,
1483 connected: enabled,
1484 error: None,
1485 auth_required: false,
1486 capability_metadata: if enabled {
1487 crate::mcp::McpServerCapabilityMetadata::LegacyFallback
1488 } else {
1489 crate::mcp::McpServerCapabilityMetadata::NotObserved
1490 },
1491 tools,
1492 resources: Vec::new(),
1493 prompts: Vec::new(),
1494 };
1495 McpManagerSnapshot {
1496 config_path: PathBuf::from("mcp.json"),
1497 config_exists: true,
1498 reload_required: false,
1499 servers: vec![
1500 server(
1501 "search",
1502 true,
1503 vec![
1504 McpDiscoveredItem {
1505 name: "web_search".to_string(),
1506 model_name: "mcp_search_web_search".to_string(),
1507 description: Some("Search the web".to_string()),
1508 },
1509 McpDiscoveredItem {
1510 name: "broken".to_string(),
1511 model_name: " ".to_string(),
1512 description: None,
1513 },
1514 ],
1515 ),
1516 server(
1517 "offline",
1518 false,
1519 vec![McpDiscoveredItem {
1520 name: "other_tool".to_string(),
1521 model_name: "mcp_offline_other_tool".to_string(),
1522 description: None,
1523 }],
1524 ),
1525 ],
1526 }
1527 }
1528
1529 struct TestHotbarAction {
1530 id: &'static str,
1531 }
1532
1533 impl HotbarAction for TestHotbarAction {
1534 fn id(&self) -> &str {
1535 self.id
1536 }
1537
1538 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1539 HotbarActionMetadata {
1540 id: self.id.to_string(),
1541 source_id: "test".to_string(),
1542 display_name: "Test action".to_string(),
1543 compact_label: "test".to_string(),
1544 description: "Test action descriptor".to_string(),
1545 category: HotbarActionCategory::App,
1546 args: HotbarArgsBehavior::None,
1547 safety: HotbarSafetyClass::LocalUi,
1548 recommendation: HotbarRecommendation::Eligible,
1549 }
1550 }
1551
1552 fn short_label(&self) -> &str {
1553 "test"
1554 }
1555
1556 fn category(&self) -> &str {
1557 "app"
1558 }
1559
1560 fn is_active(&self, _app: &App) -> bool {
1561 false
1562 }
1563
1564 fn dispatch(&self, _app: &mut App) -> Result<HotbarDispatch> {
1565 Ok(HotbarDispatch::Handled)
1566 }
1567 }
1568
1569 struct DeferredTestHotbarSource;
1570
1571 impl HotbarActionSource for DeferredTestHotbarSource {
1572 fn descriptor(&self) -> HotbarSourceDescriptor {
1573 HOTBAR_SOURCE_DESCRIPTORS
1574 .iter()
1575 .copied()
1576 .find(|descriptor| descriptor.category == HotbarActionCategory::Plugin)
1577 .expect("plugin descriptor exists")
1578 }
1579
1580 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
1581 registry.register(TestHotbarAction {
1582 id: "plugin.deferred-test",
1583 });
1584 }
1585 }
1586
1587 #[test]
1588 #[should_panic(expected = "duplicate hotbar action id duplicate.action")]
1589 fn registry_rejects_duplicate_action_ids() {
1590 let mut registry = HotbarActionRegistry::new();
1591 registry.register(TestHotbarAction {
1592 id: "duplicate.action",
1593 });
1594 registry.register(TestHotbarAction {
1595 id: "duplicate.action",
1596 });
1597 }
1598
1599 #[test]
1600 fn registry_metadata_contract_covers_registered_actions() {
1601 let registry = HotbarActionRegistry::with_builtins();
1602 let errors = registry.metadata_validation_errors(Locale::En);
1603 assert!(errors.is_empty(), "metadata validation failed: {errors:?}");
1604
1605 let metadata = registry.metadata(Locale::En);
1606 assert_eq!(metadata.len(), registry.len());
1607
1608 let ids = metadata
1609 .iter()
1610 .map(|entry| entry.id.as_str())
1611 .collect::<Vec<_>>();
1612 let mut sorted_ids = ids.clone();
1613 sorted_ids.sort_unstable();
1614 assert_eq!(
1615 ids, sorted_ids,
1616 "registry metadata should have stable id order"
1617 );
1618 assert_eq!(
1619 ids.iter().copied().collect::<BTreeSet<_>>().len(),
1620 ids.len(),
1621 "metadata ids must be unique"
1622 );
1623
1624 for entry in metadata {
1625 assert_eq!(
1626 HotbarActionCategory::parse(entry.category.as_str()),
1627 Some(entry.category)
1628 );
1629 let entry_errors = entry.validation_errors();
1630 assert!(
1631 entry_errors.is_empty(),
1632 "metadata entry failed validation: {entry_errors:?}"
1633 );
1634 assert!(
1635 unicode_width::UnicodeWidthStr::width(entry.compact_label.as_str())
1636 <= HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1637 "compact label should be validated: {entry:?}"
1638 );
1639 }
1640 }
1641
1642 #[test]
1643 fn source_descriptors_cover_dispatch_boundaries() {
1644 let descriptors = hotbar_source_descriptors();
1645 let categories = descriptors
1646 .iter()
1647 .map(|descriptor| descriptor.category)
1648 .collect::<BTreeSet<_>>();
1649
1650 assert_eq!(
1651 categories,
1652 BTreeSet::from([
1653 HotbarActionCategory::App,
1654 HotbarActionCategory::Route,
1655 HotbarActionCategory::Slash,
1656 HotbarActionCategory::Mcp,
1657 HotbarActionCategory::Skill,
1658 HotbarActionCategory::Plugin,
1659 ])
1660 );
1661 assert_eq!(
1662 descriptors
1663 .iter()
1664 .find(|descriptor| descriptor.category == HotbarActionCategory::App)
1665 .map(|descriptor| (
1666 descriptor.boundary,
1667 descriptor.safety_modes,
1668 descriptor.registers_dispatchable_actions()
1669 )),
1670 Some((
1671 HotbarSourceDispatchBoundary::DirectApp,
1672 HOTBAR_DIRECT_APP_SAFETY,
1673 true
1674 ))
1675 );
1676 assert_eq!(
1677 descriptors
1678 .iter()
1679 .find(|descriptor| descriptor.category == HotbarActionCategory::Route)
1680 .map(|descriptor| (
1681 descriptor.boundary,
1682 descriptor.safety_modes,
1683 descriptor.registers_dispatchable_actions()
1684 )),
1685 Some((
1686 HotbarSourceDispatchBoundary::ModelRoute,
1687 HOTBAR_ROUTE_SAFETY,
1688 true
1689 ))
1690 );
1691 assert_eq!(
1692 descriptors
1693 .iter()
1694 .find(|descriptor| descriptor.category == HotbarActionCategory::Slash)
1695 .map(|descriptor| (
1696 descriptor.boundary,
1697 descriptor.safety_modes,
1698 descriptor.registers_dispatchable_actions()
1699 )),
1700 Some((
1701 HotbarSourceDispatchBoundary::SlashCommand,
1702 HOTBAR_SLASH_SAFETY,
1703 true
1704 ))
1705 );
1706 assert_eq!(
1707 descriptors
1708 .iter()
1709 .find(|descriptor| descriptor.category == HotbarActionCategory::Mcp)
1710 .map(|descriptor| (
1711 descriptor.boundary,
1712 descriptor.safety_modes,
1713 descriptor.registers_dispatchable_actions()
1714 )),
1715 Some((
1716 HotbarSourceDispatchBoundary::ComposerPrefill,
1717 HOTBAR_MCP_SAFETY,
1718 true
1719 ))
1720 );
1721 assert_eq!(
1722 descriptors
1723 .iter()
1724 .find(|descriptor| descriptor.category == HotbarActionCategory::Skill)
1725 .map(|descriptor| (
1726 descriptor.boundary,
1727 descriptor.safety_modes,
1728 descriptor.registers_dispatchable_actions()
1729 )),
1730 Some((
1731 HotbarSourceDispatchBoundary::SlashCommand,
1732 HOTBAR_SKILL_SAFETY,
1733 true
1734 ))
1735 );
1736 let plugin = descriptors
1737 .iter()
1738 .find(|descriptor| descriptor.category == HotbarActionCategory::Plugin)
1739 .expect("missing descriptor for Plugin");
1740 assert_eq!(plugin.boundary, HotbarSourceDispatchBoundary::Deferred);
1741 assert_eq!(plugin.safety_modes, HOTBAR_DEFERRED_SAFETY);
1742 assert_eq!(plugin.status, "exploratory");
1743 assert!(
1744 !plugin.registers_dispatchable_actions(),
1745 "deferred Plugin source must not be dispatchable"
1746 );
1747 }
1748
1749 #[test]
1750 #[should_panic(
1751 expected = "deferred hotbar source Plugin must not register dispatchable actions"
1752 )]
1753 fn deferred_sources_cannot_register_dispatchable_actions() {
1754 let mut registry = HotbarActionRegistry::new();
1755 registry.register_source(&DeferredTestHotbarSource);
1756 }
1757
1758 #[test]
1759 fn source_adapters_register_previous_default_registry_surface() {
1760 let mut registry = HotbarActionRegistry::new();
1761 registry.register_source(&BuiltinHotbarActionSource);
1762 registry.register_source(&SlashCommandHotbarActionSource);
1763
1764 let adapter_ids = registry
1765 .iter()
1766 .map(|action| action.id().to_string())
1767 .collect::<Vec<_>>();
1768 let default_ids = HotbarActionRegistry::with_builtins()
1769 .iter()
1770 .map(|action| action.id().to_string())
1771 .collect::<Vec<_>>();
1772
1773 assert_eq!(adapter_ids, default_ids);
1774 assert_eq!(
1775 BuiltinHotbarActionSource.descriptor().category,
1776 HotbarActionCategory::App
1777 );
1778 assert_eq!(
1779 SlashCommandHotbarActionSource.descriptor().category,
1780 HotbarActionCategory::Slash
1781 );
1782 }
1783
1784 #[test]
1785 fn slash_source_matches_command_palette_command_entries() {
1786 // Hermetic: the palette builder also reads `~/.claude/skills`, so a
1787 // host with imported Claude skills would leak entries into one side
1788 // and fail the comparison. Isolate HOME the way the other
1789 // environment-reading tests in this file do.
1790 let _lock = crate::test_support::lock_test_env();
1791 let tmp = tempfile::TempDir::new().expect("tempdir");
1792 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
1793 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
1794 let _codewhale_home =
1795 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
1796 let palette_slash_ids = build_command_palette_entries(
1797 Locale::En,
1798 tmp.path(),
1799 true,
1800 tmp.path(),
1801 &tmp.path().join("mcp.json"),
1802 None,
1803 )
1804 .into_iter()
1805 .filter(|entry| entry.section() == crate::tui::command_palette::PaletteSection::Command)
1806 .filter_map(|entry| {
1807 entry
1808 .label
1809 .strip_prefix('/')
1810 .map(|name| format!("slash.{name}"))
1811 })
1812 .collect::<BTreeSet<_>>();
1813
1814 let mut registry = HotbarActionRegistry::new();
1815 registry.register_source(&SlashCommandHotbarActionSource);
1816 let hotbar_slash_ids = registry
1817 .iter()
1818 .map(|action| action.id().to_string())
1819 .collect::<BTreeSet<_>>();
1820
1821 // The hotbar is a binding substrate and registers every command; the
1822 // palette is a browsing surface and omits the unlisted ones. So the
1823 // palette is a subset, and the difference is exactly the unlisted set.
1824 let unlisted_ids = commands::command_infos()
1825 .iter()
1826 .filter(|info| info.is_unlisted())
1827 .map(|info| format!("slash.{}", info.name))
1828 .collect::<BTreeSet<_>>();
1829 assert!(
1830 palette_slash_ids.is_subset(&hotbar_slash_ids),
1831 "the palette must not offer a command the hotbar cannot bind"
1832 );
1833 assert_eq!(
1834 hotbar_slash_ids
1835 .difference(&palette_slash_ids)
1836 .cloned()
1837 .collect::<BTreeSet<_>>(),
1838 unlisted_ids,
1839 "the only commands the hotbar has and the palette hides are the unlisted ones"
1840 );
1841 }
1842
1843 #[test]
1844 fn default_hotbar_actions_have_registered_default_metadata() {
1845 let registry = HotbarActionRegistry::with_builtins();
1846
1847 for id in codewhale_config::DEFAULT_HOTBAR_ACTIONS {
1848 let action = registry
1849 .get(id)
1850 .unwrap_or_else(|| panic!("missing default hotbar action {id}"));
1851 let metadata = action.metadata(Locale::En);
1852 if id.starts_with("slash.") {
1853 assert_eq!(metadata.category, HotbarActionCategory::Slash);
1854 } else {
1855 assert_eq!(metadata.category, HotbarActionCategory::App);
1856 assert_eq!(metadata.args, HotbarArgsBehavior::None);
1857 }
1858 assert_eq!(metadata.recommendation, HotbarRecommendation::Default);
1859 assert!(
1860 metadata.recommendation.is_recommendable(),
1861 "default action must be recommendable: {metadata:?}"
1862 );
1863 assert!(!metadata.display_name.trim().is_empty());
1864 assert!(!metadata.description.trim().is_empty());
1865 }
1866 }
1867
1868 #[test]
1869 fn slash_action_metadata_describes_args_and_recommendations() {
1870 let registry = HotbarActionRegistry::with_builtins();
1871
1872 let compact = registry
1873 .get("slash.compact")
1874 .expect("compact slash action")
1875 .metadata(Locale::En);
1876 assert_eq!(compact.category, HotbarActionCategory::Slash);
1877 assert_eq!(compact.source_id, "command:compact");
1878 assert_eq!(compact.display_name, "/compact");
1879 // `/compact [focus]` takes an optional summary focus (2026-07-23).
1880 assert_eq!(compact.args, HotbarArgsBehavior::Optional);
1881 assert_eq!(compact.safety, HotbarSafetyClass::ExistingCommand);
1882 assert_eq!(compact.recommendation, HotbarRecommendation::Eligible);
1883
1884 let mode = registry
1885 .get("slash.mode")
1886 .expect("mode slash action")
1887 .metadata(Locale::En);
1888 assert_eq!(mode.args, HotbarArgsBehavior::Optional);
1889
1890 let rename = registry
1891 .get("slash.rename")
1892 .expect("rename slash action")
1893 .metadata(Locale::En);
1894 assert_eq!(rename.args, HotbarArgsBehavior::Required);
1895 assert_eq!(rename.recommendation, HotbarRecommendation::Advanced);
1896 }
1897
1898 #[test]
1899 fn reasoning_action_remains_available_for_auto_model_routing() {
1900 let registry = HotbarActionRegistry::with_builtins();
1901 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
1902 let mut app = test_app();
1903
1904 let metadata = reasoning.metadata(Locale::En);
1905 assert_eq!(metadata.category, HotbarActionCategory::App);
1906 assert_eq!(metadata.safety, HotbarSafetyClass::LocalState);
1907 assert_eq!(metadata.recommendation, HotbarRecommendation::Eligible);
1908 assert!(reasoning.disabled_reason(&app).is_none());
1909
1910 app.auto_model = true;
1911 assert!(reasoning.disabled_reason(&app).is_none());
1912 }
1913
1914 #[test]
1915 fn hotbar_recommendations_default_to_stable_slot_order() {
1916 let app = test_app();
1917
1918 let recommendations =
1919 recommend_hotbar_actions(&app, HotbarRecommendationOptions::default());
1920
1921 assert_eq!(
1922 recommendations
1923 .iter()
1924 .map(|entry| entry.metadata.id.as_str())
1925 .collect::<Vec<_>>(),
1926 codewhale_config::DEFAULT_HOTBAR_ACTIONS
1927 );
1928 assert!(recommendations.iter().all(|entry| {
1929 entry.metadata.recommendation == HotbarRecommendation::Default
1930 && entry.disabled_reason.is_none()
1931 }));
1932 }
1933
1934 #[test]
1935 fn hotbar_recommendations_keep_reasoning_for_auto_model() {
1936 let mut app = test_app();
1937 app.auto_model = true;
1938
1939 let recommendations =
1940 recommend_hotbar_actions(&app, HotbarRecommendationOptions::for_setup_wizard());
1941
1942 assert!(
1943 recommendations
1944 .iter()
1945 .any(|entry| entry.metadata.id == "reasoning.cycle")
1946 );
1947 }
1948
1949 #[test]
1950 fn hotbar_recommendations_exclude_required_args_by_default() {
1951 let app = test_app();
1952
1953 let recommendations =
1954 recommend_hotbar_actions(&app, HotbarRecommendationOptions::for_setup_wizard());
1955
1956 assert!(
1957 !recommendations
1958 .iter()
1959 .any(|entry| entry.metadata.id == "slash.rename")
1960 );
1961 }
1962
1963 #[test]
1964 fn hotbar_recommendations_limit_eligible_actions_by_category() {
1965 let app = test_app();
1966 let recommendations = recommend_hotbar_actions(
1967 &app,
1968 HotbarRecommendationOptions {
1969 max_total: usize::MAX,
1970 max_eligible_per_category: 1,
1971 include_required_args: false,
1972 },
1973 );
1974
1975 for default_id in codewhale_config::DEFAULT_HOTBAR_ACTIONS {
1976 assert!(
1977 recommendations
1978 .iter()
1979 .any(|entry| entry.metadata.id == default_id),
1980 "default recommendation {default_id} should not be category-capped"
1981 );
1982 }
1983 let slash_recommendations = recommendations
1984 .iter()
1985 .filter(|entry| entry.metadata.category == HotbarActionCategory::Slash)
1986 .collect::<Vec<_>>();
1987 let default_slash = slash_recommendations
1988 .iter()
1989 .filter(|entry| entry.metadata.recommendation == HotbarRecommendation::Default)
1990 .count();
1991 let eligible_slash = slash_recommendations
1992 .iter()
1993 .filter(|entry| entry.metadata.recommendation == HotbarRecommendation::Eligible)
1994 .count();
1995 assert_eq!(default_slash, 3);
1996 assert_eq!(eligible_slash, 1);
1997 }
1998
1999 #[test]
2000 fn recommended_hotbar_bindings_serialize_action_ids_and_labels() {
2001 let app = test_app();
2002
2003 let bindings = recommended_hotbar_bindings(&app, HotbarRecommendationOptions::default());
2004
2005 assert_eq!(
2006 bindings
2007 .iter()
2008 .map(|binding| binding.action.as_str())
2009 .collect::<Vec<_>>(),
2010 codewhale_config::DEFAULT_HOTBAR_ACTIONS
2011 );
2012 assert_eq!(
2013 bindings
2014 .iter()
2015 .map(|binding| (binding.slot, binding.label.as_deref()))
2016 .collect::<Vec<_>>(),
2017 vec![
2018 (1, Some("wf")),
2019 (2, Some("goal")),
2020 (3, Some("auto")),
2021 (4, Some("plan")),
2022 (5, Some("agent")),
2023 (6, Some("operate")),
2024 (7, Some("palette")),
2025 (8, Some("side")),
2026 ]
2027 );
2028
2029 let config = codewhale_config::ConfigToml {
2030 hotbar: Some(bindings.clone()),
2031 ..Default::default()
2032 };
2033 let serialized = toml::to_string_pretty(&config).expect("serialize hotbar recommendations");
2034 let round_tripped: codewhale_config::ConfigToml =
2035 toml::from_str(&serialized).expect("deserialize hotbar recommendations");
2036 assert_eq!(round_tripped.hotbar, Some(bindings));
2037 }
2038
2039 #[test]
2040 fn builtins_register_expected_actions() {
2041 let mut registry = HotbarActionRegistry::new();
2042 registry.register_builtins();
2043 let ids = registry.iter().map(HotbarAction::id).collect::<Vec<_>>();
2044
2045 assert_eq!(
2046 ids,
2047 vec![
2048 "filetree.toggle",
2049 "mode.agent",
2050 "mode.operate",
2051 "mode.plan",
2052 "palette.open",
2053 "reasoning.cycle",
2054 "session.compact",
2055 "sidebar.toggle",
2056 "trust.toggle",
2057 "voice.toggle",
2058 ]
2059 );
2060 assert!(registry.get("missing.action").is_none());
2061 for action in registry.iter() {
2062 assert_eq!(action.category(), "app");
2063 assert!(
2064 unicode_width::UnicodeWidthStr::width(action.short_label())
2065 <= HOTBAR_COMPACT_LABEL_MAX_WIDTH,
2066 "{} has an overlong short label",
2067 action.id()
2068 );
2069 }
2070 }
2071
2072 #[test]
2073 fn app_starts_with_builtin_hotbar_registry() {
2074 let app = test_app();
2075 assert!(app.hotbar_actions.len() > HotbarActionRegistry::with_builtins().len());
2076 assert!(app.hotbar_actions.get("mode.agent").is_some());
2077 assert!(app.hotbar_actions.get("slash.help").is_some());
2078 assert!(app.hotbar_actions.get("slash.mode").is_some());
2079 assert!(
2080 app.hotbar_actions
2081 .iter()
2082 .any(|action| action.metadata(Locale::En).category == HotbarActionCategory::Route)
2083 );
2084 }
2085
2086 #[test]
2087 fn configured_routes_register_provider_model_actions() {
2088 let mut config = Config::default();
2089 config
2090 .provider_config_for_mut(ApiProvider::Openrouter)
2091 .model = Some("anthropic/claude-sonnet-4".to_string());
2092 let mut provider_models = HashMap::new();
2093 provider_models.insert(
2094 ApiProvider::Openrouter.as_str().to_string(),
2095 "openai/gpt-4o".to_string(),
2096 );
2097 let registry = HotbarActionRegistry::with_configured_routes(
2098 &config,
2099 ApiProvider::Deepseek,
2100 "deepseek-v4-pro",
2101 &provider_models,
2102 );
2103
2104 let active = registry
2105 .get("route.deepseek.deepseek-v4-pro")
2106 .expect("active DeepSeek route");
2107 assert_eq!(active.category(), "route");
2108 assert_eq!(
2109 active.metadata(Locale::En).safety,
2110 HotbarSafetyClass::ConfigChange
2111 );
2112
2113 let openrouter = registry
2114 .get("route.openrouter.anthropic/claude-sonnet-4")
2115 .expect("configured OpenRouter route");
2116 let metadata = openrouter.metadata(Locale::En);
2117 assert_eq!(metadata.category, HotbarActionCategory::Route);
2118 assert!(metadata.display_name.contains("OpenRouter"));
2119 assert!(metadata.display_name.contains("anthropic/claude-sonnet-4"));
2120
2121 let mut app = test_app();
2122 assert_eq!(
2123 openrouter.dispatch(&mut app).expect("dispatch route"),
2124 HotbarDispatch::AppAction(AppAction::SwitchModelRoute {
2125 provider: ApiProvider::Openrouter,
2126 model: "anthropic/claude-sonnet-4".to_string(),
2127 })
2128 );
2129 }
2130
2131 #[test]
2132 fn slash_commands_register_as_hotbar_actions() {
2133 let registry = HotbarActionRegistry::with_builtins();
2134
2135 for info in commands::command_infos() {
2136 let action_id = format!("slash.{}", info.name);
2137 let action = registry
2138 .get(&action_id)
2139 .unwrap_or_else(|| panic!("missing slash hotbar action for /{}", info.name));
2140 assert_eq!(action.category(), "slash");
2141 assert!(!action.is_active(&test_app()));
2142 assert!(
2143 unicode_width::UnicodeWidthStr::width(action.short_label())
2144 <= HOTBAR_COMPACT_LABEL_MAX_WIDTH,
2145 "{action_id} has an overlong short label"
2146 );
2147 }
2148 }
2149
2150 /// #1888: the hotbar is not a control surface. It binds the owning slash
2151 /// command and dispatches it through `commands::execute` with no argument,
2152 /// so what runs is the slash surface — there is no hotbar verb table and
2153 /// no `ControlSurface::Hotbar` for a test to assert into existence.
2154 ///
2155 /// What must hold is narrower and real: every owning command is bound and
2156 /// directly dispatchable, and a bare press can only reach a verb that
2157 /// declares `hotbar_bare_dispatch` — which is necessarily targetless and
2158 /// read-only, because a keypress supplies no id.
2159 #[test]
2160 fn control_plane_commands_are_bound_and_bare_dispatch_is_read_only() {
2161 use codewhale_lane::control::OPERATIONS;
2162 use codewhale_lane::{ControlAuthority, ControlSurface, TargetKind};
2163
2164 let registry = HotbarActionRegistry::with_builtins();
2165 for descriptor in OPERATIONS {
2166 let action_id = descriptor.hotbar_action_id();
2167 assert_eq!(action_id, format!("slash.{}", descriptor.slash_command));
2168 let action = registry
2169 .get(&action_id)
2170 .unwrap_or_else(|| panic!("{} has no hotbar action {action_id}", descriptor.id));
2171 assert_eq!(action.category(), "slash");
2172 // The dispatch runs as the slash surface, which must therefore be
2173 // one the descriptor actually offers.
2174 assert!(descriptor.offers(ControlSurface::Slash));
2175
2176 // A bare hotbar press fires the command with no arguments, so the
2177 // owning command must never require one — otherwise the slot would
2178 // silently become a composer prefill instead of the verb.
2179 let info = commands::get_command_info(descriptor.slash_command)
2180 .unwrap_or_else(|| panic!("/{} is not registered", descriptor.slash_command));
2181 assert!(
2182 !info.requires_required_argument(),
2183 "/{} must stay directly dispatchable",
2184 descriptor.slash_command
2185 );
2186
2187 if descriptor.hotbar_bare_dispatch {
2188 assert_eq!(descriptor.target, TargetKind::None, "{}", descriptor.id);
2189 assert_eq!(
2190 descriptor.authority,
2191 ControlAuthority::Read,
2192 "{} would mutate durable state from one keypress",
2193 descriptor.id
2194 );
2195 }
2196 }
2197
2198 // Both control domains are bound.
2199 for id in ["slash.lane", "slash.fleet"] {
2200 assert!(registry.get(id).is_some(), "{id} must be bindable");
2201 }
2202 }
2203
2204 #[test]
2205 fn retired_slash_pod_binding_stays_unbound() {
2206 let registry = HotbarActionRegistry::with_builtins();
2207 assert!(
2208 registry.get("slash.pod").is_none(),
2209 "the retired pod id must not resolve to any action"
2210 );
2211 }
2212
2213 #[test]
2214 fn slash_hotbar_action_dispatches_argless_command() {
2215 let registry = HotbarActionRegistry::with_builtins();
2216 let mode = registry.get("slash.mode").expect("mode slash action");
2217 let mut app = test_app();
2218
2219 assert_eq!(
2220 mode.dispatch(&mut app).expect("dispatch /mode"),
2221 HotbarDispatch::AppAction(AppAction::OpenModePicker)
2222 );
2223 assert!(app.input.is_empty());
2224 }
2225
2226 #[test]
2227 fn slash_hotbar_action_dispatches_optional_argument_command_with_no_args() {
2228 let registry = HotbarActionRegistry::with_builtins();
2229 let task = registry.get("slash.task").expect("task slash action");
2230 let mut app = test_app();
2231
2232 assert_eq!(
2233 task.dispatch(&mut app).expect("dispatch /task"),
2234 HotbarDispatch::AppAction(AppAction::TaskList)
2235 );
2236 assert!(app.input.is_empty());
2237 }
2238
2239 #[test]
2240 fn slash_hotbar_action_prefills_required_argument_command() {
2241 let registry = HotbarActionRegistry::with_builtins();
2242 let rename = registry.get("slash.rename").expect("rename slash action");
2243 let mut app = test_app();
2244 app.input = "draft".to_string();
2245 app.cursor_position = app.input.chars().count();
2246
2247 assert_eq!(
2248 rename.dispatch(&mut app).expect("dispatch /rename"),
2249 HotbarDispatch::Handled
2250 );
2251 assert_eq!(app.input, "/rename ");
2252 assert_eq!(app.cursor_position, app.input.chars().count());
2253 assert_eq!(app.clear_undo_buffer.as_deref(), Some("draft"));
2254 assert_eq!(
2255 app.status_message.as_deref(),
2256 Some("Command needs arguments; complete /rename")
2257 );
2258 }
2259
2260 #[test]
2261 fn skill_source_registers_known_skills_with_dedup() {
2262 let skills = vec![
2263 ("demo".to_string(), "Demo skill".to_string()),
2264 ("demo".to_string(), "Shadowed duplicate".to_string()),
2265 (" ".to_string(), "ignored blank name".to_string()),
2266 ];
2267 let mut registry = HotbarActionRegistry::new();
2268 registry.register_skills(&skills);
2269
2270 assert_eq!(registry.len(), 1);
2271 let action = registry.get("skill.demo").expect("skill action");
2272 assert_eq!(action.category(), "skill");
2273 let metadata = action.metadata(Locale::En);
2274 assert_eq!(metadata.category, HotbarActionCategory::Skill);
2275 assert_eq!(metadata.source_id, "skill:demo");
2276 assert_eq!(metadata.display_name, "$demo");
2277 assert_eq!(metadata.description, "Demo skill");
2278 assert_eq!(metadata.args, HotbarArgsBehavior::None);
2279 assert_eq!(metadata.safety, HotbarSafetyClass::ExistingCommand);
2280 assert_eq!(metadata.recommendation, HotbarRecommendation::Eligible);
2281 assert!(registry.metadata_validation_errors(Locale::En).is_empty());
2282 }
2283
2284 #[test]
2285 fn replacing_skills_removes_stale_plugin_actions_atomically() {
2286 let mut registry = HotbarActionRegistry::with_builtins();
2287 registry.register_skills(&[
2288 ("native".to_string(), "native Skill".to_string()),
2289 (
2290 "demo:review".to_string(),
2291 "reviewed plugin Skill".to_string(),
2292 ),
2293 ]);
2294 assert!(registry.get("skill.demo:review").is_some());
2295 let builtin_count = registry
2296 .iter()
2297 .filter(|action| action.category() != HotbarActionCategory::Skill.as_str())
2298 .count();
2299
2300 registry.replace_skills(&[("native".to_string(), "refreshed".to_string())]);
2301
2302 assert!(registry.get("skill.demo:review").is_none());
2303 assert!(registry.get("skill.native").is_some());
2304 assert_eq!(
2305 registry
2306 .iter()
2307 .filter(|action| action.category() != HotbarActionCategory::Skill.as_str())
2308 .count(),
2309 builtin_count,
2310 "refresh must preserve every non-Skill action source"
2311 );
2312 }
2313
2314 #[test]
2315 fn skill_hotbar_action_activates_skill_through_dollar_alias() {
2316 let workspace = tempfile::TempDir::new().expect("workspace");
2317 let skills_dir = tempfile::TempDir::new().expect("skills dir");
2318 let skill_dir = skills_dir.path().join("hotbar-demo-skill");
2319 std::fs::create_dir_all(&skill_dir).expect("skill dir");
2320 std::fs::write(
2321 skill_dir.join("SKILL.md"),
2322 "---\nname: hotbar-demo-skill\ndescription: Demo skill for hotbar tests\n---\n\nFollow the demo instructions.\n",
2323 )
2324 .expect("write SKILL.md");
2325 let config = Config {
2326 skills_dir: Some(skills_dir.path().to_string_lossy().into_owned()),
2327 ..Config::default()
2328 };
2329 let mut app = test_app_with_paths_and_config(
2330 workspace.path().to_path_buf(),
2331 skills_dir.path().to_path_buf(),
2332 &config,
2333 );
2334
2335 let action = app
2336 .hotbar_actions
2337 .get("skill.hotbar-demo-skill")
2338 .expect("skill registered from the startup skill cache");
2339 assert!(!action.is_active(&app));
2340 assert_eq!(
2341 action.dispatch(&mut app).expect("dispatch skill"),
2342 HotbarDispatch::Handled
2343 );
2344 assert!(app.active_skill.is_some());
2345 assert!(action.is_active(&app));
2346 assert!(
2347 app.status_message
2348 .as_deref()
2349 .is_some_and(|message| message.contains("activated"))
2350 );
2351 }
2352
2353 #[test]
2354 fn skill_hotbar_action_reports_unknown_skill() {
2355 let workspace = tempfile::TempDir::new().expect("workspace");
2356 let skills_dir = tempfile::TempDir::new().expect("skills dir");
2357 let mut app = test_app_with_paths(
2358 workspace.path().to_path_buf(),
2359 skills_dir.path().to_path_buf(),
2360 );
2361
2362 let mut registry = HotbarActionRegistry::new();
2363 registry.register_skills(&[(
2364 "hotbar-skill-that-does-not-exist".to_string(),
2365 "Stale cache entry".to_string(),
2366 )]);
2367 let action = registry
2368 .get("skill.hotbar-skill-that-does-not-exist")
2369 .expect("stale skill action");
2370
2371 assert_eq!(
2372 action.dispatch(&mut app).expect("dispatch stale skill"),
2373 HotbarDispatch::Handled
2374 );
2375 assert!(app.active_skill.is_none());
2376 assert!(
2377 app.status_message
2378 .as_deref()
2379 .is_some_and(|message| message.contains("Unknown skill"))
2380 );
2381 }
2382
2383 #[test]
2384 fn mcp_source_registers_enabled_server_tools_only() {
2385 let snapshot = test_mcp_snapshot();
2386 let mut registry = HotbarActionRegistry::new();
2387 registry.replace_mcp_tools(Some(&snapshot));
2388
2389 // Only the enabled server's tool with a model name registers: the
2390 // blank-model-name tool and the disabled server's tool never do.
2391 assert_eq!(registry.len(), 1);
2392 let action = registry
2393 .get("mcp.search.web_search")
2394 .expect("mcp tool action");
2395 assert_eq!(action.category(), "mcp");
2396 let metadata = action.metadata(Locale::En);
2397 assert_eq!(metadata.category, HotbarActionCategory::Mcp);
2398 assert_eq!(metadata.source_id, "mcp:search");
2399 assert_eq!(metadata.display_name, "mcp:search:web_search");
2400 assert!(metadata.description.contains("mcp_search_web_search"));
2401 assert!(metadata.description.contains("Search the web"));
2402 assert_eq!(metadata.args, HotbarArgsBehavior::Required);
2403 assert_eq!(metadata.safety, HotbarSafetyClass::RequiresApproval);
2404 assert_eq!(metadata.recommendation, HotbarRecommendation::Advanced);
2405 assert!(registry.metadata_validation_errors(Locale::En).is_empty());
2406 }
2407
2408 #[test]
2409 fn mcp_hotbar_action_prefills_composer_instead_of_executing() {
2410 let snapshot = test_mcp_snapshot();
2411 let mut registry = HotbarActionRegistry::new();
2412 registry.replace_mcp_tools(Some(&snapshot));
2413 let action = registry
2414 .get("mcp.search.web_search")
2415 .expect("mcp tool action");
2416 let mut app = test_app();
2417 app.input = "draft".to_string();
2418 app.cursor_position = app.input.chars().count();
2419
2420 assert_eq!(
2421 action.dispatch(&mut app).expect("dispatch mcp tool"),
2422 HotbarDispatch::Handled
2423 );
2424 assert_eq!(app.input, "mcp_search_web_search ");
2425 assert_eq!(app.cursor_position, app.input.chars().count());
2426 assert_eq!(app.clear_undo_buffer.as_deref(), Some("draft"));
2427 assert!(
2428 app.status_message
2429 .as_deref()
2430 .is_some_and(|message| message.contains("mcp_search_web_search"))
2431 );
2432 }
2433
2434 #[test]
2435 fn replace_mcp_tools_refreshes_and_clears_mcp_actions() {
2436 let mut registry = HotbarActionRegistry::with_builtins();
2437 let baseline = registry.len();
2438 let snapshot = test_mcp_snapshot();
2439
2440 registry.replace_mcp_tools(Some(&snapshot));
2441 assert_eq!(registry.len(), baseline + 1);
2442 // Re-applying a refreshed snapshot must not panic on duplicate ids.
2443 registry.replace_mcp_tools(Some(&snapshot));
2444 assert_eq!(registry.len(), baseline + 1);
2445
2446 registry.replace_mcp_tools(None);
2447 assert_eq!(registry.len(), baseline);
2448 assert!(registry.get("mcp.search.web_search").is_none());
2449 }
2450
2451 #[test]
2452 fn mode_actions_report_active_state_and_dispatch() {
2453 let registry = HotbarActionRegistry::with_builtins();
2454 let plan = registry.get("mode.plan").expect("plan action");
2455 let agent = registry.get("mode.agent").expect("agent action");
2456 let operate = registry.get("mode.operate").expect("operate action");
2457 let mut app = test_app();
2458
2459 assert!(agent.is_active(&app));
2460 assert!(!plan.is_active(&app));
2461 assert!(registry.get("mode.yolo").is_none());
2462
2463 assert_eq!(
2464 plan.dispatch(&mut app).expect("dispatch plan"),
2465 HotbarDispatch::AppAction(AppAction::ModeChanged(AppMode::Plan))
2466 );
2467 assert_eq!(app.mode, AppMode::Plan);
2468 assert!(plan.is_active(&app));
2469 assert!(!agent.is_active(&app));
2470
2471 assert_eq!(
2472 operate.dispatch(&mut app).expect("dispatch operate"),
2473 HotbarDispatch::AppAction(AppAction::ModeChanged(AppMode::Operate))
2474 );
2475 assert_eq!(app.mode, AppMode::Operate);
2476 assert!(operate.is_active(&app));
2477 assert!(!agent.is_active(&app));
2478 }
2479
2480 #[test]
2481 fn compact_action_emits_existing_app_action() {
2482 let registry = HotbarActionRegistry::with_builtins();
2483 let compact = registry.get("session.compact").expect("compact action");
2484 let mut app = test_app();
2485
2486 assert!(!compact.is_active(&app));
2487 assert_eq!(
2488 compact.dispatch(&mut app).expect("dispatch compact"),
2489 HotbarDispatch::AppAction(AppAction::CompactContext { focus: None })
2490 );
2491 app.is_compacting = true;
2492 assert!(compact.is_active(&app));
2493 assert_eq!(
2494 compact
2495 .dispatch(&mut app)
2496 .expect("dispatch compact while busy"),
2497 HotbarDispatch::AppAction(AppAction::CompactContext { focus: None })
2498 );
2499 }
2500
2501 #[test]
2502 fn reasoning_cycle_updates_effort_and_compaction() {
2503 let registry = HotbarActionRegistry::with_builtins();
2504 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
2505 let mut app = test_app();
2506 app.api_provider = ApiProvider::Deepseek;
2507 app.reasoning_effort = ReasoningEffort::Off;
2508
2509 assert!(!reasoning.is_active(&app));
2510 assert!(matches!(
2511 reasoning.dispatch(&mut app).expect("dispatch reasoning"),
2512 HotbarDispatch::AppAction(AppAction::UpdateCompaction(_))
2513 ));
2514 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
2515 assert!(reasoning.is_active(&app));
2516 assert_eq!(app.status_message.as_deref(), Some("Reasoning effort: low"));
2517
2518 app.auto_model = true;
2519 assert!(reasoning.is_active(&app));
2520 assert!(matches!(
2521 reasoning
2522 .dispatch(&mut app)
2523 .expect("dispatch reasoning under auto model"),
2524 HotbarDispatch::AppAction(AppAction::UpdateCompaction(_))
2525 ));
2526 assert_eq!(app.reasoning_effort, ReasoningEffort::Medium);
2527 }
2528
2529 #[test]
2530 fn reasoning_cycle_is_inert_while_a_turn_is_running() {
2531 let _lock = crate::test_support::lock_test_env();
2532 let tmp = tempfile::TempDir::new().expect("tempdir");
2533 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
2534 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
2535 let _codewhale_home =
2536 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
2537 let _deepseek_config = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
2538 let _codewhale_config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
2539 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
2540
2541 crate::settings::Settings::transact(|settings| {
2542 settings.reasoning_effort = Some("off".to_string());
2543 Ok(())
2544 })
2545 .expect("seed startup reasoning");
2546
2547 let registry = HotbarActionRegistry::with_builtins();
2548 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
2549 let mut app = test_app();
2550 app.api_provider = ApiProvider::Deepseek;
2551 app.auto_model = false;
2552 app.reasoning_effort = ReasoningEffort::Off;
2553 app.is_loading = true;
2554
2555 assert_eq!(
2556 reasoning.dispatch(&mut app).expect("dispatch while busy"),
2557 HotbarDispatch::Handled
2558 );
2559 assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
2560 assert_eq!(app.startup_defaults.pending_len(), 0);
2561 assert_eq!(
2562 crate::settings::Settings::load()
2563 .expect("reload settings")
2564 .reasoning_effort
2565 .as_deref(),
2566 Some("off"),
2567 "a refused hotbar action must not persist a different tier"
2568 );
2569 }
2570
2571 #[test]
2572 fn reasoning_cycle_uses_codex_effort_tiers() {
2573 // Codex tiers are now per-model, read from the OAuth roster. Point
2574 // CODEX_HOME at an empty directory so this exercises the static
2575 // fallback ladder instead of whatever roster the developer's own
2576 // machine happens to have cached.
2577 let _lock = crate::test_support::lock_test_env();
2578 let codex_home = tempfile::TempDir::new().expect("codex home");
2579 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
2580 let registry = HotbarActionRegistry::with_builtins();
2581 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
2582 let mut app = test_app();
2583 app.api_provider = ApiProvider::OpenaiCodex;
2584 app.auto_model = false;
2585 app.reasoning_effort = ReasoningEffort::Low;
2586
2587 for (expected_effort, expected_label) in [
2588 (ReasoningEffort::Medium, "medium"),
2589 (ReasoningEffort::High, "high"),
2590 (ReasoningEffort::Max, "max"),
2591 (ReasoningEffort::Low, "low"),
2592 ] {
2593 assert!(matches!(
2594 reasoning.dispatch(&mut app).expect("dispatch reasoning"),
2595 HotbarDispatch::AppAction(AppAction::UpdateCompaction(_))
2596 ));
2597 assert_eq!(app.reasoning_effort, expected_effort);
2598 let expected_message = format!("Reasoning effort: {expected_label}");
2599 assert_eq!(
2600 app.status_message.as_deref(),
2601 Some(expected_message.as_str())
2602 );
2603 }
2604 }
2605
2606 #[test]
2607 fn sidebar_toggle_reports_visibility_and_dispatches() {
2608 let registry = HotbarActionRegistry::with_builtins();
2609 let sidebar = registry.get("sidebar.toggle").expect("sidebar action");
2610 let mut app = test_app();
2611 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
2612
2613 assert!(sidebar.is_active(&app));
2614 assert_eq!(
2615 sidebar.dispatch(&mut app).expect("dispatch rail hide"),
2616 HotbarDispatch::Handled
2617 );
2618 assert_eq!(
2619 app.work_surface.placement,
2620 crate::tui::work_surface::WorkSurfacePlacement::Off
2621 );
2622 assert!(!sidebar.is_active(&app));
2623
2624 sidebar.dispatch(&mut app).expect("dispatch rail show");
2625 assert_eq!(
2626 app.work_surface.placement,
2627 crate::tui::work_surface::WorkSurfacePlacement::Bottom,
2628 "toggling the rail back on restores the round-3 default"
2629 );
2630 assert!(sidebar.is_active(&app));
2631 }
2632
2633 #[tokio::test]
2634 async fn filetree_toggle_reports_open_state_and_dispatches() {
2635 let registry = HotbarActionRegistry::with_builtins();
2636 let filetree = registry.get("filetree.toggle").expect("filetree action");
2637 let mut app = test_app();
2638
2639 assert!(!filetree.is_active(&app));
2640 assert_eq!(
2641 filetree.dispatch(&mut app).expect("dispatch filetree open"),
2642 HotbarDispatch::Handled
2643 );
2644 assert!(app.file_tree.is_some());
2645 assert!(filetree.is_active(&app));
2646
2647 filetree
2648 .dispatch(&mut app)
2649 .expect("dispatch filetree close");
2650 assert!(app.file_tree.is_none());
2651 assert!(!filetree.is_active(&app));
2652 }
2653
2654 #[test]
2655 fn palette_action_opens_command_palette() {
2656 let registry = HotbarActionRegistry::with_builtins();
2657 let palette = registry.get("palette.open").expect("palette action");
2658 let mut app = test_app();
2659
2660 assert!(!palette.is_active(&app));
2661 assert_eq!(
2662 palette.dispatch(&mut app).expect("dispatch palette"),
2663 HotbarDispatch::Handled
2664 );
2665 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::CommandPalette));
2666 }
2667
2668 #[test]
2669 fn trust_toggle_reports_trust_state_and_dispatches() {
2670 let registry = HotbarActionRegistry::with_builtins();
2671 let trust = registry.get("trust.toggle").expect("trust action");
2672 let mut app = test_app();
2673 app.trust_mode = false;
2674
2675 assert!(!trust.is_active(&app));
2676 assert_eq!(
2677 trust.dispatch(&mut app).expect("dispatch trust on"),
2678 HotbarDispatch::Handled
2679 );
2680 assert!(app.trust_mode);
2681 assert!(trust.is_active(&app));
2682
2683 trust.dispatch(&mut app).expect("dispatch trust off");
2684 assert!(!app.trust_mode);
2685 assert!(!trust.is_active(&app));
2686 }
2687
2688 #[test]
2689 fn voice_toggle_dispatches_the_voice_command() {
2690 let registry = HotbarActionRegistry::with_builtins();
2691 let voice = registry.get("voice.toggle").expect("voice action");
2692 let mut app = test_app();
2693
2694 assert!(!voice.is_active(&app));
2695 // The toggle is wired to the /voice command. With a recorder on the
2696 // host it arms voice input and defers capture to the UI event loop;
2697 // without one it fails gracefully with a localized error. No audio
2698 // is recorded in either case.
2699 let result = voice.dispatch(&mut app).expect("dispatch voice");
2700 assert!(app.status_message.is_some());
2701 // The old placeholder message must be gone — voice is implemented.
2702 assert_ne!(
2703 app.status_message.as_deref(),
2704 Some("Voice input is not available in this terminal session yet.")
2705 );
2706 if app.voice_enabled {
2707 assert_eq!(
2708 result,
2709 HotbarDispatch::AppAction(crate::tui::app::AppAction::VoiceCapture)
2710 );
2711 assert!(voice.is_active(&app));
2712 // A second press toggles voice input back off.
2713 let off = voice.dispatch(&mut app).expect("dispatch voice off");
2714 assert_eq!(off, HotbarDispatch::Handled);
2715 assert!(!app.voice_enabled);
2716 assert!(!voice.is_active(&app));
2717 } else {
2718 assert_eq!(result, HotbarDispatch::Handled);
2719 }
2720 }
2721 }
2722
2722 lines RUST