Merge main into feat/search-everywhere

This commit is contained in:
l0ng-ai
2026-09-27 09:38:22 +08:00
30 changed files with 2183 additions and 194 deletions
+78 -18
View File
@@ -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,6 +73,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
pane in front as before, and an ended session still says so. The key is
`ssh_tab_title` (`dynamic`, `profile-name`, `hostname`).
- **Windows file paths are links** (#965). `C:\Users\me\a.png`,
`c:/Users/me/a.png` and `\\server\share\a.png` underline and open like any
other file path, with a `:10:2`, `(10,2)` or `#L10` location kept. The path
is found when Chinese prose is glued straight onto it
(`图片已保存到:c:/Users/me/a.png`) and inside a Markdown link
(`![chart](c:/out/chart.png)`), and is handed on with backslashes so
Explorer opens and reveals it — a forward-slashed path used to open
Documents instead. In a WSL pane a drive path is looked up under
`/mnt/<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 palette is now Search Everywhere, with tabs.** <kbd>⌘ P</kbd>
@@ -86,25 +118,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
of each transcript are read, in the background, and remembered until the file
changes.
- **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`,
@@ -164,6 +208,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
tree is remembered in the config. Both are built from the status the host
already sends, so a remote repository gets them unchanged. Searching the
diff text itself is not part of this.
- **The cursor at the shell prompt can have its own shape** (#958). A new
`prompt_cursor_style` setting (Settings → Appearance → Cursor) takes
`follow`, `block`, `bar` or `underline`. `follow`, the default, keeps
`cursor_style` everywhere, exactly as before. Any other value is used at the
prompt, drawn by tty7's inline editor or by the shell's own line editor, and
leaves `cursor_style` to the programs the shell runs — so `bar` with
`cursor_style: "block"` gives kitty and ghostty's bar-at-the-prompt, block in
a TUI that never sets a shape itself, such as Claude Code. A shell prompt in
vi mode keeps its own insert/normal shapes.
### Removed
@@ -186,6 +239,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
to connect as `ssh -p 2222 me`; a line with spaces is now always taken as an
`ssh` command line.
- **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 —
+5 -3
View File
@@ -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> Search Everywhere · <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
View File
@@ -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
View File
@@ -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

+292 -3
View File
@@ -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"));
+85 -4
View File
@@ -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 [
+50
View File
@@ -331,6 +331,13 @@ pub struct Config {
#[serde(default, deserialize_with = "de_lenient")]
pub cursor_style: CursorStyle,
/// The caret's shape while the shell waits at a prompt, whether tty7's
/// inline editor or the shell's own line editor draws it. `follow` keeps
/// `cursor_style` everywhere; any other value leaves `cursor_style` to the
/// programs the shell runs, which is how kitty and ghostty put a bar at
/// the prompt and a block inside a TUI that never sets a shape itself.
#[serde(default, deserialize_with = "de_lenient")]
pub prompt_cursor_style: PromptCursorStyle,
pub macos_option_as_alt: bool,
pub mouse_hide_while_typing: bool,
@@ -536,6 +543,28 @@ pub enum CursorStyle {
Underline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PromptCursorStyle {
#[default]
Follow,
Block,
Bar,
Underline,
}
impl PromptCursorStyle {
/// The shape this names, or `None` when the prompt follows `cursor_style`.
pub fn shape(self) -> Option<CursorStyle> {
match self {
Self::Follow => None,
Self::Block => Some(CursorStyle::Block),
Self::Bar => Some(CursorStyle::Bar),
Self::Underline => Some(CursorStyle::Underline),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NewTabPosition {
@@ -763,6 +792,7 @@ impl Default for Config {
tab_completion: true,
history_search: true,
cursor_style: CursorStyle::Block,
prompt_cursor_style: PromptCursorStyle::Follow,
macos_option_as_alt: false,
mouse_hide_while_typing: true,
focus_follows_mouse: false,
@@ -3057,4 +3087,24 @@ mod tests {
assert_eq!(back.ssh_tab_title, mode);
}
}
#[test]
fn prompt_cursor_style_defaults_to_following_cursor_style() {
assert_eq!(
Config::default().prompt_cursor_style,
PromptCursorStyle::Follow
);
assert_eq!(PromptCursorStyle::Follow.shape(), None);
let cfg: Config =
serde_json::from_str(r#"{"cursor_style": "block", "prompt_cursor_style": "bar"}"#)
.unwrap();
assert_eq!(cfg.cursor_style, CursorStyle::Block);
assert_eq!(cfg.prompt_cursor_style.shape(), Some(CursorStyle::Bar));
// A value this build does not know falls back rather than failing the
// whole config.
let cfg: Config = serde_json::from_str(r#"{"prompt_cursor_style": "beam"}"#).unwrap();
assert_eq!(cfg.prompt_cursor_style, PromptCursorStyle::Follow);
}
}
+4 -2
View File
@@ -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
+1
View File
@@ -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.
+9 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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">
+1
View File
@@ -118,6 +118,7 @@ their id from the file name. [More about themes →](/customization/themes)
| `env` | object | `{}` | Extra environment variables for every pane. |
| `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. |
| `cursor_style` | enum | `"block"` | `block`, `bar`, `underline`. |
| `prompt_cursor_style` | enum | `"follow"` | The cursor at the shell prompt: `follow`, `block`, `bar`, `underline`. `follow` uses `cursor_style` everywhere; any other value leaves `cursor_style` to the programs the shell runs, e.g. `bar` here with `cursor_style: "block"` gives a bar at the prompt and a block in a TUI that sets no shape of its own. A shell prompt in vi mode keeps its own shapes. |
| `cursor_blink` | bool | `true` | |
| `bell` | enum | `"visual"` | `none`, `visual`, `audible`, `both`. |
| `per_pane_history` | bool | `false` | Give each pane its own shell history file. |
+1 -1
View File
@@ -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
View File
@@ -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 Search Everywhere |
| A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from Search Everywhere |
@@ -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:
+3
View File
@@ -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,
+90 -2
View File
@@ -4,6 +4,7 @@ use std::collections::HashMap;
use alacritty_terminal::grid::Dimensions as _;
use alacritty_terminal::index::{Column as AlacColumn, Line as AlacLine, Point as AlacPoint};
use alacritty_terminal::selection::SelectionRange;
use alacritty_terminal::term::TermMode;
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb};
use gpui::{
@@ -1723,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,
@@ -1734,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 {
@@ -1801,6 +1818,17 @@ impl GridSnapshot {
}
impl TerminalElement {
/// The shape `prompt_cursor_style` puts on the grid cursor while the shell
/// waits at a prompt with its own line editor — `prompt_editor` off, or a
/// line handed back to ZLE/readline. Shell integration has no mark for "a
/// prompt plugin set this shape", so the one it does report, vi mode, keeps
/// the shell's own insert/normal shapes.
pub(super) fn prompt_cursor_shape(&self, cx: &App) -> Option<crate::core::config::CursorStyle> {
let shape = cx.global::<Config>().prompt_cursor_style.shape()?;
let terminal = &self.view.read(cx).terminal;
(terminal.at_prompt() && !terminal.shell_vi_mode()).then_some(shape)
}
pub(super) fn build_grid(
&self,
colors: &PaintColors,
@@ -1818,6 +1846,7 @@ impl TerminalElement {
let mut any_selected = false;
let display_offset;
let history_size;
let prompt_shape = self.prompt_cursor_shape(cx);
{
let mut palette = self.view.read(cx).terminal.palette;
if let Some(active) = cx.try_global::<crate::terminal::palette::ActivePalette>() {
@@ -1933,7 +1962,11 @@ impl TerminalElement {
col,
ime_col,
hidden: cursor_hidden,
style: cursor_style_from_shape(cur.shape),
// Checked under the grid lock: `on_alt_screen` would take
// it a second time.
style: prompt_shape
.filter(|_| !term.mode().contains(TermMode::ALT_SCREEN))
.unwrap_or_else(|| cursor_style_from_shape(cur.shape)),
});
}
}
@@ -2714,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
View File
@@ -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
View File
@@ -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:
// `![chart](c:/out/chart.png)`, `[notes](docs/notes.md)`.
if let Some(i) = rest.windows(2).position(|w| w == [']', '(']) {
offsets.push(i + 2);
}
for offset in offsets {
let cut = base + offset;
if cut < chars.len() && !cuts.contains(&cut) {
@@ -2547,4 +2758,480 @@ mod tests {
"the reading behind the `note:` label is still on the ladder"
);
}
/// A Windows pane sitting in `C:\proj`, the way the reporter of #965 saw it.
fn windows_roots() -> LinkRoots {
LinkRoots {
dirs: vec![PathBuf::from(r"C:\proj")],
local_home: false,
style: PathStyle::Windows,
}
}
/// `link_at` over `line` with the pointer on the first char of `needle`,
/// against a filesystem holding exactly `exists` — and every path it was
/// asked about, so a test can say what was *not* looked up.
fn link_with(
line: &str,
needle: &str,
roots: &LinkRoots,
exists: &str,
) -> (Option<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 [
"![chart](c:/out/chart.png)",
"see [the chart](c:/out/chart.png).",
] {
let (got, start, end) = windows_link(line, "out", want);
assert_eq!(got, want, "{line:?}");
assert_eq!((start, end), span_of(line, "c:/out/chart.png"), "{line:?}");
}
let posix = LinkRoots {
dirs: vec![PathBuf::from("/home/u/proj")],
local_home: false,
style: PathStyle::Posix,
};
let line = "[notes](docs/notes.md)";
let (link, asked) = link_with(line, "docs", &posix, "/home/u/proj/docs/notes.md");
let link = link.unwrap_or_else(|| panic!("no link, asked {asked:?}"));
assert_eq!((link.start, link.end), span_of(line, "docs/notes.md"));
}
#[test]
fn a_unc_share_is_a_link_that_stands_alone() {
let want = r"\\server\share\team\a.png";
let line = r"copied to \\server\share\team\a.png";
let (got, start, end) = windows_link(line, "server", want);
assert_eq!(got, want);
assert_eq!((start, end), span_of(line, want));
}
/// Text that merely has a letter and a colon in it is not a drive path,
/// and must never be looked up as one.
#[test]
fn a_letter_and_a_colon_is_not_a_drive_path() {
for (line, needle) in [
// Drive-relative, or a bare drive: nothing a pane can resolve.
("a:b", "a:b"),
("x:y and more", "x:y"),
("C:notes.txt", "C:notes"),
("see C: now", "C:"),
// A time, a ratio, a port.
("at 12:30 today", "12:30"),
("ratio 16:9", "16:9"),
("localhost:8080", "localhost"),
// A word that ends in a letter is not a drive behind it.
("abc:/x/y", "abc"),
("http:/x", "http"),
] {
let (link, asked) = link_with(line, needle, &windows_roots(), "");
assert_eq!(link, None, "{line:?}");
for path in &asked {
assert!(
path.starts_with(r"C:\proj\"),
"{line:?} asked about {path:?}, which is not under the pane's directory"
);
}
}
// The drive-relative ones are not asked about at all.
for line in ["a:b", "C:notes.txt"] {
let (_, asked) = link_with(line, line, &windows_roots(), "");
assert!(asked.is_empty(), "{line:?} asked about {asked:?}");
}
// And nothing in `abc:/x/y` is read as drive `c:`.
assert!(
file_candidates_at("abc:/x/y", 0)
.iter()
.all(|c| !c.path.starts_with("c:")),
"{:?}",
file_candidates_at("abc:/x/y", 0)
);
}
/// A Windows path that does not exist is not underlined: the probe is
/// the gate, exactly as for a POSIX one.
#[test]
fn a_missing_drive_letter_path_is_not_a_link() {
let (link, asked) = link_with("wrote c:/out/gone.png", "out", &windows_roots(), "");
assert_eq!(link, None);
assert_eq!(asked, vec![r"C:\out\gone.png".to_string()]);
let candidate =
unresolved_candidate("wrote c:/out/gone.png", 8, PathStyle::Windows).unwrap();
assert!(candidate.looks_like_a_path(PathStyle::Windows));
assert!(candidate.is_rooted(PathStyle::Windows));
}
/// A POSIX pane has no drives, but a WSL distro can reach the Windows
/// machine it runs on through `/mnt/<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:?}");
}
}
}
+357 -27
View File
@@ -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;
@@ -6566,7 +6599,11 @@ impl TerminalView {
}
}
let cursor_style = cx.global::<Config>().cursor_style;
// This caret only exists at a prompt, so the prompt's own shape wins.
let cursor_style = {
let cfg = cx.global::<Config>();
cfg.prompt_cursor_style.shape().unwrap_or(cfg.cursor_style)
};
let cursor_paint = input_caret_paint(focused, self.cursor_visible, cursor_style);
let cursor_on = cursor_paint.is_some();
let block_cursor = cursor_paint == Some(InputCaretPaint::Block);
@@ -7253,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);
}))
@@ -7300,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.
@@ -7649,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) {
@@ -7669,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");
@@ -10057,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));
@@ -14038,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);
@@ -14062,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);
@@ -16368,6 +16455,249 @@ mod gpui_tests {
"a spawned pane counts its first agent, once"
);
}
/// #958: `prompt_cursor_style` shapes the shell's own caret at a prompt,
/// leaves the running program's cursor alone, and steps aside for a vi-mode
/// prompt, whose insert/normal shapes are the shell's to set.
#[gpui::test]
fn prompt_cursor_style_shapes_only_the_prompt(cx: &mut TestAppContext) {
use crate::core::config::{CursorStyle, PromptCursorStyle};
let (window, mut daemon) = harness(cx);
let shape = |cx: &mut TestAppContext| {
cx.update(|cx| {
let view = window.entity(cx).unwrap();
TerminalElement::new(view).prompt_cursor_shape(cx)
})
};
let wait_for = |cx: &mut TestAppContext, want: &dyn Fn(&TerminalView) -> bool| {
for _ in 0..200 {
cx.run_until_parked();
if window.update(cx, |view, _, _| want(view)).unwrap() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the pane never reached the expected prompt state");
};
cx.update(|cx| cx.global_mut::<Config>().prompt_cursor_style = PromptCursorStyle::Bar);
assert_eq!(
shape(cx),
None,
"no prompt reported yet: a program owns the cursor"
);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for(cx, &|view| view.terminal.at_prompt());
assert_eq!(shape(cx), Some(CursorStyle::Bar));
cx.update(|cx| cx.global_mut::<Config>().prompt_cursor_style = PromptCursorStyle::Follow);
assert_eq!(shape(cx), None, "follow leaves the prompt on cursor_style");
cx.update(|cx| cx.global_mut::<Config>().prompt_cursor_style = PromptCursorStyle::Bar);
DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
wait_for(cx, &|view| view.terminal.shell_vi_mode());
assert_eq!(
shape(cx),
None,
"a vi-mode prompt keeps the shell's own shapes"
);
DaemonMsg::Prompt {
active: true,
at_prompt: false,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for(cx, &|view| !view.terminal.at_prompt());
assert_eq!(shape(cx), None, "a running command gets cursor_style back");
}
/// Seeds a pane's history for the ghost tests: `(command, dirs, exit)`,
/// oldest first, with `cwd` as the directory the pane is in.
fn seed_ghost_history(
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
+209 -1
View File
@@ -3016,6 +3016,19 @@ impl Tty7App {
self.apply_terminal_config_to_panes(&cfg, cx);
}
pub(crate) fn set_prompt_cursor_style(
&mut self,
style: crate::core::config::PromptCursorStyle,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.prompt_cursor_style = style);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |_, cx| cx.notify());
}
}
}
pub(crate) fn persist_settings_config(&mut self, cx: &mut Context<Self>) {
let config = cx.global::<Config>().clone();
let error = config.try_save().err().map(|error| error.to_string());
@@ -4889,6 +4902,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
@@ -6134,6 +6161,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),
@@ -6387,6 +6416,9 @@ impl Tty7App {
}
L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx),
L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx),
L10nKey::SettingsPromptCursorShape => {
self.set_prompt_cursor_style(defaults.prompt_cursor_style, cx)
}
L10nKey::SettingsScrollback => self.set_scrollback_limit(defaults.scrollback_limit, cx),
L10nKey::SettingsNewTabPosition => {
self.set_new_tab_position(defaults.new_tab_position, cx)
@@ -6562,6 +6594,9 @@ impl Tty7App {
s.ui_font_select = ui;
}
}
L10nKey::SettingsCursorShape | L10nKey::SettingsPromptCursorShape => {
self.sync_cursor_selects(window, cx)
}
L10nKey::SettingsLanguage => {
let value = self.build_language_select(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
@@ -6689,6 +6724,8 @@ impl Tty7App {
let (font_select, font_bold_select, font_italic_select, ui_font_select) =
self.build_font_selects(&mut subs, window, cx);
let language_select = self.build_language_select(&mut subs, window, cx);
let (cursor_style_select, prompt_cursor_style_select) =
self.build_cursor_selects(&mut subs, window, cx);
#[cfg(target_os = "windows")]
let window_backdrop_select = self.build_window_backdrop_select(&mut subs, window, cx);
let (shell_program_input, shell_args_input, wd_path_input) =
@@ -6780,6 +6817,8 @@ impl Tty7App {
font_italic_select,
ui_font_select,
language_select,
cursor_style_select,
prompt_cursor_style_select,
#[cfg(target_os = "windows")]
window_backdrop_select,
shell_program_input,
@@ -6985,6 +7024,98 @@ impl Tty7App {
language_select
}
/// The cursor shape and prompt cursor shape dropdowns. Each resolves the
/// picked label back through the row list it was built from.
fn build_cursor_selects(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> (
Entity<SelectState<SearchableVec<String>>>,
Entity<SelectState<SearchableVec<String>>>,
) {
use crate::ui::settings::{
CURSOR_SHAPES, PROMPT_CURSOR_SHAPES, cursor_shape_labels, prompt_cursor_shape_labels,
};
let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx);
let shape = cx.new(|cx| {
SelectState::new(
SearchableVec::new(cursor_shape_labels()),
Some(IndexPath::default().row(shape_ix)),
window,
cx,
)
});
let prompt = cx.new(|cx| {
SelectState::new(
SearchableVec::new(prompt_cursor_shape_labels()),
Some(IndexPath::default().row(prompt_ix)),
window,
cx,
)
});
subs.push(cx.subscribe_in(
&shape,
window,
|this, _select, ev: &SelectEvent<SearchableVec<String>>, _window, cx| {
if let SelectEvent::Confirm(Some(label)) = ev {
if let Some(ix) = cursor_shape_labels().iter().position(|r| r == label) {
this.set_cursor_style(CURSOR_SHAPES[ix], cx);
}
}
},
));
subs.push(cx.subscribe_in(
&prompt,
window,
|this, _select, ev: &SelectEvent<SearchableVec<String>>, _window, cx| {
if let SelectEvent::Confirm(Some(label)) = ev {
if let Some(ix) = prompt_cursor_shape_labels().iter().position(|r| r == label) {
this.set_prompt_cursor_style(PROMPT_CURSOR_SHAPES[ix], cx);
}
}
},
));
(shape, prompt)
}
/// The dropdown rows the config's two cursor shapes sit on.
fn cursor_select_rows(cx: &App) -> (usize, usize) {
use crate::ui::settings::{CURSOR_SHAPES, PROMPT_CURSOR_SHAPES};
let cfg = cx.global::<Config>();
let shape = CURSOR_SHAPES.iter().position(|s| *s == cfg.cursor_style);
let prompt = PROMPT_CURSOR_SHAPES
.iter()
.position(|s| *s == cfg.prompt_cursor_style);
(shape.unwrap_or(0), prompt.unwrap_or(0))
}
/// Puts both cursor dropdowns back on the config's values, relabelled in
/// the current language — after a reset, a language switch, or a config
/// edit made outside this window.
fn sync_cursor_selects(&mut self, window: &mut Window, cx: &mut Context<Self>) {
use crate::ui::settings::{cursor_shape_labels, prompt_cursor_shape_labels};
let Some((shape, prompt)) = self.active_settings().map(|s| {
(
s.cursor_style_select.clone(),
s.prompt_cursor_style_select.clone(),
)
}) else {
return;
};
let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx);
for (select, rows, ix) in [
(shape, cursor_shape_labels(), shape_ix),
(prompt, prompt_cursor_shape_labels(), prompt_ix),
] {
select.update(cx, |state, cx| {
state.set_items(SearchableVec::new(rows), window, cx);
state.set_selected_index(Some(IndexPath::default().row(ix)), window, cx);
});
}
}
fn normalize_gui_language(code: &str) -> &'static str {
crate::ui::i18n::find_language(code)
.map(|lang| lang.code)
@@ -7054,6 +7185,7 @@ impl Tty7App {
}
pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.sync_cursor_selects(window, cx);
self.sidebar_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchTabs), window, cx)
});
@@ -7582,6 +7714,7 @@ impl Tty7App {
self.terminal_scrollback_limit = config.scrollback_limit;
self.apply_terminal_config_to_panes(&config, cx);
}
self.sync_cursor_selects(window, cx);
let (font_size, line_height, font_family, font_fallbacks, font_features) = {
let cfg = cx.global::<Config>();
(
@@ -9359,6 +9492,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)
}))
@@ -9647,6 +9786,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));
@@ -10835,7 +11003,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,
};
@@ -11215,6 +11383,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();
-1
View File
@@ -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"),
+19
View File
@@ -158,6 +158,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsCursor => "Cursor",
L10nKey::SettingsCursorShape => "Cursor shape",
L10nKey::SettingsCursorShapeDesc => "How the terminal cursor is drawn.",
L10nKey::SettingsPromptCursorShape => "Prompt cursor shape",
L10nKey::SettingsPromptCursorShapeDesc => {
"The cursor at the shell prompt. Follow uses the cursor shape above everywhere."
}
L10nKey::SettingsCursorBlink => "Cursor blink",
L10nKey::SettingsCursorBlinkDesc => "Pulse the cursor while the terminal is focused.",
L10nKey::SettingsLanguage => "Language",
@@ -823,6 +827,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"
@@ -855,6 +861,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsSearchCopyOnSelectKeywords => "clipboard selection yank mouse",
L10nKey::SettingsSearchCursorBlinkKeywords => "caret blinking flash",
L10nKey::SettingsSearchCursorShapeKeywords => "caret block bar underline beam",
L10nKey::SettingsSearchPromptCursorShapeKeywords => {
"prompt cursor shape caret block bar underline beam shell integration"
}
L10nKey::SettingsSearchCustomThemesKeywords => {
"theme duplicate edit colors folder yaml import background image wallpaper"
}
@@ -929,6 +938,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"
@@ -1540,6 +1555,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",
@@ -1831,6 +1848,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CursorShapeBlock => "Block",
L10nKey::CursorShapeBar => "Bar",
L10nKey::CursorShapeUnderline => "Underline",
L10nKey::PromptCursorShapeFollow => "Follow",
L10nKey::PaletteTryDifferentSearch => "Try a different search.",
L10nKey::CompletionListingRemote => "listing remote…",
L10nKey::CompletionRemoteListingFailed => "remote listing failed — {error}",
@@ -1908,6 +1926,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",
+19
View File
@@ -160,6 +160,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsCursor => "カーソル",
L10nKey::SettingsCursorShape => "カーソルの形状",
L10nKey::SettingsCursorShapeDesc => "ターミナルカーソルの描画方法",
L10nKey::SettingsPromptCursorShape => "プロンプトのカーソル形状",
L10nKey::SettingsPromptCursorShapeDesc => {
"シェルプロンプトでのカーソル。「追従」は上のカーソル形状をどこでも使う"
}
L10nKey::SettingsCursorBlink => "カーソルの点滅",
L10nKey::SettingsCursorBlinkDesc => {
"ターミナルがフォーカスされている間、カーソルを点滅させる"
@@ -833,6 +837,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"
}
@@ -881,6 +887,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchCursorShapeKeywords => {
"カーソル 形状 ブロック バー アンダーライン ビーム cursor shape caret block bar underline beam"
}
L10nKey::SettingsSearchPromptCursorShapeKeywords => {
"プロンプト カーソル 形状 ブロック バー 下線 prompt cursor shape caret block bar underline beam"
}
L10nKey::SettingsSearchCustomThemesKeywords => {
"テーマ 複製 編集 色 フォルダ 背景画像 壁紙 yaml インポート custom themes duplicate edit colors folder import background image wallpaper"
}
@@ -985,6 +994,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"
}
@@ -1604,6 +1619,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 => "作業ディレクトリをコピー",
@@ -1903,6 +1920,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CursorShapeBlock => "ブロック",
L10nKey::CursorShapeBar => "バー",
L10nKey::CursorShapeUnderline => "下線",
L10nKey::PromptCursorShapeFollow => "追従",
L10nKey::PaletteTryDifferentSearch => "別のキーワードを試してください。",
L10nKey::CompletionListingRemote => "リモートを一覧しています…",
L10nKey::CompletionRemoteListingFailed => "リモートの一覧に失敗しました — {error}",
@@ -1980,6 +1998,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 => "パスをコピー",
+13
View File
@@ -202,6 +202,8 @@ l10n_keys! {
SettingsCursor,
SettingsCursorShape,
SettingsCursorShapeDesc,
SettingsPromptCursorShape,
SettingsPromptCursorShapeDesc,
SettingsCursorBlink,
SettingsCursorBlinkDesc,
SettingsLanguage,
@@ -636,6 +638,8 @@ l10n_keys! {
SettingsAgentCrush,
SettingsAgentCodeBuddy,
SettingsAgentCursorCli,
SettingsAgentPrimeAgent,
SettingsAgentAntigravity,
SettingsSearchAppHttpProxyKeywords,
SettingsSearchAboutKeywords,
SettingsSearchAutoDownloadKeywords,
@@ -656,6 +660,7 @@ l10n_keys! {
SettingsSearchCopyOnSelectKeywords,
SettingsSearchCursorBlinkKeywords,
SettingsSearchCursorShapeKeywords,
SettingsSearchPromptCursorShapeKeywords,
SettingsSearchCustomThemesKeywords,
SettingsSearchDetectUrlsKeywords,
SettingsSearchDiffPreviewFromCountsKeywords,
@@ -695,6 +700,8 @@ l10n_keys! {
SettingsSearchCrushKeywords,
SettingsSearchCodeBuddyKeywords,
SettingsSearchCursorCliKeywords,
SettingsSearchPrimeAgentKeywords,
SettingsSearchAntigravityKeywords,
SettingsSearchRememberWindowSizeKeywords,
SettingsSearchReportMouseToAppsKeywords,
SettingsSearchRestoreLastLayoutKeywords,
@@ -1058,6 +1065,7 @@ l10n_keys! {
AppMenuZoomPane,
AppMenuClearScrollback,
AppMenuOpenLink,
AppMenuOpenLinkWithDefaultApp,
AppMenuRevealInFinder,
AppMenuRevealInFolder,
AppMenuCopyLinkPath,
@@ -1254,6 +1262,8 @@ l10n_keys! {
CmdSwapPanePrevious,
CmdNextTab,
CmdPreviousTab,
CmdMoveTabLeft,
CmdMoveTabRight,
CmdRecentTabSwitcher,
CmdRecentTabSwitcherReverse,
CmdCopyWorkingDirectory,
@@ -1482,6 +1492,7 @@ l10n_keys! {
CursorShapeBlock,
CursorShapeBar,
CursorShapeUnderline,
PromptCursorShapeFollow,
PaletteTryDifferentSearch,
CompletionListingRemote,
CompletionRemoteListingFailed,
@@ -1677,6 +1688,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
+19
View File
@@ -142,6 +142,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsCursor => "光标",
L10nKey::SettingsCursorShape => "光标形状",
L10nKey::SettingsCursorShapeDesc => "终端光标的绘制方式。",
L10nKey::SettingsPromptCursorShape => "提示符光标形状",
L10nKey::SettingsPromptCursorShapeDesc => {
"shell 提示符处的光标。跟随表示各处都用上面的光标形状。"
}
L10nKey::SettingsCursorBlink => "光标闪烁",
L10nKey::SettingsCursorBlinkDesc => "终端获得焦点时让光标闪烁。",
L10nKey::SettingsLanguage => "语言",
@@ -728,6 +732,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"
}
@@ -774,6 +780,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchCursorShapeKeywords => {
"光标形状 光标 块 竖线 下划线 cursor shape caret block bar underline beam"
}
L10nKey::SettingsSearchPromptCursorShapeKeywords => {
"提示符 光标形状 光标 块 竖线 下划线 prompt cursor shape caret block bar underline beam"
}
L10nKey::SettingsSearchCustomThemesKeywords => {
"自定义主题 复制 编辑 颜色 文件夹 背景图片 壁纸 yaml 导入 theme custom edit duplicate colors import background image wallpaper"
}
@@ -874,6 +883,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"
}
@@ -1445,6 +1460,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 => "复制工作目录",
@@ -1724,6 +1741,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CursorShapeBlock => "块状",
L10nKey::CursorShapeBar => "竖线",
L10nKey::CursorShapeUnderline => "下划线",
L10nKey::PromptCursorShapeFollow => "跟随",
L10nKey::PaletteTryDifferentSearch => "换个关键词试试。",
L10nKey::CompletionListingRemote => "正在列出远程目录…",
L10nKey::CompletionRemoteListingFailed => "远程目录列表失败——{error}",
@@ -1797,6 +1815,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuZoomPane => "缩放窗格",
L10nKey::AppMenuClearScrollback => "清除回滚内容",
L10nKey::AppMenuOpenLink => "打开",
L10nKey::AppMenuOpenLinkWithDefaultApp => "用默认程序打开",
L10nKey::AppMenuRevealInFinder => "在访达中显示",
L10nKey::AppMenuRevealInFolder => "打开所在文件夹",
L10nKey::AppMenuCopyLinkPath => "复制路径",
+15
View File
@@ -411,6 +411,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")),
@@ -727,6 +732,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(),
@@ -1427,6 +1440,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),
+8
View File
@@ -53,6 +53,8 @@ pub enum CommandKind {
SwapPanePrev,
SelectNextTab,
SelectPrevTab,
MoveTabLeft,
MoveTabRight,
ToggleMaximizePane,
ToggleFullscreen,
ToggleTabSidebar,
@@ -180,6 +182,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",
@@ -305,6 +309,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",
@@ -555,6 +561,8 @@ impl Item {
Item::localized(L10nKey::CmdSwapPanePrevious, SwapPanePrev),
Item::localized(L10nKey::CmdNextTab, SelectNextTab),
Item::localized(L10nKey::CmdPreviousTab, SelectPrevTab),
Item::localized(L10nKey::CmdMoveTabLeft, MoveTabLeft),
Item::localized(L10nKey::CmdMoveTabRight, MoveTabRight),
Item::localized(L10nKey::CmdCopyWorkingDirectory, CopyWorkingDirectory),
Item::localized(L10nKey::CmdCopySessionId, CopyAgentSessionId)
.with_subtitle(t(L10nKey::CmdCopySessionIdSubtitle)),
+75 -24
View File
@@ -24,7 +24,8 @@ use uuid::Uuid;
use crate::core::config::{
BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode,
SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop,
PromptCursorStyle, SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel,
WindowBackdrop,
};
use crate::core::keychain::{
CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents,
@@ -573,6 +574,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsCursorShape,
keywords: SettingsSearchCursorShapeKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsPromptCursorShape,
keywords: SettingsSearchPromptCursorShapeKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsCursorBlink,
@@ -799,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,
@@ -892,12 +908,43 @@ fn settings_search_entries() -> &'static [SearchEntry] {
]
}
/// The rows of the cursor shape dropdown, in `CURSOR_SHAPES` order.
pub(crate) fn cursor_shape_labels() -> Vec<String> {
[
L10nKey::CursorShapeBlock,
L10nKey::CursorShapeBar,
L10nKey::CursorShapeUnderline,
]
.into_iter()
.map(|key| t(key).to_string())
.collect()
}
pub(crate) const CURSOR_SHAPES: [CursorStyle; 3] =
[CursorStyle::Block, CursorStyle::Bar, CursorStyle::Underline];
/// The rows of the prompt cursor shape dropdown, in `PROMPT_CURSOR_SHAPES`
/// order: follow, then the shapes.
pub(crate) fn prompt_cursor_shape_labels() -> Vec<String> {
let mut rows = vec![t(L10nKey::PromptCursorShapeFollow).to_string()];
rows.extend(cursor_shape_labels());
rows
}
pub(crate) const PROMPT_CURSOR_SHAPES: [PromptCursorStyle; 4] = [
PromptCursorStyle::Follow,
PromptCursorStyle::Block,
PromptCursorStyle::Bar,
PromptCursorStyle::Underline,
];
impl SearchEntry {
fn config_key(&self) -> &'static str {
match self.title {
L10nKey::SettingsDimInactivePanes => "dim_inactive_panes",
L10nKey::SettingsCursorBlink => "cursor_blink",
L10nKey::SettingsCursorShape => "cursor_style",
L10nKey::SettingsPromptCursorShape => "prompt_cursor_style",
L10nKey::SettingsScrollback => "scrollback_limit",
L10nKey::SettingsNewTabPosition => "new_tab_position",
L10nKey::SettingsTabBarPosition => "tab_bar_position",
@@ -984,6 +1031,7 @@ impl SearchEntry {
L10nKey::SettingsFontLigatures => t(L10nKey::SettingsFontLigaturesDesc),
L10nKey::SettingsFontThicken => t(L10nKey::SettingsFontThickenDesc),
L10nKey::SettingsCursorShape => t(L10nKey::SettingsCursorShapeDesc),
L10nKey::SettingsPromptCursorShape => t(L10nKey::SettingsPromptCursorShapeDesc),
L10nKey::SettingsCursorBlink => t(L10nKey::SettingsCursorBlinkDesc),
L10nKey::SettingsBackgroundImage => t(L10nKey::SettingsBackgroundImageDesc),
L10nKey::SettingsImageOpacity => t(L10nKey::SettingsImageOpacityDesc),
@@ -1050,6 +1098,9 @@ impl SearchEntry {
}
L10nKey::SettingsCursorBlink => cfg.cursor_blink != defaults.cursor_blink,
L10nKey::SettingsCursorShape => cfg.cursor_style != defaults.cursor_style,
L10nKey::SettingsPromptCursorShape => {
cfg.prompt_cursor_style != defaults.prompt_cursor_style
}
L10nKey::SettingsScrollback => cfg.scrollback_limit != defaults.scrollback_limit,
L10nKey::SettingsNewTabPosition => cfg.new_tab_position != defaults.new_tab_position,
L10nKey::SettingsTabBarPosition => cfg.tab_bar_position != defaults.tab_bar_position,
@@ -1301,6 +1352,8 @@ pub(crate) struct SettingsState {
pub(crate) font_italic_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) ui_font_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) language_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) cursor_style_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) prompt_cursor_style_select: Entity<SelectState<SearchableVec<String>>>,
#[cfg(target_os = "windows")]
pub(crate) window_backdrop_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) shell_program_input: Entity<InputState>,
@@ -3525,8 +3578,14 @@ impl Tty7App {
),
None => return div().into_any_element(),
};
let (cursor_style_select, prompt_cursor_style_select) = match self.active_settings() {
Some(s) => (
s.cursor_style_select.clone(),
s.prompt_cursor_style_select.clone(),
),
None => return div().into_any_element(),
};
let cfg = cx.global::<Config>();
let cursor_style = cfg.cursor_style;
let cursor_blink = cfg.cursor_blink;
let font_thicken = cfg.font_thicken;
let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| {
@@ -3646,29 +3705,15 @@ impl Tty7App {
)
});
let cursor_idx = match cursor_style {
CursorStyle::Block => 0,
CursorStyle::Bar => 1,
CursorStyle::Underline => 2,
let cursor_dropdown = |state: &Entity<SelectState<SearchableVec<String>>>| {
Select::new(state)
.small()
.w(px(FIELD_W))
.h(px(24.))
.into_any_element()
};
let cursor_style_control = self.segmented(
"cursor-style",
&[
t(L10nKey::CursorShapeBlock),
t(L10nKey::CursorShapeBar),
t(L10nKey::CursorShapeUnderline),
],
cursor_idx,
cx,
|this, ix, _w, cx| {
let style = match ix {
0 => CursorStyle::Block,
1 => CursorStyle::Bar,
_ => CursorStyle::Underline,
};
this.set_cursor_style(style, cx);
},
);
let cursor_style_control = cursor_dropdown(&cursor_style_select);
let prompt_cursor_style_control = cursor_dropdown(&prompt_cursor_style_select);
let blink_switch = crate::ui::theme::switch("cursor-blink", cx)
.checked(cursor_blink)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx)))
@@ -3745,6 +3790,12 @@ impl Tty7App {
cursor_style_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsPromptCursorShape),
t(L10nKey::SettingsPromptCursorShapeDesc),
prompt_cursor_style_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsCursorBlink),
t(L10nKey::SettingsCursorBlinkDesc),
+93 -41
View File
@@ -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,