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()); + } +}