Vectrum
Sign In
764 lines · 31.5 KB · Rust
//! The command palette (Ctrl+K), as T3 Code has it: Actions and Recent
//! Threads at rest, plus Projects, Settings and Threads while searching,
//! with submenus for starting a thread in a project, the theme and the
//! appearance. A leading ">" searches actions only.

use std::rc::Rc;

use gpui_kit::component::{
    Icon, IconName, WindowExt as _, h_flex,
    input::{Input, InputEvent, InputState},
    notification::Notification,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::actions::relative_time_long;
use super::Workspace;
use crate::settings::{self, ThemeMode};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalStyled as _};
use crate::ui::themes;
use crate::{Dismiss, PaletteBack, SelectNext, SelectPrevious};

const RECENT_THREADS: usize = 12;

/// Where the palette is: its root, or one of its submenus.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaletteView {
    Root,
    NewThreadIn,
    Themes,
    Appearance,
}

pub(super) enum PaletteEvent {
    Dismiss,
}

type Run = Rc<dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>)>;

/// One row of the palette.
#[derive(Clone)]
pub(super) struct PaletteItem {
    group: &'static str,
    title: SharedString,
    /// Set in bold after the title: "New thread in **project**".
    emphasis: Option<SharedString>,
    description: Option<SharedString>,
    icon: Icon,
    shortcut: Option<SharedString>,
    timestamp: Option<String>,
    /// Marks the theme or appearance in use.
    current: bool,
    /// Opens a submenu instead of running.
    submenu: Option<PaletteView>,
    run: Option<Run>,
    /// What a search matches, most important first.
    terms: Vec<String>,
}

impl PaletteItem {
    fn new(group: &'static str, title: impl Into<SharedString>, icon: impl Into<Icon>) -> Self {
        let title = title.into();
        Self {
            group,
            terms: vec![title.to_string()],
            title,
            emphasis: None,
            description: None,
            icon: icon.into(),
            shortcut: None,
            timestamp: None,
            current: false,
            submenu: None,
            run: None,
        }
    }

    fn run(mut self, run: impl Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + 'static) -> Self {
        self.run = Some(Rc::new(run));
        self
    }

    fn submenu(mut self, view: PaletteView) -> Self {
        self.submenu = Some(view);
        self
    }

    fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
        self.shortcut = Some(shortcut.into());
        self
    }

    fn description(mut self, description: impl Into<SharedString>) -> Self {
        let description = description.into();
        self.terms.push(description.to_string());
        self.description = Some(description);
        self
    }

    fn terms(mut self, terms: &[&str]) -> Self {
        self.terms.extend(terms.iter().map(|term| term.to_string()));
        self
    }

    /// How well the query matches: every word must appear in a term; an
    /// exact match beats a prefix beats a substring, and earlier terms win.
    fn score(&self, words: &[String]) -> Option<u32> {
        let terms: Vec<String> = self.terms.iter().map(|term| term.to_lowercase()).collect();
        let mut total = 0;
        for word in words {
            let best = terms
                .iter()
                .enumerate()
                .filter_map(|(ix, term)| {
                    let rank = if term == word {
                        0
                    } else if term.starts_with(word.as_str()) {
                        1
                    } else if term.contains(word.as_str()) {
                        2
                    } else {
                        return None;
                    };
                    Some(rank * 10 + ix.min(9) as u32)
                })
                .min()?;
            total += best;
        }
        Some(total)
    }
}

/// Settings the palette finds by name, with the page they're on.
const SETTINGS: &[(&str, &str)] = &[
    ("Model", "General"),
    ("Permissions", "General"),
    ("Workspace", "General"),
    ("Submodules", "General"),
    ("Text generation model", "General"),
    ("Start from origin", "General"),
    ("Add project starts in", "General"),
    ("Project grouping", "General"),
    ("Project order", "General"),
    ("Auto-resume limited threads", "General"),
    ("Snooze limited threads", "General"),
    ("Working section (beta)", "General"),
    ("Auto-settle merged threads", "General"),
    ("Auto-settle inactive threads", "General"),
    ("Unpin confirmation", "General"),
    ("Settle confirmation", "General"),
    ("Delete confirmation", "General"),
    ("Quit shortcut", "General"),
    ("Thread notifications", "Behavior"),
    ("In-app notifications", "Behavior"),
    ("Time format", "Behavior"),
    ("Response streaming", "Behavior"),
    ("Fold finished turns", "Behavior"),
    ("Show costs", "Behavior"),
    ("Hide whitespace changes", "Behavior"),
    ("Diff layout", "Behavior"),
    ("Send shortcut", "Behavior"),
    ("Follow-up behavior", "Behavior"),
    ("Background activity", "Behavior"),
    ("Thread cards", "Appearance"),
    ("Theme", "Themes"),
    ("Providers", "Providers"),
    ("Usage", "Usage"),
    ("Allow remote connections", "Connections"),
    ("Keybindings", "Keybindings"),
    ("Archived threads", "Archive"),
    ("About", "About"),
];

