From e6009636b58e95965f96f67b44443473f78fcfd7 Mon Sep 17 00:00:00 2001 From: migege Date: Sun, 27 Sep 2026 09:29:30 +0800 Subject: [PATCH 1/7] feat(tabs): reorder the active tab from the keyboard (MoveTabLeft / MoveTabRight) (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tabs): MoveTabLeft / MoveTabRight — keyboard tab reordering The drag that reorders a tab now has a keyboard form: the active tab trades places with its neighbour in visual order, wrapping past either end. Ships unbound like the pane-swap pair; bindable from config.json and the Keybindings page, and listed in the palette and the docs. * fix(tabs): keep a keyboard tab move inside its sidebar group On a left tab bar the move stepped along the flat visual order, so a step out of a group reordered self.tabs without moving anything on screen (still saved, still synced as TabMove, and visible later on a top bar), and a wrap through another group landed the tab at its own group's far end by accident. The move now reassigns only the slots of the tab's own sidebar section, and wraps at that section's ends. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- CHANGELOG.md | 9 +++ docs/reference/keyboard-shortcuts.mdx | 2 +- src/core/actions.rs | 2 + src/ui/app.rs | 93 ++++++++++++++++++++++++++- src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 2 + src/ui/i18n/mod.rs | 2 + src/ui/i18n/zh.rs | 2 + src/ui/keymap.rs | 15 +++++ src/ui/palette.rs | 8 +++ src/ui/tab_sidebar.rs | 15 +++++ 11 files changed, 150 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 426b3eed..b3a619e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`). + The tab moves one slot past its neighbour — the keyboard form of dragging it + in the tab strip or the sidebar — and wraps past either end, so one held key + walks a tab the whole way down the list. On a left tab bar the same pair + reads as up and down, and the tab stays inside its sidebar group, wrapping + at the group's ends. Shipped unbound like the pane-swap pair: bind it under + Settings → Keyboard shortcuts ("Move Tab Left" / "Move Tab Right"), in + `config.json`, or run it from the palette. + - **Quick launch for the coding agents on your PATH** (#955). Every agent tty7 recognises whose binary is on `PATH` is a palette command — "Agent: Claude Code", "Agent: Codex", … — ordered by how often and how recently it was diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index c0509050..a1613e26 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -108,7 +108,7 @@ Keybindings**: | Group | Actions | |---|---| -| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | +| Tabs | `RenameTab` · `NewWorktreeTab` · `MoveTabLeft` · `MoveTabRight` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | | Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:` (one per agent, e.g. `LaunchAgent:claude`) | diff --git a/src/core/actions.rs b/src/core/actions.rs index bf9fff64..201edf58 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -57,6 +57,8 @@ actions!( PrevTab, SelectNextTab, SelectPrevTab, + MoveTabLeft, + MoveTabRight, ActivateTab1, ActivateTab2, ActivateTab3, diff --git a/src/ui/app.rs b/src/ui/app.rs index 5d6907a1..1cd7ad3e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4888,6 +4888,20 @@ impl Tty7App { } } + /// Reorders the active tab one slot — the keyboard form of the drag that + /// the strip and the sidebar both do. Left is back along the order and + /// right is forward (on a left tab bar that reads as up and down), and + /// both wrap: past the last tab the first one follows, so a tab can walk + /// the whole list without letting go of the key. On a left tab bar the + /// list is the tab's own sidebar group: the grouping keys off the tab, + /// not its position, so a step out of the group would move nothing. + fn move_tab(&mut self, right: bool, cx: &mut Context) { + let run = self.tab_run(self.active, cx); + if let Some(permutation) = one_slot_move(&run, self.tabs.len(), self.active, right) { + self.apply_tab_order(&permutation, cx); + } + } + pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { // A tab is woken by being looked at, the way a browser reloads a // discarded tab when it is selected: a tab on screen is always a @@ -5973,6 +5987,8 @@ impl Tty7App { SwapPanePrev => self.swap_pane(false, window, cx), SelectNextTab => self.cycle_tab(true, window, cx), SelectPrevTab => self.cycle_tab(false, window, cx), + MoveTabLeft => self.move_tab(false, cx), + MoveTabRight => self.move_tab(true, cx), ToggleMaximizePane => self.toggle_maximize(window, cx), ToggleFullscreen => self.toggle_fullscreen(window, cx), ToggleTabSidebar => self.toggle_tab_sidebar(cx), @@ -9191,6 +9207,12 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &SelectPrevTab, window, cx| { this.cycle_tab(false, window, cx) })) + .on_action( + cx.listener(|this, _: &MoveTabLeft, _window, cx| this.move_tab(false, cx)), + ) + .on_action( + cx.listener(|this, _: &MoveTabRight, _window, cx| this.move_tab(true, cx)), + ) .on_action(cx.listener(|this, _: &ActivateTab1, window, cx| { this.activate_visual(0, window, cx) })) @@ -9477,6 +9499,35 @@ fn step_in_order(order: &[usize], active: usize, forward: bool) -> Option Some(order[next]).filter(|&i| i != active) } +/// The `apply_tab_order` permutation that moves the active tab one slot +/// within `run` — a move, not a trade: the tab is lifted out and put back at +/// the neighbour's place, so through a wrap the run it crossed shifts one slot +/// (`[a, b, c]` with `c` moved right becomes `[c, a, b]`, not `[c, b, a]`). +/// `run` is the tabs drawn together (a sidebar section, or every tab on a top +/// bar) as ascending `self.tabs` indices out of `n`; only their slots are +/// reassigned, so no tab in another run moves, not even out of sight. `None` +/// when there is nothing to move: a run of fewer than two tabs, or one that +/// does not hold the active tab. +fn one_slot_move(run: &[usize], n: usize, active: usize, right: bool) -> Option> { + let len = run.len(); + if len < 2 { + return None; + } + let pos = run.iter().position(|&i| i == active)?; + let mut moved = run.to_vec(); + match (right, pos) { + (true, p) if p + 1 == len => moved.rotate_right(1), + (false, 0) => moved.rotate_left(1), + (true, p) => moved.swap(p, p + 1), + (false, p) => moved.swap(p, p - 1), + } + let mut permutation: Vec = (0..n).collect(); + for (&slot, &tab) in run.iter().zip(&moved) { + permutation[slot] = tab; + } + Some(permutation) +} + fn mru_order(stamps: &[u64], active: usize) -> Vec { let mut order: Vec = (0..stamps.len()).collect(); order.sort_by_key(|&i| (stamps[i] == 0, std::cmp::Reverse(stamps[i]), i)); @@ -10665,7 +10716,7 @@ mod tests { use super::{ CloseReason, DOCUMENT_MIN_W, Dir, Pane, Rename, TERMINAL_MIN_W, TITLE_BAR_HEIGHT, Tab, TabAgentSession, clear_window_override_values, close_prompt, document_column_px, - join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for, + join_shell_args, leaf_shares_the_window_daemon, mru_order, one_slot_move, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, rename_outcome, side_panel_max, split_shell_args, step_in_order, strip_band, wd_path_saveable, }; @@ -11045,6 +11096,46 @@ mod tests { assert_eq!(step_in_order(&[0], 0, false), None); } + #[test] + fn moving_a_tab_one_slot_puts_it_where_the_neighbour_was() { + // A move, not a trade: the neighbour it crossed shifts one slot. + assert_eq!(one_slot_move(&[0, 1, 2], 3, 1, true), Some(vec![0, 2, 1])); + assert_eq!(one_slot_move(&[0, 1, 2], 3, 1, false), Some(vec![1, 0, 2])); + } + + #[test] + fn a_tab_moved_past_an_end_carries_the_whole_run_with_it() { + // Through the wrap the tab lands at the far end and the run it + // crossed shifts one slot — `[c, a, b]`, not `[c, b, a]`. + assert_eq!(one_slot_move(&[0, 1, 2], 3, 2, true), Some(vec![2, 0, 1])); + assert_eq!(one_slot_move(&[0, 1, 2], 3, 0, false), Some(vec![1, 2, 0])); + } + + #[test] + fn a_move_inside_a_sidebar_group_leaves_the_other_groups_alone() { + // Tabs 0, 2 and 4 are one group, 1 and 3 another: only the group's + // own slots are reassigned, and the wrap stays inside it. + assert_eq!( + one_slot_move(&[0, 2, 4], 5, 2, true), + Some(vec![0, 1, 4, 3, 2]) + ); + assert_eq!( + one_slot_move(&[0, 2, 4], 5, 0, false), + Some(vec![2, 1, 4, 3, 0]) + ); + assert_eq!( + one_slot_move(&[1, 3], 5, 3, true), + Some(vec![0, 3, 2, 1, 4]) + ); + } + + #[test] + fn a_move_with_nothing_to_move_is_refused() { + assert_eq!(one_slot_move(&[], 0, 0, true), None); + assert_eq!(one_slot_move(&[0], 3, 0, true), None); + assert_eq!(one_slot_move(&[0, 1, 2], 3, 7, true), None); + } + #[test] fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() { let ours = crate::core::session::WorkspaceId::new(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index cd1991b9..68b1e8c4 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1522,6 +1522,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdSwapPanePrevious => "Swap Pane Previous", L10nKey::CmdNextTab => "Next Tab", L10nKey::CmdPreviousTab => "Previous Tab", + L10nKey::CmdMoveTabLeft => "Move Tab Left", + L10nKey::CmdMoveTabRight => "Move Tab Right", L10nKey::CmdRecentTabSwitcher => "Recent Tab Switcher", L10nKey::CmdRecentTabSwitcherReverse => "Recent Tab Switcher (Reverse)", L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a2502336..0b0c822b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1586,6 +1586,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdSwapPanePrevious => "前のペインと入れ替え", L10nKey::CmdNextTab => "次のタブ", L10nKey::CmdPreviousTab => "前のタブ", + L10nKey::CmdMoveTabLeft => "タブを左へ移動", + L10nKey::CmdMoveTabRight => "タブを右へ移動", L10nKey::CmdRecentTabSwitcher => "最近のタブを切り替える", L10nKey::CmdRecentTabSwitcherReverse => "最近のタブを切り替える(逆順)", L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4e8eaf92..e0fc74c7 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1238,6 +1238,8 @@ l10n_keys! { CmdSwapPanePrevious, CmdNextTab, CmdPreviousTab, + CmdMoveTabLeft, + CmdMoveTabRight, CmdRecentTabSwitcher, CmdRecentTabSwitcherReverse, CmdCopyWorkingDirectory, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 092a77dd..61074840 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1429,6 +1429,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdSwapPanePrevious => "与上一窗格交换", L10nKey::CmdNextTab => "下一标签页", L10nKey::CmdPreviousTab => "上一标签页", + L10nKey::CmdMoveTabLeft => "标签页左移", + L10nKey::CmdMoveTabRight => "标签页右移", L10nKey::CmdRecentTabSwitcher => "最近标签页切换器", L10nKey::CmdRecentTabSwitcherReverse => "最近标签页切换器(反向)", L10nKey::CmdCopyWorkingDirectory => "复制工作目录", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 08296cd1..fcf86522 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -410,6 +410,11 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { "SelectPrevTab", per_platform("secondary-shift-[", "ctrl-pageup"), ), + // Reordering, not switching: bound by the user who wants it (the + // palette and the Keybindings page both carry these), like the pane + // swap pair above. No chord ships free enough to spend on both. + ("MoveTabLeft", ""), + ("MoveTabRight", ""), ("ActivateTab1", per_platform("secondary-1", "alt-1")), ("ActivateTab2", per_platform("secondary-2", "alt-2")), ("ActivateTab3", per_platform("secondary-3", "alt-3")), @@ -726,6 +731,14 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::TabsPanes, t(L10nKey::CmdPreviousTab).to_string(), ), + "MoveTabLeft" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdMoveTabLeft).to_string(), + ), + "MoveTabRight" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdMoveTabRight).to_string(), + ), "NewWorkspace" => ( CommandGroup::Workspaces, t(L10nKey::CmdNewWorkspace).to_string(), @@ -1426,6 +1439,8 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "PrevTab" => KeyBinding::new(keystroke, PrevTab, None), "SelectNextTab" => KeyBinding::new(keystroke, SelectNextTab, None), "SelectPrevTab" => KeyBinding::new(keystroke, SelectPrevTab, None), + "MoveTabLeft" => KeyBinding::new(keystroke, MoveTabLeft, None), + "MoveTabRight" => KeyBinding::new(keystroke, MoveTabRight, None), "ActivateTab1" => KeyBinding::new(keystroke, ActivateTab1, None), "ActivateTab2" => KeyBinding::new(keystroke, ActivateTab2, None), "ActivateTab3" => KeyBinding::new(keystroke, ActivateTab3, None), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index bc7d16ff..8cbc4b77 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -55,6 +55,8 @@ pub enum CommandKind { SwapPanePrev, SelectNextTab, SelectPrevTab, + MoveTabLeft, + MoveTabRight, ToggleMaximizePane, ToggleFullscreen, ToggleTabSidebar, @@ -170,6 +172,8 @@ impl CommandKind { SwapPanePrev => "swap-pane-prev", SelectNextTab => "next-tab", SelectPrevTab => "prev-tab", + MoveTabLeft => "move-tab-left", + MoveTabRight => "move-tab-right", ToggleMaximizePane => "zoom-pane", ToggleFullscreen => "full-screen", ToggleTabSidebar => "tab-bar-position", @@ -294,6 +298,8 @@ impl CommandKind { // MRU switcher `NextTab` opens, so its chord hint is that one's. SelectNextTab => "SelectNextTab", SelectPrevTab => "SelectPrevTab", + MoveTabLeft => "MoveTabLeft", + MoveTabRight => "MoveTabRight", ToggleMaximizePane => "ToggleMaximizePane", ToggleFullscreen => "ToggleFullscreen", ToggleTabSidebar => "ToggleTabSidebar", @@ -494,6 +500,8 @@ impl Command { Command::localized(L10nKey::CmdSwapPanePrevious, SwapPanePrev), Command::localized(L10nKey::CmdNextTab, SelectNextTab), Command::localized(L10nKey::CmdPreviousTab, SelectPrevTab), + Command::localized(L10nKey::CmdMoveTabLeft, MoveTabLeft), + Command::localized(L10nKey::CmdMoveTabRight, MoveTabRight), Command::localized(L10nKey::CmdCopyWorkingDirectory, CopyWorkingDirectory), Command::localized(L10nKey::CmdCopySessionId, CopyAgentSessionId) .with_subtitle(t(L10nKey::CmdCopySessionIdSubtitle)), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 55d5db75..e5274f79 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -2597,6 +2597,21 @@ impl Tty7App { .collect() } + /// The tabs drawn in the same run as `index`, in `self.tabs` order: its + /// sidebar section on a left tab bar, every tab on a top one. Empty when + /// no run holds `index`. + pub(crate) fn tab_run(&self, index: usize, cx: &gpui::App) -> Vec { + if cx.global::().tab_bar_position != crate::core::config::TabBarPosition::Left { + return (0..self.tabs.len()).collect(); + } + let keys = self.sidebar_group_keys(cx); + sidebar_sections(&keys, &self.sidebar_groups) + .into_iter() + .map(|s| s.tabs) + .find(|tabs| tabs.contains(&index)) + .unwrap_or_default() + } + pub(crate) fn activate_visual( &mut self, n: usize, From 7a32e7dbbcc274d0692548cde1fc9cceec9a346b Mon Sep 17 00:00:00 2001 From: kalpakprod Date: Sun, 27 Sep 2026 06:30:11 +0500 Subject: [PATCH 2/7] feat(agents): recognise Empryo and Prime Agent, add Antigravity status hooks (#975) * feat(agents): recognise Empryo and Prime Agent Prime Agent (PrimeIntellect-ai/prime-agent) is a Pi fork: detected as `prime-agent`, resumes with `--resume `, forks with `--fork `, `--no-session` opts out, and reports status through the shared Pi extension bridge at ~/.prime/agent/extensions/tty7/index.ts. Empryo is detected as `empryo` and resumes with `empryo --session `. No hook installer yet: Empryo filters TTY7* variables out of hook processes, so `tty7-app agent-hook` would stop at the missing marker. * feat(agents): Antigravity status hooks Antigravity CLI (`agy`) now installs a `tty7` hook set in ~/.gemini/config/hooks.json, next to the user's own sets: PreInvocation -> prompt-submit, PostToolUse -> tool-complete, Stop -> stop. Only the first PreInvocation of a turn (invocationNum 0) counts as a new prompt, later ones keep the turn working; a Stop with fullyIdle false is ignored. conversationId and workspacePaths feed the session id and cwd. PreToolUse is left alone because it must answer with a decision. * fix(agents): index Prime Agent and Antigravity in settings, scope conversationId alias - Add settings titles, search keywords (en/ja/zh) and Agents index entries for the two new HookAgents; agent_rows_are_in_the_search_index requires one per HookAgent::ALL. - Read Antigravity's conversationId as the session id only for antigravity: the alias table is last-write-wins, so a global alias could replace another agent's session id. - Drop the stray .empryo/ job records. --------- Co-authored-by: kalpak Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- README.md | 8 +- README.zh-CN.md | 4 +- crates/tty7-core/src/core/agent_hooks.rs | 295 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 89 ++++++- docs/agents/overview.mdx | 6 +- docs/agents/sessions.mdx | 1 + docs/agents/status.mdx | 11 +- docs/getting-started/first-launch.mdx | 2 +- docs/index.mdx | 2 +- src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 6 + src/ui/i18n/zh.rs | 8 + src/ui/settings.rs | 10 + 14 files changed, 441 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 74d6f4ac..218771d3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | +| **Agent-aware** | per-pane detection (25 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | @@ -69,7 +69,7 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho that tells tty7 which session to fork.
-The full support matrix, all twenty-three +The full support matrix, all twenty-five | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -79,6 +79,7 @@ that tells tty7 which session to fork. | **Grok** | ✓ | ✓ | ✓ | | **OpenCode** | ✓ | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | +| **Prime Agent** | ✓ | ✓ | ✓ | | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | @@ -89,13 +90,14 @@ that tells tty7 which session to fork. | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | | **Crush** | ✓ | ✓ | | +| **Antigravity** | ✓ | ✓ | | | **Cursor** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Auggie | ✓ | | | | Hermes | ✓ | | | | Vibe | ✓ | | | -| Antigravity | ✓ | | | +| Empryo | ✓ | | |
diff --git a/README.zh-CN.md b/README.zh-CN.md index 571937ec..ccb7aa1d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -77,6 +77,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Grok** | ✓ | ✓ | ✓ | | **OpenCode** | ✓ | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | +| **Prime Agent** | ✓ | ✓ | ✓ | | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | @@ -84,13 +85,14 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Copilot** | ✓ | ✓ | | | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | +| **Antigravity** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Cursor | ✓ | | | | Auggie | ✓ | | | | Hermes | ✓ | | | | Vibe | ✓ | | | -| Antigravity | ✓ | | | +| Empryo | ✓ | | | diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 38c846c8..a4867638 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,6 +43,26 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { + // Antigravity has no turn-start event, only `PreInvocation` before every + // model call. The first call of a turn starts it; later ones are the same + // turn still working, and must not count as new prompts. `Stop` with + // `fullyIdle: false` ends one execution loop while more are queued. + if agent == "antigravity" + && let Ok(payload) = serde_json::from_str::(stdin_json) + { + if event == "prompt-submit" + && payload + .get("invocationNum") + .and_then(|n| n.as_u64()) + .unwrap_or(0) + > 0 + { + return Some("tool-complete"); + } + if event == "stop" && payload.get("fullyIdle").and_then(|v| v.as_bool()) == Some(false) { + return None; + } + } // Qoder and CodeBuddy also emit SessionStart after compacting the active // turn. Preserve its status until a real turn or session boundary arrives. if matches!(agent, "qodercli" | "codebuddy") @@ -90,11 +110,25 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { body[key] = serde_json::Value::String(v.to_string()); } } + // Antigravity names the session after the conversation, in camelCase. + // Scoped to it: the alias table is last-write-wins, and another agent's + // unrelated `conversationId` must not replace its real session id. + if agent == "antigravity" + && body.get("session_id").is_none() + && let Some(id) = payload + .get("conversationId") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + { + body["session_id"] = serde_json::Value::String(id.to_string()); + } // Cursor puts `cwd` only on its tool events; the rest carry the workspace // roots instead, and the first of those is where the session runs. + // Antigravity's `workspacePaths` is the same list under its own name. if body.get("cwd").is_none() && let Some(root) = payload .get("workspace_roots") + .or_else(|| payload.get("workspacePaths")) .and_then(|r| r.as_array()) .and_then(|r| r.first()) .and_then(|r| r.as_str()) @@ -285,10 +319,12 @@ pub enum HookAgent { Crush, CodeBuddy, Cursor, + PrimeAgent, + Antigravity, } impl HookAgent { - pub const ALL: [HookAgent; 17] = [ + pub const ALL: [HookAgent; 19] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -306,6 +342,8 @@ impl HookAgent { HookAgent::Crush, HookAgent::CodeBuddy, HookAgent::Cursor, + HookAgent::PrimeAgent, + HookAgent::Antigravity, ]; /// The hooks behind a detected agent process, if it has any. @@ -323,6 +361,8 @@ impl HookAgent { CLIAgent::Pi => Some(HookAgent::Pi), CLIAgent::Grok => Some(HookAgent::Grok), CLIAgent::OhMyPi => Some(HookAgent::OhMyPi), + CLIAgent::PrimeAgent => Some(HookAgent::PrimeAgent), + CLIAgent::Antigravity => Some(HookAgent::Antigravity), CLIAgent::Gemini => Some(HookAgent::Gemini), CLIAgent::Droid => Some(HookAgent::Droid), CLIAgent::Qwen => Some(HookAgent::Qwen), @@ -337,7 +377,7 @@ impl HookAgent { | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity => None, + | CLIAgent::Empryo => None, } } @@ -361,6 +401,8 @@ impl HookAgent { | HookAgent::Pi | HookAgent::Grok | HookAgent::OhMyPi + | HookAgent::PrimeAgent + | HookAgent::Antigravity | HookAgent::Goose | HookAgent::Kimi => None, } @@ -406,6 +448,8 @@ impl HookAgent { HookAgent::Pi => "pi", HookAgent::Grok => "grok", HookAgent::OhMyPi => "omp", + HookAgent::PrimeAgent => "prime-agent", + HookAgent::Antigravity => "antigravity", HookAgent::Gemini => "gemini", HookAgent::Droid => "droid", HookAgent::Qwen => "qwen", @@ -428,6 +472,8 @@ impl HookAgent { HookAgent::Pi => "Pi", HookAgent::Grok => "Grok Build", HookAgent::OhMyPi => "Oh My Pi", + HookAgent::PrimeAgent => "Prime Agent", + HookAgent::Antigravity => "Antigravity", HookAgent::Gemini => "Gemini", HookAgent::Droid => "Droid", HookAgent::Qwen => "Qwen Code", @@ -455,6 +501,10 @@ impl HookAgent { &["opencode", "plugins", OWNED_FILE_STEM_JS], ), HookAgent::Pi => target.under_home(&[".pi", "agent", "extensions", "tty7", "index.ts"]), + HookAgent::PrimeAgent => { + target.under_home(&[".prime", "agent", "extensions", "tty7", "index.ts"]) + } + HookAgent::Antigravity => target.under_home(&[".gemini", "config", "hooks.json"]), HookAgent::Grok => target.under_home(&[".grok", "hooks", OWNED_FILE_STEM_JSON]), HookAgent::OhMyPi => { target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"]) @@ -683,6 +733,9 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + return named_hook_set_state(target, &path, &antigravity_hook_set(target)); + } if let Some(events) = agent.toml_hook_events() { return toml_hooks_state(target, &path, agent, events); } @@ -719,6 +772,10 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + named_hook_set_install(target, &path, antigravity_hook_set(target))?; + return Ok(HookOutcome::Installed); + } if let Some(events) = agent.toml_hook_events() { toml_hooks_install(target, &path, agent, events)?; return Ok(HookOutcome::Installed); @@ -744,6 +801,9 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + return named_hook_set_uninstall(target, &path); + } if agent.toml_hook_events().is_some() { return toml_hooks_uninstall(target, &path, agent); } @@ -1324,11 +1384,108 @@ fn enable_codex_hooks_feature() -> Result<(), String> { } } +/// The key tty7's entry lives under in Antigravity's `hooks.json`. +/// +/// Antigravity names every hook set at the root of the file — +/// `{"": {"": [...]}}` — rather than grouping handlers under a +/// shared `hooks` object the way Claude does, so tty7 owns one named set and +/// never has to pick its commands out of the user's lists. +const ANTIGRAVITY_HOOK_SET: &str = "tty7"; + +/// Antigravity CLI's lifecycle events, mapped onto tty7's. +/// +/// It has no session-start or session-end event. `PreInvocation` fires before +/// every model call of a turn, so `effective_event` keeps only the first one +/// as the turn start; `Stop` fires when the execution loop ends. The +/// `PreToolUse` gate is left alone: it must answer with a `decision`, and an +/// empty reply from a status hook has no business deciding anything. +fn antigravity_hook_set(target: &HookTarget) -> serde_json::Value { + let handler = |event: &str| { + serde_json::json!({ + "type": "command", + "command": target.hook_command(HookAgent::Antigravity, event), + "timeout": 5, + }) + }; + serde_json::json!({ + "PreInvocation": [handler("prompt-submit")], + "PostToolUse": [{ "matcher": "*", "hooks": [handler("tool-complete")] }], + "Stop": [handler("stop")], + }) +} + +fn read_json_object(target: &HookTarget, path: &Path) -> anyhow::Result> { + let text = match target.read(path) { + Ok(text) => text, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + if text.trim().is_empty() { + return Ok(Some(serde_json::json!({}))); + } + let root: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not valid JSON ({e}); not touching it", + path.display() + ) + })?; + if !root.is_object() { + return Err(anyhow::anyhow!( + "{} is not a JSON object; not touching it", + path.display() + )); + } + Ok(Some(root)) +} + +fn named_hook_set_state( + target: &HookTarget, + path: &Path, + expected: &serde_json::Value, +) -> HooksState { + match read_json_object(target, path) { + Ok(Some(root)) => match root.get(ANTIGRAVITY_HOOK_SET) { + None => HooksState::NotInstalled, + Some(set) if set == expected => HooksState::Installed, + Some(_) => HooksState::Outdated, + }, + _ => HooksState::NotInstalled, + } +} + +fn named_hook_set_install( + target: &HookTarget, + path: &Path, + set: serde_json::Value, +) -> anyhow::Result<()> { + let mut root = read_json_object(target, path)?.unwrap_or_else(|| serde_json::json!({})); + root.as_object_mut() + .unwrap() + .insert(ANTIGRAVITY_HOOK_SET.to_string(), set); + target.write(path, serde_json::to_string_pretty(&root)?.as_bytes()) +} + +fn named_hook_set_uninstall(target: &HookTarget, path: &Path) -> anyhow::Result { + let Some(mut root) = read_json_object(target, path)? else { + return Ok(HookOutcome::NothingInstalled); + }; + if root + .as_object_mut() + .unwrap() + .remove(ANTIGRAVITY_HOOK_SET) + .is_none() + { + return Ok(HookOutcome::NoTty7Hooks); + } + target.write(path, serde_json::to_string_pretty(&root)?.as_bytes())?; + Ok(HookOutcome::Removed) +} + fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { match agent { HookAgent::Copilot => copilot_hooks_json(target), HookAgent::OpenCode => opencode_plugin_js(target), - HookAgent::Pi | HookAgent::OhMyPi => pi_extension_ts(target, agent), + HookAgent::Pi | HookAgent::OhMyPi | HookAgent::PrimeAgent => pi_extension_ts(target, agent), HookAgent::Grok => grok_hooks_json(target), HookAgent::Goose => goose_hooks_json(target), HookAgent::Claude @@ -1341,6 +1498,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Crush | HookAgent::CodeBuddy | HookAgent::Cursor + | HookAgent::Antigravity | HookAgent::Kimi => None, } } @@ -1568,6 +1726,7 @@ fn pi_extension_ts(target: &HookTarget, agent: HookAgent) -> Option { let (slug, package) = match agent { HookAgent::Pi => ("pi", "@mariozechner/pi-coding-agent"), HookAgent::OhMyPi => ("omp", "@oh-my-pi/pi-coding-agent"), + HookAgent::PrimeAgent => ("prime-agent", "@earendil-works/pi-coding-agent"), _ => return None, }; let exe = serde_json::to_string(&target.exe.display().to_string()).ok()?; @@ -1838,6 +1997,10 @@ mod tests { (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), + ( + HookAgent::PrimeAgent, + "/home/me/.prime/agent/extensions/tty7/index.ts", + ), ] { assert_eq!( agent.target_path(&t), @@ -2582,6 +2745,127 @@ mod tests { ); } + #[test] + fn antigravity_owns_a_named_hook_set_beside_the_users() { + let dir = std::env::temp_dir().join(format!("tty7-agy-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let host = FakeRemote::shared(); + let target = HookTarget::remote(&*host, dir.clone()); + let path = HookAgent::Antigravity.target_path(&target); + assert_eq!(path, dir.join(".gemini/config/hooks.json")); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let theirs = serde_json::json!({ + "my-linter-hook": { + "PostToolUse": [{ "matcher": "run_command", "hooks": [{ "command": "./lint.sh" }] }] + } + }); + std::fs::write(&path, theirs.to_string()).unwrap(); + + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::NotInstalled + ); + install_hooks(&target, HookAgent::Antigravity).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Installed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root["my-linter-hook"], theirs["my-linter-hook"], + "the user's set survives" + ); + let set = &root[ANTIGRAVITY_HOOK_SET]; + assert!( + set["PreInvocation"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook antigravity prompt-submit") + ); + assert_eq!(set["PostToolUse"][0]["matcher"], "*"); + assert!( + set["Stop"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook antigravity stop") + ); + assert!( + set.get("PreToolUse").is_none(), + "a status hook must not gate tools" + ); + + root_set_to(&path, serde_json::json!({ "Stop": [] }), &root); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Outdated + ); + install_hooks(&target, HookAgent::Antigravity).expect("reinstall succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Installed + ); + + assert_eq!( + uninstall_hooks(&target, HookAgent::Antigravity).expect("uninstall succeeds"), + HookOutcome::Removed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root, theirs, + "uninstall leaves exactly what was there before" + ); + assert_eq!( + uninstall_hooks(&target, HookAgent::Antigravity).expect("second uninstall"), + HookOutcome::NoTty7Hooks + ); + let _ = std::fs::remove_dir_all(&dir); + } + + fn root_set_to(path: &Path, set: serde_json::Value, root: &serde_json::Value) { + let mut root = root.clone(); + root[ANTIGRAVITY_HOOK_SET] = set; + std::fs::write(path, root.to_string()).unwrap(); + } + + #[test] + fn antigravity_payloads_map_onto_turns() { + let first = r#"{"conversationId":"c-1","invocationNum":0,"workspacePaths":["/repo"]}"#; + let later = r#"{"conversationId":"c-1","invocationNum":2,"workspacePaths":["/repo"]}"#; + assert_eq!( + effective_event("antigravity", "prompt-submit", first), + Some("prompt-submit") + ); + assert_eq!( + effective_event("antigravity", "prompt-submit", later), + Some("tool-complete") + ); + assert_eq!( + effective_event( + "antigravity", + "stop", + r#"{"conversationId":"c-1","fullyIdle":true}"# + ), + Some("stop") + ); + assert_eq!( + effective_event( + "antigravity", + "stop", + r#"{"conversationId":"c-1","fullyIdle":false}"# + ), + None + ); + let ev = round_trip("antigravity", "prompt-submit", first); + assert_eq!(ev.session_id.as_deref(), Some("c-1")); + assert_eq!(ev.cwd.as_deref(), Some(Path::new("/repo"))); + assert_eq!( + ev.agent, + Some(crate::core::cli_agent::CLIAgent::Antigravity) + ); + } + #[test] fn a_remote_install_round_trips_through_the_host() { let dir = std::env::temp_dir().join(format!("tty7-remote-hooks-{}", std::process::id())); @@ -2679,6 +2963,11 @@ mod tests { for (agent, slug, package) in [ (HookAgent::Pi, "pi", "@mariozechner/pi-coding-agent"), (HookAgent::OhMyPi, "omp", "@oh-my-pi/pi-coding-agent"), + ( + HookAgent::PrimeAgent, + "prime-agent", + "@earendil-works/pi-coding-agent", + ), ] { let bridge = pi_extension_ts(&target, agent).unwrap_or_else(|| panic!("{slug} content builds")); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 42b44526..0620a50b 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -29,10 +29,12 @@ pub enum CLIAgent { QoderCLI, Crush, CodeBuddy, + Empryo, + PrimeAgent, } impl CLIAgent { - pub const ALL: [CLIAgent; 23] = [ + pub const ALL: [CLIAgent; 25] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -56,6 +58,8 @@ impl CLIAgent { CLIAgent::QoderCLI, CLIAgent::Crush, CLIAgent::CodeBuddy, + CLIAgent::Empryo, + CLIAgent::PrimeAgent, ]; fn aliases(self) -> &'static [&'static str] { @@ -106,6 +110,8 @@ impl CLIAgent { // `cbc` also names the COIN-OR solver; a solver run wearing the // avatar until it exits is the cost of catching the short name. CLIAgent::CodeBuddy => &["codebuddy", "codebuddy-code", "cbc"], + CLIAgent::Empryo => &["empryo"], + CLIAgent::PrimeAgent => &["prime-agent"], } } @@ -134,6 +140,8 @@ impl CLIAgent { CLIAgent::QoderCLI => "qodercli", CLIAgent::Crush => "crush", CLIAgent::CodeBuddy => "codebuddy", + CLIAgent::Empryo => "empryo", + CLIAgent::PrimeAgent => "prime-agent", } } @@ -167,6 +175,8 @@ impl CLIAgent { CLIAgent::QoderCLI => "Qoder CLI", CLIAgent::Crush => "Crush", CLIAgent::CodeBuddy => "CodeBuddy", + CLIAgent::Empryo => "Empryo", + CLIAgent::PrimeAgent => "Prime Agent", } } @@ -204,13 +214,15 @@ impl CLIAgent { CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), CLIAgent::CodeBuddy => Some(format!("codebuddy{flags} --resume {session_id}")), + CLIAgent::Empryo => Some(format!("empryo{flags} --session {session_id}")), + CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --resume {session_id}")), _ => None, } } fn opts_out_of_sessions(self, argv: &[String]) -> bool { let ephemeral: &[&str] = match self { - CLIAgent::Pi | CLIAgent::OhMyPi => &["--no-session"], + CLIAgent::Pi | CLIAgent::OhMyPi | CLIAgent::PrimeAgent => &["--no-session"], // "Do not save conversation history" — nothing is persisted, so // there is no session left to resume from. CLIAgent::Auggie => &["--dont-save-session"], @@ -245,6 +257,7 @@ impl CLIAgent { )), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), + CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --fork {session_id}")), // Droid forks with a standalone flag rather than resume-plus-a-switch. CLIAgent::Droid => Some(format!("droid{flags} --fork {session_id}")), // `fork` is missing from `amp threads --help`, but the subcommand is @@ -267,6 +280,7 @@ impl CLIAgent { | CLIAgent::Grok | CLIAgent::OpenCode | CLIAgent::OhMyPi + | CLIAgent::PrimeAgent | CLIAgent::Droid | CLIAgent::Amp | CLIAgent::Qwen @@ -433,6 +447,12 @@ impl CLIAgent { // `--resume`, `-r` and `--session` are three spellings of one flag // in Oh My Pi; `--session-dir` is a different one and survives. CLIAgent::OhMyPi => &["--resume", "-r", "--session", "--fork", "--continue", "-c"], + // Prime Agent is a Pi fork with the same session switches; + // `--session-dir` is a different option and survives. + CLIAgent::PrimeAgent => &["--resume", "-r", "--fork", "--continue", "-c"], + // empryo resumes with `--session `; `--save-session` only + // applies to headless runs and is not a session selector. + CLIAgent::Empryo => &["--session"], // `--resume`/`-r` is Kimi's hidden alias for `--session`/`-S`. // `--agent`/`--agent-file` bind the main agent at session creation // and Kimi rejects either next to `--session` outright; resuming @@ -548,6 +568,8 @@ impl CLIAgent { CLIAgent::QoderCLI => 0xFFFFFF, // The blue-violet field Charm ships the Crush heart on. CLIAgent::Crush => 0x6B50FF, + CLIAgent::Empryo => 0xE8663D, + CLIAgent::PrimeAgent => 0x111111, // The near-black field CodeBuddy's own app icon sits on. CLIAgent::CodeBuddy => 0x1F1F1F, } @@ -597,7 +619,9 @@ impl CLIAgent { | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity => "icons/bot.svg", + | CLIAgent::Antigravity + | CLIAgent::Empryo + | CLIAgent::PrimeAgent => "icons/bot.svg", } } @@ -1239,7 +1263,15 @@ mod tests { .collect(); assert_eq!( fallback, - ["aider", "auggie", "hermes", "vibe", "antigravity"] + [ + "aider", + "auggie", + "hermes", + "vibe", + "antigravity", + "empryo", + "prime-agent" + ] ); assert!( !fallback.contains(&"omp"), @@ -1255,6 +1287,55 @@ mod tests { } } + #[test] + fn empryo_and_prime_agent_are_detected_and_resume() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[ + "/home/me/.empryo/bin/empryo", + "--session", + "750adff9" + ])), + Some(CLIAgent::Empryo) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["prime-agent", "--model", "x"])), + Some(CLIAgent::PrimeAgent) + ); + assert_eq!(CLIAgent::from_slug("empryo"), Some(CLIAgent::Empryo)); + assert_eq!( + CLIAgent::from_slug("prime-agent"), + Some(CLIAgent::PrimeAgent) + ); + assert_eq!( + CLIAgent::Empryo + .resume_command( + "750adff9-f366", + Some(&argv(&["empryo", "--session", "old"])) + ) + .as_deref(), + Some("empryo --session 750adff9-f366") + ); + assert_eq!( + CLIAgent::PrimeAgent + .resume_command( + "s-1", + Some(&argv(&["prime-agent", "-r", "old", "--model", "m"])) + ) + .as_deref(), + Some("prime-agent --model m --resume s-1") + ); + assert_eq!( + CLIAgent::PrimeAgent.fork_command("s-1", None).as_deref(), + Some("prime-agent --fork s-1") + ); + assert_eq!( + CLIAgent::PrimeAgent + .resume_command("s-1", Some(&argv(&["prime-agent", "--no-session"]))), + None + ); + assert_eq!(CLIAgent::Empryo.fork_command("s-1", None), None); + } + #[test] fn detects_newer_agents_by_command() { for (cmd, agent) in [ diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 649e7648..4b0ad97b 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty-three CLIs are recognised on sight, by the command running in the pane: +Twenty-five CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -42,6 +42,8 @@ Twenty-three CLIs are recognised on sight, by the command running in the pane: | Antigravity | `agy`, `antigravity` | | Pi | `pi` | | Oh My Pi | `omp` | +| Prime Agent | `prime-agent` | +| Empryo | `empryo` | Detection sees through the usual disguises: a full path, a `.cmd` or `.exe` on Windows, leading environment assignments, and an interpreter in front @@ -64,7 +66,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`). +`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`, `empryo`, `prime-agent`). ## Quick launch diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index 90703839..e47dc680 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -43,6 +43,7 @@ or right-click the **tab or sidebar row** to open the fork in a new tab. | Grok | `grok --resume --fork-session` | | OpenCode | `opencode --session --fork` | | Oh My Pi | `omp --fork ` | +| Prime Agent | `prime-agent --fork ` | It is the agent's own fork command, run in a new pane — nothing is copied by tty7 itself. diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 34dcebf8..27251cfc 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,8 +14,8 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI | Hooks available | -| Aider · Amp · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity | Hooks available | +| Aider · Amp · Auggie · Hermes · Vibe · Empryo | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed the row grows a second **Uninstall** button beside the first, which itself @@ -28,6 +28,13 @@ ships a newer hook. carries no status dot and `tty7 wait` times out rather than returning. + + Antigravity has no session-start or permission event, so its pane goes + straight from no dot to working on the first model call, and a pending + permission prompt shows as working rather than amber. Its hooks live in a + `tty7` set inside `~/.gemini/config/hooks.json`, beside any sets of your own. + + `cursor-agent -p` (print mode) sends neither of Cursor's turn hooks, so a headless run records its session id — Copy Session ID and resume still diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index ae7893dc..3ab597c6 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -54,7 +54,7 @@ leave it off if you type accented characters. ## 4. If you use coding agents, install the hooks -**Settings → Integrations.** tty7 detects 23 coding CLIs by process name on its own — +**Settings → Integrations.** tty7 detects 25 coding CLIs by process name on its own — you get brand avatars and tab labels for free. The *status dots*, the "needs your permission" notifications, and `tty7 wait` all need one more thing: a small hook the agent calls to report what it is doing. diff --git a/docs/index.mdx b/docs/index.mdx index f14d99aa..88a3b375 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 23 coding CLIs are recognised on sight. Per-pane status dots, notifications + 25 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 68b1e8c4..b2e339d7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -805,6 +805,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsAgentCursorCli => "Cursor CLI", + L10nKey::SettingsAgentPrimeAgent => "Prime Agent", + L10nKey::SettingsAgentAntigravity => "Antigravity", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -911,6 +913,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchCursorCliKeywords => { "agent integration hooks install cursor cursor-agent" } + L10nKey::SettingsSearchPrimeAgentKeywords => { + "agent integration extension install prime prime-agent primeintellect" + } + L10nKey::SettingsSearchAntigravityKeywords => { + "agent integration hooks install antigravity agy google" + } L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0b0c822b..0e0ebc46 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -815,6 +815,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsAgentCursorCli => "Cursor CLI", + L10nKey::SettingsAgentPrimeAgent => "Prime Agent", + L10nKey::SettingsAgentAntigravity => "Antigravity", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -967,6 +969,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorCliKeywords => { "エージェント 統合 フック インストール cursor cursor-agent agent integration hooks install" } + L10nKey::SettingsSearchPrimeAgentKeywords => { + "エージェント 統合 拡張 インストール prime prime-agent primeintellect agent integration extension install" + } + L10nKey::SettingsSearchAntigravityKeywords => { + "エージェント 統合 フック インストール antigravity agy google agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index e0fc74c7..ce929130 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -620,6 +620,8 @@ l10n_keys! { SettingsAgentCrush, SettingsAgentCodeBuddy, SettingsAgentCursorCli, + SettingsAgentPrimeAgent, + SettingsAgentAntigravity, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -679,6 +681,8 @@ l10n_keys! { SettingsSearchCrushKeywords, SettingsSearchCodeBuddyKeywords, SettingsSearchCursorCliKeywords, + SettingsSearchPrimeAgentKeywords, + SettingsSearchAntigravityKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1666,6 +1670,8 @@ mod tests { L10nKey::SettingsAgentCrush, L10nKey::SettingsAgentCodeBuddy, L10nKey::SettingsAgentCursorCli, + L10nKey::SettingsAgentPrimeAgent, + L10nKey::SettingsAgentAntigravity, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 61074840..7bfbb78f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -712,6 +712,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsAgentCursorCli => "Cursor CLI", + L10nKey::SettingsAgentPrimeAgent => "Prime Agent", + L10nKey::SettingsAgentAntigravity => "Antigravity", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -858,6 +860,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorCliKeywords => { "Cursor CLI agent 集成 钩子 安装 cursor cursor-agent" } + L10nKey::SettingsSearchPrimeAgentKeywords => { + "Prime Agent agent 集成 扩展 安装 prime prime-agent primeintellect" + } + L10nKey::SettingsSearchAntigravityKeywords => { + "Antigravity agent 集成 钩子 安装 antigravity agy google" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index bd2ebc97..dac43544 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -799,6 +799,16 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentCursorCli, keywords: SettingsSearchCursorCliKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentPrimeAgent, + keywords: SettingsSearchPrimeAgentKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentAntigravity, + keywords: SettingsSearchAntigravityKeywords, + }, SearchEntry { section: General, title: SettingsStartupWindow, From a6e4235d27132f80b119843b41e98a0e6be5be2b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:35 +0800 Subject: [PATCH 3/7] fix(render): stop a blank IME preedit hiding the cell under the cursor (#970) paint_marked blanks the cursor's cell to the theme background before laying an IME composition down. A composition with nothing visible in it (Windows IMEs can leave one behind) therefore painted a background-coloured hole with an underline over the character and the block cursor on every cell the cursor visited. Skip painting a preedit that has no ink. Fixes #966 --- CHANGELOG.md | 7 ++++ src/terminal/element.rs | 73 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a619e6..89a95b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The character under the cursor no longer disappears in vim and Neovim** + (#966). An input-method composition with nothing visible in it — Windows + IMEs can leave one behind — was still painted at the cursor, as a cell of + the theme's background with an underline under it, covering both the + character and the block cursor on every cell the cursor moved to. A + composition that has nothing to draw is no longer painted, so the cell keeps + its character and the block cursor draws it in reverse video as usual. - **Nerd Font icons from a fallback font come out at the text's size** (#866). With a Nerd Font icon face such as Symbols Nerd Font Mono behind a primary that lacks the icons, an icon followed by a space on the same background — diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 7424b570..68f810a1 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1723,6 +1723,22 @@ fn cursor_style_from_shape(shape: CursorShape) -> crate::core::config::CursorSty } } +/// Whether an IME preedit has anything to draw. `paint_marked` blanks the cell +/// under the cursor to the theme's background before it lays the preedit +/// down, which also covers the block cursor's reverse-video cell. A preedit of +/// nothing but blanks (a composition an IME opened and left empty-looking — +/// Windows IMEs can leave one behind, and gpui there never hears the +/// composition end) would therefore paint a theme-coloured hole with an +/// underline where the character and the caret should be, and hold it on +/// every cell the cursor visits (#966). Nothing to show means nothing to +/// paint: the grid, and the caret on it, stay as they are. +fn preedit_has_ink(marked: &str) -> bool { + use unicode_width::UnicodeWidthChar as _; + marked + .chars() + .any(|c| !c.is_whitespace() && !c.is_control() && c.width().unwrap_or(0) > 0) +} + fn paint_marked( window: &mut Window, cx: &mut App, @@ -1734,7 +1750,7 @@ fn paint_marked( default_fg: Hsla, default_bg: Hsla, ) { - if marked.is_empty() { + if !preedit_has_ink(marked) { return; } let Some((row, col)) = cursor else { @@ -2714,6 +2730,61 @@ mod tests { assert_eq!(ink, colors.default_bg); } + #[test] + fn a_caret_the_colour_of_the_text_still_leaves_its_glyph_readable() { + // Gruvbox Dark, the theme in #966: the caret is the foreground colour + // exactly, so drawing the glyph in its own ink on the caret would be + // cream on cream. Reverse video has to reach for the background. + let fg = Rgb { + r: 0xeb, + g: 0xdb, + b: 0xb2, + }; + let bg = Rgb { + r: 0x28, + g: 0x28, + b: 0x28, + }; + let colors = PaintColors { + default_fg: to_hsla(fg), + default_bg: to_hsla(bg), + caret: to_hsla(fg), + fg_rgb: fg, + bg_rgb: bg, + ..caret_colors() + }; + let mut buf = vec![RenderCell::default(); 3]; + buf[1].c = 'v'; + buf[1].fg = to_hsla(fg); + invert_cursor_cell(&mut buf, 3, 0, 1, &colors); + assert_eq!(buf[1].bg, colors.caret); + assert_eq!(buf[1].fg, colors.default_bg); + assert_eq!(buf[1].c, 'v'); + } + + #[test] + fn a_preedit_with_nothing_to_show_leaves_the_cursor_cell_alone() { + // Painting any of these would blank the cell under the cursor — + // character and block caret both — to the theme background (#966). + for blank in [ + "", + " ", + " ", + "\u{3000}", + "\u{200b}", + "\t", + "\u{0}", + " \u{200d} ", + ] { + assert!(!preedit_has_ink(blank), "{blank:?} has nothing to draw"); + } + // Real compositions still paint, including a leading or trailing + // blank the IME put between syllables. + for text in ["n", "ni hao", "你好", " a", "に", "ㅎ", "é"] { + assert!(preedit_has_ink(text), "{text:?} is a real preedit"); + } + } + #[test] fn to_hsla_normalizes_channels_and_alpha() { let black = to_hsla(Rgb { r: 0, g: 0, b: 0 }); From 03c24514b215310247f5c27b6598b9ae58ee549f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:43 +0800 Subject: [PATCH 4/7] fix(editor): ghost suggests the newest match, not the most frecent (#968) The inline suggestion now takes the newest history entry that extends the line, preferring one run in the current directory and falling back to the newest anywhere, and skips commands whose last run failed. It used to be the top frecency match, where run count and the directory bonus outweighed recency. Ctrl+R keeps ranking by frecency. Re-submitting a command now drops its older copy, matching the global dedup applied when history loads, so up-arrow steps onto each command once. --- CHANGELOG.md | 12 ++ src/terminal/history.rs | 40 ++----- src/terminal/view.rs | 240 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 239 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a95b2b..0b8501ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The command-line ghost suggests what you ran last, not what you ran + most.** It was the top prefix match by frecency, where run count and the + current-directory bonus outweighed recency, so after `git commit -m x` + typing `git c` still offered `git checkout main` because that had run 20 + times here. The ghost is now the newest entry that extends the line, + preferring one run in the current directory and falling back to the newest + anywhere, and it skips commands whose last run exited non-zero, so it names + what ↑ recalls. Ctrl+R still ranks by frecency. Re-running a command now + moves it to the newest history entry instead of adding a second copy + (before, only an immediate repeat was collapsed), so ↑ steps onto each + command once, the same as after a restart. + - **The sidebar groups tabs by repo automatically; pin what you want to keep** (#955). Groups now come in two halves, split by a divider. Below it, every tab you have not pinned is filed under its git repository, and an SSH tab diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 8167eb0a..914f0146 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -190,23 +190,6 @@ pub fn frecency_scores( .collect() } -pub fn rank_by_frecency( - entries: &[String], - counts: &HashMap, - cwds: &HashMap>, - cwd: Option<&str>, -) -> Vec { - let scores = frecency_scores(entries, counts, cwds, cwd); - let mut idx: Vec = (0..entries.len()).collect(); - idx.sort_by(|&a, &b| { - scores[b] - .partial_cmp(&scores[a]) - .unwrap_or(std::cmp::Ordering::Equal) - .then(b.cmp(&a)) - }); - idx.into_iter().map(|i| entries[i].clone()).collect() -} - pub fn format_ago(now: u64, ts: u64) -> String { let s = now.saturating_sub(ts); let (n, unit) = if s < 60 { @@ -904,12 +887,9 @@ mod tests { counts.insert("git status".to_string(), 40); counts.insert("ls".to_string(), 5); counts.insert("oops typo".to_string(), 1); - let ranked = rank_by_frecency(&entries, &counts, &HashMap::new(), None); - assert_eq!(ranked[0], "git status"); - assert!( - ranked.iter().position(|e| e == "git status").unwrap() - < ranked.iter().position(|e| e == "oops typo").unwrap() - ); + let scores = frecency_scores(&entries, &counts, &HashMap::new(), None); + assert!(scores[0] > scores[1], "frequent beats newer: {scores:?}"); + assert!(scores[0] > scores[2], "frequent beats newest: {scores:?}"); } #[test] @@ -920,15 +900,17 @@ mod tests { cwds.entry("cargo build".to_string()) .or_default() .insert("/work/proj".to_string()); - let ranked = rank_by_frecency(&entries, &counts, &cwds, Some("/work/proj")); - assert_eq!(ranked[0], "cargo build"); - let neutral = rank_by_frecency(&entries, &counts, &cwds, None); - assert_eq!(neutral[0], "cargo build"); - assert_eq!(neutral[1], "npm test"); + let here = frecency_scores(&entries, &counts, &cwds, Some("/work/proj")); + let neutral = frecency_scores(&entries, &counts, &cwds, None); + assert!(here[1] > here[0]); + assert!( + here[1] - here[0] > neutral[1] - neutral[0], + "running here widens the lead: {here:?} vs {neutral:?}" + ); } #[test] - fn frecency_scores_align_with_the_ranking() { + fn frecency_scores_favour_the_newer_of_two_equals() { let entries = vec!["a".to_string(), "b".to_string()]; let scores = frecency_scores(&entries, &HashMap::new(), &HashMap::new(), None); assert_eq!(scores.len(), 2); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 6ccac9f9..d4924e8b 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -458,7 +458,6 @@ pub struct TerminalView { history_counts: std::collections::HashMap, history_cwds: std::collections::HashMap>, history_meta: std::collections::HashMap, - history_ranked: Vec, history_frecency: Vec, history_scope: super::history::Scope, /// What each scope this pane has already loaded held when it was left, so @@ -1579,12 +1578,6 @@ impl TerminalView { window.focus(&focus_handle, cx); let history = super::history::load(&super::history::Scope::Local); - let history_ranked = super::history::rank_by_frecency( - &history.entries, - &history.counts, - &history.cwds, - None, - ); let history_frecency = super::history::frecency_scores(&history.entries, &history.counts, &history.cwds, None); @@ -1676,7 +1669,6 @@ impl TerminalView { history_counts: history.counts, history_cwds: history.cwds, history_meta: history.meta, - history_ranked, history_frecency, history_scope: super::history::Scope::Local, history_cache: Vec::new(), @@ -3908,7 +3900,6 @@ impl TerminalView { self.history_ready = false; } } - self.history_ranked.clear(); self.history_frecency.clear(); self.history_nav = None; self.reverse_search = None; @@ -4684,9 +4675,11 @@ impl TerminalView { exit: None, }, ); - if self.history.last().map(String::as_str) != Some(line.as_str()) { - self.history.push(line.clone()); - } + // One entry per command, at its latest run: the same global + // dedup `normalize` applies on load, so ↑ does not step onto a + // command twice and a restart does not reorder anything. + self.history.retain(|h| *h != line); + self.history.push(line.clone()); self.flush_pending_history(); self.pending_history = Some(PendingHistory { line: line.clone(), @@ -4696,7 +4689,10 @@ impl TerminalView { }); self.rerank_history(cwd.as_deref()); } + // The dedup above can shift entries down, so nothing may keep an + // index into `history` across a submit. self.history_nav = None; + self.last_word_nav = None; self.history_stash.clear(); self.history_prefix.clear(); self.close_completion(); @@ -4806,12 +4802,6 @@ impl TerminalView { fn rerank_history(&mut self, cwd: Option<&std::path::Path>) { let cwd_str = cwd.and_then(|p| p.to_str()); - self.history_ranked = super::history::rank_by_frecency( - &self.history, - &self.history_counts, - &self.history_cwds, - cwd_str, - ); self.history_frecency = super::history::frecency_scores( &self.history, &self.history_counts, @@ -4836,15 +4826,42 @@ impl TerminalView { super::history::append(&self.history_scope, &p.line, p.cwd.as_deref(), p.ts, exit); } + /// The inline suggestion: the most recent entry that extends the line, + /// preferring one run in the current directory and falling back to the + /// most recent anywhere. Commands that last exited non-zero are never + /// offered. Pure recency, not frecency, so the ghost names what ↑ would + /// recall rather than an older command that merely ran more often; Ctrl+R + /// is where frecency ranks. fn ghost_suggestion(&self) -> Option { if self.cmd.is_empty() || self.cmd.cursor() != self.cmd.len() { return None; } let line = self.cmd.text(); - self.history_ranked - .iter() - .find(|h| h.len() > line.len() && h.starts_with(&line)) - .cloned() + let cwd = self.ranked_cwd.as_deref().and_then(|p| p.to_str()); + let mut elsewhere = None; + for h in self.history.iter().rev() { + if h.len() <= line.len() || !h.starts_with(&line) { + continue; + } + if self + .history_meta + .get(h) + .and_then(|m| m.exit) + .is_some_and(|code| code != 0) + { + continue; + } + let here = cwd.is_some_and(|dir| { + self.history_cwds + .get(h) + .is_some_and(|dirs| dirs.contains(dir)) + }); + if here || cwd.is_none() { + return Some(h.clone()); + } + elsewhere.get_or_insert(h); + } + elsewhere.cloned() } fn note_integration_gap(&mut self, cx: &mut Context) { @@ -14038,7 +14055,7 @@ mod gpui_tests { window .update(cx, |view, _, cx| { assert!(view.input_active(), "the local editor owns a fresh prompt"); - view.history_ranked = vec!["git log --oneline".to_string()]; + view.history = vec!["git log --oneline".to_string()]; view.cmd.set("git l"); view.handle_editor_key(&key("ctrl-e"), cx); @@ -14062,7 +14079,7 @@ mod gpui_tests { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - view.history_ranked = vec!["git log --oneline".to_string()]; + view.history = vec!["git log --oneline".to_string()]; view.cmd.set_with_cursor("git l", 2); view.handle_editor_key(&key("ctrl-e"), cx); @@ -16368,6 +16385,181 @@ mod gpui_tests { "a spawned pane counts its first agent, once" ); } + /// Seeds a pane's history for the ghost tests: `(command, dirs, exit)`, + /// oldest first, with `cwd` as the directory the pane is in. + fn seed_ghost_history( + view: &mut TerminalView, + entries: &[(&str, &[&str], Option)], + cwd: Option<&str>, + ) { + view.history = entries.iter().map(|(cmd, _, _)| cmd.to_string()).collect(); + view.history_cwds.clear(); + view.history_meta.clear(); + for (cmd, dirs, exit) in entries { + view.history_cwds.insert( + cmd.to_string(), + dirs.iter().map(|d| d.to_string()).collect(), + ); + view.history_meta.insert( + cmd.to_string(), + crate::terminal::history::EntryMeta { + ts: None, + exit: *exit, + }, + ); + } + view.rerank_history(cwd.map(std::path::Path::new)); + } + + #[gpui::test] + fn ghost_suggests_the_command_just_run_over_a_frequent_one(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + seed_ghost_history( + view, + &[ + ("git checkout main", &["/work/proj"], Some(0)), + ("git commit -m x", &["/work/proj"], Some(0)), + ], + Some("/work/proj"), + ); + view.history_counts + .insert("git checkout main".to_string(), 20); + view.rerank_history(Some(std::path::Path::new("/work/proj"))); + view.cmd.set("git c"); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("git commit -m x"), + "recency decides the ghost, however often the older one ran" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn ghost_prefers_this_directory_then_falls_back_to_anywhere(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + seed_ghost_history( + view, + &[ + ("cargo build", &["/work/proj"], None), + ("cargo test --all", &["/elsewhere"], None), + ("npm run dev", &["/elsewhere"], None), + ], + Some("/work/proj"), + ); + view.cmd.set("cargo "); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("cargo build"), + "an older match run here beats a newer one run elsewhere" + ); + view.cmd.set("npm "); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("npm run dev"), + "with nothing run here, the newest match anywhere is offered" + ); + view.cmd.set("python "); + assert_eq!(view.ghost_suggestion(), None); + }) + .unwrap(); + } + + #[gpui::test] + fn ghost_skips_commands_that_failed(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + seed_ghost_history( + view, + &[ + ("make test", &["/work/proj"], Some(0)), + ("make tset", &["/work/proj"], Some(2)), + ("make lint", &["/work/proj"], Some(1)), + ], + Some("/work/proj"), + ); + view.cmd.set("make t"); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("make test"), + "the newer typo exited 2, so the older success is offered" + ); + view.cmd.set("make l"); + assert_eq!( + view.ghost_suggestion(), + None, + "a command whose only run failed is never offered" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn ghost_names_what_up_arrow_recalls(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + seed_ghost_history( + view, + &[ + ("git status", &[], None), + ("git log --oneline", &[], None), + ("ls", &[], None), + ("git push", &[], None), + ("echo hi", &[], None), + ], + None, + ); + view.history_counts.insert("git status".to_string(), 50); + view.rerank_history(None); + for typed in ["git", "git l", "e", "l"] { + view.history_nav = None; + view.cmd.set(typed); + let ghost = view.ghost_suggestion(); + view.handle_editor_key(&key("up"), cx); + assert_eq!( + ghost.as_deref(), + Some(view.cmd.text().as_str()), + "ghost and ↑ disagree on {typed:?}" + ); + } + }) + .unwrap(); + } + + #[gpui::test] + fn submitting_a_command_again_moves_it_to_the_newest_entry(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + prompt_ready(&window, cx, &mut daemon); + + window + .update(cx, |view, _, cx| { + view.history = ["git status", "ls", "make"] + .into_iter() + .map(String::from) + .collect(); + view.cmd.set("git status"); + view.submit_command(cx); + assert_eq!( + view.history, + ["ls", "make", "git status"], + "one entry per command, at its latest run, as a reload leaves it" + ); + + view.cmd.set(""); + view.handle_editor_key(&key("up"), cx); + assert_eq!(view.cmd.text(), "git status"); + view.handle_editor_key(&key("up"), cx); + assert_eq!(view.cmd.text(), "make", "↑ never lands on a command twice"); + }) + .unwrap(); + } } /// The window between the shell reporting a prompt and its line editor From b5cb6efe7a23b7678f6dfe193e2f3389a08490b0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:49 +0800 Subject: [PATCH 5/7] =?UTF-8?q?style(sidebar):=20mark=20pinned=20groups=20?= =?UTF-8?q?with=20=E2=97=86=20instead=20of=20a=20pin=20icon,=20drop=20the?= =?UTF-8?q?=20divider=20(#972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin icon read as a smudge at header size. Kept groups now carry a small ◆ beside their name — every kept group, label groups too, since with the divider gone it is the one thing that tells kept from derived. On a folder group the mark is still the click that unpins it, and the hover button that pins an auto group shows the same character. The divider is no longer drawn at rest. It keeps its (smaller) place in the layout and its recorded bounds, so pinning a header by dragging it up and handing a tab back by dropping it below behave exactly as before, and it still shows as a line while it is the drop target. --- CHANGELOG.md | 36 ++++++------ assets/icons/pin.svg | 1 - docs/window/sidebar.mdx | 22 ++++---- src/ui/assets.rs | 1 - src/ui/tab_sidebar.rs | 119 ++++++++++++++++++++++++++-------------- 5 files changed, 108 insertions(+), 71 deletions(-) delete mode 100644 assets/icons/pin.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8501ee..256b157c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,24 +88,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 command once, the same as after a restart. - **The sidebar groups tabs by repo automatically; pin what you want to keep** - (#955). Groups now come in two halves, split by a divider. Below it, every - tab you have not pinned is filed under its git repository, and an SSH tab - under the host it is on rather than its remote path — `/home/ubuntu` on two - machines used to share a header. Above it sit the groups you keep, in the - order you drag them into, until you delete them. A pinned group can keep a - folder: a tab whose working directory enters it joins it (the deepest folder - wins when they nest, and a worktree of a pinned repo counts), while a tab you - drag out stays out until it leaves the folder and comes back. Pin an auto - group with the pin on its header or by dragging the header above the - divider; pin a folder by dropping it from Finder, with **Pin as Group** in the - Files panel, or with **Open Folder as Group…** in the palette. **New Group** - in the palette or on a tab's right-click makes a label group. Deleting a - group closes nothing — its tabs go back to auto grouping — and dragging a tab - below the divider does the same for one tab. Groups, their order and which - are folded are stored with the workspace, so every window onto it agrees. - **Settings → Window & Tabs → Auto grouping** replaces the three-way *Sidebar - grouping* choice; off, unpinned tabs sit in one flat list under the pinned - groups. Scratch is now **Ungrouped**. + (#955). Groups now come in two halves. Below, every tab you have not pinned is + filed under its git repository, and an SSH tab under the host it is on rather + than its remote path — `/home/ubuntu` on two machines used to share a header. + Above sit the groups you keep, each marked ◆ beside its name, in the order you + drag them into, until you delete them. A pinned group can keep a folder: a tab + whose working directory enters it joins it (the deepest folder wins when they + nest, and a worktree of a pinned repo counts), while a tab you drag out stays + out until it leaves the folder and comes back. Pin an auto group with the ◆ on + its header or by dragging the header up among the pinned ones; pin a folder by + dropping it from Finder, with **Pin as Group** in the Files panel, or with + **Open Folder as Group…** in the palette. **New Group** in the palette or on a + tab's right-click makes a label group. Deleting a group closes nothing — its + tabs go back to auto grouping — and dragging a tab below the pinned groups + does the same for one tab. Groups, their order and which are folded are stored + with the workspace, so every window onto it agrees. **Settings → Window & Tabs + → Auto grouping** replaces the three-way *Sidebar grouping* choice; off, + unpinned tabs sit in one flat list under the pinned groups. Scratch is now + **Ungrouped**. - **The New Tab menu names three shells, not every one the machine has.** A stock macOS box reports nine, so the `+` menu opened on a column of `csh`, diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg deleted file mode 100644 index 05568e0d..00000000 --- a/assets/icons/pin.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index a5c32cd5..e53b214d 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -18,17 +18,19 @@ is doing. The sidebar groups tabs by repository automatically. Pin what you want to keep. -**Auto groups** sit below a divider. Every tab you have not pinned is filed -under the git repository its working directory is in — a linked worktree under -the repository it belongs to, a submodule under itself — and an SSH tab under -the host it is connected to, so `/home/ubuntu` on two machines is two groups. +**Auto groups** sit below the pinned ones. Every tab you have not pinned is +filed under the git repository its working directory is in — a linked worktree +under the repository it belongs to, a submodule under itself — and an SSH tab +under the host it is connected to, so `/home/ubuntu` on two machines is two +groups. Everything else collects in a trailing **Ungrouped** section. An auto group follows the tab's working directory, not its history: `cd` into another repository and the row moves; switching branches never does. When its last tab leaves, the group is gone. -**Pinned groups** sit above the divider, in the order you put them, and stay -until you delete them — an empty one keeps its place with a **+ New Tab** row. +**Pinned groups** sit at the top, each marked ◆ beside its name, in the order +you put them, and stay until you delete them — an empty one keeps its place +with a **+ New Tab** row. A tab in a pinned group never leaves it on its own. There are two kinds: - A **folder group** keeps a directory. A tab whose working directory *enters* @@ -36,7 +38,7 @@ A tab in a pinned group never leaves it on its own. There are two kinds: repository the folder is. When folders nest, the deepest one wins, so pinning a monorepo and one package in it files the package's tabs under the package. A tab you drag out while it is still inside stays out until it leaves the - folder and comes back. Hover the header for the folder's path; click its pin + folder and comes back. Hover the header for the folder's path; click its ◆ to unpin it, and its tabs go back to auto groups. - A **label group** is just a name, for tabs that belong together for a reason no directory shows. Tabs go in and out of it by hand. @@ -45,7 +47,7 @@ Ways to pin: | To get | Do this | |---|---| -| A folder group from an auto group | Click the pin on its header, or drag the header above the divider | +| A folder group from an auto group | Click the ◆ on its header, or drag the header up among the pinned groups | | A folder group from a folder | Drop it from Finder onto the sidebar, choose **Pin as Group** on it in the Files panel, or run **Open Folder as Group…** from the command palette | | A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from the command palette | @@ -92,8 +94,8 @@ they simply stop opening the overlay. Drag a row to reorder it within its group, or drag a group header to move the group — pinned groups among pinned groups, auto groups among auto groups. Drag -a row onto a pinned group to put it there, or anywhere below the divider to -hand it back to auto grouping. A row cannot be dragged into an auto group: its +a row onto a pinned group to put it there, or anywhere below the pinned groups +to hand it back to auto grouping. A row cannot be dragged into an auto group: its membership comes from the working directory, so `cd` is what moves it. Drag a row out over the panes instead and it stops being a session of its own: diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 73ffca40..1731232b 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -34,7 +34,6 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), - "icons/pin.svg" => include_bytes!("../../assets/icons/pin.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), "icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"), "icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index e5274f79..4bf498fc 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -34,6 +34,17 @@ const GRAB_HANDLE_W: f32 = 48.; const ROW_GAP: f32 = 2.; +/// What marks a kept group, and what the header button that keeps one reads. +/// +/// A character rather than an icon: at a header's size a drawn pin is a +/// smudge, and the mark only has to say "kept" beside the name, the way a +/// bullet does. With no line between kept and derived groups it is also the +/// only thing that tells the two apart, so every kept group carries it. +const PIN_MARK: &str = "\u{25C6}"; + +/// The mark's size: small enough to sit under the header text's x-height. +const PIN_MARK_SIZE: f32 = 8.; + /// A single-line tab row: one line of `text_sm` and a little air, the same /// 28px the search field and the workspace chip above it stand at. const ROW_HEIGHT: f32 = 28.; @@ -1283,37 +1294,39 @@ impl Tty7App { .child(Icon::new(IconName::ChevronRight).xsmall()), ) }) - // The pin marks a folder group: kept, and keeping a folder. - // It is also the way to stop keeping it — a click unpins, - // and the folder's tabs fall back to the groups their cwds - // resolve to. A label group has no such mark: it sits above - // the divider, and there is no folder to fall back on. - .when_some( - pinned_id.filter(|_| pinned_folder.is_some()), - |header, id| { - header.child( - div() - .id(("sidebar-group-unpin", group_ix)) - .flex_shrink_0() - .cursor_pointer() - .hover(|s| s.text_color(cx.theme().foreground)) - .child(Icon::empty().path("icons/pin.svg").xsmall()) - .tooltip(|window, cx| { - gpui_component::tooltip::Tooltip::new(t( - L10nKey::SidebarUnpinGroup, - )) - .build(window, cx) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation() - }) - .on_click(cx.listener(move |this, _, _window, cx| { - cx.stop_propagation(); - this.delete_group(id, cx); - })), - ) - }, - ) + // Every kept group carries the mark; it is what sets them + // apart from the derived groups below. On a folder group it + // is also the way to stop keeping it — a click unpins, and + // the folder's tabs fall back to the groups their cwds + // resolve to. A label group's mark is only a mark: without a + // folder there is nothing to fall back on, so letting go of + // it is Delete Group, in its menu, not a stray click. + .when_some(pinned_id, |header, id| { + let mark = div() + .flex_shrink_0() + .text_size(px(PIN_MARK_SIZE)) + .line_height(px(PIN_MARK_SIZE)) + .child(PIN_MARK); + header.child(match pinned_folder.is_some() { + false => mark.into_any_element(), + true => mark + .id(("sidebar-group-unpin", group_ix)) + .cursor_pointer() + .hover(|s| s.text_color(cx.theme().foreground)) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t( + L10nKey::SidebarUnpinGroup, + )) + .build(window, cx) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.delete_group(id, cx); + })) + .into_any_element(), + }) + }) .child(match renaming_group { Some(input) => div() .id(("sidebar-group-rename", group_ix)) @@ -1823,8 +1836,14 @@ impl Tty7App { .child(handle) } - /// The line between the kept groups and the derived ones, recording where - /// it was drawn so a drag next frame can tell which side it is on. + /// The boundary between the kept groups and the derived ones, recording + /// where it lies so a drag next frame can tell which side it is on. + /// + /// Not drawn at rest — the mark on each kept header already says which + /// side a group is on, and a hairline across the list was one more rule + /// for the eye to cross. It keeps its place in the layout all the same, so + /// nothing jumps when a drag starts, and shows as a line only while it is + /// the target: when letting go would pin a header or hand a tab back. /// /// With nothing pinned yet it only appears while an auto header is being /// carried, and then as a drop zone rather than a hairline: a one-pixel @@ -1833,9 +1852,10 @@ impl Tty7App { /// back to auto grouping. fn sidebar_divider(&self, lit: bool, zone: bool, cx: &Context) -> AnyElement { let bounds = self.sidebar_divider.clone(); - let ink = match lit { - true => cx.theme().drag_border, - false => cx.theme().sidebar_border, + let ink = match (lit, zone) { + (true, _) => cx.theme().drag_border, + (false, true) => cx.theme().sidebar_border, + (false, false) => gpui::transparent_black(), }; let body = match zone { true => h_flex() @@ -1862,7 +1882,9 @@ impl Tty7App { .relative() .w_full() .px_2() - .pt(px(10.)) + // Less than when it was a drawn rule: the next header's own top + // margin already opens the gap between the two kinds of group. + .pt(px(4.)) .pb(px(2.)) .child( canvas(move |b, _window, _cx| bounds.set(Some(b)), |_, _, _, _| {}) @@ -1895,7 +1917,7 @@ impl Tty7App { fade.a = 0.; let muted = cx.theme().muted_foreground; let ink = cx.theme().foreground; - let button = |id: &'static str, path: &'static str, tip: L10nKey| { + let button = |id: &'static str, glyph: AnyElement, tip: L10nKey| { div() .id((id, group_ix)) .flex_shrink_0() @@ -1904,7 +1926,7 @@ impl Tty7App { .cursor_pointer() .text_color(muted) .hover(move |s| s.text_color(ink)) - .child(Icon::empty().path(path).xsmall()) + .child(glyph) .tooltip(move |window, cx| { gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx) }) @@ -1914,7 +1936,19 @@ impl Tty7App { buttons = buttons.child( button( "sidebar-group-pin", - "icons/pin.svg", + // The mark the group will carry once kept, the size of + // the icon beside it so the two buttons line up. + div() + .size(px(12.)) + .flex() + .items_center() + .justify_center() + .text_size(px(PIN_MARK_SIZE)) + // Its own line box: the header's is several times the + // mark's size and would drop it below the "+" beside it. + .line_height(px(PIN_MARK_SIZE)) + .child(PIN_MARK) + .into_any_element(), L10nKey::SidebarPinGroup, ) .on_mouse_down( @@ -1930,7 +1964,10 @@ impl Tty7App { buttons = buttons.child( button( "sidebar-group-add", - "icons/plus.svg", + Icon::empty() + .path("icons/plus.svg") + .xsmall() + .into_any_element(), L10nKey::SidebarGroupNewTab, ) .on_mouse_down( From 334734b0b62f5fd4087313d4babce132ef37c69c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:58 +0800 Subject: [PATCH 6/7] feat(links): make Windows file paths clickable (#971) * feat(links): make Windows file paths clickable Drive-letter paths with either separator and UNC shares resolve as file links, including when CJK prose or a Markdown link is glued onto them. Windows paths are spelled with backslashes and an upper-case drive so Explorer can open and reveal them, the Windows join is textual so a Mac asking a Windows host sends one spelling, and a POSIX pane looks a drive path up under /mnt/ for WSL. Drive-relative tokens (a:b, C:, C:notes.txt) are never probed. Closes #965 * feat(links): quoted paths with spaces and Open with Default App A path enclosed in matching double quotes, single quotes or backticks is read as one candidate even when it contains spaces, with a line:column location inside the quotes or right after the closing one. The quoted span must look like a path (a separator, no space at either end, at most 260 chars) and yields a single reading, so quoted prose costs one lookup. Unquoted spaces still end a path. The file-link context menu gains Open with Default App, which always uses the OS association. It is shown only for local files and hidden when link_file_open is already system. The OS opener and Reveal now spell Windows paths with backslashes before handing them to Explorer. --- CHANGELOG.md | 23 ++ src/core/actions.rs | 1 + src/terminal/search.rs | 731 +++++++++++++++++++++++++++++++++++++++-- src/terminal/view.rs | 70 +++- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + 8 files changed, 805 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 256b157c..8d8775bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pane in front as before, and an ended session still says so. The key is `ssh_tab_title` (`dynamic`, `profile-name`, `hostname`). +- **Windows file paths are links** (#965). `C:\Users\me\a.png`, + `c:/Users/me/a.png` and `\\server\share\a.png` underline and open like any + other file path, with a `:10:2`, `(10,2)` or `#L10` location kept. The path + is found when Chinese prose is glued straight onto it + (`图片已保存到:c:/Users/me/a.png`) and inside a Markdown link + (`![chart](c:/out/chart.png)`), and is handed on with backslashes so + Explorer opens and reveals it — a forward-slashed path used to open + Documents instead. In a WSL pane a drive path is looked up under + `/mnt/`. A letter and a colon alone (`a:b`, `C:`, `C:notes.txt`) is + never read as a drive. + +- **A quoted path may contain spaces** (#965). `"C:\Program Files\app\app.exe"`, + `'/Users/me/My Docs/a.txt'` and the same in backticks are one link, with a + `:10:2` or `(10,2)` location read inside the quotes or just after the + closing one. Only quotes do this: an unquoted space still ends a path, and + quoted prose that is not written like a path — no separator, a space at + either end, over 260 characters — is left alone. + +- **"Open with Default App" in a file link's right-click menu** (#965). It + hands the file to whatever the OS has it associated with, whatever + `link_file_open` makes "Open" do. Shown for files on this machine only, and + not when "Open" already uses the system opener. + ### Changed - **The command-line ghost suggests what you ran last, not what you ran diff --git a/src/core/actions.rs b/src/core/actions.rs index 201edf58..b01cf6d0 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -27,6 +27,7 @@ actions!( CloseTabsToTheRight, CopyWorkingDirectory, OpenLinkUnderPointer, + OpenLinkWithDefaultApp, RevealLinkUnderPointer, CopyLinkPathUnderPointer, MarkTabUnread, diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 26e1f1ee..0be17715 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -22,6 +22,10 @@ const MAX_MATCHES: usize = 10_000; /// from turning a hover into a burst of filesystem calls. const MAX_FILE_CANDIDATES: usize = 8; +/// The longest quoted span read as one path with spaces in it. Windows' +/// classic `MAX_PATH`: past this a quote is holding a sentence, not a path. +const MAX_QUOTED_PATH: usize = 260; + /// How long a printing pane has to stay quiet before an open search bar /// rescans it. Short enough that a command's output is re-counted by the time /// the eye gets back to the bar, long enough that a flood costs one scan per @@ -794,35 +798,139 @@ impl PathStyle { // A UNC share, or a drive letter with a separator behind it. // `C:foo` is drive-*relative* and deliberately not included, which // is what `Path::is_absolute` says on Windows too. - PathStyle::Windows => { - if path.starts_with("\\\\") { - return true; - } - let mut chars = path.chars(); - chars.next().is_some_and(|c| c.is_ascii_alphabetic()) - && chars.next() == Some(':') - && matches!(chars.next(), Some('\\' | '/')) - } + PathStyle::Windows => path.starts_with("\\\\") || drive_root(path).is_some(), } } + /// The one path a token that names its own root stands for, spelled the + /// way the pane's host can look it up — `None` for a token that has to be + /// measured from somewhere. + /// + /// A Windows pane gets its path back with every separator a `\` and the + /// drive letter upper-cased. `c:/Users/me/a.png` is what Node prints on + /// Windows, and while the filesystem takes either slash, Explorer does + /// not: handed a forward-slashed path to open or `/select,` it shows + /// Documents and reports success. + /// + /// A POSIX pane has no drives, but it may well be a WSL distro looking at + /// the Windows machine it runs on — a Windows tool run through interop, a + /// `wslpath -w`, a build log from the other side. There `C:\Users` is + /// `/mnt/c/Users`, and that is what the host is asked about. Anywhere else + /// the mount is not there and the probe simply misses, so nothing + /// underlines; that beats measuring `C:\Users` from the pane's directory, + /// which could only ever be nonsense. + pub fn rooted(self, path: &str) -> Option { + match self { + PathStyle::Posix if path.starts_with('/') => Some(path.to_string()), + PathStyle::Posix => { + let (drive, rest) = drive_root(path)?; + let rest = rest.replace('\\', "/"); + Some(format!( + "/mnt/{}/{}", + drive.to_ascii_lowercase(), + rest.trim_start_matches('/') + )) + } + PathStyle::Windows => self.is_absolute(path).then(|| windows_spelling(path)), + } + } + + /// Whether a token only means something against the current directory of + /// one particular drive: `C:notes.txt`, or `C:` on its own. + /// + /// There is no such thing as "the current directory of drive C" for a + /// pane — only for a process — so the one answer that would exist is tty7's + /// own, which has nothing to do with what the pane printed. And a bare + /// letter and a colon is far more often prose than a path: `a:b`, `x:y`. + fn is_drive_relative(self, path: &str) -> bool { + let mut chars = path.chars(); + self == PathStyle::Windows + && chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.next() == Some(':') + && !matches!(chars.next(), Some('\\' | '/')) + } + /// `rel` measured from `root`, spelled the way the pane's host spells it. /// - /// The POSIX arm joins textually because `Path::join` would reach for this + /// Both arms join textually because `Path::join` would reach for this /// machine's separator: on Windows it turns `/home/u` and `src/lib.rs` /// into `/home/u\src/lib.rs`, which the Linux box on the other end of the - /// probe cannot stat. + /// probe cannot stat, and on a Mac it turns `C:\proj` and `src\lib.rs` + /// into `C:\proj/src\lib.rs` for a Windows host. + /// + /// A Windows `rel` that starts with a separator is rooted on the root's + /// own drive (or share), the way `cd \etc` is in a `cmd.exe` sitting on C:. pub fn join(self, root: &Path, rel: &str) -> PathBuf { + let root = root.to_string_lossy(); match self { - PathStyle::Posix => PathBuf::from(format!( - "{}/{rel}", - root.to_string_lossy().trim_end_matches('/') - )), - PathStyle::Windows => root.join(rel), + PathStyle::Posix => PathBuf::from(format!("{}/{rel}", root.trim_end_matches('/'))), + PathStyle::Windows => { + let joined = match rel.starts_with(['\\', '/']) { + true => format!("{}{rel}", windows_prefix(&root)), + false => format!("{}\\{rel}", root.trim_end_matches(['\\', '/'])), + }; + PathBuf::from(windows_spelling(&joined)) + } } } } +/// The drive letter a path starts with, and everything after the colon — only +/// when a separator follows it, which is what makes it absolute. `C:\x` and +/// `c:/x` are; `C:x`, `C:` and `12:30` are not. +fn drive_root(path: &str) -> Option<(char, &str)> { + let mut chars = path.chars(); + let drive = chars.next().filter(char::is_ascii_alphabetic)?; + let rest = path.strip_prefix(drive)?.strip_prefix(':')?; + rest.starts_with(['\\', '/']).then_some((drive, rest)) +} + +/// The part of a Windows path that names its drive or share: `C:` out of +/// `C:\proj`, `\\server\share` out of `\\server\share\proj`. Empty when there +/// is none to take. +fn windows_prefix(path: &str) -> &str { + if drive_root(path).is_some() || path.len() == 2 && path.ends_with(':') { + return &path[..2]; + } + if path.starts_with("\\\\") || path.starts_with("//") { + // Past the two leading separators, the server, and the share. + let mut seps = path + .char_indices() + .skip(2) + .filter(|&(_, c)| matches!(c, '\\' | '/')); + seps.next(); + return match seps.next() { + Some((end, _)) => &path[..end], + None => path, + }; + } + "" +} + +/// `path` spelled the way `style`'s own tools want it — for handing a path to +/// something outside tty7, like the OS opener. Only a Windows spelling +/// changes, and only a path that is valid UTF-8: rewriting anything else +/// through a lossy string would name a different file. +pub(super) fn spelled_for(style: PathStyle, path: &Path) -> PathBuf { + match (style, path.to_str()) { + (PathStyle::Windows, Some(text)) => PathBuf::from(windows_spelling(text)), + _ => path.to_path_buf(), + } +} + +/// A Windows path the way Windows itself writes one: `\` throughout and an +/// upper-case drive letter. Every Windows API takes `/` too, but Explorer — +/// the system opener and the "Reveal" menu item — does not. +fn windows_spelling(path: &str) -> String { + let mut out = path.replace('/', "\\"); + if let Some((drive, _)) = drive_root(&out) + && drive.is_ascii_lowercase() + { + out.replace_range(..1, &drive.to_ascii_uppercase().to_string()); + } + out +} + /// Where a relative path printed by a pane is measured from. /// /// `local_home` is the part that is easy to miss. `~` has to become a real @@ -955,11 +1063,11 @@ impl FileCandidate { let Some(expanded) = expand_home(&self.path, roots) else { return Vec::new(); }; - if expanded.is_empty() { + if expanded.is_empty() || roots.style.is_drive_relative(&expanded) { return Vec::new(); } - if roots.style.is_absolute(&expanded) { - return vec![PathBuf::from(expanded)]; + if let Some(rooted) = roots.style.rooted(&expanded) { + return vec![PathBuf::from(rooted)]; } let mut out: Vec = Vec::new(); for root in &roots.dirs { @@ -981,7 +1089,7 @@ impl FileCandidate { /// `/etc/hosts` printed by a `cmd.exe` pane is not, and `Path` alone /// cannot tell those apart. pub fn is_rooted(&self, style: PathStyle) -> bool { - self.path.starts_with('~') || style.is_absolute(&self.path) + self.path.starts_with('~') || style.rooted(&self.path).is_some() } /// Whether the token is written enough like a path to be worth telling the @@ -990,11 +1098,12 @@ impl FileCandidate { /// /// A backslash counts only where it separates directories. In a POSIX /// pane it is an escape or an ordinary filename character, so `foo\ bar` - /// there is a word, not a path. + /// there is a word, not a path. A drive letter is one in any pane. pub fn looks_like_a_path(&self, style: PathStyle) -> bool { self.path.starts_with('~') || self.path.contains('/') || (style == PathStyle::Windows && self.path.contains('\\')) + || drive_root(&self.path).is_some() } } @@ -1012,8 +1121,9 @@ impl FileCandidate { /// separator: naming the prefixes costs a handful of readings where /// enumerating pairs costs a probe per pair, on every hover. pub(super) fn file_candidates_at(text: &str, col: usize) -> Vec { + let quoted = quoted_candidates_at(text, col); let Some((base, _, token)) = non_ws_token_at(text, col) else { - return Vec::new(); + return quoted; }; let chars: Vec = token.chars().collect(); let mut out: Vec = Vec::new(); @@ -1030,10 +1140,93 @@ pub(super) fn file_candidates_at(text: &str, col: usize) -> Vec { .cmp(&a.path.chars().count()) .then(a.start.cmp(&b.start)) }); + // A quote is the one place a path is allowed a space, and the writer put + // it there to say where the path starts and stops — so it is asked first. + for (i, candidate) in quoted.into_iter().enumerate() { + out.retain(|c| *c != candidate); + out.insert(i, candidate); + } out.truncate(MAX_FILE_CANDIDATES); out } +/// The path a pair of quotes holds around `col`, when it has a space in it. +/// +/// Whitespace ends a path everywhere else, and guessing where a path with +/// spaces stops in unquoted prose is how `see C:\Program Files for more` +/// would become a link to a folder that happens to exist. A quote is the +/// writer saying where it stops — `"C:\Program Files\app\app.exe"`, +/// `'/Users/me/My Docs/a.txt'` — so that is the only place a space is read as +/// part of a path. +/// +/// What is inside still has to be written like a path: no space at either +/// end, a separator somewhere, and short enough to be one. A quoted sentence +/// that clears all that and still names nothing costs one reading, the same +/// as a single unquoted word — never one per word inside it. +/// +/// A token with no space in it is left to the ordinary rules, which already +/// peel quotes off a path. A location may be written inside the quotes +/// (`"my file.rs:10:2"`) or straight after the closing one +/// (`"my file.rs":10:2`, `"my file.cs"(10,2)`). +fn quoted_candidates_at(text: &str, col: usize) -> Vec { + let chars: Vec = text.chars().collect(); + if col >= chars.len() { + return Vec::new(); + } + let Some((open, close)) = ['"', '\'', '`'] + .into_iter() + .filter_map(|q| { + let open = chars[..col].iter().rposition(|&c| c == q)?; + let close = col + chars[col..].iter().position(|&c| c == q)?; + (close > col).then_some((open, close)) + }) + // The innermost pair: `"it's '/a b/c'"` means the single-quoted one. + .min_by_key(|&(open, close)| close - open) + else { + return Vec::new(); + }; + let inner = &chars[open + 1..close]; + let path_shaped = inner.len() <= MAX_QUOTED_PATH + && inner.iter().any(|c| c.is_whitespace()) + && inner.first().is_some_and(|c| !c.is_whitespace()) + && inner.last().is_some_and(|c| !c.is_whitespace()) + && inner.iter().any(|&c| matches!(c, '/' | '\\')) + && !inner.iter().any(|c| c.is_control()); + if !path_shaped { + return Vec::new(); + } + let written: String = inner.iter().collect(); + let mut location = split_file_location(&written); + if location.line.is_none() { + let after: String = chars[close + 1..] + .iter() + .take_while(|c| !c.is_whitespace()) + .collect(); + let after = after.trim_end_matches([':', ',', ';']); + if !after.is_empty() { + let outside = split_file_location(&format!("{written}{after}")); + if outside.line.is_some() && outside.path == written { + location = outside; + } + } + } + let mut readings = Vec::with_capacity(2); + if location.literal_too && location.line.is_some() && location.path != written { + readings.push((written.clone(), None, None)); + } + readings.push((location.path, location.line, location.column)); + readings + .into_iter() + .map(|(path, line, column)| FileCandidate { + start: open + 1, + end: close - 1, + path, + line, + column, + }) + .collect() +} + /// The best reading of the token under `col`, which is what most of the /// carving tests are about. #[cfg(test)] @@ -1101,6 +1294,24 @@ fn left_cuts(chars: &[char]) -> Vec { if matches!(rest.first(), Some('a' | 'b')) && rest.get(1) == Some(&'/') { offsets.push(2); } + // A Windows drive glued onto whatever came before it: `保存到:C:\out`, + // `图片已生成c:/Users/me/a.png`. CJK prose puts no space in front of a + // path, and the label rule above cannot see these — the only ASCII + // colon in the token is the drive's own. The letter must not be the + // tail of an ASCII word, or `abc:/x` would read as drive `c:`. + if let Some(i) = (1..rest.len()).find(|&i| { + !rest[i - 1].is_ascii_alphanumeric() + && rest[i].is_ascii_alphabetic() + && rest.get(i + 1) == Some(&':') + && matches!(rest.get(i + 2), Some('\\' | '/')) + }) { + offsets.push(i); + } + // A Markdown link's target, which is what an agent prints a file as: + // `![chart](c:/out/chart.png)`, `[notes](docs/notes.md)`. + if let Some(i) = rest.windows(2).position(|w| w == [']', '(']) { + offsets.push(i + 2); + } for offset in offsets { let cut = base + offset; if cut < chars.len() && !cuts.contains(&cut) { @@ -2547,4 +2758,480 @@ mod tests { "the reading behind the `note:` label is still on the ladder" ); } + + /// A Windows pane sitting in `C:\proj`, the way the reporter of #965 saw it. + fn windows_roots() -> LinkRoots { + LinkRoots { + dirs: vec![PathBuf::from(r"C:\proj")], + local_home: false, + style: PathStyle::Windows, + } + } + + /// `link_at` over `line` with the pointer on the first char of `needle`, + /// against a filesystem holding exactly `exists` — and every path it was + /// asked about, so a test can say what was *not* looked up. + fn link_with( + line: &str, + needle: &str, + roots: &LinkRoots, + exists: &str, + ) -> (Option, Vec) { + let byte = line.find(needle).expect("needle in line"); + let col = line[..byte].chars().count(); + let mut asked = Vec::new(); + let mut probe = |path: &Path, _: bool| { + let path = path.to_string_lossy().into_owned(); + let hit = path == exists; + asked.push(path); + match hit { + true => Probe::Hit { is_dir: false }, + false => Probe::Miss, + } + }; + (link_at(line, col, roots, true, &mut probe), asked) + } + + /// The path and span a Windows pane underlines under `needle`. + fn windows_link(line: &str, needle: &str, exists: &str) -> (String, usize, usize) { + let (link, asked) = link_with(line, needle, &windows_roots(), exists); + let link = link.unwrap_or_else(|| panic!("{line:?}: no link, asked {asked:?}")); + match link.target { + LinkTarget::File { path, .. } => { + (path.to_string_lossy().into_owned(), link.start, link.end) + } + LinkTarget::Url(url) => panic!("{line:?}: expected a file link, got {url}"), + } + } + + /// The columns `needle` occupies in `line`, inclusive. + fn span_of(line: &str, needle: &str) -> (usize, usize) { + let byte = line.find(needle).expect("needle in line"); + let start = line[..byte].chars().count(); + (start, start + needle.chars().count() - 1) + } + + /// #965: the path exactly as the reporter pasted it, and its backslashed + /// twin. Either separator names the same file, and the file is handed on + /// spelled the way Explorer will take it. + #[test] + fn a_drive_letter_path_is_a_link_with_either_separator() { + let want = r"C:\Users\xxxxx\images\gen_gptimage2_gen_20260925_220725.png"; + for written in [ + "c:/Users/xxxxx/images/gen_gptimage2_gen_20260925_220725.png", + r"C:\Users\xxxxx\images\gen_gptimage2_gen_20260925_220725.png", + r"C:/Users\xxxxx/images\gen_gptimage2_gen_20260925_220725.png", + ] { + let line = format!("saved {written} ok"); + let (path, start, end) = windows_link(&line, "images", want); + assert_eq!(path, want, "{line:?}"); + assert_eq!((start, end), span_of(&line, written), "{line:?}"); + } + } + + /// Prose and markup around a Windows path is not part of it — the same + /// trimming a POSIX path gets. + #[test] + fn a_drive_letter_path_sheds_the_punctuation_around_it() { + let want = r"C:\out\a.png"; + for (line, path) in [ + ("wrote c:/out/a.png.", "c:/out/a.png"), + (r"wrote C:\out\a.png, then quit", r"C:\out\a.png"), + (r"(see C:\out\a.png)", r"C:\out\a.png"), + (r#"open "C:\out\a.png";"#, r"C:\out\a.png"), + (r"`C:\out\a.png`", r"C:\out\a.png"), + ("「c:/out/a.png」。", "c:/out/a.png"), + ("", "c:/out/a.png"), + ("│ C:/out/a.png │", "C:/out/a.png"), + (r"C:\out\a.png:", r"C:\out\a.png"), + ] { + let (got, start, end) = windows_link(line, "out", want); + assert_eq!(got, want, "{line:?}"); + assert_eq!((start, end), span_of(line, path), "{line:?}"); + } + } + + /// Compilers on Windows write the location onto the path; the drive's own + /// colon must not be mistaken for one. + #[test] + fn a_drive_letter_path_keeps_its_line_and_column() { + let want = r"C:\src\main.rs"; + for (line, loc) in [ + (r"C:\src\main.rs:10:2: error", (Some(10), Some(2))), + ("c:/src/main.rs:10", (Some(10), None)), + (r"C:\src\main.rs(10,2): error C2065", (Some(10), Some(2))), + ("c:/src/main.rs#L10", (Some(10), None)), + (r#"File "C:\src\main.rs", line 7"#, (Some(7), None)), + ] { + let (link, asked) = link_with(line, "src", &windows_roots(), want); + match link.map(|l| l.target) { + Some(LinkTarget::File { + path, + line: l, + column, + .. + }) => { + assert_eq!(path, PathBuf::from(want), "{line:?}"); + assert_eq!((l, column), loc, "{line:?}"); + } + other => panic!("{line:?}: {other:?}, asked {asked:?}"), + } + } + } + + /// Chinese output glues a path straight onto the sentence in front of it, + /// often behind a full-width colon — which is how an agent reports the + /// file it just wrote in #965. The sentence is not part of the path. + #[test] + fn a_drive_letter_path_is_found_inside_the_prose_glued_onto_it() { + let want = r"C:\Users\me\a.png"; + for (line, path) in [ + ("图片已保存到:c:/Users/me/a.png", "c:/Users/me/a.png"), + ("图片已生成c:/Users/me/a.png", "c:/Users/me/a.png"), + (r"路径:C:\Users\me\a.png", r"C:\Users\me\a.png"), + ("--out=c:/Users/me/a.png", "c:/Users/me/a.png"), + ("output:c:/Users/me/a.png", "c:/Users/me/a.png"), + ] { + let (got, start, end) = windows_link(line, "Users", want); + assert_eq!(got, want, "{line:?}"); + assert_eq!((start, end), span_of(line, path), "{line:?}"); + } + } + + /// An agent prints the image it made as Markdown. The target is the link; + /// the brackets and alt text are not. + #[test] + fn a_markdown_link_target_is_the_path() { + let want = r"C:\out\chart.png"; + for line in [ + "![chart](c:/out/chart.png)", + "see [the chart](c:/out/chart.png).", + ] { + let (got, start, end) = windows_link(line, "out", want); + assert_eq!(got, want, "{line:?}"); + assert_eq!((start, end), span_of(line, "c:/out/chart.png"), "{line:?}"); + } + let posix = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; + let line = "[notes](docs/notes.md)"; + let (link, asked) = link_with(line, "docs", &posix, "/home/u/proj/docs/notes.md"); + let link = link.unwrap_or_else(|| panic!("no link, asked {asked:?}")); + assert_eq!((link.start, link.end), span_of(line, "docs/notes.md")); + } + + #[test] + fn a_unc_share_is_a_link_that_stands_alone() { + let want = r"\\server\share\team\a.png"; + let line = r"copied to \\server\share\team\a.png"; + let (got, start, end) = windows_link(line, "server", want); + assert_eq!(got, want); + assert_eq!((start, end), span_of(line, want)); + } + + /// Text that merely has a letter and a colon in it is not a drive path, + /// and must never be looked up as one. + #[test] + fn a_letter_and_a_colon_is_not_a_drive_path() { + for (line, needle) in [ + // Drive-relative, or a bare drive: nothing a pane can resolve. + ("a:b", "a:b"), + ("x:y and more", "x:y"), + ("C:notes.txt", "C:notes"), + ("see C: now", "C:"), + // A time, a ratio, a port. + ("at 12:30 today", "12:30"), + ("ratio 16:9", "16:9"), + ("localhost:8080", "localhost"), + // A word that ends in a letter is not a drive behind it. + ("abc:/x/y", "abc"), + ("http:/x", "http"), + ] { + let (link, asked) = link_with(line, needle, &windows_roots(), ""); + assert_eq!(link, None, "{line:?}"); + for path in &asked { + assert!( + path.starts_with(r"C:\proj\"), + "{line:?} asked about {path:?}, which is not under the pane's directory" + ); + } + } + // The drive-relative ones are not asked about at all. + for line in ["a:b", "C:notes.txt"] { + let (_, asked) = link_with(line, line, &windows_roots(), ""); + assert!(asked.is_empty(), "{line:?} asked about {asked:?}"); + } + // And nothing in `abc:/x/y` is read as drive `c:`. + assert!( + file_candidates_at("abc:/x/y", 0) + .iter() + .all(|c| !c.path.starts_with("c:")), + "{:?}", + file_candidates_at("abc:/x/y", 0) + ); + } + + /// A Windows path that does not exist is not underlined: the probe is + /// the gate, exactly as for a POSIX one. + #[test] + fn a_missing_drive_letter_path_is_not_a_link() { + let (link, asked) = link_with("wrote c:/out/gone.png", "out", &windows_roots(), ""); + assert_eq!(link, None); + assert_eq!(asked, vec![r"C:\out\gone.png".to_string()]); + let candidate = + unresolved_candidate("wrote c:/out/gone.png", 8, PathStyle::Windows).unwrap(); + assert!(candidate.looks_like_a_path(PathStyle::Windows)); + assert!(candidate.is_rooted(PathStyle::Windows)); + } + + /// A POSIX pane has no drives, but a WSL distro can reach the Windows + /// machine it runs on through `/mnt/`, so that is what it is asked. + #[test] + fn a_posix_pane_reads_a_drive_letter_through_the_wsl_mount() { + let roots = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; + let want = "/mnt/c/Users/me/a.png"; + for written in ["c:/Users/me/a.png", r"C:\Users\me\a.png"] { + let line = format!("wrote {written}"); + let (link, asked) = link_with(&line, "Users", &roots, want); + assert_eq!(asked.first().map(String::as_str), Some(want), "{line:?}"); + let link = link.unwrap_or_else(|| panic!("{line:?}: no link")); + assert_eq!((link.start, link.end), span_of(&line, written)); + let candidate = file_candidate_at(&line, 8).unwrap(); + assert!(candidate.is_rooted(PathStyle::Posix), "{line:?}"); + assert!(candidate.looks_like_a_path(PathStyle::Posix), "{line:?}"); + } + // Where there is no such mount the probe misses and nothing underlines. + let (link, _) = link_with("wrote c:/Users/me/a.png", "Users", &roots, ""); + assert_eq!(link, None); + } + + #[test] + fn a_rooted_token_is_spelled_the_way_its_host_looks_it_up() { + let w = PathStyle::Windows; + assert_eq!(w.rooted("c:/Users/me").as_deref(), Some(r"C:\Users\me")); + assert_eq!(w.rooted(r"D:\x/y").as_deref(), Some(r"D:\x\y")); + assert_eq!( + w.rooted(r"\\server\share\x").as_deref(), + Some(r"\\server\share\x") + ); + for relative in ["src/lib.rs", r"src\lib.rs", "C:x", "C:", "/etc/hosts", ""] { + assert_eq!(w.rooted(relative), None, "{relative:?}"); + } + + let p = PathStyle::Posix; + assert_eq!(p.rooted("/etc/hosts").as_deref(), Some("/etc/hosts")); + assert_eq!(p.rooted(r"C:\Users\me").as_deref(), Some("/mnt/c/Users/me")); + assert_eq!(p.rooted("d:/").as_deref(), Some("/mnt/d/")); + for relative in ["src/lib.rs", "C:x", r"\\server\share", "12:30"] { + assert_eq!(p.rooted(relative), None, "{relative:?}"); + } + } + + /// The Windows join is textual so a Mac looking at a Windows host asks it + /// about a path it can stat, and so it can be pinned here at all. + #[test] + fn a_windows_join_spells_the_whole_path_the_windows_way() { + let w = PathStyle::Windows; + let join = + |root: &str, rel: &str| w.join(Path::new(root), rel).to_string_lossy().into_owned(); + assert_eq!(join(r"C:\proj", "src/lib.rs"), r"C:\proj\src\lib.rs"); + assert_eq!(join(r"C:\proj\", r"src\lib.rs"), r"C:\proj\src\lib.rs"); + assert_eq!(join("c:/proj", "a.txt"), r"C:\proj\a.txt"); + assert_eq!(join(r"C:\", "a.txt"), r"C:\a.txt"); + // A leading separator is rooted on the root's own drive or share. + assert_eq!(join(r"C:\proj", "/etc/hosts"), r"C:\etc\hosts"); + assert_eq!(join(r"D:\proj", r"\tmp\x"), r"D:\tmp\x"); + assert_eq!(join(r"\\srv\share\proj", r"\x"), r"\\srv\share\x"); + assert_eq!(join(r"\\srv\share\proj", "x"), r"\\srv\share\proj\x"); + assert_eq!(windows_prefix(r"\\srv\share"), r"\\srv\share"); + assert_eq!(windows_prefix("C:"), "C:"); + assert_eq!(windows_prefix("proj"), ""); + } + + #[test] + fn a_path_handed_to_the_os_is_spelled_its_way() { + assert_eq!( + spelled_for(PathStyle::Windows, Path::new("c:/Users/me/a b.png")), + PathBuf::from(r"C:\Users\me\a b.png") + ); + assert_eq!( + spelled_for(PathStyle::Windows, Path::new(r"\\srv/share/x")), + PathBuf::from(r"\\srv\share\x") + ); + assert_eq!( + spelled_for(PathStyle::Posix, Path::new("/Users/me/a\\b.png")), + PathBuf::from("/Users/me/a\\b.png"), + "a POSIX backslash is a filename character, not a separator" + ); + } + + fn posix_roots() -> LinkRoots { + LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + } + } + + /// The file link under the first char of `needle`, as (path, line, + /// column, start, end) — or `None`, with what was asked about. + fn file_link_with( + line: &str, + needle: &str, + roots: &LinkRoots, + exists: &str, + ) -> Result<(String, Option, Option, usize, usize), Vec> { + let (link, asked) = link_with(line, needle, roots, exists); + match link { + Some(LinkMatch { + start, + end, + target: + LinkTarget::File { + path, line, column, .. + }, + }) => Ok(( + path.to_string_lossy().into_owned(), + line, + column, + start, + end, + )), + _ => Err(asked), + } + } + + /// A quote is where a path is allowed its spaces: every word of it is the + /// same link, and the quotes are not part of it. + #[test] + fn a_quoted_windows_path_keeps_its_spaces() { + let want = r"C:\Program Files\foo\bar.exe"; + for line in [ + r#"run "C:\Program Files\foo\bar.exe" now"#, + r"run 'C:\Program Files\foo\bar.exe' now", + r"run `C:\Program Files\foo\bar.exe` now", + r#"run "c:/Program Files/foo/bar.exe" now"#, + ] { + let written = &line[5..line.len() - 5]; + for needle in ["Program", "Files", "bar.exe"] { + let got = file_link_with(line, needle, &windows_roots(), want); + assert_eq!( + got, + Ok(( + want.to_string(), + None, + None, + span_of(line, written).0, + span_of(line, written).1 + )), + "{line:?} at {needle:?}" + ); + } + } + } + + #[test] + fn a_quoted_posix_path_keeps_its_spaces_and_its_location() { + let roots = posix_roots(); + let docs = "/Users/me/My Docs/a.txt"; + let line = "open '/Users/me/My Docs/a.txt' please"; + let (start, end) = span_of(line, docs); + assert_eq!( + file_link_with(line, "Docs", &roots, docs), + Ok((docs.to_string(), None, None, start, end)) + ); + + let file = "/home/u/proj/src/my file.rs"; + for (line, loc) in [ + ( + r#"error: "src/my file.rs:10:2" failed"#, + (Some(10), Some(2)), + ), + ( + r#"error: "src/my file.rs":10:2: failed"#, + (Some(10), Some(2)), + ), + ( + r#"error: "src/my file.rs"(10,2): failed"#, + (Some(10), Some(2)), + ), + ( + r#"File "src/my file.rs", line 7, in "#, + (Some(7), None), + ), + (r#"see "src/my file.rs"."#, (None, None)), + ] { + let got = file_link_with(line, "file", &roots, file) + .unwrap_or_else(|asked| panic!("{line:?}: no link, asked {asked:?}")); + assert_eq!(got.0, file, "{line:?}"); + assert_eq!((got.1, got.2), loc, "{line:?}"); + let byte = line.find("src/").unwrap(); + assert_eq!( + got.3, + line[..byte].chars().count(), + "{line:?} starts inside the quote" + ); + } + } + + /// Outside a quote a space still ends a path — the part after it is not + /// guessed onto the part before. + #[test] + fn an_unquoted_space_still_ends_a_path() { + let want = r"C:\Program Files\foo\bar.exe"; + let line = r"run C:\Program Files\foo\bar.exe now"; + for needle in ["Program", "Files", "bar.exe"] { + assert!( + file_link_with(line, needle, &windows_roots(), want).is_err(), + "{needle:?}" + ); + } + let docs = "/Users/me/My Docs/a.txt"; + assert!( + file_link_with("open /Users/me/My Docs/a.txt", "Docs", &posix_roots(), docs).is_err() + ); + } + + /// Quotes around ordinary prose are not a path, and one that looks enough + /// like one to be asked about is asked about once. + #[test] + fn a_quote_around_prose_is_not_a_path() { + let spaced = |line: &str, needle: &str| { + let byte = line.find(needle).unwrap(); + file_candidates_at(line, line[..byte].chars().count()) + .into_iter() + .filter(|c| c.path.contains(' ')) + .collect::>() + }; + for (line, needle) in [ + (r#"say "hello world" twice"#, "world"), + (r#"say " /a b/c " twice"#, "b/c"), + (r#"mismatched "/a b/c' here"#, "b/c"), + (r#"a "x" and "y/z w""#, "and"), + ("it's a /tmp/x dir", "tmp"), + ("tab\"/a\tb/c\"", "b/c"), + ] { + assert_eq!(spaced(line, needle), Vec::new(), "{line:?}"); + } + let long = format!("\"/{} x/y\"", "a".repeat(MAX_QUOTED_PATH)); + assert_eq!(spaced(&long, "x/y"), Vec::new(), "past the cap"); + let at_cap = format!("\"/{} x/y\"", "a".repeat(MAX_QUOTED_PATH - 6)); + assert_eq!(spaced(&at_cap, "x/y").len(), 1, "at the cap"); + + // A quoted sentence that clears the shape check and names nothing + // is one question, not one per word. + let line = r#"note "see the docs/ folder and the src/ tree" ok"#; + for needle in ["see", "folder", "tree"] { + let (link, asked) = link_with(line, needle, &posix_roots(), ""); + assert_eq!(link, None); + let spaced: Vec<_> = asked.iter().filter(|p| p.contains(' ')).collect(); + assert_eq!(spaced.len(), 1, "{needle:?} asked {asked:?}"); + } + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d4924e8b..5d6a4ecd 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, - OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab, - SplitDown, SplitRight, ToggleMaximizePane, + OpenLinkUnderPointer, OpenLinkWithDefaultApp, RevealLinkUnderPointer, SaveAgentLaunchArgs, + SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; use crate::core::shell_quote::quote_for_shell; @@ -5992,6 +5992,22 @@ impl TerminalView { self.open_file_link(path, line, column, is_dir, window, cx); } + /// "Open with Default App": the file link under the menu, handed to the + /// OS association whatever `link_file_open` says "Open" does. + fn open_menu_link_with_default_app(&mut self, window: &mut Window, cx: &mut Context) { + // The menu only offers this for a local pane; a key binding could + // still fire it on a remote one, where the path means nothing here. + if !self.host_id.is_local() { + return; + } + let Some(path) = self.menu_link_path().map(std::path::Path::to_path_buf) else { + return; + }; + if let Err(e) = open_file_path(&path) { + self.warn_file_open_failed(&path, &e, window, cx); + } + } + fn reveal_menu_link(&mut self, window: &mut Window, cx: &mut Context) { let Some(path) = self.menu_link_path().map(std::path::Path::to_path_buf) else { return; @@ -7270,6 +7286,9 @@ impl Render for TerminalView { .on_action(cx.listener(|this, _: &OpenLinkUnderPointer, window, cx| { this.open_menu_link(window, cx); })) + .on_action(cx.listener(|this, _: &OpenLinkWithDefaultApp, window, cx| { + this.open_menu_link_with_default_app(window, cx); + })) .on_action(cx.listener(|this, _: &RevealLinkUnderPointer, window, cx| { this.reveal_menu_link(window, cx); })) @@ -7317,10 +7336,18 @@ impl Render for TerminalView { false => L10nKey::AppMenuRevealInFolder, }; let label = link_menu_label(path); + let default_app = + offers_default_app_open(local, cx.global::().file_open_mode()); menu.min_w(px(220.)) .action_context(menu_focus.clone()) .label(label) .menu(t(L10nKey::AppMenuOpenLink), Box::new(OpenLinkUnderPointer)) + .when(default_app, |menu| { + menu.menu( + t(L10nKey::AppMenuOpenLinkWithDefaultApp), + Box::new(OpenLinkWithDefaultApp), + ) + }) // A file on another machine has no folder here to // show it in, and naming this one's would show // whatever it happens to keep at that path. @@ -7666,9 +7693,23 @@ fn select_end_copy(enabled: bool, grid: bool, editor: bool) -> SelectEndCopy { } } +/// Whether a file link's menu offers "Open with Default App" beside "Open". +/// +/// Only for a file on this machine: the OS opener is handed a local path, and +/// a remote pane's path names nothing here — or worse, this machine's copy. +/// And not when "Open" already goes to the OS association, where the two rows +/// would do the same thing. +fn offers_default_app_open(host_is_local: bool, open_mode: LinkFileOpen) -> bool { + host_is_local && open_mode != LinkFileOpen::System +} + /// Hands a path to whatever the OS has it associated with. Also the fallback /// for a directory the file tree cannot reach. +/// +/// The path is spelled this OS's way first: Explorer takes `c:/a.png` as a +/// request to show Documents. pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> { + let path = &super::search::spelled_for(super::search::PathStyle::NATIVE, path); let opener = if cfg!(target_os = "macos") { "open" } else if cfg!(windows) { @@ -7686,6 +7727,7 @@ pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> { /// no desktop-neutral Linux equivalent exists, so there the folder is opened /// and the file is left for the eye to find. pub(crate) fn reveal_file_path(path: &std::path::Path) -> std::io::Result<()> { + let path = &super::search::spelled_for(super::search::PathStyle::NATIVE, path); #[cfg(target_os = "macos")] let mut command = { let mut c = std::process::Command::new("open"); @@ -10074,6 +10116,30 @@ mod tests { assert!(highlight_runs("", &[]).is_empty()); } + /// "Open with Default App" sits beside "Open" only where it adds + /// something: on a local file, and when "Open" is not already the OS + /// association. + #[test] + fn the_default_app_row_is_offered_only_where_it_differs_from_open() { + use super::{LinkFileOpen, offers_default_app_open}; + assert!(offers_default_app_open(true, LinkFileOpen::Internal)); + assert!(offers_default_app_open(true, LinkFileOpen::Command)); + assert!( + !offers_default_app_open(true, LinkFileOpen::System), + "it would repeat Open" + ); + for mode in [ + LinkFileOpen::Internal, + LinkFileOpen::System, + LinkFileOpen::Command, + ] { + assert!( + !offers_default_app_open(false, mode), + "a remote file has nothing here for the OS to open ({mode:?})" + ); + } + } + #[test] fn only_a_matching_host_may_answer_for_a_panes_paths() { assert!(cwd_is_on_host(false, true)); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b2e339d7..a2bd10f7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1902,6 +1902,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuZoomPane => "Zoom Pane", L10nKey::AppMenuClearScrollback => "Clear Scrollback", L10nKey::AppMenuOpenLink => "Open", + L10nKey::AppMenuOpenLinkWithDefaultApp => "Open with Default App", L10nKey::AppMenuRevealInFinder => "Show in Finder", L10nKey::AppMenuRevealInFolder => "Show Containing Folder", L10nKey::AppMenuCopyLinkPath => "Copy Path", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0e0ebc46..0a8319b4 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1974,6 +1974,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuZoomPane => "ペインを拡大", L10nKey::AppMenuClearScrollback => "スクロールバックをクリア", L10nKey::AppMenuOpenLink => "開く", + L10nKey::AppMenuOpenLinkWithDefaultApp => "既定のアプリで開く", L10nKey::AppMenuRevealInFinder => "Finder に表示", L10nKey::AppMenuRevealInFolder => "含まれるフォルダーを開く", L10nKey::AppMenuCopyLinkPath => "パスをコピー", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ce929130..1eecd6d1 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1046,6 +1046,7 @@ l10n_keys! { AppMenuZoomPane, AppMenuClearScrollback, AppMenuOpenLink, + AppMenuOpenLinkWithDefaultApp, AppMenuRevealInFinder, AppMenuRevealInFolder, AppMenuCopyLinkPath, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7bfbb78f..55105350 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1793,6 +1793,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuZoomPane => "缩放窗格", L10nKey::AppMenuClearScrollback => "清除回滚内容", L10nKey::AppMenuOpenLink => "打开", + L10nKey::AppMenuOpenLinkWithDefaultApp => "用默认程序打开", L10nKey::AppMenuRevealInFinder => "在访达中显示", L10nKey::AppMenuRevealInFolder => "打开所在文件夹", L10nKey::AppMenuCopyLinkPath => "复制路径", From 39ceb35fdd75bfc4b47f5600f71bddcff9e977b1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:35:18 +0800 Subject: [PATCH 7/7] feat(cursor): give the shell prompt its own cursor shape (#963) A new prompt_cursor_style setting (follow, block, bar, underline) shapes the caret while the shell waits at a prompt, whether tty7's inline editor or the shell's own line editor draws it. `follow`, the default, keeps cursor_style everywhere, so nothing changes until it is set. Any other value leaves cursor_style to the programs the shell runs: bar here with cursor_style block gives kitty and ghostty's bar at the prompt and a block inside a TUI that never sets a shape, such as Claude Code. A vi-mode prompt keeps the shell's own insert/normal shapes. Settings shows both cursor shapes as dropdowns, kept in step with resets, language switches and config edits made outside the window. Closes #958 --- CHANGELOG.md | 9 +++ crates/tty7-core/src/core/config.rs | 50 ++++++++++++ docs/reference/configuration.mdx | 1 + src/terminal/element.rs | 19 ++++- src/terminal/view.rs | 74 +++++++++++++++++- src/ui/app.rs | 117 ++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 8 ++ src/ui/i18n/ja.rs | 8 ++ src/ui/i18n/mod.rs | 4 + src/ui/i18n/zh.rs | 8 ++ src/ui/settings.rs | 89 +++++++++++++++------ 11 files changed, 361 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8775bb..01682019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tree is remembered in the config. Both are built from the status the host already sends, so a remote repository gets them unchanged. Searching the diff text itself is not part of this. +- **The cursor at the shell prompt can have its own shape** (#958). A new + `prompt_cursor_style` setting (Settings → Appearance → Cursor) takes + `follow`, `block`, `bar` or `underline`. `follow`, the default, keeps + `cursor_style` everywhere, exactly as before. Any other value is used at the + prompt, drawn by tty7's inline editor or by the shell's own line editor, and + leaves `cursor_style` to the programs the shell runs — so `bar` with + `cursor_style: "block"` gives kitty and ghostty's bar-at-the-prompt, block in + a TUI that never sets a shape itself, such as Claude Code. A shell prompt in + vi mode keeps its own insert/normal shapes. ### Removed diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 5c6b9a18..37dc6c04 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -331,6 +331,13 @@ pub struct Config { #[serde(default, deserialize_with = "de_lenient")] pub cursor_style: CursorStyle, + /// The caret's shape while the shell waits at a prompt, whether tty7's + /// inline editor or the shell's own line editor draws it. `follow` keeps + /// `cursor_style` everywhere; any other value leaves `cursor_style` to the + /// programs the shell runs, which is how kitty and ghostty put a bar at + /// the prompt and a block inside a TUI that never sets a shape itself. + #[serde(default, deserialize_with = "de_lenient")] + pub prompt_cursor_style: PromptCursorStyle, pub macos_option_as_alt: bool, pub mouse_hide_while_typing: bool, @@ -536,6 +543,28 @@ pub enum CursorStyle { Underline, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PromptCursorStyle { + #[default] + Follow, + Block, + Bar, + Underline, +} + +impl PromptCursorStyle { + /// The shape this names, or `None` when the prompt follows `cursor_style`. + pub fn shape(self) -> Option { + match self { + Self::Follow => None, + Self::Block => Some(CursorStyle::Block), + Self::Bar => Some(CursorStyle::Bar), + Self::Underline => Some(CursorStyle::Underline), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NewTabPosition { @@ -763,6 +792,7 @@ impl Default for Config { tab_completion: true, history_search: true, cursor_style: CursorStyle::Block, + prompt_cursor_style: PromptCursorStyle::Follow, macos_option_as_alt: false, mouse_hide_while_typing: true, focus_follows_mouse: false, @@ -3057,4 +3087,24 @@ mod tests { assert_eq!(back.ssh_tab_title, mode); } } + + #[test] + fn prompt_cursor_style_defaults_to_following_cursor_style() { + assert_eq!( + Config::default().prompt_cursor_style, + PromptCursorStyle::Follow + ); + assert_eq!(PromptCursorStyle::Follow.shape(), None); + + let cfg: Config = + serde_json::from_str(r#"{"cursor_style": "block", "prompt_cursor_style": "bar"}"#) + .unwrap(); + assert_eq!(cfg.cursor_style, CursorStyle::Block); + assert_eq!(cfg.prompt_cursor_style.shape(), Some(CursorStyle::Bar)); + + // A value this build does not know falls back rather than failing the + // whole config. + let cfg: Config = serde_json::from_str(r#"{"prompt_cursor_style": "beam"}"#).unwrap(); + assert_eq!(cfg.prompt_cursor_style, PromptCursorStyle::Follow); + } } diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index f7af4865..5fa308eb 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -118,6 +118,7 @@ their id from the file name. [More about themes →](/customization/themes) | `env` | object | `{}` | Extra environment variables for every pane. | | `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | | `cursor_style` | enum | `"block"` | `block`, `bar`, `underline`. | +| `prompt_cursor_style` | enum | `"follow"` | The cursor at the shell prompt: `follow`, `block`, `bar`, `underline`. `follow` uses `cursor_style` everywhere; any other value leaves `cursor_style` to the programs the shell runs, e.g. `bar` here with `cursor_style: "block"` gives a bar at the prompt and a block in a TUI that sets no shape of its own. A shell prompt in vi mode keeps its own shapes. | | `cursor_blink` | bool | `true` | | | `bell` | enum | `"visual"` | `none`, `visual`, `audible`, `both`. | | `per_pane_history` | bool | `false` | Give each pane its own shell history file. | diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 68f810a1..00c02980 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use alacritty_terminal::grid::Dimensions as _; use alacritty_terminal::index::{Column as AlacColumn, Line as AlacLine, Point as AlacPoint}; use alacritty_terminal::selection::SelectionRange; +use alacritty_terminal::term::TermMode; use alacritty_terminal::term::cell::{Cell, Flags}; use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb}; use gpui::{ @@ -1817,6 +1818,17 @@ impl GridSnapshot { } impl TerminalElement { + /// The shape `prompt_cursor_style` puts on the grid cursor while the shell + /// waits at a prompt with its own line editor — `prompt_editor` off, or a + /// line handed back to ZLE/readline. Shell integration has no mark for "a + /// prompt plugin set this shape", so the one it does report, vi mode, keeps + /// the shell's own insert/normal shapes. + pub(super) fn prompt_cursor_shape(&self, cx: &App) -> Option { + let shape = cx.global::().prompt_cursor_style.shape()?; + let terminal = &self.view.read(cx).terminal; + (terminal.at_prompt() && !terminal.shell_vi_mode()).then_some(shape) + } + pub(super) fn build_grid( &self, colors: &PaintColors, @@ -1834,6 +1846,7 @@ impl TerminalElement { let mut any_selected = false; let display_offset; let history_size; + let prompt_shape = self.prompt_cursor_shape(cx); { let mut palette = self.view.read(cx).terminal.palette; if let Some(active) = cx.try_global::() { @@ -1949,7 +1962,11 @@ impl TerminalElement { col, ime_col, hidden: cursor_hidden, - style: cursor_style_from_shape(cur.shape), + // Checked under the grid lock: `on_alt_screen` would take + // it a second time. + style: prompt_shape + .filter(|_| !term.mode().contains(TermMode::ALT_SCREEN)) + .unwrap_or_else(|| cursor_style_from_shape(cur.shape)), }); } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5d6a4ecd..6bdec557 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6599,7 +6599,11 @@ impl TerminalView { } } - let cursor_style = cx.global::().cursor_style; + // This caret only exists at a prompt, so the prompt's own shape wins. + let cursor_style = { + let cfg = cx.global::(); + cfg.prompt_cursor_style.shape().unwrap_or(cfg.cursor_style) + }; let cursor_paint = input_caret_paint(focused, self.cursor_visible, cursor_style); let cursor_on = cursor_paint.is_some(); let block_cursor = cursor_paint == Some(InputCaretPaint::Block); @@ -16451,6 +16455,74 @@ mod gpui_tests { "a spawned pane counts its first agent, once" ); } + + /// #958: `prompt_cursor_style` shapes the shell's own caret at a prompt, + /// leaves the running program's cursor alone, and steps aside for a vi-mode + /// prompt, whose insert/normal shapes are the shell's to set. + #[gpui::test] + fn prompt_cursor_style_shapes_only_the_prompt(cx: &mut TestAppContext) { + use crate::core::config::{CursorStyle, PromptCursorStyle}; + + let (window, mut daemon) = harness(cx); + let shape = |cx: &mut TestAppContext| { + cx.update(|cx| { + let view = window.entity(cx).unwrap(); + TerminalElement::new(view).prompt_cursor_shape(cx) + }) + }; + let wait_for = |cx: &mut TestAppContext, want: &dyn Fn(&TerminalView) -> bool| { + for _ in 0..200 { + cx.run_until_parked(); + if window.update(cx, |view, _, _| want(view)).unwrap() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the pane never reached the expected prompt state"); + }; + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Bar); + + assert_eq!( + shape(cx), + None, + "no prompt reported yet: a program owns the cursor" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| view.terminal.at_prompt()); + assert_eq!(shape(cx), Some(CursorStyle::Bar)); + + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Follow); + assert_eq!(shape(cx), None, "follow leaves the prompt on cursor_style"); + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Bar); + + DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| view.terminal.shell_vi_mode()); + assert_eq!( + shape(cx), + None, + "a vi-mode prompt keeps the shell's own shapes" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| !view.terminal.at_prompt()); + assert_eq!(shape(cx), None, "a running command gets cursor_style back"); + } + /// Seeds a pane's history for the ghost tests: `(command, dirs, exit)`, /// oldest first, with `cwd` as the directory the pane is in. fn seed_ghost_history( diff --git a/src/ui/app.rs b/src/ui/app.rs index 1cd7ad3e..439de03b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3015,6 +3015,19 @@ impl Tty7App { self.apply_terminal_config_to_panes(&cfg, cx); } + pub(crate) fn set_prompt_cursor_style( + &mut self, + style: crate::core::config::PromptCursorStyle, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.prompt_cursor_style = style); + for tab in &self.tabs { + for leaf in tab.pane.terminals() { + leaf.update(cx, |_, cx| cx.notify()); + } + } + } + pub(crate) fn persist_settings_config(&mut self, cx: &mut Context) { let config = cx.global::().clone(); let error = config.try_save().err().map(|error| error.to_string()); @@ -6235,6 +6248,9 @@ impl Tty7App { } L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx), L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx), + L10nKey::SettingsPromptCursorShape => { + self.set_prompt_cursor_style(defaults.prompt_cursor_style, cx) + } L10nKey::SettingsScrollback => self.set_scrollback_limit(defaults.scrollback_limit, cx), L10nKey::SettingsNewTabPosition => { self.set_new_tab_position(defaults.new_tab_position, cx) @@ -6410,6 +6426,9 @@ impl Tty7App { s.ui_font_select = ui; } } + L10nKey::SettingsCursorShape | L10nKey::SettingsPromptCursorShape => { + self.sync_cursor_selects(window, cx) + } L10nKey::SettingsLanguage => { let value = self.build_language_select(&mut subs, window, cx); if let Some(s) = self.active_settings_mut() { @@ -6537,6 +6556,8 @@ impl Tty7App { let (font_select, font_bold_select, font_italic_select, ui_font_select) = self.build_font_selects(&mut subs, window, cx); let language_select = self.build_language_select(&mut subs, window, cx); + let (cursor_style_select, prompt_cursor_style_select) = + self.build_cursor_selects(&mut subs, window, cx); #[cfg(target_os = "windows")] let window_backdrop_select = self.build_window_backdrop_select(&mut subs, window, cx); let (shell_program_input, shell_args_input, wd_path_input) = @@ -6628,6 +6649,8 @@ impl Tty7App { font_italic_select, ui_font_select, language_select, + cursor_style_select, + prompt_cursor_style_select, #[cfg(target_os = "windows")] window_backdrop_select, shell_program_input, @@ -6833,6 +6856,98 @@ impl Tty7App { language_select } + /// The cursor shape and prompt cursor shape dropdowns. Each resolves the + /// picked label back through the row list it was built from. + fn build_cursor_selects( + &mut self, + subs: &mut Vec, + window: &mut Window, + cx: &mut Context, + ) -> ( + Entity>>, + Entity>>, + ) { + use crate::ui::settings::{ + CURSOR_SHAPES, PROMPT_CURSOR_SHAPES, cursor_shape_labels, prompt_cursor_shape_labels, + }; + let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx); + let shape = cx.new(|cx| { + SelectState::new( + SearchableVec::new(cursor_shape_labels()), + Some(IndexPath::default().row(shape_ix)), + window, + cx, + ) + }); + let prompt = cx.new(|cx| { + SelectState::new( + SearchableVec::new(prompt_cursor_shape_labels()), + Some(IndexPath::default().row(prompt_ix)), + window, + cx, + ) + }); + subs.push(cx.subscribe_in( + &shape, + window, + |this, _select, ev: &SelectEvent>, _window, cx| { + if let SelectEvent::Confirm(Some(label)) = ev { + if let Some(ix) = cursor_shape_labels().iter().position(|r| r == label) { + this.set_cursor_style(CURSOR_SHAPES[ix], cx); + } + } + }, + )); + subs.push(cx.subscribe_in( + &prompt, + window, + |this, _select, ev: &SelectEvent>, _window, cx| { + if let SelectEvent::Confirm(Some(label)) = ev { + if let Some(ix) = prompt_cursor_shape_labels().iter().position(|r| r == label) { + this.set_prompt_cursor_style(PROMPT_CURSOR_SHAPES[ix], cx); + } + } + }, + )); + (shape, prompt) + } + + /// The dropdown rows the config's two cursor shapes sit on. + fn cursor_select_rows(cx: &App) -> (usize, usize) { + use crate::ui::settings::{CURSOR_SHAPES, PROMPT_CURSOR_SHAPES}; + let cfg = cx.global::(); + let shape = CURSOR_SHAPES.iter().position(|s| *s == cfg.cursor_style); + let prompt = PROMPT_CURSOR_SHAPES + .iter() + .position(|s| *s == cfg.prompt_cursor_style); + (shape.unwrap_or(0), prompt.unwrap_or(0)) + } + + /// Puts both cursor dropdowns back on the config's values, relabelled in + /// the current language — after a reset, a language switch, or a config + /// edit made outside this window. + fn sync_cursor_selects(&mut self, window: &mut Window, cx: &mut Context) { + use crate::ui::settings::{cursor_shape_labels, prompt_cursor_shape_labels}; + let Some((shape, prompt)) = self.active_settings().map(|s| { + ( + s.cursor_style_select.clone(), + s.prompt_cursor_style_select.clone(), + ) + }) else { + return; + }; + let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx); + for (select, rows, ix) in [ + (shape, cursor_shape_labels(), shape_ix), + (prompt, prompt_cursor_shape_labels(), prompt_ix), + ] { + select.update(cx, |state, cx| { + state.set_items(SearchableVec::new(rows), window, cx); + state.set_selected_index(Some(IndexPath::default().row(ix)), window, cx); + }); + } + } + fn normalize_gui_language(code: &str) -> &'static str { crate::ui::i18n::find_language(code) .map(|lang| lang.code) @@ -6902,6 +7017,7 @@ impl Tty7App { } pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context) { + self.sync_cursor_selects(window, cx); self.sidebar_search.update(cx, |state, cx| { state.set_placeholder(t(L10nKey::SearchTabs), window, cx) }); @@ -7430,6 +7546,7 @@ impl Tty7App { self.terminal_scrollback_limit = config.scrollback_limit; self.apply_terminal_config_to_panes(&config, cx); } + self.sync_cursor_selects(window, cx); let (font_size, line_height, font_family, font_fallbacks, font_features) = { let cfg = cx.global::(); ( diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a2bd10f7..18618168 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -140,6 +140,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsCursor => "Cursor", L10nKey::SettingsCursorShape => "Cursor shape", L10nKey::SettingsCursorShapeDesc => "How the terminal cursor is drawn.", + L10nKey::SettingsPromptCursorShape => "Prompt cursor shape", + L10nKey::SettingsPromptCursorShapeDesc => { + "The cursor at the shell prompt. Follow uses the cursor shape above everywhere." + } L10nKey::SettingsCursorBlink => "Cursor blink", L10nKey::SettingsCursorBlinkDesc => "Pulse the cursor while the terminal is focused.", L10nKey::SettingsLanguage => "Language", @@ -839,6 +843,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchCopyOnSelectKeywords => "clipboard selection yank mouse", L10nKey::SettingsSearchCursorBlinkKeywords => "caret blinking flash", L10nKey::SettingsSearchCursorShapeKeywords => "caret block bar underline beam", + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "prompt cursor shape caret block bar underline beam shell integration" + } L10nKey::SettingsSearchCustomThemesKeywords => { "theme duplicate edit colors folder yaml import background image wallpaper" } @@ -1825,6 +1832,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CursorShapeBlock => "Block", L10nKey::CursorShapeBar => "Bar", L10nKey::CursorShapeUnderline => "Underline", + L10nKey::PromptCursorShapeFollow => "Follow", L10nKey::PaletteTryDifferentSearch => "Try a different search.", L10nKey::CompletionListingRemote => "listing remote…", L10nKey::CompletionRemoteListingFailed => "remote listing failed — {error}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0a8319b4..99941ff0 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -142,6 +142,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCursor => "カーソル", L10nKey::SettingsCursorShape => "カーソルの形状", L10nKey::SettingsCursorShapeDesc => "ターミナルカーソルの描画方法", + L10nKey::SettingsPromptCursorShape => "プロンプトのカーソル形状", + L10nKey::SettingsPromptCursorShapeDesc => { + "シェルプロンプトでのカーソル。「追従」は上のカーソル形状をどこでも使う" + } L10nKey::SettingsCursorBlink => "カーソルの点滅", L10nKey::SettingsCursorBlinkDesc => { "ターミナルがフォーカスされている間、カーソルを点滅させる" @@ -865,6 +869,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorShapeKeywords => { "カーソル 形状 ブロック バー アンダーライン ビーム cursor shape caret block bar underline beam" } + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "プロンプト カーソル 形状 ブロック バー 下線 prompt cursor shape caret block bar underline beam" + } L10nKey::SettingsSearchCustomThemesKeywords => { "テーマ 複製 編集 色 フォルダ 背景画像 壁紙 yaml インポート custom themes duplicate edit colors folder import background image wallpaper" } @@ -1897,6 +1904,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CursorShapeBlock => "ブロック", L10nKey::CursorShapeBar => "バー", L10nKey::CursorShapeUnderline => "下線", + L10nKey::PromptCursorShapeFollow => "追従", L10nKey::PaletteTryDifferentSearch => "別のキーワードを試してください。", L10nKey::CompletionListingRemote => "リモートを一覧しています…", L10nKey::CompletionRemoteListingFailed => "リモートの一覧に失敗しました — {error}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1eecd6d1..bbfcea66 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -186,6 +186,8 @@ l10n_keys! { SettingsCursor, SettingsCursorShape, SettingsCursorShapeDesc, + SettingsPromptCursorShape, + SettingsPromptCursorShapeDesc, SettingsCursorBlink, SettingsCursorBlinkDesc, SettingsLanguage, @@ -642,6 +644,7 @@ l10n_keys! { SettingsSearchCopyOnSelectKeywords, SettingsSearchCursorBlinkKeywords, SettingsSearchCursorShapeKeywords, + SettingsSearchPromptCursorShapeKeywords, SettingsSearchCustomThemesKeywords, SettingsSearchDetectUrlsKeywords, SettingsSearchDiffPreviewFromCountsKeywords, @@ -1475,6 +1478,7 @@ l10n_keys! { CursorShapeBlock, CursorShapeBar, CursorShapeUnderline, + PromptCursorShapeFollow, PaletteTryDifferentSearch, CompletionListingRemote, CompletionRemoteListingFailed, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 55105350..7f88acf5 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -126,6 +126,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCursor => "光标", L10nKey::SettingsCursorShape => "光标形状", L10nKey::SettingsCursorShapeDesc => "终端光标的绘制方式。", + L10nKey::SettingsPromptCursorShape => "提示符光标形状", + L10nKey::SettingsPromptCursorShapeDesc => { + "shell 提示符处的光标。跟随表示各处都用上面的光标形状。" + } L10nKey::SettingsCursorBlink => "光标闪烁", L10nKey::SettingsCursorBlinkDesc => "终端获得焦点时让光标闪烁。", L10nKey::SettingsLanguage => "语言", @@ -760,6 +764,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorShapeKeywords => { "光标形状 光标 块 竖线 下划线 cursor shape caret block bar underline beam" } + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "提示符 光标形状 光标 块 竖线 下划线 prompt cursor shape caret block bar underline beam" + } L10nKey::SettingsSearchCustomThemesKeywords => { "自定义主题 复制 编辑 颜色 文件夹 背景图片 壁纸 yaml 导入 theme custom edit duplicate colors import background image wallpaper" } @@ -1720,6 +1727,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CursorShapeBlock => "块状", L10nKey::CursorShapeBar => "竖线", L10nKey::CursorShapeUnderline => "下划线", + L10nKey::PromptCursorShapeFollow => "跟随", L10nKey::PaletteTryDifferentSearch => "换个关键词试试。", L10nKey::CompletionListingRemote => "正在列出远程目录…", L10nKey::CompletionRemoteListingFailed => "远程目录列表失败——{error}", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index dac43544..053c0008 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -24,7 +24,8 @@ use uuid::Uuid; use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, - SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + PromptCursorStyle, SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, + WindowBackdrop, }; use crate::core::keychain::{ CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, @@ -573,6 +574,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsCursorShape, keywords: SettingsSearchCursorShapeKeywords, }, + SearchEntry { + section: Appearance, + title: SettingsPromptCursorShape, + keywords: SettingsSearchPromptCursorShapeKeywords, + }, SearchEntry { section: Appearance, title: SettingsCursorBlink, @@ -902,12 +908,43 @@ fn settings_search_entries() -> &'static [SearchEntry] { ] } +/// The rows of the cursor shape dropdown, in `CURSOR_SHAPES` order. +pub(crate) fn cursor_shape_labels() -> Vec { + [ + L10nKey::CursorShapeBlock, + L10nKey::CursorShapeBar, + L10nKey::CursorShapeUnderline, + ] + .into_iter() + .map(|key| t(key).to_string()) + .collect() +} + +pub(crate) const CURSOR_SHAPES: [CursorStyle; 3] = + [CursorStyle::Block, CursorStyle::Bar, CursorStyle::Underline]; + +/// The rows of the prompt cursor shape dropdown, in `PROMPT_CURSOR_SHAPES` +/// order: follow, then the shapes. +pub(crate) fn prompt_cursor_shape_labels() -> Vec { + let mut rows = vec![t(L10nKey::PromptCursorShapeFollow).to_string()]; + rows.extend(cursor_shape_labels()); + rows +} + +pub(crate) const PROMPT_CURSOR_SHAPES: [PromptCursorStyle; 4] = [ + PromptCursorStyle::Follow, + PromptCursorStyle::Block, + PromptCursorStyle::Bar, + PromptCursorStyle::Underline, +]; + impl SearchEntry { fn config_key(&self) -> &'static str { match self.title { L10nKey::SettingsDimInactivePanes => "dim_inactive_panes", L10nKey::SettingsCursorBlink => "cursor_blink", L10nKey::SettingsCursorShape => "cursor_style", + L10nKey::SettingsPromptCursorShape => "prompt_cursor_style", L10nKey::SettingsScrollback => "scrollback_limit", L10nKey::SettingsNewTabPosition => "new_tab_position", L10nKey::SettingsTabBarPosition => "tab_bar_position", @@ -994,6 +1031,7 @@ impl SearchEntry { L10nKey::SettingsFontLigatures => t(L10nKey::SettingsFontLigaturesDesc), L10nKey::SettingsFontThicken => t(L10nKey::SettingsFontThickenDesc), L10nKey::SettingsCursorShape => t(L10nKey::SettingsCursorShapeDesc), + L10nKey::SettingsPromptCursorShape => t(L10nKey::SettingsPromptCursorShapeDesc), L10nKey::SettingsCursorBlink => t(L10nKey::SettingsCursorBlinkDesc), L10nKey::SettingsBackgroundImage => t(L10nKey::SettingsBackgroundImageDesc), L10nKey::SettingsImageOpacity => t(L10nKey::SettingsImageOpacityDesc), @@ -1060,6 +1098,9 @@ impl SearchEntry { } L10nKey::SettingsCursorBlink => cfg.cursor_blink != defaults.cursor_blink, L10nKey::SettingsCursorShape => cfg.cursor_style != defaults.cursor_style, + L10nKey::SettingsPromptCursorShape => { + cfg.prompt_cursor_style != defaults.prompt_cursor_style + } L10nKey::SettingsScrollback => cfg.scrollback_limit != defaults.scrollback_limit, L10nKey::SettingsNewTabPosition => cfg.new_tab_position != defaults.new_tab_position, L10nKey::SettingsTabBarPosition => cfg.tab_bar_position != defaults.tab_bar_position, @@ -1311,6 +1352,8 @@ pub(crate) struct SettingsState { pub(crate) font_italic_select: Entity>>, pub(crate) ui_font_select: Entity>>, pub(crate) language_select: Entity>>, + pub(crate) cursor_style_select: Entity>>, + pub(crate) prompt_cursor_style_select: Entity>>, #[cfg(target_os = "windows")] pub(crate) window_backdrop_select: Entity>>, pub(crate) shell_program_input: Entity, @@ -3535,8 +3578,14 @@ impl Tty7App { ), None => return div().into_any_element(), }; + let (cursor_style_select, prompt_cursor_style_select) = match self.active_settings() { + Some(s) => ( + s.cursor_style_select.clone(), + s.prompt_cursor_style_select.clone(), + ), + None => return div().into_any_element(), + }; let cfg = cx.global::(); - let cursor_style = cfg.cursor_style; let cursor_blink = cfg.cursor_blink; let font_thicken = cfg.font_thicken; let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| { @@ -3656,29 +3705,15 @@ impl Tty7App { ) }); - let cursor_idx = match cursor_style { - CursorStyle::Block => 0, - CursorStyle::Bar => 1, - CursorStyle::Underline => 2, + let cursor_dropdown = |state: &Entity>>| { + Select::new(state) + .small() + .w(px(FIELD_W)) + .h(px(24.)) + .into_any_element() }; - let cursor_style_control = self.segmented( - "cursor-style", - &[ - t(L10nKey::CursorShapeBlock), - t(L10nKey::CursorShapeBar), - t(L10nKey::CursorShapeUnderline), - ], - cursor_idx, - cx, - |this, ix, _w, cx| { - let style = match ix { - 0 => CursorStyle::Block, - 1 => CursorStyle::Bar, - _ => CursorStyle::Underline, - }; - this.set_cursor_style(style, cx); - }, - ); + let cursor_style_control = cursor_dropdown(&cursor_style_select); + let prompt_cursor_style_control = cursor_dropdown(&prompt_cursor_style_select); let blink_switch = crate::ui::theme::switch("cursor-blink", cx) .checked(cursor_blink) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx))) @@ -3755,6 +3790,12 @@ impl Tty7App { cursor_style_control, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsPromptCursorShape), + t(L10nKey::SettingsPromptCursorShapeDesc), + prompt_cursor_style_control, + cx, + )) .child(self.settings_row( t(L10nKey::SettingsCursorBlink), t(L10nKey::SettingsCursorBlinkDesc),