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 78e9a45e..d136c30e 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 1ac31aec..5be10e5f 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, 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::{ @@ -123,6 +125,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 +671,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 +1397,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 +3287,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 +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(); + // 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 ⌘⏎ / →. @@ -3500,7 +3537,8 @@ impl Tty7App { format!("SSH: {title}"), CommandKind::ConnectSavedProfile(p.id), ) - .with_subtitle(subtitle), + .with_subtitle(subtitle) + .in_group(CommandGroup::Ssh), ); } @@ -3518,10 +3556,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 +3609,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 +3666,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 +5478,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..d2293b5b 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,12 +366,71 @@ 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", + } + } +} + +/// 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 { @@ -203,6 +439,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 +448,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 +460,43 @@ 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` 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, chrome: ChromeState) -> Vec { use CommandKind::*; - vec![ + 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 = chrome.rail_collapsed || !tab_bar_left; + let right_panel_open = chrome.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 +511,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 +695,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 +816,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 +917,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 +971,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 +1077,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 +1117,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 +1205,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 +1319,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 +1444,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 671fea96..c5e20dd6 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -581,9 +581,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,