Merge pull request #175 from l0ng-ai/feat/ux-overhaul

feat(ux): rebuild the menu bar, command palette, and Settings IA
This commit is contained in:
l0ng-ai
2026-07-26 10:12:52 +08:00
committed by GitHub
13 changed files with 1930 additions and 488 deletions
+36
View File
@@ -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).
+10
View File
@@ -303,6 +303,15 @@ pub struct Config {
#[serde(default)]
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
/// 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<String, ProfileUsage>,
// ── 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,
}
+171 -82
View File
@@ -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<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) -> 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<Self>) {
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")),
+179 -30
View File
@@ -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<Self>) {
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<Self>) {
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<Command> {
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<Entity<TerminalView>> {
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<Self>) {
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<Self>) {
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
+3 -1
View File
@@ -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
+74
View File
@@ -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<KeyBinding> {
"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<KeyBinding> {
"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),
+750 -118
View File
File diff suppressed because it is too large Load Diff
+59 -13
View File
@@ -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<Self>) -> 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<Self>) -> 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
+508 -199
View File
@@ -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<SettingsSection> {
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<Self>) -> 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<Self>) -> 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<Self>) -> AnyElement {
let foreground = cx.theme().foreground;
let cfg = cx.global::<Config>();
@@ -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<Self>) -> AnyElement {
let cfg = cx.global::<Config>();
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<Self>) -> 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<Self>) -> 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");
+5 -2
View File
@@ -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))
},
),
+125 -39
View File
@@ -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<Pixels> {
}
/// (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<MenuItem> {
// 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<MenuItem> {
// 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<MenuItem> {
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
+6 -3
View File
@@ -191,10 +191,13 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> {
};
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<SpecItem> {
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
+4 -1
View File
@@ -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,