Skip to main content

kitchen_shell/
command.rs

1//! What an app can do, declared once: each [`CommandSpec`] has an id, a title, an icon, a line
2//! saying what it does, a group, a key, the JSON Schema of its arguments, when it is enabled (and
3//! why not), and the function that runs it. The [`Registry`] turns that one list into the menus
4//! ([`Registry::menu_button`]), the search rows ([`crate::search`]), the key bindings
5//! ([`Registry::bind_keys`]) and an agent's tools (`kitchen-agent`), so none drifts from the
6//! others: a command is in the search because it is in the list, greyed with its reason
7//! wherever it cannot run.
8
9use std::cell::RefCell;
10use std::rc::Rc;
11
12use gpui_kit::assets::IconName;
13use gpui_kit::component::button::{Button, ButtonVariants as _};
14use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
15use gpui_kit::component::{Icon, Sizable as _, v_flex};
16use gpui_kit::{Action, App, Context, IntoElement, KeyBinding, ParentElement as _, SharedString, Styled as _, Window, div};
17use kitchen_ui::theme::color;
18use schemars::JsonSchema;
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21
22/// Runs a command with its arguments (`null` when it takes none); its result is JSON (`null`
23/// for a plain action), and an error says why it did not run.
24pub type RunFn = Rc<dyn Fn(Value, &mut Window, &mut App) -> Result<Value, String>>;
25/// Whether a command can run now; `Err` says why not (shown greyed in menus and search).
26pub type EnabledFn = Rc<dyn Fn(&App) -> Result<(), String>>;
27/// Whether a toggle command is on (a check mark in menus).
28pub type CheckedFn = Rc<dyn Fn(&App) -> bool>;
29/// Makes a menu's entries when it opens, from what the app holds then (its images, recent
30/// files, the models that fit): see [`Registry::provide`] and [`MenuItem::Provided`].
31pub type MenuProvider = Rc<dyn Fn(&App) -> Vec<MenuItem>>;
32
33/// The action every command key and menu item dispatches: run the command `id` (no arguments).
34#[derive(Clone, Debug, PartialEq, Action)]
35#[action(namespace = kitchen, no_json)]
36pub struct RunCommand {
37    /// The command's id.
38    pub id: SharedString,
39}
40
41/// One thing an app can do.
42#[derive(Clone)]
43pub struct CommandSpec {
44    /// Unique, dotted by area: `file.open`, `hist.find_cells`, `pipeline.add_op`.
45    pub id: SharedString,
46    /// What menus and search show: "Open image…".
47    pub title: SharedString,
48    /// Its icon, in menus and search.
49    pub icon: IconName,
50    /// One line saying what it does (search, tooltips, the agent's tool description).
51    pub hint: SharedString,
52    /// The group it is listed under in search ("File", "Process").
53    pub group: SharedString,
54    /// Its key in GPUI's notation (`secondary-o`: Cmd on macOS, Ctrl elsewhere), if any.
55    pub key: Option<SharedString>,
56    /// The JSON Schema of its arguments, when an agent can pass some ([`CommandSpec::with_args`]).
57    pub args: Option<Value>,
58    /// Whether it only reads (an agent may run it without asking).
59    pub read_only: bool,
60    /// Whether it can run now, and why not.
61    pub enabled: EnabledFn,
62    /// For a toggle: whether it is on.
63    pub checked: Option<CheckedFn>,
64    /// What it does.
65    pub run: RunFn,
66}
67
68impl CommandSpec {
69    /// A plain command: no arguments, no result.
70    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
71        Self::raw(id, title, icon, None, Rc::new(move |_, window, cx| {
72            run(window, cx);
73            Ok(Value::Null)
74        }))
75    }
76
77    /// A command taking arguments `A` (their JSON Schema comes from `A`, and a call whose
78    /// arguments do not read as `A` is refused with the reason) and returning JSON.
79    pub fn with_args<A: DeserializeOwned + JsonSchema + 'static>(
80        id: impl Into<SharedString>,
81        title: impl Into<SharedString>,
82        icon: IconName,
83        run: impl Fn(A, &mut Window, &mut App) -> Result<Value, String> + 'static,
84    ) -> Self {
85        let schema = serde_json::to_value(schemars::schema_for!(A)).ok();
86        Self::raw(id, title, icon, schema, Rc::new(move |args, window, cx| {
87            // A command whose arguments are all optional also runs with none
88            let args = if args.is_null() { Value::Object(Default::default()) } else { args };
89            let a: A = serde_json::from_value(args).map_err(|e| format!("arguments: {e}"))?;
90            run(a, window, cx)
91        }))
92    }
93
94    /// A command from its parts: `args` is its JSON Schema (or `None`), `run` gets the raw JSON.
95    pub fn raw(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, args: Option<Value>, run: RunFn) -> Self {
96        Self {
97            id: id.into(),
98            title: title.into(),
99            icon,
100            hint: SharedString::default(),
101            group: "Commands".into(),
102            key: None,
103            args,
104            read_only: false,
105            enabled: Rc::new(|_| Ok(())),
106            checked: None,
107            run,
108        }
109    }
110
111    /// Sets the line saying what it does.
112    pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
113        self.hint = hint.into();
114        self
115    }
116
117    /// Sets the group it is listed under.
118    pub fn group(mut self, group: impl Into<SharedString>) -> Self {
119        self.group = group.into();
120        self
121    }
122
123    /// Sets its key (`secondary-o`, `f3`, `secondary-shift-s`).
124    pub fn key(mut self, key: impl Into<SharedString>) -> Self {
125        self.key = Some(key.into());
126        self
127    }
128
129    /// Marks it as only reading (an agent may run it without asking).
130    pub fn read_only(mut self) -> Self {
131        self.read_only = true;
132        self
133    }
134
135    /// Sets when it can run (`Err(why)` otherwise).
136    pub fn enabled(mut self, enabled: impl Fn(&App) -> Result<(), String> + 'static) -> Self {
137        self.enabled = Rc::new(enabled);
138        self
139    }
140
141    /// Makes it a toggle, on when `checked` says so.
142    pub fn checked(mut self, checked: impl Fn(&App) -> bool + 'static) -> Self {
143        self.checked = Some(Rc::new(checked));
144        self
145    }
146
147    /// Its key as people read it ("Ctrl+Shift+S", "⌘⇧S" on macOS).
148    pub fn key_label(&self) -> Option<String> {
149        self.key.as_deref().map(key_label)
150    }
151}
152
153/// An entry of a menu.
154#[derive(Clone, Debug, PartialEq)]
155pub enum MenuItem {
156    /// A registered command, by id.
157    Command(SharedString),
158    /// A registered command run with arguments, under its own title: one entry per image, per
159    /// recent file, per model. Greyed with the command's reason when it cannot run.
160    Run {
161        /// What the entry says.
162        title: SharedString,
163        /// Its icon (the command's when `None`).
164        icon: Option<IconName>,
165        /// The command's id.
166        command: SharedString,
167        /// Its arguments.
168        args: Value,
169        /// A check mark (the entry is the current choice).
170        checked: bool,
171    },
172    /// The entries the provider `id` makes when the menu opens ([`Registry::provide`]).
173    Provided(SharedString),
174    /// A line between groups.
175    Separator,
176    /// A heading (not clickable).
177    Label(SharedString),
178    /// A menu inside the menu.
179    Submenu {
180        /// What the entry says.
181        title: SharedString,
182        /// Its icon.
183        icon: Option<IconName>,
184        /// What it holds.
185        items: Vec<MenuItem>,
186    },
187}
188
189impl MenuItem {
190    /// The command `id`.
191    pub fn command(id: impl Into<SharedString>) -> Self {
192        MenuItem::Command(id.into())
193    }
194
195    /// A submenu `title` holding `items`.
196    pub fn submenu(title: impl Into<SharedString>, items: Vec<MenuItem>) -> Self {
197        MenuItem::Submenu { title: title.into(), icon: None, items }
198    }
199
200    /// A submenu `title` with `icon`, holding `items`.
201    pub fn submenu_with_icon(title: impl Into<SharedString>, icon: IconName, items: Vec<MenuItem>) -> Self {
202        MenuItem::Submenu { title: title.into(), icon: Some(icon), items }
203    }
204
205    /// The command `command` run with `args`, titled `title`.
206    pub fn run(title: impl Into<SharedString>, command: impl Into<SharedString>, args: Value) -> Self {
207        MenuItem::Run { title: title.into(), icon: None, command: command.into(), args, checked: false }
208    }
209
210    /// The entries of the provider `id`.
211    pub fn provided(id: impl Into<SharedString>) -> Self {
212        MenuItem::Provided(id.into())
213    }
214
215    /// A heading.
216    pub fn label(text: impl Into<SharedString>) -> Self {
217        MenuItem::Label(text.into())
218    }
219
220    /// Sets the icon of a [`MenuItem::Run`] or a submenu (other entries are unchanged).
221    pub fn icon(mut self, with: IconName) -> Self {
222        match &mut self {
223            MenuItem::Run { icon, .. } | MenuItem::Submenu { icon, .. } => *icon = Some(with),
224            _ => {}
225        }
226        self
227    }
228
229    /// Sets the check mark of a [`MenuItem::Run`] (other entries are unchanged).
230    pub fn checked(mut self, on: bool) -> Self {
231        if let MenuItem::Run { checked, .. } = &mut self {
232            *checked = on;
233        }
234        self
235    }
236}
237
238/// A menu of a menu row or title bar: its title and its entries.
239#[derive(Clone, Debug, PartialEq)]
240pub struct MenuSpec {
241    /// What the button says ("File", "Process").
242    pub title: SharedString,
243    /// The tooltip of its button.
244    pub hint: SharedString,
245    /// Its entries.
246    pub items: Vec<MenuItem>,
247}
248
249impl MenuSpec {
250    /// A menu `title` with `items`.
251    pub fn new(title: impl Into<SharedString>, items: Vec<MenuItem>) -> Self {
252        Self { title: title.into(), hint: SharedString::default(), items }
253    }
254
255    /// Sets the tooltip of its button.
256    pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
257        self.hint = hint.into();
258        self
259    }
260
261    /// The ids of the commands it holds, submenus included, in order.
262    pub fn command_ids(&self) -> Vec<SharedString> {
263        fn walk(items: &[MenuItem], out: &mut Vec<SharedString>) {
264            for i in items {
265                match i {
266                    MenuItem::Command(id) | MenuItem::Run { command: id, .. } => out.push(id.clone()),
267                    MenuItem::Submenu { items, .. } => walk(items, out),
268                    _ => {}
269                }
270            }
271        }
272        let mut out = Vec::new();
273        walk(&self.items, &mut out);
274        out
275    }
276}
277
278/// Every command of an app, shared by its menus, search, keys and agent (a cheap handle: clones
279/// share the same list).
280#[derive(Clone, Default)]
281pub struct Registry {
282    commands: Rc<RefCell<Vec<CommandSpec>>>,
283    providers: Rc<RefCell<Vec<(SharedString, MenuProvider)>>>,
284}
285
286impl Registry {
287    /// A registry holding `commands`.
288    pub fn new(commands: Vec<CommandSpec>) -> Self {
289        let r = Self::default();
290        r.extend(commands);
291        r
292    }
293
294    /// Adds a command, replacing one with the same id.
295    pub fn add(&self, command: CommandSpec) {
296        let mut list = self.commands.borrow_mut();
297        match list.iter_mut().find(|c| c.id == command.id) {
298            Some(c) => *c = command,
299            None => list.push(command),
300        }
301    }
302
303    /// Adds several commands ([`Registry::add`] each).
304    pub fn extend(&self, commands: impl IntoIterator<Item = CommandSpec>) {
305        for c in commands {
306            self.add(c);
307        }
308    }
309
310    /// Removes the command `id`; whether it was there.
311    pub fn remove(&self, id: &str) -> bool {
312        let mut list = self.commands.borrow_mut();
313        let n = list.len();
314        list.retain(|c| c.id != id);
315        list.len() != n
316    }
317
318    /// The command `id`.
319    pub fn get(&self, id: &str) -> Option<CommandSpec> {
320        self.commands.borrow().iter().find(|c| c.id == id).cloned()
321    }
322
323    /// Every command, in the order added.
324    pub fn all(&self) -> Vec<CommandSpec> {
325        self.commands.borrow().clone()
326    }
327
328    /// How many commands it holds.
329    pub fn len(&self) -> usize {
330        self.commands.borrow().len()
331    }
332
333    /// Whether it holds none.
334    pub fn is_empty(&self) -> bool {
335        self.commands.borrow().is_empty()
336    }
337
338    /// Registers the menu provider `id` (replacing one with the same id): a
339    /// [`MenuItem::Provided`] naming it shows the entries it makes when the menu opens.
340    pub fn provide(&self, id: impl Into<SharedString>, provider: impl Fn(&App) -> Vec<MenuItem> + 'static) {
341        let id = id.into();
342        let mut list = self.providers.borrow_mut();
343        list.retain(|(p, _)| *p != id);
344        list.push((id, Rc::new(provider)));
345    }
346
347    /// The entries the provider `id` makes now (none when there is no such provider).
348    pub fn provided(&self, id: &str, cx: &App) -> Vec<MenuItem> {
349        let provider = self.providers.borrow().iter().find(|(p, _)| p == id).map(|(_, f)| f.clone());
350        provider.map(|f| f(cx)).unwrap_or_default()
351    }
352
353    /// Whether `id` can run now (`Err` says why not, or that there is no such command).
354    pub fn check(&self, id: &str, cx: &App) -> Result<(), String> {
355        let c = self.get(id).ok_or_else(|| format!("no command {id:?}"))?;
356        (c.enabled)(cx)
357    }
358
359    /// Runs `id` with `args` (`null` for none), when it can run now.
360    pub fn run(&self, id: &str, args: Value, window: &mut Window, cx: &mut App) -> Result<Value, String> {
361        let c = self.get(id).ok_or_else(|| format!("no command {id:?}"))?;
362        (c.enabled)(cx)?;
363        (c.run)(args, window, cx)
364    }
365
366    /// Binds every command's key to [`RunCommand`] (globally; `context` limits them to a part of
367    /// the window, as GPUI's key contexts do). A key that does not parse is skipped and named
368    /// in the result.
369    pub fn bind_keys(&self, context: Option<&str>, cx: &mut App) -> Vec<String> {
370        let mut bindings = Vec::new();
371        let mut bad = Vec::new();
372        let context = match context.map(gpui_kit::KeyBindingContextPredicate::parse).transpose() {
373            Ok(c) => c.map(Rc::new),
374            Err(e) => return vec![format!("context {context:?}: {e}")],
375        };
376        for c in self.all() {
377            let Some(key) = c.key.clone() else { continue };
378            match KeyBinding::load(&key, Box::new(RunCommand { id: c.id.clone() }), context.clone(), false, None, cx) {
379                Ok(b) => bindings.push(b),
380                Err(e) => bad.push(format!("{}: {key}: {e:?}", c.id)),
381            }
382        }
383        cx.bind_keys(bindings);
384        bad
385    }
386
387    /// A ghost button titled as `menu`, opening its entries: each command with its icon, key
388    /// and check mark; a command that cannot run now is greyed with its reason under it.
389    pub fn menu_button(&self, id: impl Into<SharedString>, menu: &MenuSpec) -> gpui_kit::AnyElement {
390        let registry = self.clone();
391        let items = Rc::new(menu.items.clone());
392        let mut button = Button::new(id.into()).ghost().small().label(menu.title.clone());
393        if !menu.hint.is_empty() {
394            button = button.tooltip(menu.hint.clone());
395        }
396        // Long menus (an entry per image or channel) scroll
397        button.dropdown_menu(move |m, window, cx| registry.fill_menu(m.min_w(gpui_kit::px(240.)).scrollable(true).max_h(gpui_kit::px(560.)), &items, window, cx)).into_any_element()
398    }
399
400    /// Adds `items` to `menu` (as [`Registry::menu_button`] does).
401    pub fn fill_menu(&self, mut menu: PopupMenu, items: &[MenuItem], window: &mut Window, cx: &mut Context<PopupMenu>) -> PopupMenu {
402        for item in items {
403            menu = match item {
404                MenuItem::Separator => menu.separator(),
405                MenuItem::Label(text) => menu.label(text.clone()),
406                MenuItem::Submenu { title, icon, items } => {
407                    let (registry, items) = (self.clone(), items.clone());
408                    menu.submenu_with_icon(icon.map(Icon::new), title.clone(), window, cx, move |m, window, cx| registry.fill_menu(m, &items, window, cx))
409                }
410                MenuItem::Command(id) => match self.get(id) {
411                    Some(c) => menu.item(self.menu_item(&c, cx)),
412                    // A menu naming a command the app did not register shows it, greyed
413                    None => menu.item(PopupMenuItem::new(format!("{id} (not registered)")).disabled(true)),
414                },
415                MenuItem::Run { title, icon, command, args, checked } => match self.get(command) {
416                    Some(c) => menu.item(self.run_item(&c, title, icon.unwrap_or(c.icon), args, *checked, cx)),
417                    None => menu.item(PopupMenuItem::new(format!("{title} ({command} not registered)")).disabled(true)),
418                },
419                MenuItem::Provided(id) => {
420                    let items = self.provided(id, cx);
421                    self.fill_menu(menu, &items, window, cx)
422                }
423            };
424        }
425        menu
426    }
427
428    /// An entry running `c` with `args` under `title`.
429    fn run_item(&self, c: &CommandSpec, title: &SharedString, icon: IconName, args: &Value, checked: bool, cx: &App) -> PopupMenuItem {
430        match (c.enabled)(cx) {
431            Ok(()) => {
432                let (registry, id, args) = (self.clone(), c.id.clone(), args.clone());
433                PopupMenuItem::new(title.clone()).icon(Icon::new(icon)).checked(checked).on_click(move |_, window, cx| {
434                    if let Err(e) = registry.run(&id, args.clone(), window, cx) {
435                        eprintln!("{id}: {e}");
436                    }
437                })
438            }
439            Err(why) => {
440                let title = title.clone();
441                PopupMenuItem::element(move |_, _| {
442                    v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
443                })
444                .icon(Icon::new(icon))
445                .disabled(true)
446            }
447        }
448    }
449
450    fn menu_item(&self, c: &CommandSpec, cx: &App) -> PopupMenuItem {
451        let action = Box::new(RunCommand { id: c.id.clone() });
452        match (c.enabled)(cx) {
453            Ok(()) => {
454                let (registry, id) = (self.clone(), c.id.clone());
455                let checked = c.checked.as_ref().is_some_and(|f| f(cx));
456                // The action shows the key; the handler runs the command (it wins over the action)
457                PopupMenuItem::new(c.title.clone()).icon(Icon::new(c.icon)).checked(checked).action(action).on_click(move |_, window, cx| {
458                    if let Err(e) = registry.run(&id, Value::Null, window, cx) {
459                        eprintln!("{id}: {e}");
460                    }
461                })
462            }
463            Err(why) => {
464                let title = c.title.clone();
465                PopupMenuItem::element(move |_, _| {
466                    v_flex().child(div().text_color(color::text_disabled()).child(title.clone())).child(div().text_xs().text_color(color::text_muted()).child(why.clone()))
467                })
468                .icon(Icon::new(c.icon))
469                .disabled(true)
470            }
471        }
472    }
473}
474
475/// A key in GPUI's notation as people read it: `secondary-shift-s` is "Ctrl+Shift+S" (and
476/// "⌘⇧S" on macOS), `f3` is "F3".
477pub fn key_label(key: &str) -> String {
478    // A sequence of keystrokes is separated by spaces
479    key.split_whitespace().map(stroke_label).collect::<Vec<_>>().join(" ")
480}
481
482fn stroke_label(stroke: &str) -> String {
483    let mac = cfg!(target_os = "macos");
484    let parts: Vec<&str> = stroke.split('-').collect();
485    // `ctrl--` (the minus key) splits into an empty last part
486    let (mods, key) = match parts.as_slice() {
487        [mods @ .., "", ""] => (mods, "-"),
488        [mods @ .., key] => (mods, *key),
489        [] => (&[][..], ""),
490    };
491    let mut out: Vec<String> = mods
492        .iter()
493        .map(|m| match (*m, mac) {
494            ("secondary" | "cmd", true) => "⌘".into(),
495            ("secondary" | "ctrl", false) => "Ctrl".into(),
496            ("ctrl", true) => "⌃".into(),
497            ("cmd", false) => "Super".into(),
498            ("shift", true) => "⇧".into(),
499            ("shift", false) => "Shift".into(),
500            ("alt", true) => "⌥".into(),
501            ("alt", false) => "Alt".into(),
502            (other, _) => other.to_string(),
503        })
504        .collect();
505    let key = match key {
506        k if k.len() == 1 => k.to_uppercase(),
507        k if k.starts_with('f') && k[1..].parse::<u8>().is_ok() => k.to_uppercase(),
508        "escape" => "Esc".into(),
509        k => {
510            let mut c = k.chars();
511            c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
512        }
513    };
514    out.push(key);
515    if mac { out.concat() } else { out.join("+") }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn keys_read_as_people_write_them() {
524        if cfg!(target_os = "macos") {
525            assert_eq!(key_label("secondary-shift-s"), "⌘⇧S");
526        } else {
527            assert_eq!(key_label("secondary-shift-s"), "Ctrl+Shift+S");
528            assert_eq!(key_label("secondary-,"), "Ctrl+,");
529            assert_eq!(key_label("ctrl--"), "Ctrl+-");
530            assert_eq!(key_label("secondary-k secondary-s"), "Ctrl+K Ctrl+S");
531        }
532        assert_eq!(key_label("f3"), "F3");
533        assert_eq!(key_label("escape"), "Esc");
534    }
535
536    #[test]
537    fn menus_list_their_commands() {
538        let m = MenuSpec::new("File", vec![MenuItem::command("file.open"), MenuItem::Separator, MenuItem::submenu("Recent", vec![MenuItem::command("file.recent.1")])]);
539        assert_eq!(m.command_ids(), vec![SharedString::from("file.open"), SharedString::from("file.recent.1")]);
540    }
541
542    #[test]
543    fn a_registry_replaces_a_command_with_the_same_id() {
544        let r = Registry::new(vec![CommandSpec::new("a", "A", IconName::Plus, |_, _| {}), CommandSpec::new("b", "B", IconName::Plus, |_, _| {})]);
545        r.add(CommandSpec::new("a", "A again", IconName::Plus, |_, _| {}));
546        assert_eq!(r.len(), 2);
547        assert_eq!(r.get("a").unwrap().title.as_ref(), "A again");
548        assert!(r.remove("b") && !r.remove("b"));
549    }
550
551    #[derive(serde::Deserialize, JsonSchema)]
552    #[allow(dead_code)]
553    struct Threshold {
554        /// The level, 0–1.
555        level: f64,
556    }
557
558    #[test]
559    fn typed_commands_publish_their_schema() {
560        let c = CommandSpec::with_args::<Threshold>("t", "Threshold", IconName::Plus, |a, _, _| Ok(serde_json::json!(a.level)));
561        let schema = c.args.unwrap();
562        assert_eq!(schema["properties"]["level"]["type"], "number");
563        assert_eq!(schema["required"][0], "level");
564    }
565}