impl Workspace {
    /// Opens the palette, or closes it when it's open at the root.
    pub(super) fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if self.palette.is_some() {
            self.close_palette(window, cx);
        } else {
            self.open_palette(PaletteView::Root, window, cx);
        }
    }

    pub(super) fn open_palette(&mut self, view: PaletteView, window: &mut Window, cx: &mut Context<Self>) {
        let workspace = cx.weak_entity();
        let palette = cx.new(|cx| CommandPalette::new(workspace, view, window, cx));
        let subscription = cx.subscribe_in(&palette, window, |this, _, event: &PaletteEvent, window, cx| match event {
            PaletteEvent::Dismiss => this.close_palette(window, cx),
        });
        let input = palette.read(cx).input.clone();
        input.update(cx, |input, cx| input.focus(window, cx));
        self.palette = Some((palette, subscription));
        cx.notify();
    }

    pub(super) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if self.palette.take().is_some() {
            if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
                chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
            }
            cx.notify();
        }
    }

    /// Switches between System, Light and Dark, saying which in a toast.
    pub(super) fn cycle_appearance(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let next = match settings::get(|settings| settings.theme_mode) {
            ThemeMode::System => ThemeMode::Light,
            ThemeMode::Light => ThemeMode::Dark,
            ThemeMode::Dark => ThemeMode::System,
        };
        themes::set_mode(next, window, cx);
        window.push_notification(Notification::info(format!("Appearance: {}", mode_label(next))), cx);
        self.settings_changed(window, cx);
    }

    /// The palette's rows for a view.
    fn palette_items(&self, view: PaletteView, searching: bool, cx: &App) -> Vec<PaletteItem> {
        match view {
            PaletteView::Root => {
                let mut items = self.palette_actions(cx);
                if searching {
                    items.extend(self.palette_projects(cx));
                    items.extend(SETTINGS.iter().map(|(title, page)| {
                        let page = *page;
                        PaletteItem::new("Settings", *title, IconName::Settings)
                            .description(format!("Settings · {page}"))
                            .run(move |this, window, cx| this.open_settings_page(page, window, cx))
                    }));
                    items.extend(self.palette_threads(usize::MAX, "Threads", cx));
                } else {
                    items.extend(self.palette_threads(RECENT_THREADS, "Recent Threads", cx));
                }
                items
            }
            PaletteView::NewThreadIn => self.palette_new_thread_in(cx),
            PaletteView::Themes => {
                let (mode, light, dark) =
                    settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone()));
                let showing_dark = metal::palette(cx).dark;
                themes::all()
                    .iter()
                    .enumerate()
                    .map(|(ix, theme)| {
                        let current = (theme.palette.dark && theme.id == dark && (showing_dark || mode == ThemeMode::Dark))
                            || (!theme.palette.dark && theme.id == light && (!showing_dark || mode == ThemeMode::Light));
                        let mut item = PaletteItem::new("Themes", theme.name, IconName::Palette)
                            .description(if theme.palette.dark { "Dark" } else { "Light" })
                            .run(move |this, window, cx| {
                                themes::pick(&themes::all()[ix], window, cx);
                                this.settings_changed(window, cx);
                            });
                        item.current = current;
                        item
                    })
                    .collect()
            }
            PaletteView::Appearance => {
                let mode = settings::get(|settings| settings.theme_mode);
                [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark]
                    .into_iter()
                    .map(|option| {
                        let mut item = PaletteItem::new("Appearance", mode_label(option), Lucide::Monitor).run(move |this, window, cx| {
                            themes::set_mode(option, window, cx);
                            this.settings_changed(window, cx);
                        });
                        item.current = option == mode;
                        item
                    })
                    .collect()
            }
        }
    }

    fn palette_actions(&self, cx: &App) -> Vec<PaletteItem> {
        let mut items = Vec::new();
        let contextual = (!self.projects.is_empty()).then(|| self.contextual_project());
        if let Some(ix) = contextual {
            let name = self.projects[ix].name.clone();
            let mut item = PaletteItem::new("Actions", "New thread in ", Lucide::SquarePen)
                .shortcut("Ctrl+N")
                .terms(&[&format!("New thread in {name}"), "new chat"])
                .run(move |this, window, cx| this.new_thread(ix, window, cx));
            item.emphasis = Some(name);
            items.push(item);
            items.push(
                PaletteItem::new("Actions", "New thread in...", Lucide::SquarePen)
                    .terms(&["project", "new chat"])
                    .submenu(PaletteView::NewThreadIn),
            );
        }
        items.push(
            PaletteItem::new("Actions", "New thread without a project", Lucide::MessageSquareDashed)
                .shortcut("Ctrl+Alt+N")
                .terms(&["scratch", "no project"])
                .run(|this, window, cx| this.new_thread_without_project(window, cx)),
        );
        if let Some(session) = self.active.clone().filter(|session| !session.read(cx).is_blank()) {
            let state = session.read(cx);
            let (title, value) = match &state.pull_request {
                Some(pr) => ("Copy PR link", pr.url.clone()),
                None => ("Copy thread ID", state.id.clone()),
            };
            let copy_session = session.clone();
            items.push(
                PaletteItem::new("Actions", title, Lucide::Link)
                    .description(value)
                    .shortcut("Ctrl+Shift+C")
                    .run(move |_, window, cx| Workspace::copy_thread_reference(&copy_session, window, cx)),
            );
            items.push(
                PaletteItem::new("Actions", "Restart agent session", Lucide::RotateCcw).terms(&["reset", "provider"]).run(
                    move |_, window, cx| {
                        let restarted = session.update(cx, |session, cx| session.restart_agent(cx));
                        if restarted {
                            window.push_notification(
                                Notification::success("Your next message starts a fresh session.").title("Agent session will restart"),
                                cx,
                            );
                        } else {
                            window.push_notification(
                                Notification::error("The agent is busy or not running.").title("Unable to run command"),
                                cx,
                            );
                        }
                    },
                ),
            );
        }
        items.push(
            PaletteItem::new("Actions", "Add project", Lucide::FolderPlus)
                .terms(&["open folder", "local folder"])
                .run(|this, window, cx| this.prompt_for_project(window, cx)),
        );
        items.push(
            PaletteItem::new("Actions", "Change theme", IconName::Palette)
                .shortcut("Ctrl+Alt+A")
                .terms(&["color scheme", "colors"])
                .submenu(PaletteView::Themes),
        );
        items.push(
            PaletteItem::new("Actions", "Change appearance", Lucide::Monitor)
                .shortcut("Ctrl+Alt+Shift+A")
                .terms(&["light", "dark", "system", "mode"])
                .submenu(PaletteView::Appearance),
        );
        items.push(
            PaletteItem::new("Actions", "Open usage", Lucide::ChartNoAxesColumn)
                .shortcut("Ctrl+U")
                .terms(&["cost", "tokens", "limits"])
                .run(|this, window, cx| this.open_settings_page("Usage", window, cx)),
        );
        items.push(
            PaletteItem::new("Actions", "Open settings", IconName::Settings)
                .shortcut("Ctrl+,")
                .terms(&["preferences"])
                .run(|this, window, cx| this.open_settings(window, cx)),
        );
        if let Some(ix) = contextual {
            items.push(
                PaletteItem::new("Actions", "Project settings", IconName::Folder)
                    .description(self.projects[ix].name.clone())
                    .terms(&["rename", "remove"])
                    .run(move |this, window, cx| this.open_project_settings(ix, window, cx)),
            );
        }
        items
    }

    /// `{machine} · {path}`, the description of a project row.
    fn project_description(&self, ix: usize) -> String {
        let project = &self.projects[ix];
        let machine = match &project.environment {
            Some(env) => self.environment_name(env),
            None => self.hostname.to_string(),
        };
        format!("{machine} · {}", super::display_path(&project.path))
    }

    fn palette_projects(&self, cx: &App) -> Vec<PaletteItem> {
        let scratch = super::scratch_dir();
        self.ordered_projects(cx)
            .into_iter()
            .filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref())
            .map(|ix| {
                PaletteItem::new("Projects", self.projects[ix].name.clone(), IconName::Folder)
                    .description(self.project_description(ix))
                    .run(move |this, window, cx| this.new_thread(ix, window, cx))
            })
            .collect()
    }

    fn palette_new_thread_in(&self, cx: &App) -> Vec<PaletteItem> {
        let scratch = super::scratch_dir();
        let current = (!self.projects.is_empty()).then(|| self.contextual_project());
        let mut order: Vec<usize> = self
            .ordered_projects(cx)
            .into_iter()
            .filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref())
            .collect();
        if let Some(current) = current
            && let Some(position) = order.iter().position(|&ix| ix == current)
        {
            order.remove(position);
            order.insert(0, current);
        }
        let mut items = Vec::new();
        let mut number = 0;
        for (row, ix) in order.into_iter().enumerate() {
            if row == 1 {
                items.push(self.no_project_item());
            }
            number += 1;
            let mut item = PaletteItem::new("Projects", self.projects[ix].name.clone(), IconName::Folder)
                .description(self.project_description(ix))
                .run(move |this, window, cx| this.new_thread(ix, window, cx));
            if number <= 9 {
                item = item.shortcut(format!("Ctrl+{number}"));
            }
            items.push(item);
        }
        if items.len() <= 1 {
            items.push(self.no_project_item());
        }
        items
    }

    fn no_project_item(&self) -> PaletteItem {
        PaletteItem::new("Projects", "No project", Lucide::MessageSquareDashed)
            .shortcut("Ctrl+Alt+N")
            .run(|this, window, cx| this.new_thread_without_project(window, cx))
    }

    fn palette_threads(&self, limit: usize, group: &'static str, cx: &App) -> Vec<PaletteItem> {
        let mut threads: Vec<(usize, Entity<crate::session::Session>)> = self
            .projects
            .iter()
            .enumerate()
            .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
            .filter(|(_, session)| {
                let state = session.read(cx);
                !state.archived && !state.is_blank()
            })
            .collect();
        threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
        threads
            .into_iter()
            .take(limit)
            .map(|(ix, session)| {
                let state = session.read(cx);
                let project = &self.projects[ix];
                let mut description = project.name.to_string();
                if let Some(branch) = self.thread_branch(&session, cx) {
                    description.push_str(&format!(" · #{branch}"));
                }
                if self.active.as_ref() == Some(&session) {
                    description.push_str(" · Current thread");
                }
                let mut item = PaletteItem::new(group, state.title.clone(), Lucide::MessageSquare).description(description);
                item.timestamp = Some(relative_time_long(state.updated));
                item.run(move |this, window, cx| {
                    if let Some(ix) = this.project_of(&session) {
                        this.select(ix, session.clone(), window, cx);
                    }
                })
            })
            .collect()
    }

    /// Starts a thread without a project, in the scratch folder.
    pub(super) fn new_thread_without_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(path) = super::scratch_dir() else { return };
        let ix = self.ensure_project(path, Some("No project"));
        self.new_thread(ix, window, cx);
    }
}

