From 4ac7d3017873b1892352013f8206ac5d1f63df9b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:19:00 +0800 Subject: [PATCH 1/2] feat(ux): rebuild the menu bar, command palette, and Settings IA The menu bar shipped four menus in the order App / Shell / Window / View with no Edit menu at all, so Copy and Paste existed nowhere but a right-click, and About / Check for Updates / Hide / Minimize / Help had no home. It now follows the macOS HIG's standard set. The palette listed 47 commands in three competing naming styles, ranked only by catalog order, with no grouping and no way to reach most of what the tab context menu could do. It now has one documented grammar, a scored fuzzy ranker, group bands with a frecency-driven Recent, and the commands it was missing. Settings had a three-row Shell page indistinguishable from Terminal, a seven-group Terminal page that had become a junk drawer, two different groups called "Window", and a search index that had drifted so far from the rendered rows that "opacity" and "completion" returned nothing. Also folds copy / cut / paste / undo onto one code path each, which fixed two real drift bugs: the right-click Paste skipped the image-paste branch that Cmd+V had, and Copy rendered disabled whenever the selection was in the prompt editor rather than the grid. --- src/core/actions.rs | 36 ++ src/core/config.rs | 10 + src/terminal/view.rs | 253 +++++++++---- src/ui/app.rs | 199 ++++++++-- src/ui/home.rs | 4 +- src/ui/keymap.rs | 74 ++++ src/ui/palette.rs | 847 ++++++++++++++++++++++++++++++++++++------ src/ui/right_panel.rs | 72 +++- src/ui/settings.rs | 707 +++++++++++++++++++++++++---------- src/ui/tab_strip.rs | 7 +- src/ui/theme.rs | 164 ++++++-- src/ui/tray/mod.rs | 9 +- src/ui/windows.rs | 5 +- 13 files changed, 1899 insertions(+), 488 deletions(-) diff --git a/src/core/actions.rs b/src/core/actions.rs index 144e6f10..417912c9 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -38,6 +38,16 @@ actions!( SelectWorkspace8, SelectWorkspace9, CloseActiveTab, + // Tab operations that until now existed only as tab-context-menu rows, + // reachable by right-clicking the *right* chip. As actions they also + // reach the menu bar, the palette, and Settings → Keybindings; each acts + // on the active tab, which is what "this tab" means with no chip clicked. + RenameTab, + NewWorktreeTab, + CloseOtherTabs, + CloseTabsToTheRight, + CopyWorkingDirectory, + MarkTabUnread, SplitRight, SplitDown, FocusNextPane, @@ -100,6 +110,32 @@ actions!( ShowRightPanelChanges, ShowRightPanelFiles, OpenSettings, + // Open Settings straight to its Keybindings section — the Help menu's + // "Keyboard Shortcuts" and the palette's shortcut entry both land here, + // rather than making the user open Settings and then find the section. + ShowKeyboardShortcuts, + // Open Settings on the About section. The macOS App menu's first item + // has to exist and has to be called "About tty7"; routing it to the + // section that already carries version/links keeps one About, not two. + About, + // Run the same update check the app does at startup (see `core::update`) + // on demand, then report the outcome. Previously only the tray offered + // this, which is not where a Mac user looks for it. + CheckForUpdates, + // Standard macOS App-menu items. gpui exposes the platform calls but + // binds nothing by default, so they need real actions to hang off. + HideApp, + HideOthers, + ShowAll, + // Standard macOS Window-menu items. + MinimizeWindow, + ZoomWindow, + // Help menu destinations. Each opens a URL in the default browser; kept + // as separate actions (rather than one parameterized one) so they can be + // bound and searched by name like everything else. + OpenDocumentation, + OpenDiscord, + ReportIssue, RestartDaemon, // Show the detail panel's Files tab, which browses the focused pane's // remote filesystem over SFTP when that pane is native SSH (WS5). diff --git a/src/core/config.rs b/src/core/config.rs index c78bc58c..8bcbe8a4 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -303,6 +303,15 @@ pub struct Config { #[serde(default)] pub ssh_profile_frecency: HashMap, + /// Per-command usage for the palette's "Recent" group, keyed by the stable + /// id in `ui::palette::CommandKind::id`. The static command list is ordered + /// by hand, which means the first screenful is whatever the author typed + /// first rather than what this user actually runs; this is what lets the + /// palette lead with the latter. Only commands with a stable id are tracked + /// — a "switch to tab 3" is not a thing to be recently-used. + #[serde(default)] + pub command_frecency: HashMap, + // ── CLI coding agents ──────────────────────────────────────────────────── /// User-defined agent-detection rules: a command basename → an agent slug /// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get @@ -581,6 +590,7 @@ impl Default for Config { verify_host_keys: true, ssh_warn_on_close: false, ssh_profile_frecency: HashMap::new(), + command_frecency: HashMap::new(), agent_commands: HashMap::new(), restore_agent_sessions: true, } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e3d5b8cc..e7ee2388 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -40,15 +40,24 @@ use crate::daemon::protocol::{RemoteContext, ShellSpec}; const GRID_PAD_X: f32 = 8.; const GRID_PAD_Y: f32 = 4.; -// Terminal-scoped actions dispatched by the right-click context menu. They route -// to this view via `.on_action` handlers on the terminal surface; tab/split -// actions in the same menu bubble up to `Tty7App` from the focused terminal. +// Terminal-scoped actions dispatched by the right-click context menu and the +// menu bar's Edit menu. They route to this view via `.on_action` handlers on the +// terminal surface; tab/split actions in the same menu bubble up to `Tty7App` +// from the focused terminal. +// +// Every one of these is the *single* path for its gesture: the ⌘-chord, the +// context-menu row, and the Edit-menu item all dispatch the same action, so the +// three can't drift (they did — the context menu's Paste used to skip the +// image-paste branch that ⌘V had). actions!( terminal, [ CopyText, + CutText, PasteText, SelectAll, + UndoEdit, + RedoEdit, FindInTerminal, FindNext, FindPrevious, @@ -1605,73 +1614,34 @@ impl TerminalView { ) -> CmdKey { let m = &ks.modifiers; match ks.key.as_str() { + // Copy / cut / paste route through the same methods the `CopyText` / + // `CutText` / `PasteText` actions call, so the chord, the right-click + // row and the Edit menu can't drift apart. "c" => { - // At the prompt, ⌘C copies the editor's selection — but only when - // the editor actually has one. With no editor selection we must NOT - // swallow the key: the user may have mouse-selected terminal - // output/scrollback (which lives in `term.selection`), so fall - // through to the terminal-selection branch below. - if self.input_active() { - if let Some(text) = self.cmd.selected_text() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - // Same dual-purpose rule as the terminal selection - // below: a Ctrl+C copy consumes the editor selection, - // so the next press reaches the editor's ^C (abort - // line) instead of copying forever (#111). - if m.control { - self.cmd.clear_selection(); - cx.notify(); - } - return CmdKey::Consumed; - } + // `clear_on_copy`: Ctrl+C is dual-purpose — copy with a + // selection, ^C (SIGINT) without — so the copy must consume the + // selection or the next press copies again instead of + // interrupting (#111). ⌘C never doubles as SIGINT, so there the + // selection stays highlighted (the macOS convention). + if self.copy_contextual(m.control, cx) { + CmdKey::Consumed + } else { + // Nothing was selected anywhere: don't swallow the key, so + // Ctrl+C still reaches the PTY as ^C. + CmdKey::FallThrough } - // Copy the terminal selection, if any; else fall through - // (Ctrl+C handles SIGINT). - if self.has_selection() { - self.copy_selection(cx); - // Ctrl+C is dual-purpose — copy with a selection, ^C - // (SIGINT) without — so the copy must consume the selection - // or the next press copies again instead of interrupting - // (#111). Cmd+C never doubles as SIGINT, so there the - // selection stays highlighted (the macOS convention). - if m.control { - self.terminal.term.lock().selection = None; - cx.notify(); - } - return CmdKey::Consumed; - } - CmdKey::FallThrough } "x" => { - // Cut: only meaningful in the editor with a selection — copy it - // out, then delete it. Elsewhere it's a no-op (swallowed). - if self.input_active() { - if let Some(text) = self.cmd.selected_text() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - self.cmd.delete_selection(); - self.close_completion(); - self.cursor_visible = true; - cx.notify(); - } - return CmdKey::Consumed; + // Cut is editor-only; outside the prompt there is nothing to + // remove, so the key falls through rather than dying silently. + if self.cut_contextual(cx) { + CmdKey::Consumed + } else { + CmdKey::FallThrough } - CmdKey::FallThrough } "v" => { - if let Some(item) = cx.read_from_clipboard() { - if let Some(text) = clipboard_paste_text(&item) { - self.paste(text, cx); - } else if !self.input_active() { - if let Some(img) = item.entries().iter().find_map(|e| match e { - ClipboardEntry::Image(img) => Some(img), - _ => None, - }) { - // Clipboard holds an image (e.g. a screenshot) with no text, - // and a foreground TUI (a coding agent) owns the pane. - self.paste_clipboard_image(img, cx); - } - } - } + self.paste_from_clipboard(cx); CmdKey::Consumed } // Find (open bar) and ⌘G / ⌘⇧G (next / previous match) are registered @@ -1688,15 +1658,7 @@ impl TerminalView { // The following are editor-only (macOS line editing); they're swallowed // elsewhere since they have no terminal meaning. "z" => { - if self.input_active() { - if m.shift { - self.cmd.redo(); - } else { - self.cmd.undo(); - } - self.close_completion(); - cx.notify(); - } + self.undo_edit(m.shift, cx); CmdKey::Consumed } "left" => { @@ -2220,6 +2182,14 @@ impl TerminalView { self.terminal.term.lock().selection.is_some() } + /// Is there anything [`copy_contextual`](Self::copy_contextual) would copy — + /// in the grid *or* in the prompt editor? What the Copy / Cut menu rows gate + /// on: `has_selection` alone is grid-only, so a prompt selection used to + /// leave "Copy" greyed out even though ⌘C would have copied it. + fn any_selection(&self) -> bool { + self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some()) + } + /// Snapshot the Kitty keyboard-protocol flags the app has enabled, read off the /// local `Term`'s mode bits (the reader thread keeps them current by advancing /// the emulator over all child output). Consulted by the key encoder so TUIs @@ -2496,15 +2466,117 @@ impl TerminalView { } } + /// Copy whatever is selected, preferring the prompt editor's selection over + /// the terminal grid's. Returns whether anything was actually copied — the + /// ⌃C path needs to know, because with nothing selected the key has to fall + /// through to ^C (SIGINT). + /// + /// `clear_on_copy` drops the selection after copying. Ctrl+C is dual-purpose + /// (copy with a selection, SIGINT without), so it must consume the selection + /// or the next press copies forever instead of interrupting (#111); ⌘C and + /// the menu items leave the highlight up, the macOS convention. + /// + /// The single copy path: ⌘C / ⌃C, the right-click "Copy" row, and the Edit + /// menu all land here. + pub fn copy_contextual(&mut self, clear_on_copy: bool, cx: &mut Context) -> bool { + // At the prompt the editor's selection wins — but only when it has one. + // With no editor selection we fall on through: the user may have + // mouse-selected terminal output/scrollback, which lives in + // `term.selection`, not in the editor. + if self.input_active() { + if let Some(text) = self.cmd.selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + if clear_on_copy { + self.cmd.clear_selection(); + cx.notify(); + } + return true; + } + } + if self.has_selection() { + self.copy_selection(cx); + if clear_on_copy { + self.terminal.term.lock().selection = None; + cx.notify(); + } + return true; + } + false + } + + /// Step to the next (`forward`) or previous search match. A no-op while the + /// find bar is closed — there is nothing to step through. Exposed for the + /// palette's "Find Next" / "Find Previous", which run from outside the + /// terminal module and so can't reach `step_match` directly. + pub fn find_step(&mut self, forward: bool, cx: &mut Context) { + let direction = if forward { + Direction::Right + } else { + Direction::Left + }; + self.step_match(direction, cx); + } + + /// Undo (or, with `redo`, redo) the last prompt edit. Editor-only: the + /// terminal grid has no edit history, so outside the prompt this is a no-op + /// that still swallows the gesture rather than sending ⌘Z to the PTY. + /// Shared by the ⌘Z chord and the Edit menu's Undo / Redo. + pub fn undo_edit(&mut self, redo: bool, cx: &mut Context) { + if !self.input_active() { + return; + } + if redo { + self.cmd.redo(); + } else { + self.cmd.undo(); + } + self.close_completion(); + cx.notify(); + } + + /// Cut the prompt editor's selection: copy it out, then delete it. Only + /// meaningful at the prompt — the terminal grid is not editable — so this + /// reports whether the gesture was *handled* (i.e. the prompt was active), + /// not whether text was actually removed; a cut with nothing selected is + /// still a no-op the prompt owns rather than a key the PTY should see. + pub fn cut_contextual(&mut self, cx: &mut Context) -> bool { + if !self.input_active() { + return false; + } + if let Some(text) = self.cmd.selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.cmd.delete_selection(); + self.close_completion(); + self.cursor_visible = true; + cx.notify(); + } + true + } + /// Read the system clipboard and paste it into the PTY (bracketed-paste - /// aware). Used by Cmd+V and the right-click "Paste" item. + /// aware). The single paste path: ⌘V / ⌃V, the right-click "Paste" row, and + /// the Edit menu. + /// + /// Text wins when the clipboard carries any. Failing that — an image-only + /// clipboard (a screenshot) dropped on a pane whose foreground app is a TUI + /// coding agent — the image is written to a temp file and its path typed in, + /// which is how those agents take attachments. pub fn paste_from_clipboard(&mut self, cx: &mut Context) { - if let Some(text) = cx - .read_from_clipboard() - .as_ref() - .and_then(clipboard_paste_text) - { + let Some(item) = cx.read_from_clipboard() else { + return; + }; + if let Some(text) = clipboard_paste_text(&item) { self.paste(text, cx); + return; + } + if self.input_active() { + return; + } + if let Some(img) = item.entries().iter().find_map(|e| match e { + ClipboardEntry::Image(img) => Some(img), + _ => None, + }) { + self.paste_clipboard_image(img, cx); } } @@ -5143,9 +5215,10 @@ impl Render for TerminalView { // Captured for the right-click menu: the focus handle routes dispatched // actions to this terminal (and lets tab/split ones bubble to the root), - // and the selection state greys out "Copy" when there's nothing selected. + // and the selection state greys out "Copy" / "Cut" when there's nothing + // selected in either the grid or the prompt editor. let menu_focus = self.focus_handle.clone(); - let has_selection = self.has_selection(); + let has_selection = self.any_selection(); div() .id("terminal-surface") @@ -5189,9 +5262,18 @@ impl Render for TerminalView { })) // Context-menu actions handled by this view; tab/split actions in the // same menu fall through to `Tty7App`. - .on_action(cx.listener(|this, _: &CopyText, _w, cx| this.copy_selection(cx))) + // Menu-dispatched copy leaves the selection up (`clear_on_copy: + // false`) — only the dual-purpose ⌃C chord has to consume it. + .on_action(cx.listener(|this, _: &CopyText, _w, cx| { + this.copy_contextual(false, cx); + })) + .on_action(cx.listener(|this, _: &CutText, _w, cx| { + this.cut_contextual(cx); + })) .on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx))) .on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx))) + .on_action(cx.listener(|this, _: &UndoEdit, _w, cx| this.undo_edit(false, cx))) + .on_action(cx.listener(|this, _: &RedoEdit, _w, cx| this.undo_edit(true, cx))) .on_action( cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)), ) @@ -5241,7 +5323,7 @@ impl Render for TerminalView { // match the command palette's row height. A fixed min-width keeps // the menu a consistent, intentional size instead of hugging the // longest label (which reads ragged). - // Copy/Paste/Select All/Find are dispatched inline (see + // Copy/Cut/Paste/Select All are dispatched inline (see // `handle_cmd_shortcut`) with no registered `KeyBinding`, so the menu // can't auto-derive their hints the way it does for the items below. // We render the hint ourselves via `menu_row_with_hint` to keep the @@ -5254,6 +5336,13 @@ impl Render for TerminalView { !has_selection, menu_row_with_hint("Copy", Some("secondary-c")), ) + // Cut is prompt-only; it shares Copy's enablement cue rather + // than offering a row that silently does nothing on output. + .menu_element_with_disabled( + Box::new(CutText), + !has_selection, + menu_row_with_hint("Cut", Some("secondary-x")), + ) .menu_element( Box::new(PasteText), menu_row_with_hint("Paste", Some("secondary-v")), diff --git a/src/ui/app.rs b/src/ui/app.rs index b0d1bd1b..abb72540 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -28,7 +28,7 @@ use crate::core::ssh_config; use crate::core::window_state::WindowState; use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value}; use crate::terminal::view::{ChildExited, TerminalView}; -use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView}; +use crate::ui::palette::{Command, CommandGroup, CommandKind, PaletteEvent, PaletteView}; use crate::ui::pane::{CloseOutcome, Dir, Pane}; use crate::ui::presets::Fill; use crate::ui::settings::{ @@ -123,6 +123,13 @@ pub(crate) const TILE_GLYPH_LINE: f32 = 16.; pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.; pub(crate) const TILE_PAD_SM: f32 = (TILE_SIZE_SM - TILE_GLYPH_SM) / 2.; +/// Help-menu destinations. The README already points people at these; the app +/// itself offered none of them, so the only in-product way to reach the docs or +/// the chat was to already know the URL. +const DOCS_URL: &str = "https://github.com/l0ng-ai/tty7#readme"; +const DISCORD_URL: &str = "https://discord.gg/s3dethqz2V"; +const ISSUES_URL: &str = "https://github.com/l0ng-ai/tty7/issues/new"; + /// The one content inset the whole window aligns to: the rail's text and icons, /// the title bar's chrome glyphs, and the side panels all start (or end) here, so /// every vertical edge in the chrome falls on one of two lines rather than the @@ -662,16 +669,19 @@ impl Tty7App { running in them is terminated." .to_string(), }; + // Phrased as the question it is, like every other prompt in the app — + // this one used to be a bare statement of fact with two verbs under it. + // The version details it used to carry in the title are in the body. let answer = window.prompt( PromptLevel::Warning, - "Daemon Is From Another Version", + "Restart Daemon?", Some(&detail), - &["Keep Sessions", "Restart Daemon"], + &["Keep Sessions", "Restart"], cx, ); cx.spawn(async move |this, cx| { - // Index 1 == "Restart Daemon"; "Keep Sessions" or a dismissed - // prompt leave the old daemon (and every session) untouched. + // Index 1 == "Restart"; "Keep Sessions" or a dismissed prompt leave + // the old daemon (and every session) untouched. if !matches!(answer.await, Ok(1)) { return; } @@ -1385,11 +1395,10 @@ impl Tty7App { } TrayAction::CheckForUpdates => { surface_window(window, cx); - // Forced: a manual "check now" should work even when the - // startup check is disabled. The result lands in the About - // panel we open next (via the `UpdateStatus` global). - crate::core::update::spawn_check_forced(cx); - self.open_settings_section(SettingsSection::About, window, cx); + // Same path as the App menu's "Check for Updates…" — the tray + // used to carry its own copy of this, and was for a while the + // only place in the app offering the check at all. + self.check_for_updates_now(window, cx); } // Same as ⌘Q: sessions keep running in the daemon. TrayAction::Quit => cx.quit(), @@ -3276,6 +3285,24 @@ impl Tty7App { .and_then(|leaf| leaf.read(cx).cwd()) } + /// Copy the active tab's working directory to the clipboard — the + /// `CopyWorkingDirectory` action behind the File menu, the palette, and the + /// tab context menu's row of the same name. A no-op when the pane has yet to + /// report a cwd, which is also when the context-menu row renders disabled. + pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context) { + if let Some(cwd) = self.tab_cwd(self.active, window, cx) { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(cwd.display().to_string())); + } + } + + /// An explicit "check now", from the App menu or the tray. Forced, so it + /// works even with the startup check turned off — "I asked" outranks "don't + /// ask on my behalf" — and it opens About, where the result lands. + pub(crate) fn check_for_updates_now(&mut self, window: &mut Window, cx: &mut Context) { + crate::core::update::spawn_check_forced(cx); + self.open_settings_section(SettingsSection::About, window, cx); + } + /// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine — /// for the worktree operations, which shell out to a local `git`. "Copy /// Working Directory" deliberately keeps using `tab_cwd`: copying a remote @@ -3468,7 +3495,7 @@ impl Tty7App { /// Build the full command catalog: the static commands plus one /// "Switch to Tab: …" entry per open tab (label matches the tab strip). fn palette_commands(&self, cx: &App) -> Vec { - let mut commands = Command::base_commands(); + let mut commands = Command::base_commands(cx); // Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row // connects (natively) on Enter and edits on ⌘⏎ / →. @@ -3500,7 +3527,8 @@ impl Tty7App { format!("SSH: {title}"), CommandKind::ConnectSavedProfile(p.id), ) - .with_subtitle(subtitle), + .with_subtitle(subtitle) + .in_group(CommandGroup::Ssh), ); } @@ -3518,10 +3546,13 @@ impl Tty7App { continue; } let label = self.tab_label(tab, i, None, cx); - commands.push(Command::new( - format!("Switch to Tab: {label}"), - CommandKind::ActivateTab(i), - )); + commands.push( + Command::new( + format!("Switch to Tab: {label}"), + CommandKind::ActivateTab(i), + ) + .in_group(CommandGroup::TabsPanes), + ); } commands } @@ -3568,9 +3599,30 @@ impl Tty7App { cx.notify(); } + /// The focused terminal of the active tab, for palette commands that act on + /// the pane rather than the shell. The palette has already closed by the + /// time these run, so focus is back where the user left it. + fn focused_leaf(&self, window: &Window, cx: &App) -> Option> { + self.tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + } + + /// Record that a palette command was run, for the palette's Recent band. + /// Only commands with a stable id are tracked (see `CommandKind::id`). + fn bump_command_frecency(&mut self, kind: &CommandKind, cx: &mut Context) { + let Some(id) = kind.id() else { return }; + self.update_config(cx, |cfg| { + let entry = cfg.command_frecency.entry(id.to_string()).or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = crate::core::config::unix_now(); + }); + } + /// Run a palette command by dispatching to the matching tab/pane operation. fn run_command(&mut self, kind: CommandKind, window: &mut Window, cx: &mut Context) { use CommandKind::*; + self.bump_command_frecency(&kind, cx); match kind { NewTab => self.new_tab(window, cx), NewWorkspace => crate::ui::windows::open(cx, None), @@ -3604,30 +3656,72 @@ impl Tty7App { ToggleRightPanel => self.toggle_right_panel(cx), ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx), ResetFontSize => self.reset_font_size(cx), + // Pane-scoped commands act on the terminal the closing palette just + // handed focus back to. FindInTerminal => { - // Open the search bar on the pane focus just returned to (the - // palette closed before we got here, restoring terminal focus). - if let Some(leaf) = self - .tabs - .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - { + if let Some(leaf) = self.focused_leaf(window, cx) { leaf.update(cx, |view, cx| view.open_search(window, cx)); } } + FindNext => { + if let Some(leaf) = self.focused_leaf(window, cx) { + leaf.update(cx, |view, cx| view.find_step(true, cx)); + } + } + FindPrevious => { + if let Some(leaf) = self.focused_leaf(window, cx) { + leaf.update(cx, |view, cx| view.find_step(false, cx)); + } + } ClearTerminal => { - // Same focus story as FindInTerminal: act on the pane the closing - // palette just handed focus back to. - if let Some(leaf) = self - .tabs - .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - { + if let Some(leaf) = self.focused_leaf(window, cx) { leaf.update(cx, |view, cx| view.clear_scrollback(cx)); } } + CopyText => { + if let Some(leaf) = self.focused_leaf(window, cx) { + // `false`: a menu/palette copy leaves the highlight up. Only + // the dual-purpose ⌃C chord has to consume the selection. + leaf.update(cx, |view, cx| { + view.copy_contextual(false, cx); + }); + } + } + CutText => { + if let Some(leaf) = self.focused_leaf(window, cx) { + leaf.update(cx, |view, cx| { + view.cut_contextual(cx); + }); + } + } + PasteText => { + if let Some(leaf) = self.focused_leaf(window, cx) { + leaf.update(cx, |view, cx| view.paste_from_clipboard(cx)); + } + } + SelectAllText => { + if let Some(leaf) = self.focused_leaf(window, cx) { + leaf.update(cx, |view, cx| view.select_all_contextual(cx)); + } + } ReopenClosedTab => self.reopen_closed_tab(window, cx), + RenameTab => self.start_rename(self.active, window, cx), + NewWorktreeTab => self.new_worktree_tab(self.active, window, cx), + CloseOtherTabs => self.close_other_tabs(self.active, window, cx), + CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx), + CopyWorkingDirectory => self.copy_active_cwd(window, cx), + MarkTabUnread => self.mark_tab_unread(self.active, cx), + RenameWorkspace => self.start_workspace_rename(window, cx), OpenSettings => self.toggle_settings(window, cx), + ShowKeyboardShortcuts => { + self.open_settings_section(SettingsSection::Keybindings, window, cx) + } + About => self.open_settings_section(SettingsSection::About, window, cx), + CheckForUpdates => self.check_for_updates_now(window, cx), + OpenDocumentation => cx.open_url(DOCS_URL), + OpenDiscord => cx.open_url(DISCORD_URL), + ReportIssue => cx.open_url(ISSUES_URL), + Quit => cx.quit(), RestartDaemon => self.restart_daemon(window, cx), ToggleSftp => self.toggle_sftp(window, cx), ShowSshForwards => self.show_ssh_forwards(window, cx), @@ -5374,6 +5468,51 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &RestartSshSession, window, cx| { this.restart_ssh_session(window, cx) })) + // Tab operations that used to be reachable only by right-clicking a + // chip. Each targets the active tab, so the menu bar / palette / + // keyboard all mean "this tab" without a click to say which. + .on_action(cx.listener(|this, _: &RenameTab, window, cx| { + this.start_rename(this.active, window, cx) + })) + .on_action(cx.listener(|this, _: &NewWorktreeTab, window, cx| { + this.new_worktree_tab(this.active, window, cx) + })) + .on_action(cx.listener(|this, _: &CloseOtherTabs, window, cx| { + this.close_other_tabs(this.active, window, cx) + })) + .on_action(cx.listener(|this, _: &CloseTabsToTheRight, window, cx| { + this.close_tabs_right_of(this.active, window, cx) + })) + .on_action(cx.listener(|this, _: &CopyWorkingDirectory, window, cx| { + this.copy_active_cwd(window, cx) + })) + .on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| { + this.mark_tab_unread(this.active, cx) + })) + // Settings destinations that deserve their own way in: Help → + // Keyboard Shortcuts and the App menu's About both used to require + // opening Settings and then hunting for the section. + .on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| { + this.open_settings_section(SettingsSection::Keybindings, window, cx) + })) + .on_action(cx.listener(|this, _: &About, window, cx| { + this.open_settings_section(SettingsSection::About, window, cx) + })) + .on_action(cx.listener(|this, _: &CheckForUpdates, window, cx| { + this.check_for_updates_now(window, cx) + })) + // Standard macOS App / Window menu items. gpui exposes the platform + // calls but ships no actions for them. + .on_action(cx.listener(|_, _: &HideApp, _window, cx| cx.hide())) + .on_action(cx.listener(|_, _: &HideOthers, _window, cx| cx.hide_other_apps())) + .on_action(cx.listener(|_, _: &ShowAll, _window, cx| cx.unhide_other_apps())) + .on_action(cx.listener(|_, _: &MinimizeWindow, window, _cx| window.minimize_window())) + .on_action(cx.listener(|_, _: &ZoomWindow, window, _cx| window.zoom_window())) + // Help destinations. Opened in the default browser; a failure here is + // not worth interrupting the user over, so it is logged, not toasted. + .on_action(cx.listener(|_, _: &OpenDocumentation, _window, cx| cx.open_url(DOCS_URL))) + .on_action(cx.listener(|_, _: &OpenDiscord, _window, cx| cx.open_url(DISCORD_URL))) + .on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL))) // The theme's background image, composited over the background fill // at its own opacity and under all content. Absolute, so it doesn't // participate in the flex column; the wrapper clips the Cover diff --git a/src/ui/home.rs b/src/ui/home.rs index 64e58a9a..1de76fa9 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -44,7 +44,9 @@ const HOME_SHORTCUTS: [(&str, &str); 6] = [ ("TogglePalette", "Command Palette"), ("SplitRight", "Split Right"), ("SplitDown", "Split Down"), - ("OpenSettings", "Settings"), + // "Settings…" everywhere: the menu bar, the tray, the palette and this page + // used to offer four different names for the same destination. + ("OpenSettings", "Settings…"), ]; /// Longest label shown for a recently-closed tab before ellipsizing, matching diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e09ed538..db7ec959 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -116,6 +116,16 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("NewTab", "secondary-t"), ("NewWorkspace", "secondary-shift-n"), ("CloseActiveTab", "secondary-w"), + // Tab operations promoted out of the tab context menu (see + // `core::actions`). No default chords: the menu bar, the palette and the + // right-click menu all reach them, and none is frequent enough to earn a + // reflexive shortcut — but they're bindable here like anything else. + ("RenameTab", ""), + ("NewWorktreeTab", ""), + ("CloseOtherTabs", ""), + ("CloseTabsToTheRight", ""), + ("CopyWorkingDirectory", ""), + ("MarkTabUnread", ""), // No default chord on purpose: this is the one action that kills running // sessions, and it must not sit one slip away from ⌘W. Reachable from // the Shell menu and the palette; bindable in Settings for anyone who @@ -224,6 +234,53 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { // Like Terminal.app / iTerm2 / Ghostty ⌘K: wipe the screen + scrollback. ("ClearScrollback", "secondary-k"), ("OpenSettings", "secondary-,"), + // Help → Keyboard Shortcuts, on the ⌘/ that editors and browsers use for + // "show me the shortcuts". Off macOS `secondary-/` is Ctrl+/, which some + // shells bind to undo, so leave it unbound there. + ( + "ShowKeyboardShortcuts", + if cfg!(target_os = "macos") { + "secondary-/" + } else { + "" + }, + ), + // Menu-bar-only entries: real actions so the palette and Settings can see + // them, but nothing here wants a chord by default. + ("About", ""), + ("CheckForUpdates", ""), + ("OpenDocumentation", ""), + ("OpenDiscord", ""), + ("ReportIssue", ""), + // macOS supplies these chords itself for a standard App/Window menu; we + // list them so they show up in Settings → Keybindings rather than looking + // like undocumented magic, but bind them only where they exist. + ( + "HideApp", + if cfg!(target_os = "macos") { + "secondary-h" + } else { + "" + }, + ), + ( + "HideOthers", + if cfg!(target_os = "macos") { + "secondary-alt-h" + } else { + "" + }, + ), + ("ShowAll", ""), + ( + "MinimizeWindow", + if cfg!(target_os = "macos") { + "secondary-m" + } else { + "" + }, + ), + ("ZoomWindow", ""), // No default chord — reachable from the command palette ("SSH: Remote // Files") and bindable in Settings like any other action. ("ToggleSftp", ""), @@ -484,6 +541,12 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "DeleteWorkspace" => KeyBinding::new(keystroke, DeleteWorkspace, None), "RenameWorkspace" => KeyBinding::new(keystroke, RenameWorkspace, None), "CloseActiveTab" => KeyBinding::new(keystroke, CloseActiveTab, None), + "RenameTab" => KeyBinding::new(keystroke, RenameTab, None), + "NewWorktreeTab" => KeyBinding::new(keystroke, NewWorktreeTab, None), + "CloseOtherTabs" => KeyBinding::new(keystroke, CloseOtherTabs, None), + "CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None), + "CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None), + "MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None), "SplitRight" => KeyBinding::new(keystroke, SplitRight, None), "SplitDown" => KeyBinding::new(keystroke, SplitDown, None), "FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None), @@ -546,6 +609,17 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "FindPrevious" => KeyBinding::new(keystroke, FindPrevious, Some("Terminal")), "ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, Some("Terminal")), "OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None), + "ShowKeyboardShortcuts" => KeyBinding::new(keystroke, ShowKeyboardShortcuts, None), + "About" => KeyBinding::new(keystroke, About, None), + "CheckForUpdates" => KeyBinding::new(keystroke, CheckForUpdates, None), + "OpenDocumentation" => KeyBinding::new(keystroke, OpenDocumentation, None), + "OpenDiscord" => KeyBinding::new(keystroke, OpenDiscord, None), + "ReportIssue" => KeyBinding::new(keystroke, ReportIssue, None), + "HideApp" => KeyBinding::new(keystroke, HideApp, None), + "HideOthers" => KeyBinding::new(keystroke, HideOthers, None), + "ShowAll" => KeyBinding::new(keystroke, ShowAll, None), + "MinimizeWindow" => KeyBinding::new(keystroke, MinimizeWindow, None), + "ZoomWindow" => KeyBinding::new(keystroke, ZoomWindow, None), "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), "ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None), "ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 1337874d..2d4428e7 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -8,10 +8,34 @@ //! [`PaletteView`] wraps that list with the overlay chrome (scrim + card) and //! emits a [`PaletteEvent`] on confirm/dismiss; command *execution* lives in //! `app.rs`, where it can touch `Tty7App`'s tab/pane operations. +//! +//! ## The naming grammar +//! +//! Every title in [`Command::base_commands`] follows one shape, because a +//! palette is read by scanning and a list written in three different styles +//! can't be scanned. The rules, in order of precedence: +//! +//! 1. **`Verb Object`** — "New Tab", "Split Right", "Clear Scrollback". The +//! verb comes first because that is what the user is searching for. +//! 2. **`Namespace: Verb Object`** when the command belongs to an enumerable +//! subsystem — `SSH:`, `Agent:`, `Right Panel:`. If one command in a group +//! carries the prefix, *all* of them do; a single bare sibling (this list +//! used to have "Reconnect SSH Session" sitting beside four `SSH:` rows) is +//! what makes a namespace look accidental. +//! 3. **A trailing `…`** means "this asks for something else before it acts" — +//! another list, a text field, a confirmation. Not "this opens a panel". +//! 4. **No `Toggle`.** A toggle names the mechanism; the user wants the result. +//! Titles read "Hide Left Sidebar" or "Show Left Sidebar" depending on where +//! the sidebar currently is, which also removes the guesswork about what a +//! toggle would do from the current state. +//! 5. **Two commands that could be confused must not merely differ by a word.** +//! "Toggle Tab Sidebar" and "Toggle Left Sidebar" were, respectively, moving +//! the tab bar and collapsing the rail; they now read "Tab Bar: Move to Left +//! Sidebar" and "Hide Left Sidebar". use gpui::{ - App, Context, Entity, EventEmitter, MouseButton, MouseDownEvent, Subscription, Task, Window, - div, prelude::*, px, + App, Context, Entity, EventEmitter, MouseButton, MouseDownEvent, SharedString, Subscription, + Task, Window, div, prelude::*, px, }; use gpui_component::{ ActiveTheme as _, IndexPath, h_flex, @@ -21,6 +45,7 @@ use gpui_component::{ use uuid::Uuid; +use crate::core::config::{Config, RightPanelTab, TabBarPosition}; use crate::core::ssh_profile::parse_quick_connect; /// What a command actually does. Most variants map to an existing `Tty7App` @@ -37,6 +62,8 @@ pub enum CommandKind { /// this focuses that window; otherwise the current window swaps over to it /// and its previous workspace detaches into the picker. SwitchToWorkspace(crate::core::session::WorkspaceId), + /// Rename this window's workspace in place, from the title-bar chip. + RenameWorkspace, /// Stop this window's workspace: kill its sessions and close the window, /// keeping the layout so it can be started again. The counterpart to /// closing a window, which only detaches. @@ -46,6 +73,14 @@ pub enum CommandKind { SplitRight, SplitDown, ClosePane, + // Tab operations that used to live only in the tab context menu, so the + // palette could not reach what a right-click could. + RenameTab, + NewWorktreeTab, + CloseOtherTabs, + CloseTabsToTheRight, + CopyWorkingDirectory, + MarkTabUnread, ResetFontSize, NextPane, PrevPane, @@ -68,11 +103,28 @@ pub enum CommandKind { ToggleRightPanel, /// Switch the right panel to a specific tab, opening it if it was closed — /// so the palette can land you on Changes without a toggle-then-click. - ShowRightPanel(crate::core::config::RightPanelTab), + ShowRightPanel(RightPanelTab), ClearTerminal, FindInTerminal, + FindNext, + FindPrevious, + // The clipboard trio + Select All. Dispatched to the focused terminal, the + // same actions the Edit menu and the right-click menu use. + CopyText, + CutText, + PasteText, + SelectAllText, ReopenClosedTab, OpenSettings, + /// Settings, opened straight on its Keybindings section. + ShowKeyboardShortcuts, + /// Settings, opened straight on its About section. + About, + CheckForUpdates, + OpenDocumentation, + OpenDiscord, + ReportIssue, + Quit, RestartDaemon, /// Show the focused native-SSH pane's remote filesystem — the detail /// panel's Files tab, which browses over SFTP for a remote pane (WS5). @@ -124,23 +176,130 @@ impl CommandKind { _ => None, } } -} -impl CommandKind { - /// The action whose keystroke should be shown beside this command in the - /// palette, if any. Commands with no global binding (Change Theme and its - /// sub-entries, Find, tab switching) return `None` and render without a hint. - fn binding_action(&self) -> Option<&'static str> { + /// A stable key for [`Config::command_frecency`], or `None` for commands + /// that aren't a repeatable "thing you run" — a specific tab index, a theme + /// slot, a typed host. Recording those would fill the Recent group with + /// entries that mean something different next launch. + pub fn id(&self) -> Option<&'static str> { use CommandKind::*; Some(match self { + NewTab => "new-tab", + NewWorkspace => "new-workspace", + OpenWorkspacePicker => "switch-workspace", + RenameWorkspace => "rename-workspace", + StopWorkspace => "stop-workspace", + DeleteWorkspace => "delete-workspace", + SplitRight => "split-right", + SplitDown => "split-down", + ClosePane => "close-pane", + RenameTab => "rename-tab", + NewWorktreeTab => "new-worktree-tab", + CloseOtherTabs => "close-other-tabs", + CloseTabsToTheRight => "close-tabs-right", + CopyWorkingDirectory => "copy-cwd", + MarkTabUnread => "mark-tab-unread", + ResetFontSize => "reset-font-size", + NextPane => "next-pane", + PrevPane => "prev-pane", + FocusPaneLeft => "focus-pane-left", + FocusPaneRight => "focus-pane-right", + FocusPaneUp => "focus-pane-up", + FocusPaneDown => "focus-pane-down", + ResizePaneLeft => "resize-pane-left", + ResizePaneRight => "resize-pane-right", + ResizePaneUp => "resize-pane-up", + ResizePaneDown => "resize-pane-down", + SwapPaneNext => "swap-pane-next", + SwapPanePrev => "swap-pane-prev", + NextTab => "next-tab", + PrevTab => "prev-tab", + ToggleMaximizePane => "zoom-pane", + ToggleFullscreen => "full-screen", + ToggleTabSidebar => "tab-bar-position", + ToggleLeftPanel => "left-sidebar", + ToggleRightPanel => "right-panel", + ShowRightPanel(RightPanelTab::Info) => "right-panel-info", + ShowRightPanel(RightPanelTab::Outline) => "right-panel-outline", + ShowRightPanel(RightPanelTab::Changes) => "right-panel-changes", + ShowRightPanel(RightPanelTab::Files) => "right-panel-files", + ClearTerminal => "clear-scrollback", + FindInTerminal => "find", + FindNext => "find-next", + FindPrevious => "find-previous", + CopyText => "copy", + CutText => "cut", + PasteText => "paste", + SelectAllText => "select-all", + ReopenClosedTab => "reopen-closed-tab", + OpenSettings => "settings", + ShowKeyboardShortcuts => "keyboard-shortcuts", + About => "about", + CheckForUpdates => "check-for-updates", + OpenDocumentation => "documentation", + OpenDiscord => "discord", + ReportIssue => "report-issue", + Quit => "quit", + RestartDaemon => "restart-daemon", + ToggleSftp => "ssh-remote-files", + ShowSshForwards => "ssh-port-forwarding", + ToggleCodePanel => "code-panel", + RestartSshSession => "ssh-reconnect", + SendSelectionToAgent => "agent-send-selection", + SendGitDiffToAgent => "agent-send-diff", + OpenThemePicker => "change-theme", + OpenSshConnectInput => "ssh-add-connection", + OpenSshProfiles => "ssh-manage-profiles", + // Instance-specific: a tab index, a theme slot, a profile id, a + // typed host. Not stable across sessions, so not tracked. + SwitchToWorkspace(_) + | OpenSshConnect(_) + | SetTheme(_) + | ActivateTab(_) + | ConnectSavedProfile(_) + | EditSavedProfile(_) + | QuickConnect(_) + | SaveQuickConnect(_) => return None, + }) + } + + /// The keystroke shown beside this command, as a config keyspec. + /// + /// Most commands resolve through the live keymap, so a user remap shows up + /// here automatically. The clipboard trio and Select All are the exception: + /// they're handled inline in `terminal::view::handle_cmd_shortcut` rather + /// than as registered bindings (⌃C has to fall through to SIGINT with + /// nothing selected, which a registered binding would swallow), so their + /// chords are stated literally — the same thing the right-click menu does. + fn key_spec(&self, cx: &App) -> Option { + use CommandKind::*; + // Inline-handled chords, macOS-only: off macOS these live on Ctrl and + // Ctrl+A / Ctrl+F keep their readline meaning, so advertising them + // would be a lie. + let inline = + |spec: &str| -> Option { cfg!(target_os = "macos").then(|| spec.to_string()) }; + match self { + CopyText => return inline("secondary-c"), + CutText => return inline("secondary-x"), + PasteText => return inline("secondary-v"), + SelectAllText => return inline("secondary-a"), + _ => {} + } + let action = match self { NewTab => "NewTab", NewWorkspace => "NewWorkspace", - OpenWorkspacePicker | SwitchToWorkspace(_) => return None, + RenameWorkspace => "RenameWorkspace", StopWorkspace => "StopWorkspace", DeleteWorkspace => "DeleteWorkspace", SplitRight => "SplitRight", SplitDown => "SplitDown", ClosePane => "CloseActiveTab", + RenameTab => "RenameTab", + NewWorktreeTab => "NewWorktreeTab", + CloseOtherTabs => "CloseOtherTabs", + CloseTabsToTheRight => "CloseTabsToTheRight", + CopyWorkingDirectory => "CopyWorkingDirectory", + MarkTabUnread => "MarkTabUnread", ResetFontSize => "ResetFontSize", NextPane => "FocusNextPane", PrevPane => "FocusPrevPane", @@ -161,26 +320,44 @@ impl CommandKind { ToggleTabSidebar => "ToggleTabSidebar", ToggleLeftPanel => "ToggleLeftPanel", ToggleRightPanel => "ToggleRightPanel", - ShowRightPanel(tab) => { - use crate::core::config::RightPanelTab as T; - match tab { - T::Info => "ShowRightPanelInfo", - T::Outline => "ShowRightPanelOutline", - T::Changes => "ShowRightPanelChanges", - T::Files => "ShowRightPanelFiles", - } - } + ShowRightPanel(tab) => match tab { + RightPanelTab::Info => "ShowRightPanelInfo", + RightPanelTab::Outline => "ShowRightPanelOutline", + RightPanelTab::Changes => "ShowRightPanelChanges", + RightPanelTab::Files => "ShowRightPanelFiles", + }, ClearTerminal => "ClearScrollback", + // Previously grouped with the hint-less commands even though it has + // shipped a default ⌘F for as long as the binding has existed — so + // the one command whose shortcut users most want to learn was the + // one the palette refused to teach. + FindInTerminal => "FindInTerminal", + FindNext => "FindNext", + FindPrevious => "FindPrevious", ReopenClosedTab => "ReopenClosedTab", OpenSettings => "OpenSettings", + ShowKeyboardShortcuts => "ShowKeyboardShortcuts", + About => "About", + CheckForUpdates => "CheckForUpdates", + OpenDocumentation => "OpenDocumentation", + OpenDiscord => "OpenDiscord", + ReportIssue => "ReportIssue", + Quit => "Quit", RestartDaemon => "RestartDaemon", ToggleSftp => "ToggleSftp", ShowSshForwards => "ShowSshForwards", ToggleCodePanel => "ToggleCodePanel", RestartSshSession => "RestartSshSession", - SendSelectionToAgent + OpenSshProfiles => "OpenSshProfiles", + // No global binding, by design or by nature. + CopyText + | CutText + | PasteText + | SelectAllText + | SendSelectionToAgent | SendGitDiffToAgent - | FindInTerminal + | OpenWorkspacePicker + | SwitchToWorkspace(_) | OpenThemePicker | OpenSshConnectInput | OpenSshConnect(_) @@ -189,9 +366,50 @@ impl CommandKind { | ConnectSavedProfile(_) | EditSavedProfile(_) | QuickConnect(_) - | SaveQuickConnect(_) - | OpenSshProfiles => return None, - }) + | SaveQuickConnect(_) => return None, + }; + crate::ui::keymap::effective_key(action, cx) + } +} + +/// The band a command is filed under in the unfiltered palette. Groups exist so +/// the resting list reads as a map of the app rather than 60 undifferentiated +/// rows; while a search is running they're dropped and the results rank purely +/// by match quality. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CommandGroup { + TabsPanes, + Workspaces, + View, + Terminal, + Ssh, + Agents, + Application, +} + +impl CommandGroup { + /// Display order of the groups, which is roughly "how often you reach for + /// this": the tab/pane verbs first, the app-level chores last. + const ORDER: [CommandGroup; 7] = [ + CommandGroup::TabsPanes, + CommandGroup::Workspaces, + CommandGroup::View, + CommandGroup::Terminal, + CommandGroup::Ssh, + CommandGroup::Agents, + CommandGroup::Application, + ]; + + fn title(self) -> &'static str { + match self { + CommandGroup::TabsPanes => "Tabs & Panes", + CommandGroup::Workspaces => "Workspaces", + CommandGroup::View => "View", + CommandGroup::Terminal => "Terminal", + CommandGroup::Ssh => "SSH", + CommandGroup::Agents => "Agents", + CommandGroup::Application => "Application", + } } } @@ -203,6 +421,7 @@ pub struct Command { /// profile's `user@host`, or `(~/.ssh/config)` for an alias). pub subtitle: Option, pub kind: CommandKind, + pub group: CommandGroup, } impl Command { @@ -211,6 +430,9 @@ impl Command { title: title.into(), subtitle: None, kind, + // Overwritten by `base_commands`, which files every entry; the + // default only matters for the sub-lists, which render ungrouped. + group: CommandGroup::Application, } } @@ -220,25 +442,40 @@ impl Command { self } + /// File this command under a group. The dynamic entries the host appends + /// (saved SSH profiles, "Switch to Tab: …") have to say where they belong + /// or they'd all land in the default band. + pub fn in_group(mut self, group: CommandGroup) -> Self { + self.group = group; + self + } + /// The static commands available regardless of how many tabs exist. The - /// caller appends the dynamic "Switch to Tab: …" entries (one per tab). + /// caller appends the dynamic SSH-profile and "Switch to Tab: …" entries. /// - /// Trailing "…" flags a command that opens further UI rather than acting - /// immediately (a sub-list for Change Theme, a search bar for Find). The - /// held-key font zoom (⌘+/⌘−) is deliberately absent — stepping it needs a - /// re-open per press, so it makes a poor palette citizen; only the one-shot - /// Reset is worth a slot. - pub fn base_commands() -> Vec { + /// Titles follow the grammar documented at the top of this module. Several + /// are *stateful*: a command that flips something reads as the outcome it + /// will produce right now ("Hide Left Sidebar" when the rail is out), which + /// is why this needs `cx`. + /// + /// The held-key font zoom (⌘+/⌘−) is deliberately absent — stepping it needs + /// a re-open per press, so it makes a poor palette citizen; only the + /// one-shot Reset is worth a slot. + pub fn base_commands(cx: &App) -> Vec { use CommandKind::*; - vec![ + let cfg = cx.global::(); + let tab_bar_left = cfg.tab_bar_position == TabBarPosition::Left; + let sidebar_hidden = cfg.sidebar_collapsed || !tab_bar_left; + let right_panel_open = cfg.right_panel_visible; + + let tabs = [ Command::new("New Tab", NewTab), - Command::new("New Workspace", NewWorkspace), - Command::new("Switch Workspace…", OpenWorkspacePicker), - Command::new("Stop Workspace…", StopWorkspace), - Command::new("Delete Workspace…", DeleteWorkspace), + Command::new("New Worktree Tab", NewWorktreeTab) + .with_subtitle("isolated checkout on a fresh branch"), + Command::new("Rename Tab…", RenameTab), Command::new("Split Right", SplitRight), Command::new("Split Down", SplitDown), - Command::new("Close Pane/Tab", ClosePane), + Command::new("Zoom Pane", ToggleMaximizePane), Command::new("Next Pane", NextPane), Command::new("Previous Pane", PrevPane), Command::new("Focus Pane Left", FocusPaneLeft), @@ -253,45 +490,124 @@ impl Command { Command::new("Swap Pane Previous", SwapPanePrev), Command::new("Next Tab", NextTab), Command::new("Previous Tab", PrevTab), - Command::new("Toggle Maximize Pane", ToggleMaximizePane), - Command::new("Toggle Fullscreen", ToggleFullscreen), - Command::new("Toggle Tab Sidebar", ToggleTabSidebar), - Command::new("Toggle Left Sidebar", ToggleLeftPanel), - Command::new("Toggle Right Panel", ToggleRightPanel), + Command::new("Copy Working Directory", CopyWorkingDirectory), + Command::new("Mark Tab as Unread", MarkTabUnread), + Command::new("Close Pane / Tab", ClosePane), + Command::new("Close Other Tabs", CloseOtherTabs), + Command::new("Close Tabs to the Right", CloseTabsToTheRight), + Command::new("Reopen Closed Tab", ReopenClosedTab), + ]; + + let workspaces = [ + Command::new("New Workspace", NewWorkspace), + Command::new("Switch Workspace…", OpenWorkspacePicker), + Command::new("Rename Workspace…", RenameWorkspace), + Command::new("Stop Workspace…", StopWorkspace) + .with_subtitle("ends its sessions, keeps the layout"), + Command::new("Delete Workspace…", DeleteWorkspace) + .with_subtitle("ends its sessions and forgets the layout"), + ]; + + let view = [ + // Stateful titles: what the command will do from here, not the name + // of the switch it throws. Command::new( - "Right Panel: Info", - ShowRightPanel(crate::core::config::RightPanelTab::Info), + if sidebar_hidden { + "Show Left Sidebar" + } else { + "Hide Left Sidebar" + }, + ToggleLeftPanel, ), + Command::new( + if right_panel_open { + "Hide Right Panel" + } else { + "Show Right Panel" + }, + ToggleRightPanel, + ), + Command::new("Show Code Panel", ToggleCodePanel), + // Was "Toggle Tab Sidebar", one row away from "Toggle Left Sidebar" + // and indistinguishable from it. + Command::new( + if tab_bar_left { + "Tab Bar: Move to Top" + } else { + "Tab Bar: Move to Left Sidebar" + }, + ToggleTabSidebar, + ), + Command::new("Right Panel: Info", ShowRightPanel(RightPanelTab::Info)), Command::new( "Right Panel: Outline", - ShowRightPanel(crate::core::config::RightPanelTab::Outline), + ShowRightPanel(RightPanelTab::Outline), ), Command::new( "Right Panel: Changes", - ShowRightPanel(crate::core::config::RightPanelTab::Changes), + ShowRightPanel(RightPanelTab::Changes), ), - Command::new( - "Right Panel: Files", - ShowRightPanel(crate::core::config::RightPanelTab::Files), - ), - Command::new("Clear", ClearTerminal), + Command::new("Right Panel: Files", ShowRightPanel(RightPanelTab::Files)), + Command::new("Change Theme…", OpenThemePicker), + Command::new("Reset Font Size", ResetFontSize), + Command::new("Enter Full Screen", ToggleFullscreen), + ]; + + let terminal = [ + // Was "Clear", which never said what it cleared. + Command::new("Clear Scrollback", ClearTerminal), Command::new("Find in Terminal…", FindInTerminal), - Command::new("Reopen Closed Tab", ReopenClosedTab), + Command::new("Find Next", FindNext), + Command::new("Find Previous", FindPrevious), + Command::new("Copy", CopyText), + Command::new("Cut", CutText), + Command::new("Paste", PasteText), + Command::new("Select All", SelectAllText), + ]; + + let ssh = [ Command::new("SSH: Add Connection…", OpenSshConnectInput), Command::new("SSH: Manage Profiles…", OpenSshProfiles), - Command::new("Reconnect SSH Session", RestartSshSession), + // Was "Reconnect SSH Session" — the one bare sibling among five. + Command::new("SSH: Reconnect Session", RestartSshSession), Command::new("SSH: Remote Files", ToggleSftp), Command::new("SSH: Port Forwarding", ShowSshForwards), - Command::new("Code Panel", ToggleCodePanel), - Command::new("Change Theme…", OpenThemePicker), - Command::new("Open Settings", OpenSettings), - Command::new("Reset Font Size", ResetFontSize), - Command::new("Restart Daemon…", RestartDaemon), + ]; + + let agents = [ Command::new("Agent: Send Selection", SendSelectionToAgent) .with_subtitle("selection → running coding agent"), Command::new("Agent: Send Git Diff for Review", SendGitDiffToAgent) .with_subtitle("git diff → running coding agent"), - ] + ]; + + let application = [ + // Was "Open Settings" while the menu bar, the tray and the home page + // all said "Settings" — four names for one destination. + Command::new("Settings…", OpenSettings), + Command::new("Keyboard Shortcuts", ShowKeyboardShortcuts), + Command::new("About tty7", About), + Command::new("Check for Updates…", CheckForUpdates), + Command::new("Documentation", OpenDocumentation), + Command::new("Join the Discord", OpenDiscord), + Command::new("Report an Issue…", ReportIssue), + Command::new("Restart Daemon…", RestartDaemon) + .with_subtitle("ends every running shell; layout is kept"), + Command::new("Quit tty7", Quit).with_subtitle("sessions keep running"), + ]; + + let mut out = Vec::new(); + let mut push = |cmds: Vec, group: CommandGroup| { + out.extend(cmds.into_iter().map(|c| c.in_group(group))); + }; + push(tabs.into(), CommandGroup::TabsPanes); + push(workspaces.into(), CommandGroup::Workspaces); + push(view.into(), CommandGroup::View); + push(terminal.into(), CommandGroup::Terminal); + push(ssh.into(), CommandGroup::Ssh); + push(agents.into(), CommandGroup::Agents); + push(application.into(), CommandGroup::Application); + out } /// The workspace sub-list: every workspace tty7 knows about, most recently @@ -358,42 +674,113 @@ impl Command { } } -/// Case-insensitive subsequence match: every character of `query` must appear -/// in `title`, in order (but not necessarily contiguously). An empty query -/// matches everything. This is the simple "fuzzy" rule the palette filters on. -pub fn fuzzy_match(query: &str, title: &str) -> bool { - let mut needle = query.chars().flat_map(char::to_lowercase).peekable(); - for ch in title.chars().flat_map(char::to_lowercase) { - if needle.peek() == Some(&ch) { - needle.next(); - } +/// Score how well `query` matches `text`, or `None` when it doesn't match at +/// all. Higher is better; an empty query scores every candidate 0. +/// +/// The rule is still "every character of the query appears in order", but the +/// old boolean version left results in catalog order, so typing `sr` put "New +/// Tab" (**s**plit… no — the first row whose letters happened to line up) above +/// "Split Right". Scoring adds what makes a palette feel like it read your +/// mind: matches on word boundaries and runs of adjacent characters count for +/// much more than letters scattered through a long title. +pub fn fuzzy_score(query: &str, text: &str) -> Option { + let needle: Vec = query + .chars() + .flat_map(char::to_lowercase) + .filter(|c| !c.is_whitespace()) + .collect(); + if needle.is_empty() { + return Some(0); } - // All needle chars consumed → matched. An empty query trivially satisfies this. - needle.peek().is_none() + let hay: Vec = text.chars().flat_map(char::to_lowercase).collect(); + if needle.len() > hay.len() { + return None; + } + + let mut qi = 0usize; + let mut score = 0i32; + let mut run = 0i32; + let mut prev_hit = false; + for (i, ch) in hay.iter().enumerate() { + if qi >= needle.len() { + break; + } + if *ch != needle[qi] { + prev_hit = false; + run = 0; + continue; + } + score += 1; + // Start of the string, or of a word: "sr" → "**S**plit **R**ight" is + // what the user meant, and it must outrank the same letters buried + // mid-word somewhere else. + let word_start = i == 0 || !hay[i - 1].is_alphanumeric(); + if word_start { + score += 12; + } + if i == 0 { + score += 10; + } + if prev_hit { + run += 1; + score += 6 + run.min(8); + } else { + run = 0; + } + prev_hit = true; + qi += 1; + } + if qi < needle.len() { + return None; + } + + if hay == needle { + score += 120; + } else if hay.starts_with(&needle) { + score += 50; + } + // Among equally good matches, prefer the shorter title: "Copy" should beat + // "Copy Working Directory" for the query "copy". + score -= (hay.len() as i32) / 6; + Some(score) } -/// True when `query` fuzzy-matches a command's subtitle (e.g. typing a hostname -/// matches a profile row whose subtitle is `user@host`). A command with no -/// subtitle never matches this way. -fn fuzzy_match_subtitle(query: &str, cmd: &Command) -> bool { - cmd.subtitle +/// The best score for a command against `query`: its title, or its subtitle at +/// a discount (a subtitle hit is a weaker signal of intent than a title hit, +/// but typing a hostname should still find the profile row it belongs to). +fn command_score(query: &str, cmd: &Command) -> Option { + let title = fuzzy_score(query, &cmd.title); + let subtitle = cmd + .subtitle .as_deref() - .is_some_and(|s| fuzzy_match(query, s)) + .and_then(|s| fuzzy_score(query, s)) + .map(|s| s / 2 - 25); + match (title, subtitle) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + } +} + +/// One rendered band of the list: an optional header plus its rows. A search +/// collapses everything into a single header-less section ranked by score. +#[derive(Clone)] +struct Section { + title: Option, + commands: Vec, } /// Feeds the command catalog to gpui-component's `ListState`. It keeps the full -/// catalog plus the subset matching the current query (`matched`), re-filtering -/// in `perform_search` whenever the search input changes. +/// catalog plus the sections matching the current query, re-filtering in +/// `perform_search` whenever the search input changes. pub struct PaletteDelegate { /// The full catalog: static commands followed by per-tab switch entries. commands: Vec, - /// The subset matching the current query — exactly what the list renders. - matched: Vec, + /// Exactly what the list renders, in render order. + sections: Vec
, input: Option, - query: String, - /// Whether this is the root catalog, where a query that parses as - /// `user@host[:port]` injects live "Connect to …" / "Save … as profile" - /// rows so QuickConnect shares the one entry box (PRD §6.2 ①). + /// Whether this is the root catalog: grouped when idle, and a query that + /// parses as `user@host[:port]` injects live "Connect to …" / "Save … as + /// profile" rows so QuickConnect shares the one entry box (PRD §6.2 ①). quick_connect_root: bool, /// Index of the highlighted row, mirrored from the list's own selection so /// `render_item` can mark it. `None` when nothing matches. @@ -408,22 +795,74 @@ enum PaletteInput { impl PaletteDelegate { pub fn new(commands: Vec) -> Self { Self { - matched: commands.clone(), + sections: vec![Section { + title: None, + commands: commands.clone(), + }], commands, input: None, - query: String::new(), quick_connect_root: false, selected: Some(IndexPath::default()), } } - /// The root delegate: like [`new`], but a query that parses as a QuickConnect - /// target injects live connect/save rows. - pub fn root(commands: Vec) -> Self { - Self { + /// The root delegate: grouped headers while idle, QuickConnect rows on a + /// host-like query. + pub fn root(commands: Vec, cx: &App) -> Self { + let mut this = Self { quick_connect_root: true, ..Self::new(commands) + }; + this.sections = this.grouped_sections(cx); + this + } + + /// The idle (empty-query) layout: a Recent band built from + /// [`Config::command_frecency`], then one band per [`CommandGroup`]. + /// + /// Recent exists because the catalog's order is authored, not personal: the + /// first screenful used to be whatever was typed first — four Focus Pane + /// directions and four Resize Pane directions — while Change Theme and + /// Settings sat below the fold. + fn grouped_sections(&self, cx: &App) -> Vec
{ + let cfg = cx.global::(); + let now = crate::core::config::unix_now(); + let mut sections = Vec::new(); + + let mut recent: Vec<(f64, &Command)> = self + .commands + .iter() + .filter_map(|c| { + let id = c.kind.id()?; + let usage = cfg.command_frecency.get(id)?; + let score = usage.score(now); + (score > 0.0).then_some((score, c)) + }) + .collect(); + recent.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + recent.truncate(RECENT_ROWS); + if !recent.is_empty() { + sections.push(Section { + title: Some("Recent".into()), + commands: recent.into_iter().map(|(_, c)| c.clone()).collect(), + }); } + + for group in CommandGroup::ORDER { + let commands: Vec = self + .commands + .iter() + .filter(|c| c.group == group) + .cloned() + .collect(); + if !commands.is_empty() { + sections.push(Section { + title: Some(group.title().into()), + commands, + }); + } + } + sections } /// The QuickConnect rows for a query at the root, if it parses as a target. @@ -457,34 +896,52 @@ impl PaletteDelegate { } fn ssh_connect() -> Self { - let matched = vec![Command::ssh_connect_command("")]; Self { commands: Vec::new(), - matched, + sections: vec![Section { + title: None, + commands: vec![Command::ssh_connect_command("")], + }], input: Some(PaletteInput::SshConnect), - query: String::new(), quick_connect_root: false, selected: Some(IndexPath::default()), } } - /// The command kind at the given (filtered) index path, if any. Called by - /// `app.rs` when the list confirms a selection. + /// The command kind at the given index path, if any. Called by `app.rs` + /// when the list confirms a selection. pub fn command_at(&self, ix: IndexPath) -> Option { - self.matched.get(ix.row).map(|c| c.kind.clone()) + self.sections + .get(ix.section)? + .commands + .get(ix.row) + .map(|c| c.kind.clone()) } /// The currently highlighted command, if any (for the ⌘⏎ / → edit gesture). pub fn selected_command(&self) -> Option { self.selected.and_then(|ix| self.command_at(ix)) } + + /// The first selectable row, or `None` when nothing matched. + fn first_row(&self) -> Option { + let section = self.sections.iter().position(|s| !s.commands.is_empty())?; + Some(IndexPath::new(0).section(section)) + } } impl ListDelegate for PaletteDelegate { type Item = ListItem; - fn items_count(&self, _section: usize, _cx: &App) -> usize { - self.matched.len() + fn sections_count(&self, _cx: &App) -> usize { + self.sections.len().max(1) + } + + fn items_count(&self, section: usize, _cx: &App) -> usize { + self.sections + .get(section) + .map(|s| s.commands.len()) + .unwrap_or(0) } /// Re-filter the catalog against the live query and reset the highlight to @@ -493,37 +950,99 @@ impl ListDelegate for PaletteDelegate { &mut self, query: &str, _window: &mut Window, - _cx: &mut Context>, + cx: &mut Context>, ) -> Task<()> { if let Some(PaletteInput::SshConnect) = self.input { - self.query = query.to_string(); - self.matched = vec![Command::ssh_connect_command(query)]; + self.sections = vec![Section { + title: None, + commands: vec![Command::ssh_connect_command(query)], + }]; + } else if query.trim().is_empty() { + // Idle: the grouped map of the app (root), or the sub-list as-is. + self.sections = if self.quick_connect_root { + self.grouped_sections(cx) + } else { + vec![Section { + title: None, + commands: self.commands.clone(), + }] + }; } else { - let mut matched: Vec = Vec::new(); + // Searching: one flat, header-less band ranked by match quality. + // Headers would only get in the way of "type three letters, hit + // Enter", and the ranking already puts the best row first. + let mut scored: Vec<(i32, Command)> = self + .commands + .iter() + .filter_map(|c| command_score(query, c).map(|s| (s, c.clone()))) + .collect(); + // Stable sort, so equal scores keep catalog order. + scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + let mut commands: Vec = Vec::new(); // At the root, a query that parses as a connect target leads with - // QuickConnect rows (PRD §6.2 ①), above the fuzzy-matched catalog. + // QuickConnect rows (PRD §6.2 ①), above the ranked catalog. if self.quick_connect_root { - matched.extend(Self::quick_connect_commands(query)); + commands.extend(Self::quick_connect_commands(query)); } - matched.extend( - self.commands - .iter() - .filter(|c| fuzzy_match(query, &c.title) || fuzzy_match_subtitle(query, c)) - .cloned(), - ); - self.matched = matched; + commands.extend(scored.into_iter().map(|(_, c)| c)); + self.sections = vec![Section { + title: None, + commands, + }]; } - self.selected = (!self.matched.is_empty()).then(IndexPath::default); + self.selected = self.first_row(); Task::ready(()) } + fn render_section_header( + &mut self, + section: usize, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let title = self.sections.get(section)?.title.clone()?; + Some( + // Same fixed height as a row: the card's viewport is sized to a + // whole number of `PALETTE_ROW_H` units, and a header of any other + // height would leave the last visible row sliced by the card edge. + h_flex() + .h(px(PALETTE_ROW_H)) + .px(px(11.)) + .items_center() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(title), + ) + } + + fn render_empty( + &mut self, + _window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + // A blank card reads as a hang. Name the miss and point at the one + // thing this box does besides run commands. + v_flex() + .py_8() + .gap_1() + .items_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No matching commands") + .child( + div() + .text_xs() + .child("Type user@host to connect over SSH instead."), + ) + } + fn render_item( &mut self, ix: IndexPath, _window: &mut Window, cx: &mut Context>, ) -> Option { - let cmd = self.matched.get(ix.row)?; + let cmd = self.sections.get(ix.section)?.commands.get(ix.row)?.clone(); // Read the colours we need as Copy values, then release the theme borrow // so we can borrow `cx` again for the keybinding lookup below. @@ -537,8 +1056,7 @@ impl ListDelegate for PaletteDelegate { // command palette feel professional and teaches the shortcut in passing. let keys = cmd .kind - .binding_action() - .and_then(|action| crate::ui::keymap::effective_key(action, cx)) + .key_spec(cx) .map(|spec| crate::ui::keymap::key_tokens(&spec)); // Title, with an optional dimmed subtitle to its right (a profile's @@ -578,7 +1096,10 @@ impl ListDelegate for PaletteDelegate { } Some( - ListItem::new(ix.row) + // Keyed by section *and* row: with grouped sections a bare row index + // repeats across bands, and duplicate element ids make the list + // reuse the wrong row's state. + ListItem::new(("palette-row", ix.section * 1000 + ix.row)) .selected(Some(ix) == self.selected) // Fixed-height, dense rows (see `PALETTE_ROW_H`: the card's // list viewport is sized to a whole number of rows). The 5px @@ -663,13 +1184,15 @@ impl PaletteView { Self::build_list_with_delegate(PaletteDelegate::new(commands), window, cx) } - /// The root list, whose delegate injects live QuickConnect rows. + /// The root list: grouped while idle, and its delegate injects live + /// QuickConnect rows for a host-like query. fn build_root_list( commands: Vec, window: &mut Window, cx: &mut Context, ) -> Entity> { - Self::build_list_with_delegate(PaletteDelegate::root(commands), window, cx) + let delegate = PaletteDelegate::root(commands, cx); + Self::build_list_with_delegate(delegate, window, cx) } fn build_list_with_delegate( @@ -775,9 +1298,13 @@ impl EventEmitter for PaletteView {} /// Fixed command-row height (see `render_item`). The list viewport must hold a /// whole number of rows, or the card's bottom edge cuts the last one mid-height. +/// Section headers are pinned to the same height for the same reason. const PALETTE_ROW_H: f32 = 30.; /// Rows visible before the list scrolls. const PALETTE_VISIBLE_ROWS: f32 = 12.; +/// How many entries the idle "Recent" band shows. Small on purpose: it's a +/// shortcut to the two or three things you actually repeat, not a history log. +const RECENT_ROWS: usize = 5; impl Render for PaletteView { /// The centered overlay: a dim full-window scrim plus the command card. The @@ -896,4 +1423,88 @@ mod tests { assert!(row_titles("java:99999").is_empty()); assert!(row_titles("@").is_empty()); } + + #[test] + fn empty_query_matches_everything() { + assert_eq!(fuzzy_score("", "anything"), Some(0)); + } + + #[test] + fn non_subsequence_does_not_match() { + assert_eq!(fuzzy_score("zzz", "Split Right"), None); + assert_eq!(fuzzy_score("thgir", "Split Right"), None); + } + + /// The ranking's whole job: word-initials and prefixes beat letters + /// scattered through a longer title. + #[test] + fn word_initials_outrank_scattered_letters() { + let target = fuzzy_score("sr", "Split Right").expect("matches"); + // "Se...r" — an s and a later r, neither on a word boundary after the + // first, in a longer title. + let scattered = fuzzy_score("sr", "SSH: Manage Profiles…").expect("matches"); + assert!( + target > scattered, + "expected 'Split Right' ({target}) to outrank 'SSH: Manage Profiles…' ({scattered})" + ); + } + + #[test] + fn exact_and_prefix_beat_mid_string() { + let exact = fuzzy_score("copy", "Copy").expect("matches"); + let longer = fuzzy_score("copy", "Copy Working Directory").expect("matches"); + assert!( + exact > longer, + "expected exact 'Copy' ({exact}) above 'Copy Working Directory' ({longer})" + ); + } + + /// A subtitle hit still finds the row, but never outranks a title hit — + /// typing a hostname should reach the profile whose subtitle carries it. + #[test] + fn subtitle_matches_are_found_but_discounted() { + let cmd = Command::new("prod-web", CommandKind::NewTab) + .with_subtitle("deploy@10.0.0.5".to_string()); + assert!(command_score("10.0.0", &cmd).is_some()); + let title_hit = command_score("prod", &cmd).expect("title matches"); + let subtitle_hit = command_score("deploy", &cmd).expect("subtitle matches"); + assert!(title_hit > subtitle_hit); + } + + /// Every command that can be filed under Recent needs a stable id, and no + /// two commands may share one — a collision would make the Recent band + /// promote the wrong row. + #[test] + fn stable_ids_are_unique() { + let mut seen = std::collections::HashSet::new(); + for kind in [ + CommandKind::NewTab, + CommandKind::SplitRight, + CommandKind::ClearTerminal, + CommandKind::CopyText, + CommandKind::CutText, + CommandKind::PasteText, + CommandKind::SelectAllText, + CommandKind::FindInTerminal, + CommandKind::FindNext, + CommandKind::FindPrevious, + CommandKind::OpenSettings, + CommandKind::ShowKeyboardShortcuts, + CommandKind::About, + CommandKind::Quit, + CommandKind::ShowRightPanel(RightPanelTab::Info), + CommandKind::ShowRightPanel(RightPanelTab::Files), + ] { + let id = kind.id().expect("static command has an id"); + assert!(seen.insert(id), "duplicate command id {id:?}"); + } + } + + /// Instance-specific commands must stay out of the frecency store. + #[test] + fn dynamic_commands_have_no_id() { + assert!(CommandKind::ActivateTab(2).id().is_none()); + assert!(CommandKind::SetTheme(0).id().is_none()); + assert!(CommandKind::QuickConnect("a@b".into()).id().is_none()); + } } diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index bfaef1f1..df97ac7c 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -461,14 +461,28 @@ impl Tty7App { .into_any_element() } - /// A quiet "nothing to show" line, used wherever a tab has no data yet. - fn panel_empty(&self, text: &str, cx: &mut Context) -> AnyElement { - div() + /// A quiet "nothing to show" line, used wherever a tab has no data yet, + /// with an optional second line saying what would fill it. + /// + /// The hint is the point. An empty state that only reports the absence + /// ("No changes.") leaves the user to work out whether the panel is broken, + /// still loading, or simply pointed at the wrong thing; one that names the + /// condition turns a dead end into an instruction. + fn panel_empty(&self, text: &str, hint: Option<&str>, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + v_flex() .px(px(CONTENT_INSET)) .py(px(4.)) + .gap(px(3.)) .text_size(px(12.)) - .text_color(cx.theme().muted_foreground) + .text_color(muted) .child(text.to_string()) + .children(hint.map(|h| { + div() + .text_size(px(11.)) + .text_color(muted.opacity(0.75)) + .child(h.to_string()) + })) .into_any_element() } @@ -535,7 +549,14 @@ impl Tty7App { } if rows.is_empty() { - return self.panel_scroll(self.panel_empty("No active session.", cx), title); + return self.panel_scroll( + self.panel_empty( + "No active session.", + Some("Open a tab to see its shell, directory, and processes here."), + cx, + ), + title, + ); } // Keep the process/port query pointed at the pane on screen, and keep it @@ -916,7 +937,14 @@ impl Tty7App { .and_then(|t| t.detail_pane(window, cx)) else { let title = self.panel_title("Outline", None, None, cx); - return self.panel_scroll(self.panel_empty("No active session.", cx), title); + return self.panel_scroll( + self.panel_empty( + "No active session.", + Some("Open a tab to see its shell, directory, and processes here."), + cx, + ), + title, + ); }; // Count first (a cheap getter) so the borrow ends before `panel_title` // needs `&mut cx`; the list re-borrows the marks below. @@ -927,7 +955,11 @@ impl Tty7App { // `sh`, a nested PTY that eats the marks). let title = self.panel_title("Outline", None, None, cx); return self.panel_scroll( - self.panel_empty("No commands recorded for this pane.", cx), + self.panel_empty( + "No commands recorded for this pane.", + Some("Run a command — shell integration marks each one so you can jump back to it."), + cx, + ), title, ); } @@ -1024,7 +1056,14 @@ impl Tty7App { let Some(cwd) = cwd else { let title = self.panel_title("Changes", None, None, cx); - return self.panel_scroll(self.panel_empty("No working directory.", cx), title); + return self.panel_scroll( + self.panel_empty( + "No working directory.", + Some("This pane has not reported one yet."), + cx, + ), + title, + ); }; // Probe on first paint for this cwd, and whenever the pane moves to a // different repository. Refreshes ride the same git-status observer the @@ -1055,11 +1094,18 @@ impl Tty7App { let mono = cx.theme().mono_font_family.clone(); let inner = match &self.right_panel.diff { - None => self.panel_empty("Loading…", cx), - Some(None) => self.panel_empty("Not a git work tree.", cx), - Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => { - self.panel_empty("No changes.", cx) - } + None => self.panel_empty("Loading…", None, cx), + Some(None) => self.panel_empty( + "Not a git repository.", + Some("cd into one and this tab lists its uncommitted changes."), + cx, + ), + Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => self + .panel_empty( + "No uncommitted changes.", + Some("The working tree is clean."), + cx, + ), Some(Some(snap)) => { let files: Vec<(String, u32, u32)> = snap .files diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 07ac80e7..7c4c8e17 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -45,13 +45,26 @@ use crate::ui::presets; /// Which section of the settings panel is currently selected in the sidebar. /// Sections are named for the *object* being configured (the appearance, the -/// terminal, the shell, the window) — never for a property class like -/// "Behavior", which reads fine but predicts nothing about what's inside. +/// terminal, the window) — never for a property class like "Behavior", which +/// reads fine but predicts nothing about what's inside. +/// +/// Two of these were rearranged because the old split didn't survive contact +/// with a user asking "which page is that on?": +/// +/// * **Shell** used to be its own page holding three settings, and nothing +/// distinguished "the Terminal page" from "the Shell page" from the outside. +/// Its rows are now Terminal's first group — the program a pane launches is a +/// property of the terminal, not a peer of it. (It also freed the word +/// "Shell", which the menu bar was simultaneously using for its File menu.) +/// * **Input** is new. Completion, history search, the Option/Meta split and +/// selection/clipboard behaviour were scattered through the bottom of the +/// Terminal page under four headers; they're the app's most distinctive +/// surface and they now have a name you can look for. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsSection { Appearance, Terminal, - Shell, + Input, Ssh, Agents, WindowTabs, @@ -60,13 +73,27 @@ pub(crate) enum SettingsSection { } impl SettingsSection { + /// Every section, in nav order. The single source of truth for "what + /// sections exist" — [`best_matching_section`] used to carry its own + /// hand-written copy of this list and had silently fallen two behind. + pub(crate) const ALL: [SettingsSection; 8] = [ + SettingsSection::Appearance, + SettingsSection::Terminal, + SettingsSection::Input, + SettingsSection::Ssh, + SettingsSection::Agents, + SettingsSection::WindowTabs, + SettingsSection::Keybindings, + SettingsSection::About, + ]; + /// A `&'static` label for `TTY7_PROFILE` aggregation, so each section's build /// cost and rebuild rate report under their own line. fn profile_label(self) -> &'static str { match self { SettingsSection::Appearance => "settings:appearance", SettingsSection::Terminal => "settings:terminal", - SettingsSection::Shell => "settings:shell", + SettingsSection::Input => "settings:input", SettingsSection::Ssh => "settings:ssh", SettingsSection::Agents => "settings:agents", SettingsSection::WindowTabs => "settings:window-tabs", @@ -93,7 +120,7 @@ struct SearchEntry { fn settings_search_entries() -> &'static [SearchEntry] { use SettingsSection::*; &[ - // Appearance + // ── Appearance ────────────────────────────────────────────────────── SearchEntry { section: Appearance, title: "Theme", @@ -101,8 +128,23 @@ fn settings_search_entries() -> &'static [SearchEntry] { }, SearchEntry { section: Appearance, - title: "Font family", - keywords: "typeface monospace typography", + title: "Sync with system", + keywords: "theme dark light auto follow os appearance mode", + }, + SearchEntry { + section: Appearance, + title: "Custom themes", + keywords: "theme duplicate edit colors folder yaml import", + }, + SearchEntry { + section: Appearance, + title: "Opacity", + keywords: "transparency translucent see through window alpha", + }, + SearchEntry { + section: Appearance, + title: "Blur", + keywords: "transparency translucent frosted vibrancy window background", }, SearchEntry { section: Appearance, @@ -114,6 +156,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Line height", keywords: "typography leading spacing", }, + SearchEntry { + section: Appearance, + title: "Font family", + keywords: "typeface monospace typography", + }, SearchEntry { section: Appearance, title: "Bold font", @@ -144,11 +191,21 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "ANSI colors", keywords: "palette 16 terminal colours theme", }, - // Terminal + // ── Terminal ──────────────────────────────────────────────────────── SearchEntry { section: Terminal, - title: "Option acts as Meta", - keywords: "alt keyboard modifier escape macos", + title: "Program", + keywords: "shell binary zsh bash fish pwsh powershell executable launch", + }, + SearchEntry { + section: Terminal, + title: "Arguments", + keywords: "shell flags login args", + }, + SearchEntry { + section: Terminal, + title: "Start in", + keywords: "cwd working directory start folder path home inherit custom", }, SearchEntry { section: Terminal, @@ -170,6 +227,16 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Hide mouse while typing", keywords: "cursor pointer autohide", }, + SearchEntry { + section: Terminal, + title: "Report mouse to apps", + keywords: "mouse reporting vim tmux click scroll shift passthrough", + }, + SearchEntry { + section: Terminal, + title: "Terminal bell", + keywords: "bell audible visual flash sound silence beep ^g", + }, SearchEntry { section: Terminal, title: "Detect URLs", @@ -178,45 +245,50 @@ fn settings_search_entries() -> &'static [SearchEntry] { SearchEntry { section: Terminal, title: "Forward SSH loopback links", - keywords: "ssh remote port tunnel localhost forward", + keywords: "ssh remote port tunnel localhost forward links", }, SearchEntry { section: Terminal, + title: "Open files with", + keywords: "links file editor command external app path line column", + }, + // ── Input ─────────────────────────────────────────────────────────── + SearchEntry { + section: Input, + title: "Tab completion", + keywords: "complete completion menu suggestions tab prompt", + }, + SearchEntry { + section: Input, + title: "History search", + keywords: "ctrl-r reverse search fuzzy history recall fzf prompt", + }, + SearchEntry { + section: Input, + title: "Option acts as Meta", + keywords: "alt keyboard modifier escape macos option meta", + }, + SearchEntry { + section: Input, title: "Smart selection", - keywords: "double click word url path select semantic", + keywords: "double click word url path select semantic bracket email", }, SearchEntry { - section: Terminal, + section: Input, title: "Copy on select", - keywords: "clipboard selection yank", + keywords: "clipboard selection yank mouse", }, SearchEntry { - section: Terminal, + section: Input, title: "Trim trailing spaces on copy", - keywords: "clipboard whitespace", + keywords: "clipboard whitespace copy", }, + // ── SSH ───────────────────────────────────────────────────────────── SearchEntry { - section: Terminal, - title: "Notify on command finish", - keywords: "notification alert bell done osc", + section: Ssh, + title: "SSH profiles", + keywords: "ssh host connection saved profile import ssh_config manage add edit", }, - // Shell - SearchEntry { - section: Shell, - title: "Program", - keywords: "shell binary zsh bash fish executable", - }, - SearchEntry { - section: Shell, - title: "Arguments", - keywords: "shell flags login args", - }, - SearchEntry { - section: Shell, - title: "Working directory", - keywords: "cwd start folder path directory", - }, - // SSH SearchEntry { section: Ssh, title: "Verify host keys", @@ -227,7 +299,12 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Warn before closing", keywords: "ssh confirm close tab pane live session security", }, - // Agents + SearchEntry { + section: Ssh, + title: "Port forwarding", + keywords: "ssh tunnel local remote dynamic socks forward rule", + }, + // ── Agents ────────────────────────────────────────────────────────── SearchEntry { section: Agents, title: "Claude Code hooks", @@ -253,17 +330,22 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Pi extension", keywords: "agent integration install pi", }, - // Window & Tabs + // ── Window & Tabs ─────────────────────────────────────────────────── SearchEntry { section: WindowTabs, title: "Startup window", - keywords: "restore session launch open", + keywords: "launch open maximized fullscreen normal", }, SearchEntry { section: WindowTabs, title: "Remember window size & position", keywords: "window size position bounds geometry launch startup remember", }, + SearchEntry { + section: WindowTabs, + title: "Restore last layout", + keywords: "restore session previous tabs splits reopen launch startup layout", + }, SearchEntry { section: WindowTabs, title: "Show tray icon", @@ -272,24 +354,43 @@ fn settings_search_entries() -> &'static [SearchEntry] { SearchEntry { section: WindowTabs, title: "New tab position", - keywords: "tabs order end after", + keywords: "tabs order end after current", }, SearchEntry { section: WindowTabs, title: "Tab bar position", - keywords: "tabs vertical sidebar left top layout", + keywords: "tabs vertical sidebar left top layout rail", }, - // Keybindings + SearchEntry { + section: WindowTabs, + title: "Sidebar grouping", + keywords: "tabs group repo repository git scratch header sidebar flat", + }, + SearchEntry { + section: WindowTabs, + title: "Notify on command finish", + keywords: "notification alert done osc desktop banner long command", + }, + SearchEntry { + section: WindowTabs, + title: "Notify threshold", + keywords: "notification alert seconds duration long command delay", + }, + // ── Keybindings / About ───────────────────────────────────────────── SearchEntry { section: Keybindings, title: "Keybindings", - keywords: "shortcut hotkey keyboard binding chord tmux preset rebind", + keywords: "shortcut hotkey keyboard binding chord tmux preset rebind prefix", }, - // About SearchEntry { section: About, title: "About", - keywords: "version license credits build", + keywords: "version license credits build update check github", + }, + SearchEntry { + section: About, + title: "How sessions work", + keywords: "session daemon detach persist background close quit stop delete workspace layout survive reboot tmux", }, ] } @@ -312,9 +413,12 @@ pub(crate) fn section_match_count(section: SettingsSection, query: &str) -> usiz /// The section a search should jump to: the one with the most matches, ties /// broken by nav order (the first section wins). `None` when nothing matches, so /// the caller leaves the current selection alone. +/// +/// Driven by [`SettingsSection::ALL`] rather than a hand-written list: the old +/// literal here omitted SSH and Agents, so searching "claude" or "known hosts" +/// annotated the nav with a match count and then refused to go there. pub(crate) fn best_matching_section(query: &str) -> Option { - use SettingsSection::*; - [Appearance, Terminal, Shell, WindowTabs, Keybindings, About] + SettingsSection::ALL .into_iter() .map(|s| (s, section_match_count(s, query))) .filter(|(_, n)| *n > 0) @@ -712,7 +816,7 @@ impl Tty7App { } }; - // The six section links stay put during search — only their `(N)` suffixes + // The section links stay put during search — only their `(N)` suffixes // change — so the nav never collapses out from under the user. let nav_body = SidebarMenu::new() .child(nav_item( @@ -720,18 +824,18 @@ impl Tty7App { SettingsSection::Appearance, Icon::new(IconName::Palette), )) - // Sliders for Terminal (it's the tuning page), the `>_` - // prompt glyph for Shell (it configures the prompt's - // program) — the two would otherwise both claim `>_`. + // The `>_` prompt glyph for Terminal, which now owns the shell + // program; the "Aa" glyph is the closest thing the icon set has to + // a keyboard for Input. .child(nav_item( "Terminal", SettingsSection::Terminal, - Icon::new(IconName::Settings2), + Icon::new(IconName::SquareTerminal), )) .child(nav_item( - "Shell", - SettingsSection::Shell, - Icon::new(IconName::SquareTerminal), + "Input", + SettingsSection::Input, + Icon::new(IconName::Settings2), )) .child(nav_item( "SSH", @@ -821,7 +925,7 @@ impl Tty7App { let content = match section { SettingsSection::Appearance => self.render_settings_appearance(cx), SettingsSection::Terminal => self.render_settings_terminal(cx), - SettingsSection::Shell => self.render_settings_shell(cx), + SettingsSection::Input => self.render_settings_input(cx), SettingsSection::Ssh => self.render_settings_ssh(cx), SettingsSection::Agents => self.render_settings_agents(cx), SettingsSection::WindowTabs => self.render_settings_window_tabs(cx), @@ -1359,7 +1463,10 @@ impl Tty7App { .into_any_element(); v_flex() - .child(self.section_header("Window", cx)) + // Not "Window": Settings → Window & Tabs owns that word for the + // window's lifecycle, and two groups called Window on two pages is + // how a user ends up on the wrong one. + .child(self.section_header("Transparency", cx)) .child(self.settings_row( "Opacity", "How opaque the window background is, for every theme. Below \ @@ -2713,12 +2820,18 @@ impl Tty7App { section.into_any_element() } - /// Shell section: the program tty7 launches in each new terminal, plus its - /// launch arguments. Both apply to *newly spawned* panes/tabs — existing - /// shells keep running until closed. An empty program falls back to the - /// platform default (the login shell on Unix; PowerShell 7 when installed, - /// else Windows PowerShell, on Windows). - fn render_settings_shell(&self, cx: &mut Context) -> AnyElement { + /// The Shell group at the top of the Terminal section: the program tty7 + /// launches in each new pane, its launch arguments, and where a fresh shell + /// starts. All apply to *newly spawned* panes/tabs — existing shells keep + /// running until closed. An empty program falls back to the platform default + /// (the login shell on Unix; PowerShell 7 when installed, else Windows + /// PowerShell, on Windows). + /// + /// This used to be a section of its own, which left a three-row page and no + /// way for a user to guess whether a given knob was filed under "Terminal" + /// or under "Shell". The program a pane runs is a property of the terminal, + /// so it opens the Terminal page instead. + fn render_shell_group(&self, cx: &mut Context) -> AnyElement { let muted_fg = cx.theme().muted_foreground; let (program_input, args_input, wd_path_input) = match self.active_settings() { Some(s) => ( @@ -2797,8 +2910,6 @@ impl Tty7App { args_control, cx, )) - .child(self.section_rule(cx)) - .child(self.section_header("Working directory", cx)) .child(self.settings_row( "Start in", "What a fresh shell starts in: tty7's launch directory, your home folder, or a fixed path.", @@ -2823,11 +2934,16 @@ impl Tty7App { .into_any_element() } - /// Terminal section: how the terminal surface itself behaves — scrolling, - /// mouse, links, clipboard, notifications. Plain switches and segmented - /// controls driven straight off the `Config` global (each control's handler - /// mutates + saves it). Small groups on purpose: each header names exactly - /// what it contains, so it doubles as the landmark you scan for. + /// Terminal section: what a pane runs and how the terminal surface itself + /// behaves — the shell, scrolling, the mouse, the bell, links. Plain + /// switches and segmented controls driven straight off the `Config` global + /// (each control's handler mutates + saves it). Small groups on purpose: + /// each header names exactly what it contains, so it doubles as the landmark + /// you scan for. + /// + /// Typing, selection and the clipboard used to live down here too, under + /// four more headers; they moved to their own Input section, which is both + /// findable by name and short enough to read in one screen. fn render_settings_terminal(&self, cx: &mut Context) -> AnyElement { let foreground = cx.theme().foreground; let cfg = cx.global::(); @@ -2835,23 +2951,9 @@ impl Tty7App { let ssh_loopback_forward = cfg.ssh_loopback_forward; let mouse_hide = cfg.mouse_hide_while_typing; let focus_follows = cfg.focus_follows_mouse; - let option_as_alt = cfg.macos_option_as_alt; let scroll_mult = cfg.mouse_scroll_multiplier; - let clip_trim = cfg.clipboard_trim_trailing_spaces; - let copy_on_select = cfg.copy_on_select; let mouse_reporting = cfg.mouse_reporting; - let smart_select = cfg.smart_select; - let tab_completion = cfg.tab_completion; - let history_search = cfg.history_search; let bell = cfg.bell; - // Map the persisted threshold onto its preset radio index (nearest slot - // for any off-preset value a hand-edit might leave). - let threshold_idx = match cfg.notify_threshold_secs { - n if n <= 5 => 0, - n if n <= 10 => 1, - n if n <= 30 => 2, - _ => 3, - }; // Map the persisted scrollback depth onto its preset radio index (default // to 10k's slot for any off-preset value a hand-edit might leave). let scrollback_idx = match cfg.scrollback_limit { @@ -2859,11 +2961,6 @@ impl Tty7App { n if n <= 10_000 => 1, _ => 2, }; - let notify_idx = match cfg.notify_on_command_finish { - NotifyMode::Never => 0, - NotifyMode::Unfocused => 1, - NotifyMode::Always => 2, - }; let scroll_slider = match self.active_settings() { Some(s) => s.scroll_slider.clone(), None => return div().into_any_element(), @@ -2899,20 +2996,6 @@ impl Tty7App { this.set_scrollback_limit(lines, cx); }, ); - let notify_radio = self.segmented( - "term-notify", - &["Never", "When unfocused", "Always"], - notify_idx, - cx, - |this, ix, _w, cx| { - let mode = match ix { - 0 => NotifyMode::Never, - 1 => NotifyMode::Unfocused, - _ => NotifyMode::Always, - }; - this.set_notify_mode(mode, cx); - }, - ); let focus_switch = Switch::new("term-focus-follows") .checked(focus_follows) @@ -2924,30 +3007,10 @@ impl Tty7App { cx.listener(|this, on: &bool, _w, cx| this.set_mouse_hide_while_typing(*on, cx)), ) .into_any_element(); - let trim_switch = Switch::new("term-clip-trim") - .checked(clip_trim) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx))) - .into_any_element(); - let copy_on_select_switch = Switch::new("term-copy-on-select") - .checked(copy_on_select) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx))) - .into_any_element(); let mouse_report_switch = Switch::new("term-mouse-report") .checked(mouse_reporting) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx))) .into_any_element(); - let smart_select_switch = Switch::new("term-smart-select") - .checked(smart_select) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx))) - .into_any_element(); - let tab_completion_switch = Switch::new("term-tab-completion") - .checked(tab_completion) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx))) - .into_any_element(); - let history_search_switch = Switch::new("term-history-search") - .checked(history_search) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx))) - .into_any_element(); let bell_idx = match bell { BellMode::None => 0, BellMode::Visual => 1, @@ -2967,38 +3030,6 @@ impl Tty7App { this.set_bell_mode(mode, cx); }, ); - let threshold_radio = self.segmented( - "term-notify-threshold", - &["5s", "10s", "30s", "1m"], - threshold_idx, - cx, - |this, ix, _w, cx| { - let secs = match ix { - 0 => 5, - 1 => 10, - 2 => 30, - _ => 60, - }; - this.set_notify_threshold(secs, cx); - }, - ); - // macOS only: the Option/special-character split this toggle resolves - // doesn't exist on other platforms, where Alt always carries Meta. - let option_alt_row = cfg!(target_os = "macos").then(|| { - let switch = Switch::new("term-option-as-alt") - .checked(option_as_alt) - .on_click( - cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)), - ) - .into_any_element(); - self.settings_row( - "Option (⌥) acts as Meta", - "⌥+key sends the escape chord shells expect (⌥B = back one word) \ - instead of typing a special character (∫).", - switch, - cx, - ) - }); // Slider + a live readout of the current multiplier beside it. let scroll_control = h_flex() .items_center() @@ -3015,6 +3046,8 @@ impl Tty7App { .into_any_element(); v_flex() + .child(self.render_shell_group(cx)) + .child(self.section_rule(cx)) .child(self.section_header("Scrolling", cx)) .child(self.settings_row( "Scrollback", @@ -3048,30 +3081,14 @@ impl Tty7App { mouse_report_switch, cx, )) - .child(self.settings_row( - "Smart selection", - "Double-click selects the whole URL, file path, email, or bracket pair under the cursor.", - smart_select_switch, - cx, - )) .child(self.section_rule(cx)) - .child(self.section_header("Keyboard", cx)) + .child(self.section_header("Bell", cx)) .child(self.settings_row( - "Tab completion", - "Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \ - shell's own completion instead.", - tab_completion_switch, + "Terminal bell", + "How a bell (^G) is signalled: silenced, a brief flash, or the system sound.", + bell_control, cx, )) - .child(self.settings_row( - "History search", - "⌃R at the prompt opens tty7's fuzzy history menu. When off, ⌃R goes to the \ - shell instead — its own reverse-i-search, or whatever you've bound there \ - (fzf, percol).", - history_search_switch, - cx, - )) - .when_some(option_alt_row, |v, row| v.child(row)) .child(self.section_rule(cx)) .child(self.section_header("Links", cx)) .child(self.settings_row( @@ -3094,8 +3111,94 @@ impl Tty7App { link_file_command_control, cx, )) + .into_any_element() + } + + /// Input section: everything about putting text *in* and taking text *out* — + /// the completion and history menus at the prompt, the Option/Meta split, + /// and how selection reaches the clipboard. + /// + /// A section of its own because these are the settings that distinguish tty7 + /// from a plain terminal, and they were previously the last four groups of a + /// seven-group Terminal page — findable only by scrolling past everything + /// else, and not findable by search at all (completion and history search + /// had no index entries). + fn render_settings_input(&self, cx: &mut Context) -> AnyElement { + let cfg = cx.global::(); + let option_as_alt = cfg.macos_option_as_alt; + let tab_completion = cfg.tab_completion; + let history_search = cfg.history_search; + let smart_select = cfg.smart_select; + let copy_on_select = cfg.copy_on_select; + let clip_trim = cfg.clipboard_trim_trailing_spaces; + + let tab_completion_switch = Switch::new("term-tab-completion") + .checked(tab_completion) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx))) + .into_any_element(); + let history_search_switch = Switch::new("term-history-search") + .checked(history_search) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx))) + .into_any_element(); + let smart_select_switch = Switch::new("term-smart-select") + .checked(smart_select) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx))) + .into_any_element(); + let copy_on_select_switch = Switch::new("term-copy-on-select") + .checked(copy_on_select) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx))) + .into_any_element(); + let trim_switch = Switch::new("term-clip-trim") + .checked(clip_trim) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx))) + .into_any_element(); + // macOS only: the Option/special-character split this toggle resolves + // doesn't exist on other platforms, where Alt always carries Meta. + let option_alt_row = cfg!(target_os = "macos").then(|| { + let switch = Switch::new("term-option-as-alt") + .checked(option_as_alt) + .on_click( + cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)), + ) + .into_any_element(); + self.settings_row( + "Option (⌥) acts as Meta", + "⌥+key sends the escape chord shells expect (⌥B = back one word) \ + instead of typing a special character (∫).", + switch, + cx, + ) + }); + + v_flex() + .child(self.section_intro( + "Prompt", + "tty7's own menus at the shell prompt. Turn one off to hand the key back to the shell.", + cx, + )) + .child(self.settings_row( + "Tab completion", + "Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \ + shell's own completion instead.", + tab_completion_switch, + cx, + )) + .child(self.settings_row( + "History search", + "⌃R at the prompt opens tty7's fuzzy history menu. When off, ⌃R goes to the \ + shell instead — its own reverse-i-search, or whatever you've bound there \ + (fzf, percol).", + history_search_switch, + cx, + )) .child(self.section_rule(cx)) - .child(self.section_header("Clipboard", cx)) + .child(self.section_header("Selection & clipboard", cx)) + .child(self.settings_row( + "Smart selection", + "Double-click selects the whole URL, file path, email, or bracket pair under the cursor.", + smart_select_switch, + cx, + )) .child(self.settings_row( "Copy on select", "Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed.", @@ -3108,28 +3211,11 @@ impl Tty7App { trim_switch, cx, )) - .child(self.section_rule(cx)) - .child(self.section_header("Bell", cx)) - .child(self.settings_row( - "Terminal bell", - "How a bell (^G) is signalled: silenced, a brief flash, or the system sound.", - bell_control, - cx, - )) - .child(self.section_rule(cx)) - .child(self.section_header("Notifications", cx)) - .child(self.settings_row( - "Notify on command finish", - "Desktop alert after a long foreground command completes.", - notify_radio, - cx, - )) - .child(self.settings_row( - "Notify threshold", - "How long a command must run to qualify as \"long\".", - threshold_radio, - cx, - )) + .when_some(option_alt_row, |v, row| { + v.child(self.section_rule(cx)) + .child(self.section_header("Keyboard", cx)) + .child(row) + }) .into_any_element() } @@ -3253,6 +3339,53 @@ impl Tty7App { crate::core::config::SidebarGrouping::Repo => 0, crate::core::config::SidebarGrouping::None => 1, }; + // Notifications are app-level, not terminal-level: the tray menu already + // exposed the same `NotifyMode` at the top of its own menu while the + // setting itself sat at the bottom of the Terminal page. + let notify_idx = match cfg.notify_on_command_finish { + NotifyMode::Never => 0, + NotifyMode::Unfocused => 1, + NotifyMode::Always => 2, + }; + // Map the persisted threshold onto its preset radio index (nearest slot + // for any off-preset value a hand-edit might leave). + let threshold_idx = match cfg.notify_threshold_secs { + n if n <= 5 => 0, + n if n <= 10 => 1, + n if n <= 30 => 2, + _ => 3, + }; + let notify_radio = self.segmented( + "wt-notify", + // Same order and casing as the tray's Notifications submenu, which + // writes this very setting — the two used to disagree on both. + &["Never", "When Unfocused", "Always"], + notify_idx, + cx, + |this, ix, _w, cx| { + let mode = match ix { + 0 => NotifyMode::Never, + 1 => NotifyMode::Unfocused, + _ => NotifyMode::Always, + }; + this.set_notify_mode(mode, cx); + }, + ); + let threshold_radio = self.segmented( + "wt-notify-threshold", + &["5s", "10s", "30s", "1m"], + threshold_idx, + cx, + |this, ix, _w, cx| { + let secs = match ix { + 0 => 5, + 1 => 10, + 2 => 30, + _ => 60, + }; + this.set_notify_threshold(secs, cx); + }, + ); let restore_switch = Switch::new("wt-restore-session") .checked(restore_session) @@ -3337,8 +3470,12 @@ impl Tty7App { remember_window_switch, cx, )) + // "Session" already means "a shell running in the background" all + // over this app; using it here for "the saved arrangement of tabs" + // made the one word mean two things on the same page. The thing + // being restored is the layout. .child(self.settings_row( - "Restore previous session", + "Restore last layout", "Reopen the last window's tabs, splits, and directories on launch. Off starts with a single fresh terminal.", restore_switch, cx, @@ -3371,6 +3508,20 @@ impl Tty7App { sidebar_grouping_radio, cx, )) + .child(self.section_rule(cx)) + .child(self.section_header("Notifications", cx)) + .child(self.settings_row( + "Notify on command finish", + "Desktop alert after a long foreground command completes.", + notify_radio, + cx, + )) + .child(self.settings_row( + "Notify threshold", + "How long a command must run to qualify as \"long\".", + threshold_radio, + cx, + )) .into_any_element() } @@ -4000,6 +4151,75 @@ impl Tty7App { .into_any_element() } + /// "How sessions work": the four-line explanation of the app's own model — + /// what closing a window does, what Stop does, what Delete does, what Quit + /// does. + /// + /// This is tty7's central idea and the thing that most surprises a user + /// arriving from another terminal, and until now it was explained *only* + /// inside the confirmation dialogs — that is, at the moment the user is + /// already committing to an action, and never before. Stating it once, in + /// the one page that describes what the app is, means the dialogs confirm a + /// model the user has already met instead of teaching it under pressure. + /// + /// Deliberately a plain definition list rather than settings rows: nothing + /// here is configurable, and giving it switch-shaped chrome would suggest + /// otherwise. + fn render_session_model(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); + + let entry = |term: &'static str, meaning: &'static str| { + v_flex() + .gap_0p5() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(term), + ) + .child(div().text_xs().text_color(muted_fg).child(meaning)) + }; + + v_flex() + .mt_6() + .gap_2() + .child(self.section_rule(cx)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("How sessions work"), + ) + .child(div().text_xs().text_color(muted_fg).child( + "Your shells run in a background daemon, not in this window. That is what lets them outlive a quit or a reboot — and it means \"close\" and \"end\" are different things here.", + )) + .child( + v_flex() + .mt_2() + .gap_3() + .child(entry( + "Closing a window (⌘W on the last tab)", + "Detaches the workspace. Every shell keeps running; the workspace waits on the home page and in the title-bar menu.", + )) + .child(entry( + "Quitting tty7 (⌘Q)", + "Same deal, for every window. Nothing running is interrupted.", + )) + .child(entry( + "Stop Workspace", + "Ends that workspace's shells but keeps its layout, so you can start it again with fresh ones.", + )) + .child(entry( + "Delete Workspace", + "Ends the shells and forgets the layout. The only step here you can't undo.", + )), + ) + .into_any_element() + } + /// About section: app identity and stack. fn render_settings_about(&self, cx: &mut Context) -> AnyElement { let theme = cx.theme(); @@ -4068,6 +4288,7 @@ impl Tty7App { .child("Pure Rust · GPU rendering on Zed's gpui · VT core from Alacritty"), ), ) + .child(self.render_session_model(cx)) // Updates: the startup check drops a newer version here if it found // one. We never self-update — "Download" just opens the Releases // page; the toggle turns the check off (see `core::update`). @@ -4165,6 +4386,94 @@ impl Tty7App { mod tests { use super::*; + /// Every section must carry at least one index entry, or the search box can + /// annotate the nav with a count it can never jump to — and, worse, a whole + /// page of settings becomes unreachable by search. + #[test] + fn every_section_has_search_entries() { + for section in SettingsSection::ALL { + let n = settings_search_entries() + .iter() + .filter(|e| e.section == section) + .count(); + assert!( + n > 0, + "section {:?} has no search entries", + section.profile_label() + ); + } + } + + /// `best_matching_section` must be able to reach every section — it used to + /// be driven by a hand-written list that had fallen behind by two. + #[test] + fn best_matching_section_can_reach_every_section() { + for section in SettingsSection::ALL { + let entry = settings_search_entries() + .iter() + .find(|e| e.section == section) + .expect("checked by every_section_has_search_entries"); + let query = entry.title.to_lowercase(); + let landed = best_matching_section(&query); + assert!( + landed.is_some(), + "query {query:?} matched nothing at all (section {:?})", + section.profile_label() + ); + } + } + + /// Settings that had no index entry at all before this pass — searching for + /// any of them returned an empty result on a page that plainly had the knob. + #[test] + fn previously_unsearchable_settings_are_findable() { + use SettingsSection::*; + let cases: &[(&str, SettingsSection)] = &[ + ("opacity", Appearance), + ("blur", Appearance), + ("completion", Input), + ("ctrl-r", Input), + ("grouping", WindowTabs), + ("threshold", WindowTabs), + ("report mouse", Terminal), + ("open files with", Terminal), + ("bell", Terminal), + ("known_hosts", Ssh), + ("claude", Agents), + ]; + for (query, expected) in cases { + assert_eq!( + best_matching_section(query).map(|s| s.profile_label()), + Some(expected.profile_label()), + "query {query:?} should land on {:?}", + expected.profile_label() + ); + } + } + + /// The index names rows, so a title that no longer matches the rendered row + /// sends the user to the right page and then leaves them hunting. This + /// pins the ones that had drifted (the index said "Working directory"; the + /// row says "Start in"). + #[test] + fn index_titles_match_rendered_row_labels() { + for title in [ + "Start in", + "Restore last layout", + "Terminal bell", + "Report mouse to apps", + "Open files with", + "Sidebar grouping", + "Tab completion", + "History search", + ] { + assert!( + settings_search_entries().iter().any(|e| e.title == title), + "no index entry titled {title:?}" + ); + } + } + #[test] fn humanize_action_splits_on_capitals() { assert_eq!(humanize_action("NewTab"), "New Tab"); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 0521687b..8f3ffe24 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -569,9 +569,12 @@ impl Tty7App { .menu("Stop Workspace…", Box::new(StopWorkspace)) // The app-level entries the "⋯" used to carry. // Folded in here so the corner has one menu rather - // than two adjacent ones. + // than two adjacent ones. Deliberately just these + // two: Help/About live in the menu bar, and + // duplicating them here only makes this menu + // longer without making anything reachable. .separator() - .menu("Command Palette", Box::new(TogglePalette)) + .menu("Command Palette…", Box::new(TogglePalette)) .menu("Settings…", Box::new(OpenSettings)) }, ), diff --git a/src/ui/theme.rs b/src/ui/theme.rs index f15e3a4f..bb52c0cc 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -4,13 +4,17 @@ //! `ui::presets`) and publishes the terminal-facing palette. use gpui::{ - App, Background, Hsla, Menu, MenuItem, Pixels, Point, Window, WindowBackgroundAppearance, - linear_color_stop, linear_gradient, point, px, rgb, + App, Background, Hsla, Menu, MenuItem, OsAction, Pixels, Point, SystemMenuType, Window, + WindowBackgroundAppearance, linear_color_stop, linear_gradient, point, px, rgb, }; use gpui_component::{Theme, ThemeMode}; use crate::core::actions::*; use crate::core::config::Config; +use crate::terminal::view::{ + ClearScrollback, CopyText, CutText, FindInTerminal, FindNext, FindPrevious, PasteText, + RedoEdit, SelectAll, UndoEdit, +}; use crate::ui::presets; use crate::ui::presets::Fill; @@ -26,47 +30,118 @@ pub(crate) fn traffic_light_position() -> Point { } /// (Re)build the macOS menu bar. +/// +/// Menu order and contents follow the macOS HIG's standard set — App, File, +/// Edit, View, Window, Help — because that is where a Mac user's hand goes +/// before they read a single label. The app used to ship four menus in the +/// order App / Shell / Window / View with no Edit at all, which put Copy and +/// Paste nowhere but a right-click and made the whole bar read as improvised. +/// +/// Two deliberate departures from a stock bar: +/// +/// * There is no "Shell" menu. Its contents (new/close/split/rename) are File's +/// job everywhere else, and the name collided with Settings → Shell, which +/// configures something entirely different — the program a pane launches. +/// * "Restart Daemon…" lives at the bottom of Help, not near Settings. It is a +/// break-glass repair, it ends every running shell, and it has no business +/// one slot away from ⌘,. pub(crate) fn set_menus(cx: &mut App) { cx.set_menus([ Menu::new("tty7").items([ + MenuItem::action("About tty7", About), + MenuItem::action("Check for Updates…", CheckForUpdates), + MenuItem::separator(), MenuItem::action("Settings…", OpenSettings), MenuItem::separator(), + MenuItem::os_submenu("Services", SystemMenuType::Services), + MenuItem::separator(), + MenuItem::action("Hide tty7", HideApp), + MenuItem::action("Hide Others", HideOthers), + MenuItem::action("Show All", ShowAll), + MenuItem::separator(), + MenuItem::action("Quit tty7", Quit), + ]), + Menu::new("File").items([ + MenuItem::action("New Tab", NewTab), + MenuItem::action("New Workspace", NewWorkspace), + MenuItem::action("New Worktree Tab", NewWorktreeTab), + MenuItem::separator(), + MenuItem::action("Split Right", SplitRight), + MenuItem::action("Split Down", SplitDown), + MenuItem::separator(), + MenuItem::action("Rename Tab…", RenameTab), + MenuItem::action("Copy Working Directory", CopyWorkingDirectory), + MenuItem::separator(), + MenuItem::action("Close Pane / Tab", CloseActiveTab), + MenuItem::action("Close Other Tabs", CloseOtherTabs), + MenuItem::action("Close Tabs to the Right", CloseTabsToTheRight), + MenuItem::action("Reopen Closed Tab", ReopenClosedTab), + MenuItem::separator(), + MenuItem::action("Rename Workspace…", RenameWorkspace), + // Separated: the only item above the rule that touches running + // sessions is none of them — closing a window or a tab leaves the + // shells alive in the daemon. Stop ends them but keeps the layout. + MenuItem::action("Stop Workspace…", StopWorkspace), + // Alone at the very bottom, behind its own rule: the one + // irreversible item in the entire menu bar. It used to sit directly + // under Stop, distinguishable only by the verb. + MenuItem::separator(), + MenuItem::action("Delete Workspace…", DeleteWorkspace), + ]), + // `os_action` routes these through the standard Cut/Copy/Paste/Select All + // selectors, so they behave like every other Mac app's Edit menu (and stay + // enabled via the app delegate) while still dispatching our own actions. + // They carry no key-equivalent glyph: the chords are handled inline in + // `terminal::view::handle_cmd_shortcut` rather than as registered + // bindings, because ⌃C has to fall through to SIGINT when nothing is + // selected — a registered binding would swallow it. + Menu::new("Edit").items([ + MenuItem::os_action("Undo", UndoEdit, OsAction::Undo), + MenuItem::os_action("Redo", RedoEdit, OsAction::Redo), + MenuItem::separator(), + MenuItem::os_action("Cut", CutText, OsAction::Cut), + MenuItem::os_action("Copy", CopyText, OsAction::Copy), + MenuItem::os_action("Paste", PasteText, OsAction::Paste), + MenuItem::os_action("Select All", SelectAll, OsAction::SelectAll), + MenuItem::separator(), + MenuItem::action("Find…", FindInTerminal), + MenuItem::action("Find Next", FindNext), + MenuItem::action("Find Previous", FindPrevious), + ]), + Menu::new("View").items([ + MenuItem::action("Command Palette…", TogglePalette), + MenuItem::separator(), + MenuItem::action("Increase Font Size", IncreaseFontSize), + MenuItem::action("Decrease Font Size", DecreaseFontSize), + MenuItem::action("Reset Font Size", ResetFontSize), + MenuItem::separator(), + // The three docks and the tab rail's placement — the most literally + // "view" things in the app, and until now reachable only by chord. + MenuItem::action("Left Sidebar", ToggleLeftPanel), + MenuItem::action("Right Panel", ToggleRightPanel), + MenuItem::action("Code Panel", ToggleCodePanel), + MenuItem::action("Tab Bar Position", ToggleTabSidebar), + MenuItem::separator(), + MenuItem::action("Focus Next Pane", FocusNextPane), + MenuItem::action("Focus Previous Pane", FocusPrevPane), + MenuItem::action("Zoom Pane", ToggleMaximizePane), + MenuItem::separator(), + MenuItem::action("Clear Scrollback", ClearScrollback), + MenuItem::separator(), + MenuItem::action("Enter Full Screen", ToggleFullscreen), + ]), + Menu::new("Window").items(window_menu_items(cx)), + Menu::new("Help").items([ + MenuItem::action("tty7 Documentation", OpenDocumentation), + MenuItem::action("Keyboard Shortcuts", ShowKeyboardShortcuts), + MenuItem::separator(), + MenuItem::action("Join the Discord", OpenDiscord), + MenuItem::action("Report an Issue…", ReportIssue), + MenuItem::separator(), // Force a fresh background daemon (so a newly granted macOS permission // such as Full Disk Access takes effect). The trailing "…" signals the // confirmation prompt; it ends every running session. MenuItem::action("Restart Daemon…", RestartDaemon), - MenuItem::separator(), - MenuItem::action("Quit tty7", Quit), - ]), - Menu::new("Shell").items([ - MenuItem::action("New Tab", NewTab), - MenuItem::action("New Workspace", NewWorkspace), - MenuItem::action("Split Right", SplitRight), - MenuItem::action("Split Down", SplitDown), - MenuItem::separator(), - MenuItem::action("Focus Next Pane", FocusNextPane), - MenuItem::action("Focus Previous Pane", FocusPrevPane), - MenuItem::action("Toggle Maximize Pane", ToggleMaximizePane), - MenuItem::separator(), - MenuItem::action("Reopen Closed Tab", ReopenClosedTab), - MenuItem::separator(), - MenuItem::action("Close Pane / Tab", CloseActiveTab), - // Last, and separated: the only two items here that touch running - // sessions. Everything above them — including closing the window — - // leaves the shells alive in the daemon, so these sit apart rather - // than a mis-click away from "Close Pane / Tab". Stop keeps the - // layout; Delete is the only thing that discards it. - MenuItem::separator(), - MenuItem::action("Stop Workspace…", StopWorkspace), - MenuItem::action("Delete Workspace…", DeleteWorkspace), - ]), - Menu::new("Window").items(window_menu_items(cx)), - Menu::new("View").items([ - MenuItem::action("Increase Font Size", IncreaseFontSize), - MenuItem::action("Decrease Font Size", DecreaseFontSize), - MenuItem::action("Reset Font Size", ResetFontSize), - MenuItem::separator(), - MenuItem::action("Toggle Full Screen", ToggleFullscreen), ]), ]); } @@ -94,7 +169,15 @@ fn window_menu_items(cx: &App) -> Vec { // dispatches identically wherever it was clicked. let slot_action = crate::ui::tab_strip::select_workspace_action; - let mut items = Vec::new(); + // Minimize / Zoom first: every Mac app's Window menu opens with them, and a + // menu that jumps straight into a bespoke list reads as if the standard ones + // were forgotten. The workspace roster follows behind a rule. + let mut items = vec![ + MenuItem::action("Minimize", MinimizeWindow), + MenuItem::action("Zoom", ZoomWindow), + MenuItem::separator(), + ]; + let workspace_start = items.len(); let mut separated = false; for (i, (id, open)) in order.iter().enumerate() { let Some(workspace) = store.get(*id) else { @@ -105,7 +188,10 @@ fn window_menu_items(cx: &App) -> Vec { // away. Only drawn once, and never as a leading rule. if !open && !separated { separated = true; - if !items.is_empty() { + // Compared against the roster's own start, not the whole menu: with + // Minimize/Zoom above, `items` is never empty and the old check + // would have drawn a second rule directly under the first. + if items.len() > workspace_start { items.push(MenuItem::Separator); } } @@ -128,9 +214,9 @@ fn window_menu_items(cx: &App) -> Vec { disabled: false, }); } - if items.is_empty() { - // Never hand back an empty menu — an unclickable "Window" title reads - // as broken. The one workspace that must exist is the current one. + if items.len() == workspace_start { + // Never leave the roster empty — a Window menu that lists no windows + // reads as broken. The one workspace that must exist is the current one. items.push(MenuItem::action("New Workspace", NewWorkspace)); } items diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index 2de4ae08..69f82321 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -191,10 +191,13 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec { }; items.push(SpecItem::Submenu { label: "Notifications".into(), + // Weakest to strongest, matching Settings → Window & Tabs → Notify on + // command finish, which writes the same setting. The two used to run in + // opposite directions with different capitalisation. items: vec![ - notify("notify:always", "Always", NotifyMode::Always), - notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused), notify("notify:never", "Never", NotifyMode::Never), + notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused), + notify("notify:always", "Always", NotifyMode::Always), ], }); items.push(item("settings", "Settings…".into())); @@ -203,7 +206,7 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec { items.push(item("quit", "Quit tty7".into())); // Plain quit leaves the daemon (and every session) running; this one // stops the daemon too. "Daemon" is already in the product vocabulary — - // the app menu ships "Restart Daemon…" — and the confirm prompt spells + // the Help menu ships "Restart Daemon…" — and the confirm prompt spells // out the consequences. items.push(item("quit-stop", "Quit and Stop Daemon…".into())); items diff --git a/src/ui/windows.rs b/src/ui/windows.rs index dc857294..0a1365e2 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -356,9 +356,12 @@ fn confirm_destructive( (1, _) => "1 running session will be ended.".to_string(), (n, _) => format!("{n} running sessions will be ended."), }; + // Title Case, like every other prompt title in the app — this one used to + // lowercase "workspace" while its siblings read "Close Window?" / + // "Quit and Stop Daemon?". let answer = window.prompt( gpui::PromptLevel::Warning, - &format!("{verb} workspace \u{201c}{name}\u{201d}?"), + &format!("{verb} Workspace \u{201c}{name}\u{201d}?"), Some(&detail), &["Cancel", verb], cx, From e6de537124d6779487e3e911b0459d10c238d09b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:08:50 +0800 Subject: [PATCH 2/2] fix(palette): label the sidebar and right-panel toggles from this window's state The stateful palette titles (Hide Left Sidebar / Show Right Panel) read `cfg.sidebar_collapsed` and `cfg.right_panel_visible`, but both of those are per-window state living on `Tty7App` -- the config copies only record whichever window toggled them last. With two windows in different states the palette in one of them named the other's rail: the row read "Show Left Sidebar" while the rail was out, and clicking it hid it. Pass the calling window's own state in as a `ChromeState` instead. Deliberately not `left_panel_open()`: that also folds in `!tabs.is_empty()`, and on the home page the command still flips the collapse flag, so the title has to describe the flag rather than what is currently on screen. The tab bar's side stays on `Config` -- that one really is app-wide. --- src/ui/app.rs | 14 ++++++++++++-- src/ui/palette.rs | 29 +++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index abb72540..6f52f669 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -28,7 +28,9 @@ use crate::core::ssh_config; use crate::core::window_state::WindowState; use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value}; use crate::terminal::view::{ChildExited, TerminalView}; -use crate::ui::palette::{Command, CommandGroup, CommandKind, PaletteEvent, PaletteView}; +use crate::ui::palette::{ + ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView, +}; use crate::ui::pane::{CloseOutcome, Dir, Pane}; use crate::ui::presets::Fill; use crate::ui::settings::{ @@ -3495,7 +3497,15 @@ impl Tty7App { /// Build the full command catalog: the static commands plus one /// "Switch to Tab: …" entry per open tab (label matches the tab strip). fn palette_commands(&self, cx: &App) -> Vec { - let mut commands = Command::base_commands(cx); + // This window's own chrome state, not the config's copy of it — see + // `ChromeState`. + let mut commands = Command::base_commands( + cx, + ChromeState { + rail_collapsed: self.sidebar_collapsed, + right_panel_visible: self.right_panel_visible, + }, + ); // Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row // connects (natively) on Enter and edits on ⌘⏎ / →. diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 2d4428e7..d2293b5b 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -413,6 +413,24 @@ impl CommandGroup { } } +/// The chrome state the stateful titles ("Hide Left Sidebar", "Show Right +/// Panel") read, passed in by the window opening the palette. +/// +/// Deliberately *not* read off `Config`: both of these are per-window state +/// living on `Tty7App`, and their config copies only record whichever window +/// toggled them last. Reading the config would label the row by another +/// window's rail — and clicking it would then do the opposite of what it said. +#[derive(Clone, Copy)] +pub struct ChromeState { + /// This window's rail collapse flag (`Tty7App::sidebar_collapsed`), not the + /// config's. Note this is the *toggle's* state, not whether the rail is on + /// screen: on the home page there are no tabs to list, but the command + /// still flips this flag, so the title has to describe that. + pub rail_collapsed: bool, + /// This window's `Tty7App::right_panel_visible`. + pub right_panel_visible: bool, +} + /// A single palette entry: a label plus the action it triggers. #[derive(Clone)] pub struct Command { @@ -456,17 +474,20 @@ impl Command { /// Titles follow the grammar documented at the top of this module. Several /// are *stateful*: a command that flips something reads as the outcome it /// will produce right now ("Hide Left Sidebar" when the rail is out), which - /// is why this needs `cx`. + /// is why this needs `cx` and the calling window's [`ChromeState`]. /// /// The held-key font zoom (⌘+/⌘−) is deliberately absent — stepping it needs /// a re-open per press, so it makes a poor palette citizen; only the /// one-shot Reset is worth a slot. - pub fn base_commands(cx: &App) -> Vec { + pub fn base_commands(cx: &App, chrome: ChromeState) -> Vec { use CommandKind::*; let cfg = cx.global::(); + // The tab bar's side is a genuine app-wide setting, so it comes off the + // config; the rail's collapse flag and the right panel's visibility do + // not (see `ChromeState`). let tab_bar_left = cfg.tab_bar_position == TabBarPosition::Left; - let sidebar_hidden = cfg.sidebar_collapsed || !tab_bar_left; - let right_panel_open = cfg.right_panel_visible; + let sidebar_hidden = chrome.rail_collapsed || !tab_bar_left; + let right_panel_open = chrome.right_panel_visible; let tabs = [ Command::new("New Tab", NewTab),