From 83365c17e54e54e0599372817edc689f7109988b Mon Sep 17 00:00:00 2001 From: Cameron Sjo Date: Tue, 4 Aug 2026 11:04:14 -0500 Subject: [PATCH] feat: add a command palette and move panes from it Opens with prefix+/ as its own overlay, listing every runnable action alongside its shortcut. Typing filters the list, and matches rank by quality so a query naming one command exactly wins over a longer command that merely contains it. Arrows move the selection, enter runs it, esc closes, and clicking a row runs it. Three of the commands relocate the focused pane and have no shortcut of their own: to an existing space, to a new space, or to a new tab. Choosing an existing space arms the session navigator as a destination picker offering a new space, each space as a new tab there, and each tab to split into. Panes are not offered, since a pane is where a move lands rather than somewhere to send one. The line above the footer says what accepting the selected row will do. The keybind reference is unchanged. Its rows carry the action they describe so the palette can source a single inventory, which means a new keybind shows up in both without a second list to maintain. A move that changes nothing now says so. The server reports "already there" as a success carrying changed: false, which the only caller reading these responses was not looking for. --- docs/next/CHANGELOG.md | 2 + .../website/src/content/docs/ja/keyboard.mdx | 8 + .../website/src/content/docs/keyboard.mdx | 8 + .../src/content/docs/zh-cn/keyboard.mdx | 8 + .../website/src/data/config-reference.json | 6 + src/app/actions.rs | 62 +++- src/app/ids.rs | 7 +- src/app/input/mod.rs | 280 +++++++++++++++++- src/app/input/modal.rs | 146 +++++++-- src/app/input/navigate.rs | 236 +++++++++++++++ src/app/input/overlays.rs | 123 +++++++- src/app/mod.rs | 13 +- src/app/runtime_mutations.rs | 14 +- src/app/state.rs | 15 + src/config/keybinds.rs | 3 + src/config/model.rs | 8 + src/main.rs | 1 + src/ui.rs | 36 ++- src/ui/keybind_help.rs | 265 +++++++++++++---- src/ui/navigator.rs | 24 +- src/ui/palette.rs | 269 +++++++++++++++++ 21 files changed, 1419 insertions(+), 115 deletions(-) create mode 100644 src/ui/palette.rs diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 9d8a1bd4d0..4209308ef6 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Settings and `ui.status_indicators = "symbols"` can now use distinct static shapes for blocked, working, done, idle, and unknown agent states. (#2260) - The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits. +- Added a command palette on `prefix+/` listing every runnable action with its shortcut, filtered as you type and ranked so an exact command name outranks a longer command containing it. +- Added palette commands to move the focused pane to an existing space, a new space, or a new tab. Choosing an existing space opens the session navigator as a destination picker offering a new space, each space as a new tab there, or each tab to split into, with the selected row's effect shown above the footer. ### Fixed - Claude Code confirmation prompts using `Enter to confirm · Esc to cancel` now report `blocked` instead of `idle`. (#2268) diff --git a/docs/next/website/src/content/docs/ja/keyboard.mdx b/docs/next/website/src/content/docs/ja/keyboard.mdx index 24b554c8ec..5b16b61943 100644 --- a/docs/next/website/src/content/docs/ja/keyboard.mdx +++ b/docs/next/website/src/content/docs/ja/keyboard.mdx @@ -17,6 +17,14 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚 いつでも `prefix+?` を押すと、すべての有効なバインドが表示されます。キーバインドヘルプで `/` を押すと、アクションとショートカットを絞り込めます。Backspace でフィルターを編集し、`ctrl+u` でクリアできます。 +`prefix+/` を押すとコマンドパレットが開きます。実行できるすべてのアクションを、そのショートカットとともに一覧表示し、入力に応じて絞り込みます。矢印キーで選択を移動し、`enter` で選択中のコマンドを実行、`esc` で閉じます。行をクリックしても実行できます。 + +一致は入力内容への適合度で並びます。あるコマンド名を正確に入力した場合、その語を含むだけのより長いコマンドより上位になります。 + +フォーカス中のペインを移動する次の 3 つのコマンドには専用のショートカットがないため、パレットから実行します: `move pane to space`、`move pane to new space`、`move pane to new tab`。 + +`move pane to space` はセッションナビゲーターを開いて移動先を選ばせます。同じように絞り込んで `enter` を押します。`+ new space` を選ぶとペイン専用のスペースへ、スペースを選ぶとその中の新しいタブへ、タブを選ぶとそのタブ内に分割して配置されます。フッターの上の行に選択中の行が何をするかが表示され、`esc` を押すと何も移動せずに取り消します。 + ## まずこの 5 つを覚える | アクション | キー | diff --git a/docs/next/website/src/content/docs/keyboard.mdx b/docs/next/website/src/content/docs/keyboard.mdx index 22283518ad..41606c2212 100644 --- a/docs/next/website/src/content/docs/keyboard.mdx +++ b/docs/next/website/src/content/docs/keyboard.mdx @@ -17,6 +17,14 @@ The prefix solves this. Press the prefix key, default `ctrl+b`, and the next key Press `prefix+?` at any time to see every active binding. Press `/` in the keybind help to filter actions and shortcuts; use Backspace to edit the filter or `ctrl+u` to clear it. +Press `prefix+/` to open the command palette. It lists every action you can run, with that action's shortcut alongside it, and filters as you type. Arrow keys move the selection, `enter` runs the selected command, and `esc` closes. Clicking a row runs it too. + +Matches are ranked by how well they answer what you typed, so a query naming one command exactly wins over a longer command that merely contains it. + +Three commands move the focused pane and have no shortcut of their own, so the palette is where you reach them: `move pane to space`, `move pane to new space`, and `move pane to new tab`. + +`move pane to space` opens the session navigator to pick a destination. Filter it the same way, then press `enter`. Pick `+ new space` to give the pane a space of its own, a space to put it in a new tab there, or a tab to split it into that tab. The line above the footer says what the selected row will do, and `esc` cancels without moving anything. + ## Learn these five first | Action | Key | diff --git a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx index d7b0d297e6..7c32c1a3bd 100644 --- a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx +++ b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx @@ -17,6 +17,14 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智 随时按 `prefix+?` 可以查看所有生效的绑定。在快捷键帮助中按 `/` 即可筛选操作和快捷键;使用 Backspace 编辑筛选内容,或按 `ctrl+u` 清除。 +按 `prefix+/` 打开命令面板。它会列出所有可执行的操作,并在旁边显示各自的快捷键,并随输入进行筛选。方向键移动选择,`enter` 执行选中的命令,`esc` 关闭。点击某一行也可以执行它。 + +匹配项按与输入内容的贴合程度排序,因此准确输入某个命令名时,它会排在仅仅包含该词的更长命令之前。 + +有三个移动当前窗格的命令没有自己的快捷键,只能从命令面板执行:`move pane to space`、`move pane to new space` 和 `move pane to new tab`。 + +`move pane to space` 会打开会话导航器让你选择目标:用同样的方式筛选,然后按 `enter`。选中 `+ new space` 会让该窗格独占一个新空间,选中空间会放进该空间的新标签页,选中标签页则会在该标签页内分割。页脚上方的一行会说明选中的行将执行什么操作,按 `esc` 则取消,不移动任何内容。 + ## 先学这五个 | 动作 | 按键 | diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 8cd6f0a156..cfecc23aa8 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -265,6 +265,12 @@ "default": "\"prefix+w\"", "description": "Open the workspace navigation surface." }, + { + "key": "keys.command_palette", + "type": "keybinding", + "default": "\"prefix+/\"", + "description": "Open the command palette, listing every runnable action with its shortcut and filtering as you type." + }, { "key": "keys.goto", "type": "keybinding", diff --git a/src/app/actions.rs b/src/app/actions.rs index 51f7187791..9f27d656c2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -266,6 +266,21 @@ impl AppState { }) } + pub(crate) fn public_pane_id(&self, ws_idx: usize, pane_id: PaneId) -> Option { + let ws = self.workspaces.get(ws_idx)?; + let pane_number = ws.public_pane_number(pane_id)?; + Some(crate::workspace::public_pane_id_for_number( + &ws.id, + pane_number, + )) + } + + pub(crate) fn focused_public_pane_id(&self) -> Option { + let ws_idx = self.active?; + let pane_id = self.workspaces.get(ws_idx)?.focused_pane_id()?; + self.public_pane_id(ws_idx, pane_id) + } + pub(crate) fn pane_focus_target_indices( &self, target: &PaneFocusTarget, @@ -360,15 +375,14 @@ impl AppState { self.navigator.state_filter = None; self.navigator.scroll = 0; self.navigator.expanded_workspaces.clear(); + self.navigator.pending_pane_move = None; for ws in &self.workspaces { self.navigator.expanded_workspaces.insert(ws.id.clone()); } self.mode = Mode::Navigator; - self.navigator.selected = self - .current_navigator_row_index_from(terminal_runtimes) - .unwrap_or(0); + self.reseat_navigator_selection_from(terminal_runtimes); self.ensure_navigator_selection_visible_from(terminal_runtimes); } @@ -385,6 +399,27 @@ impl AppState { let query = self.navigator.query.trim().to_lowercase(); let query_kind = navigator_query_kind(&query, self.navigator.state_filter); let mut rows = Vec::new(); + if self.navigator.pending_pane_move.is_some() { + let row = NavigatorRow { + target: NavigatorTarget::NewWorkspace, + depth: 0, + label: "+ new space".to_string(), + meta: String::new(), + status: crate::detect::AgentState::Unknown, + seen: true, + is_current: false, + is_workspace: false, + is_tab: false, + expanded: false, + search_text: "new space".to_string(), + matched: true, + }; + if !matches!(query_kind, NavigatorQueryKind::Text) + || navigator_matches(&query, &row.search_text) + { + rows.push(row); + } + } for (ws_idx, ws) in self.workspaces.iter().enumerate() { let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes); let activity = workspace_activity_summary(ws, &self.terminals); @@ -453,7 +488,13 @@ impl AppState { if let Some(tab_row) = tab_row.as_mut() { tab_row.matched = tab_matches; } - let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab); + // A pane is where a moved pane lands, not a destination of its own, + // so the armed picker offers spaces and tabs only. + let mut pane_rows = if self.navigator.pending_pane_move.is_some() { + Vec::new() + } else { + self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab) + }; let filtered_panes = match query_kind { NavigatorQueryKind::Empty => pane_rows, NavigatorQueryKind::State(filter) => pane_rows @@ -594,6 +635,17 @@ impl AppState { rows } + /// Arming a move changes which rows exist, so the selection has to be + /// chosen against the row set the user will actually see. + pub(crate) fn reseat_navigator_selection_from( + &mut self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) { + self.navigator.selected = self + .current_navigator_row_index_from(terminal_runtimes) + .unwrap_or(0); + } + fn current_navigator_row_index_from( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, @@ -788,6 +840,8 @@ impl AppState { pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool { match target { + // Exists only as a move destination, so there is nothing to focus. + NavigatorTarget::NewWorkspace => false, NavigatorTarget::Workspace { ws_idx } => { if ws_idx >= self.workspaces.len() { return false; diff --git a/src/app/ids.rs b/src/app/ids.rs index 4bebedc694..a608c05be0 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -29,12 +29,7 @@ impl App { ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option { - let ws = self.state.workspaces.get(ws_idx)?; - let pane_number = ws.public_pane_number(pane_id)?; - Some(crate::workspace::public_pane_id_for_number( - &ws.id, - pane_number, - )) + self.state.public_pane_id(ws_idx, pane_id) } pub(super) fn pane_launch_env( diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index f77821c9de..870344be04 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -51,12 +51,13 @@ mod terminal; pub(crate) use self::{ lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan}, modal::{ - handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, + handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, handle_palette_key, insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text, open_new_workspace_dialog, }, navigate::{ terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action, + NavigateAction, }, settings::open_settings_at, }; @@ -75,6 +76,12 @@ use super::App; // --------------------------------------------------------------------------- impl App { + /// Runs an action chosen from an overlay. `Direct` lands it the same way + /// pressing its shortcut would. + pub(super) fn run_overlay_action(&mut self, action: navigate::NavigateAction) { + self.execute_tui_navigate_action(action, navigate::ActionContext::Direct); + } + pub(super) async fn handle_key( &mut self, key: TerminalKey, @@ -114,8 +121,17 @@ impl App { Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), + Mode::Palette => { + if let Some(action) = handle_palette_key(&mut self.state, key) { + self.run_overlay_action(action); + } + } Mode::Navigator => { - handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) + if let Some(action) = + handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) + { + self.run_overlay_action(action); + } } Mode::Terminal => unreachable!(), }, @@ -908,6 +924,266 @@ mod tests { ) } + fn app_with_two_workspaces() -> App { + let mut app = test_app(); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("alpha"), + crate::workspace::Workspace::test_new("beta"), + ]; + app.state.active = Some(0); + app.state.selected = 0; + app + } + + #[tokio::test] + async fn move_pane_to_space_arms_the_navigator_with_the_focused_pane() { + let mut app = app_with_two_workspaces(); + let focused = app + .state + .focused_public_pane_id() + .expect("workspace has a focused pane"); + + app.run_overlay_action(NavigateAction::MovePaneToSpace); + + assert_eq!(app.state.mode, Mode::Navigator); + assert_eq!(app.state.navigator.pending_pane_move, Some(focused)); + } + + #[tokio::test] + async fn completing_a_move_targets_the_selected_workspace_and_disarms() { + let mut app = app_with_two_workspaces(); + app.run_overlay_action(NavigateAction::MovePaneToSpace); + + let rows = app.state.navigator_rows_from(&app.terminal_runtimes); + let beta_row = rows + .iter() + .position(|row| { + matches!( + row.target, + crate::app::state::NavigatorTarget::Workspace { ws_idx: 1 } + ) + }) + .expect("beta workspace has a navigator row"); + app.state.navigator.selected = beta_row; + let beta_id = app.state.workspaces[1].id.clone(); + let beta_tabs_before = app.state.workspaces[1].tabs.len(); + + app.complete_pending_pane_move(); + + assert_eq!(app.state.navigator.pending_pane_move, None); + assert_eq!(app.state.mode, Mode::Terminal); + let beta = app + .state + .workspaces + .iter() + .find(|ws| ws.id == beta_id) + .expect("beta survives the move"); + assert_eq!( + beta.tabs.len(), + beta_tabs_before + 1, + "the moved pane should land in a new tab in beta" + ); + // Alpha held only the moved pane, and herdr closes an emptied workspace. + assert_eq!(app.state.workspaces.len(), 1); + } + + #[tokio::test] + async fn each_navigator_row_kind_maps_to_its_own_destination() { + use crate::api::schema::PaneMoveDestination; + use crate::app::state::NavigatorTarget; + + let app = app_with_two_workspaces(); + let pane_id = app.state.workspaces[1] + .focused_pane_id() + .expect("beta has a focused pane"); + + let space = app.pane_move_destination_for(NavigatorTarget::Workspace { ws_idx: 1 }); + assert!( + matches!(space, Some(PaneMoveDestination::NewTab { .. })), + "a space row opens a new tab, got {space:?}" + ); + + let tab = app.pane_move_destination_for(NavigatorTarget::Tab { + ws_idx: 1, + tab_idx: 0, + }); + assert!( + matches!( + tab, + Some(PaneMoveDestination::Tab { + target_pane_id: None, + .. + }) + ), + "a tab row splits against the tab's own focused pane, got {tab:?}" + ); + + let new_space = app.pane_move_destination_for(NavigatorTarget::NewWorkspace); + assert!( + matches!(new_space, Some(PaneMoveDestination::NewWorkspace { .. })), + "the new-space row creates a space, got {new_space:?}" + ); + + let pane = app.pane_move_destination_for(NavigatorTarget::Pane { + ws_idx: 1, + tab_idx: 0, + pane_id, + }); + assert_eq!( + pane, tab, + "which pane a move lands beside is layout, not a destination" + ); + } + + fn pane_move_response(changed: bool, reason: Option<&str>) -> String { + let mut move_result = serde_json::json!({ "changed": changed }); + if let Some(reason) = reason { + move_result["reason"] = serde_json::Value::String(reason.to_string()); + } + serde_json::json!({ + "id": "1", + "result": { "type": "pane_move", "move_result": move_result } + }) + .to_string() + } + + #[tokio::test] + async fn a_move_that_changes_nothing_is_reported() { + let mut app = app_with_two_workspaces(); + + app.toast_unchanged_move(&pane_move_response(false, Some("same_tab"))); + + let toast = app + .state + .toast + .as_ref() + .expect("the server calls this a success, so nothing else would report it"); + assert_eq!(toast.title, "pane not moved"); + assert_eq!(toast.context, "it is already in that tab"); + } + + #[tokio::test] + async fn a_zoomed_tab_says_what_to_do_about_it() { + let mut app = app_with_two_workspaces(); + + app.toast_unchanged_move(&pane_move_response(false, Some("zoomed_tab"))); + + let toast = app.state.toast.as_ref().expect("a toast"); + assert_eq!(toast.context, "unzoom the tab first"); + } + + #[tokio::test] + async fn a_move_that_lands_stays_quiet() { + let mut app = app_with_two_workspaces(); + + app.toast_unchanged_move(&pane_move_response(true, None)); + + assert!( + app.state.toast.is_none(), + "a move that actually happened needs no explanation" + ); + } + + #[tokio::test] + async fn an_armed_picker_offers_no_pane_rows() { + let mut app = app_with_two_workspaces(); + app.run_overlay_action(NavigateAction::MovePaneToSpace); + + let rows = app.state.navigator_rows_from(&app.terminal_runtimes); + assert!( + !rows + .iter() + .any(|row| matches!(row.target, crate::app::state::NavigatorTarget::Pane { .. })), + "a pane is where a move lands, not a destination to pick" + ); + assert!( + rows.iter() + .any(|row| matches!(row.target, crate::app::state::NavigatorTarget::NewWorkspace)), + "the armed picker must offer a new space without backing out" + ); + } + + #[tokio::test] + async fn an_armed_picker_does_not_open_on_a_self_move() { + let mut app = app_with_two_workspaces(); + app.run_overlay_action(NavigateAction::MovePaneToSpace); + + let rows = app.state.navigator_rows_from(&app.terminal_runtimes); + let selected = rows + .get(app.state.navigator.selected) + .expect("the seeded selection points at a real row"); + let destination = app.pane_move_destination_for(selected.target.clone()); + assert!( + destination.is_some(), + "the row the picker opens on must be a usable destination, got {selected:?}" + ); + } + + #[tokio::test] + async fn an_unarmed_navigator_still_offers_pane_rows() { + let mut app = app_with_two_workspaces(); + app.state.open_navigator_from(&app.terminal_runtimes); + + let rows = app.state.navigator_rows_from(&app.terminal_runtimes); + assert!( + rows.iter() + .any(|row| matches!(row.target, crate::app::state::NavigatorTarget::Pane { .. })), + "focus switching still needs pane rows" + ); + assert!( + !rows + .iter() + .any(|row| matches!(row.target, crate::app::state::NavigatorTarget::NewWorkspace)), + "the new-space row is a move destination only" + ); + } + + #[tokio::test] + async fn navigator_enter_with_no_matches_keeps_the_picker_open() { + let mut app = app_with_two_workspaces(); + app.state.open_navigator_from(&app.terminal_runtimes); + app.state.navigator.query = "zzzznomatch".to_string(); + assert!( + app.state + .navigator_rows_from(&app.terminal_runtimes) + .is_empty(), + "the filter must match nothing for this test to mean anything" + ); + + app.run_overlay_action(NavigateAction::AcceptNavigatorRow); + + assert_eq!( + app.state.mode, + Mode::Navigator, + "enter on a filter that matches nothing must not discard the picker" + ); + } + + #[tokio::test] + async fn navigator_enter_with_no_matches_keeps_a_move_armed() { + let mut app = app_with_two_workspaces(); + app.run_overlay_action(NavigateAction::MovePaneToSpace); + app.state.navigator.query = "zzzznomatch".to_string(); + + app.run_overlay_action(NavigateAction::AcceptNavigatorRow); + + assert!( + app.state.navigator.pending_pane_move.is_some(), + "a filter that matches nothing must not consume the armed move" + ); + } + + #[tokio::test] + async fn a_disarmed_navigator_still_focuses_on_accept() { + let mut app = app_with_two_workspaces(); + app.state.open_navigator_from(&app.terminal_runtimes); + + app.accept_navigator_row(); + + assert_eq!(app.state.navigator.pending_pane_move, None); + assert_eq!(app.state.mode, Mode::Terminal); + } + #[tokio::test] async fn paste_routes_to_rename_modal_input() { let mut app = test_app(); diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 0a5c13e569..9bc3e217f2 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -14,6 +14,8 @@ use crate::{ layout::NavDirection, }; +use super::navigate::NavigateAction; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ModalAction { Continue, @@ -159,19 +161,18 @@ pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { } } +#[must_use] pub(crate) fn handle_navigator_key( state: &mut AppState, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, key: KeyEvent, -) { +) -> Option { if state.navigator.search_focused { match key.code { KeyCode::Esc => { state.navigator.search_focused = false; } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } + KeyCode::Enter => return Some(NavigateAction::AcceptNavigatorRow), KeyCode::Backspace => { state.navigator.state_filter = None; state.navigator.query.pop(); @@ -197,16 +198,14 @@ pub(crate) fn handle_navigator_key( } _ => {} } - return; + return None; } match key.code { KeyCode::Esc => { leave_modal(state); } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } + KeyCode::Enter => return Some(NavigateAction::AcceptNavigatorRow), KeyCode::Char('/') => { state.navigator.state_filter = None; state.navigator.search_focused = true; @@ -271,6 +270,7 @@ pub(crate) fn handle_navigator_key( } _ => {} } + None } pub(crate) fn insert_navigator_search_text( @@ -307,6 +307,65 @@ pub(super) fn keybind_help_back(state: &mut AppState) { } } +pub(crate) fn open_palette(state: &mut AppState) { + state.mode = Mode::Palette; + state.command_palette.query.clear(); + reset_palette_selection(state); +} + +fn reset_palette_selection(state: &mut AppState) { + state.command_palette.selected = 0; + state.command_palette.scroll = 0; +} + +fn move_palette_selection(state: &mut AppState, delta: i32) { + let count = crate::ui::filtered_palette_commands(state).len(); + if count == 0 { + state.command_palette.selected = 0; + return; + } + let current = state.command_palette.selected.min(count - 1) as i32; + state.command_palette.selected = (current + delta).rem_euclid(count as i32) as usize; + state.ensure_palette_selection_visible(); +} + +/// Enter on a filter matching nothing leaves the palette open. +fn run_palette_selection(state: &mut AppState) -> Option { + let action = crate::ui::filtered_palette_commands(state) + .get(state.command_palette.selected) + .map(|command| command.action)?; + leave_modal(state); + Some(action) +} + +#[must_use] +pub(crate) fn handle_palette_key(state: &mut AppState, key: TerminalKey) -> Option { + let text_char = keybind_help_text_char(key.clone()); + match key.code { + KeyCode::Esc => leave_modal(state), + KeyCode::Enter => return run_palette_selection(state), + KeyCode::Up | KeyCode::BackTab => move_palette_selection(state, -1), + KeyCode::Down | KeyCode::Tab => move_palette_selection(state, 1), + KeyCode::PageUp => move_palette_selection(state, -8), + KeyCode::PageDown => move_palette_selection(state, 8), + KeyCode::Backspace => { + state.command_palette.query.pop(); + reset_palette_selection(state); + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { + state.command_palette.query.clear(); + reset_palette_selection(state); + } + _ => { + if let Some(character) = text_char { + state.command_palette.query.push(character); + reset_palette_selection(state); + } + } + } + None +} + pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { if state.keybind_help.search_focused { let text_char = keybind_help_text_char(key.clone()); @@ -449,6 +508,7 @@ pub(super) fn open_new_tab_dialog(state: &mut AppState) { } pub(super) fn leave_modal(state: &mut AppState) { + state.navigator.pending_pane_move = None; if state.active.is_some() { state.mode = Mode::Terminal; } else { @@ -1887,7 +1947,7 @@ mod tests { state.mode = Mode::Navigator; state.navigator.search_focused = true; - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), @@ -1897,7 +1957,7 @@ mod tests { assert!(!state.navigator.search_focused); assert!(state.navigator.query.is_empty()); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()), @@ -1909,7 +1969,7 @@ mod tests { ); assert!(state.navigator.query.is_empty()); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), @@ -1918,6 +1978,58 @@ mod tests { assert_eq!(state.mode, Mode::Terminal); } + #[test] + fn navigator_enter_defers_the_accept_to_the_dispatcher() { + let mut state = state_with_workspaces(&["alpha", "beta"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.mode = Mode::Navigator; + + for pending in [None, Some("w1p1".to_string())] { + for search_focused in [false, true] { + state.navigator.pending_pane_move = pending.clone(); + state.navigator.search_focused = search_focused; + + let action = handle_navigator_key( + &mut state, + &terminal_runtimes, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(action, Some(NavigateAction::AcceptNavigatorRow)); + assert_eq!(state.navigator.pending_pane_move, pending); + } + } + } + + #[test] + fn navigator_escape_disarms_a_pending_move() { + let mut state = state_with_workspaces(&["alpha", "beta"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.mode = Mode::Navigator; + state.navigator.search_focused = false; + state.navigator.pending_pane_move = Some("w1p1".to_string()); + + let action = handle_navigator_key( + &mut state, + &terminal_runtimes, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(action, None); + assert_eq!(state.navigator.pending_pane_move, None); + } + + #[test] + fn opening_the_navigator_clears_a_stale_pending_move() { + let mut state = state_with_workspaces(&["alpha", "beta"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.navigator.pending_pane_move = Some("w1p1".to_string()); + + state.open_navigator_from(&terminal_runtimes); + + assert_eq!(state.navigator.pending_pane_move, None); + } + #[test] fn navigator_search_escape_blurs_then_next_escape_closes() { let mut state = state_with_workspaces(&["alpha", "beta"]); @@ -1926,7 +2038,7 @@ mod tests { state.navigator.search_focused = true; state.navigator.query = "a".into(); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), @@ -1936,7 +2048,7 @@ mod tests { assert!(!state.navigator.search_focused); assert_eq!(state.navigator.query, "a"); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), @@ -1945,7 +2057,7 @@ mod tests { assert_eq!(state.navigator.selected, 1); assert_eq!(state.navigator.query, "a"); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()), @@ -1955,7 +2067,7 @@ mod tests { assert!(state.navigator.search_focused); assert_eq!(state.navigator.query, "a"); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()), @@ -1963,7 +2075,7 @@ mod tests { assert_eq!(state.navigator.query, "al"); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), @@ -1972,7 +2084,7 @@ mod tests { assert_eq!(state.mode, Mode::Navigator); assert!(!state.navigator.search_focused); - handle_navigator_key( + let _ = handle_navigator_key( &mut state, &terminal_runtimes, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 68941e663f..98937828a9 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -400,6 +400,39 @@ impl App { leave_navigate_mode(&mut self.state); } NavigateAction::Help => super::modal::open_keybind_help(&mut self.state), + NavigateAction::OpenCommandPalette => { + super::modal::open_palette(&mut self.state); + } + NavigateAction::MovePaneToSpace => { + if let Some(pane_id) = self.state.focused_public_pane_id() { + self.state.open_navigator_from(&self.terminal_runtimes); + self.state.navigator.pending_pane_move = Some(pane_id); + self.state + .reseat_navigator_selection_from(&self.terminal_runtimes); + } + } + NavigateAction::MovePaneToNewSpace => { + self.move_focused_pane_via_api( + crate::api::schema::PaneMoveDestination::NewWorkspace { + label: None, + tab_label: None, + }, + ); + leave_navigate_mode(&mut self.state); + } + NavigateAction::MovePaneToNewTab => { + self.move_focused_pane_via_api(crate::api::schema::PaneMoveDestination::NewTab { + workspace_id: None, + label: None, + }); + leave_navigate_mode(&mut self.state); + } + NavigateAction::AcceptNavigatorRow => { + // Returns early: `finish_action_context` would close the picker, + // since a failed accept deliberately leaves the mode unchanged. + self.accept_navigator_row(); + return; + } NavigateAction::Settings => super::settings::open_settings(&mut self.state), NavigateAction::ReloadConfig => { self.runtime_server_reload_config("tui.server.reload_config"); @@ -583,6 +616,161 @@ impl App { ); } + fn move_pane_via_api( + &mut self, + pane_id: String, + destination: crate::api::schema::PaneMoveDestination, + ) { + let response = self.runtime_pane_move( + "tui.pane.move", + crate::api::schema::PaneMoveParams { + pane_id, + destination, + focus: true, + }, + ); + self.toast_api_error("move pane failed", &response); + self.toast_unchanged_move(&response); + } + + /// The server reports "it is already there" as a success carrying + /// `changed: false`, so without this the move reads as nothing happening. + pub(super) fn toast_unchanged_move(&mut self, response: &str) { + let Ok(value) = serde_json::from_str::(response) else { + return; + }; + if value + .pointer("/result/move_result/changed") + .and_then(serde_json::Value::as_bool) + != Some(false) + { + return; + } + let context = match value + .pointer("/result/move_result/reason") + .and_then(serde_json::Value::as_str) + { + Some("zoomed_tab") => "unzoom the tab first", + _ => "it is already in that tab", + }; + let previous_toast = self.state.toast.clone(); + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "pane not moved".to_string(), + context: context.to_string(), + position: None, + target: None, + }); + self.sync_toast_deadline(previous_toast); + } + + pub(crate) fn move_focused_pane_via_api( + &mut self, + destination: crate::api::schema::PaneMoveDestination, + ) { + let Some(pane_id) = self.state.focused_public_pane_id() else { + return; + }; + self.move_pane_via_api(pane_id, destination); + } + + pub(super) fn accept_navigator_row(&mut self) { + if self.state.navigator.pending_pane_move.is_some() { + self.complete_pending_pane_move(); + } else { + self.state + .accept_navigator_selection_from(&self.terminal_runtimes); + } + } + + pub(crate) fn complete_pending_pane_move(&mut self) { + if self.state.navigator.pending_pane_move.is_none() { + return; + } + let destination = self + .state + .navigator_rows_from(&self.terminal_runtimes) + .get(self.state.navigator.selected) + .map(|row| row.target.clone()) + .and_then(|target| self.pane_move_destination_for(target)); + // Leave the move armed when nothing resolved, so the pane id survives. + let Some(destination) = destination else { + return; + }; + let Some(pane_id) = self.state.navigator.pending_pane_move.take() else { + return; + }; + self.move_pane_via_api(pane_id, destination); + self.state.mode = Mode::Terminal; + } + + pub(super) fn pane_move_destination_for( + &self, + target: crate::app::state::NavigatorTarget, + ) -> Option { + use crate::app::state::NavigatorTarget; + match target { + // `public_workspace_id` indexes without a bounds check. + NavigatorTarget::Workspace { ws_idx } => { + Some(crate::api::schema::PaneMoveDestination::NewTab { + workspace_id: Some(self.state.workspaces.get(ws_idx)?.id.clone()), + label: None, + }) + } + NavigatorTarget::Tab { ws_idx, tab_idx } => { + Some(crate::api::schema::PaneMoveDestination::Tab { + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + // The server splits against the tab's focused pane. + target_pane_id: None, + split: crate::api::schema::SplitDirection::Right, + ratio: None, + }) + } + NavigatorTarget::NewWorkspace => { + Some(crate::api::schema::PaneMoveDestination::NewWorkspace { + label: None, + tab_label: None, + }) + } + // Unreachable while armed, since the picker drops pane rows; a pane + // resolves to its tab rather than to a position beside itself. + NavigatorTarget::Pane { + ws_idx, tab_idx, .. + } => Some(crate::api::schema::PaneMoveDestination::Tab { + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + target_pane_id: None, + split: crate::api::schema::SplitDirection::Right, + ratio: None, + }), + } + } + + fn toast_api_error(&mut self, title: &str, response: &str) { + let Ok(value) = serde_json::from_str::(response) else { + tracing::warn!( + title, + "api response was not valid json, dropping error toast" + ); + return; + }; + let Some(message) = value + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + else { + return; + }; + let previous_toast = self.state.toast.clone(); + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: title.to_string(), + context: message.to_string(), + position: None, + target: None, + }); + self.sync_toast_deadline(previous_toast); + } + pub(crate) fn close_focused_pane_via_api_requires_confirmation(&mut self) -> bool { let Some((ws_idx, pane_id)) = self.focused_pane_target() else { return false; @@ -1341,6 +1529,11 @@ pub(crate) enum NavigateAction { SwitchTab(usize), FocusAgent(usize), WorkspacePicker, + OpenCommandPalette, + MovePaneToSpace, + MovePaneToNewSpace, + MovePaneToNewTab, + AcceptNavigatorRow, PreviousWorkspace, NextWorkspace, PreviousAgent, @@ -1476,6 +1669,7 @@ fn non_indexed_action_for_key( (&kb.help, NavigateAction::Help), (&kb.settings, NavigateAction::Settings), (&kb.workspace_picker, NavigateAction::WorkspacePicker), + (&kb.command_palette, NavigateAction::OpenCommandPalette), (&kb.new_workspace, NavigateAction::NewWorkspace), (&kb.new_worktree, NavigateAction::NewWorktree), (&kb.open_worktree, NavigateAction::OpenWorktree), @@ -1758,6 +1952,27 @@ pub(super) fn execute_navigate_action_in_context( leave_navigate_mode(state); } NavigateAction::Help => super::modal::open_keybind_help(state), + NavigateAction::OpenCommandPalette => { + super::modal::open_palette(state); + } + NavigateAction::MovePaneToSpace => { + if let Some(pane_id) = state.focused_public_pane_id() { + state.open_navigator_from(terminal_runtimes); + state.navigator.pending_pane_move = Some(pane_id); + state.reseat_navigator_selection_from(terminal_runtimes); + } + } + NavigateAction::MovePaneToNewSpace | NavigateAction::MovePaneToNewTab => { + leave_navigate_mode(state) + } + NavigateAction::AcceptNavigatorRow => { + if state.navigator.pending_pane_move.take().is_some() { + state.mode = Mode::Terminal; + } else { + state.accept_navigator_selection_from(terminal_runtimes); + } + return; + } NavigateAction::Settings => super::settings::open_settings(state), NavigateAction::ReloadConfig => { state.request_reload_config = true; @@ -1904,6 +2119,27 @@ mod tests { app::App, config::Config, input::TerminalKey, terminal::TerminalState, workspace::Workspace, }; + #[test] + fn prefix_slash_opens_the_palette_ready_to_type() { + let mut state = state_with_workspaces(&["test"]); + + // The real binding table, not the test-only dispatch twin. + let action = action_for_key( + &state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), + BindingDispatch::Prefix, + ); + assert_eq!(action, Some(NavigateAction::OpenCommandPalette)); + + execute_navigate_action(&mut state, NavigateAction::OpenCommandPalette); + assert_eq!(state.mode, Mode::Palette); + assert!(state.command_palette.query.is_empty()); + assert_eq!( + state.command_palette.selected, 0, + "a command must already be selected so enter runs without arrowing first" + ); + } + fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: key.into(), diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 58f2ef7141..a501af4492 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -159,12 +159,10 @@ impl App { &self.terminal_runtimes, ); } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); + self.accept_navigator_row(); } } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); + self.accept_navigator_row(); } } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { leave_modal(&mut self.state); @@ -190,6 +188,43 @@ impl App { return true; } + if self.state.mode == Mode::Palette { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(index) = self.state.palette_row_at(mouse.column, mouse.row) { + self.state.command_palette.selected = index; + let action = crate::ui::filtered_palette_commands(&self.state) + .get(index) + .map(|command| command.action); + if let Some(action) = action { + leave_modal(&mut self.state); + self.run_overlay_action(action); + } + } else { + let rect = self.state.palette_popup_rect(); + let inside = mouse.column >= rect.x + && mouse.column < rect.x + rect.width + && mouse.row >= rect.y + && mouse.row < rect.y + rect.height; + if !inside { + leave_modal(&mut self.state); + } + } + } + MouseEventKind::ScrollUp => { + self.state.command_palette.scroll = + self.state.command_palette.scroll.saturating_sub(3); + } + MouseEventKind::ScrollDown => { + let max = self.state.palette_max_scroll(); + self.state.command_palette.scroll = + self.state.command_palette.scroll.saturating_add(3).min(max); + } + _ => {} + } + return true; + } + if self.state.mode == Mode::KeybindHelp { match mouse.kind { MouseEventKind::Down(MouseButton::Left) @@ -609,6 +644,54 @@ impl AppState { } } + pub(super) fn palette_popup_rect(&self) -> Rect { + crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() + } + + pub(super) fn palette_body_rect(&self) -> Option { + let inner = self.onboarding_modal_inner(76, 22)?; + if inner.height < 6 || inner.width < 20 { + return None; + } + Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) + } + + pub(crate) fn palette_max_scroll(&self) -> u16 { + let Some(body) = self.palette_body_rect() else { + return 0; + }; + let total = crate::ui::filtered_palette_commands(self).len(); + total.saturating_sub(body.height.max(1) as usize) as u16 + } + + fn palette_row_at(&self, col: u16, row: u16) -> Option { + let body = self.palette_body_rect()?; + if col < body.x || col >= body.x + body.width || row < body.y || row >= body.y + body.height + { + return None; + } + let index = self.command_palette.scroll as usize + (row - body.y) as usize; + (index < crate::ui::filtered_palette_commands(self).len()).then_some(index) + } + + pub(super) fn ensure_palette_selection_visible(&mut self) { + let Some(body) = self.palette_body_rect() else { + return; + }; + let viewport = body.height.max(1) as usize; + let selected = self.command_palette.selected; + let scroll = self.command_palette.scroll as usize; + + let adjusted = if selected < scroll { + selected + } else if selected >= scroll + viewport { + selected + 1 - viewport + } else { + return; + }; + self.command_palette.scroll = adjusted.min(self.palette_max_scroll() as usize) as u16; + } + pub(super) fn keybind_help_popup_rect(&self) -> Rect { crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() } @@ -765,6 +848,38 @@ mod tests { assert!(app.state.keybind_help.query.is_empty()); } + #[test] + fn clicking_a_palette_row_runs_that_command() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::Palette; + app.state.command_palette.query = "settings".into(); + + let body = app.state.palette_body_rect().expect("body rect"); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + body.x + 2, + body.y, + )); + + assert_eq!(app.state.mode, Mode::Settings); + } + + #[test] + fn clicking_past_the_last_palette_row_leaves_the_palette_open() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::Palette; + app.state.command_palette.query = "settings".into(); + + let body = app.state.palette_body_rect().expect("body rect"); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + body.x + 2, + body.y + body.height - 1, + )); + + assert_eq!(app.state.mode, Mode::Palette); + } + #[test] fn onboarding_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index c27c249ec3..6bad86f76b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,6 +16,7 @@ mod creation; mod git_refresh; mod ids; mod input; +pub(crate) use input::NavigateAction; mod popup; mod runtime; mod runtime_mutations; @@ -565,6 +566,7 @@ impl App { } }), keybind_help: state::KeybindHelpState::default(), + command_palette: state::PaletteState::default(), navigator: state::NavigatorState::default(), copy_mode: None, workspace_scroll: 0, @@ -1832,6 +1834,11 @@ impl App { Mode::ContextMenu => { self.handle_context_menu_key_via_api(key_event); } + Mode::Palette => { + if let Some(action) = input::handle_palette_key(&mut self.state, key) { + self.run_overlay_action(action); + } + } Mode::KeybindHelp => { input::handle_keybind_help_key(&mut self.state, key); } @@ -1851,7 +1858,11 @@ impl App { self.handle_settings_key(key_event); } Mode::Navigator => { - input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event); + if let Some(action) = + input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) + { + self.run_overlay_action(action); + } } Mode::Terminal => { // Should not be called in terminal mode. diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs index f765bf20c3..5b3c1fec44 100644 --- a/src/app/runtime_mutations.rs +++ b/src/app/runtime_mutations.rs @@ -1,9 +1,9 @@ use crate::api::schema::{ - EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneRenameParams, - PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomParams, TabCreateParams, - TabMoveParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceMoveBlockParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, - WorktreeOpenParams, WorktreeRemoveParams, + EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneMoveParams, + PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, + PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget, + WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams, + WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, WorktreeRemoveParams, }; use super::App; @@ -137,6 +137,10 @@ impl App { self.dispatch_runtime_mutation(id, Method::PaneSwap(params)) } + pub(crate) fn runtime_pane_move(&mut self, id: &'static str, params: PaneMoveParams) -> String { + self.dispatch_runtime_mutation(id, Method::PaneMove(params)) + } + pub(crate) fn runtime_pane_split( &mut self, id: &'static str, diff --git a/src/app/state.rs b/src/app/state.rs index 7a8837eee0..87c0dfc6ba 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -813,6 +813,7 @@ pub enum Mode { GlobalMenu, KeybindHelp, Navigator, + Palette, } impl Mode { @@ -861,6 +862,8 @@ pub(crate) enum NavigatorTarget { tab_idx: usize, pane_id: PaneId, }, + /// Destination-only row, offered while a pane move is armed. + NewWorkspace, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -936,6 +939,8 @@ pub(crate) struct NavigatorState { pub search_focused: bool, pub state_filter: Option, pub expanded_workspaces: std::collections::HashSet, + /// While set, accepting a navigator row moves that pane instead of focusing it. + pub pending_pane_move: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1373,6 +1378,14 @@ pub struct KeybindHelpState { pub search_focused: bool, } +#[derive(Default)] +pub struct PaletteState { + pub query: String, + /// Index into the filtered command list; a match is always selected. + pub selected: usize, + pub scroll: u16, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SidebarWidthSource { ConfigDefault, @@ -1437,6 +1450,7 @@ pub struct AppState { pub release_notes: Option, pub product_announcement: Option, pub keybind_help: KeybindHelpState, + pub command_palette: PaletteState, pub navigator: NavigatorState, pub copy_mode: Option, pub workspace_scroll: usize, @@ -1800,6 +1814,7 @@ impl AppState { release_notes: None, product_announcement: None, keybind_help: KeybindHelpState::default(), + command_palette: PaletteState::default(), navigator: NavigatorState::default(), copy_mode: None, workspace_scroll: 0, diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 199076f03c..3393bf996b 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -315,6 +315,7 @@ pub struct Keybinds { pub rename_workspace: ActionKeybinds, pub close_workspace: ActionKeybinds, pub workspace_picker: ActionKeybinds, + pub command_palette: ActionKeybinds, pub goto: ActionKeybinds, pub detach: ActionKeybinds, pub reload_config: ActionKeybinds, @@ -477,6 +478,7 @@ impl Config { rename_workspace: empty_action!(), close_workspace: empty_action!(), workspace_picker: empty_action!(), + command_palette: empty_action!(), goto: empty_action!(), detach: empty_action!(), reload_config: empty_action!(), @@ -599,6 +601,7 @@ impl Config { apply_action!(keybinds.rename_workspace, rename_workspace, source); apply_action!(keybinds.close_workspace, close_workspace, source); apply_action!(keybinds.workspace_picker, workspace_picker, source); + apply_action!(keybinds.command_palette, command_palette, source); apply_action!(keybinds.goto, goto, source); apply_action!(keybinds.detach, detach, source); apply_action!(keybinds.reload_config, reload_config, source); diff --git a/src/config/model.rs b/src/config/model.rs index 97b8c7fa3a..766deb2b29 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -346,6 +346,9 @@ pub struct KeysConfig { pub close_workspace: BindingConfig, /// Open the workspace navigation surface. Default: "prefix+w" pub workspace_picker: BindingConfig, + /// Open the keybind help ready to filter, so a command can be typed and run + /// without opening the help first. Default: "prefix+/" + pub command_palette: BindingConfig, /// Open the session navigator. Default: "prefix+g" pub goto: BindingConfig, /// Move workspace selection up in navigate mode. Default: "up". @@ -466,6 +469,8 @@ pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] workspace_picker: Option, #[serde(skip_serializing_if = "Option::is_none")] + command_palette: Option, + #[serde(skip_serializing_if = "Option::is_none")] goto: Option, #[serde(skip_serializing_if = "Option::is_none")] navigate_workspace_up: Option, @@ -584,6 +589,7 @@ impl<'de> Deserialize<'de> for KeysConfig { apply_field!(rename_workspace); apply_field!(close_workspace); apply_field!(workspace_picker); + apply_field!(command_palette); apply_field!(goto); apply_field!(navigate_workspace_up); apply_field!(navigate_workspace_down); @@ -682,6 +688,7 @@ impl KeysConfig { copy_effective_action_field!(rename_workspace, keybinds.rename_workspace); copy_effective_action_field!(close_workspace, keybinds.close_workspace); copy_effective_action_field!(workspace_picker, keybinds.workspace_picker); + copy_effective_action_field!(command_palette, keybinds.command_palette); copy_effective_action_field!(goto, keybinds.goto); copy_effective_action_field!(navigate_workspace_up, keybinds.navigate.workspace_up); copy_effective_action_field!(navigate_workspace_down, keybinds.navigate.workspace_down); @@ -965,6 +972,7 @@ impl Default for KeysConfig { rename_workspace: BindingConfig::one("prefix+shift+w"), close_workspace: BindingConfig::one("prefix+shift+d"), workspace_picker: BindingConfig::one("prefix+w"), + command_palette: BindingConfig::one("prefix+/"), goto: BindingConfig::one("prefix+g"), navigate_workspace_up: BindingConfig::one("up"), navigate_workspace_down: BindingConfig::one("down"), diff --git a/src/main.rs b/src/main.rs index da90531168..23de2105e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,6 +177,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # reload_config = "prefix+shift+r" # open_notification_target = "prefix+o" # workspace_picker = "prefix+w" +# command_palette = "prefix+/" # goto = "prefix+g" # new_workspace = "prefix+shift+n" # new_worktree = "prefix+shift+g" diff --git a/src/ui.rs b/src/ui.rs index 60143fe448..61c6f387be 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,6 +11,7 @@ mod menus; mod mobile; mod navigator; mod onboarding; +mod palette; mod panes; mod release_notes; mod scrollbar; @@ -39,6 +40,7 @@ use self::mobile::{ use self::navigator::render_navigator_overlay; pub(crate) use self::onboarding::onboarding_welcome_continue_rect; use self::onboarding::render_onboarding_overlay; +use self::palette::render_palette_overlay; pub(crate) use self::panes::popup_pane_rects; use self::panes::{render_empty, render_popup_pane, resize_popup_pane}; pub(crate) use self::release_notes::{ @@ -94,6 +96,7 @@ pub(crate) use self::{ mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, mobile_switcher_workspace_doc_range, MobileSwitcherTarget, }, + palette::filtered_palette_commands, panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back}, tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView}, tabs::compute_tab_bar_view, @@ -456,6 +459,7 @@ pub fn render_with_runtime_registry( Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()), Mode::GlobalMenu => render_global_launcher_menu(app, frame), Mode::KeybindHelp => render_keybind_help_overlay(app, frame), + Mode::Palette => render_palette_overlay(app, frame), Mode::Navigator => render_navigator_overlay(app, terminal_runtimes, frame), Mode::Terminal => {} } @@ -1446,34 +1450,34 @@ mod tests { assert!(workspace_tab .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "previous workspace")); + .any(|entry| entry.key == "unset" && entry.label.as_ref() == "previous workspace")); assert!(workspace_tab .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "next workspace")); + .any(|entry| entry.key == "unset" && entry.label.as_ref() == "next workspace")); assert!(workspace_tab .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "previous agent")); + .any(|entry| entry.key == "unset" && entry.label.as_ref() == "previous agent")); assert!(workspace_tab .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "next agent")); + .any(|entry| entry.key == "unset" && entry.label.as_ref() == "next agent")); assert!(workspace_tab .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "focus agent 1-9")); + .any(|entry| entry.key == "unset" && entry.label.as_ref() == "focus agent 1-9")); assert!(workspace_tab .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "switch workspace 1-9")); + .any(|entry| entry.key == "unset" && entry.label.as_ref() == "switch workspace 1-9")); assert!(panes .iter() - .any(|(key, label)| key == "prefix+h" && label.as_ref() == "focus pane left")); + .any(|entry| entry.key == "prefix+h" && entry.label.as_ref() == "focus pane left")); assert!(panes .iter() - .any(|(key, label)| key == "prefix+j" && label.as_ref() == "focus pane down")); + .any(|entry| entry.key == "prefix+j" && entry.label.as_ref() == "focus pane down")); assert!(panes .iter() - .any(|(key, label)| key == "prefix+k" && label.as_ref() == "focus pane up")); + .any(|entry| entry.key == "prefix+k" && entry.label.as_ref() == "focus pane up")); assert!(panes .iter() - .any(|(key, label)| key == "prefix+l" && label.as_ref() == "focus pane right")); + .any(|entry| entry.key == "prefix+l" && entry.label.as_ref() == "focus pane right")); } #[test] @@ -1509,10 +1513,10 @@ mod tests { .clone(); assert!(custom .iter() - .any(|(key, label)| key == "prefix+alt+g" && label.as_ref() == "open lazygit")); + .any(|entry| entry.key == "prefix+alt+g" && entry.label.as_ref() == "open lazygit")); assert!(custom .iter() - .any(|(key, label)| key == "prefix+alt+h" && label.as_ref() == "custom command")); + .any(|entry| entry.key == "prefix+alt+h" && entry.label.as_ref() == "custom command")); let rendered_help = keybind_help_lines(&app) .into_iter() @@ -1546,13 +1550,13 @@ switch_workspace = "ctrl+1..9" let switch_tab_key = workspace_tab .iter() - .find(|(_, label)| label.as_ref() == "switch tab 1-9") - .map(|(key, _)| key.as_str()) + .find(|entry| entry.label.as_ref() == "switch tab 1-9") + .map(|entry| entry.key.as_str()) .expect("switch tab help entry"); let switch_workspace_key = workspace_tab .iter() - .find(|(_, label)| label.as_ref() == "switch workspace 1-9") - .map(|(key, _)| key.as_str()) + .find(|entry| entry.label.as_ref() == "switch workspace 1-9") + .map(|entry| entry.key.as_str()) .expect("switch workspace help entry"); assert_eq!(switch_tab_key, "prefix+1..9 / alt+1..9"); diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index 25c999fa3d..4ce2ff8b5b 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -14,13 +14,31 @@ use super::widgets::{ modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, render_modal_shell, }; -use crate::app::AppState; +use crate::app::{AppState, NavigateAction}; + +#[derive(Debug, Clone)] +pub(super) struct HelpEntry { + pub key: String, + pub label: Cow<'static, str>, + pub action: Option, +} -pub(super) type HelpEntry = (String, Cow<'static, str>); pub(super) type HelpGroup = (&'static str, Vec); fn help_entry(key: impl Into, label: &'static str) -> HelpEntry { - (key.into(), Cow::Borrowed(label)) + HelpEntry { + key: key.into(), + label: Cow::Borrowed(label), + action: None, + } +} + +fn help_action(key: impl Into, label: &'static str, action: NavigateAction) -> HelpEntry { + HelpEntry { + key: key.into(), + label: Cow::Borrowed(label), + action: Some(action), + } } fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { @@ -71,12 +89,21 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec { "prefix mode", ), help_entry(keybind_label(&kb.help), "keybinds"), - help_entry(keybind_label(&kb.settings), "settings"), - help_entry(keybind_label(&kb.detach), "detach"), - help_entry(keybind_label(&kb.reload_config), "reload config"), - help_entry( + help_action( + keybind_label(&kb.settings), + "settings", + NavigateAction::Settings, + ), + help_action(keybind_label(&kb.detach), "detach", NavigateAction::Detach), + help_action( + keybind_label(&kb.reload_config), + "reload config", + NavigateAction::ReloadConfig, + ), + help_action( keybind_label(&kb.open_notification_target), "open notification target", + NavigateAction::OpenNotificationTarget, ), ], )); @@ -110,52 +137,178 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec { )); let workspace_tab = vec![ - help_entry(keybind_label(&kb.workspace_picker), "workspace navigation"), - help_entry(keybind_label(&kb.goto), "session navigator"), - help_entry(keybind_label(&kb.new_workspace), "new workspace"), - help_entry(keybind_label(&kb.new_worktree), "new worktree"), - help_entry(keybind_label(&kb.open_worktree), "open worktree"), - help_entry( + help_action( + keybind_label(&kb.workspace_picker), + "workspace navigation", + NavigateAction::WorkspacePicker, + ), + help_action( + keybind_label(&kb.command_palette), + "command palette", + NavigateAction::OpenCommandPalette, + ), + help_action( + keybind_label(&kb.goto), + "session navigator", + NavigateAction::OpenNavigator, + ), + help_action( + keybind_label(&kb.new_workspace), + "new workspace", + NavigateAction::NewWorkspace, + ), + help_action( + keybind_label(&kb.new_worktree), + "new worktree", + NavigateAction::NewWorktree, + ), + help_action( + keybind_label(&kb.open_worktree), + "open worktree", + NavigateAction::OpenWorktree, + ), + help_action( keybind_label(&kb.remove_worktree), "delete worktree checkout", + NavigateAction::RemoveWorktree, + ), + help_action( + keybind_label(&kb.rename_workspace), + "rename workspace", + NavigateAction::RenameWorkspace, + ), + help_action( + keybind_label(&kb.close_workspace), + "close workspace", + NavigateAction::CloseWorkspace, + ), + help_action( + keybind_label(&kb.previous_workspace), + "previous workspace", + NavigateAction::PreviousWorkspace, + ), + help_action( + keybind_label(&kb.next_workspace), + "next workspace", + NavigateAction::NextWorkspace, ), - help_entry(keybind_label(&kb.rename_workspace), "rename workspace"), - help_entry(keybind_label(&kb.close_workspace), "close workspace"), - help_entry(keybind_label(&kb.previous_workspace), "previous workspace"), - help_entry(keybind_label(&kb.next_workspace), "next workspace"), help_entry(indexed_label(&kb.switch_workspace), "switch workspace 1-9"), - help_entry(keybind_label(&kb.previous_agent), "previous agent"), - help_entry(keybind_label(&kb.next_agent), "next agent"), + help_action( + keybind_label(&kb.previous_agent), + "previous agent", + NavigateAction::PreviousAgent, + ), + help_action( + keybind_label(&kb.next_agent), + "next agent", + NavigateAction::NextAgent, + ), help_entry(indexed_label(&kb.focus_agent), "focus agent 1-9"), - help_entry(keybind_label(&kb.new_tab), "new tab"), - help_entry(keybind_label(&kb.rename_tab), "rename tab"), - help_entry(keybind_label(&kb.previous_tab), "previous tab"), - help_entry(keybind_label(&kb.next_tab), "next tab"), + help_action( + keybind_label(&kb.new_tab), + "new tab", + NavigateAction::NewTab, + ), + help_action( + keybind_label(&kb.rename_tab), + "rename tab", + NavigateAction::RenameTab, + ), + help_action( + keybind_label(&kb.previous_tab), + "previous tab", + NavigateAction::PreviousTab, + ), + help_action( + keybind_label(&kb.next_tab), + "next tab", + NavigateAction::NextTab, + ), help_entry(indexed_label(&kb.switch_tab), "switch tab 1-9"), - help_entry(keybind_label(&kb.close_tab), "close tab"), + help_action( + keybind_label(&kb.close_tab), + "close tab", + NavigateAction::CloseTab, + ), ]; groups.push(("workspaces / tabs", workspace_tab)); let panes = vec![ - help_entry(keybind_label(&kb.split_vertical), "split vertical"), - help_entry(keybind_label(&kb.split_horizontal), "split horizontal"), - help_entry(keybind_label(&kb.close_pane), "close pane"), - help_entry(keybind_label(&kb.rename_pane), "rename pane"), + help_action( + keybind_label(&kb.split_vertical), + "split vertical", + NavigateAction::SplitVertical, + ), + help_action( + keybind_label(&kb.split_horizontal), + "split horizontal", + NavigateAction::SplitHorizontal, + ), + help_action( + keybind_label(&kb.close_pane), + "close pane", + NavigateAction::ClosePane, + ), + help_action( + keybind_label(&kb.rename_pane), + "rename pane", + NavigateAction::RenamePane, + ), + help_action("", "move pane to space", NavigateAction::MovePaneToSpace), + help_action( + "", + "move pane to new space", + NavigateAction::MovePaneToNewSpace, + ), + help_action("", "move pane to new tab", NavigateAction::MovePaneToNewTab), help_entry(keybind_label(&kb.edit_scrollback), "edit scrollback"), help_entry(keybind_label(&kb.copy_mode), "copy mode"), - help_entry(keybind_label(&kb.zoom), "zoom pane"), - help_entry(keybind_label(&kb.resize_mode), "resize mode"), - help_entry(keybind_label(&kb.toggle_sidebar), "toggle sidebar"), - help_entry(keybind_label(&kb.focus_pane_left), "focus pane left"), - help_entry(keybind_label(&kb.focus_pane_down), "focus pane down"), - help_entry(keybind_label(&kb.focus_pane_up), "focus pane up"), - help_entry(keybind_label(&kb.focus_pane_right), "focus pane right"), - help_entry(keybind_label(&kb.cycle_pane_next), "cycle pane next"), - help_entry( + help_action(keybind_label(&kb.zoom), "zoom pane", NavigateAction::Zoom), + help_action( + keybind_label(&kb.resize_mode), + "resize mode", + NavigateAction::EnterResizeMode, + ), + help_action( + keybind_label(&kb.toggle_sidebar), + "toggle sidebar", + NavigateAction::ToggleSidebar, + ), + help_action( + keybind_label(&kb.focus_pane_left), + "focus pane left", + NavigateAction::FocusPaneLeft, + ), + help_action( + keybind_label(&kb.focus_pane_down), + "focus pane down", + NavigateAction::FocusPaneDown, + ), + help_action( + keybind_label(&kb.focus_pane_up), + "focus pane up", + NavigateAction::FocusPaneUp, + ), + help_action( + keybind_label(&kb.focus_pane_right), + "focus pane right", + NavigateAction::FocusPaneRight, + ), + help_action( + keybind_label(&kb.cycle_pane_next), + "cycle pane next", + NavigateAction::CyclePaneNext, + ), + help_action( keybind_label(&kb.cycle_pane_previous), "cycle pane previous", + NavigateAction::CyclePanePrevious, + ), + help_action( + keybind_label(&kb.last_pane), + "last pane", + NavigateAction::LastPane, ), - help_entry(keybind_label(&kb.last_pane), "last pane"), ]; groups.push(("panes", panes)); @@ -164,15 +317,14 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec { "custom", kb.custom_commands .iter() - .map(|binding| { - ( - binding.label.clone(), - binding - .description - .clone() - .map(Cow::Owned) - .unwrap_or(Cow::Borrowed("custom command")), - ) + .map(|binding| HelpEntry { + key: binding.label.clone(), + label: binding + .description + .clone() + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed("custom command")), + action: None, }) .collect(), )); @@ -192,8 +344,9 @@ fn filter_keybind_help_groups(groups: Vec, query: &str) -> Vec>(); (!entries.is_empty()).then_some((group, entries)) @@ -213,7 +366,7 @@ pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> let groups = filter_keybind_help_groups(keybind_help_groups(app), &app.keybind_help.query); let key_width = groups .iter() - .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) + .flat_map(|(_, entries)| entries.iter().map(|entry| entry.key.chars().count())) .max() .unwrap_or(8); @@ -235,14 +388,14 @@ pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> group.len() + 1, Line::from(vec![Span::styled(format!(" {group}"), heading_style)]), )); - for (key, label) in entries { - let padded_key = format!(" {: "moves this pane to a new space".to_string(), + NavigatorTarget::Workspace { .. } => "moves this pane to a new tab here".to_string(), + NavigatorTarget::Tab { .. } | NavigatorTarget::Pane { .. } => { + "splits this pane into this tab".to_string() + } + }; + } match row.target { NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, terminal_runtimes, ws_idx), NavigatorTarget::Tab { ws_idx, tab_idx } => { @@ -384,6 +395,7 @@ fn selected_detail(app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry) tab_idx, pane_id, } => pane_detail(app, terminal_runtimes, ws_idx, tab_idx, pane_id), + NavigatorTarget::NewWorkspace => String::new(), } } @@ -539,21 +551,25 @@ fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD); let dim = Style::default().fg(p.overlay0); + // While a move is armed, enter relocates the pane instead of switching to the + // row, so the footer has to say which one it is. + let armed = app.navigator.pending_pane_move.is_some(); + let accept = if armed { " move here " } else { " switch " }; let line = if app.navigator.search_focused { Line::from(vec![ Span::styled(" enter", key), - Span::styled(" switch ", dim), + Span::styled(accept, dim), Span::styled("↑↓", key), Span::styled(" move ", dim), Span::styled("ctrl+u", key), Span::styled(" clear ", dim), Span::styled("esc", key), - Span::styled(" back", dim), + Span::styled(if armed { " cancel" } else { " back" }, dim), ]) } else { Line::from(vec![ Span::styled(" enter", key), - Span::styled(" switch ", dim), + Span::styled(accept, dim), Span::styled("/", key), Span::styled(" search ", dim), Span::styled("b/w/i/d/a", key), @@ -561,7 +577,7 @@ fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { Span::styled("j/k/↑↓", key), Span::styled(" move ", dim), Span::styled("esc", key), - Span::styled(" close", dim), + Span::styled(if armed { " cancel" } else { " close" }, dim), ]) }; frame.render_widget(Paragraph::new(line), area); diff --git a/src/ui/palette.rs b/src/ui/palette.rs new file mode 100644 index 0000000000..58b2a0d28b --- /dev/null +++ b/src/ui/palette.rs @@ -0,0 +1,269 @@ +use std::borrow::Cow; + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::release_notes::release_notes_close_button_rect; +use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; +use super::widgets::{ + modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, + render_modal_shell, +}; +use crate::app::{AppState, NavigateAction}; + +pub(crate) struct PaletteCommand { + pub name: Cow<'static, str>, + pub key: String, + pub action: NavigateAction, +} + +pub(crate) fn palette_commands(app: &AppState) -> Vec { + super::keybind_help::keybind_help_groups(app) + .into_iter() + .flat_map(|(_, entries)| entries) + .filter_map(|entry| { + Some(PaletteCommand { + name: entry.label, + key: entry.key, + action: entry.action?, + }) + }) + .collect() +} + +/// Ranking by match quality rather than list order keeps a query that exactly +/// names one command from being answered by a longer command containing it. +fn match_rank(name: &str, query: &str) -> Option { + let name = name.to_lowercase(); + if name == query { + Some(0) + } else if name.starts_with(query) { + Some(1) + } else if name.split_whitespace().any(|word| word.starts_with(query)) { + Some(2) + } else if name.contains(query) { + Some(3) + } else { + None + } +} + +pub(crate) fn filtered_palette_commands(app: &AppState) -> Vec { + let query = app.command_palette.query.trim().to_lowercase(); + let commands = palette_commands(app); + if query.is_empty() { + return commands; + } + + let mut ranked: Vec<(u8, usize, PaletteCommand)> = commands + .into_iter() + .enumerate() + .filter_map(|(index, command)| { + match_rank(&command.name, &query).map(|rank| (rank, index, command)) + }) + .collect(); + ranked.sort_by_key(|(rank, index, _)| (*rank, *index)); + ranked.into_iter().map(|(_, _, command)| command).collect() +} + +fn palette_lines(app: &AppState, width: usize) -> Vec> { + let selected_style = Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let name_style = Style::default().fg(app.palette.text); + let key_style = Style::default().fg(app.palette.overlay1); + + filtered_palette_commands(app) + .into_iter() + .enumerate() + .map(|(index, command)| { + let (name_span_style, key_span_style) = if index == app.command_palette.selected { + (selected_style, selected_style) + } else { + (name_style, key_style) + }; + let name = format!(" {}", command.name); + let key = format!("{} ", command.key); + let gap = width.saturating_sub(name.chars().count() + key.chars().count()); + Line::from(vec![ + Span::styled(name, name_span_style), + Span::styled(" ".repeat(gap), name_span_style), + Span::styled(key, key_span_style), + ]) + }) + .collect() +} + +pub(super) fn render_palette_overlay(app: &AppState, frame: &mut Frame) { + super::dim_background(frame, frame.area()); + + let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else { + return; + }; + if inner.height < 6 || inner.width < 20 { + return; + } + + let stack = modal_stack_areas(inner, 2, 1, 0, 1); + let header_rows = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); + + render_modal_header(frame, header_rows[0], "commands", &app.palette); + render_action_button( + frame, + release_notes_close_button_rect(header_rows[0]), + Some("esc"), + "close", + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ); + + let query_span = if app.command_palette.query.is_empty() { + Span::styled("type to filter", Style::default().fg(app.palette.overlay0)) + } else { + Span::styled( + app.command_palette.query.clone(), + Style::default() + .fg(app.palette.text) + .add_modifier(Modifier::BOLD), + ) + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " > ", + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ), + query_span, + ])), + header_rows[1], + ); + + let body_area = stack.content; + let total = filtered_palette_commands(app).len(); + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: app + .palette_max_scroll() + .saturating_sub(app.command_palette.scroll) as usize, + max_offset_from_bottom: app.palette_max_scroll() as usize, + viewport_rows: body_area.height.max(1) as usize, + }; + let track = release_notes_scrollbar_rect(body_area, metrics); + let text_area = track + .map(|_| { + Rect::new( + body_area.x, + body_area.y, + body_area.width.saturating_sub(1), + body_area.height, + ) + }) + .unwrap_or(body_area); + + let lines = if total == 0 { + vec![Line::from(Span::styled( + " no matching commands", + Style::default().fg(app.palette.overlay1), + ))] + } else { + palette_lines(app, text_area.width as usize) + }; + frame.render_widget( + Paragraph::new(lines).scroll((app.command_palette.scroll, 0)), + text_area, + ); + if let Some(track) = track { + render_scrollbar( + frame, + metrics, + track, + app.palette.overlay0, + app.palette.overlay1, + "▐", + ); + } + + let mut spans = Vec::new(); + for (index, (label, keys)) in [("run ", "enter"), ("move ", "↑↓"), ("close ", "esc")] + .into_iter() + .enumerate() + { + spans.push(Span::styled( + if index == 0 { + format!(" {label}") + } else { + label.to_string() + }, + Style::default().fg(app.palette.overlay0), + )); + spans.push(Span::styled( + keys.to_string(), + Style::default().fg(app.palette.text), + )); + spans.push(Span::styled( + " · ", + Style::default().fg(app.palette.overlay0), + )); + } + spans.pop(); + frame.render_widget( + Paragraph::new(Line::from(spans)), + stack.footer.unwrap_or_default(), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(query: &str) -> Vec { + let mut state = AppState::test_new(); + state.command_palette.query = query.into(); + filtered_palette_commands(&state) + .into_iter() + .map(|command| command.name.into_owned()) + .collect() + } + + #[test] + fn an_exact_name_outranks_a_longer_command_containing_it() { + let matches = names("new tab"); + assert_eq!( + matches.first().map(String::as_str), + Some("new tab"), + "got {matches:?}" + ); + assert!( + matches.iter().any(|name| name == "move pane to new tab"), + "the longer command should still match, got {matches:?}" + ); + } + + #[test] + fn a_word_prefix_outranks_a_mid_word_substring() { + let matches = names("pane"); + let first = matches.first().map(String::as_str).unwrap_or_default(); + assert!( + first + .split_whitespace() + .any(|word| word.starts_with("pane")), + "got {matches:?}" + ); + } + + #[test] + fn every_palette_command_is_runnable() { + let state = AppState::test_new(); + assert!(!palette_commands(&state).is_empty()); + } +}