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