fn mode_label(mode: ThemeMode) -> &'static str {
    match mode {
        ThemeMode::System => "System",
        ThemeMode::Light => "Light",
        ThemeMode::Dark => "Dark",
    }
}

/// The palette itself.
pub(super) struct CommandPalette {
    workspace: WeakEntity<Workspace>,
    input: Entity<InputState>,
    stack: Vec<PaletteView>,
    highlight: usize,
    scroll: ScrollHandle,
    _subscription: Subscription,
}

impl EventEmitter<PaletteEvent> for CommandPalette {}

impl CommandPalette {
    fn new(workspace: WeakEntity<Workspace>, view: PaletteView, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let input = cx.new(|cx| InputState::new(window, cx));
        let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event {
            InputEvent::Change => {
                this.highlight = 0;
                cx.notify();
            }
            InputEvent::PressEnter { .. } => this.confirm(window, cx),
            _ => {}
        });
        let mut stack = vec![PaletteView::Root];
        if view != PaletteView::Root {
            stack.push(view);
        }
        let palette = Self { workspace, input, stack, highlight: 0, scroll: ScrollHandle::new(), _subscription: subscription };
        palette.sync_placeholder(window, cx);
        palette
    }

    fn view(&self) -> PaletteView {
        *self.stack.last().unwrap_or(&PaletteView::Root)
    }

    fn sync_placeholder(&self, window: &mut Window, cx: &mut Context<Self>) {
        let placeholder = if self.view() == PaletteView::Root { "Search commands, projects, and threads..." } else { "Search..." };
        self.input.update(cx, |input, cx| input.set_placeholder(placeholder, window, cx));
    }

    /// The rows that match the query, in their groups, best first.
    fn results(&self, cx: &App) -> Vec<PaletteItem> {
        let Some(workspace) = self.workspace.upgrade() else { return Vec::new() };
        let raw = self.input.read(cx).value().to_string();
        let actions_only = self.view() == PaletteView::Root && raw.trim_start().starts_with('>');
        let query = raw.trim_start().trim_start_matches('>').trim().to_lowercase();
        let words: Vec<String> = query.split_whitespace().map(str::to_string).collect();
        let items = workspace.read(cx).palette_items(self.view(), !words.is_empty(), cx);
        let items: Vec<PaletteItem> = items.into_iter().filter(|item| !actions_only || item.group == "Actions").collect();
        if words.is_empty() {
            return items;
        }
        let mut groups: Vec<&'static str> = Vec::new();
        for item in &items {
            if !groups.contains(&item.group) {
                groups.push(item.group);
            }
        }
        let mut scored: Vec<(usize, u32, usize, PaletteItem)> = items
            .into_iter()
            .enumerate()
            .filter_map(|(order, item)| {
                let score = item.score(&words)?;
                let group = groups.iter().position(|group| *group == item.group).unwrap_or(0);
                // Thread titles tie, keeping the most recent first.
                let score = if item.group == "Threads" { 0 } else { score };
                Some((group, score, order, item))
            })
            .collect();
        scored.sort_by_key(|(group, score, order, _)| (*group, *score, *order));
        scored.into_iter().map(|(_, _, _, item)| item).collect()
    }

    fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
        let count = self.results(cx).len();
        if count == 0 {
            return;
        }
        self.highlight = (self.highlight as isize + delta).rem_euclid(count as isize) as usize;
        self.scroll.scroll_to_item(self.highlight);
        cx.notify();
    }

    fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let results = self.results(cx);
        if let Some(item) = results.get(self.highlight).cloned() {
            self.execute(item, window, cx);
        }
    }

    fn execute(&mut self, item: PaletteItem, window: &mut Window, cx: &mut Context<Self>) {
        if let Some(view) = item.submenu {
            self.stack.push(view);
            self.highlight = 0;
            self.input.update(cx, |input, cx| input.set_value("", window, cx));
            self.sync_placeholder(window, cx);
            cx.notify();
            return;
        }
        let Some(run) = item.run.clone() else { return };
        cx.emit(PaletteEvent::Dismiss);
        let _ = self.workspace.update(cx, |this, cx| run(this, window, cx));
    }

    /// Backspace on an empty query leaves a submenu.
    fn back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if self.input.read(cx).value().is_empty() && self.stack.len() > 1 {
            self.stack.pop();
            self.highlight = 0;
            self.sync_placeholder(window, cx);
            cx.notify();
        } else {
            cx.propagate();
        }
    }

    /// Ctrl+1–9 runs the row with that number.
    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
        let keystroke = &event.keystroke;
        if !keystroke.modifiers.control || keystroke.modifiers.shift || keystroke.modifiers.alt {
            return;
        }
        let Ok(digit) = keystroke.key.parse::<usize>() else { return };
        let label = format!("Ctrl+{digit}");
        let results = self.results(cx);
        if let Some(item) = results.into_iter().find(|item| item.shortcut.as_deref() == Some(label.as_str())) {
            cx.stop_propagation();
            self.execute(item, window, cx);
        }
    }
}

