diff --git a/config.example.toml b/config.example.toml index 5a2782a5..8d6686fb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -88,6 +88,26 @@ cost_currency = "usd" # usd | cny check_for_updates = true # update_uri = "https://internal.mirror.example/codewhale/releases/latest" +# ───────────────────────────────────────────────────────────────────────────────── +# Hotbar slots (#2061 / #2064) +# ───────────────────────────────────────────────────────────────────────────────── +# Optional 1-8 sidebar hotbar bindings. When no [[hotbar]] tables are present, +# the TUI uses built-in defaults: +# 1 voice.toggle 2 session.compact 3 mode.plan 4 mode.agent +# 5 mode.yolo 6 palette.open 7 sidebar.toggle 8 trust.toggle +# +# Invalid slots are skipped with a warning, duplicate slots use the last entry, +# and unknown actions are preserved so the UI can show a disabled placeholder. +# +# [[hotbar]] +# slot = 1 +# label = "voice" +# action = "voice.toggle" +# +# [[hotbar]] +# slot = 2 +# action = "session.compact" + # ───────────────────────────────────────────────────────────────────────────────── # Paths # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index bbad2352..d5b655ff 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,6 +1,7 @@ pub mod provider; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::fs; #[cfg(unix)] use std::io::Write; @@ -539,6 +540,10 @@ pub struct ConfigToml { /// v0.9 slices; this is the durable config data model. #[serde(default)] pub harness_profiles: Vec, + /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls + /// back to the built-in default slots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hotbar: Option>, /// App-server hook sink configuration. Kept separate from the TUI /// lifecycle `[hooks]` table so config rewrites preserve existing hooks. #[serde(default)] @@ -563,6 +568,16 @@ impl ConfigToml { .iter() .find(|profile| profile.matches_route(provider_route, model)) } + + /// Resolve durable hotbar config into normalized 1-8 slot bindings. + /// + /// `known_action_ids` is supplied by the TUI action registry in later + /// slices. Unknown actions are preserved so the UI can render a disabled + /// `?` cell instead of silently deleting user config. + #[must_use] + pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution { + resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids) + } } fn provider_routes_equal(expected: &str, actual: &str) -> bool { @@ -616,6 +631,148 @@ pub struct ProviderChain { position: usize, } +pub const HOTBAR_SLOT_COUNT: u8 = 8; + +pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [ + "voice.toggle", + "session.compact", + "mode.plan", + "mode.agent", + "mode.yolo", + "palette.open", + "sidebar.toggle", + "trust.toggle", +]; + +/// On-disk schema for one `[[hotbar]]` table. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HotbarBindingToml { + pub slot: u8, + pub action: String, + #[serde(default)] + pub label: Option, +} + +/// Validated hotbar binding used by future render/dispatch layers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotbarBinding { + pub slot: u8, + pub action: String, + pub label: Option, +} + +/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots +/// use the last binding; unknown actions are kept for UI feedback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HotbarConfigWarning { + SlotOutOfRange { + slot: u8, + action: String, + }, + DuplicateSlot { + slot: u8, + previous_action: String, + replacement_action: String, + }, + UnknownAction { + slot: u8, + action: String, + }, +} + +impl fmt::Display for HotbarConfigWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SlotOutOfRange { slot, action } => write!( + f, + "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped" + ), + Self::DuplicateSlot { + slot, + previous_action, + replacement_action, + } => write!( + f, + "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'" + ), + Self::UnknownAction { slot, action } => write!( + f, + "hotbar slot {slot} references unknown action '{action}'; keeping binding" + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotbarConfigResolution { + pub bindings: Vec, + pub warnings: Vec, +} + +#[must_use] +pub fn default_hotbar_bindings() -> Vec { + DEFAULT_HOTBAR_ACTIONS + .iter() + .enumerate() + .map(|(idx, action)| HotbarBinding { + slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"), + action: (*action).to_string(), + label: None, + }) + .collect() +} + +#[must_use] +pub fn resolve_hotbar_bindings( + configured: Option<&[HotbarBindingToml]>, + known_action_ids: &[&str], +) -> HotbarConfigResolution { + let known = known_action_ids.iter().copied().collect::>(); + let mut warnings = Vec::new(); + + let source = match configured { + Some(bindings) => bindings + .iter() + .map(|binding| HotbarBinding { + slot: binding.slot, + action: binding.action.clone(), + label: binding.label.clone(), + }) + .collect::>(), + None => default_hotbar_bindings(), + }; + + let mut by_slot: BTreeMap = BTreeMap::new(); + for binding in source { + if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) { + warnings.push(HotbarConfigWarning::SlotOutOfRange { + slot: binding.slot, + action: binding.action, + }); + continue; + } + if !known.is_empty() && !known.contains(binding.action.as_str()) { + warnings.push(HotbarConfigWarning::UnknownAction { + slot: binding.slot, + action: binding.action.clone(), + }); + } + if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) { + warnings.push(HotbarConfigWarning::DuplicateSlot { + slot: binding.slot, + previous_action: previous.action, + replacement_action: binding.action, + }); + } + } + + HotbarConfigResolution { + bindings: by_slot.into_values().collect(), + warnings, + } +} + impl ProviderChain { #[must_use] pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self { @@ -3176,6 +3333,132 @@ mod tests { assert!(err.message().contains("unknown field")); } + #[test] + fn hotbar_defaults_when_config_is_absent() { + let config = ConfigToml::default(); + + let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS); + + assert_eq!(resolved.warnings, Vec::new()); + assert_eq!(resolved.bindings, default_hotbar_bindings()); + assert_eq!( + resolved + .bindings + .iter() + .map(|binding| (binding.slot, binding.action.as_str())) + .collect::>(), + vec![ + (1, "voice.toggle"), + (2, "session.compact"), + (3, "mode.plan"), + (4, "mode.agent"), + (5, "mode.yolo"), + (6, "palette.open"), + (7, "sidebar.toggle"), + (8, "trust.toggle"), + ] + ); + } + + #[test] + fn hotbar_tables_parse_and_round_trip() { + let config: ConfigToml = toml::from_str( + r#" +[[hotbar]] +slot = 1 +label = "Plan" +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "session.compact" +"#, + ) + .expect("parse hotbar tables"); + + let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]); + + assert_eq!( + resolved.bindings, + vec![ + HotbarBinding { + slot: 1, + action: "mode.plan".to_string(), + label: Some("Plan".to_string()), + }, + HotbarBinding { + slot: 2, + action: "session.compact".to_string(), + label: None, + }, + ] + ); + assert_eq!(resolved.warnings, Vec::new()); + + let serialized = toml::to_string_pretty(&config).expect("serialize config"); + let round_tripped: ConfigToml = + toml::from_str(&serialized).expect("deserialize serialized config"); + assert_eq!(round_tripped.hotbar, config.hotbar); + } + + #[test] + fn hotbar_validation_warns_without_dropping_unknown_actions() { + let config: ConfigToml = toml::from_str( + r#" +[[hotbar]] +slot = 0 +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "custom.action" + +[[hotbar]] +slot = 9 +action = "mode.agent" +"#, + ) + .expect("parse hotbar tables"); + + let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]); + + assert_eq!( + resolved.bindings, + vec![HotbarBinding { + slot: 2, + action: "custom.action".to_string(), + label: None, + }] + ); + assert_eq!( + resolved.warnings, + vec![ + HotbarConfigWarning::SlotOutOfRange { + slot: 0, + action: "mode.plan".to_string(), + }, + HotbarConfigWarning::UnknownAction { + slot: 2, + action: "custom.action".to_string(), + }, + HotbarConfigWarning::DuplicateSlot { + slot: 2, + previous_action: "mode.plan".to_string(), + replacement_action: "custom.action".to_string(), + }, + HotbarConfigWarning::SlotOutOfRange { + slot: 9, + action: "mode.agent".to_string(), + }, + ] + ); + assert!(resolved.warnings[1].to_string().contains("keeping binding")); + } + #[test] fn config_store_loads_sibling_permissions_toml() { use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ccb4af18..aeb75ead 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1656,6 +1656,11 @@ pub struct Config { #[serde(default)] pub auto: Option, + /// Optional 1-8 hotbar slot bindings (#2064). When absent, future hotbar + /// UI slices use the built-in defaults from `codewhale_config`. + #[serde(default)] + pub hotbar: Option>, + /// Startup update-check behavior. When absent, the TUI keeps the default /// fire-and-forget latest-release check. #[serde(default)] @@ -2918,6 +2923,15 @@ impl Config { self.update.clone().unwrap_or_default() } + /// Resolve durable hotbar bindings for future render/dispatch layers. + #[must_use] + pub fn resolve_hotbar_bindings( + &self, + known_action_ids: &[&str], + ) -> codewhale_config::HotbarConfigResolution { + codewhale_config::resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids) + } + /// Resolve enabled features from defaults and config entries. #[must_use] pub fn features(&self) -> Features { @@ -4495,6 +4509,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { memory: override_cfg.memory.or(base.memory), speech: override_cfg.speech.or(base.speech), auto: override_cfg.auto.or(base.auto), + hotbar: override_cfg.hotbar.or(base.hotbar), update: override_cfg.update.or(base.update), lsp: override_cfg.lsp.or(base.lsp), context: ContextConfig { @@ -5647,6 +5662,39 @@ mod tests { assert!(parsed.base.allow_shell()); } + #[test] + fn tui_config_parses_hotbar_bindings() { + let raw = r#" +[[hotbar]] +slot = 1 +label = "Plan" +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "session.compact" +"#; + let parsed: ConfigFile = toml::from_str(raw).expect("parse hotbar config"); + + let resolved = parsed + .base + .resolve_hotbar_bindings(&["mode.plan", "session.compact"]); + + assert_eq!(resolved.warnings, Vec::new()); + assert_eq!( + resolved + .bindings + .iter() + .map(|binding| ( + binding.slot, + binding.action.as_str(), + binding.label.as_deref() + )) + .collect::>(), + vec![(1, "mode.plan", Some("Plan")), (2, "session.compact", None),] + ); + } + #[test] fn update_config_defaults_to_enabled_without_uri() { let config = Config::default();