diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2fc084..01682019 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 @@ -64,27 +73,62 @@ 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 + 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 - 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`, @@ -165,6 +209,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/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/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/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/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/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/core/actions.rs b/src/core/actions.rs index bf9fff64..b01cf6d0 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -27,6 +27,7 @@ actions!( CloseTabsToTheRight, CopyWorkingDirectory, OpenLinkUnderPointer, + OpenLinkWithDefaultApp, RevealLinkUnderPointer, CopyLinkPathUnderPointer, MarkTabUnread, @@ -57,6 +58,8 @@ actions!( PrevTab, SelectNextTab, SelectPrevTab, + MoveTabLeft, + MoveTabRight, ActivateTab1, ActivateTab2, ActivateTab3, diff --git a/src/terminal/element.rs b/src/terminal/element.rs index a3027e74..00c02980 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1724,6 +1724,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, @@ -1735,7 +1751,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 { @@ -2731,6 +2747,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 }); 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/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 53287fd3..6bdec557 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; @@ -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) { @@ -5975,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; @@ -7257,6 +7290,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); })) @@ -7304,10 +7340,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. @@ -7653,9 +7697,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) { @@ -7673,6 +7731,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"); @@ -10061,6 +10120,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)); @@ -14042,7 +14125,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); @@ -14066,7 +14149,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); @@ -16439,6 +16522,182 @@ mod gpui_tests { 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( + 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 diff --git a/src/ui/app.rs b/src/ui/app.rs index e8d0a2ed..439de03b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4901,6 +4901,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 @@ -5986,6 +6000,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), @@ -9308,6 +9324,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) })) @@ -9594,6 +9616,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)); @@ -10782,7 +10833,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, }; @@ -11162,6 +11213,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/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/i18n/en.rs b/src/ui/i18n/en.rs index da64b4c4..18618168 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -809,6 +809,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" @@ -918,6 +920,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" @@ -1529,6 +1537,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", @@ -1900,6 +1910,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 e7345ec2..99941ff0 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -819,6 +819,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" } @@ -974,6 +976,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" } @@ -1593,6 +1601,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 => "作業ディレクトリをコピー", @@ -1972,6 +1982,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 efd4f5f9..bbfcea66 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -622,6 +622,8 @@ l10n_keys! { SettingsAgentCrush, SettingsAgentCodeBuddy, SettingsAgentCursorCli, + SettingsAgentPrimeAgent, + SettingsAgentAntigravity, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -682,6 +684,8 @@ l10n_keys! { SettingsSearchCrushKeywords, SettingsSearchCodeBuddyKeywords, SettingsSearchCursorCliKeywords, + SettingsSearchPrimeAgentKeywords, + SettingsSearchAntigravityKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1045,6 +1049,7 @@ l10n_keys! { AppMenuZoomPane, AppMenuClearScrollback, AppMenuOpenLink, + AppMenuOpenLinkWithDefaultApp, AppMenuRevealInFinder, AppMenuRevealInFolder, AppMenuCopyLinkPath, @@ -1241,6 +1246,8 @@ l10n_keys! { CmdSwapPanePrevious, CmdNextTab, CmdPreviousTab, + CmdMoveTabLeft, + CmdMoveTabRight, CmdRecentTabSwitcher, CmdRecentTabSwitcherReverse, CmdCopyWorkingDirectory, @@ -1668,6 +1675,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 a0435488..7f88acf5 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -716,6 +716,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" } @@ -865,6 +867,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" } @@ -1436,6 +1444,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 => "复制工作目录", @@ -1791,6 +1801,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuZoomPane => "缩放窗格", L10nKey::AppMenuClearScrollback => "清除回滚内容", L10nKey::AppMenuOpenLink => "打开", + L10nKey::AppMenuOpenLinkWithDefaultApp => "用默认程序打开", L10nKey::AppMenuRevealInFinder => "在访达中显示", L10nKey::AppMenuRevealInFolder => "打开所在文件夹", L10nKey::AppMenuCopyLinkPath => "复制路径", 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/settings.rs b/src/ui/settings.rs index 8fe561fc..053c0008 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -805,6 +805,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, diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 55d5db75..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( @@ -2597,6 +2634,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,