返回 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::{Config, ProviderIdentity, ProviderKind};
10 use crate::provider_lake::models_for_provider;
11 use crate::tui::app::{App, AppAction};
12 use crate::tui::command_palette::{
13 CommandPaletteView, build_entries_with_plugins 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, config: &Config) -> 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: Option<&ProviderIdentity>,
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: Option<&ProviderIdentity>,
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: Option<&'a ProviderIdentity>,
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 let Some(active) = self
753 .active_provider
754 .filter(|identity| self.config.verify_provider_identity(identity).is_ok())
755 else {
756 return;
757 };
758 let mut identities = self.config.provider_identities();
759 identities.sort_by(|left, right| {
760 let left_label = left
761 .compatibility()
762 .map_or(left.key.as_str(), |row| row.label);
763 let right_label = right
764 .compatibility()
765 .map_or(right.key.as_str(), |row| row.label);
766 left_label
767 .cmp(right_label)
768 .then_with(|| left.key.as_str().cmp(right.key.as_str()))
769 });
770 for identity in identities {
771 if identity.provider == ProviderKind::Antigravity {
772 continue;
773 }
774 if !crate::config::provider_is_configured_for_active(self.config, &identity, active) {
775 continue;
776 }
777 for model in configured_route_models_for_provider(
778 self.config,
779 &identity,
780 active,
781 self.active_model,
782 self.provider_models,
783 ) {
784 registry.register(RouteHotbarAction::new(identity.clone(), model));
785 }
786 }
787 }
788 }
789
790 impl HotbarActionRegistry {
791 #[must_use]
792 pub fn get(&self, id: &str) -> Option<Arc<dyn HotbarAction>> {
793 self.actions.get(id).cloned()
794 }
795
796 #[must_use]
797 pub fn len(&self) -> usize {
798 self.actions.len()
799 }
800
801 #[expect(dead_code)]
802 #[must_use]
803 pub fn is_empty(&self) -> bool {
804 self.actions.is_empty()
805 }
806
807 pub fn iter(&self) -> impl Iterator<Item = &dyn HotbarAction> {
808 self.actions.values().map(Arc::as_ref)
809 }
810
811 #[cfg_attr(not(test), expect(dead_code))]
812 #[must_use]
813 pub fn metadata(&self, locale: Locale) -> Vec<HotbarActionMetadata> {
814 self.iter().map(|action| action.metadata(locale)).collect()
815 }
816
817 #[cfg_attr(not(test), expect(dead_code))]
818 #[must_use]
819 pub fn metadata_validation_errors(&self, locale: Locale) -> Vec<String> {
820 let mut errors = Vec::new();
821 for action in self.iter() {
822 let metadata = action.metadata(locale);
823 if metadata.id != action.id() {
824 errors.push(format!(
825 "{} metadata id {:?} does not match action id",
826 action.id(),
827 metadata.id
828 ));
829 }
830 if metadata.compact_label != action.short_label() {
831 errors.push(format!(
832 "{} metadata compact_label {:?} does not match short_label {:?}",
833 action.id(),
834 metadata.compact_label,
835 action.short_label()
836 ));
837 }
838 if metadata.category.as_str() != action.category() {
839 errors.push(format!(
840 "{} metadata category {:?} does not match category {:?}",
841 action.id(),
842 metadata.category.as_str(),
843 action.category()
844 ));
845 }
846 errors.extend(metadata.validation_errors());
847 }
848 errors
849 }
850 }
851
852 fn dispatch_command_result(app: &mut App, result: CommandResult) -> HotbarDispatch {
853 app.status_message = result.message;
854 result
855 .action
856 .map_or(HotbarDispatch::Handled, HotbarDispatch::AppAction)
857 }
858
859 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
860 enum AppHotbarKind {
861 VoiceToggle,
862 SessionCompact,
863 Mode(AppMode),
864 ReasoningCycle,
865 SidebarToggle,
866 FileTreeToggle,
867 PaletteOpen,
868 TrustToggle,
869 }
870
871 struct AppHotbarAction {
872 id: &'static str,
873 short_label: &'static str,
874 display_name: &'static str,
875 description: &'static str,
876 kind: AppHotbarKind,
877 }
878
879 impl AppHotbarAction {
880 const fn new(
881 id: &'static str,
882 short_label: &'static str,
883 display_name: &'static str,
884 description: &'static str,
885 kind: AppHotbarKind,
886 ) -> Self {
887 Self {
888 id,
889 short_label,
890 display_name,
891 description,
892 kind,
893 }
894 }
895
896 fn safety(&self) -> HotbarSafetyClass {
897 match self.kind {
898 AppHotbarKind::VoiceToggle => HotbarSafetyClass::ExternalInput,
899 AppHotbarKind::TrustToggle => HotbarSafetyClass::LocalState,
900 AppHotbarKind::SessionCompact | AppHotbarKind::ReasoningCycle => {
901 HotbarSafetyClass::LocalState
902 }
903 AppHotbarKind::Mode(_)
904 | AppHotbarKind::SidebarToggle
905 | AppHotbarKind::FileTreeToggle
906 | AppHotbarKind::PaletteOpen => HotbarSafetyClass::LocalUi,
907 }
908 }
909
910 fn recommendation(&self) -> HotbarRecommendation {
911 if codewhale_config::DEFAULT_HOTBAR_ACTIONS.contains(&self.id) {
912 HotbarRecommendation::Default
913 } else {
914 HotbarRecommendation::Eligible
915 }
916 }
917
918 fn localized_display_name(&self, locale: Locale) -> String {
919 let Some(id) = self.display_name_id() else {
920 return self.display_name.to_string();
921 };
922 tr(locale, id).into_owned()
923 }
924
925 fn localized_description(&self, locale: Locale) -> String {
926 let Some(id) = self.description_id() else {
927 return self.description.to_string();
928 };
929 tr(locale, id).into_owned()
930 }
931
932 fn display_name_id(&self) -> Option<MessageId> {
933 Some(match self.kind {
934 AppHotbarKind::VoiceToggle => MessageId::HotbarActionVoiceToggleName,
935 AppHotbarKind::SessionCompact => MessageId::HotbarActionSessionCompactName,
936 AppHotbarKind::Mode(AppMode::Plan) => MessageId::HotbarActionModePlanName,
937 AppHotbarKind::Mode(AppMode::Agent) => MessageId::HotbarActionModeAgentName,
938 AppHotbarKind::Mode(AppMode::Operate) => MessageId::HotbarActionModeOperateName,
939 AppHotbarKind::ReasoningCycle => MessageId::HotbarActionReasoningCycleName,
940 AppHotbarKind::SidebarToggle => MessageId::HotbarActionSidebarToggleName,
941 AppHotbarKind::FileTreeToggle => MessageId::HotbarActionFileTreeToggleName,
942 AppHotbarKind::PaletteOpen => MessageId::HotbarActionPaletteOpenName,
943 AppHotbarKind::TrustToggle => MessageId::HotbarActionTrustToggleName,
944 })
945 }
946
947 fn description_id(&self) -> Option<MessageId> {
948 Some(match self.kind {
949 AppHotbarKind::VoiceToggle => MessageId::HotbarActionVoiceToggleDescription,
950 AppHotbarKind::SessionCompact => MessageId::HotbarActionSessionCompactDescription,
951 AppHotbarKind::Mode(AppMode::Plan) => MessageId::HotbarActionModePlanDescription,
952 AppHotbarKind::Mode(AppMode::Agent) => MessageId::HotbarActionModeAgentDescription,
953 AppHotbarKind::Mode(AppMode::Operate) => MessageId::HotbarActionModeOperateDescription,
954 AppHotbarKind::ReasoningCycle => MessageId::HotbarActionReasoningCycleDescription,
955 AppHotbarKind::SidebarToggle => MessageId::HotbarActionSidebarToggleDescription,
956 AppHotbarKind::FileTreeToggle => MessageId::HotbarActionFileTreeToggleDescription,
957 AppHotbarKind::PaletteOpen => MessageId::HotbarActionPaletteOpenDescription,
958 AppHotbarKind::TrustToggle => MessageId::HotbarActionTrustToggleDescription,
959 })
960 }
961 }
962
963 impl HotbarAction for AppHotbarAction {
964 fn id(&self) -> &str {
965 self.id
966 }
967
968 fn metadata(&self, locale: Locale) -> HotbarActionMetadata {
969 HotbarActionMetadata {
970 id: self.id.to_string(),
971 source_id: "builtin".to_string(),
972 display_name: self.localized_display_name(locale),
973 compact_label: self.short_label.to_string(),
974 description: self.localized_description(locale),
975 category: HotbarActionCategory::App,
976 args: HotbarArgsBehavior::None,
977 safety: self.safety(),
978 recommendation: self.recommendation(),
979 }
980 }
981
982 fn short_label(&self) -> &str {
983 self.short_label
984 }
985
986 fn category(&self) -> &str {
987 "app"
988 }
989
990 fn is_active(&self, app: &App) -> bool {
991 match self.kind {
992 AppHotbarKind::VoiceToggle => app.voice_enabled,
993 AppHotbarKind::SessionCompact => app.is_compacting || app.manual_compaction_queued,
994 AppHotbarKind::Mode(mode) => app.mode == mode,
995 AppHotbarKind::ReasoningCycle => {
996 app.reasoning_effort != crate::reasoning_preference::ReasoningEffort::Off
997 }
998 AppHotbarKind::SidebarToggle => {
999 app.work_surface.placement != crate::tui::work_surface::WorkSurfacePlacement::Off
1000 }
1001 AppHotbarKind::FileTreeToggle => app.file_tree.is_some(),
1002 AppHotbarKind::PaletteOpen => false,
1003 AppHotbarKind::TrustToggle => app.trust_mode,
1004 }
1005 }
1006
1007 fn dispatch(&self, app: &mut App, _config: &Config) -> Result<HotbarDispatch> {
1008 match self.kind {
1009 AppHotbarKind::VoiceToggle => {
1010 let result = crate::commands::voice::voice(app);
1011 Ok(dispatch_command_result(app, result))
1012 }
1013 AppHotbarKind::SessionCompact => {
1014 Ok(HotbarDispatch::AppAction(AppAction::CompactContext {
1015 focus: None,
1016 }))
1017 }
1018 AppHotbarKind::Mode(mode) => {
1019 // User-facing selection: persists the startup default too.
1020 let outcome = app.select_mode(mode);
1021 // Only a live change needs an `AppAction`; a persisted-same
1022 // selection still gets its own receipt so the row does not look
1023 // inert when it actually wrote the startup default.
1024 app.report_mode_selection(mode, outcome);
1025 if outcome.changed_live_state() {
1026 Ok(HotbarDispatch::AppAction(AppAction::ModeChanged(mode)))
1027 } else {
1028 Ok(HotbarDispatch::Handled)
1029 }
1030 }
1031 AppHotbarKind::ReasoningCycle => {
1032 if app.cycle_effort().changed_live_state() {
1033 Ok(HotbarDispatch::AppAction(AppAction::UpdateCompaction(
1034 app.compaction_config(),
1035 )))
1036 } else {
1037 Ok(HotbarDispatch::Handled)
1038 }
1039 }
1040 AppHotbarKind::SidebarToggle => {
1041 if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off
1042 {
1043 app.work_surface.placement =
1044 crate::tui::work_surface::WorkSurfacePlacement::Bottom;
1045 app.status_message = Some("Workbar: bottom placement".to_string());
1046 } else {
1047 app.work_surface.placement =
1048 crate::tui::work_surface::WorkSurfacePlacement::Off;
1049 app.status_message = Some("Workbar is off".to_string());
1050 }
1051 app.needs_redraw = true;
1052 Ok(HotbarDispatch::Handled)
1053 }
1054 AppHotbarKind::FileTreeToggle => {
1055 if app.file_tree.is_some() {
1056 app.file_tree = None;
1057 app.status_message = Some("File tree closed".to_string());
1058 } else {
1059 app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(&app.workspace));
1060 app.status_message =
1061 Some("File tree: ↑/↓ navigate Enter select Esc close".to_string());
1062 }
1063 app.needs_redraw = true;
1064 Ok(HotbarDispatch::Handled)
1065 }
1066 AppHotbarKind::PaletteOpen => {
1067 app.view_stack.push(CommandPaletteView::new_for_locale(
1068 app.ui_locale,
1069 build_command_palette_entries(
1070 app.ui_locale,
1071 &app.skills_dir,
1072 app.skills_discovery_mode,
1073 &app.workspace,
1074 &app.mcp_config_path,
1075 app.mcp_snapshot.as_ref(),
1076 app.extension_plugin_view().as_ref(),
1077 ),
1078 ));
1079 Ok(HotbarDispatch::Handled)
1080 }
1081 AppHotbarKind::TrustToggle => {
1082 app.trust_mode = !app.trust_mode;
1083 app.status_message = Some(if app.trust_mode {
1084 "Workspace trust mode enabled.".to_string()
1085 } else {
1086 "Workspace trust mode disabled.".to_string()
1087 });
1088 Ok(HotbarDispatch::Handled)
1089 }
1090 }
1091 }
1092 }
1093
1094 struct SlashHotbarAction {
1095 info: &'static CommandInfo,
1096 id: String,
1097 short_label: String,
1098 }
1099
1100 impl SlashHotbarAction {
1101 fn new(info: &'static CommandInfo) -> Self {
1102 let short_label = match info.name {
1103 "workflow" => "wf".to_string(),
1104 other => other.chars().take(7).collect(),
1105 };
1106 Self {
1107 info,
1108 id: format!("slash.{}", info.name),
1109 short_label,
1110 }
1111 }
1112
1113 fn prefill_composer(&self, app: &mut App) {
1114 app.clear_input_recoverable();
1115 app.input = format!("/{} ", self.info.name);
1116 app.cursor_position = app.input.chars().count();
1117 app.slash_menu_hidden = false;
1118 app.needs_redraw = true;
1119 app.status_message = Some(format!(
1120 "Command needs arguments; complete {}",
1121 app.input.trim_end()
1122 ));
1123 }
1124 }
1125
1126 impl HotbarAction for SlashHotbarAction {
1127 fn id(&self) -> &str {
1128 &self.id
1129 }
1130
1131 fn metadata(&self, locale: Locale) -> HotbarActionMetadata {
1132 let recommendation = if codewhale_config::DEFAULT_HOTBAR_ACTIONS.contains(&self.id.as_str())
1133 {
1134 HotbarRecommendation::Default
1135 } else {
1136 match self.info.discovery() {
1137 crate::commands::traits::CommandDiscovery::Primary => {
1138 HotbarRecommendation::Eligible
1139 }
1140 crate::commands::traits::CommandDiscovery::Advanced
1141 | crate::commands::traits::CommandDiscovery::Compatibility => {
1142 HotbarRecommendation::Advanced
1143 }
1144 }
1145 };
1146 HotbarActionMetadata {
1147 id: self.id.clone(),
1148 source_id: format!("command:{}", self.info.name),
1149 display_name: format!("/{}", self.info.name),
1150 compact_label: self.short_label.clone(),
1151 description: self.info.description_for(locale).to_string(),
1152 category: HotbarActionCategory::Slash,
1153 args: HotbarArgsBehavior::for_command(self.info),
1154 safety: HotbarSafetyClass::ExistingCommand,
1155 recommendation,
1156 }
1157 }
1158
1159 fn short_label(&self) -> &str {
1160 &self.short_label
1161 }
1162
1163 fn category(&self) -> &str {
1164 "slash"
1165 }
1166
1167 fn is_active(&self, _app: &App) -> bool {
1168 false
1169 }
1170
1171 fn dispatch(&self, app: &mut App, config: &Config) -> Result<HotbarDispatch> {
1172 if self.info.requires_required_argument() {
1173 self.prefill_composer(app);
1174 return Ok(HotbarDispatch::Handled);
1175 }
1176
1177 let input = format!("/{}", self.info.name);
1178 let result = commands::execute_with_config(&input, app, config);
1179 Ok(dispatch_command_result(app, result))
1180 }
1181 }
1182
1183 struct RouteHotbarAction {
1184 identity: ProviderIdentity,
1185 model: String,
1186 id: String,
1187 short_label: String,
1188 }
1189
1190 impl RouteHotbarAction {
1191 fn new(identity: ProviderIdentity, model: String) -> Self {
1192 let trimmed_model = model.trim().to_string();
1193 Self {
1194 id: route_action_id(&identity, &trimmed_model),
1195 short_label: crate::tui::ui_text::truncate_line_to_width(
1196 identity.key.as_str(),
1197 HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1198 ),
1199 model: trimmed_model,
1200 identity,
1201 }
1202 }
1203 }
1204
1205 impl HotbarAction for RouteHotbarAction {
1206 fn id(&self) -> &str {
1207 &self.id
1208 }
1209
1210 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1211 HotbarActionMetadata {
1212 id: self.id.clone(),
1213 source_id: format!("route:{}", self.identity.key.as_str()),
1214 display_name: format!(
1215 "{} · {}",
1216 self.identity
1217 .compatibility()
1218 .map_or(self.identity.key.as_str(), |row| row.label),
1219 self.model
1220 ),
1221 compact_label: self.short_label.clone(),
1222 description: format!(
1223 "Switch to {} on {} through the existing /model route path.",
1224 self.model,
1225 self.identity
1226 .compatibility()
1227 .map_or(self.identity.key.as_str(), |row| row.label)
1228 ),
1229 category: HotbarActionCategory::Route,
1230 args: HotbarArgsBehavior::None,
1231 safety: HotbarSafetyClass::ConfigChange,
1232 recommendation: HotbarRecommendation::Eligible,
1233 }
1234 }
1235
1236 fn short_label(&self) -> &str {
1237 &self.short_label
1238 }
1239
1240 fn category(&self) -> &str {
1241 "route"
1242 }
1243
1244 fn is_active(&self, app: &App) -> bool {
1245 !app.auto_model
1246 && app.provider_identity.as_ref() == Some(&self.identity)
1247 && if self.identity.provider == ProviderKind::Custom {
1248 app.model.trim() == self.model.trim()
1249 } else {
1250 app.model.trim().eq_ignore_ascii_case(self.model.trim())
1251 }
1252 }
1253
1254 fn dispatch(&self, _app: &mut App, _config: &Config) -> Result<HotbarDispatch> {
1255 Ok(HotbarDispatch::AppAction(AppAction::SwitchModelRoute {
1256 identity: self.identity.clone(),
1257 model: self.model.clone(),
1258 }))
1259 }
1260 }
1261
1262 struct SkillHotbarAction {
1263 name: String,
1264 id: String,
1265 short_label: String,
1266 description: String,
1267 }
1268
1269 impl SkillHotbarAction {
1270 fn new(name: &str, description: &str) -> Self {
1271 let description = description.trim();
1272 Self {
1273 name: name.to_string(),
1274 id: format!("skill.{name}"),
1275 short_label: crate::tui::ui_text::truncate_line_to_width(
1276 name,
1277 HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1278 ),
1279 description: if description.is_empty() {
1280 format!("Activate the {name} skill for the next message.")
1281 } else {
1282 description.to_string()
1283 },
1284 }
1285 }
1286 }
1287
1288 impl HotbarAction for SkillHotbarAction {
1289 fn id(&self) -> &str {
1290 &self.id
1291 }
1292
1293 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1294 HotbarActionMetadata {
1295 id: self.id.clone(),
1296 source_id: format!("skill:{}", self.name),
1297 display_name: format!("${}", self.name),
1298 compact_label: self.short_label.clone(),
1299 description: self.description.clone(),
1300 category: HotbarActionCategory::Skill,
1301 args: HotbarArgsBehavior::None,
1302 safety: HotbarSafetyClass::ExistingCommand,
1303 recommendation: HotbarRecommendation::Eligible,
1304 }
1305 }
1306
1307 fn short_label(&self) -> &str {
1308 &self.short_label
1309 }
1310
1311 fn category(&self) -> &str {
1312 "skill"
1313 }
1314
1315 fn is_active(&self, app: &App) -> bool {
1316 // `activate_skill` stores the full instruction block; the heading line
1317 // inside it is the stable marker for which skill is armed.
1318 app.active_skill
1319 .as_deref()
1320 .is_some_and(|instruction| instruction.contains(&format!("# Skill: {}\n", self.name)))
1321 }
1322
1323 fn dispatch(&self, app: &mut App, config: &Config) -> Result<HotbarDispatch> {
1324 // Same path as typing `$<name>`: activates the skill for the next
1325 // message (local state plus a visible receipt cell); nothing is sent
1326 // to the model until the user submits a message.
1327 let input = format!("${}", self.name);
1328 let result = commands::execute_with_config(&input, app, config);
1329 Ok(dispatch_command_result(app, result))
1330 }
1331 }
1332
1333 struct McpToolHotbarAction {
1334 server: String,
1335 tool_name: String,
1336 model_name: String,
1337 description: Option<String>,
1338 id: String,
1339 short_label: String,
1340 }
1341
1342 impl McpToolHotbarAction {
1343 fn new(server: &str, tool: &crate::mcp::McpDiscoveredItem) -> Self {
1344 Self {
1345 server: server.to_string(),
1346 tool_name: tool.name.clone(),
1347 model_name: tool.model_name.trim().to_string(),
1348 description: tool.description.clone(),
1349 id: format!("mcp.{server}.{}", tool.name),
1350 short_label: crate::tui::ui_text::truncate_line_to_width(
1351 &tool.name,
1352 HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1353 ),
1354 }
1355 }
1356
1357 fn prefill_composer(&self, app: &mut App) {
1358 app.clear_input_recoverable();
1359 app.input = format!("{} ", self.model_name);
1360 app.cursor_position = app.input.chars().count();
1361 app.needs_redraw = true;
1362 app.status_message = Some(format!(
1363 "MCP tool needs a request; complete {}",
1364 app.input.trim_end()
1365 ));
1366 }
1367 }
1368
1369 impl HotbarAction for McpToolHotbarAction {
1370 fn id(&self) -> &str {
1371 &self.id
1372 }
1373
1374 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1375 let description = match self.description.as_deref().map(str::trim) {
1376 Some(desc) if !desc.is_empty() => {
1377 format!("Prefill the composer with {} — {desc}", self.model_name)
1378 }
1379 _ => format!(
1380 "Prefill the composer with {}; the call still runs through tool approval.",
1381 self.model_name
1382 ),
1383 };
1384 HotbarActionMetadata {
1385 id: self.id.clone(),
1386 source_id: format!("mcp:{}", self.server),
1387 display_name: format!("mcp:{}:{}", self.server, self.tool_name),
1388 compact_label: self.short_label.clone(),
1389 description,
1390 category: HotbarActionCategory::Mcp,
1391 args: HotbarArgsBehavior::Required,
1392 safety: HotbarSafetyClass::RequiresApproval,
1393 recommendation: HotbarRecommendation::Advanced,
1394 }
1395 }
1396
1397 fn short_label(&self) -> &str {
1398 &self.short_label
1399 }
1400
1401 fn category(&self) -> &str {
1402 "mcp"
1403 }
1404
1405 fn is_active(&self, _app: &App) -> bool {
1406 false
1407 }
1408
1409 fn dispatch(&self, app: &mut App, _config: &Config) -> Result<HotbarDispatch> {
1410 // Never execute the tool from the hotbar: prefill the composer with
1411 // the model-visible tool name (same text the command palette's
1412 // "> use" entry inserts) and let the user describe the call. The
1413 // eventual invocation stays behind the normal tool approval flow.
1414 self.prefill_composer(app);
1415 Ok(HotbarDispatch::Handled)
1416 }
1417 }
1418
1419 fn configured_route_models_for_provider(
1420 config: &Config,
1421 identity: &ProviderIdentity,
1422 active: &ProviderIdentity,
1423 active_model: &str,
1424 provider_models: &HashMap<String, String>,
1425 ) -> Vec<String> {
1426 let mut models = Vec::new();
1427 if identity == active {
1428 push_route_model(&mut models, active_model);
1429 }
1430 if let Some(model) = provider_models.get(identity.key.as_str()) {
1431 push_route_model(&mut models, model);
1432 }
1433 if let Some(model) = config
1434 .provider_config_for(identity)
1435 .and_then(|entry| entry.model.as_deref())
1436 {
1437 push_route_model(&mut models, model);
1438 }
1439 for model in models_for_provider(config, identity)
1440 .into_iter()
1441 .filter(|model| !model.trim().eq_ignore_ascii_case("auto"))
1442 .take(1)
1443 {
1444 push_route_model(&mut models, &model);
1445 }
1446 models
1447 }
1448
1449 fn push_route_model(models: &mut Vec<String>, model: &str) {
1450 let trimmed = model.trim();
1451 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
1452 return;
1453 }
1454 if models.iter().any(|existing| existing == trimmed) {
1455 return;
1456 }
1457 models.push(trimmed.to_string());
1458 }
1459
1460 fn route_action_id(identity: &ProviderIdentity, model: &str) -> String {
1461 format!("route.{}.{}", identity.key, model.trim())
1462 }
1463
1464 #[cfg(test)]
1465 mod tests {
1466 use std::collections::{BTreeSet, HashMap};
1467 use std::path::PathBuf;
1468
1469 use crate::config::{Config, ProviderKind};
1470 use crate::reasoning_preference::ReasoningEffort;
1471 use crate::tui::app::TuiOptions;
1472 use crate::tui::views::ModalKind;
1473
1474 use super::*;
1475
1476 fn test_app_with_paths_and_config(
1477 workspace: PathBuf,
1478 skills_dir: PathBuf,
1479 config: &Config,
1480 ) -> App {
1481 let options = TuiOptions {
1482 skills_dir,
1483 start_in_agent_mode: true,
1484 ..crate::test_support::test_tui_options(workspace)
1485 };
1486 let mut app = App::new(options, config);
1487 app.ui_locale = codewhale_localization::Locale::En;
1488 app
1489 }
1490
1491 fn test_app_with_paths(workspace: PathBuf, skills_dir: PathBuf) -> App {
1492 test_app_with_paths_and_config(workspace, skills_dir, &Config::default())
1493 }
1494
1495 fn test_app() -> App {
1496 test_app_with_paths(PathBuf::from("."), PathBuf::from("."))
1497 }
1498
1499 fn test_mcp_snapshot() -> crate::mcp::McpManagerSnapshot {
1500 use crate::mcp::{McpDiscoveredItem, McpManagerSnapshot, McpServerSnapshot};
1501 let server = |name: &str, enabled: bool, tools: Vec<McpDiscoveredItem>| McpServerSnapshot {
1502 name: name.to_string(),
1503 enabled,
1504 required: false,
1505 transport: "stdio".to_string(),
1506 command_or_url: format!("{name}-server"),
1507 connect_timeout: 5,
1508 execute_timeout: 5,
1509 read_timeout: 5,
1510 connected: enabled,
1511 error: None,
1512 auth_required: false,
1513 capability_metadata: if enabled {
1514 crate::mcp::McpServerCapabilityMetadata::LegacyFallback
1515 } else {
1516 crate::mcp::McpServerCapabilityMetadata::NotObserved
1517 },
1518 tools,
1519 resources: Vec::new(),
1520 prompts: Vec::new(),
1521 };
1522 McpManagerSnapshot {
1523 config_path: PathBuf::from("mcp.json"),
1524 config_exists: true,
1525 reload_required: false,
1526 servers: vec![
1527 server(
1528 "search",
1529 true,
1530 vec![
1531 McpDiscoveredItem {
1532 name: "web_search".to_string(),
1533 model_name: "mcp_search_web_search".to_string(),
1534 description: Some("Search the web".to_string()),
1535 },
1536 McpDiscoveredItem {
1537 name: "broken".to_string(),
1538 model_name: " ".to_string(),
1539 description: None,
1540 },
1541 ],
1542 ),
1543 server(
1544 "offline",
1545 false,
1546 vec![McpDiscoveredItem {
1547 name: "other_tool".to_string(),
1548 model_name: "mcp_offline_other_tool".to_string(),
1549 description: None,
1550 }],
1551 ),
1552 ],
1553 }
1554 }
1555
1556 struct TestHotbarAction {
1557 id: &'static str,
1558 }
1559
1560 impl HotbarAction for TestHotbarAction {
1561 fn id(&self) -> &str {
1562 self.id
1563 }
1564
1565 fn metadata(&self, _locale: Locale) -> HotbarActionMetadata {
1566 HotbarActionMetadata {
1567 id: self.id.to_string(),
1568 source_id: "test".to_string(),
1569 display_name: "Test action".to_string(),
1570 compact_label: "test".to_string(),
1571 description: "Test action descriptor".to_string(),
1572 category: HotbarActionCategory::App,
1573 args: HotbarArgsBehavior::None,
1574 safety: HotbarSafetyClass::LocalUi,
1575 recommendation: HotbarRecommendation::Eligible,
1576 }
1577 }
1578
1579 fn short_label(&self) -> &str {
1580 "test"
1581 }
1582
1583 fn category(&self) -> &str {
1584 "app"
1585 }
1586
1587 fn is_active(&self, _app: &App) -> bool {
1588 false
1589 }
1590
1591 fn dispatch(&self, _app: &mut App, _config: &Config) -> Result<HotbarDispatch> {
1592 Ok(HotbarDispatch::Handled)
1593 }
1594 }
1595
1596 struct DeferredTestHotbarSource;
1597
1598 impl HotbarActionSource for DeferredTestHotbarSource {
1599 fn descriptor(&self) -> HotbarSourceDescriptor {
1600 HOTBAR_SOURCE_DESCRIPTORS
1601 .iter()
1602 .copied()
1603 .find(|descriptor| descriptor.category == HotbarActionCategory::Plugin)
1604 .expect("plugin descriptor exists")
1605 }
1606
1607 fn register_actions(&self, registry: &mut HotbarActionRegistry) {
1608 registry.register(TestHotbarAction {
1609 id: "plugin.deferred-test",
1610 });
1611 }
1612 }
1613
1614 #[test]
1615 #[should_panic(expected = "duplicate hotbar action id duplicate.action")]
1616 fn registry_rejects_duplicate_action_ids() {
1617 let mut registry = HotbarActionRegistry::new();
1618 registry.register(TestHotbarAction {
1619 id: "duplicate.action",
1620 });
1621 registry.register(TestHotbarAction {
1622 id: "duplicate.action",
1623 });
1624 }
1625
1626 #[test]
1627 fn registry_metadata_contract_covers_registered_actions() {
1628 let registry = HotbarActionRegistry::with_builtins();
1629 let errors = registry.metadata_validation_errors(Locale::En);
1630 assert!(errors.is_empty(), "metadata validation failed: {errors:?}");
1631
1632 let metadata = registry.metadata(Locale::En);
1633 assert_eq!(metadata.len(), registry.len());
1634
1635 let ids = metadata
1636 .iter()
1637 .map(|entry| entry.id.as_str())
1638 .collect::<Vec<_>>();
1639 let mut sorted_ids = ids.clone();
1640 sorted_ids.sort_unstable();
1641 assert_eq!(
1642 ids, sorted_ids,
1643 "registry metadata should have stable id order"
1644 );
1645 assert_eq!(
1646 ids.iter().copied().collect::<BTreeSet<_>>().len(),
1647 ids.len(),
1648 "metadata ids must be unique"
1649 );
1650
1651 for entry in metadata {
1652 assert_eq!(
1653 HotbarActionCategory::parse(entry.category.as_str()),
1654 Some(entry.category)
1655 );
1656 let entry_errors = entry.validation_errors();
1657 assert!(
1658 entry_errors.is_empty(),
1659 "metadata entry failed validation: {entry_errors:?}"
1660 );
1661 assert!(
1662 unicode_width::UnicodeWidthStr::width(entry.compact_label.as_str())
1663 <= HOTBAR_COMPACT_LABEL_MAX_WIDTH,
1664 "compact label should be validated: {entry:?}"
1665 );
1666 }
1667 }
1668
1669 #[test]
1670 fn source_descriptors_cover_dispatch_boundaries() {
1671 let descriptors = hotbar_source_descriptors();
1672 let categories = descriptors
1673 .iter()
1674 .map(|descriptor| descriptor.category)
1675 .collect::<BTreeSet<_>>();
1676
1677 assert_eq!(
1678 categories,
1679 BTreeSet::from([
1680 HotbarActionCategory::App,
1681 HotbarActionCategory::Route,
1682 HotbarActionCategory::Slash,
1683 HotbarActionCategory::Mcp,
1684 HotbarActionCategory::Skill,
1685 HotbarActionCategory::Plugin,
1686 ])
1687 );
1688 assert_eq!(
1689 descriptors
1690 .iter()
1691 .find(|descriptor| descriptor.category == HotbarActionCategory::App)
1692 .map(|descriptor| (
1693 descriptor.boundary,
1694 descriptor.safety_modes,
1695 descriptor.registers_dispatchable_actions()
1696 )),
1697 Some((
1698 HotbarSourceDispatchBoundary::DirectApp,
1699 HOTBAR_DIRECT_APP_SAFETY,
1700 true
1701 ))
1702 );
1703 assert_eq!(
1704 descriptors
1705 .iter()
1706 .find(|descriptor| descriptor.category == HotbarActionCategory::Route)
1707 .map(|descriptor| (
1708 descriptor.boundary,
1709 descriptor.safety_modes,
1710 descriptor.registers_dispatchable_actions()
1711 )),
1712 Some((
1713 HotbarSourceDispatchBoundary::ModelRoute,
1714 HOTBAR_ROUTE_SAFETY,
1715 true
1716 ))
1717 );
1718 assert_eq!(
1719 descriptors
1720 .iter()
1721 .find(|descriptor| descriptor.category == HotbarActionCategory::Slash)
1722 .map(|descriptor| (
1723 descriptor.boundary,
1724 descriptor.safety_modes,
1725 descriptor.registers_dispatchable_actions()
1726 )),
1727 Some((
1728 HotbarSourceDispatchBoundary::SlashCommand,
1729 HOTBAR_SLASH_SAFETY,
1730 true
1731 ))
1732 );
1733 assert_eq!(
1734 descriptors
1735 .iter()
1736 .find(|descriptor| descriptor.category == HotbarActionCategory::Mcp)
1737 .map(|descriptor| (
1738 descriptor.boundary,
1739 descriptor.safety_modes,
1740 descriptor.registers_dispatchable_actions()
1741 )),
1742 Some((
1743 HotbarSourceDispatchBoundary::ComposerPrefill,
1744 HOTBAR_MCP_SAFETY,
1745 true
1746 ))
1747 );
1748 assert_eq!(
1749 descriptors
1750 .iter()
1751 .find(|descriptor| descriptor.category == HotbarActionCategory::Skill)
1752 .map(|descriptor| (
1753 descriptor.boundary,
1754 descriptor.safety_modes,
1755 descriptor.registers_dispatchable_actions()
1756 )),
1757 Some((
1758 HotbarSourceDispatchBoundary::SlashCommand,
1759 HOTBAR_SKILL_SAFETY,
1760 true
1761 ))
1762 );
1763 let plugin = descriptors
1764 .iter()
1765 .find(|descriptor| descriptor.category == HotbarActionCategory::Plugin)
1766 .expect("missing descriptor for Plugin");
1767 assert_eq!(plugin.boundary, HotbarSourceDispatchBoundary::Deferred);
1768 assert_eq!(plugin.safety_modes, HOTBAR_DEFERRED_SAFETY);
1769 assert_eq!(plugin.status, "exploratory");
1770 assert!(
1771 !plugin.registers_dispatchable_actions(),
1772 "deferred Plugin source must not be dispatchable"
1773 );
1774 }
1775
1776 #[test]
1777 #[should_panic(
1778 expected = "deferred hotbar source Plugin must not register dispatchable actions"
1779 )]
1780 fn deferred_sources_cannot_register_dispatchable_actions() {
1781 let mut registry = HotbarActionRegistry::new();
1782 registry.register_source(&DeferredTestHotbarSource);
1783 }
1784
1785 #[test]
1786 fn source_adapters_register_previous_default_registry_surface() {
1787 let mut registry = HotbarActionRegistry::new();
1788 registry.register_source(&BuiltinHotbarActionSource);
1789 registry.register_source(&SlashCommandHotbarActionSource);
1790
1791 let adapter_ids = registry
1792 .iter()
1793 .map(|action| action.id().to_string())
1794 .collect::<Vec<_>>();
1795 let default_ids = HotbarActionRegistry::with_builtins()
1796 .iter()
1797 .map(|action| action.id().to_string())
1798 .collect::<Vec<_>>();
1799
1800 assert_eq!(adapter_ids, default_ids);
1801 assert_eq!(
1802 BuiltinHotbarActionSource.descriptor().category,
1803 HotbarActionCategory::App
1804 );
1805 assert_eq!(
1806 SlashCommandHotbarActionSource.descriptor().category,
1807 HotbarActionCategory::Slash
1808 );
1809 }
1810
1811 #[test]
1812 fn slash_source_matches_command_palette_command_entries() {
1813 // Hermetic: the palette builder also reads `~/.claude/skills`, so a
1814 // host with imported Claude skills would leak entries into one side
1815 // and fail the comparison. Isolate HOME the way the other
1816 // environment-reading tests in this file do.
1817 let _lock = crate::test_support::lock_test_env();
1818 let tmp = tempfile::TempDir::new().expect("tempdir");
1819 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
1820 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
1821 let _codewhale_home =
1822 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
1823 let palette_slash_ids = build_command_palette_entries(
1824 Locale::En,
1825 tmp.path(),
1826 crate::skills::SkillDiscoveryMode::CodeWhaleOnly,
1827 tmp.path(),
1828 &tmp.path().join("mcp.json"),
1829 None,
1830 &crate::plugins::PluginRegistry::empty(tmp.path()),
1831 )
1832 .into_iter()
1833 .filter(|entry| entry.section() == crate::tui::command_palette::PaletteSection::Command)
1834 .filter_map(|entry| {
1835 entry
1836 .label
1837 .strip_prefix('/')
1838 .map(|name| format!("slash.{name}"))
1839 })
1840 .collect::<BTreeSet<_>>();
1841
1842 let mut registry = HotbarActionRegistry::new();
1843 registry.register_source(&SlashCommandHotbarActionSource);
1844 let hotbar_slash_ids = registry
1845 .iter()
1846 .map(|action| action.id().to_string())
1847 .collect::<BTreeSet<_>>();
1848
1849 // The hotbar is a binding substrate and registers every command; the
1850 // palette is a browsing surface and omits the unlisted ones. So the
1851 // palette is a subset, and the difference is exactly the unlisted set.
1852 let unlisted_ids = commands::command_infos()
1853 .iter()
1854 .filter(|info| info.is_unlisted())
1855 .map(|info| format!("slash.{}", info.name))
1856 .collect::<BTreeSet<_>>();
1857 assert!(
1858 palette_slash_ids.is_subset(&hotbar_slash_ids),
1859 "the palette must not offer a command the hotbar cannot bind"
1860 );
1861 assert_eq!(
1862 hotbar_slash_ids
1863 .difference(&palette_slash_ids)
1864 .cloned()
1865 .collect::<BTreeSet<_>>(),
1866 unlisted_ids,
1867 "the only commands the hotbar has and the palette hides are the unlisted ones"
1868 );
1869 }
1870
1871 #[test]
1872 fn default_hotbar_actions_have_registered_default_metadata() {
1873 let registry = HotbarActionRegistry::with_builtins();
1874
1875 for id in codewhale_config::DEFAULT_HOTBAR_ACTIONS {
1876 let action = registry
1877 .get(id)
1878 .unwrap_or_else(|| panic!("missing default hotbar action {id}"));
1879 let metadata = action.metadata(Locale::En);
1880 if id.starts_with("slash.") {
1881 assert_eq!(metadata.category, HotbarActionCategory::Slash);
1882 } else {
1883 assert_eq!(metadata.category, HotbarActionCategory::App);
1884 assert_eq!(metadata.args, HotbarArgsBehavior::None);
1885 }
1886 assert_eq!(metadata.recommendation, HotbarRecommendation::Default);
1887 assert!(
1888 metadata.recommendation.is_recommendable(),
1889 "default action must be recommendable: {metadata:?}"
1890 );
1891 assert!(!metadata.display_name.trim().is_empty());
1892 assert!(!metadata.description.trim().is_empty());
1893 }
1894 }
1895
1896 #[test]
1897 fn slash_action_metadata_describes_args_and_recommendations() {
1898 let registry = HotbarActionRegistry::with_builtins();
1899
1900 let compact = registry
1901 .get("slash.compact")
1902 .expect("compact slash action")
1903 .metadata(Locale::En);
1904 assert_eq!(compact.category, HotbarActionCategory::Slash);
1905 assert_eq!(compact.source_id, "command:compact");
1906 assert_eq!(compact.display_name, "/compact");
1907 // `/compact [focus]` takes an optional summary focus (2026-07-23).
1908 assert_eq!(compact.args, HotbarArgsBehavior::Optional);
1909 assert_eq!(compact.safety, HotbarSafetyClass::ExistingCommand);
1910 assert_eq!(compact.recommendation, HotbarRecommendation::Eligible);
1911
1912 let mode = registry
1913 .get("slash.mode")
1914 .expect("mode slash action")
1915 .metadata(Locale::En);
1916 assert_eq!(mode.args, HotbarArgsBehavior::Optional);
1917
1918 let rename = registry
1919 .get("slash.rename")
1920 .expect("rename slash action")
1921 .metadata(Locale::En);
1922 assert_eq!(rename.args, HotbarArgsBehavior::Required);
1923 assert_eq!(rename.recommendation, HotbarRecommendation::Advanced);
1924 }
1925
1926 #[test]
1927 fn reasoning_action_remains_available_for_auto_model_routing() {
1928 let registry = HotbarActionRegistry::with_builtins();
1929 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
1930 let mut app = test_app();
1931
1932 let metadata = reasoning.metadata(Locale::En);
1933 assert_eq!(metadata.category, HotbarActionCategory::App);
1934 assert_eq!(metadata.safety, HotbarSafetyClass::LocalState);
1935 assert_eq!(metadata.recommendation, HotbarRecommendation::Eligible);
1936 assert!(reasoning.disabled_reason(&app).is_none());
1937
1938 app.auto_model = true;
1939 assert!(reasoning.disabled_reason(&app).is_none());
1940 }
1941
1942 #[test]
1943 fn hotbar_recommendations_default_to_stable_slot_order() {
1944 let app = test_app();
1945
1946 let recommendations =
1947 recommend_hotbar_actions(&app, HotbarRecommendationOptions::default());
1948
1949 assert_eq!(
1950 recommendations
1951 .iter()
1952 .map(|entry| entry.metadata.id.as_str())
1953 .collect::<Vec<_>>(),
1954 codewhale_config::DEFAULT_HOTBAR_ACTIONS
1955 );
1956 assert!(recommendations.iter().all(|entry| {
1957 entry.metadata.recommendation == HotbarRecommendation::Default
1958 && entry.disabled_reason.is_none()
1959 }));
1960 }
1961
1962 #[test]
1963 fn hotbar_recommendations_keep_reasoning_for_auto_model() {
1964 let mut app = test_app();
1965 app.auto_model = true;
1966
1967 let recommendations =
1968 recommend_hotbar_actions(&app, HotbarRecommendationOptions::for_setup_wizard());
1969
1970 assert!(
1971 recommendations
1972 .iter()
1973 .any(|entry| entry.metadata.id == "reasoning.cycle")
1974 );
1975 }
1976
1977 #[test]
1978 fn hotbar_recommendations_exclude_required_args_by_default() {
1979 let app = test_app();
1980
1981 let recommendations =
1982 recommend_hotbar_actions(&app, HotbarRecommendationOptions::for_setup_wizard());
1983
1984 assert!(
1985 !recommendations
1986 .iter()
1987 .any(|entry| entry.metadata.id == "slash.rename")
1988 );
1989 }
1990
1991 #[test]
1992 fn hotbar_recommendations_limit_eligible_actions_by_category() {
1993 let app = test_app();
1994 let recommendations = recommend_hotbar_actions(
1995 &app,
1996 HotbarRecommendationOptions {
1997 max_total: usize::MAX,
1998 max_eligible_per_category: 1,
1999 include_required_args: false,
2000 },
2001 );
2002
2003 for default_id in codewhale_config::DEFAULT_HOTBAR_ACTIONS {
2004 assert!(
2005 recommendations
2006 .iter()
2007 .any(|entry| entry.metadata.id == default_id),
2008 "default recommendation {default_id} should not be category-capped"
2009 );
2010 }
2011 let slash_recommendations = recommendations
2012 .iter()
2013 .filter(|entry| entry.metadata.category == HotbarActionCategory::Slash)
2014 .collect::<Vec<_>>();
2015 let default_slash = slash_recommendations
2016 .iter()
2017 .filter(|entry| entry.metadata.recommendation == HotbarRecommendation::Default)
2018 .count();
2019 let eligible_slash = slash_recommendations
2020 .iter()
2021 .filter(|entry| entry.metadata.recommendation == HotbarRecommendation::Eligible)
2022 .count();
2023 assert_eq!(default_slash, 3);
2024 assert_eq!(eligible_slash, 1);
2025 }
2026
2027 #[test]
2028 fn recommended_hotbar_bindings_serialize_action_ids_and_labels() {
2029 let app = test_app();
2030
2031 let bindings = recommended_hotbar_bindings(&app, HotbarRecommendationOptions::default());
2032
2033 assert_eq!(
2034 bindings
2035 .iter()
2036 .map(|binding| binding.action.as_str())
2037 .collect::<Vec<_>>(),
2038 codewhale_config::DEFAULT_HOTBAR_ACTIONS
2039 );
2040 assert_eq!(
2041 bindings
2042 .iter()
2043 .map(|binding| (binding.slot, binding.label.as_deref()))
2044 .collect::<Vec<_>>(),
2045 vec![
2046 (1, Some("wf")),
2047 (2, Some("goal")),
2048 (3, Some("auto")),
2049 (4, Some("plan")),
2050 (5, Some("agent")),
2051 (6, Some("operate")),
2052 (7, Some("palette")),
2053 (8, Some("side")),
2054 ]
2055 );
2056
2057 let config = codewhale_config::ConfigToml {
2058 hotbar: Some(bindings.clone()),
2059 ..Default::default()
2060 };
2061 let serialized = toml::to_string_pretty(&config).expect("serialize hotbar recommendations");
2062 let round_tripped: codewhale_config::ConfigToml =
2063 toml::from_str(&serialized).expect("deserialize hotbar recommendations");
2064 assert_eq!(round_tripped.hotbar, Some(bindings));
2065 }
2066
2067 #[test]
2068 fn builtins_register_expected_actions() {
2069 let mut registry = HotbarActionRegistry::new();
2070 registry.register_builtins();
2071 let ids = registry.iter().map(HotbarAction::id).collect::<Vec<_>>();
2072
2073 assert_eq!(
2074 ids,
2075 vec![
2076 "filetree.toggle",
2077 "mode.agent",
2078 "mode.operate",
2079 "mode.plan",
2080 "palette.open",
2081 "reasoning.cycle",
2082 "session.compact",
2083 "sidebar.toggle",
2084 "trust.toggle",
2085 "voice.toggle",
2086 ]
2087 );
2088 assert!(registry.get("missing.action").is_none());
2089 for action in registry.iter() {
2090 assert_eq!(action.category(), "app");
2091 assert!(
2092 unicode_width::UnicodeWidthStr::width(action.short_label())
2093 <= HOTBAR_COMPACT_LABEL_MAX_WIDTH,
2094 "{} has an overlong short label",
2095 action.id()
2096 );
2097 }
2098 }
2099
2100 #[test]
2101 fn app_starts_with_builtin_hotbar_registry() {
2102 let app = test_app();
2103 assert!(app.hotbar_actions.len() > HotbarActionRegistry::with_builtins().len());
2104 assert!(app.hotbar_actions.get("mode.agent").is_some());
2105 assert!(app.hotbar_actions.get("slash.help").is_some());
2106 assert!(app.hotbar_actions.get("slash.mode").is_some());
2107 assert!(
2108 app.hotbar_actions
2109 .iter()
2110 .any(|action| action.metadata(Locale::En).category == HotbarActionCategory::Route)
2111 );
2112 }
2113
2114 #[test]
2115 fn configured_routes_register_provider_model_actions() {
2116 let mut config = Config::default();
2117 config
2118 .provider_config_for_mut(
2119 &config
2120 .builtin_provider_identity(ProviderKind::Openrouter)
2121 .unwrap(),
2122 )
2123 .unwrap()
2124 .model = Some("anthropic/claude-sonnet-4".to_string());
2125 let mut provider_models = HashMap::new();
2126 provider_models.insert(
2127 ProviderKind::Openrouter.as_str().to_string(),
2128 "openai/gpt-4o".to_string(),
2129 );
2130 let registry = HotbarActionRegistry::with_configured_routes(
2131 &config,
2132 Some(&config.active_provider_identity().unwrap()),
2133 "deepseek-v4-pro",
2134 &provider_models,
2135 );
2136
2137 let active = registry
2138 .get("route.deepseek.deepseek-v4-pro")
2139 .expect("active DeepSeek route");
2140 assert_eq!(active.category(), "route");
2141 assert_eq!(
2142 active.metadata(Locale::En).safety,
2143 HotbarSafetyClass::ConfigChange
2144 );
2145
2146 let openrouter = registry
2147 .get("route.openrouter.anthropic/claude-sonnet-4")
2148 .expect("configured OpenRouter route");
2149 let metadata = openrouter.metadata(Locale::En);
2150 assert_eq!(metadata.category, HotbarActionCategory::Route);
2151 assert!(metadata.display_name.contains("OpenRouter"));
2152 assert!(metadata.display_name.contains("anthropic/claude-sonnet-4"));
2153
2154 let mut app = test_app();
2155 assert_eq!(
2156 openrouter
2157 .dispatch(&mut app, &config)
2158 .expect("dispatch route"),
2159 HotbarDispatch::AppAction(AppAction::SwitchModelRoute {
2160 identity: config
2161 .builtin_provider_identity(ProviderKind::Openrouter)
2162 .unwrap(),
2163 model: "anthropic/claude-sonnet-4".to_string(),
2164 })
2165 );
2166 }
2167
2168 #[test]
2169 fn slash_commands_register_as_hotbar_actions() {
2170 let registry = HotbarActionRegistry::with_builtins();
2171
2172 for info in commands::command_infos() {
2173 let action_id = format!("slash.{}", info.name);
2174 let action = registry
2175 .get(&action_id)
2176 .unwrap_or_else(|| panic!("missing slash hotbar action for /{}", info.name));
2177 assert_eq!(action.category(), "slash");
2178 assert!(!action.is_active(&test_app()));
2179 assert!(
2180 unicode_width::UnicodeWidthStr::width(action.short_label())
2181 <= HOTBAR_COMPACT_LABEL_MAX_WIDTH,
2182 "{action_id} has an overlong short label"
2183 );
2184 }
2185 }
2186
2187 /// #1888: the hotbar is not a control surface. It binds the owning slash
2188 /// command and dispatches it through `commands::execute` with no argument,
2189 /// so what runs is the slash surface — there is no hotbar verb table and
2190 /// no `ControlSurface::Hotbar` for a test to assert into existence.
2191 ///
2192 /// What must hold is narrower and real: every owning command is bound and
2193 /// directly dispatchable, and a bare press can only reach a verb that
2194 /// declares `hotbar_bare_dispatch` — which is necessarily targetless and
2195 /// read-only, because a keypress supplies no id.
2196 #[test]
2197 fn control_plane_commands_are_bound_and_bare_dispatch_is_read_only() {
2198 use codewhale_lane::control::OPERATIONS;
2199 use codewhale_lane::{ControlAuthority, ControlSurface, TargetKind};
2200
2201 let registry = HotbarActionRegistry::with_builtins();
2202 for descriptor in OPERATIONS {
2203 let action_id = descriptor.hotbar_action_id();
2204 assert_eq!(action_id, format!("slash.{}", descriptor.slash_command));
2205 let action = registry
2206 .get(&action_id)
2207 .unwrap_or_else(|| panic!("{} has no hotbar action {action_id}", descriptor.id));
2208 assert_eq!(action.category(), "slash");
2209 // The dispatch runs as the slash surface, which must therefore be
2210 // one the descriptor actually offers.
2211 assert!(descriptor.offers(ControlSurface::Slash));
2212
2213 // A bare hotbar press fires the command with no arguments, so the
2214 // owning command must never require one — otherwise the slot would
2215 // silently become a composer prefill instead of the verb.
2216 let info = commands::get_command_info(descriptor.slash_command)
2217 .unwrap_or_else(|| panic!("/{} is not registered", descriptor.slash_command));
2218 assert!(
2219 !info.requires_required_argument(),
2220 "/{} must stay directly dispatchable",
2221 descriptor.slash_command
2222 );
2223
2224 if descriptor.hotbar_bare_dispatch {
2225 assert_eq!(descriptor.target, TargetKind::None, "{}", descriptor.id);
2226 assert_eq!(
2227 descriptor.authority,
2228 ControlAuthority::Read,
2229 "{} would mutate durable state from one keypress",
2230 descriptor.id
2231 );
2232 }
2233 }
2234
2235 // Both control domains are bound.
2236 for id in ["slash.lane", "slash.fleet"] {
2237 assert!(registry.get(id).is_some(), "{id} must be bindable");
2238 }
2239 }
2240
2241 #[test]
2242 fn retired_slash_pod_binding_stays_unbound() {
2243 let registry = HotbarActionRegistry::with_builtins();
2244 assert!(
2245 registry.get("slash.pod").is_none(),
2246 "the retired pod id must not resolve to any action"
2247 );
2248 }
2249
2250 #[test]
2251 fn slash_hotbar_action_dispatches_argless_command() {
2252 let registry = HotbarActionRegistry::with_builtins();
2253 let mode = registry.get("slash.mode").expect("mode slash action");
2254 let mut app = test_app();
2255
2256 assert_eq!(
2257 mode.dispatch(&mut app, &Config::default())
2258 .expect("dispatch /mode"),
2259 HotbarDispatch::AppAction(AppAction::OpenModePicker)
2260 );
2261 assert!(app.input.is_empty());
2262 }
2263
2264 #[test]
2265 fn slash_hotbar_action_dispatches_optional_argument_command_with_no_args() {
2266 let registry = HotbarActionRegistry::with_builtins();
2267 let task = registry.get("slash.task").expect("task slash action");
2268 let mut app = test_app();
2269
2270 assert_eq!(
2271 task.dispatch(&mut app, &Config::default())
2272 .expect("dispatch /task"),
2273 HotbarDispatch::AppAction(AppAction::TaskList)
2274 );
2275 assert!(app.input.is_empty());
2276 }
2277
2278 #[test]
2279 fn slash_hotbar_action_prefills_required_argument_command() {
2280 let registry = HotbarActionRegistry::with_builtins();
2281 let rename = registry.get("slash.rename").expect("rename slash action");
2282 let mut app = test_app();
2283 app.input = "draft".to_string();
2284 app.cursor_position = app.input.chars().count();
2285
2286 assert_eq!(
2287 rename
2288 .dispatch(&mut app, &Config::default())
2289 .expect("dispatch /rename"),
2290 HotbarDispatch::Handled
2291 );
2292 assert_eq!(app.input, "/rename ");
2293 assert_eq!(app.cursor_position, app.input.chars().count());
2294 assert_eq!(app.clear_undo_buffer.as_deref(), Some("draft"));
2295 assert_eq!(
2296 app.status_message.as_deref(),
2297 Some("Command needs arguments; complete /rename")
2298 );
2299 }
2300
2301 #[test]
2302 fn skill_source_registers_known_skills_with_dedup() {
2303 let skills = vec![
2304 ("demo".to_string(), "Demo skill".to_string()),
2305 ("demo".to_string(), "Shadowed duplicate".to_string()),
2306 (" ".to_string(), "ignored blank name".to_string()),
2307 ];
2308 let mut registry = HotbarActionRegistry::new();
2309 registry.register_skills(&skills);
2310
2311 assert_eq!(registry.len(), 1);
2312 let action = registry.get("skill.demo").expect("skill action");
2313 assert_eq!(action.category(), "skill");
2314 let metadata = action.metadata(Locale::En);
2315 assert_eq!(metadata.category, HotbarActionCategory::Skill);
2316 assert_eq!(metadata.source_id, "skill:demo");
2317 assert_eq!(metadata.display_name, "$demo");
2318 assert_eq!(metadata.description, "Demo skill");
2319 assert_eq!(metadata.args, HotbarArgsBehavior::None);
2320 assert_eq!(metadata.safety, HotbarSafetyClass::ExistingCommand);
2321 assert_eq!(metadata.recommendation, HotbarRecommendation::Eligible);
2322 assert!(registry.metadata_validation_errors(Locale::En).is_empty());
2323 }
2324
2325 #[test]
2326 fn replacing_skills_removes_stale_plugin_actions_atomically() {
2327 let mut registry = HotbarActionRegistry::with_builtins();
2328 registry.register_skills(&[
2329 ("native".to_string(), "native Skill".to_string()),
2330 (
2331 "demo:review".to_string(),
2332 "reviewed plugin Skill".to_string(),
2333 ),
2334 ]);
2335 assert!(registry.get("skill.demo:review").is_some());
2336 let builtin_count = registry
2337 .iter()
2338 .filter(|action| action.category() != HotbarActionCategory::Skill.as_str())
2339 .count();
2340
2341 registry.replace_skills(&[("native".to_string(), "refreshed".to_string())]);
2342
2343 assert!(registry.get("skill.demo:review").is_none());
2344 assert!(registry.get("skill.native").is_some());
2345 assert_eq!(
2346 registry
2347 .iter()
2348 .filter(|action| action.category() != HotbarActionCategory::Skill.as_str())
2349 .count(),
2350 builtin_count,
2351 "refresh must preserve every non-Skill action source"
2352 );
2353 }
2354
2355 #[test]
2356 fn skill_hotbar_action_activates_skill_through_dollar_alias() {
2357 let workspace = tempfile::TempDir::new().expect("workspace");
2358 let skills_dir = tempfile::TempDir::new().expect("skills dir");
2359 let skill_dir = skills_dir.path().join("hotbar-demo-skill");
2360 std::fs::create_dir_all(&skill_dir).expect("skill dir");
2361 std::fs::write(
2362 skill_dir.join("SKILL.md"),
2363 "---\nname: hotbar-demo-skill\ndescription: Demo skill for hotbar tests\n---\n\nFollow the demo instructions.\n",
2364 )
2365 .expect("write SKILL.md");
2366 let config = Config {
2367 skills_dir: Some(skills_dir.path().to_string_lossy().into_owned()),
2368 ..Config::default()
2369 };
2370 let mut app = test_app_with_paths_and_config(
2371 workspace.path().to_path_buf(),
2372 skills_dir.path().to_path_buf(),
2373 &config,
2374 );
2375
2376 let action = app
2377 .hotbar_actions
2378 .get("skill.hotbar-demo-skill")
2379 .expect("skill registered from the startup skill cache");
2380 assert!(!action.is_active(&app));
2381 assert_eq!(
2382 action.dispatch(&mut app, &config).expect("dispatch skill"),
2383 HotbarDispatch::Handled
2384 );
2385 assert!(app.active_skill.is_some());
2386 assert!(action.is_active(&app));
2387 assert!(
2388 app.status_message
2389 .as_deref()
2390 .is_some_and(|message| message.contains("activated"))
2391 );
2392 }
2393
2394 #[test]
2395 fn skill_hotbar_action_reports_unknown_skill() {
2396 let workspace = tempfile::TempDir::new().expect("workspace");
2397 let skills_dir = tempfile::TempDir::new().expect("skills dir");
2398 let mut app = test_app_with_paths(
2399 workspace.path().to_path_buf(),
2400 skills_dir.path().to_path_buf(),
2401 );
2402
2403 let mut registry = HotbarActionRegistry::new();
2404 registry.register_skills(&[(
2405 "hotbar-skill-that-does-not-exist".to_string(),
2406 "Stale cache entry".to_string(),
2407 )]);
2408 let action = registry
2409 .get("skill.hotbar-skill-that-does-not-exist")
2410 .expect("stale skill action");
2411
2412 assert_eq!(
2413 action
2414 .dispatch(&mut app, &Config::default())
2415 .expect("dispatch stale skill"),
2416 HotbarDispatch::Handled
2417 );
2418 assert!(app.active_skill.is_none());
2419 assert!(
2420 app.status_message
2421 .as_deref()
2422 .is_some_and(|message| message.contains("Unknown skill"))
2423 );
2424 }
2425
2426 #[test]
2427 fn mcp_source_registers_enabled_server_tools_only() {
2428 let snapshot = test_mcp_snapshot();
2429 let mut registry = HotbarActionRegistry::new();
2430 registry.replace_mcp_tools(Some(&snapshot));
2431
2432 // Only the enabled server's tool with a model name registers: the
2433 // blank-model-name tool and the disabled server's tool never do.
2434 assert_eq!(registry.len(), 1);
2435 let action = registry
2436 .get("mcp.search.web_search")
2437 .expect("mcp tool action");
2438 assert_eq!(action.category(), "mcp");
2439 let metadata = action.metadata(Locale::En);
2440 assert_eq!(metadata.category, HotbarActionCategory::Mcp);
2441 assert_eq!(metadata.source_id, "mcp:search");
2442 assert_eq!(metadata.display_name, "mcp:search:web_search");
2443 assert!(metadata.description.contains("mcp_search_web_search"));
2444 assert!(metadata.description.contains("Search the web"));
2445 assert_eq!(metadata.args, HotbarArgsBehavior::Required);
2446 assert_eq!(metadata.safety, HotbarSafetyClass::RequiresApproval);
2447 assert_eq!(metadata.recommendation, HotbarRecommendation::Advanced);
2448 assert!(registry.metadata_validation_errors(Locale::En).is_empty());
2449 }
2450
2451 #[test]
2452 fn mcp_hotbar_action_prefills_composer_instead_of_executing() {
2453 let snapshot = test_mcp_snapshot();
2454 let mut registry = HotbarActionRegistry::new();
2455 registry.replace_mcp_tools(Some(&snapshot));
2456 let action = registry
2457 .get("mcp.search.web_search")
2458 .expect("mcp tool action");
2459 let mut app = test_app();
2460 app.input = "draft".to_string();
2461 app.cursor_position = app.input.chars().count();
2462
2463 assert_eq!(
2464 action
2465 .dispatch(&mut app, &Config::default())
2466 .expect("dispatch mcp tool"),
2467 HotbarDispatch::Handled
2468 );
2469 assert_eq!(app.input, "mcp_search_web_search ");
2470 assert_eq!(app.cursor_position, app.input.chars().count());
2471 assert_eq!(app.clear_undo_buffer.as_deref(), Some("draft"));
2472 assert!(
2473 app.status_message
2474 .as_deref()
2475 .is_some_and(|message| message.contains("mcp_search_web_search"))
2476 );
2477 }
2478
2479 #[test]
2480 fn replace_mcp_tools_refreshes_and_clears_mcp_actions() {
2481 let mut registry = HotbarActionRegistry::with_builtins();
2482 let baseline = registry.len();
2483 let snapshot = test_mcp_snapshot();
2484
2485 registry.replace_mcp_tools(Some(&snapshot));
2486 assert_eq!(registry.len(), baseline + 1);
2487 // Re-applying a refreshed snapshot must not panic on duplicate ids.
2488 registry.replace_mcp_tools(Some(&snapshot));
2489 assert_eq!(registry.len(), baseline + 1);
2490
2491 registry.replace_mcp_tools(None);
2492 assert_eq!(registry.len(), baseline);
2493 assert!(registry.get("mcp.search.web_search").is_none());
2494 }
2495
2496 #[test]
2497 fn mode_actions_report_active_state_and_dispatch() {
2498 let registry = HotbarActionRegistry::with_builtins();
2499 let plan = registry.get("mode.plan").expect("plan action");
2500 let agent = registry.get("mode.agent").expect("agent action");
2501 let operate = registry.get("mode.operate").expect("operate action");
2502 let mut app = test_app();
2503
2504 assert!(agent.is_active(&app));
2505 assert!(!plan.is_active(&app));
2506 assert!(registry.get("mode.yolo").is_none());
2507
2508 assert_eq!(
2509 plan.dispatch(&mut app, &Config::default())
2510 .expect("dispatch plan"),
2511 HotbarDispatch::AppAction(AppAction::ModeChanged(AppMode::Plan))
2512 );
2513 assert_eq!(app.mode, AppMode::Plan);
2514 assert!(plan.is_active(&app));
2515 assert!(!agent.is_active(&app));
2516
2517 assert_eq!(
2518 operate
2519 .dispatch(&mut app, &Config::default())
2520 .expect("dispatch operate"),
2521 HotbarDispatch::AppAction(AppAction::ModeChanged(AppMode::Operate))
2522 );
2523 assert_eq!(app.mode, AppMode::Operate);
2524 assert!(operate.is_active(&app));
2525 assert!(!agent.is_active(&app));
2526 }
2527
2528 #[test]
2529 fn compact_action_emits_existing_app_action() {
2530 let registry = HotbarActionRegistry::with_builtins();
2531 let compact = registry.get("session.compact").expect("compact action");
2532 let mut app = test_app();
2533
2534 assert!(!compact.is_active(&app));
2535 assert_eq!(
2536 compact
2537 .dispatch(&mut app, &Config::default())
2538 .expect("dispatch compact"),
2539 HotbarDispatch::AppAction(AppAction::CompactContext { focus: None })
2540 );
2541 app.is_compacting = true;
2542 assert!(compact.is_active(&app));
2543 assert_eq!(
2544 compact
2545 .dispatch(&mut app, &Config::default())
2546 .expect("dispatch compact while busy"),
2547 HotbarDispatch::AppAction(AppAction::CompactContext { focus: None })
2548 );
2549 }
2550
2551 #[test]
2552 fn reasoning_cycle_updates_effort_and_compaction() {
2553 let registry = HotbarActionRegistry::with_builtins();
2554 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
2555 let mut app = test_app();
2556 app.api_provider = ProviderKind::Deepseek;
2557 app.reasoning_effort = ReasoningEffort::Off;
2558
2559 assert!(!reasoning.is_active(&app));
2560 assert!(matches!(
2561 reasoning
2562 .dispatch(&mut app, &Config::default())
2563 .expect("dispatch reasoning"),
2564 HotbarDispatch::AppAction(AppAction::UpdateCompaction(_))
2565 ));
2566 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
2567 assert!(reasoning.is_active(&app));
2568 assert_eq!(app.status_message.as_deref(), Some("Reasoning effort: low"));
2569
2570 app.auto_model = true;
2571 assert!(reasoning.is_active(&app));
2572 assert!(matches!(
2573 reasoning
2574 .dispatch(&mut app, &Config::default())
2575 .expect("dispatch reasoning under auto model"),
2576 HotbarDispatch::AppAction(AppAction::UpdateCompaction(_))
2577 ));
2578 assert_eq!(app.reasoning_effort, ReasoningEffort::Medium);
2579 }
2580
2581 #[test]
2582 fn reasoning_cycle_is_inert_while_a_turn_is_running() {
2583 let _lock = crate::test_support::lock_test_env();
2584 let tmp = tempfile::TempDir::new().expect("tempdir");
2585 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
2586 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
2587 let _codewhale_home =
2588 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
2589 let _deepseek_config = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
2590 let _codewhale_config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
2591 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
2592
2593 crate::settings::Settings::transact(|settings| {
2594 settings.reasoning_effort = Some("off".to_string());
2595 Ok(())
2596 })
2597 .expect("seed startup reasoning");
2598
2599 let registry = HotbarActionRegistry::with_builtins();
2600 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
2601 let mut app = test_app();
2602 app.api_provider = ProviderKind::Deepseek;
2603 app.auto_model = false;
2604 app.reasoning_effort = ReasoningEffort::Off;
2605 app.is_loading = true;
2606
2607 assert_eq!(
2608 reasoning
2609 .dispatch(&mut app, &Config::default())
2610 .expect("dispatch while busy"),
2611 HotbarDispatch::Handled
2612 );
2613 assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
2614 assert_eq!(app.startup_defaults.pending_len(), 0);
2615 assert_eq!(
2616 crate::settings::Settings::load()
2617 .expect("reload settings")
2618 .reasoning_effort
2619 .as_deref(),
2620 Some("off"),
2621 "a refused hotbar action must not persist a different tier"
2622 );
2623 }
2624
2625 #[test]
2626 fn reasoning_cycle_uses_codex_effort_tiers() {
2627 // Codex tiers are now per-model, read from the OAuth roster. Point
2628 // CODEX_HOME at an empty directory so this exercises the static
2629 // fallback ladder instead of whatever roster the developer's own
2630 // machine happens to have cached.
2631 let _lock = crate::test_support::lock_test_env();
2632 let codex_home = tempfile::TempDir::new().expect("codex home");
2633 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
2634 let registry = HotbarActionRegistry::with_builtins();
2635 let reasoning = registry.get("reasoning.cycle").expect("reasoning action");
2636 let mut app = test_app();
2637 app.set_provider_identity_record(
2638 crate::config::Config::default()
2639 .resolve_provider_identity(ProviderKind::OpenaiCodex.as_str())
2640 .expect("captured fixture provider"),
2641 );
2642 app.auto_model = false;
2643 app.reasoning_effort = ReasoningEffort::Low;
2644
2645 for (expected_effort, expected_label) in [
2646 (ReasoningEffort::Medium, "medium"),
2647 (ReasoningEffort::High, "high"),
2648 (ReasoningEffort::Max, "max"),
2649 (ReasoningEffort::Low, "low"),
2650 ] {
2651 assert!(matches!(
2652 reasoning
2653 .dispatch(&mut app, &Config::default())
2654 .expect("dispatch reasoning"),
2655 HotbarDispatch::AppAction(AppAction::UpdateCompaction(_))
2656 ));
2657 assert_eq!(app.reasoning_effort, expected_effort);
2658 let expected_message = format!("Reasoning effort: {expected_label}");
2659 assert_eq!(
2660 app.status_message.as_deref(),
2661 Some(expected_message.as_str())
2662 );
2663 }
2664 }
2665
2666 #[test]
2667 fn sidebar_toggle_reports_visibility_and_dispatches() {
2668 let registry = HotbarActionRegistry::with_builtins();
2669 let sidebar = registry.get("sidebar.toggle").expect("sidebar action");
2670 let mut app = test_app();
2671 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
2672
2673 assert!(sidebar.is_active(&app));
2674 assert_eq!(
2675 sidebar
2676 .dispatch(&mut app, &Config::default())
2677 .expect("dispatch rail hide"),
2678 HotbarDispatch::Handled
2679 );
2680 assert_eq!(
2681 app.work_surface.placement,
2682 crate::tui::work_surface::WorkSurfacePlacement::Off
2683 );
2684 assert!(!sidebar.is_active(&app));
2685
2686 sidebar
2687 .dispatch(&mut app, &Config::default())
2688 .expect("dispatch rail show");
2689 assert_eq!(
2690 app.work_surface.placement,
2691 crate::tui::work_surface::WorkSurfacePlacement::Bottom,
2692 "toggling the rail back on restores the round-3 default"
2693 );
2694 assert!(sidebar.is_active(&app));
2695 }
2696
2697 #[tokio::test]
2698 async fn filetree_toggle_reports_open_state_and_dispatches() {
2699 let registry = HotbarActionRegistry::with_builtins();
2700 let filetree = registry.get("filetree.toggle").expect("filetree action");
2701 let mut app = test_app();
2702
2703 assert!(!filetree.is_active(&app));
2704 assert_eq!(
2705 filetree
2706 .dispatch(&mut app, &Config::default())
2707 .expect("dispatch filetree open"),
2708 HotbarDispatch::Handled
2709 );
2710 assert!(app.file_tree.is_some());
2711 assert!(filetree.is_active(&app));
2712
2713 filetree
2714 .dispatch(&mut app, &Config::default())
2715 .expect("dispatch filetree close");
2716 assert!(app.file_tree.is_none());
2717 assert!(!filetree.is_active(&app));
2718 }
2719
2720 #[test]
2721 fn palette_action_opens_command_palette() {
2722 let registry = HotbarActionRegistry::with_builtins();
2723 let palette = registry.get("palette.open").expect("palette action");
2724 let mut app = test_app();
2725
2726 assert!(!palette.is_active(&app));
2727 assert_eq!(
2728 palette
2729 .dispatch(&mut app, &Config::default())
2730 .expect("dispatch palette"),
2731 HotbarDispatch::Handled
2732 );
2733 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::CommandPalette));
2734 }
2735
2736 #[test]
2737 fn trust_toggle_reports_trust_state_and_dispatches() {
2738 let registry = HotbarActionRegistry::with_builtins();
2739 let trust = registry.get("trust.toggle").expect("trust action");
2740 let mut app = test_app();
2741 app.trust_mode = false;
2742
2743 assert!(!trust.is_active(&app));
2744 assert_eq!(
2745 trust
2746 .dispatch(&mut app, &Config::default())
2747 .expect("dispatch trust on"),
2748 HotbarDispatch::Handled
2749 );
2750 assert!(app.trust_mode);
2751 assert!(trust.is_active(&app));
2752
2753 trust
2754 .dispatch(&mut app, &Config::default())
2755 .expect("dispatch trust off");
2756 assert!(!app.trust_mode);
2757 assert!(!trust.is_active(&app));
2758 }
2759
2760 #[test]
2761 fn voice_toggle_dispatches_the_voice_command() {
2762 let registry = HotbarActionRegistry::with_builtins();
2763 let voice = registry.get("voice.toggle").expect("voice action");
2764 let mut app = test_app();
2765
2766 assert!(!voice.is_active(&app));
2767 // The toggle is wired to the /voice command. With a recorder on the
2768 // host it arms voice input and defers capture to the UI event loop;
2769 // without one it fails gracefully with a localized error. No audio
2770 // is recorded in either case.
2771 let result = voice
2772 .dispatch(&mut app, &Config::default())
2773 .expect("dispatch voice");
2774 assert!(app.status_message.is_some());
2775 // The old placeholder message must be gone — voice is implemented.
2776 assert_ne!(
2777 app.status_message.as_deref(),
2778 Some("Voice input is not available in this terminal session yet.")
2779 );
2780 if app.voice_enabled {
2781 assert_eq!(
2782 result,
2783 HotbarDispatch::AppAction(crate::tui::app::AppAction::VoiceCapture)
2784 );
2785 assert!(voice.is_active(&app));
2786 // A second press toggles voice input back off.
2787 let off = voice
2788 .dispatch(&mut app, &Config::default())
2789 .expect("dispatch voice off");
2790 assert_eq!(off, HotbarDispatch::Handled);
2791 assert!(!app.voice_enabled);
2792 assert!(!voice.is_active(&app));
2793 } else {
2794 assert_eq!(result, HotbarDispatch::Handled);
2795 }
2796 }
2797 }
2798
2798 lines RUST