mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
Merge main into feat/prompt-cursor-style
This commit is contained in:
+69
-18
@@ -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
|
||||
(``), 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/<drive>`. 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 —
|
||||
|
||||
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
|
||||
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · <kbd>⌘ J</kbd> 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.
|
||||
|
||||
<details>
|
||||
<summary>The full support matrix, all twenty-three</summary>
|
||||
<summary>The full support matrix, all twenty-five</summary>
|
||||
|
||||
| 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 | ✓ | | |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+3
-1
@@ -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 | ✓ | | |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
|
||||
|
Before Width: | Height: | Size: 424 B |
@@ -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::<serde_json::Value>(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<u8> {
|
||||
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<HookOutcome> {
|
||||
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<Ho
|
||||
|
||||
pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result<HookOutcome> {
|
||||
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 —
|
||||
/// `{"<name>": {"<Event>": [...]}}` — 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<Option<serde_json::Value>> {
|
||||
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<HookOutcome> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
| HookAgent::Crush
|
||||
| HookAgent::CodeBuddy
|
||||
| HookAgent::Cursor
|
||||
| HookAgent::Antigravity
|
||||
| HookAgent::Kimi => None,
|
||||
}
|
||||
}
|
||||
@@ -1568,6 +1726,7 @@ fn pi_extension_ts(target: &HookTarget, agent: HookAgent) -> Option<String> {
|
||||
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"));
|
||||
|
||||
@@ -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 <id>`; `--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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ or right-click the **tab or sidebar row** to open the fork in a new tab.
|
||||
| Grok | `grok --resume <id> --fork-session` |
|
||||
| OpenCode | `opencode --session <id> --fork` |
|
||||
| Oh My Pi | `omp --fork <id>` |
|
||||
| Prime Agent | `prime-agent --fork <id>` |
|
||||
|
||||
It is the agent's own fork command, run in a new pane — nothing is copied by
|
||||
tty7 itself.
|
||||
|
||||
@@ -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.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ something floods the screen.
|
||||
syntax highlighting, click-to-place-caret, real multi-line editing.
|
||||
</Card>
|
||||
<Card title="Agent-aware" icon="robot" href="/agents/overview">
|
||||
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.
|
||||
</Card>
|
||||
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
|
||||
|
||||
@@ -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:<slug>` (one per agent, e.g. `LaunchAgent:claude`) |
|
||||
|
||||
+12
-10
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+72
-1
@@ -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 });
|
||||
|
||||
+11
-29
@@ -190,23 +190,6 @@ pub fn frecency_scores(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn rank_by_frecency(
|
||||
entries: &[String],
|
||||
counts: &HashMap<String, u32>,
|
||||
cwds: &HashMap<String, HashSet<String>>,
|
||||
cwd: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let scores = frecency_scores(entries, counts, cwds, cwd);
|
||||
let mut idx: Vec<usize> = (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);
|
||||
|
||||
+709
-22
@@ -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<String> {
|
||||
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<PathBuf> = 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<FileCandidate> {
|
||||
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<char> = token.chars().collect();
|
||||
let mut out: Vec<FileCandidate> = Vec::new();
|
||||
@@ -1030,10 +1140,93 @@ pub(super) fn file_candidates_at(text: &str, col: usize) -> Vec<FileCandidate> {
|
||||
.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<FileCandidate> {
|
||||
let chars: Vec<char> = 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<usize> {
|
||||
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:
|
||||
// ``, `[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<LinkMatch>, Vec<String>) {
|
||||
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 │", "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 [
|
||||
"",
|
||||
"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/<drive>`, 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<u32>, Option<u32>, usize, usize), Vec<String>> {
|
||||
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 <module>"#,
|
||||
(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::<Vec<_>>()
|
||||
};
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+285
-26
@@ -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<String, u32>,
|
||||
history_cwds: std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
history_meta: std::collections::HashMap<String, super::history::EntryMeta>,
|
||||
history_ranked: Vec<String>,
|
||||
history_frecency: Vec<f64>,
|
||||
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<String> {
|
||||
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<Self>) {
|
||||
@@ -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<Self>) {
|
||||
// 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<Self>) {
|
||||
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::<Config>().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<i32>)],
|
||||
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
|
||||
|
||||
+92
-1
@@ -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<Self>) {
|
||||
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<Self>) {
|
||||
// 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<usize>
|
||||
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<Vec<usize>> {
|
||||
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<usize> = (0..n).collect();
|
||||
for (&slot, &tab) in run.iter().zip(&moved) {
|
||||
permutation[slot] = tab;
|
||||
}
|
||||
Some(permutation)
|
||||
}
|
||||
|
||||
fn mru_order(stamps: &[u64], active: usize) -> Vec<usize> {
|
||||
let mut order: Vec<usize> = (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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "パスをコピー",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => "复制路径",
|
||||
|
||||
@@ -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<KeyBinding> {
|
||||
"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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+93
-41
@@ -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<Self>) -> 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<usize> {
|
||||
if cx.global::<Config>().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,
|
||||
|
||||
Reference in New Issue
Block a user