impl Render for CommandPalette {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let results = self.results(cx);
        let highlight = self.highlight.min(results.len().saturating_sub(1));
        let in_submenu = self.stack.len() > 1;
        let actions_only = self.input.read(cx).value().trim_start().starts_with('>');
        let mut rows: Vec<AnyElement> = Vec::new();
        let mut last_group = None;
        for (ix, item) in results.iter().enumerate() {
            if last_group != Some(item.group) {
                last_group = Some(item.group);
                rows.push(
                    div()
                        .px(px(10.))
                        .pt(px(if ix == 0 { 4. } else { 10. }))
                        .pb(px(4.))
                        .engraved(p)
                        .child(item.group.to_uppercase())
                        .into_any_element(),
                );
            }
            let selected = ix == highlight;
            let item = item.clone();
            let run_item = item.clone();
            rows.push(
                h_flex()
                    .id(("palette-row", ix))
                    .min_h(px(36.))
                    .px(px(10.))
                    .py(px(6.))
                    .gap(px(10.))
                    .rounded(px(10.))
                    .cursor_pointer()
                    .when(selected, |this| this.bg(p.menu_row_hover))
                    .hover(|style| style.bg(p.menu_row_hover))
                    .on_click(cx.listener(move |this, _, window, cx| this.execute(run_item.clone(), window, cx)))
                    .child(item.icon.clone().size(px(16.)).text_color(p.icon))
                    .child(
                        v_flex()
                            .flex_1()
                            .min_w_0()
                            .child(
                                h_flex()
                                    .min_w_0()
                                    .text_size(px(13.5))
                                    .text_color(p.ink)
                                    .child(div().flex_none().whitespace_nowrap().child(item.title.clone()))
                                    .when_some(item.emphasis.clone(), |this, emphasis| {
                                        this.child(div().min_w_0().truncate().font_weight(FontWeight::SEMIBOLD).child(emphasis))
                                    }),
                            )
                            .when_some(item.description.clone(), |this, description| {
                                this.child(div().truncate().text_size(px(12.)).text_color(p.ink3).child(description))
                            }),
                    )
                    .when(item.current, |this| this.child(metal::tag("Current", p)))
                    .when_some(item.timestamp.clone(), |this, timestamp| {
                        this.child(div().flex_none().min_w(px(48.)).text_right().text_size(px(12.)).text_color(p.ink3).child(timestamp))
                    })
                    .when_some(item.shortcut.clone(), |this, shortcut| this.child(metal::keycap(shortcut, p)))
                    .when(item.submenu.is_some(), |this| {
                        this.child(Icon::new(IconName::ChevronRight).size(px(16.)).text_color(p.ink3))
                    })
                    .into_any_element(),
            );
        }
        let empty = results.is_empty();
        let hint = |keys: Vec<AnyElement>, label: &'static str| {
            h_flex().gap(px(4.)).children(keys).child(div().text_size(px(12.)).text_color(p.ink3).child(label))
        };
        v_flex()
            .key_context("CommandPalette")
            .on_action(cx.listener(|this, _: &SelectPrevious, _, cx| this.move_highlight(-1, cx)))
            .on_action(cx.listener(|this, _: &SelectNext, _, cx| this.move_highlight(1, cx)))
            .on_action(cx.listener(|_, _: &Dismiss, _, cx| cx.emit(PaletteEvent::Dismiss)))
            .on_action(cx.listener(|this, _: &PaletteBack, window, cx| this.back(window, cx)))
            .on_key_down(cx.listener(Self::on_key_down))
            .w(px(600.))
            .max_w_full()
            .rounded(px(18.))
            .bg(p.menu_bg)
            .shadow(p.raise.clone())
            .occlude()
            .child(
                h_flex()
                    .h(px(48.))
                    .px(px(14.))
                    .gap(px(10.))
                    .child(if in_submenu {
                        div()
                            .id("palette-back")
                            .cursor_pointer()
                            .on_click(cx.listener(|this, _, window, cx| {
                                this.stack.pop();
                                this.highlight = 0;
                                this.sync_placeholder(window, cx);
                                cx.notify();
                            }))
                            .child(Icon::new(IconName::ArrowLeft).size(px(16.)).text_color(p.ink2))
                            .into_any_element()
                    } else {
                        Icon::new(IconName::Search).size(px(16.)).text_color(p.field_ink).into_any_element()
                    })
                    .child(div().flex_1().text_size(px(14.)).child(Input::new(&self.input).appearance(false))),
            )
            .child(metal::rule(p))
            .child(
                v_flex()
                    .id("palette-results")
                    .track_scroll(&self.scroll)
                    .max_h(px(448.))
                    .overflow_y_scroll()
                    .p(px(6.))
                    .when(empty, |this| {
                        this.child(
                            div().py(px(40.)).flex().justify_center().text_size(px(13.)).text_color(p.ink3).child(if actions_only {
                                "No matching actions."
                            } else {
                                "No matching commands, projects, or threads."
                            }),
                        )
                    })
                    .children(rows),
            )
            .child(metal::rule(p))
            .child(
                h_flex()
                    .h(px(40.))
                    .px(px(14.))
                    .gap(px(14.))
                    .child(hint(
                        vec![
                            metal::keycap("↑", p).into_any_element(),
                            metal::keycap("↓", p).into_any_element(),
                        ],
                        "Navigate",
                    ))
                    .child(hint(vec![metal::keycap("Enter", p).into_any_element()], "Select"))
                    .when(in_submenu, |this| this.child(hint(vec![metal::keycap("Backspace", p).into_any_element()], "Back")))
                    .child(hint(vec![metal::keycap("Esc", p).into_any_element()], "Close")),
            )
    }
}