//! 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)>; /// 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, description: Option, icon: Icon, shortcut: Option, timestamp: Option, /// Marks the theme or appearance in use. current: bool, /// Opens a submenu instead of running. submenu: Option, run: Option, /// What a search matches, most important first. terms: Vec, } impl PaletteItem { fn new(group: &'static str, title: impl Into, icon: impl Into) -> 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) + '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) -> Self { self.shortcut = Some(shortcut.into()); self } fn description(mut self, description: impl Into) -> 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 { let terms: Vec = 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) { 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) { 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) { 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) { 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 { 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 { 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(©_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 { 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 { let scratch = super::scratch_dir(); let current = (!self.projects.is_empty()).then(|| self.contextual_project()); let mut order: Vec = 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 { let mut threads: Vec<(usize, Entity)> = 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) { 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, input: Entity, stack: Vec, highlight: usize, scroll: ScrollHandle, _subscription: Subscription, } impl EventEmitter for CommandPalette {} impl CommandPalette { fn new(workspace: WeakEntity, view: PaletteView, window: &mut Window, cx: &mut Context) -> 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) { 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 { 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 = query.split_whitespace().map(str::to_string).collect(); let items = workspace.read(cx).palette_items(self.view(), !words.is_empty(), cx); let items: Vec = 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) { let results = self.results(cx); if results.is_empty() { return; } self.highlight = (self.highlight as isize + delta).rem_euclid(results.len() as isize) as usize; // The list's children include a heading per group. let headings = results[..=self.highlight] .iter() .enumerate() .filter(|(ix, item)| *ix == 0 || results[ix - 1].group != item.group) .count(); self.scroll.scroll_to_item(self.highlight + headings); cx.notify(); } fn confirm(&mut self, window: &mut Window, cx: &mut Context) { 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) { 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) { 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) { let keystroke = &event.keystroke; if !keystroke.modifiers.control || keystroke.modifiers.shift || keystroke.modifiers.alt { return; } let Ok(digit) = keystroke.key.parse::() 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) -> 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 = 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() .flex_none() .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)) .flex_none() .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, 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")), ) } }