Skip to main content

herdr_pretty_which/
model.rs

1use serde::Deserialize;
2use std::collections::{BTreeMap, BTreeSet};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
5pub enum Category {
6    Core,
7    Workspaces,
8    Tabs,
9    Panes,
10    Agents,
11    Custom,
12    /// Actions auto-discovered from `herdr --default-config` that are not yet
13    /// modeled in the static [`SPECS`] table. Surfaced so new Herdr actions are
14    /// never silently hidden; see `crate::discover`.
15    Discovered,
16}
17
18impl Category {
19    pub const ALL: [Category; 7] = [
20        Category::Core,
21        Category::Workspaces,
22        Category::Tabs,
23        Category::Panes,
24        Category::Agents,
25        Category::Custom,
26        Category::Discovered,
27    ];
28
29    pub const fn title(self) -> &'static str {
30        match self {
31            Category::Core => "Core",
32            Category::Workspaces => "Workspaces",
33            Category::Tabs => "Tabs",
34            Category::Panes => "Panes",
35            Category::Agents => "Agents",
36            Category::Custom => "Custom",
37            Category::Discovered => "Discovered",
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum BindingSource {
44    Default,
45    Custom,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BindingStatus {
50    Active,
51    Disabled,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Binding {
56    pub action: String,
57    pub label: String,
58    pub keys: Vec<String>,
59    pub default_keys: Vec<String>,
60    pub category: Category,
61    pub tree_path: Vec<String>,
62    pub source: BindingSource,
63    pub status: BindingStatus,
64    pub hint: String,
65}
66
67#[derive(Debug, Clone, Deserialize, Default)]
68pub struct KeysSection {
69    pub prefix: Option<String>,
70    pub help: Option<KeyValue>,
71    pub settings: Option<KeyValue>,
72    pub detach: Option<KeyValue>,
73    pub reload_config: Option<KeyValue>,
74    pub open_notification_target: Option<KeyValue>,
75    pub remote_image_paste: Option<KeyValue>,
76    pub workspace_picker: Option<KeyValue>,
77    pub goto: Option<KeyValue>,
78    pub new_workspace: Option<KeyValue>,
79    pub new_worktree: Option<KeyValue>,
80    pub open_worktree: Option<KeyValue>,
81    pub remove_worktree: Option<KeyValue>,
82    pub rename_workspace: Option<KeyValue>,
83    pub close_workspace: Option<KeyValue>,
84    pub previous_workspace: Option<KeyValue>,
85    pub next_workspace: Option<KeyValue>,
86    pub previous_agent: Option<KeyValue>,
87    pub next_agent: Option<KeyValue>,
88    pub focus_agent: Option<KeyValue>,
89    pub new_tab: Option<KeyValue>,
90    pub rename_tab: Option<KeyValue>,
91    pub previous_tab: Option<KeyValue>,
92    pub next_tab: Option<KeyValue>,
93    pub switch_tab: Option<KeyValue>,
94    pub switch_workspace: Option<KeyValue>,
95    pub close_tab: Option<KeyValue>,
96    pub rename_pane: Option<KeyValue>,
97    pub edit_scrollback: Option<KeyValue>,
98    pub focus_pane_left: Option<KeyValue>,
99    pub focus_pane_down: Option<KeyValue>,
100    pub focus_pane_up: Option<KeyValue>,
101    pub focus_pane_right: Option<KeyValue>,
102    pub cycle_pane_next: Option<KeyValue>,
103    pub cycle_pane_previous: Option<KeyValue>,
104    pub last_pane: Option<KeyValue>,
105    pub split_vertical: Option<KeyValue>,
106    pub split_horizontal: Option<KeyValue>,
107    pub close_pane: Option<KeyValue>,
108    pub zoom: Option<KeyValue>,
109    pub fullscreen: Option<KeyValue>,
110    pub resize_mode: Option<KeyValue>,
111    pub toggle_sidebar: Option<KeyValue>,
112    pub navigate_workspace_up: Option<KeyValue>,
113    pub navigate_workspace_down: Option<KeyValue>,
114    pub navigate_pane_left: Option<KeyValue>,
115    pub navigate_pane_down: Option<KeyValue>,
116    pub navigate_pane_up: Option<KeyValue>,
117    pub navigate_pane_right: Option<KeyValue>,
118    #[serde(default)]
119    pub command: Vec<CommandBinding>,
120}
121
122#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
123#[serde(untagged)]
124pub enum KeyValue {
125    One(String),
126    Many(Vec<String>),
127}
128
129impl KeyValue {
130    pub fn keys(&self) -> Vec<String> {
131        match self {
132            KeyValue::One(value) => vec![value.clone()],
133            KeyValue::Many(values) => values.clone(),
134        }
135    }
136}
137
138#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
139pub struct CommandBinding {
140    pub name: Option<String>,
141    pub description: Option<String>,
142    pub key: Option<KeyValue>,
143    #[serde(default)]
144    pub r#type: Option<String>,
145    pub command: Option<String>,
146}
147
148#[derive(Debug, Clone)]
149struct BindingSpec {
150    action: &'static str,
151    label: &'static str,
152    default_keys: &'static [&'static str],
153    category: Category,
154    hint: &'static str,
155}
156
157const SPECS: &[BindingSpec] = &[
158    BindingSpec {
159        action: "help",
160        label: "Help",
161        default_keys: &["prefix+?"],
162        category: Category::Core,
163        hint: "Open Herdr's canonical key map.",
164    },
165    BindingSpec {
166        action: "settings",
167        label: "Settings",
168        default_keys: &["prefix+s"],
169        category: Category::Core,
170        hint: "Open in-app settings.",
171    },
172    BindingSpec {
173        action: "detach",
174        label: "Detach",
175        default_keys: &["prefix+q"],
176        category: Category::Core,
177        hint: "Leave Herdr; panes keep running.",
178    },
179    BindingSpec {
180        action: "reload_config",
181        label: "Reload config",
182        default_keys: &["prefix+shift+r"],
183        category: Category::Core,
184        hint: "Apply config changes without restarting.",
185    },
186    BindingSpec {
187        action: "open_notification_target",
188        label: "Open notification",
189        default_keys: &["prefix+o"],
190        category: Category::Core,
191        hint: "Jump to what needs attention.",
192    },
193    BindingSpec {
194        action: "remote_image_paste",
195        label: "Remote image paste",
196        default_keys: &["ctrl+v"],
197        category: Category::Core,
198        hint: "Paste images while attached to a remote Herdr session.",
199    },
200    BindingSpec {
201        action: "workspace_picker",
202        label: "Workspace picker",
203        default_keys: &["prefix+w"],
204        category: Category::Workspaces,
205        hint: "Choose a workspace from the native picker.",
206    },
207    BindingSpec {
208        action: "goto",
209        label: "Goto",
210        default_keys: &["prefix+g"],
211        category: Category::Workspaces,
212        hint: "Fuzzy jump across workspaces, tabs, and agents.",
213    },
214    BindingSpec {
215        action: "new_workspace",
216        label: "New workspace",
217        default_keys: &["prefix+shift+n"],
218        category: Category::Workspaces,
219        hint: "Create a fresh workspace.",
220    },
221    BindingSpec {
222        action: "new_worktree",
223        label: "New worktree",
224        default_keys: &["prefix+shift+g"],
225        category: Category::Workspaces,
226        hint: "Create a Git worktree workspace.",
227    },
228    BindingSpec {
229        action: "open_worktree",
230        label: "Open worktree",
231        default_keys: &[],
232        category: Category::Workspaces,
233        hint: "Open an existing worktree.",
234    },
235    BindingSpec {
236        action: "remove_worktree",
237        label: "Remove worktree",
238        default_keys: &[],
239        category: Category::Workspaces,
240        hint: "Remove a worktree after confirmation.",
241    },
242    BindingSpec {
243        action: "rename_workspace",
244        label: "Rename workspace",
245        default_keys: &["prefix+shift+w"],
246        category: Category::Workspaces,
247        hint: "Name the current workspace.",
248    },
249    BindingSpec {
250        action: "close_workspace",
251        label: "Close workspace",
252        default_keys: &["prefix+shift+d"],
253        category: Category::Workspaces,
254        hint: "Close the whole workspace.",
255    },
256    BindingSpec {
257        action: "previous_workspace",
258        label: "Previous workspace",
259        default_keys: &[],
260        category: Category::Workspaces,
261        hint: "Direct previous workspace shortcut.",
262    },
263    BindingSpec {
264        action: "next_workspace",
265        label: "Next workspace",
266        default_keys: &[],
267        category: Category::Workspaces,
268        hint: "Direct next workspace shortcut.",
269    },
270    BindingSpec {
271        action: "new_tab",
272        label: "New tab",
273        default_keys: &["prefix+c"],
274        category: Category::Tabs,
275        hint: "Create a tab in this workspace.",
276    },
277    BindingSpec {
278        action: "rename_tab",
279        label: "Rename tab",
280        default_keys: &["prefix+shift+t"],
281        category: Category::Tabs,
282        hint: "Give the focused tab a useful name.",
283    },
284    BindingSpec {
285        action: "previous_tab",
286        label: "Previous tab",
287        default_keys: &["prefix+p"],
288        category: Category::Tabs,
289        hint: "Move one tab left/back.",
290    },
291    BindingSpec {
292        action: "next_tab",
293        label: "Next tab",
294        default_keys: &["prefix+n"],
295        category: Category::Tabs,
296        hint: "Move one tab right/forward.",
297    },
298    BindingSpec {
299        action: "switch_tab",
300        label: "Switch tab",
301        default_keys: &["prefix+1..9"],
302        category: Category::Tabs,
303        hint: "Jump to tab by index.",
304    },
305    BindingSpec {
306        action: "switch_workspace",
307        label: "Switch workspace",
308        default_keys: &[],
309        category: Category::Workspaces,
310        hint: "Optional indexed workspace jump binding.",
311    },
312    BindingSpec {
313        action: "close_tab",
314        label: "Close tab",
315        default_keys: &["prefix+shift+x"],
316        category: Category::Tabs,
317        hint: "Close the active tab.",
318    },
319    BindingSpec {
320        action: "rename_pane",
321        label: "Rename pane",
322        default_keys: &["prefix+shift+p"],
323        category: Category::Panes,
324        hint: "Name the focused pane.",
325    },
326    BindingSpec {
327        action: "edit_scrollback",
328        label: "Edit scrollback",
329        default_keys: &["prefix+e"],
330        category: Category::Panes,
331        hint: "Open scrollback for review/copy.",
332    },
333    BindingSpec {
334        action: "focus_pane_left",
335        label: "Focus left",
336        default_keys: &["prefix+h"],
337        category: Category::Panes,
338        hint: "Focus pane to the left.",
339    },
340    BindingSpec {
341        action: "focus_pane_down",
342        label: "Focus down",
343        default_keys: &["prefix+j"],
344        category: Category::Panes,
345        hint: "Focus pane below.",
346    },
347    BindingSpec {
348        action: "focus_pane_up",
349        label: "Focus up",
350        default_keys: &["prefix+k"],
351        category: Category::Panes,
352        hint: "Focus pane above.",
353    },
354    BindingSpec {
355        action: "focus_pane_right",
356        label: "Focus right",
357        default_keys: &["prefix+l"],
358        category: Category::Panes,
359        hint: "Focus pane to the right.",
360    },
361    BindingSpec {
362        action: "cycle_pane_next",
363        label: "Next pane",
364        default_keys: &["prefix+tab"],
365        category: Category::Panes,
366        hint: "Cycle through panes.",
367    },
368    BindingSpec {
369        action: "cycle_pane_previous",
370        label: "Previous pane",
371        default_keys: &["prefix+shift+tab"],
372        category: Category::Panes,
373        hint: "Cycle backward through panes.",
374    },
375    BindingSpec {
376        action: "last_pane",
377        label: "Last pane",
378        default_keys: &[],
379        category: Category::Panes,
380        hint: "Jump back to the last pane.",
381    },
382    BindingSpec {
383        action: "split_vertical",
384        label: "Split vertical",
385        default_keys: &["prefix+v"],
386        category: Category::Panes,
387        hint: "Split side by side.",
388    },
389    BindingSpec {
390        action: "split_horizontal",
391        label: "Split horizontal",
392        default_keys: &["prefix+minus"],
393        category: Category::Panes,
394        hint: "Split top/bottom.",
395    },
396    BindingSpec {
397        action: "close_pane",
398        label: "Close pane",
399        default_keys: &["prefix+x"],
400        category: Category::Panes,
401        hint: "Close only the focused pane.",
402    },
403    BindingSpec {
404        action: "zoom",
405        label: "Zoom",
406        default_keys: &["prefix+z"],
407        category: Category::Panes,
408        hint: "Toggle focused pane fullscreen.",
409    },
410    BindingSpec {
411        action: "resize_mode",
412        label: "Resize mode",
413        default_keys: &["prefix+r"],
414        category: Category::Panes,
415        hint: "Enter pane resize mode.",
416    },
417    BindingSpec {
418        action: "toggle_sidebar",
419        label: "Toggle sidebar",
420        default_keys: &["prefix+b"],
421        category: Category::Panes,
422        hint: "Show/hide Herdr's sidebar.",
423    },
424    BindingSpec {
425        action: "navigate_workspace_up",
426        label: "Navigate workspace up",
427        default_keys: &["up"],
428        category: Category::Workspaces,
429        hint: "Move up while Herdr navigate mode is open.",
430    },
431    BindingSpec {
432        action: "navigate_workspace_down",
433        label: "Navigate workspace down",
434        default_keys: &["down"],
435        category: Category::Workspaces,
436        hint: "Move down while Herdr navigate mode is open.",
437    },
438    BindingSpec {
439        action: "navigate_pane_left",
440        label: "Navigate left",
441        default_keys: &["h"],
442        category: Category::Panes,
443        hint: "Move left while Herdr navigate mode is open.",
444    },
445    BindingSpec {
446        action: "navigate_pane_down",
447        label: "Navigate down",
448        default_keys: &["j"],
449        category: Category::Panes,
450        hint: "Move down while Herdr navigate mode is open.",
451    },
452    BindingSpec {
453        action: "navigate_pane_up",
454        label: "Navigate up",
455        default_keys: &["k"],
456        category: Category::Panes,
457        hint: "Move up while Herdr navigate mode is open.",
458    },
459    BindingSpec {
460        action: "navigate_pane_right",
461        label: "Navigate right",
462        default_keys: &["l"],
463        category: Category::Panes,
464        hint: "Move right while Herdr navigate mode is open.",
465    },
466    BindingSpec {
467        action: "previous_agent",
468        label: "Previous agent",
469        default_keys: &[],
470        category: Category::Agents,
471        hint: "Focus previous visible agent.",
472    },
473    BindingSpec {
474        action: "next_agent",
475        label: "Next agent",
476        default_keys: &[],
477        category: Category::Agents,
478        hint: "Focus next visible agent.",
479    },
480    BindingSpec {
481        action: "focus_agent",
482        label: "Focus agent",
483        default_keys: &[],
484        category: Category::Agents,
485        hint: "Indexed agent focus binding.",
486    },
487];
488
489pub fn effective_bindings(keys: &KeysSection) -> Vec<Binding> {
490    effective_bindings_with_discovery(keys, None)
491}
492
493/// Return the set of action names covered by the static [`SPECS`] table. Used to
494/// detect actions discovered from `herdr --default-config` that Pretty Which
495/// does not yet model, so they can be surfaced instead of silently dropped.
496pub fn modeled_actions() -> BTreeSet<&'static str> {
497    SPECS.iter().map(|spec| spec.action).collect()
498}
499
500/// Build effective bindings, optionally merging actions auto-discovered from
501/// `herdr --default-config`. Discovered actions not present in the static
502/// [`SPECS`] table are surfaced under [`Category::Discovered`] so new Herdr
503/// actions are visible instead of silently dropped. See `crate::discover`.
504pub fn effective_bindings_with_discovery(
505    keys: &KeysSection,
506    discovered: Option<&BTreeMap<String, Vec<String>>>,
507) -> Vec<Binding> {
508    let mut out = Vec::new();
509    for spec in SPECS {
510        let configured = configured_value(keys, spec.action);
511        let default_keys = strings(spec.default_keys);
512        let keys = configured.clone().unwrap_or_else(|| default_keys.clone());
513        let status = if keys.iter().all(|key| key.trim().is_empty()) {
514            BindingStatus::Disabled
515        } else {
516            BindingStatus::Active
517        };
518        let source = if configured.is_some() {
519            BindingSource::Custom
520        } else {
521            BindingSource::Default
522        };
523        out.push(Binding {
524            action: spec.action.to_string(),
525            label: spec.label.to_string(),
526            keys: keys
527                .into_iter()
528                .filter(|key| !key.trim().is_empty())
529                .collect(),
530            default_keys,
531            category: spec.category,
532            tree_path: tree_path_for_action(spec.action, spec.category),
533            source,
534            status,
535            hint: spec.hint.to_string(),
536        });
537    }
538
539    for (index, command) in keys.command.iter().enumerate() {
540        let keys = command.key.as_ref().map(KeyValue::keys).unwrap_or_default();
541        let label = command
542            .name
543            .clone()
544            .or_else(|| command.description.clone())
545            .or_else(|| command.command.clone())
546            .unwrap_or_else(|| format!("Custom command {}", index + 1));
547        let status = if keys.iter().all(|key| key.trim().is_empty()) {
548            BindingStatus::Disabled
549        } else {
550            BindingStatus::Active
551        };
552        out.push(Binding {
553            action: format!("command[{index}]"),
554            label,
555            keys: keys
556                .into_iter()
557                .filter(|key| !key.trim().is_empty())
558                .collect(),
559            default_keys: Vec::new(),
560            category: Category::Custom,
561            tree_path: vec!["Custom".to_string(), "Commands".to_string()],
562            source: BindingSource::Custom,
563            status,
564            hint: command
565                .command
566                .clone()
567                .unwrap_or_else(|| "Custom Herdr command".to_string()),
568        });
569    }
570
571    if let Some(discovered) = discovered {
572        let modeled: BTreeSet<&str> = SPECS.iter().map(|spec| spec.action).collect();
573        for (action, default_keys) in discovered {
574            if modeled.contains(action.as_str()) {
575                continue;
576            }
577            let configured = configured_value(keys, action);
578            let binding_keys = configured.clone().unwrap_or_else(|| default_keys.clone());
579            let status = if binding_keys.iter().all(|key| key.trim().is_empty()) {
580                BindingStatus::Disabled
581            } else {
582                BindingStatus::Active
583            };
584            let source = if configured.is_some() {
585                BindingSource::Custom
586            } else {
587                BindingSource::Default
588            };
589            out.push(Binding {
590                action: action.clone(),
591                label: humanize_action(action),
592                keys: binding_keys
593                    .into_iter()
594                    .filter(|key| !key.trim().is_empty())
595                    .collect(),
596                default_keys: default_keys.clone(),
597                category: Category::Discovered,
598                tree_path: vec!["Discovered".to_string(), "Unmodeled".to_string()],
599                source,
600                status,
601                hint: "Auto-discovered from `herdr --default-config`; not yet modeled in Pretty Which specs.".to_string(),
602            });
603        }
604    }
605
606    out
607}
608
609/// Turn a snake_case action name into a human-friendly label, e.g.
610/// `open_navigator` -> "Open Navigator".
611fn humanize_action(action: &str) -> String {
612    action
613        .split('_')
614        .map(|word| {
615            let mut chars = word.chars();
616            match chars.next() {
617                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
618                None => String::new(),
619            }
620        })
621        .collect::<Vec<_>>()
622        .join(" ")
623}
624
625pub fn category_counts(bindings: &[Binding]) -> BTreeMap<Category, usize> {
626    let mut counts = BTreeMap::new();
627    for binding in bindings
628        .iter()
629        .filter(|binding| binding.status == BindingStatus::Active)
630    {
631        *counts.entry(binding.category).or_insert(0) += 1;
632    }
633    counts
634}
635
636fn strings(values: &[&str]) -> Vec<String> {
637    values.iter().map(|value| (*value).to_string()).collect()
638}
639
640fn tree_path_for_action(action: &str, category: Category) -> Vec<String> {
641    let segments = match action {
642        "help"
643        | "settings"
644        | "detach"
645        | "reload_config"
646        | "open_notification_target"
647        | "remote_image_paste" => &["Session", "Core"][..],
648        "workspace_picker" | "goto" => &["Workspaces", "Picker"][..],
649        "new_workspace" | "rename_workspace" | "close_workspace" => {
650            &["Workspaces", "Lifecycle"][..]
651        }
652        "new_worktree" | "open_worktree" | "remove_worktree" => &["Workspaces", "Worktrees"][..],
653        "previous_workspace"
654        | "next_workspace"
655        | "switch_workspace"
656        | "navigate_workspace_up"
657        | "navigate_workspace_down" => &["Workspaces", "Navigation"][..],
658        "new_tab" | "rename_tab" | "close_tab" => &["Tabs", "Lifecycle"][..],
659        "previous_tab" | "next_tab" | "switch_tab" => &["Tabs", "Navigation"][..],
660        "rename_pane" | "close_pane" => &["Panes", "Lifecycle"][..],
661        "edit_scrollback" => &["Panes", "Scrollback"][..],
662        "focus_pane_left"
663        | "focus_pane_down"
664        | "focus_pane_up"
665        | "focus_pane_right"
666        | "cycle_pane_next"
667        | "cycle_pane_previous"
668        | "last_pane" => &["Panes", "Focus"][..],
669        "split_vertical" | "split_horizontal" | "zoom" | "resize_mode" | "toggle_sidebar" => {
670            &["Panes", "Layout"][..]
671        }
672        "navigate_pane_left"
673        | "navigate_pane_down"
674        | "navigate_pane_up"
675        | "navigate_pane_right" => &["Panes", "Navigation mode"][..],
676        "previous_agent" | "next_agent" | "focus_agent" => &["Agents", "Focus"][..],
677        _ => return vec![category.title().to_string(), "Other".to_string()],
678    };
679    strings(segments)
680}
681
682fn configured_value(keys: &KeysSection, action: &str) -> Option<Vec<String>> {
683    let value = match action {
684        "help" => keys.help.as_ref(),
685        "settings" => keys.settings.as_ref(),
686        "detach" => keys.detach.as_ref(),
687        "reload_config" => keys.reload_config.as_ref(),
688        "open_notification_target" => keys.open_notification_target.as_ref(),
689        "remote_image_paste" => keys.remote_image_paste.as_ref(),
690        "workspace_picker" => keys.workspace_picker.as_ref(),
691        "goto" => keys.goto.as_ref(),
692        "new_workspace" => keys.new_workspace.as_ref(),
693        "new_worktree" => keys.new_worktree.as_ref(),
694        "open_worktree" => keys.open_worktree.as_ref(),
695        "remove_worktree" => keys.remove_worktree.as_ref(),
696        "rename_workspace" => keys.rename_workspace.as_ref(),
697        "close_workspace" => keys.close_workspace.as_ref(),
698        "previous_workspace" => keys.previous_workspace.as_ref(),
699        "next_workspace" => keys.next_workspace.as_ref(),
700        "previous_agent" => keys.previous_agent.as_ref(),
701        "next_agent" => keys.next_agent.as_ref(),
702        "focus_agent" => keys.focus_agent.as_ref(),
703        "new_tab" => keys.new_tab.as_ref(),
704        "rename_tab" => keys.rename_tab.as_ref(),
705        "previous_tab" => keys.previous_tab.as_ref(),
706        "next_tab" => keys.next_tab.as_ref(),
707        "switch_tab" => keys.switch_tab.as_ref(),
708        "switch_workspace" => keys.switch_workspace.as_ref(),
709        "close_tab" => keys.close_tab.as_ref(),
710        "rename_pane" => keys.rename_pane.as_ref(),
711        "edit_scrollback" => keys.edit_scrollback.as_ref(),
712        "focus_pane_left" => keys.focus_pane_left.as_ref(),
713        "focus_pane_down" => keys.focus_pane_down.as_ref(),
714        "focus_pane_up" => keys.focus_pane_up.as_ref(),
715        "focus_pane_right" => keys.focus_pane_right.as_ref(),
716        "cycle_pane_next" => keys.cycle_pane_next.as_ref(),
717        "cycle_pane_previous" => keys.cycle_pane_previous.as_ref(),
718        "last_pane" => keys.last_pane.as_ref(),
719        "split_vertical" => keys.split_vertical.as_ref(),
720        "split_horizontal" => keys.split_horizontal.as_ref(),
721        "close_pane" => keys.close_pane.as_ref(),
722        "zoom" => keys.zoom.as_ref().or(keys.fullscreen.as_ref()),
723        "resize_mode" => keys.resize_mode.as_ref(),
724        "toggle_sidebar" => keys.toggle_sidebar.as_ref(),
725        "navigate_workspace_up" => keys.navigate_workspace_up.as_ref(),
726        "navigate_workspace_down" => keys.navigate_workspace_down.as_ref(),
727        "navigate_pane_left" => keys.navigate_pane_left.as_ref(),
728        "navigate_pane_down" => keys.navigate_pane_down.as_ref(),
729        "navigate_pane_up" => keys.navigate_pane_up.as_ref(),
730        "navigate_pane_right" => keys.navigate_pane_right.as_ref(),
731        _ => None,
732    }?;
733    Some(value.keys())
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn defaults_include_native_tab_navigation() {
742        let bindings = effective_bindings(&KeysSection::default());
743        let next_tab = bindings
744            .iter()
745            .find(|binding| binding.action == "next_tab")
746            .unwrap();
747        assert_eq!(next_tab.keys, vec!["prefix+n"]);
748        assert_eq!(next_tab.source, BindingSource::Default);
749    }
750
751    #[test]
752    fn bindings_include_explicit_tree_paths() {
753        let bindings = effective_bindings(&KeysSection::default());
754        let next_tab = bindings
755            .iter()
756            .find(|binding| binding.action == "next_tab")
757            .unwrap();
758        let new_worktree = bindings
759            .iter()
760            .find(|binding| binding.action == "new_worktree")
761            .unwrap();
762        let focus_left = bindings
763            .iter()
764            .find(|binding| binding.action == "focus_pane_left")
765            .unwrap();
766
767        assert_eq!(next_tab.tree_path, vec!["Tabs", "Navigation"]);
768        assert_eq!(new_worktree.tree_path, vec!["Workspaces", "Worktrees"]);
769        assert_eq!(focus_left.tree_path, vec!["Panes", "Focus"]);
770    }
771
772    #[test]
773    fn empty_string_disables_binding() {
774        let keys = KeysSection {
775            next_tab: Some(KeyValue::One(String::new())),
776            ..Default::default()
777        };
778        let bindings = effective_bindings(&keys);
779        let next_tab = bindings
780            .iter()
781            .find(|binding| binding.action == "next_tab")
782            .unwrap();
783        assert_eq!(next_tab.status, BindingStatus::Disabled);
784        assert!(next_tab.keys.is_empty());
785    }
786
787    #[test]
788    fn command_bindings_are_preserved() {
789        let keys = KeysSection {
790            command: vec![CommandBinding {
791                name: Some("Pretty Which".to_string()),
792                key: Some(KeyValue::One("prefix+?".to_string())),
793                command: Some("ramarivera.pretty-which.open".to_string()),
794                ..Default::default()
795            }],
796            ..Default::default()
797        };
798        let bindings = effective_bindings(&keys);
799        assert!(
800            bindings
801                .iter()
802                .any(|binding| binding.category == Category::Custom
803                    && binding.label == "Pretty Which")
804        );
805    }
806
807    #[test]
808    fn discovery_surfaces_unmodeled_actions_without_duplicating_modeled_ones() {
809        // Simulate a future Herdr release adding a new action that Pretty
810        // Which's static SPECS table does not yet know about.
811        let mut discovered = BTreeMap::new();
812        discovered.insert("future_action".to_string(), vec!["prefix+9".to_string()]);
813        // A modeled action must NOT be duplicated as Discovered.
814        discovered.insert("next_tab".to_string(), vec!["prefix+n".to_string()]);
815
816        let bindings =
817            effective_bindings_with_discovery(&KeysSection::default(), Some(&discovered));
818
819        let future = bindings
820            .iter()
821            .find(|binding| binding.action == "future_action")
822            .expect("unmodeled discovered action is surfaced");
823        assert_eq!(future.category, Category::Discovered);
824        assert_eq!(future.keys, vec!["prefix+9"]);
825        assert_eq!(future.source, BindingSource::Default);
826        assert_eq!(future.label, "Future Action");
827        assert_eq!(future.tree_path, vec!["Discovered", "Unmodeled"]);
828
829        // Modeled actions stay in their native category and are not duplicated
830        // into Discovered.
831        let next_tab_occurrences = bindings
832            .iter()
833            .filter(|binding| binding.action == "next_tab")
834            .count();
835        assert_eq!(next_tab_occurrences, 1);
836        assert_eq!(
837            bindings
838                .iter()
839                .find(|binding| binding.action == "next_tab")
840                .unwrap()
841                .category,
842            Category::Tabs
843        );
844    }
845
846    #[test]
847    fn modeled_actions_covers_all_static_specs() {
848        let modeled = modeled_actions();
849        assert!(modeled.contains("next_tab"));
850        assert!(modeled.contains("split_vertical"));
851        assert_eq!(modeled.len(), SPECS.len());
852    }
853}