Merge main into feat/v4-redesign

Ports the v4 palette styling onto Search Everywhere (neutral selected row,
keycaps, esc cap, footer), and the new settings from main (prompt cursor
shape, auto grouping switch, SSH tab title) onto the rebuilt settings pages.
This commit is contained in:
l0ng-ai
2026-09-27 09:48:10 +08:00
119 changed files with 17871 additions and 3674 deletions
+256
View File
@@ -5,6 +5,262 @@ All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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
launched or seen running, and each is bindable as `LaunchAgent:<slug>`. "New
Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is
select-all) starts the one used last, and the New Tab menu's "Launch Agent…"
row opens the palette on the list. A launch always opens a new tab in the
current tab's directory and types the agent's command into its shell once the
pane exists, never into a pane that was already there, so detection, status
and resume work as for a hand-typed agent and quitting it returns to the
shell. The command is the agent's bare binary unless `agent_launch` in
`config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`);
a wrapper named there is detected as that agent without an `agent_commands`
entry, including a script run under its interpreter, and the daemon picks up
an edit without a restart. A running agent's pane menu has "Set Current Launch
Args as Default", which writes the flags it was started with — minus the
session it resumed and any prompt — into `agent_launch`. A remote workspace
cannot be asked for its `PATH`, so there the list is the agents already seen
running in that workspace.
- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a
tab → Hibernate, or "Hibernate Tab" in the command palette, stops every
process in the tab to give its memory back while the tab keeps its place in
the sidebar, faded and marked with a moon. Selecting it wakes it — or
right-click → Wake to warm it up without switching. Waking is the restore a
reboot already runs: each pane comes back in its old directory and shell,
opens on the screen it left, and a supported agent with a captured session
is resumed. A sleeping tab stays asleep across app and daemon restarts; the
tab on screen is always awake, so the last awake tab cannot be put to sleep.
`tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane
ls --json` carry a `hibernated` flag. Offered only where the machine's server
can do it (this build or newer).
- **A port forward can be switched off without losing its rule** (#439). A
forward could only be removed, so pointing one local port at a different
remote target meant deleting the rule and typing the other one in again. Each
forward in the Ports section, and each rule under Settings → SSH → Port
forwarding, now has a switch: off releases the listener and keeps the rule in
the list, on binds it again from the rule it was made from. Switching one on
while another switched-on forward holds its port is refused with a notice
naming that forward, rather than failing at bind time. A switch flipped in the
panel on a rule that came from a saved host is written back to that host, so
the next connection opens the same set; a rule saved switched off is listed
but not opened. Profiles saved before this load with every rule on.
- **SSH tabs can be named after the host instead of whatever the remote shell
titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is
*Dynamic* (the default, and what tty7 always did), *Profile name* — the saved
host's name, the alias for a `~/.ssh/config` host, the address typed for a
quick connect — or *Hostname*, the address dialled. Only the tab's name is
pinned: OSC 0/2 titles are still tracked and come back the moment it is
*Dynamic* again, a tab you renamed keeps its name, a split tab follows the
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>
(<kbd>Ctrl ⇧ P</kbd> elsewhere) opens one search over four tabs — **All**,
**Actions**, **Terminals** and **Hosts** — walked with <kbd>⇥</kbd> /
<kbd>⇧ ⇥</kbd>, keeping what is typed. **Terminals** lists every open tab of
every workspace, this window's most recently used first, and jumps to it
wherever it lives; the shells and agents it can open follow. **Hosts** holds
the saved SSH hosts and whatever address or `ssh …` command line is typed,
which replaces the separate input *SSH: Add Connection…* used to open. **All**
shows the best few rows of each, the tab with the best match first, and folds
the rest into a row that opens its tab. With nothing typed, <kbd>⌘ P</kbd>
<kbd>⏎</kbd> goes back to the tab you were just in. The keybinding action is
still `TogglePalette`, so a custom binding keeps working.
- **Resume a past agent session from Search Everywhere.** Its **Sessions** tab
lists the Claude Code and Codex sessions on this computer — those that ran in
the focused tab's directory first — by the title the agent gave them, with
directory, branch and age, and <kbd>⏎</kbd> resumes one in a new tab in the
directory it ran in, with the agent's configured launch flags. Only the ends
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. 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`,
`tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for.
The Local section now lists the default shell, always first, and then only
shells that have actually been opened, by frecency — three rows at most, the
way the SSH section already caps its hosts. The rest sit behind an "Other
Shells…" row that opens the command palette filtered to them: every shell is
now a palette command, "Shell: {name}", and running one does what its menu
row does, splitting instead when ⌥ (Alt) is held. The row is left out when
the menu already names the whole list.
- **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so
`config.json` can be synced between machines for its colours and keys
without carrying a list of servers. `ssh_profiles` and
`ssh_profile_frecency` move across by themselves the first time a new build
reads an older `config.json`: `servers.json` is written first (mode `0600`),
and only once it has landed are the two keys taken out of `config.json` —
nothing else in that file is touched, including keys this build does not
know. When both files hold hosts, `servers.json` wins and the stale copy in
`config.json` is dropped at its next save. A `servers.json` that cannot be
parsed is kept aside as `servers.json.corrupt` and saving is refused until it
is repaired, the same rule `config.json` has. Hand edits to `servers.json`
hot-reload. Passwords and passphrases stay in the OS keychain.
- **`tty7 exec` runs a command in a pane that already exists and hands back its
result** (#839). `run` makes a new pane and `send` does not wait, so every
script that wanted a command's exit code from a shell it already had ended up
as `send`, a sleep, a `capture` and a guess, or an `echo $? > /tmp/rc` side
channel. `tty7 exec %3 -- cargo test` types the line at the pane's prompt,
follows the shell integration's marks to the command's end, prints what it
printed — as text, the way `capture --plain` reads it, or escapes intact with
`--raw` — and exits with its exit code. `--timeout` gives up with 124 and
leaves the command running; a pane with no prompt marks, or one that is not
at a prompt, is refused before anything is typed instead of waited on.
- **`tty7 send` takes its text from stdin or a file, and can paste it**
(#838). A token passed as `send %1 "$TOKEN"` sits in the CLI's command line,
where any local user reads it in `ps`, and in the caller's shell history;
`--stdin` and `--from-file` send the bytes exactly as read and never echo
them in `--json`. `--paste` sends the text the way a paste into the window
does — in bracketed paste when the pane has switched it on, so a multi-line
text arrives as text instead of running line by line, with any ESC stripped
so the text cannot close the paste itself. A pane without the mode gets the
same unframed paste the GUI would send it, and `--json` says which one went.
The server now reports each pane's bracketed-paste mode in `tty7 procs`.
- **The source control panel's changed files can be filtered and shown as a
tree** (#473). A filter field sits above the list: every word typed has to
appear somewhere in a file's path, in any order and any case, and the group
headers' counts and their stage/unstage/discard-all buttons follow what the
filter leaves on screen. The tile at its end switches the list to a
directory tree — directories before files, a chain of directories that hold
nothing but the next one compacted into a single row (`crates/core/src`),
each directory row foldable and counting the files beneath it. A filter
opens every folded directory while it is set. The choice between list and
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
- **The *By repo or folder* grouping mode, "Group Automatically", and groups
stored by name.** A folder you want grouped is pinned instead, and dragging a
tab below the divider is the way back to automatic grouping. Hand-made groups
and folds from earlier versions are not carried over. The control dialect
moves to v11, so each remote host needs one Update Server, which ends the
sessions on it.
### Fixed
- **Return runs the top row after a search that found nothing.** Backspacing
from a query with no results to one with some — or opening the search
already filtered, as the New Tab menu's *Other Shells…* and *Launch Agent…*
rows do — left no row selected, so Return did nothing until an arrow key was
pressed.
- **A typed `ssh -p 2222 me@box` is no longer offered as an address.** The
address parser read everything before the `@` as the user name and offered
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 —
every icon in a coloured Powerline or p10k segment — was held to one cell
and shrunk to about two thirds of the text's height. Icons with a plain
space after them were already drawn full size, so one prompt had icons in
two sizes. A lone Private Use Area glyph supplied by a fallback face is now
fitted, aspect ratio kept, to its cells and one em of height, taking the
blank after it when that blank paints no background or the icon's own and
when doing so makes it bigger, and centred in the row; a face whose icons
ink less than a cell grows (at most 2×). The Powerline separators, CJK,
emoji, other text and the primary font's own icons are drawn as before.
Not on Linux, where the text system reports a glyph's advance box rather
than its ink.
## [26.9.3] - 2026-09-23
### Added
+8 -4
View File
@@ -52,10 +52,10 @@ 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 |
| **Agent-aware** | per-pane detection (25 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups |
| **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` |
| **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · <kbd>⌘ J</kbd> panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME |
| **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 |
| **Shell integration** | injected when a pane starts, nothing to install · prompt marks · working directory · exit codes · command-finished notifications · zsh, bash, fish, PowerShell, WSL, remote panes |
| **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off |
| **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install |
@@ -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,19 +90,22 @@ that tells tty7 which session to fork.
| **Kimi Code** | ✓ | ✓ | |
| **Pi** | ✓ | ✓ | |
| **Crush** | ✓ | ✓ | |
| **Antigravity** | ✓ | ✓ | |
| **Cursor** | ✓ | ✓ | |
| Aider | ✓ | | |
| Amp | ✓ | | |
| Auggie | ✓ | | |
| Hermes | ✓ | | |
| Vibe | ✓ | | |
| Antigravity | ✓ | | |
| Empryo | ✓ | | |
</details>
None of them are wrapped or proxied — the agent you start is the agent you get,
in a normal PTY, with its own interface. An agent launched through a wrapper
script can be mapped to one by name with `agent_commands` in `config.json`.
Every agent on your `PATH` is also a row in Search Everywhere (**Agent: Claude Code**, …)
that opens it in a new tab; `agent_launch` sets the command line it starts with.
## Documentation
+5 -2
View File
@@ -54,7 +54,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
| **Agent 感知** | 逐 pane 识别 20 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 侧边栏按仓库分组 |
| **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` 转发命令输出并原样返回退出码 · `split` · `send` · `wait --until free` · `capture` |
| **编辑器级输入** | 从历史推出影子建议 · Tab 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · <kbd>⌃ R</kbd> 模糊搜索历史 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 命令面板 · <kbd>⌘ F</kbd> 回滚搜索 · <kbd>⌘ J</kbd> 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 随处搜索 · <kbd>⌘ F</kbd> 回滚搜索 · <kbd>⌘ J</kbd> 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 |
| **Shell 集成** | pane 启动时自动注入,不用你装什么 · 提示符边界 · 工作目录 · 退出码 · 命令跑完发通知 · 覆盖 zsh、bash、fish、PowerShell、WSL 和远程 pane |
| **远程工作区** | 远端的文件、仓库、改动、diff、worktree、标签页和 pane · 从任意客户端重连,接着离开时的位置继续 |
| **SSH** | 自带 russh 实现,不依赖外部 ssh:profile 凭据存入 keychain · SFTP 面板 · 端口转发 · 跳板机 · `tty7-server` 只需安装一次,无需 root |
@@ -77,6 +77,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
| **Grok** | ✓ | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ |
| **Prime Agent** | ✓ | ✓ | ✓ |
| **Droid** | ✓ | ✓ | ✓ |
| **Qwen Code** | ✓ | ✓ | ✓ |
| **Goose** | ✓ | ✓ | ✓ |
@@ -84,18 +85,20 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
| **Copilot** | ✓ | ✓ | |
| **Kimi Code** | ✓ | ✓ | |
| **Pi** | ✓ | ✓ | |
| **Antigravity** | ✓ | ✓ | |
| Aider | ✓ | | |
| Amp | ✓ | | |
| Cursor | ✓ | | |
| Auggie | ✓ | | |
| Hermes | ✓ | | |
| Vibe | ✓ | | |
| Antigravity | ✓ | | |
| Empryo | ✓ | | |
</details>
tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。
如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。
PATH 上的每个 agent 也都能在随处搜索里找到(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。
## 文档
+1
View File
@@ -0,0 +1 @@
<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="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<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="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+1
View File
@@ -0,0 +1 @@
<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 3v8.6"/><path d="M17.9 6.6a8.4 8.4 0 1 1-11.8 0"/></svg>

After

Width:  |  Height:  |  Size: 253 B

+39
View File
@@ -61,6 +61,17 @@ pub trait Backend {
fn run_wait(&mut self) -> Result<Option<i32>>;
/// Type `line` at the pane's shell prompt and follow the pane until its
/// prompt comes back, the deadline passes, or it exits. Refuses — sending
/// nothing — a pane with no shell integration or one that is not at a
/// prompt; see [`ExecEnd`](crate::exec::ExecEnd).
fn exec(
&mut self,
pane: u64,
line: Vec<u8>,
timeout: Option<std::time::Duration>,
) -> Result<crate::exec::ExecRun>;
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>;
}
@@ -78,6 +89,7 @@ pub mod mock {
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs};
use super::{Backend, CaptureSegment, RunSpec};
use crate::exec::{ExecEnd, ExecRun};
pub struct MockBackend {
pub machine: Machine,
@@ -103,6 +115,8 @@ pub mod mock {
pub events: Vec<ControlEvent>,
/// Set to make `hello` fail — doctor's unreachable-server branch.
pub unreachable: bool,
pub execs: Vec<(u64, Vec<u8>, Option<std::time::Duration>)>,
pub exec_run: ExecRun,
}
impl Default for MockBackend {
@@ -127,6 +141,21 @@ pub mod mock {
run_exit: Some(0),
events: Vec::new(),
unreachable: false,
execs: Vec::new(),
exec_run: ExecRun {
end: ExecEnd::Finished {
exit: Some(0),
ran: true,
},
output: Vec::new(),
dropped: 0,
size: tty7_core::daemon::protocol::WinSize {
cols: 80,
rows: 24,
cell_w: 8,
cell_h: 16,
},
},
}
}
}
@@ -219,6 +248,16 @@ pub mod mock {
Ok(self.run_exit)
}
fn exec(
&mut self,
pane: u64,
line: Vec<u8>,
timeout: Option<std::time::Duration>,
) -> Result<ExecRun> {
self.execs.push((pane, line, timeout));
Ok(self.exec_run.clone())
}
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
for event in self.events.drain(..) {
on_event(event)?;
+111
View File
@@ -13,6 +13,7 @@ use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, Win
use tty7_core::daemon::router::RouteTarget;
use super::{Backend, CaptureSegment, RunSpec};
use crate::exec::{ExecEnd, ExecRun, Transcript};
const SESSION_SIZE: WinSize = WinSize {
cols: 120,
@@ -23,6 +24,9 @@ const SESSION_SIZE: WinSize = WinSize {
const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10);
const REPLAY_SETTLE: Duration = Duration::from_millis(300);
/// A backstop on reading the replay: it ends on the first live output or a
/// quiet spell, and this only bounds a daemon that keeps sending status.
const EXEC_REPLAY_MAX: Duration = Duration::from_secs(10);
const NOT_RUNNING: &str =
"could not reach the tty7 server on this machine — `tty7 server start` brings one up";
@@ -275,6 +279,113 @@ impl Backend for RealBackend {
Ok(code)
}
fn exec(&mut self, pane: u64, line: Vec<u8>, timeout: Option<Duration>) -> Result<ExecRun> {
let deadline = timeout.and_then(|t| std::time::Instant::now().checked_add(t));
// Watching starts before anything is typed, so no byte the command
// prints can go past unseen. The replay that comes first is only read
// for the pane's state — the daemon sends its prompt report last.
let mut session = self
.pane_client()?
.observe(pane, SESSION_SIZE)
.with_context(|| format!("observing pane %{pane}"))?;
let mut size = SESSION_SIZE;
// (integration active, at a prompt), as the daemon last reported them.
let mut prompt: Option<(bool, bool)> = None;
let until = std::time::Instant::now() + EXEC_REPLAY_MAX;
let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT));
let finish = |session: PaneSession, end, size| -> Result<ExecRun> {
let _ = session.detach();
Ok(ExecRun {
end,
output: Vec::new(),
dropped: 0,
size,
})
};
loop {
match session.recv() {
Ok(DaemonMsg::Size(seen)) => size = seen,
Ok(DaemonMsg::Prompt {
active, at_prompt, ..
}) => prompt = Some((active, at_prompt)),
Ok(DaemonMsg::Exited { code }) => {
return finish(session, ExecEnd::PaneExited(code), size);
}
// Live output: the replay is over, and so is `capture`'s
// reading of it. A pane still printing is not at a prompt,
// and its report has already said so.
Ok(DaemonMsg::Output(_)) => break,
Ok(_) => {}
Err(e) if timed_out(&e) => break,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return finish(session, ExecEnd::PaneExited(None), size);
}
Err(e) => return Err(anyhow!(e).context("reading the pane replay")),
}
if std::time::Instant::now() >= until {
break;
}
let _ = session.set_recv_timeout(Some(REPLAY_SETTLE));
}
match prompt {
Some((true, true)) => {}
Some((true, false)) => return finish(session, ExecEnd::Busy, size),
_ => return finish(session, ExecEnd::NoIntegration, size),
}
self.send_input(pane, line)?;
let mut transcript = Transcript::new();
let end = loop {
let wait = match deadline {
Some(d) => {
let left = d.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
break ExecEnd::TimedOut {
started: transcript.started(),
};
}
Some(left)
}
None => None,
};
let _ = session.set_recv_timeout(wait);
match session.recv() {
Ok(DaemonMsg::Output(bytes)) => {
transcript.output(&bytes);
transcript.compact();
}
Ok(DaemonMsg::Size(seen)) => size = seen,
Ok(DaemonMsg::Prompt {
at_prompt,
last_exit,
..
}) => {
if let Some(done) = transcript.prompt(at_prompt, last_exit) {
break ExecEnd::Finished {
exit: done.exit,
ran: done.ran,
};
}
}
Ok(DaemonMsg::Exited { code }) => break ExecEnd::PaneExited(code),
Ok(_) => {}
Err(e) if timed_out(&e) => continue,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break ExecEnd::PaneExited(None);
}
Err(e) => return Err(anyhow!(e).context("following the command's output")),
}
};
let _ = session.detach();
let (output, dropped) = transcript.take();
Ok(ExecRun {
end,
output,
dropped,
size,
})
}
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
let client = self.control_client()?;
for event in client.events() {
+144 -1
View File
@@ -72,11 +72,35 @@ pub enum Command {
// written out here: a hand-copied vocabulary drifts the first time a key
// is added, and this is the text a caller reaches for to learn the names.
#[command(
about = "Type text into a pane, or send it keystrokes with --key",
about = "Type text into a pane — from an argument, --stdin or --from-file — or send \
it keystrokes with --key",
long_about = crate::keys::send_long_help()
)]
Send(SendArgs),
// `run` is the other half of this pair, and the split is docker's: `run`
// makes a new place for a command, `exec` uses one that already exists.
// Not a flag on `run` — that verb execs an argv in a fresh pty, while this
// types a line into a shell that is already sitting at its prompt, and
// none of `run`'s flags (`--keep`, `--cwd`, `--ws`) mean anything here.
#[command(
about = "Run a command line in an existing pane's shell, wait for its prompt to \
come back, print what the command printed, and exit with its exit code",
long_about = "Run a command line in an existing pane's shell, wait for its prompt to \
come back, print what the command printed, and exit with its exit code.\n\n\
The line is typed at the shell's prompt, exactly as `send … --enter` \
would, so it runs with that shell's cwd, environment and history. The \
pane's shell integration is what says where the command's output starts \
and ends and what its exit code was: a pane with none (no OSC 133 prompt \
marks yet) is refused rather than waited on, and so is a pane that is not \
at a prompt. The output is what the pane printed between the command \
starting and the prompt coming back, replayed through a terminal to \
text the way `capture --plain` does — or as raw bytes with `--raw`.\n\n\
A line the shell never runs — an unclosed quote waiting on a continuation \
prompt — has no end to wait for; pass --timeout in anything unattended."
)]
Exec(ExecArgs),
#[command(
about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \
escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \
@@ -158,6 +182,47 @@ pub struct RunArgs {
pub cmd: Vec<String>,
}
#[derive(Debug, Args)]
pub struct ExecArgs {
#[arg(
value_name = "%PANE",
help = "Pane whose shell runs the command; defaults to $TTY7_PANE inside a tty7 shell"
)]
pub target: Option<String>,
#[arg(
long,
value_name = "SECS",
help = "Give up after this many seconds, with exit code 124; the command is left \
running in the pane"
)]
pub timeout: Option<u64>,
// Text is the default here, unlike `capture`: what a caller of this verb
// wants is the answer, and the raw bytes between the marks are not only
// the command's — zsh draws its PROMPT_SP (the `%` that marks a missing
// newline, then a row of spaces and a CR) *before* its precmd hooks run,
// so before the `D` mark. Replayed through a grid that erases itself, as
// it does on screen; left raw it is a line of noise after every command.
#[arg(
long,
help = "Print the output as the pane received it, escapes intact, instead of \
replaying it through a terminal to text (the `capture` default)"
)]
pub raw: bool,
// Joined with spaces and handed to the pane's shell as one line, which is
// what separates this from `run`: `tty7 exec %3 -- 'make && ls'` means the
// shell's `&&`, where `run` would look for a program called `make && ls`.
#[arg(
last = true,
required = true,
value_name = "CMD",
help = "The command line, after `--`: tty7 exec %3 -- cargo test"
)]
pub cmd: Vec<String>,
}
#[derive(Debug, Args)]
#[command(group = ArgGroup::new("axis").required(true))]
pub struct SplitArgs {
@@ -221,6 +286,34 @@ pub struct SendArgs {
(C-c, escape, up, enter, …). See `tty7 send --help`"
)]
pub keys: Vec<crate::keys::Key>,
// The other place the text can come from. A secret in an argument is in
// this process's command line, where every local user can read it in
// `ps`, and in the caller's own shell history; reading it from a pipe or a
// file keeps it out of both. The bytes go to the pty exactly as read.
#[arg(
long,
conflicts_with_all = ["from_file", "second"],
help = "Send what stdin holds, byte for byte, instead of TEXT — keeps a secret \
out of `ps` and your shell history"
)]
pub stdin: bool,
#[arg(
long,
value_name = "PATH",
conflicts_with = "second",
help = "Send the file's contents, byte for byte, instead of TEXT"
)]
pub from_file: Option<std::path::PathBuf>,
#[arg(
long,
help = "Send the text as a paste, the way the GUI pastes: framed in bracketed \
paste when the pane has switched it on, so the lines of a multi-line text \
arrive as text rather than each running on its own"
)]
pub paste: bool,
}
/// One resting place a `wait` can end on. Three ontologies meet here, which is
@@ -945,4 +1038,54 @@ mod tests {
let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err();
assert_eq!(err.exit_code(), 2);
}
#[test]
fn exec_takes_an_address_then_the_command_after_the_separator() {
let Some(Command::Exec(args)) = parse(&[
"tty7",
"exec",
"%3",
"--timeout",
"5",
"--",
"make",
"--raw",
])
.command
else {
panic!("exec did not parse");
};
assert_eq!(args.target.as_deref(), Some("%3"));
assert_eq!(args.timeout, Some(5));
assert!(!args.raw, "after `--` a flag belongs to the command");
assert_eq!(args.cmd, ["make", "--raw"]);
let Some(Command::Exec(args)) = parse(&["tty7", "exec", "--", "ls"]).command else {
panic!("exec without an address did not parse");
};
assert_eq!(args.target, None, "the pane then comes from $TTY7_PANE");
let err = Cli::try_parse_from(["tty7", "exec", "%3"]).unwrap_err();
assert_eq!(err.exit_code(), 2, "a command is required");
}
#[test]
fn send_reads_its_text_from_one_place() {
let Some(Command::Send(args)) =
parse(&["tty7", "send", "%3", "--stdin", "--paste", "--enter"]).command
else {
panic!("send --stdin did not parse");
};
assert!(args.stdin && args.paste && args.enter);
assert_eq!(args.first.as_deref(), Some("%3"));
for bad in [
vec!["tty7", "send", "%3", "text", "--stdin"],
vec!["tty7", "send", "%3", "text", "--from-file", "f"],
vec!["tty7", "send", "%3", "--stdin", "--from-file", "f"],
] {
let err = Cli::try_parse_from(&bad).unwrap_err();
assert_eq!(err.exit_code(), 2, "{bad:?} should be a usage error");
}
}
}
+700 -19
View File
@@ -11,9 +11,10 @@ use tty7_core::daemon::protocol::PROTOCOL_VERSION;
use crate::address::{self, Context, WorkspaceAddress};
use crate::backend::{Backend, RunSpec};
use crate::cli::{
CaptureArgs, Cli, Command, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, SplitArgs,
TabCmd, WaitArgs, WaitState, WsCmd,
CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd,
SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd,
};
use crate::exec::ExecEnd;
use crate::output;
use crate::resolve;
use crate::screen;
@@ -78,6 +79,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
pane_split(args, ctx, backend)
}
Some(Command::Send(args)) => send(args, ctx, backend),
Some(Command::Exec(args)) => exec(args, ctx, backend),
Some(Command::Capture(args)) => capture(args, ctx, backend),
Some(Command::Procs { target }) => procs(target.as_deref(), ctx, backend),
Some(Command::Tab(TabCmd::Ls { ws })) => tab_ls(ws.as_deref(), ctx, backend),
@@ -486,8 +488,6 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu
}
fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
const KEY_GAP: Duration = Duration::from_millis(200);
// `--enter` is the same thing as `--key enter`, and predates it. Keeping it
// as sugar rather than deprecating it: it reads better for the overwhelming
// case, which is typing one command and running it. Going through the same
@@ -500,6 +500,43 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
pressed.push(crate::keys::parse("enter").expect("enter is in the vocabulary"));
}
// Text from stdin or a file has no positional of its own, so the one
// positional there is can only be an address — and a word that is not one
// is a mistake, not text to type into the caller's own pane.
if args.stdin || args.from_file.is_some() {
let source = if args.stdin { "--stdin" } else { "--from-file" };
if let Some(first) = &args.first {
address::parse_pane(first).with_context(|| {
format!(
"with {source} the text comes from there, so '{first}' has to be the \
pane to send it to"
)
})?;
}
let pane = address::pane_or_context(args.first.as_deref(), ctx)?;
let payload = match &args.from_file {
Some(path) => {
std::fs::read(path).with_context(|| format!("reading {}", path.display()))?
}
None => {
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut std::io::stdin(), &mut bytes)
.context("reading stdin")?;
bytes
}
};
return deliver(
pane,
Payload::Piped {
source: if args.stdin { "stdin" } else { "file" },
bytes: payload,
},
&args,
pressed,
backend,
);
}
// Three shapes reach here, and only the address is ever ambiguous:
// `send %3 "text"`, `send "text"` (this pane), and — new with --key —
// `send %3 --key C-c`, where there is no text at all and the lone
@@ -555,9 +592,75 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
};
let pane = address::pane_or_context(target, ctx)?;
let payload = match text {
Some(text) => Payload::Typed(text.to_string()),
None => Payload::None,
};
deliver(pane, payload, &args, pressed, backend)
}
/// What `send` has to put in front of the keys.
enum Payload {
None,
/// From the command line — echoed back in `--json`, as it always was.
Typed(String),
/// From stdin or a file. Never echoed: the reason to use these is to keep
/// the bytes out of places other people can read, and a JSON line in a
/// log is one of them.
Piped {
source: &'static str,
bytes: Vec<u8>,
},
}
fn deliver(
pane: u64,
payload: Payload,
args: &SendArgs,
pressed: Vec<crate::keys::Key>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
const KEY_GAP: Duration = Duration::from_millis(200);
let (source, bytes) = match &payload {
Payload::None => ("none", &[][..]),
Payload::Typed(text) => ("argument", text.as_bytes()),
Payload::Piped { source, bytes } => (*source, &bytes[..]),
};
if args.paste && bytes.is_empty() {
bail!("--paste needs text to paste — TEXT, --stdin or --from-file");
}
// `--paste` frames the way the GUI's paste does, which is to ask the pane
// first: brackets for a pane that switched mode 2004 on, and for one that
// did not, the line breaks a keyboard would send. Forcing the brackets on
// a pane that never asked for them would put `ESC[200~` in front of a
// program that reads it as keystrokes. `None` is a server that cannot say.
let mode = if args.paste {
backend.procs(pane)?.context.and_then(|c| c.bracketed_paste)
} else {
None
};
let bracketed = mode == Some(true);
if args.paste && !bracketed {
eprintln!(
"tty7: pane %{pane} {} bracketed paste, so the text went unframed — each line \
break was sent as Enter",
if mode.is_none() {
"is on a server too old to say whether it has"
} else {
"has not switched on"
}
);
}
let wire = if args.paste {
tty7_core::core::paste::paste_bytes(bytes, bracketed)
} else {
bytes.to_vec()
};
let mut already_wrote = false;
if let Some(text) = text {
backend.send_input(pane, text.as_bytes().to_vec())?;
if !wire.is_empty() {
backend.send_input(pane, wire)?;
already_wrote = true;
}
for key in &pressed {
@@ -576,7 +679,18 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
"",
json!({
"pane": pane,
"sent": text.unwrap_or_default(),
"sent": match &payload {
Payload::Typed(text) => Some(text.as_str()),
Payload::None => Some(""),
Payload::Piped { .. } => None,
},
"source": source,
"bytes": bytes.len(),
"paste": args.paste,
// Whether the paste went framed, and what the pane said about the
// mode — `null` when nothing was asked or the server cannot say.
"bracketed": bracketed,
"bracketed_paste_mode": mode,
"enter": args.enter,
"keys": pressed.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(),
}),
@@ -594,6 +708,132 @@ fn tried_to_write_an_address(s: &str) -> bool {
.is_some_and(|c| c.is_ascii_digit())
}
fn exec(args: ExecArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
let line = args.cmd.join(" ");
if line.trim().is_empty() {
bail!("exec needs a command after `--`");
}
// The line is typed, so every byte of it goes through the shell's line
// editor: a newline would submit half of it and a Tab would complete. One
// line of printable text is the only thing that runs as written — the same
// test the GUI applies before it types a line rather than pasting it.
if line.chars().any(char::is_control) {
bail!(
"exec types one line at the shell's prompt, and this one holds a control \
character (a newline, a tab, an escape) the line editor would act on — join \
the commands with `;` or `&&`, or put them in a script and exec that"
);
}
let mut wire = line.clone().into_bytes();
wire.push(b'\r');
let started = std::time::Instant::now();
let run = backend.exec(pane, wire, args.timeout.map(Duration::from_secs))?;
let elapsed_ms = started.elapsed().as_millis() as u64;
// The caller's own pane is the one pane that can never be at a prompt
// while this runs: its shell is busy running `tty7 exec`.
let own = ctx
.pane
.as_deref()
.and_then(|p| address::parse_pane(p).ok())
== Some(pane);
match run.end {
ExecEnd::NoIntegration => bail!(
"pane %{pane} has sent no shell-integration prompt marks, so nothing would say \
when a command there ends — exec needs tty7's shell integration loaded in the \
pane's shell (`tty7 doctor`); without it, `send … --enter` and \
`wait --until free` are the way"
),
ExecEnd::Busy if own => bail!(
"pane %{pane} is this shell, and it is busy running this very command — exec \
into another pane"
),
ExecEnd::Busy => bail!(
"pane %{pane} is not at a shell prompt — something is already running there. \
`tty7 wait %{pane} --until free` waits for it"
),
_ => {}
}
let text = if args.raw {
String::from_utf8_lossy(&run.output).into_owned()
} else {
screen::render(&[crate::backend::CaptureSegment {
size: run.size,
bytes: run.output.clone(),
}])
};
if run.dropped > 0 {
eprintln!(
"tty7: exec %{pane}: the first {} bytes of output were dropped to keep the rest \
in memory — the pane's own scrollback may still hold them",
run.dropped
);
}
let (code, known, timed_out) = match run.end {
ExecEnd::Finished {
exit: Some(code),
ran: true,
} => (code, true, false),
ExecEnd::Finished {
exit: None,
ran: true,
} => {
eprintln!("tty7: {EXIT_CODE_UNKNOWN}");
(1, false, false)
}
ExecEnd::Finished { ran: false, .. } => {
eprintln!(
"tty7: pane %{pane}: the shell came back to its prompt without running the \
line — a syntax error, most likely; what it printed is the output"
);
(1, false, false)
}
ExecEnd::TimedOut { started: true } => {
eprintln!(
"tty7: pane %{pane}: still running after {}s — timed out, and left running \
(`tty7 send %{pane} --key C-c` interrupts it)",
args.timeout.unwrap_or_default()
);
(124, false, true)
}
ExecEnd::TimedOut { started: false } => {
eprintln!(
"tty7: pane %{pane}: timed out after {}s, and the shell never started the \
line — it is most likely waiting on a continuation prompt (an unclosed \
quote or bracket); `tty7 send %{pane} --key C-c` abandons it",
args.timeout.unwrap_or_default()
);
(124, false, true)
}
ExecEnd::PaneExited(_) => {
eprintln!("tty7: pane %{pane} exited before its shell came back to a prompt");
(1, false, false)
}
ExecEnd::NoIntegration | ExecEnd::Busy => unreachable!("refused above"),
};
// `-q` keeps the exit code and drops the output, which is what the global
// flag already does to any report.
Ok(Outcome::Exit(
code,
Report {
human: text.clone(),
json: json!({
"pane": pane,
"command": line,
"exit": code,
"exit_code_known": known,
"timed_out": timed_out,
"pane_exited": matches!(run.end, ExecEnd::PaneExited(_)),
"output": text,
"bytes": run.output.len(),
"elapsed_ms": elapsed_ms,
}),
},
))
}
fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
let segments = backend.capture(pane, args.scrollback)?;
@@ -678,12 +918,11 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
vec![
format!("@{}", resolve::ordinal_of(&machine, tab.id).unwrap_or(0)),
output::tab_label(view),
// The GUI files tabs under a directory and shows its last
// segment as the heading; the full path would be the widest
// column in the table for no gain.
tab.sidebar_group
.as_deref()
.map(|g| output::path_leaf(g).to_string())
// Only a pinned group is the workspace's to report: an auto
// group is worked out by the GUI from a probe of the tab's
// cwd, which the tree does not hold.
pinned_group_of(ws, tab)
.map(output::pinned_group_label)
.unwrap_or_else(|| "-".to_string()),
tab.root.pane_ids().len().to_string(),
]
@@ -702,8 +941,13 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
"name": tab.name,
"label": output::tab_label(view),
"agent": view.agent.map(|a| a.display_name()),
"group": tab.sidebar_group,
"group": pinned_group_of(ws, tab).map(|g| json!({
"id": g.id.to_string(),
"name": g.given_name(),
"folder": g.folder,
})),
"panes": tab.root.pane_ids(),
"hibernated": tab.hibernated,
})
})
.collect();
@@ -713,6 +957,16 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
)
}
/// The pinned group `tab` is filed under, when it names one the workspace
/// still has. A tab naming a deleted group is an auto tab, the same way the
/// sidebar draws it.
fn pinned_group_of<'a>(
ws: &'a tty7_core::core::machine::Workspace,
tab: &tty7_core::core::machine::Tab,
) -> Option<&'a tty7_core::core::group_key::PinnedGroup> {
ws.groups.get(tab.group?)
}
fn tab_new(
explicit: Option<&str>,
cwd: Option<String>,
@@ -907,6 +1161,7 @@ fn pane_ls(explicit: Option<&str>, all: bool, backend: &mut dyn Backend) -> Resu
"tab": tab.id.to_string(),
"cwd": record.and_then(|r| r.cwd.clone()),
"live": record.map(|r| r.live),
"hibernated": tab.hibernated,
}));
}
}
@@ -2229,10 +2484,22 @@ mod tests {
);
}
/// File tab @2 under a pinned folder group, the way the GUI would.
fn pin_second_tab(backend: &mut MockBackend, name: Option<&str>) -> String {
use tty7_core::core::group_key::PinnedGroup;
let mut group = PinnedGroup::folder(std::path::Path::new("C:\\proj\\sub"));
group.name = name.map(str::to_string);
let id = group.id;
let ws = &mut backend.machine.workspaces[0];
ws.groups.pinned.push(group);
ws.tabs[1].group = Some(id);
id.to_string()
}
#[test]
fn tab_ls_names_an_unnamed_tab_and_shows_the_leaf_of_its_group() {
let mut backend = mock();
backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into());
pin_second_tab(&mut backend, None);
let out = run_cli(
&["tty7", "tab", "ls", "api"],
@@ -2241,17 +2508,55 @@ mod tests {
);
// @1 was named; @2 was not, so it borrows the leaf of its cwd. The
// GROUP column is the heading's last segment, not the whole path.
// GROUP column is what the header reads — the folder's last segment
// for a group nobody renamed, not the whole path.
assert_eq!(
human(out),
"TAB NAME GROUP PANES\n@1 build - 1\n@2 proj sub 2\n"
);
}
#[test]
fn tab_ls_shows_a_renamed_group_by_its_name() {
let mut backend = mock();
pin_second_tab(&mut backend, Some("work"));
let out = run_cli(
&["tty7", "tab", "ls", "api"],
&Context::default(),
&mut backend,
);
assert_eq!(
human(out),
"TAB NAME GROUP PANES\n@1 build - 1\n@2 proj work 2\n"
);
}
/// A tab naming a group the workspace no longer has is an auto tab, as
/// the sidebar draws it.
#[test]
fn tab_ls_shows_no_group_for_a_tab_naming_a_deleted_one() {
let mut backend = mock();
pin_second_tab(&mut backend, None);
backend.machine.workspaces[0].groups.pinned.clear();
let out = run_cli(
&["tty7", "tab", "ls", "api"],
&Context::default(),
&mut backend,
);
assert_eq!(
human(out),
"TAB NAME GROUP PANES\n@1 build - 1\n@2 proj - 2\n"
);
}
#[test]
fn tab_ls_json_keeps_the_literal_name_beside_the_label() {
let mut backend = mock();
backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into());
let id = pin_second_tab(&mut backend, None);
let out = run_cli(
&["tty7", "tab", "ls", "api"],
@@ -2266,9 +2571,11 @@ mod tests {
assert_eq!(tabs[1]["name"], Value::Null, "nobody named this tab");
assert_eq!(tabs[1]["label"], "proj", "the table's stand-in travels too");
assert_eq!(
tabs[1]["group"], "C:\\proj\\sub",
"the JSON keeps the whole heading the table abbreviates"
tabs[1]["group"],
json!({ "id": id, "name": null, "folder": "C:\\proj\\sub" }),
"the JSON keeps the whole folder the table abbreviates"
);
assert_eq!(tabs[0]["group"], Value::Null, "an auto tab names no group");
}
#[test]
@@ -3446,6 +3753,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen: false,
bracketed_paste: None,
}
}
@@ -3472,6 +3780,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen,
bracketed_paste: None,
}),
}
}
@@ -3493,6 +3802,7 @@ mod tests {
local_pty: false,
at_prompt,
remote_prompt_seen: at_prompt == Some(true),
bracketed_paste: None,
}),
}
}
@@ -4472,4 +4782,375 @@ mod tests {
backend.control_calls
);
}
#[test]
fn tab_ls_and_pane_ls_say_which_tabs_are_asleep() {
let mut backend = mock();
backend.machine.workspaces[0].tabs[1].hibernated = true;
let Outcome::Report(tabs) = run_cli(
&["tty7", "tab", "ls", "api"],
&Context::default(),
&mut backend,
) else {
panic!("tab ls must report");
};
let tabs = tabs.json["tabs"].as_array().expect("tabs").clone();
assert_eq!(tabs[0]["hibernated"], false);
assert_eq!(tabs[1]["hibernated"], true);
let Outcome::Report(panes) = run_cli(
&["tty7", "pane", "ls", "api"],
&Context::default(),
&mut backend,
) else {
panic!("pane ls must report");
};
let panes = panes.json["panes"].as_array().expect("panes").clone();
let asleep: Vec<u64> = panes
.iter()
.filter(|p| p["hibernated"] == true)
.map(|p| p["pane"].as_u64().unwrap())
.collect();
assert_eq!(asleep, vec![2, 3]);
}
fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun {
crate::exec::ExecRun {
end,
output: output.to_vec(),
dropped: 0,
size: segment(b"").size,
}
}
fn exec_outcome(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> (i32, Report) {
match execute(cli(args), ctx, backend) {
Ok(Outcome::Exit(code, report)) => (code, report),
other => panic!("exec stands in for the command, so it exits: {other:?}"),
}
}
#[test]
fn exec_types_one_line_and_passes_the_exit_code_through() {
let mut backend = mock();
backend.exec_run = exec_run(
ExecEnd::Finished {
exit: Some(3),
ran: true,
},
b"built\r\n",
);
let (code, report) = exec_outcome(
&[
"tty7",
"exec",
"%1",
"--raw",
"--timeout",
"30",
"--",
"make",
"&&",
"ls",
],
&Context::default(),
&mut backend,
);
assert_eq!(code, 3, "the command's own code, not ours");
assert_eq!(
backend.execs,
vec![(1, b"make && ls\r".to_vec(), Some(Duration::from_secs(30)))],
"the words are one shell line, typed and submitted"
);
assert_eq!(report.human, "built\r\n");
assert_eq!(report.json["exit"], 3);
assert_eq!(report.json["exit_code_known"], true);
assert_eq!(report.json["command"], "make && ls");
assert_eq!(report.json["output"], "built\r\n");
assert_eq!(report.json["timed_out"], false);
// Nothing was sent anywhere but through the exec seam.
assert!(backend.sent.is_empty());
}
#[test]
fn exec_renders_the_output_the_way_capture_plain_does() {
let mut backend = mock();
backend.exec_run = exec_run(
ExecEnd::Finished {
exit: Some(0),
ran: true,
},
b"\x1b[31mred\x1b[0m\r\nprogress 10%\rprogress 100%\r\n",
);
let (code, report) = exec_outcome(
&["tty7", "exec", "%1", "--", "x"],
&Context::default(),
&mut backend,
);
assert_eq!(code, 0);
assert!(!report.human.contains('\u{1b}'), "{:?}", report.human);
assert!(report.human.contains("red"), "{:?}", report.human);
assert!(report.human.contains("progress 100%"), "{:?}", report.human);
assert!(
!report.human.contains("progress 10%\n"),
"{:?}",
report.human
);
}
#[test]
fn exec_refuses_a_pane_it_cannot_follow_before_typing_anything() {
// The backend sends nothing on these; the verb has to turn them into
// an error that says what to do instead of an exit code that says
// nothing.
let mut backend = mock();
backend.exec_run = exec_run(ExecEnd::NoIntegration, b"");
let err = execute(
cli(&["tty7", "exec", "%1", "--", "ls"]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("shell-integration"), "{err}");
assert!(err.contains("wait --until free"), "{err}");
let mut backend = mock();
backend.exec_run = exec_run(ExecEnd::Busy, b"");
let err = execute(
cli(&["tty7", "exec", "%1", "--", "ls"]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("not at a shell prompt"), "{err}");
// The caller's own pane can only ever be busy — running this.
let ctx = Context {
pane: Some("%1".into()),
..Context::default()
};
let err = execute(cli(&["tty7", "exec", "--", "ls"]), &ctx, &mut backend)
.unwrap_err()
.to_string();
assert!(err.contains("this shell"), "{err}");
}
#[test]
fn exec_refuses_a_line_the_editor_would_act_on() {
for line in ["make\nrm -rf build", "ls\tx", "a\u{1b}b"] {
let mut backend = mock();
let err = execute(
cli(&["tty7", "exec", "%1", "--", line]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("control"), "{line:?}: {err}");
assert!(backend.execs.is_empty(), "nothing may be typed: {line:?}");
}
}
#[test]
fn exec_reports_every_way_it_can_end_without_an_exit_code() {
let cases = [
(ExecEnd::TimedOut { started: true }, 124, true, false),
(ExecEnd::TimedOut { started: false }, 124, true, false),
(ExecEnd::PaneExited(Some(0)), 1, false, true),
(
ExecEnd::Finished {
exit: None,
ran: true,
},
1,
false,
false,
),
(
ExecEnd::Finished {
exit: None,
ran: false,
},
1,
false,
false,
),
];
for (end, want, timed_out, pane_exited) in cases {
let mut backend = mock();
backend.exec_run = exec_run(end.clone(), b"partial\r\n");
let (code, report) = exec_outcome(
&["tty7", "exec", "%1", "--raw", "--timeout", "1", "--", "x"],
&Context::default(),
&mut backend,
);
assert_eq!(code, want, "{end:?}");
assert_eq!(report.json["exit_code_known"], false, "{end:?}");
assert_eq!(report.json["timed_out"], timed_out, "{end:?}");
assert_eq!(report.json["pane_exited"], pane_exited, "{end:?}");
assert_eq!(
report.human, "partial\r\n",
"what did arrive is still the answer: {end:?}"
);
}
}
fn procs_with_paste_mode(mode: Option<bool>) -> tty7_core::daemon::protocol::PaneProcs {
tty7_core::daemon::protocol::PaneProcs {
context: Some(tty7_core::daemon::protocol::PaneContext {
local_pty: true,
bracketed_paste: mode,
..Default::default()
}),
..Default::default()
}
}
#[test]
fn send_from_file_sends_the_bytes_verbatim_and_never_echoes_them() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, b"s3cr\\et\n\xff").unwrap();
let mut backend = mock();
let json = json_of(run_cli(
&["tty7", "send", "%1", "--from-file", path.to_str().unwrap()],
&Context::default(),
&mut backend,
));
assert_eq!(backend.sent, vec![(1, b"s3cr\\et\n\xff".to_vec())]);
assert!(
json["sent"].is_null(),
"the secret must not be echoed: {json}"
);
assert!(!json.to_string().contains("s3cr"), "{json}");
assert_eq!(json["source"], "file");
assert_eq!(json["bytes"], 9);
assert!(
backend.procs_calls.is_empty(),
"without --paste the pane is not asked anything"
);
// Keys still follow the payload, the way they follow typed text.
let mut backend = mock();
run_cli(
&[
"tty7",
"send",
"%1",
"--from-file",
path.to_str().unwrap(),
"--enter",
],
&Context::default(),
&mut backend,
);
assert_eq!(
backend.sent,
vec![(1, b"s3cr\\et\n\xff".to_vec()), (1, b"\r".to_vec())]
);
}
#[test]
fn send_from_file_takes_its_positional_as_an_address_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("payload");
std::fs::write(&path, b"x").unwrap();
let mut backend = mock();
let err = execute(
cli(&[
"tty7",
"send",
"echo",
"--from-file",
path.to_str().unwrap(),
]),
&Context::default(),
&mut backend,
)
.unwrap_err();
let err = format!("{err:#}");
assert!(err.contains("has to be the pane"), "{err}");
assert!(backend.sent.is_empty(), "nothing may be typed");
// With no address it is this pane, as it is for text.
let ctx = Context {
pane: Some("%3".into()),
..Context::default()
};
run_cli(
&["tty7", "send", "--from-file", path.to_str().unwrap()],
&ctx,
&mut backend,
);
assert_eq!(backend.sent, vec![(3, b"x".to_vec())]);
}
#[test]
fn send_paste_frames_only_for_a_pane_that_switched_bracketed_paste_on() {
let mut backend = mock();
backend.procs_reply = procs_with_paste_mode(Some(true));
let json = json_of(run_cli(
&[
"tty7",
"send",
"%1",
"a\r\nb\x1b[201~c",
"--paste",
"--enter",
],
&Context::default(),
&mut backend,
));
assert_eq!(
backend.sent,
vec![
(1, b"\x1b[200~a\nb[201~c\x1b[201~".to_vec()),
(1, b"\r".to_vec())
],
"the GUI's framing: CRLF folded, ESC stripped so the text cannot close the paste"
);
assert_eq!(json["bracketed"], true);
assert_eq!(json["bracketed_paste_mode"], true);
// Off: brackets would reach the program as keystrokes, so the text goes
// the way a paste into such a pane always has — a CR per line break.
let mut backend = mock();
backend.procs_reply = procs_with_paste_mode(Some(false));
let json = json_of(run_cli(
&["tty7", "send", "%1", "a\nb", "--paste"],
&Context::default(),
&mut backend,
));
assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]);
assert_eq!(json["bracketed"], false);
assert_eq!(json["bracketed_paste_mode"], false);
// A server that cannot say is not taken as a yes.
let mut backend = mock();
backend.procs_reply = procs_with_paste_mode(None);
let json = json_of(run_cli(
&["tty7", "send", "%1", "a\nb", "--paste"],
&Context::default(),
&mut backend,
));
assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]);
assert!(json["bracketed_paste_mode"].is_null(), "{json}");
}
#[test]
fn send_paste_needs_something_to_paste() {
let mut backend = mock();
let err = execute(
cli(&["tty7", "send", "%1", "--paste", "--enter"]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("--paste needs text"), "{err}");
assert!(backend.sent.is_empty());
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Finding one command's output and exit code in a pane's byte stream.
//!
//! `tty7 exec` types a line at a shell's prompt and has to know two things the
//! pty alone never says: where the command's output starts and ends, and how it
//! exited. The shell integration says both, in OSC 133 marks: `C` right before
//! the command runs, `D;<code>` the moment it is over, `A` when the next prompt
//! opens. The daemon already reads those marks — it is what sends `Prompt` —
//! but only as a state, not as a position in the bytes, so the positions are
//! found here, in the same stream the daemon read them from.
//!
//! Which of the two gets the last word is deliberate. The byte marks say
//! *where*; the daemon's `Prompt` report says *when*, because it is the one
//! that has already set aside marks relayed by a program running in the
//! foreground (a nested shell, an `ssh`) — a `D` in the bytes is only the end
//! once the daemon agrees the shell is back at its prompt.
use tty7_core::core::osc::OscTokenizer;
use tty7_core::daemon::protocol::WinSize;
/// Output kept past this is dropped from the front: the end of a long build is
/// the part worth reading, and a command that prints without bound must not
/// take the CLI's memory with it. The pane's own scrollback is the same size.
pub const KEEP_OUTPUT: usize = 8 * 1024 * 1024;
const MARK_D: &[u8] = b"\x1b]133;D";
const MARK_A: &[u8] = b"\x1b]133;A";
/// How an `exec` ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecEnd {
/// The pane's shell has never sent a prompt mark, so nothing would ever
/// say the command is over. Nothing was sent.
NoIntegration,
/// The pane is not at a prompt — something already runs there. Nothing
/// was sent.
Busy,
/// The shell is back at its prompt. `ran` is false when it came back
/// without running anything — a syntax error, most likely — in which case
/// there is no exit code to report and the output is what the shell said
/// about the line.
Finished { exit: Option<i32>, ran: bool },
/// The deadline passed first. `started` says whether the shell ever
/// began the command: when it did not, the line is most likely sitting on
/// a continuation prompt (an unclosed quote) rather than running.
TimedOut { started: bool },
/// The pane went away before its shell came back to a prompt.
PaneExited(Option<i32>),
}
/// What an `exec` saw: how it ended, and the output that belongs to it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecRun {
pub end: ExecEnd,
pub output: Vec<u8>,
/// Bytes of output dropped from the front to stay under [`KEEP_OUTPUT`].
pub dropped: usize,
/// The pane's size, which is what `--plain` replays the output at.
pub size: WinSize,
}
/// The pane's output from the moment the line was sent, and where the marks
/// that bound the command fell in it.
pub struct Transcript {
tok: OscTokenizer,
bytes: Vec<u8>,
/// Absolute offset of `bytes[0]` — everything before it was dropped.
base: usize,
/// One past the command's `C` mark: where its output starts.
started: Option<usize>,
/// Where the newest `D` after `started` begins: where the output ends.
ended: Option<usize>,
/// Where an `A` that arrived with no `C` before it begins — the shell drew
/// a fresh prompt without running the line.
reprompted: Option<usize>,
exit_mark: Option<i32>,
}
/// What the daemon's `Prompt` report made of the pane.
pub struct Finished {
pub exit: Option<i32>,
pub ran: bool,
}
impl Default for Transcript {
fn default() -> Transcript {
Transcript::new()
}
}
impl Transcript {
pub fn new() -> Transcript {
Transcript {
tok: OscTokenizer::new(&[b"133"]),
bytes: Vec::new(),
base: 0,
started: None,
ended: None,
reprompted: None,
exit_mark: None,
}
}
/// Take the next chunk of the pane's output.
pub fn output(&mut self, chunk: &[u8]) {
let at = self.base + self.bytes.len();
self.bytes.extend_from_slice(chunk);
let (bytes, base) = (&self.bytes, self.base);
let (mut started, mut ended, mut reprompted, mut exit_mark) =
(self.started, self.ended, self.reprompted, self.exit_mark);
self.tok.feed_at(chunk, |end, payload| {
let end = at + end;
// Where the sequence that just ended began: the newest copy of its
// introducer before its terminator. Payloads carry no ESC, so the
// search cannot land inside a different sequence.
let begin = |intro: &[u8]| {
let upto = end - base;
bytes[..upto]
.windows(intro.len())
.rposition(|w| w == intro)
.map_or(end, |at| base + at)
};
match payload.get(4) {
// Only the first: a command that prints a `C` of its own must
// not move the start of its own output.
Some(b'C') if started.is_none() => started = Some(end),
Some(b'D') if started.is_some() => {
ended = Some(begin(MARK_D));
exit_mark = payload
.strip_prefix(b"133;D;")
.and_then(|c| std::str::from_utf8(c).ok())
.and_then(|c| c.trim().parse().ok());
}
Some(b'A') if started.is_none() && reprompted.is_none() => {
reprompted = Some(begin(MARK_A));
}
_ => {}
}
});
(self.started, self.ended, self.reprompted, self.exit_mark) =
(started, ended, reprompted, exit_mark);
}
/// The daemon's report that the pane's shell is (or is not) at a prompt.
/// `Some` once the command is over.
pub fn prompt(&self, at_prompt: bool, last_exit: Option<i32>) -> Option<Finished> {
if !at_prompt {
return None;
}
if self.started.is_some() {
// The byte mark and the report are the same `D`, read twice; the
// report is preferred only because the daemon parsed it first.
return self.ended.map(|_| Finished {
exit: last_exit.or(self.exit_mark),
ran: true,
});
}
self.reprompted.map(|_| Finished {
exit: None,
ran: false,
})
}
/// Whether the shell has begun running the command.
pub fn started(&self) -> bool {
self.started.is_some()
}
/// The output that belongs to the command, as far as it has got, and how
/// much of it was dropped from the front to keep it bounded.
pub fn take(self) -> (Vec<u8>, usize) {
let all = self.base + self.bytes.len();
let (from, to) = match (self.started, self.ended, self.reprompted) {
(Some(start), Some(end), _) => (start, end),
(Some(start), None, _) => (start, all),
// Never ran: what the shell printed about the line, up to the new
// prompt — the echo of the line itself and whatever error it drew.
(None, _, Some(end)) => (0, end),
(None, _, None) => (0, all),
};
let to = to.clamp(from, all);
let kept = from.max(self.base).max(to.saturating_sub(KEEP_OUTPUT));
(
self.bytes[kept - self.base..to - self.base].to_vec(),
kept - from,
)
}
/// Bound the memory held: once twice [`KEEP_OUTPUT`] has piled up, drop
/// all but the last [`KEEP_OUTPUT`]. Only ever what [`take`](Self::take)
/// would not return anyway, which keeps the newest that much.
pub fn compact(&mut self) {
let len = self.bytes.len();
if len <= KEEP_OUTPUT * 2 {
return;
}
let drop = len - KEEP_OUTPUT;
self.bytes.drain(..drop);
self.base += drop;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What zsh with tty7's integration prints for one `echo hi` typed at its
/// prompt: the echo of the line, `C` with the line, the output, `D` with
/// the exit code, then the next prompt.
const ONE_COMMAND: &[u8] = b"echo hi\r\n\x1b]133;C;echo hi\x07hi\r\n\x1b]133;D;0\x07\
\x1b]7;file://host/tmp\x07\x1b]133;A\x07% \x1b]133;B\x07";
#[test]
fn the_output_is_what_lies_between_c_and_d() {
let mut t = Transcript::new();
t.output(ONE_COMMAND);
let done = t.prompt(true, Some(0)).expect("D then a prompt is the end");
assert_eq!((done.exit, done.ran), (Some(0), true));
assert_eq!(t.take(), (b"hi\r\n".to_vec(), 0));
}
#[test]
fn marks_split_across_reads_still_bound_the_output() {
// The pty hands the stream over in whatever chunks it likes, including
// through the middle of a mark.
for split in 1..ONE_COMMAND.len() {
let mut t = Transcript::new();
t.output(&ONE_COMMAND[..split]);
t.output(&ONE_COMMAND[split..]);
assert!(t.prompt(true, Some(0)).is_some(), "split at {split}");
assert_eq!(t.take().0, b"hi\r\n".to_vec(), "split at {split}");
}
}
#[test]
fn a_prompt_report_before_the_command_has_ended_is_not_the_end() {
let mut t = Transcript::new();
// The shell has only echoed the line; a prompt redraw (a `B` in PS1
// repainted by a transient-prompt theme) reports "at a prompt" too.
t.output(b"make\r\n\x1b]133;B\x07");
assert!(t.prompt(true, None).is_none());
// Running: the daemon reports "not at a prompt".
t.output(b"\x1b]133;C;make\x07building\r\n");
assert!(t.prompt(false, None).is_none());
// A `D` the daemon did not believe — relayed by something running in
// the foreground, reported as "not at a prompt" — ends nothing.
t.output(b"\x1b]133;D;7\x07more\r\n");
assert!(t.prompt(false, Some(7)).is_none());
t.output(b"done\r\n\x1b]133;D;2\x07\x1b]133;A\x07$ ");
let done = t.prompt(true, Some(2)).expect("the real end");
assert_eq!(done.exit, Some(2));
assert_eq!(
t.take().0,
b"building\r\n\x1b]133;D;7\x07more\r\ndone\r\n".to_vec(),
"the output runs to the D the shell's own prompt followed"
);
}
#[test]
fn a_prompt_with_no_command_before_it_means_the_line_never_ran() {
let mut t = Transcript::new();
t.output(b"echo (\r\nzsh: parse error near `('\r\n\x1b]133;A\x07% ");
let done = t.prompt(true, None).expect("the shell is back");
assert_eq!((done.exit, done.ran), (None, false));
assert_eq!(
t.take().0,
b"echo (\r\nzsh: parse error near `('\r\n".to_vec(),
"what the shell said about the line is the only output there is"
);
}
#[test]
fn the_exit_code_falls_back_to_the_mark_itself() {
let mut t = Transcript::new();
t.output(b"\x1b]133;C\x07\x1b]133;D;5\x1b\\");
let done = t
.prompt(true, None)
.expect("ST terminates a mark as well as BEL");
assert_eq!(done.exit, Some(5));
assert!(t.take().0.is_empty());
}
#[test]
fn a_timeout_keeps_what_the_command_has_printed_so_far() {
let mut t = Transcript::new();
t.output(b"sleep 9\r\n\x1b]133;C;sleep 9\x07tick\r\n");
assert_eq!(t.take().0, b"tick\r\n".to_vec());
}
#[test]
fn output_past_the_bound_is_dropped_from_the_front() {
let mut t = Transcript::new();
t.output(b"\x1b]133;C\x07");
let chunk = vec![b'x'; KEEP_OUTPUT];
for _ in 0..3 {
t.output(&chunk);
t.compact();
}
t.output(b"end\x1b]133;D;0\x07");
assert!(t.prompt(true, Some(0)).is_some());
let (out, dropped) = t.take();
assert_eq!(out.len(), KEEP_OUTPUT);
assert!(out.ends_with(b"end"));
assert_eq!(out.len() + dropped, KEEP_OUTPUT * 3 + 3);
}
}
+6
View File
@@ -90,6 +90,12 @@ pub fn send_long_help() -> String {
when there is none, so `send %42 --enter` runs whatever is already typed in pane 42. \
An unmarked id is not a target for it — `send 83 --enter` is refused, because it \
reads just as much like typing 83 into your own pane; write %83 to mean the pane.\n\n\
--stdin and --from-file take the text from there instead, byte for byte — the way \
to send a secret, which as TEXT is visible to every local user in `ps` and lands in \
your shell history. The one positional is then the pane.\n\n\
--paste sends the text the way the GUI pastes: framed in bracketed paste when the \
pane has switched it on, so a multi-line text arrives as one block of text instead \
of running line by line. --json reports whether it was framed.\n\n\
Keys: {}. Aliases: {}.",
vocabulary(),
aliases.join(", ")
+1
View File
@@ -2,6 +2,7 @@ mod address;
mod backend;
mod cli;
mod commands;
mod exec;
mod gui;
mod keys;
mod output;
+44 -3
View File
@@ -47,6 +47,17 @@ fn clamp(s: &str, max: usize) -> String {
/// The last segment of a path, for columns that have room for a word and not
/// for a path. Both separators: the same server answers a Windows client, and
/// `C:\proj` has to lose its head too.
/// What the sidebar's header for a pinned group reads: the name the user gave
/// it, or the last segment of its folder — the full path would be the widest
/// column in the table for no gain.
pub fn pinned_group_label(group: &tty7_core::core::group_key::PinnedGroup) -> String {
group
.given_name()
.map(str::to_string)
.or_else(|| group.folder.as_deref().map(|f| path_leaf(f).to_string()))
.unwrap_or_else(|| "-".to_string())
}
pub fn path_leaf(path: &str) -> &str {
let trimmed = path.trim_end_matches(['/', '\\']);
match trimmed.rsplit(['/', '\\']).next() {
@@ -140,9 +151,14 @@ pub fn pane_table(machine: &Machine, only: Option<WorkspaceId>) -> String {
record
.and_then(|r| r.cwd.clone())
.unwrap_or_else(|| "-".to_string()),
record
.map(|r| if r.live { "yes" } else { "no" }.to_string())
.unwrap_or_else(|| "?".to_string()),
// Not running, and meant not to be: a sleeping tab's panes
// are waiting for their tab to be woken, not dead.
match record {
_ if tab.hibernated => "asleep".to_string(),
Some(r) if r.live => "yes".to_string(),
Some(_) => "no".to_string(),
None => "?".to_string(),
},
]);
}
}
@@ -648,4 +664,29 @@ mod tests {
procs_tables(&empty)
);
}
/// A sleeping tab's panes are not running and not dead either: the table
/// says which, so nobody reads a tab put to sleep as one that crashed.
#[test]
fn the_pane_table_calls_a_sleeping_tabs_panes_asleep() {
let mut m = two_workspace_machine();
m.workspaces[0].tabs[1].hibernated = true;
for pane in &mut m.panes {
if pane.id == 2 || pane.id == 3 {
pane.live = false;
}
}
let rendered = pane_table(&m, None);
let row = |pane: &str| {
rendered
.lines()
.find(|l| l.starts_with(pane))
.unwrap_or_else(|| panic!("no row for {pane}: {rendered}"))
.to_string()
};
assert!(row("%1 ").ends_with("yes"), "{rendered}");
assert!(row("%2 ").ends_with("asleep"), "{rendered}");
assert!(row("%3 ").ends_with("asleep"), "{rendered}");
assert!(row("%5 ").ends_with("yes"), "{rendered}");
}
}
+11 -3
View File
@@ -10,23 +10,28 @@ pub fn two_workspace_machine() -> Machine {
Tab {
id: TabId::new(),
name: Some("build".into()),
sidebar_group: None,
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
},
Tab {
id: TabId::new(),
name: None,
sidebar_group: None,
group: None,
last_auto: None,
root: PaneNode::Split {
axis: Axis::Horizontal,
ratio: 0.5,
a: Box::new(PaneNode::Leaf { pane: 2 }),
b: Box::new(PaneNode::Leaf { pane: 3 }),
},
hibernated: false,
},
],
active_tab: None,
attachment: None,
groups: Default::default(),
};
let web = Workspace {
id: WorkspaceId::new(),
@@ -35,11 +40,14 @@ pub fn two_workspace_machine() -> Machine {
tabs: vec![Tab {
id: TabId::new(),
name: None,
sidebar_group: None,
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 5 },
hibernated: false,
}],
active_tab: None,
attachment: None,
groups: Default::default(),
};
let record = |id: u64, cwd: &str| PaneRecord {
id,
+223
View File
@@ -87,6 +87,22 @@ fn main() {
"procs_says_where_the_panes_session_lives",
procs_says_where_the_panes_session_lives,
),
(
"exec_returns_the_commands_output_and_exit_code",
exec_returns_the_commands_output_and_exit_code,
),
(
"exec_refuses_a_pane_with_no_prompt_marks",
exec_refuses_a_pane_with_no_prompt_marks,
),
(
"send_stdin_delivers_bytes_it_never_echoes",
send_stdin_delivers_bytes_it_never_echoes,
),
(
"send_paste_asks_the_pane_for_its_bracketed_paste_mode",
send_paste_asks_the_pane_for_its_bracketed_paste_mode,
),
];
let mut failed = 0;
@@ -1052,3 +1068,210 @@ fn capture_tail_trims_a_real_panes_answer(daemon: &Daemon) {
std::thread::sleep(Duration::from_millis(200));
}
}
/// A pane running `program` with no shell in front of it — so no prompt marks
/// and no line editor, just the pty's own echo. What the tests below type into
/// it comes back exactly as the pane received it.
fn bare_pane(daemon: &Daemon, program: &str) -> u64 {
let shell = ShellSpec {
program: program.into(),
args: Vec::new(),
args_are_tty7_defaults: false,
};
let session = PaneClient::at(daemon.pane_endpoint())
.spawn(
None,
WinSize {
cols: 80,
rows: 24,
cell_w: 8,
cell_h: 16,
},
Some(shell),
Some("bare-pane-e2e".into()),
None,
)
.unwrap_or_else(|e| panic!("spawn {program}: {e}"));
let pane = session.pane_id();
session.detach().expect("leave the bare pane running");
pane
}
/// Block until the pane's shell has drawn its first prompt: before that there
/// are no marks for `exec` to follow, and it refuses rather than guesses.
fn await_prompt(daemon: &Daemon, address: &str) {
let deadline = Instant::now() + SETTLE_WITHIN;
loop {
let procs = daemon.run_json(&["procs", address]);
if procs["context"]["at_prompt"].as_bool() == Some(true) {
return;
}
assert!(
Instant::now() < deadline,
"the shell never reported a prompt; last procs: {procs}"
);
std::thread::sleep(Duration::from_millis(200));
}
}
fn exec_returns_the_commands_output_and_exit_code(daemon: &Daemon) {
// The marks come from tty7's integration for zsh/bash/fish/nushell/pwsh;
// the shell a Windows runner defaults to is not one this can count on.
if cfg!(windows) {
return;
}
let created = daemon.run_json(&["new", &workdir()]);
let pane = created["pane"].as_u64().expect("new prints the pane id");
let address = format!("%{pane}");
await_prompt(daemon, &address);
// `sh -c` so the exit code does not depend on which shell the pane runs.
let out = daemon.run(&[
"exec",
&address,
"--timeout",
"60",
"--",
"sh",
"-c",
"'echo tty7_e2e_exec_$((6*7)); exit 3'",
]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
out.status.code(),
Some(3),
"exec exits with the command's code; stdout {stdout:?}, stderr {:?}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout.contains("tty7_e2e_exec_42"), "{stdout:?}");
assert!(
!stdout.contains("6*7") && !stdout.contains("exit 3"),
"the typed line is not the command's output: {stdout:?}"
);
// The pane is back at its prompt, so it takes the next one straight away.
// `--json` ahead of `--`: after it, it would be an argument to `true`.
let out = daemon.run_ok(&["exec", &address, "--json", "--timeout", "60", "--", "true"]);
let json: serde_json::Value = serde_json::from_str(&out).expect("--json prints JSON");
assert_eq!(json["exit"], 0, "{json}");
assert_eq!(json["exit_code_known"], true, "{json}");
assert_eq!(json["pane"], pane, "{json}");
assert_eq!(
json["output"], "",
"a command that prints nothing has nothing to show — not the shell's \
prompt preparation: {json}"
);
}
fn exec_refuses_a_pane_with_no_prompt_marks(daemon: &Daemon) {
if cfg!(windows) {
return;
}
let pane = bare_pane(daemon, "/bin/cat");
let out = daemon.run(&["exec", &format!("%{pane}"), "--", "echo", "never"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("shell-integration"), "{stderr}");
// Refused before typing: nothing reached the pane.
let seen = daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"]);
assert!(!seen.contains("never"), "{seen:?}");
}
fn send_via_stdin(daemon: &Daemon, args: &[&str], input: &[u8]) -> Output {
let mut child = daemon
.cli(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("could not run tty7 {args:?}: {e}"));
child
.stdin
.take()
.expect("stdin was piped")
.write_all(input)
.expect("feed tty7's stdin");
child.wait_with_output().expect("tty7 ran to completion")
}
fn await_capture(daemon: &Daemon, address: &str, marker: &str) -> String {
let deadline = Instant::now() + SETTLE_WITHIN;
loop {
let seen = daemon.run_ok(&["capture", address, "--scrollback"]);
if seen.contains(marker) {
return seen;
}
assert!(
Instant::now() < deadline,
"{marker:?} never showed up; last capture:\n{seen}"
);
std::thread::sleep(Duration::from_millis(200));
}
}
fn send_stdin_delivers_bytes_it_never_echoes(daemon: &Daemon) {
if cfg!(windows) {
return;
}
let pane = bare_pane(daemon, "/bin/cat");
let address = format!("%{pane}");
let out = send_via_stdin(
daemon,
&["send", &address, "--stdin", "--json"],
b"tty7_e2e_stdin_secret\n",
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"{stdout}{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!stdout.contains("tty7_e2e_stdin_secret"),
"the payload must not come back in the report: {stdout}"
);
let json: serde_json::Value = serde_json::from_str(&stdout).expect("--json prints JSON");
assert_eq!(json["source"], "stdin", "{json}");
assert_eq!(json["bytes"], 22, "{json}");
await_capture(daemon, &address, "tty7_e2e_stdin_secret");
}
fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) {
if cfg!(windows) {
return;
}
let pane = bare_pane(daemon, "/bin/cat");
let address = format!("%{pane}");
// Off to begin with: nothing in the pane has asked for it.
let json = daemon.run_json(&["send", &address, "tty7_e2e_one\ntty7_e2e_two", "--paste"]);
assert_eq!(json["bracketed_paste_mode"], false, "{json}");
assert_eq!(json["bracketed"], false, "{json}");
await_capture(daemon, &address, "tty7_e2e_two");
// `cat` hands back what it reads, so the program in the pane is now the
// one that switched mode 2004 on — the daemon sees it in the output.
let out = send_via_stdin(daemon, &["send", &address, "--stdin"], b"\x1b[?2004h\n");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let deadline = Instant::now() + SETTLE_WITHIN;
loop {
let procs = daemon.run_json(&["procs", &address]);
if procs["context"]["bracketed_paste"].as_bool() == Some(true) {
break;
}
assert!(
Instant::now() < deadline,
"the pane never reported bracketed paste on: {procs}"
);
std::thread::sleep(Duration::from_millis(200));
}
let json = daemon.run_json(&["send", &address, "tty7_e2e_framed", "--paste"]);
assert_eq!(json["bracketed"], true, "{json}");
// The frame reached the pty: cat's echo of it carries the opener's tail.
let seen = await_capture(daemon, &address, "tty7_e2e_framed");
assert!(seen.contains("[200~"), "{seen:?}");
}
+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"));
+427 -22
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
@@ -304,6 +318,32 @@ impl CLIAgent {
}
fn replay_flags(self, argv: &[String]) -> Option<Vec<String>> {
let tail = self.session_free_tail(argv)?;
let safe = |t: &str| {
!t.is_empty()
&& t.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b))
};
if !tail.iter().all(|t| safe(t)) {
return None;
}
let mut prev_was_flag = false;
for t in &tail {
let is_flag = t.starts_with('-');
if !is_flag && !prev_was_flag {
return None;
}
prev_was_flag = is_flag;
}
Some(tail.into_iter().map(String::from).collect())
}
/// What follows the agent's own name in `argv`, with everything that names
/// a session taken out: the resume/fork subcommands and the flags listed as
/// stale for this agent, each with its value. `None` when no token in
/// `argv` names this agent — a wrapper, where there is no telling which
/// arguments are the agent's.
fn session_free_tail(self, argv: &[String]) -> Option<Vec<&str>> {
let names_self = |token: &str| {
token.split(['/', '\\']).any(|seg| {
CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self)
@@ -407,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
@@ -490,24 +536,7 @@ impl CLIAgent {
i += 1;
}
}
let safe = |t: &str| {
!t.is_empty()
&& t.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b))
};
if !tail.iter().all(|t| safe(t)) {
return None;
}
let mut prev_was_flag = false;
for t in &tail {
let is_flag = t.starts_with('-');
if !is_flag && !prev_was_flag {
return None;
}
prev_was_flag = is_flag;
}
Some(tail.into_iter().map(String::from).collect())
Some(tail)
}
pub fn accent_rgb(self) -> u32 {
@@ -539,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,
}
@@ -588,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",
}
}
@@ -598,6 +631,58 @@ impl CLIAgent {
.find(|a| a.aliases().contains(&token))
}
/// The program a launch with no override runs: the first name the agent
/// is detected by, which is the one its installer puts on `PATH`.
pub fn binary(self) -> &'static str {
self.aliases()[0]
}
/// The command line a quick launch types for this agent: the user's
/// `agent_launch` entry when there is one, the bare binary otherwise.
///
/// Keys are matched the way [`CLIAgent::from_slug`] reads them, so a
/// hand-written `"Claude"` still counts. A blank entry is no override.
pub fn launch_command(self, overrides: &HashMap<String, String>) -> String {
overrides
.iter()
.find(|(slug, _)| CLIAgent::from_slug(slug) == Some(self))
.map(|(_, line)| line.trim())
.filter(|line| !line.is_empty())
.map_or_else(|| self.binary().to_string(), str::to_string)
}
/// `argv` as a command line worth launching this agent with again: its
/// binary followed by the flags it ran with, minus everything that names
/// one particular session (a resume, a fork, a session id) and minus
/// positional arguments such as a prompt, which belong to that one run.
///
/// A run that went through something that does not name the agent — a
/// wrapper script mapped by `agent_commands` — cannot be taken apart, so
/// it comes back whole: it is what was typed, and it launched the agent.
pub fn launch_argv_for_default(self, argv: &[String]) -> Option<Vec<String>> {
let env = argv.iter().take_while(|t| is_env_assignment(t)).count();
if argv.len() == env {
return None;
}
let Some(tail) = self.session_free_tail(argv) else {
return Some(argv.to_vec());
};
let mut out: Vec<String> = argv[..env].to_vec();
out.push(self.binary().to_string());
let mut takes_value = false;
for t in tail {
let is_flag = t.starts_with('-') && t.len() > 1;
if is_flag {
out.push(t.to_string());
takes_value = !t.contains('=');
} else if takes_value {
out.push(t.to_string());
takes_value = false;
}
}
Some(out)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn detect_from_argv(argv: &[String]) -> Option<CLIAgent> {
Self::detect_from_argv_with(argv, &HashMap::new())
@@ -625,6 +710,20 @@ impl CLIAgent {
return Some(agent);
}
// A wrapper that is a script runs as its interpreter, so the name the
// user mapped is the script's, one token in: `bash ~/bin/cc`,
// `node ~/bin/cc.js`. Only the mapped names are looked for there — the
// built-in ones already have the scan below.
if (is_interpreter(launcher_stem) || is_shell(launcher_stem))
&& let Some(agent) = rest
.clone()
.find(|a| !a.starts_with('-') && !is_env_assignment(a))
.and_then(|script| custom.get(&base_stem(script).to_ascii_lowercase()))
.and_then(|slug| CLIAgent::from_slug(slug))
{
return Some(agent);
}
if is_interpreter(launcher_stem) {
for arg in rest {
if arg.starts_with('-') {
@@ -719,6 +818,99 @@ fn is_interpreter(stem: &str) -> bool {
)
}
fn is_shell(stem: &str) -> bool {
matches!(
stem.to_ascii_lowercase().as_str(),
"sh" | "bash" | "zsh" | "dash" | "ksh" | "fish"
)
}
/// The program a launch command line runs: its first word that is not an
/// environment assignment.
pub fn launch_program(command: &str) -> Option<String> {
command_argv(command)
.into_iter()
.find(|t| !is_env_assignment(t))
}
/// The wrapper names `agent_launch` implies, in the shape `agent_commands`
/// takes: `{"claude": "cc --fast"}` means a `cc` in a pane is Claude Code.
///
/// This is how a pane opened by a quick launch through a wrapper is still
/// recognised without the user also writing an `agent_commands` entry: the
/// launch typed exactly this command, so its program is known to be that
/// agent. A program that is already an agent's own name needs no entry, and
/// an interpreter (`npx`, `node`, `env`, …) or a shell is never mapped
/// wholesale — `npx` is not Claude Code just because Claude Code is one of the
/// things it can run.
pub fn launch_aliases(overrides: &HashMap<String, String>) -> HashMap<String, String> {
let mut out = HashMap::new();
for (slug, line) in overrides {
let Some(agent) = CLIAgent::from_slug(slug) else {
continue;
};
let Some(program) = launch_program(line) else {
continue;
};
let stem = base_stem(&program).to_ascii_lowercase();
if stem.is_empty()
|| CLIAgent::match_token(&stem).is_some()
|| is_interpreter(&stem)
|| is_shell(&stem)
{
continue;
}
out.insert(stem, agent.slug().to_string());
}
out
}
/// Whether `program` names an executable file, either directly (it has a
/// directory in it) or through one of the directories in `path`.
///
/// On Windows a bare name is also tried with the extensions an npm or winget
/// install leaves behind, since `claude` there is really `claude.cmd`.
pub fn program_on_path(program: &str, path: &std::ffi::OsStr) -> bool {
let program = program.trim();
if program.is_empty() {
return false;
}
if program.contains(['/', '\\']) {
// The launch is typed into a shell, which expands a leading `~/`.
let home = program
.strip_prefix("~/")
.and_then(|rest| Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join(rest)));
return is_executable(home.as_deref().unwrap_or(std::path::Path::new(program)));
}
let exts: &[&str] = if cfg!(windows) {
&["", ".exe", ".cmd", ".bat", ".ps1"]
} else {
&[""]
};
std::env::split_paths(path).any(|dir| {
exts.iter()
.any(|ext| is_executable(&dir.join(format!("{program}{ext}"))))
})
}
fn is_executable(path: &std::path::Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -1071,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"),
@@ -1087,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 [
@@ -2253,4 +2502,160 @@ mod tests {
"\"waiting\""
);
}
fn overrides(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn a_launch_is_the_bare_binary_unless_overridden() {
let none = HashMap::new();
assert_eq!(CLIAgent::Claude.launch_command(&none), "claude");
assert_eq!(CLIAgent::Codex.launch_command(&none), "codex");
assert_eq!(CLIAgent::Cursor.launch_command(&none), "cursor-agent");
assert_eq!(CLIAgent::Antigravity.launch_command(&none), "agy");
assert_eq!(CLIAgent::QoderCLI.launch_command(&none), "qoder");
let set = overrides(&[
("claude", " claude --dangerously-skip-permissions "),
// Keys are read the way `from_slug` reads them.
("Codex", "codex --model o3"),
// A blank entry is no override at all.
("gemini", " "),
]);
assert_eq!(
CLIAgent::Claude.launch_command(&set),
"claude --dangerously-skip-permissions"
);
assert_eq!(CLIAgent::Codex.launch_command(&set), "codex --model o3");
assert_eq!(CLIAgent::Gemini.launch_command(&set), "gemini");
assert_eq!(CLIAgent::Aider.launch_command(&set), "aider");
}
#[test]
fn every_agent_launches_as_a_name_it_is_detected_by() {
let none = HashMap::new();
for agent in CLIAgent::ALL {
let argv = command_argv(&agent.launch_command(&none));
assert_eq!(
CLIAgent::detect_from_argv(&argv),
Some(agent),
"{agent:?} would launch as something detection does not know"
);
}
}
#[test]
fn a_wrapper_named_by_a_launch_override_is_detected_as_its_agent() {
let aliases = launch_aliases(&overrides(&[("claude", "FOO=1 cc --fast")]));
// Run as a binary, and run as a script under its interpreter.
for run in [
argv(&["cc", "--fast"]),
argv(&["/Users/me/bin/cc", "--fast"]),
argv(&["/bin/bash", "/Users/me/bin/cc", "--fast"]),
argv(&["node", "/Users/me/bin/cc.js", "--fast"]),
] {
assert_eq!(
CLIAgent::detect_from_argv_with(&run, &aliases),
Some(CLIAgent::Claude),
"{run:?}"
);
}
// Without the override the same wrapper is nobody.
assert_eq!(
CLIAgent::detect_from_argv_with(&argv(&["cc", "--fast"]), &HashMap::new()),
None
);
}
#[test]
fn a_launch_override_never_maps_an_interpreter_or_a_real_agent_name() {
let aliases = launch_aliases(&overrides(&[
("claude", "npx @anthropic-ai/claude-code"),
("codex", "bash -lc codex"),
// Already detected as itself; mapping it would add nothing.
("gemini", "gemini --yolo"),
("not-an-agent", "cc"),
]));
assert!(aliases.is_empty(), "{aliases:?}");
// A shell running something else is still nobody.
assert_eq!(
CLIAgent::detect_from_argv_with(
&argv(&["bash", "./build.sh"]),
&launch_aliases(&overrides(&[("claude", "cc")]))
),
None
);
}
#[test]
fn launch_args_drop_the_session_and_the_prompt_but_keep_the_flags() {
assert_eq!(
CLIAgent::Claude.launch_argv_for_default(&argv(&[
"node",
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"--model",
"opus",
"--resume",
"abc-123",
"--append-system-prompt",
"be terse",
"fix the bug",
])),
Some(argv(&[
"claude",
"--model",
"opus",
"--append-system-prompt",
"be terse",
]))
);
assert_eq!(
CLIAgent::Codex.launch_argv_for_default(&argv(&[
"codex",
"resume",
"019a",
"--model=o3",
"prompt",
])),
Some(argv(&["codex", "--model=o3"]))
);
// A wrapper cannot be taken apart; what was typed is what launched it.
assert_eq!(
CLIAgent::Claude.launch_argv_for_default(&argv(&["cc", "--fast", "x"])),
Some(argv(&["cc", "--fast", "x"]))
);
assert_eq!(CLIAgent::Claude.launch_argv_for_default(&[]), None);
}
#[test]
fn program_on_path_finds_only_executables() {
let dir = tempfile::TempDir::new().unwrap();
let bin = dir.path().join("claude");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::write(dir.path().join("codex"), "not executable").unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!program_on_path("codex", dir.path().as_os_str()));
}
std::fs::create_dir(dir.path().join("gemini")).unwrap();
let path =
std::env::join_paths([std::path::Path::new("/nonexistent"), dir.path()]).unwrap();
assert!(program_on_path("claude", &path));
assert!(
!program_on_path("gemini", &path),
"a directory is not a program"
);
assert!(!program_on_path("aider", &path));
assert!(program_on_path(
bin.to_str().unwrap(),
std::ffi::OsStr::new("")
));
assert!(!program_on_path("", &path));
}
}
+629 -74
View File
@@ -213,6 +213,11 @@ pub struct Config {
pub new_tab_position: NewTabPosition,
#[serde(default, deserialize_with = "de_lenient")]
pub tab_bar_position: TabBarPosition,
/// What an SSH pane's tab is called (#726). Only the name the tab shows:
/// the titles the remote side sets are still read and kept, and win again
/// the moment this is back on [`SshTabTitle::Dynamic`].
#[serde(default, deserialize_with = "de_lenient")]
pub ssh_tab_title: SshTabTitle,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
#[serde(default)]
@@ -246,6 +251,12 @@ pub struct Config {
/// impression than one they asked for.
#[serde(default)]
pub scm_graph_expanded: bool,
/// Whether the source control panel lists changed files as a directory
/// tree rather than one flat run per group. Off by default: the flat list
/// is what a handful of changes reads best as, and the tree is for the
/// agent run that touched sixty files across a dozen modules.
#[serde(default)]
pub scm_changes_tree: bool,
/// Whether a file opens in the code panel with soft wrap on. Not a
/// setting anyone picks up front: it is whatever the status bar's Wrap
/// toggle (or `ToggleDocumentWrap`) was last left at, so the next file
@@ -257,24 +268,12 @@ pub struct Config {
/// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it.
#[serde(default)]
pub editor_markdown_preview: bool,
#[serde(default, deserialize_with = "de_lenient")]
pub sidebar_grouping: SidebarGrouping,
/// Which sidebar groups are folded shut, by group key: the repo root the
/// group is named after, or the empty string for the scratch group, which
/// has no root of its own and no real key can ever collide with.
///
/// Kept as a list of the folded ones rather than a flag per group because
/// groups come and go with the tabs — a group nobody has opened yet has to
/// start expanded, and an entry for a repo that is no longer around costs
/// one dead path in the file.
///
/// `String`, not `PathBuf`: serde refuses to serialize a non-UTF-8
/// `PathBuf`, and `Config::save` turns that refusal into one `warn!` and
/// a return — so a single repo root with odd bytes in it would silently
/// stop the *whole* config being written from then on. A lossy spelling
/// of such a root at worst folds two of them together.
#[serde(default, deserialize_with = "de_lenient")]
pub sidebar_collapsed_groups: Vec<String>,
/// Whether the sidebar files tabs nobody pinned into groups of its own —
/// by repository, and by host for an SSH pane. Off, those tabs sit in one
/// flat list below the pinned groups, which are the user's and show
/// either way.
#[serde(default = "default_true")]
pub sidebar_auto_grouping: bool,
#[serde(default = "default_true")]
pub sidebar_diff_preview: bool,
#[serde(default, deserialize_with = "de_lenient")]
@@ -332,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,
@@ -377,20 +383,45 @@ pub struct Config {
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
/// The saved SSH hosts. They live in [`SERVERS_FILE`], not in
/// `config.json` (#911): people sync `config.json` between machines for its
/// colours and keys, and a list of servers is not something every machine
/// should carry. Still read from `config.json` so [`Config::load`] can
/// move an older file's hosts across, never written back to it.
#[serde(default, skip_serializing)]
pub ssh_profiles: Vec<crate::core::ssh_profile::SshProfile>,
#[serde(default = "default_true")]
pub verify_host_keys: bool,
#[serde(default)]
pub ssh_warn_on_close: bool,
#[serde(default)]
/// How often each saved host is used, keyed by its id — which means
/// nothing without the hosts it counts, so it lives in [`SERVERS_FILE`]
/// beside them.
#[serde(default, skip_serializing)]
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
/// How often and how lately each shell was opened from the New Tab menu
/// or its palette command, keyed by the label the inventory shows it
/// under. Orders the menu's short list of shells the way
/// `ssh_profile_frecency` orders its hosts.
#[serde(default)]
pub shell_frecency: HashMap<String, ProfileUsage>,
#[serde(default)]
pub command_frecency: HashMap<String, ProfileUsage>,
#[serde(default)]
pub agent_commands: HashMap<String, String>,
/// The command line a quick launch types for an agent, keyed by its slug:
/// `{"claude": "claude --dangerously-skip-permissions"}`. An agent with no
/// entry is launched as its bare binary. A wrapper named here is detected
/// as that agent without an `agent_commands` entry of its own.
#[serde(default)]
pub agent_launch: HashMap<String, String>,
/// How often and how recently each agent (by slug) was launched or seen
/// running — what orders the quick-launch commands and picks the agent
/// "New Agent Tab" opens.
#[serde(default)]
pub agent_frecency: HashMap<String, ProfileUsage>,
#[serde(default = "default_true")]
pub restore_agent_sessions: bool,
/// Give each pane its own shell history instead of one file every pane
@@ -412,6 +443,45 @@ pub struct Config {
/// permanent data loss (#537). Cleared only by a load that parses.
#[serde(skip)]
pub quarantined: bool,
/// [`SERVERS_FILE`] is there but could not be read or parsed. The hosts
/// in hand are not the ones in it, so [`Config::save`] refuses to run
/// rather than write them over it — the same rule, for the same reason,
/// as [`Self::quarantined`].
#[serde(skip)]
pub servers_unreadable: bool,
}
/// Where the saved SSH hosts live, beside `config.json` (#911).
pub const SERVERS_FILE: &str = "servers.json";
/// What [`SERVERS_FILE`] holds. The keys are the ones `config.json` used to
/// carry, so a hand-moved block pastes straight across.
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
struct ServersFile {
ssh_profiles: Vec<crate::core::ssh_profile::SshProfile>,
ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
}
#[derive(Serialize)]
struct ServersFileRef<'a> {
ssh_profiles: &'a [crate::core::ssh_profile::SshProfile],
ssh_profile_frecency: &'a HashMap<uuid::Uuid, ProfileUsage>,
}
/// Whether a `config.json` still carries the keys that moved to
/// [`SERVERS_FILE`] — the cue for the one-time move.
#[derive(Deserialize)]
struct LegacyServerKeys {
#[serde(default)]
ssh_profiles: Option<serde::de::IgnoredAny>,
#[serde(default)]
ssh_profile_frecency: Option<serde::de::IgnoredAny>,
}
fn has_legacy_server_keys(text: &str) -> bool {
serde_json::from_str::<LegacyServerKeys>(strip_bom(text))
.is_ok_and(|keys| keys.ssh_profiles.is_some() || keys.ssh_profile_frecency.is_some())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
@@ -473,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 {
@@ -489,6 +581,21 @@ pub enum TabBarPosition {
Left,
}
/// Where an SSH pane's tab takes its name from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshTabTitle {
/// Whatever the remote shell or program titles itself, falling back to
/// the host's name until it says anything — what tty7 always did.
#[default]
Dynamic,
/// The saved host's name, the alias for a `~/.ssh/config` host, the
/// address typed for a quick connect.
ProfileName,
/// The address the connection dialled.
Hostname,
}
/// Native window backdrop material for the Windows GUI. Other platforms retain
/// the value for config synchronization but do not use it for rendering.
/// `Auto` keeps the legacy behavior where theme blur decides between blurred
@@ -507,17 +614,6 @@ pub enum WindowBackdrop {
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SidebarGrouping {
#[default]
Repo,
/// By repository where there is one; a tab whose cwd is known not to be
/// in a repo groups under that cwd instead of falling to Scratch.
RepoOrDirectory,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NotifyMode {
@@ -667,6 +763,7 @@ impl Default for Config {
scrollback_limit: 10_000,
new_tab_position: NewTabPosition::AfterCurrent,
tab_bar_position: TabBarPosition::Left,
ssh_tab_title: SshTabTitle::Dynamic,
sidebar_width: default_sidebar_width(),
sidebar_collapsed: false,
right_panel_visible: false,
@@ -676,10 +773,10 @@ impl Default for Config {
document_layout: DocumentLayout::default(),
document_ratio: default_document_ratio(),
scm_graph_expanded: false,
scm_changes_tree: false,
editor_soft_wrap: false,
editor_markdown_preview: false,
sidebar_grouping: SidebarGrouping::Repo,
sidebar_collapsed_groups: Vec::new(),
sidebar_auto_grouping: true,
sidebar_diff_preview: true,
notify_on_command_finish: NotifyMode::Unfocused,
check_for_updates: true,
@@ -695,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,
@@ -715,11 +813,15 @@ impl Default for Config {
verify_host_keys: true,
ssh_warn_on_close: false,
ssh_profile_frecency: HashMap::new(),
shell_frecency: HashMap::new(),
command_frecency: HashMap::new(),
agent_commands: HashMap::new(),
agent_launch: HashMap::new(),
agent_frecency: HashMap::new(),
restore_agent_sessions: true,
per_pane_history: false,
quarantined: false,
servers_unreadable: false,
}
}
}
@@ -766,13 +868,42 @@ impl Config {
/// app onto the result needs the outcome to keep a broken file from
/// evicting the settings the app is running on.
pub fn load_with_outcome() -> (Self, LoadOutcome) {
let Some(path) = Self::path() else {
return (Config::default(), LoadOutcome::Absent);
};
let text = match std::fs::read_to_string(&path) {
match Self::path() {
Some(path) => Self::load_from(&path),
None => (Config::default(), LoadOutcome::Absent),
}
}
/// [`Config::load_with_outcome`] for the `config.json` at `path`, with the
/// saved hosts read from the [`SERVERS_FILE`] beside it.
///
/// A `config.json` from before the split still holds the hosts itself.
/// When there is no [`SERVERS_FILE`] yet they are moved into one, once:
/// the new file is written first and `config.json` rewritten without them
/// only after that has landed, so a failure anywhere leaves them where
/// they were. When both files are there the new one wins and the copy in
/// `config.json` is not read — it is what an older tty7 wrote after the
/// move, or a synced file brought in, and it falls out of `config.json`
/// at its next save.
pub(crate) fn load_from(path: &Path) -> (Self, LoadOutcome) {
let (mut cfg, outcome, text) = Self::load_config_file(path);
let servers = path.with_file_name(SERVERS_FILE);
let found = cfg.load_servers(&servers);
if !found
&& let Some(text) = text
&& has_legacy_server_keys(&text)
{
cfg.move_servers_out(&text, path, &servers);
}
(cfg, outcome)
}
/// `config.json` alone, with the text it parsed from.
fn load_config_file(path: &Path) -> (Self, LoadOutcome, Option<String>) {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (Config::default(), LoadOutcome::Absent);
return (Config::default(), LoadOutcome::Absent, None);
}
Err(e) => {
// Unreadable is not unparseable, but the rule is the same:
@@ -785,13 +916,13 @@ impl Config {
);
let mut cfg = Config::default();
cfg.quarantined = true;
return (cfg, LoadOutcome::Unreadable);
return (cfg, LoadOutcome::Unreadable, None);
}
};
match serde_json::from_str::<Config>(strip_bom(&text)) {
Ok(mut cfg) => {
cfg.sanitize();
(cfg, LoadOutcome::Parsed)
(cfg, LoadOutcome::Parsed, Some(text))
}
Err(e) => {
// The next `save` overwrites this file wholesale, so handing
@@ -803,14 +934,83 @@ impl Config {
"failed to parse config at {}: {e}; keeping it aside and using defaults",
path.display()
);
quarantine(&path);
quarantine(path);
let mut cfg = Config::default();
cfg.quarantined = true;
(cfg, LoadOutcome::Quarantined)
(cfg, LoadOutcome::Quarantined, None)
}
}
}
/// Takes the hosts from the [`SERVERS_FILE`] at `path`, if there is one.
/// Returns whether there was — a file that is there but broken counts, so
/// that nothing is moved over the top of it.
fn load_servers(&mut self, path: &Path) -> bool {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false,
Err(e) => {
log::warn!(
"failed to read {}: {e}; saved SSH hosts unavailable, writes suppressed",
path.display()
);
self.servers_unreadable = true;
return true;
}
};
match serde_json::from_str::<ServersFile>(strip_bom(&text)) {
Ok(servers) => {
self.ssh_profiles = servers.ssh_profiles;
self.ssh_profile_frecency = servers.ssh_profile_frecency;
}
Err(e) => {
log::warn!(
"failed to parse {}: {e}; keeping it aside, writes suppressed",
path.display()
);
quarantine(path);
self.servers_unreadable = true;
}
}
true
}
/// The one-time move of the hosts out of `config.json` (#911). New file
/// first: until it has landed, `config.json` is the only copy.
///
/// `config.json` is then rewritten from its own `text` with just those
/// two keys taken out, not from `self` the way a save writes it. Nobody
/// asked for this write, so it should change nothing else: a hand-kept
/// file stays the handful of keys it was rather than growing every
/// default, and a key this build does not know — one a newer tty7 on a
/// synced machine wrote — survives.
fn move_servers_out(&self, text: &str, config: &Path, servers: &Path) {
if let Err(e) = self.write_servers(servers) {
log::warn!(
"could not move SSH hosts to {}: {e}; leaving them in config.json",
servers.display()
);
return;
}
let rewrite = || -> std::io::Result<()> {
let mut value: serde_json::Value =
serde_json::from_str(strip_bom(text)).map_err(std::io::Error::other)?;
if let Some(object) = value.as_object_mut() {
object.remove("ssh_profiles");
object.remove("ssh_profile_frecency");
}
let text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
write_atomic(config, text.as_bytes())
};
match rewrite() {
Ok(()) => log::info!("moved SSH hosts from config.json to {}", servers.display()),
Err(e) => log::warn!(
"moved SSH hosts to {}, but could not rewrite config.json: {e}",
servers.display()
),
}
}
/// What a clicked file link should open in, with the pre-setting default
/// filled in for a `Config` that never went through [`Self::sanitize`].
pub fn file_open_mode(&self) -> LinkFileOpen {
@@ -898,18 +1098,62 @@ impl Config {
/// Persist without hiding a failure from an interactive settings editor.
pub fn try_save(&self) -> std::io::Result<()> {
let path = Self::path()
.ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?;
self.try_save_to(&path)
}
/// [`Config::try_save`] for the `config.json` at `path`, and the
/// [`SERVERS_FILE`] beside it.
pub(crate) fn try_save_to(&self, path: &Path) -> std::io::Result<()> {
if self.quarantined {
return Err(std::io::Error::other(
"the existing configuration could not be read; repair it before saving",
));
}
let path = Self::path()
.ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?;
if self.servers_unreadable {
return Err(std::io::Error::other(format!(
"{SERVERS_FILE} could not be read; repair it before saving"
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// Hosts first, for the same reason the move out of `config.json`
// writes them first: a `config.json` that lands without them while
// their own file failed to would have dropped them.
self.write_servers(&path.with_file_name(SERVERS_FILE))?;
self.write_config(path)
}
fn write_config(&self, path: &Path) -> std::io::Result<()> {
let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
write_atomic(&path, text.as_bytes())
write_atomic(path, text.as_bytes())
}
/// Writes the hosts to their own file — unless it already says exactly
/// this, which is nearly every save: a dragged divider has nothing to do
/// with the hosts, and rewriting the file anyway would wake the watcher a
/// second time. No hosts and no file stays no file.
fn write_servers(&self, path: &Path) -> std::io::Result<()> {
let on_disk = match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str::<ServersFile>(strip_bom(&text)).ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ServersFile::default()),
Err(_) => None,
};
if on_disk.as_ref().is_some_and(|held| {
held.ssh_profiles == self.ssh_profiles
&& held.ssh_profile_frecency == self.ssh_profile_frecency
}) {
return Ok(());
}
let text = serde_json::to_string_pretty(&ServersFileRef {
ssh_profiles: &self.ssh_profiles,
ssh_profile_frecency: &self.ssh_profile_frecency,
})
.map_err(std::io::Error::other)?;
// Host names, users and key paths: nobody else's business.
write_atomic_private(path, text.as_bytes())
}
fn path() -> Option<PathBuf> {
@@ -1194,15 +1438,44 @@ pub fn extra_env() -> HashMap<String, String> {
Config::load().env
}
pub fn agent_commands_cached() -> &'static HashMap<String, String> {
static CACHE: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
Config::load()
.agent_commands
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect()
})
/// Every wrapper name the daemon should read as an agent: `agent_commands`,
/// plus the programs `agent_launch` entries run (see
/// [`crate::core::cli_agent::launch_aliases`]). An explicit `agent_commands`
/// entry wins over an implied one.
///
/// Read again whenever `config.json` changes rather than once per process: the
/// daemon outlives any number of edits, and a launch override added while it
/// runs has to be recognised by the very next launch that uses it.
pub fn agent_detection_aliases() -> std::sync::Arc<HashMap<String, String>> {
type Stamp = Option<(std::time::SystemTime, u64)>;
static CACHE: std::sync::Mutex<Option<(Stamp, std::sync::Arc<HashMap<String, String>>)>> =
std::sync::Mutex::new(None);
let stamp: Stamp = Config::path()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| Some((m.modified().ok()?, m.len())));
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((seen, aliases)) = cache.as_ref()
&& *seen == stamp
{
return aliases.clone();
}
let cfg = Config::load();
let aliases = std::sync::Arc::new(detection_aliases(&cfg.agent_commands, &cfg.agent_launch));
*cache = Some((stamp, aliases.clone()));
aliases
}
/// [`agent_detection_aliases`] over the two maps it reads, for a caller that
/// already holds them.
pub fn detection_aliases(
commands: &HashMap<String, String>,
launch: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut aliases = crate::core::cli_agent::launch_aliases(launch);
for (name, slug) in commands {
aliases.insert(name.to_ascii_lowercase(), slug.clone());
}
aliases
}
fn default_preset() -> String {
@@ -1431,8 +1704,12 @@ mod tests {
last_used: 42,
},
);
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
// The count travels in the servers file now (#911), so the round
// trip is through disk rather than through `config.json`'s serde.
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
cfg.try_save_to(&config).unwrap();
let (back, _) = Config::load_from(&config);
assert!(back.ssh_warn_on_close);
assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4);
}
@@ -1509,24 +1786,13 @@ mod tests {
}
#[test]
fn sidebar_grouping_defaults_and_round_trips_leniently() {
assert_eq!(Config::default().sidebar_grouping, SidebarGrouping::Repo);
let text = serde_json::to_string(&Config {
sidebar_grouping: SidebarGrouping::RepoOrDirectory,
..Config::default()
})
.unwrap();
assert!(text.contains("\"sidebar_grouping\":\"repo-or-directory\""));
let back: Config = serde_json::from_str(&text).unwrap();
assert_eq!(back.sidebar_grouping, SidebarGrouping::RepoOrDirectory);
let flat: Config = serde_json::from_str(r#"{"sidebar_grouping":"none"}"#).unwrap();
assert_eq!(flat.sidebar_grouping, SidebarGrouping::None);
// Unknown values fall back to Repo instead of rejecting the whole config.
let lenient: Config = serde_json::from_str(r#"{"sidebar_grouping":"folders"}"#).unwrap();
assert_eq!(lenient.sidebar_grouping, SidebarGrouping::Repo);
fn sidebar_auto_grouping_defaults_on_and_round_trips() {
assert!(Config::default().sidebar_auto_grouping);
let off: Config = serde_json::from_str(r#"{"sidebar_auto_grouping":false}"#).unwrap();
assert!(!off.sidebar_auto_grouping);
let json = serde_json::to_string(&off).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert!(!back.sidebar_auto_grouping, "persisted");
}
#[test]
@@ -2554,4 +2820,293 @@ mod tests {
std::fs::write(exe_dir.path().join(PORTABLE_DATA_DIR), "").unwrap();
assert_eq!(portable_data_dir(exe_dir.path()), None);
}
#[test]
fn launch_overrides_teach_the_daemon_their_wrappers_as_the_file_changes() {
let _guard = lock_config_file();
pin_config_dir();
let mut cfg = Config::default();
cfg.agent_launch
.insert("claude".to_string(), "cc --fast".to_string());
cfg.save();
let aliases = agent_detection_aliases();
assert_eq!(aliases.get("cc").map(String::as_str), Some("claude"));
// An edit made while the daemon runs is picked up on the next read,
// and an explicit `agent_commands` entry outranks an implied one.
cfg.agent_launch
.insert("codex".to_string(), "/opt/bin/cx".to_string());
cfg.agent_commands
.insert("CC".to_string(), "gemini".to_string());
cfg.save();
let aliases = agent_detection_aliases();
assert_eq!(aliases.get("cc").map(String::as_str), Some("gemini"));
assert_eq!(aliases.get("cx").map(String::as_str), Some("codex"));
let back: Config = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(back.agent_launch, cfg.agent_launch);
Config::default().save();
}
fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile {
let mut profile = crate::core::ssh_profile::SshProfile::new(name);
profile.host = address.to_string();
profile.user = "deploy".to_string();
profile
}
/// A `config.json` from before #911, holding hosts, a usage count, a key
/// this build has never heard of, and the handful of settings the user
/// actually wrote.
fn legacy_config(profile: &crate::core::ssh_profile::SshProfile) -> String {
let usage = serde_json::json!({ profile.id.to_string(): { "count": 3, "last_used": 5 } });
serde_json::to_string_pretty(&serde_json::json!({
"font_size": 18.0,
"a_key_from_a_newer_build": { "x": 1 },
"ssh_profiles": [profile],
"ssh_profile_frecency": usage,
"keybindings": { "SplitRight": "cmd-d" },
}))
.unwrap()
}
#[test]
fn the_hosts_move_out_of_config_json_once_and_nothing_else_moves() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
let servers = dir.path().join(SERVERS_FILE);
let profile = host("prod-web", "10.0.0.5");
std::fs::write(&config, legacy_config(&profile)).unwrap();
let (cfg, outcome) = Config::load_from(&config);
assert_eq!(outcome, LoadOutcome::Parsed);
assert_eq!(cfg.ssh_profiles, vec![profile.clone()]);
assert_eq!(cfg.ssh_profile_frecency[&profile.id].count, 3);
assert_eq!(cfg.font_size, 18.0);
let moved: ServersFile =
serde_json::from_str(&std::fs::read_to_string(&servers).unwrap()).unwrap();
assert_eq!(moved.ssh_profiles, vec![profile.clone()]);
assert_eq!(moved.ssh_profile_frecency[&profile.id].count, 3);
let left: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
let left = left.as_object().unwrap();
assert!(!left.contains_key("ssh_profiles"));
assert!(!left.contains_key("ssh_profile_frecency"));
// The rest of the file is exactly what it was: nothing the user did
// not write appears, and nothing this build cannot read disappears.
assert_eq!(left["font_size"], serde_json::json!(18.0));
assert_eq!(
left["a_key_from_a_newer_build"],
serde_json::json!({ "x": 1 })
);
assert_eq!(
left["keybindings"],
serde_json::json!({ "SplitRight": "cmd-d" })
);
assert_eq!(left.len(), 3, "the move must not expand the file: {left:?}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&servers).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
// Once is once: a second load reads the new file and writes nothing.
let config_after = std::fs::read(&config).unwrap();
let servers_after = std::fs::read(&servers).unwrap();
let (again, _) = Config::load_from(&config);
assert_eq!(again.ssh_profiles, vec![profile]);
assert_eq!(std::fs::read(&config).unwrap(), config_after);
assert_eq!(std::fs::read(&servers).unwrap(), servers_after);
}
#[test]
fn with_both_files_present_the_servers_file_wins() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
let servers = dir.path().join(SERVERS_FILE);
let current = host("current", "10.0.0.1");
let stale = host("stale", "10.0.0.2");
let cfg = Config {
ssh_profiles: vec![current.clone()],
..Config::default()
};
cfg.try_save_to(&config).unwrap();
// An older tty7, or a synced file, puts hosts back in config.json.
std::fs::write(&config, legacy_config(&stale)).unwrap();
let servers_before = std::fs::read(&servers).unwrap();
let (loaded, _) = Config::load_from(&config);
assert_eq!(loaded.ssh_profiles, vec![current.clone()]);
assert!(loaded.ssh_profile_frecency.is_empty());
assert_eq!(std::fs::read(&servers).unwrap(), servers_before);
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
legacy_config(&stale),
"loading is not the moment to rewrite config.json"
);
// The stale copy falls out at the next ordinary save.
loaded.try_save_to(&config).unwrap();
let text = std::fs::read_to_string(&config).unwrap();
assert!(!text.contains("ssh_profiles"), "{text}");
assert_eq!(Config::load_from(&config).0.ssh_profiles, vec![current]);
}
#[test]
fn a_config_json_without_hosts_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
std::fs::write(&config, "{\"font_size\": 18}").unwrap();
let (cfg, _) = Config::load_from(&config);
assert!(cfg.ssh_profiles.is_empty());
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
"{\"font_size\": 18}"
);
assert!(!dir.path().join(SERVERS_FILE).exists());
}
#[test]
fn an_empty_hosts_key_is_dropped_without_making_a_servers_file() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
std::fs::write(&config, "{\"font_size\": 18, \"ssh_profiles\": []}").unwrap();
Config::load_from(&config);
let text = std::fs::read_to_string(&config).unwrap();
assert!(!text.contains("ssh_profiles"), "{text}");
assert!(text.contains("font_size"), "{text}");
assert!(!dir.path().join(SERVERS_FILE).exists());
}
#[test]
fn saving_writes_the_hosts_to_their_own_file_and_nowhere_else() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
let servers = dir.path().join(SERVERS_FILE);
// No hosts, no file.
Config::default().try_save_to(&config).unwrap();
assert!(!servers.exists());
let profile = host("prod-web", "10.0.0.5");
let mut cfg = Config {
ssh_profiles: vec![profile.clone()],
..Config::default()
};
cfg.ssh_profile_frecency.insert(
profile.id,
ProfileUsage {
count: 2,
last_used: 9,
},
);
cfg.try_save_to(&config).unwrap();
let text = std::fs::read_to_string(&config).unwrap();
assert!(!text.contains("ssh_profile"), "{text}");
assert!(!text.contains("10.0.0.5"), "{text}");
let (loaded, _) = Config::load_from(&config);
assert_eq!(loaded.ssh_profiles, vec![profile]);
assert_eq!(loaded.ssh_profile_frecency, cfg.ssh_profile_frecency);
// Deleting the last host empties the file rather than leaving it be.
let mut emptied = loaded.clone();
emptied.ssh_profiles.clear();
emptied.ssh_profile_frecency.clear();
emptied.try_save_to(&config).unwrap();
assert!(Config::load_from(&config).0.ssh_profiles.is_empty());
}
#[test]
fn a_broken_servers_file_is_kept_aside_and_never_overwritten() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
let servers = dir.path().join(SERVERS_FILE);
let profile = host("prod-web", "10.0.0.5");
std::fs::write(&config, legacy_config(&profile)).unwrap();
std::fs::write(&servers, "{ nope").unwrap();
let (cfg, outcome) = Config::load_from(&config);
assert_eq!(outcome, LoadOutcome::Parsed, "config.json itself is fine");
assert!(cfg.servers_unreadable);
assert_eq!(
std::fs::read_to_string(servers.with_extension("json.corrupt")).unwrap(),
"{ nope"
);
// Nothing moved on top of it, and nothing saves over either file.
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
legacy_config(&profile)
);
assert!(cfg.try_save_to(&config).is_err());
assert_eq!(std::fs::read_to_string(&servers).unwrap(), "{ nope");
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
legacy_config(&profile)
);
}
#[test]
fn the_hosts_never_serialize_into_config_json_but_an_old_file_still_reads() {
let mut cfg: Config = serde_json::from_str(
r#"{"ssh_profiles":[{"name":"a","host":"h"}],"verify_host_keys":false}"#,
)
.unwrap();
assert_eq!(cfg.ssh_profiles.len(), 1);
assert!(!cfg.verify_host_keys);
cfg.ssh_profile_frecency
.insert(cfg.ssh_profiles[0].id, ProfileUsage::default());
let value = serde_json::to_value(&cfg).unwrap();
assert!(value.get("ssh_profiles").is_none());
assert!(value.get("ssh_profile_frecency").is_none());
// The SSH preferences are settings like any other and stay put.
assert_eq!(value["verify_host_keys"], serde_json::json!(false));
}
#[test]
fn ssh_tab_title_defaults_to_dynamic_and_round_trips_leniently() {
let cfg: Config = serde_json::from_str("{}").unwrap();
assert_eq!(cfg.ssh_tab_title, SshTabTitle::Dynamic);
for (raw, mode) in [
("\"dynamic\"", SshTabTitle::Dynamic),
("\"profile-name\"", SshTabTitle::ProfileName),
("\"hostname\"", SshTabTitle::Hostname),
("\"sideways\"", SshTabTitle::Dynamic),
("7", SshTabTitle::Dynamic),
] {
let cfg: Config =
serde_json::from_str(&format!("{{\"ssh_tab_title\": {raw}, \"font_size\": 20}}"))
.unwrap();
assert_eq!(cfg.ssh_tab_title, mode, "{raw}");
assert_eq!(cfg.font_size, 20.0, "a bad value must not cost the file");
let back: Config = serde_json::from_value(serde_json::to_value(&cfg).unwrap()).unwrap();
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);
}
}
+559 -105
View File
@@ -1,156 +1,610 @@
//! Which sidebar group a tab belongs to, and where that answer came from.
//! Which sidebar group a tab belongs to, and who decided.
//!
//! Two kinds of group live in the same sidebar and look alike, but they are
//! owned by different things. A [`GroupKey::Repo`] is *derived*: the sidebar
//! recomputes it every frame from the tab's cwd, so a tab that `cd`s into
//! another repository walks into another group on its own. A
//! [`GroupKey::Custom`] is *stated*: the user put the tab there by hand, and
//! nothing about the cwd may move it out again.
//! "The sidebar groups tabs by repo automatically. Pin what you want to keep."
//! Everything here follows from that sentence. Groups above the divider are
//! *kept*: a [`PinnedGroup`] is stored with the workspace, has an id, an order
//! the user chose and a name they may change, and a tab in one only ever
//! leaves by hand. Everything below the divider is *derived*: an [`AutoKey`]
//! is worked out from the tab's cwd (or, for an SSH pane, from the host it is
//! on) every time the sidebar is drawn, so a tab that `cd`s into another
//! repository walks into another group on its own, and a group whose last tab
//! leaves simply stops existing. Nothing about an auto group is stored except
//! whether it is folded.
//!
//! Keeping the two in one enum is what makes the "never overwrite a stated
//! group" rule enforceable — the alternative, a path plus a `pinned` flag,
//! leaves the flag and the path free to disagree, and a custom group's name
//! is not a path in the first place.
//! A pinned group may name a folder. That is what lets a group be kept for a
//! project while its tabs come and go: a tab whose cwd *enters* the folder
//! joins it (see [`EntryWatch`]), and an empty folder group stays on screen
//! with a row to open a tab in it. A pinned group without a folder is a plain
//! label the user files tabs under by hand.
use std::path::{Path, PathBuf};
/// The prefix that marks a custom group in the flat string spelling. No
/// absolute path can collide with it: a POSIX root starts with `/` and a
/// Windows one with a single-letter drive, so neither can begin `custom:`.
const CUSTOM: &str = "custom:";
use serde::{Deserialize, Serialize};
/// The identity of a pinned group. A name cannot be one — two groups may share
/// a name, and a rename must not orphan every tab filed under the old one —
/// so tabs point at this instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GroupId(uuid::Uuid);
impl GroupId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
}
impl Default for GroupId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for GroupId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A group the user chose to keep.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinnedGroup {
pub id: GroupId,
/// What the header reads. `None` falls back to the folder's last
/// component, so pinning `~/src/tty7` reads `tty7` until someone renames
/// it — and renaming it back to nothing brings that back.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// A directory on the workspace's own host, or `None` for a label group.
///
/// A `String`, not a `PathBuf`, for the same reason `PaneRecord::cwd` is
/// one: serde refuses to write a non-UTF-8 `PathBuf`, and the machine tree
/// is written whole — one odd byte in one folder would stop every layout
/// on the machine being saved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub collapsed: bool,
}
impl PinnedGroup {
/// A group for tabs the user files by hand, called `name`.
pub fn label(name: impl Into<String>) -> Self {
Self {
id: GroupId::new(),
name: Some(name.into()),
folder: None,
collapsed: false,
}
}
/// A group that keeps `folder`, named after it until renamed.
pub fn folder(folder: &Path) -> Self {
Self {
id: GroupId::new(),
name: None,
folder: Some(folder.to_string_lossy().into_owned()),
collapsed: false,
}
}
pub fn folder_path(&self) -> Option<&Path> {
self.folder.as_deref().map(Path::new)
}
/// The name the user typed, when there is one worth printing. A blank
/// name is no name: it would draw an unlabelled header.
pub fn given_name(&self) -> Option<&str> {
self.name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
}
}
/// A group the sidebar works out for itself. Never stored as a membership —
/// only as the key a fold is remembered under.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(from = "AutoKeyWire", into = "AutoKeyWire")]
pub enum AutoKey {
/// The repository home a tab's cwd resolved to. A linked worktree resolves
/// to the repo it belongs to, and a submodule to itself — so a submodule
/// gets its own group, and a worktree sits with its main checkout.
Repo(PathBuf),
/// The host an SSH pane is on, as its `user@host` target.
///
/// By host and not by remote path: two machines' `/home/ubuntu` are not
/// the same directory, and grouping by the path alone filed tabs on two
/// different boxes under one header.
SshHost(String),
}
/// The spelling [`AutoKey`] is stored in. A repo root goes down as a lossy
/// string for the reason [`PinnedGroup::folder`] is one.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum AutoKeyWire {
Repo(String),
SshHost(String),
}
impl From<AutoKeyWire> for AutoKey {
fn from(w: AutoKeyWire) -> Self {
match w {
AutoKeyWire::Repo(p) => AutoKey::Repo(PathBuf::from(p)),
AutoKeyWire::SshHost(h) => AutoKey::SshHost(h),
}
}
}
impl From<AutoKey> for AutoKeyWire {
fn from(k: AutoKey) -> Self {
match k {
AutoKey::Repo(p) => AutoKeyWire::Repo(p.to_string_lossy().into_owned()),
AutoKey::SshHost(h) => AutoKeyWire::SshHost(h),
}
}
}
/// Where a tab is drawn: a pinned group, an auto group, or — as `None` beside
/// it — Ungrouped.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum GroupKey {
/// The repository (or, under `RepoOrDirectory`, the plain directory) the
/// sidebar worked out for itself. Recomputed from the cwd every frame.
Repo(PathBuf),
/// A group the user named and put this tab in. Never recomputed.
Custom(String),
Pinned(GroupId),
Auto(AutoKey),
}
impl GroupKey {
/// The custom group called `name`, or `None` when the name is blank —
/// an empty name would print as an unlabelled header and collide with
/// Scratch, which already owns "no name at all".
pub fn custom(name: &str) -> Option<Self> {
let name = name.trim();
(!name.is_empty()).then(|| Self::Custom(name.to_string()))
pub fn is_pinned(&self) -> bool {
matches!(self, Self::Pinned(_))
}
pub fn is_custom(&self) -> bool {
matches!(self, Self::Custom(_))
}
pub fn repo_root(&self) -> Option<&Path> {
pub fn pinned(&self) -> Option<GroupId> {
match self {
Self::Repo(p) => Some(p),
Self::Custom(_) => None,
Self::Pinned(id) => Some(*id),
Self::Auto(_) => None,
}
}
/// The flat spelling used everywhere a group has to survive as one
/// string: the control protocol, the session file, and the list of
/// folded groups in the config.
pub fn encode(&self) -> String {
pub fn auto(&self) -> Option<&AutoKey> {
match self {
// Lossy on purpose. `Config::save` turns a serde failure on a
// non-UTF-8 `PathBuf` into one `warn!` and a return, so a single
// repo root with odd bytes in it would silently stop the whole
// config being written from then on. Two such roots folding
// together is the cheaper failure by a wide margin.
Self::Repo(p) => p.to_string_lossy().into_owned(),
Self::Custom(name) => format!("{CUSTOM}{name}"),
}
}
/// Reads back [`GroupKey::encode`]. Anything without the marker is a
/// repo root, which is also what every group written before custom
/// groups existed decodes to.
pub fn decode(s: &str) -> Option<Self> {
match s.strip_prefix(CUSTOM) {
Some(name) => Self::custom(name),
None => (!s.is_empty()).then(|| Self::Repo(PathBuf::from(s))),
Self::Auto(key) => Some(key),
Self::Pinned(_) => None,
}
}
}
impl serde::Serialize for GroupKey {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&self.encode())
/// Everything about a workspace's sidebar groups that outlives a frame, stored
/// on the workspace in the machine tree so every window onto it agrees.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceGroups {
/// In display order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pinned: Vec<PinnedGroup>,
/// The auto groups folded shut. A list of the folded ones rather than a
/// flag per group because auto groups come and go with the tabs — one
/// nobody has seen yet has to start open.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auto_collapsed: Vec<AutoKey>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ungrouped_collapsed: bool,
}
impl WorkspaceGroups {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn get(&self, id: GroupId) -> Option<&PinnedGroup> {
self.pinned.iter().find(|g| g.id == id)
}
pub fn get_mut(&mut self, id: GroupId) -> Option<&mut PinnedGroup> {
self.pinned.iter_mut().find(|g| g.id == id)
}
pub fn contains(&self, id: GroupId) -> bool {
self.get(id).is_some()
}
/// Whether the group drawn under `key` is folded; `None` is Ungrouped.
pub fn is_folded(&self, key: Option<&GroupKey>) -> bool {
match key {
Some(GroupKey::Pinned(id)) => self.get(*id).is_some_and(|g| g.collapsed),
Some(GroupKey::Auto(auto)) => self.auto_collapsed.contains(auto),
None => self.ungrouped_collapsed,
}
}
pub fn toggle_folded(&mut self, key: Option<&GroupKey>) {
match key {
Some(GroupKey::Pinned(id)) => {
if let Some(g) = self.get_mut(*id) {
g.collapsed = !g.collapsed;
}
}
Some(GroupKey::Auto(auto)) => {
match self.auto_collapsed.iter().position(|k| k == auto) {
Some(at) => {
self.auto_collapsed.remove(at);
}
None => self.auto_collapsed.push(auto.clone()),
}
}
None => self.ungrouped_collapsed = !self.ungrouped_collapsed,
}
}
/// The pinned folder group a cwd is inside, if any — see
/// [`pinned_folder_for`].
pub fn folder_for(&self, cwd: Option<&Path>, repo_home: Option<&Path>) -> Option<GroupId> {
pinned_folder_for(&self.pinned, cwd, repo_home)
}
}
impl<'de> serde::Deserialize<'de> for GroupKey {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
Self::decode(&s).ok_or_else(|| serde::de::Error::custom("empty sidebar group key"))
/// The pinned folder group a tab sitting in `cwd` (whose repo home, when it is
/// in one, is `repo_home`) belongs in.
///
/// A folder counts when the cwd is inside it, or when the cwd's repo home *is*
/// it: a linked worktree usually lives outside the checkout it was made from
/// (`~/wt/feature` for `~/src/tty7`), and pinning the repo is a statement about
/// all of its checkouts. When folders nest the deepest one wins, so pinning a
/// monorepo root and one package inside it files the package's tabs under the
/// package. Depth is counted in path components; two folders at the same depth
/// cannot both contain one cwd unless they are the same folder, and then the
/// first in the list wins.
pub fn pinned_folder_for(
pinned: &[PinnedGroup],
cwd: Option<&Path>,
repo_home: Option<&Path>,
) -> Option<GroupId> {
let mut best: Option<(usize, GroupId)> = None;
for group in pinned {
let Some(folder) = group.folder_path() else {
continue;
};
let inside = cwd.is_some_and(|c| c.starts_with(folder)) || repo_home == Some(folder);
if !inside {
continue;
}
let depth = folder.components().count();
if best.is_none_or(|(d, _)| depth > d) {
best = Some((depth, group.id));
}
}
best.map(|(_, id)| id)
}
/// How a group is named in `Config::sidebar_collapsed_groups`. The scratch
/// group has no key of its own, so it is written as the empty string — which
/// neither a repo root nor a custom group can ever be.
pub fn collapse_key(key: Option<&GroupKey>) -> String {
key.map(GroupKey::encode).unwrap_or_default()
/// The auto group a tab resolves to, from what is known about it right now.
///
/// `ssh_host` is the target of an SSH pane — one whose shell runs on a machine
/// no `Host` of ours reaches, so there is no repo to probe and its cwd names a
/// directory on a box the other tabs are not on. It outranks everything: the
/// host is the only thing about such a tab that is certain.
///
/// Otherwise `known` is the repo cache's three-valued answer for the tab's cwd:
/// `Some(Some(home))` a repo, `Some(None)` a directory that is known not to be
/// in one (Ungrouped), and `None` a probe that has not landed. That last one is
/// no decision at all, answered as `None`, so the caller keeps whatever it had
/// rather than bouncing the tab through Ungrouped mid-probe.
pub fn auto_key(ssh_host: Option<&str>, known: Option<Option<PathBuf>>) -> Option<Option<AutoKey>> {
if let Some(host) = ssh_host.map(str::trim).filter(|h| !h.is_empty()) {
return Some(Some(AutoKey::SshHost(host.to_string())));
}
Some(known?.map(AutoKey::Repo))
}
/// Where a tab is drawn, given the pinned group it names (if any), the auto
/// group it resolved to, and whether auto grouping is on.
///
/// A tab naming a group that no longer exists is an auto tab: deleting a
/// group returns its tabs to auto grouping, and a window that hears of the
/// deletion before it hears of the tabs being cleared must already draw them
/// that way.
pub fn place(
stated: Option<GroupId>,
groups: &WorkspaceGroups,
auto_grouping: bool,
auto: Option<AutoKey>,
) -> Option<GroupKey> {
if let Some(id) = stated.filter(|id| groups.contains(*id)) {
return Some(GroupKey::Pinned(id));
}
if !auto_grouping {
return None;
}
auto.map(GroupKey::Auto)
}
/// Edge detection for rule 2: a tab joins a pinned folder when its cwd
/// *enters* the folder, not whenever it happens to be inside.
///
/// The difference is the user's say. A tab dragged out of a folder group while
/// it is still sitting in the folder has been told where to go; re-applying
/// "inside means member" on the next frame would put it straight back. So the
/// watch remembers which pinned folder the tab was last seen in, and only a
/// change of answer — outside to inside, or one folder to another — counts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EntryWatch {
/// `None` until the tab is first looked at; then the folder it was in.
last: Option<Option<GroupId>>,
}
impl EntryWatch {
/// For a tab that already existed when this window met it — restored from
/// the machine tree at launch, or on a pull. The first look only
/// records where it is: whatever it is sitting in, it was already there,
/// and whoever last had it decided its group.
pub fn baseline() -> Self {
Self { last: None }
}
/// For a tab that was just opened, here or by another client. It starts
/// outside every folder, so the first cwd it reports inside one is an
/// entry — which is what makes a tab opened in a pinned folder land in
/// that group.
pub fn fresh() -> Self {
Self { last: Some(None) }
}
/// Records that the tab is now in `now` (the deepest pinned folder
/// containing it, if any), and answers the folder it just entered.
pub fn observe(&mut self, now: Option<GroupId>) -> Option<GroupId> {
let entered = match self.last {
Some(before) => now.filter(|g| before != Some(*g)),
None => None,
};
self.last = Some(now);
entered
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_repo_root_round_trips_as_a_bare_path() {
let k = GroupKey::Repo(PathBuf::from("/home/u/tty7"));
assert_eq!(k.encode(), "/home/u/tty7");
assert_eq!(GroupKey::decode("/home/u/tty7"), Some(k));
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn a_custom_group_round_trips_under_its_marker() {
let k = GroupKey::custom("工作").expect("non-blank");
assert_eq!(k.encode(), "custom:工作");
assert_eq!(GroupKey::decode("custom:工作"), Some(k));
fn folders(paths: &[&str]) -> Vec<PinnedGroup> {
paths
.iter()
.map(|f| PinnedGroup::folder(Path::new(f)))
.collect()
}
/// The whole point of the marker. A custom group named after something
/// that looks like a path must not come back as a repo root, or folding
/// one would fold the other.
/// Pinning a monorepo root and a package in it: the package's tabs go to
/// the package, the rest of the repo's to the root.
#[test]
fn a_custom_group_named_like_a_path_stays_custom() {
let k = GroupKey::custom("/home/u/tty7").expect("non-blank");
assert_eq!(k.encode(), "custom:/home/u/tty7");
assert_eq!(GroupKey::decode(&k.encode()), Some(k.clone()));
assert!(k.is_custom());
assert_ne!(k, GroupKey::Repo(PathBuf::from("/home/u/tty7")));
fn nested_pinned_folders_file_a_tab_under_the_deepest() {
let pinned = folders(&["/w/mono", "/w/mono/pkg/app", "/w/mono/pkg"]);
let at = |cwd: &str| pinned_folder_for(&pinned, Some(Path::new(cwd)), None);
assert_eq!(at("/w/mono/pkg/app/src"), Some(pinned[1].id));
assert_eq!(at("/w/mono/pkg/lib"), Some(pinned[2].id));
assert_eq!(at("/w/mono/docs"), Some(pinned[0].id));
assert_eq!(at("/w/other"), None);
}
/// Every group written before custom groups existed is a bare path.
/// Order in the list says nothing about depth — the deepest wins even
/// when it was pinned first.
#[test]
fn an_old_session_key_decodes_as_a_repo() {
fn the_deepest_folder_wins_whichever_was_pinned_first() {
let pinned = folders(&["/w/mono/pkg", "/w/mono"]);
assert_eq!(
GroupKey::decode("/w/alpha"),
Some(GroupKey::Repo(PathBuf::from("/w/alpha")))
pinned_folder_for(&pinned, Some(Path::new("/w/mono/pkg/x")), None),
Some(pinned[0].id)
);
}
/// Component-wise, not by string prefix: `/w/tty7-old` is not inside
/// `/w/tty7`.
#[test]
fn a_sibling_sharing_a_prefix_is_not_inside() {
let pinned = folders(&["/w/tty7"]);
assert_eq!(
pinned_folder_for(&pinned, Some(Path::new("/w/tty7-old")), None),
None
);
}
/// A worktree lives outside the checkout it came from, but its repo home
/// is that checkout — pinning the repo keeps its worktrees too.
#[test]
fn a_worktree_joins_the_folder_its_repo_home_is_pinned_as() {
let pinned = folders(&["/w/tty7"]);
assert_eq!(
pinned_folder_for(
&pinned,
Some(Path::new("/tmp/wt/feature/src")),
Some(Path::new("/w/tty7"))
),
Some(pinned[0].id)
);
}
/// The repo home counts as the folder itself, so a deeper folder the cwd
/// is inside still wins over it.
#[test]
fn a_deeper_folder_beats_a_repo_home_match() {
let pinned = folders(&["/w/tty7", "/tmp/wt/feature"]);
assert_eq!(
pinned_folder_for(
&pinned,
Some(Path::new("/tmp/wt/feature/src")),
Some(Path::new("/w/tty7"))
),
Some(pinned[1].id)
);
}
/// A label group has no folder, so no cwd is ever inside it.
#[test]
fn a_label_group_never_pulls_a_tab_in() {
let pinned = vec![PinnedGroup::label("work")];
assert_eq!(pinned_folder_for(&pinned, Some(Path::new("/")), None), None);
}
#[test]
fn a_fresh_tab_entering_a_folder_joins_it() {
let g = GroupId::new();
let mut watch = EntryWatch::fresh();
assert_eq!(watch.observe(None), None);
assert_eq!(watch.observe(Some(g)), Some(g), "outside to inside");
assert_eq!(watch.observe(Some(g)), None, "staying inside is no entry");
}
#[test]
fn a_fresh_tab_opened_inside_a_folder_joins_on_its_first_look() {
let g = GroupId::new();
let mut watch = EntryWatch::fresh();
assert_eq!(watch.observe(Some(g)), Some(g));
}
/// A tab this window met already in place keeps the group whoever had it
/// gave it — sitting in a folder is not the same as walking into it.
#[test]
fn a_restored_tab_is_not_pulled_in_by_where_it_already_is() {
let g = GroupId::new();
let mut watch = EntryWatch::baseline();
assert_eq!(watch.observe(Some(g)), None);
assert_eq!(watch.observe(None), None);
assert_eq!(watch.observe(Some(g)), Some(g), "but walking back in is");
}
/// Dragged out while inside: not pulled back until it leaves and comes
/// back. The watch is fed whether or not the tab is in a group, so it
/// already knows the tab is inside when the drag lands.
#[test]
fn a_tab_dragged_out_is_not_pulled_back_until_it_re_enters() {
let g = GroupId::new();
let mut watch = EntryWatch::fresh();
assert_eq!(watch.observe(Some(g)), Some(g), "joined on the way in");
// … dragged out here; the cwd has not moved …
assert_eq!(watch.observe(Some(g)), None, "still inside: stays out");
assert_eq!(watch.observe(Some(g)), None);
assert_eq!(watch.observe(None), None, "left");
assert_eq!(watch.observe(Some(g)), Some(g), "re-entered: joins again");
}
#[test]
fn moving_from_one_folder_into_another_is_an_entry() {
let (a, b) = (GroupId::new(), GroupId::new());
let mut watch = EntryWatch::fresh();
watch.observe(Some(a));
assert_eq!(watch.observe(Some(b)), Some(b));
}
/// The same remote path on two machines is two directories.
#[test]
fn ssh_tabs_on_two_hosts_with_one_path_land_apart() {
let one = auto_key(Some("ubuntu@alpha"), Some(Some(p("/home/ubuntu"))));
let two = auto_key(Some("ubuntu@beta"), Some(Some(p("/home/ubuntu"))));
assert_eq!(one, Some(Some(AutoKey::SshHost("ubuntu@alpha".into()))));
assert_eq!(two, Some(Some(AutoKey::SshHost("ubuntu@beta".into()))));
assert_ne!(one, two);
}
#[test]
fn a_probe_that_has_not_landed_is_no_decision() {
assert_eq!(auto_key(None, None), None);
assert_eq!(auto_key(None, Some(None)), Some(None), "known: no repo");
assert_eq!(
auto_key(None, Some(Some(p("/w/r")))),
Some(Some(AutoKey::Repo(p("/w/r"))))
);
}
#[test]
fn a_blank_name_is_no_group_at_all() {
assert_eq!(GroupKey::custom(" "), None);
assert_eq!(GroupKey::decode("custom:"), None);
assert_eq!(GroupKey::decode(""), None);
}
#[test]
fn a_name_is_trimmed_before_it_becomes_a_key() {
assert_eq!(GroupKey::custom(" work "), GroupKey::custom("work"));
}
#[test]
fn scratch_collapses_under_the_empty_string() {
assert_eq!(collapse_key(None), "");
fn a_tab_in_a_pinned_group_is_drawn_there_whatever_its_cwd_says() {
let mut groups = WorkspaceGroups::default();
let work = PinnedGroup::label("work");
let id = work.id;
groups.pinned.push(work);
let repo = Some(AutoKey::Repo(p("/w/r")));
assert_eq!(
collapse_key(Some(&GroupKey::Repo(PathBuf::from("/w/a")))),
"/w/a"
place(Some(id), &groups, true, repo.clone()),
Some(GroupKey::Pinned(id))
);
assert_eq!(
place(None, &groups, true, repo),
Some(GroupKey::Auto(AutoKey::Repo(p("/w/r"))))
);
}
/// Deleting a group returns its tabs to auto grouping — even in a window
/// that has heard of the deletion and not yet of the tabs being cleared.
#[test]
fn a_tab_naming_a_deleted_group_is_filed_automatically() {
let groups = WorkspaceGroups::default();
let repo = Some(AutoKey::Repo(p("/w/r")));
assert_eq!(
place(Some(GroupId::new()), &groups, true, repo),
Some(GroupKey::Auto(AutoKey::Repo(p("/w/r"))))
);
}
/// Auto grouping off: no auto group, but a pinned one still stands.
#[test]
fn auto_grouping_off_keeps_pinned_groups_and_drops_the_rest() {
let mut groups = WorkspaceGroups::default();
let work = PinnedGroup::label("work");
let id = work.id;
groups.pinned.push(work);
let repo = Some(AutoKey::Repo(p("/w/r")));
assert_eq!(place(None, &groups, false, repo.clone()), None);
assert_eq!(
place(Some(id), &groups, false, repo),
Some(GroupKey::Pinned(id))
);
}
#[test]
fn folds_are_kept_per_kind_of_group() {
let mut groups = WorkspaceGroups::default();
let work = PinnedGroup::label("work");
let id = work.id;
groups.pinned.push(work);
let repo = GroupKey::Auto(AutoKey::Repo(p("/w/r")));
let pinned = GroupKey::Pinned(id);
for key in [Some(&repo), Some(&pinned), None] {
assert!(!groups.is_folded(key));
groups.toggle_folded(key);
assert!(groups.is_folded(key));
}
assert_eq!(groups.auto_collapsed, vec![AutoKey::Repo(p("/w/r"))]);
assert!(groups.pinned[0].collapsed);
assert!(groups.ungrouped_collapsed);
groups.toggle_folded(Some(&repo));
assert!(groups.auto_collapsed.is_empty(), "unfolding takes it out");
}
#[test]
fn groups_round_trip_and_an_empty_set_writes_nothing() {
assert_eq!(
serde_json::to_string(&WorkspaceGroups::default()).unwrap(),
"{}"
);
let mut groups = WorkspaceGroups::default();
groups
.pinned
.push(PinnedGroup::folder(Path::new("/w/tty7")));
groups.pinned.push(PinnedGroup::label("工作"));
groups.auto_collapsed.push(AutoKey::SshHost("u@h".into()));
groups.auto_collapsed.push(AutoKey::Repo(p("/w/r")));
let text = serde_json::to_string(&groups).unwrap();
assert!(text.contains(r#"{"repo":"/w/r"}"#), "{text}");
let back: WorkspaceGroups = serde_json::from_str(&text).unwrap();
assert_eq!(back, groups);
}
#[test]
fn a_blank_name_falls_back_to_the_folder() {
let mut g = PinnedGroup::folder(Path::new("/w/tty7"));
assert_eq!(g.given_name(), None);
g.name = Some(" ".into());
assert_eq!(g.given_name(), None);
g.name = Some(" tty ".into());
assert_eq!(g.given_name(), Some("tty"));
}
}
+278 -10
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::core::cli_agent::CLIAgent;
use crate::core::group_key::{AutoKey, GroupId, WorkspaceGroups};
use crate::core::session::WorkspaceId;
use crate::daemon::protocol::{NativeSshSpec, ShellSpec};
@@ -134,6 +135,11 @@ pub struct Workspace {
/// one read back at boot would name a holder that no longer exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment: Option<Attachment>,
/// The workspace's pinned sidebar groups and which groups are folded.
/// Kept here, beside the tabs that point into it, so every window onto
/// the workspace draws the same groups in the same order.
#[serde(default, skip_serializing_if = "WorkspaceGroups::is_empty")]
pub groups: WorkspaceGroups,
}
impl Default for Workspace {
@@ -145,6 +151,7 @@ impl Default for Workspace {
tabs: Vec::new(),
active_tab: None,
attachment: None,
groups: WorkspaceGroups::default(),
}
}
}
@@ -155,9 +162,34 @@ pub struct Tab {
pub id: TabId,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub sidebar_group: Option<String>,
/// The pinned group this tab was put in, or `None` for one the sidebar
/// files by itself. Only pinned groups are stored: an auto group is
/// worked out from the tab's cwd every time it is drawn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<GroupId>,
/// The auto group this tab last resolved to — a hint, not a membership.
///
/// Auto groups are worked out from a repo probe, and at launch no probe
/// has answered yet: without this every restored tab sat in Ungrouped
/// until its own came back, then jumped. The GUI draws the tab here until
/// the live answer lands, and the live answer always wins and rewrites
/// it. It never outranks `group`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_auto: Option<AutoKey>,
pub root: PaneNode,
/// The tab was put to sleep: its panes were stopped to give their memory
/// back, and it stays in the workspace to be woken later (#762).
///
/// The panes keep their ids in `root` and their records in
/// [`Machine::panes`] — cwd, shell, agent session — because waking is a
/// restore of exactly those panes, through the same path a reboot takes.
/// That is also what keeps the sweeps off them: a pane the tree still
/// names is one whose stored screen and history are kept.
///
/// Left out of the document while false, so a tree written by this build
/// reads the same to one that predates the field.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hibernated: bool,
}
impl Tab {
@@ -165,8 +197,10 @@ impl Tab {
Tab {
id: TabId::new(),
name: None,
sidebar_group: None,
group: None,
last_auto: None,
root: PaneNode::Leaf { pane },
hibernated: false,
}
}
}
@@ -428,7 +462,12 @@ pub enum LayoutDelta {
},
TabRegrouped {
tab: TabId,
group: Option<String>,
group: Option<GroupId>,
#[serde(default)]
last_auto: Option<AutoKey>,
},
GroupsChanged {
groups: WorkspaceGroups,
},
TabRestructured {
tab: Tab,
@@ -747,23 +786,109 @@ impl MachineStore {
})
}
/// Put a tab to sleep, or mark it awake again. Answers the panes the tab
/// holds, which for a tab going to sleep are the ones whoever asked is now
/// expected to stop — see [`ControlRequest::TabSetHibernated`].
///
/// Only the mark changes here. The panes stay in the tab and in the pane
/// list, so the facts a wake needs survive the processes: waking hands the
/// same ids to the ordinary restore, which spawns successors and swaps
/// them in with `pane_replace`. Setting the mark a tab already has is not
/// a change and raises no delta.
///
/// The delta is `TabRestructured` carrying the whole tab rather than a
/// variant of its own, so a client that predates the mark still decodes
/// it — it reads the same tab back, minus a field it does not know.
///
/// [`ControlRequest::TabSetHibernated`]: crate::daemon::control::ControlRequest::TabSetHibernated
pub fn tab_set_hibernated(
&self,
workspace: WorkspaceId,
tab: TabId,
hibernated: bool,
origin: Option<SubscriberId>,
) -> io::Result<Vec<u64>> {
self.mutate(origin, |m| {
let t = find_tab(m, workspace, tab)?;
let panes = t.root.pane_ids();
if t.hibernated == hibernated {
return Ok((panes, Vec::new()));
}
t.hibernated = hibernated;
let delta = LayoutDelta::TabRestructured {
tab: t.clone(),
pane: None,
};
Ok((panes, vec![(workspace, delta)]))
})
}
pub fn tab_set_group(
&self,
workspace: WorkspaceId,
tab: TabId,
group: Option<String>,
group: Option<GroupId>,
last_auto: Option<AutoKey>,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let t = find_tab(m, workspace, tab)?;
t.sidebar_group = group.clone();
t.group = group;
t.last_auto = last_auto.clone();
Ok((
(),
vec![(workspace, LayoutDelta::TabRegrouped { tab, group })],
vec![(
workspace,
LayoutDelta::TabRegrouped {
tab,
group,
last_auto,
},
)],
))
})
}
/// Replaces the workspace's sidebar groups whole.
///
/// Whole rather than one verb per edit: the set is small, edited by hand,
/// and every edit to it — a pin, a rename, a drag that reorders — is
/// "this is the list now". Last writer wins, which for two windows racing
/// to fold the same header is the answer either of them would have given.
///
/// A tab still pointing at a group this drops is handed back to auto
/// grouping here, in the same mutation, so the tree never holds a tab
/// filed under a group nobody can see. Each such tab is announced like any
/// other regroup; the client that sent the new set is left out of those
/// deltas as usual and has already cleared the tabs itself.
pub fn workspace_set_groups(
&self,
workspace: WorkspaceId,
groups: WorkspaceGroups,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let ws = find_workspace(m, workspace)?;
let mut deltas = Vec::new();
for t in &mut ws.tabs {
if t.group.is_some_and(|g| !groups.contains(g)) {
t.group = None;
deltas.push((
workspace,
LayoutDelta::TabRegrouped {
tab: t.id,
group: None,
last_auto: t.last_auto.clone(),
},
));
}
}
ws.groups = groups.clone();
deltas.push((workspace, LayoutDelta::GroupsChanged { groups }));
Ok(((), deltas))
})
}
pub fn pane_split(
&self,
workspace: WorkspaceId,
@@ -2056,17 +2181,63 @@ mod tests {
store
.tab_rename(ws, first.id, Some("build".into()), None)
.unwrap();
let group = GroupId::new();
store
.tab_set_group(ws, first.id, Some("/repo/tty7".into()), None)
.tab_set_group(ws, first.id, Some(group), None, None)
.unwrap();
store.tab_move(ws, first.id, 1, None).unwrap();
let workspace = store.workspace(ws).unwrap();
assert_eq!(workspace.tabs[0].id, second.id);
assert_eq!(workspace.tabs[1].name.as_deref(), Some("build"));
assert_eq!(workspace.tabs[1].group, Some(group));
}
/// Dropping a group hands its tabs back to auto grouping in the same
/// mutation, and says so — a window that only heard `GroupsChanged` would
/// otherwise keep a tab filed under a group it can no longer draw.
#[test]
fn dropping_a_group_returns_its_tabs_to_auto_grouping() {
use crate::core::group_key::PinnedGroup;
let (store, _dir, ws, first) = store_with_tab();
let second = store
.tab_create(ws, None, seed(2, "/b"), None, None)
.unwrap();
let (keep, drop) = (PinnedGroup::label("keep"), PinnedGroup::label("drop"));
let groups = WorkspaceGroups {
pinned: vec![keep.clone(), drop.clone()],
..Default::default()
};
store.workspace_set_groups(ws, groups, None).unwrap();
store
.tab_set_group(ws, first.id, Some(drop.id), None, None)
.unwrap();
store
.tab_set_group(ws, second.id, Some(keep.id), None, None)
.unwrap();
let (_sub, heard) = recorded(&store);
let kept = WorkspaceGroups {
pinned: vec![keep.clone()],
..Default::default()
};
store.workspace_set_groups(ws, kept.clone(), None).unwrap();
let workspace = store.workspace(ws).unwrap();
assert_eq!(workspace.groups, kept);
assert_eq!(workspace.tabs[0].group, None, "back to auto grouping");
assert_eq!(workspace.tabs[1].group, Some(keep.id), "untouched");
let heard = heard.lock().unwrap();
assert_eq!(
workspace.tabs[1].sidebar_group.as_deref(),
Some("/repo/tty7")
heard.iter().map(|(_, d)| d.clone()).collect::<Vec<_>>(),
vec![
LayoutDelta::TabRegrouped {
tab: first.id,
group: None,
last_auto: None,
},
LayoutDelta::GroupsChanged { groups: kept },
]
);
}
@@ -2801,4 +2972,101 @@ mod tests {
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]);
assert!(machine.panes.is_empty());
}
/// A tab put to sleep keeps its panes — ids in the layout, records in the
/// pane list — because waking is a restore of exactly those. Only the
/// orphan collection a close runs could take them, and a sleeping tab
/// still names them.
#[test]
fn a_sleeping_tab_keeps_its_panes_and_says_so_in_a_delta() {
let (store, _dir, ws, tab) = store_with_tab();
store
.pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/work"), false, None)
.unwrap();
let (_sub, heard) = recorded(&store);
let panes = store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
assert_eq!(panes, vec![1, 2]);
let m = store.machine();
let asleep = &m.workspaces[0].tabs[0];
assert!(asleep.hibernated);
assert_eq!(asleep.root.pane_ids(), vec![1, 2]);
assert!(m.panes.iter().any(|p| p.id == 1) && m.panes.iter().any(|p| p.id == 2));
let heard = heard.lock().unwrap();
assert_eq!(heard.len(), 1);
match &heard[0].1 {
LayoutDelta::TabRestructured { tab: t, pane: None } => {
assert_eq!(t.id, tab.id);
assert!(t.hibernated, "the delta carries the mark");
}
other => panic!("expected TabRestructured, heard {other:?}"),
}
}
#[test]
fn setting_the_mark_a_tab_already_has_raises_nothing() {
let (store, _dir, ws, tab) = store_with_tab();
let (_sub, heard) = recorded(&store);
store.tab_set_hibernated(ws, tab.id, false, None).unwrap();
assert!(heard.lock().unwrap().is_empty());
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
assert_eq!(heard.lock().unwrap().len(), 1);
store.tab_set_hibernated(ws, tab.id, false, None).unwrap();
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
assert_eq!(heard.lock().unwrap().len(), 2);
}
#[test]
fn hibernating_a_tab_that_is_not_there_is_refused() {
let (store, _dir, ws, _tab) = store_with_tab();
let err = store
.tab_set_hibernated(ws, TabId::new(), true, None)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
/// Asleep has to outlive the daemon: a restart reads the tree back from
/// disk, and a tab that came back awake would be respawned on the spot —
/// every process the user put to sleep, started again unasked.
#[test]
fn a_sleeping_tab_is_still_asleep_after_the_tree_is_read_back() {
let (store, dir, ws, tab) = store_with_tab();
let other = store
.tab_create(ws, None, seed(5, "/else"), None, None)
.unwrap();
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
drop(store);
let reopened = MachineStore::open(dir.path().join(MACHINE_FILE));
let m = reopened.machine();
let tabs = &m.workspaces[0].tabs;
assert!(tabs.iter().find(|t| t.id == tab.id).unwrap().hibernated);
assert!(!tabs.iter().find(|t| t.id == other.id).unwrap().hibernated);
let record = m.panes.iter().find(|p| p.id == 1).unwrap();
assert_eq!(record.cwd.as_deref(), Some("/work"));
assert!(!record.live);
}
/// Awake is the default and stays out of the document, so a tree this
/// build writes for a tab that never slept reads the same to one that
/// predates the mark, and a tree from before it decodes as awake.
#[test]
fn the_mark_is_only_written_while_it_is_set() {
let tab = Tab::leaf(4);
let json = serde_json::to_string(&tab).unwrap();
assert!(!json.contains("hibernated"), "{json}");
let back: Tab = serde_json::from_str(&json).unwrap();
assert!(!back.hibernated);
let asleep = Tab {
hibernated: true,
..Tab::leaf(4)
};
let back: Tab = serde_json::from_str(&serde_json::to_string(&asleep).unwrap()).unwrap();
assert!(back.hibernated);
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod kitty_graphics;
pub mod logfile;
pub mod machine;
pub mod osc;
pub mod paste;
pub mod path_spelling;
pub mod proc;
pub mod session;
+99
View File
@@ -0,0 +1,99 @@
//! What a paste puts on the wire.
//!
//! Lives here rather than beside the GUI's clipboard handling because the GUI
//! is not its only sender: `tty7 send --paste` has to frame a payload exactly
//! the way a paste into the window does, and a second copy of the framing is
//! how the two would come to disagree about what sending multi-line text means.
/// Opens a bracketed paste (DEC mode 2004).
pub const START: &[u8] = b"\x1b[200~";
/// Closes one.
pub const END: &[u8] = b"\x1b[201~";
/// `payload` inside paste brackets, with every ESC taken out.
///
/// The stripping is the point, not a cleanup: a payload that carries its own
/// `ESC[201~` would otherwise end the paste early, and whatever follows it
/// would reach the shell as typed input — which is how pasted text turns into
/// commands. No escape sequence survives the strip, so none can close the
/// bracket from inside.
pub fn bracket(payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(START.len() + payload.len() + END.len());
bytes.extend_from_slice(START);
bytes.extend(payload.iter().copied().filter(|&b| b != 0x1b));
bytes.extend_from_slice(END);
bytes
}
/// The bytes a paste of `text` sends, for a pane that has (`bracketed`) or has
/// not switched bracketed paste on.
///
/// CRLF folds to one line break either way, so a Windows clipboard pastes like
/// any other. Framed, the breaks stay `\n` and the shell inserts them as text.
/// Unframed there is no way to say "text" to a pty, so each break goes as the
/// `\r` a keyboard's Enter sends — the lines run one at a time, which is what
/// pasting into a terminal without the mode has always meant.
pub fn paste_bytes(text: &[u8], bracketed: bool) -> Vec<u8> {
let mut folded = Vec::with_capacity(text.len());
let mut i = 0;
while i < text.len() {
if text[i] == b'\r' && text.get(i + 1) == Some(&b'\n') {
folded.push(b'\n');
i += 2;
} else {
folded.push(text[i]);
i += 1;
}
}
if bracketed {
bracket(&folded)
} else {
for b in &mut folded {
if *b == b'\n' {
*b = b'\r';
}
}
folded
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_payload_cannot_close_its_own_bracket() {
let out = bracket(b"a\x1b[201~; rm -rf /\nb");
let inner = &out[START.len()..out.len() - END.len()];
assert!(out.starts_with(START) && out.ends_with(END));
assert!(!inner.contains(&0x1b), "{inner:?}");
assert_eq!(inner, b"a[201~; rm -rf /\nb");
}
#[test]
fn crlf_folds_before_either_framing() {
assert_eq!(paste_bytes(b"a\r\nb\n", true), b"\x1b[200~a\nb\n\x1b[201~");
assert_eq!(paste_bytes(b"a\r\nb\nc", false), b"a\rb\rc");
}
#[test]
fn a_lone_cr_is_left_alone() {
assert_eq!(paste_bytes(b"a\rb", true), b"\x1b[200~a\rb\x1b[201~");
assert_eq!(paste_bytes(b"a\rb", false), b"a\rb");
}
#[test]
fn unframed_paste_keeps_escapes() {
// Only the frame needs protecting; without one there is nothing to
// close, and the GUI has always sent these bytes through untouched.
assert_eq!(paste_bytes(b"a\x1b[201~b", false), b"a\x1b[201~b");
}
#[test]
fn bytes_that_are_not_utf8_survive() {
assert_eq!(
paste_bytes(b"\xff\xfe", true),
b"\x1b[200~\xff\xfe\x1b[201~"
);
}
}
+58 -1
View File
@@ -50,10 +50,25 @@ pub struct SessionTab {
#[serde(default)]
pub name: Option<String>,
pub pane: SessionPane,
/// The pinned sidebar group the tab was in. Carried so a tab reopened
/// with ⌘⇧T goes back where it was, when that group is still there.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sidebar_group: Option<crate::core::group_key::GroupKey>,
pub group: Option<crate::core::group_key::GroupId>,
/// The auto group the tab last resolved to, drawn until a probe answers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_auto: Option<crate::core::group_key::AutoKey>,
#[serde(skip)]
pub tree_id: Option<crate::core::machine::TabId>,
/// Asleep in the tree: comes back as a placeholder holding `pane`, and
/// nothing is spawned for it until it is woken. See
/// [`Tab::hibernated`](crate::core::machine::Tab::hibernated).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hibernated: bool,
/// What a sleeping tab is called while nothing in it runs to say — read
/// off the tree's pane records, the same way the switcher names a tab of
/// a window it does not own.
#[serde(skip)]
pub asleep_view: Option<crate::core::tab_view::TabView>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -374,6 +389,11 @@ pub struct WindowView {
/// is another client's activity, not ours); opening one clears the mark.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub synced: bool,
/// Slugs of the coding agents seen running in this workspace, most recent
/// last. A remote workspace cannot be asked what is on its `PATH`, so
/// this is what its quick-launch list is made of.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub seen_agents: Vec<String>,
}
impl Default for WindowView {
@@ -387,6 +407,7 @@ impl Default for WindowView {
label: None,
subject: None,
synced: false,
seen_agents: Vec::new(),
}
}
}
@@ -407,6 +428,17 @@ impl WindowView {
self.host.is_some()
}
/// Remember `slug` as running here, moving it to the end if it was
/// already known. Answers whether anything changed.
pub fn saw_agent(&mut self, slug: &str) -> bool {
if self.seen_agents.last().is_some_and(|s| s == slug) {
return false;
}
self.seen_agents.retain(|s| s != slug);
self.seen_agents.push(slug.to_string());
true
}
pub fn host_id(&self) -> crate::host::HostId {
match &self.host {
Some(r) => r.host_id(),
@@ -1162,4 +1194,29 @@ mod tests {
views.views.retain(|w| w.id != b_id);
assert_eq!(numbers(&views), vec![a_id, c_id]);
}
/// A remote workspace's quick-launch list is the agents seen running in
/// it, so the list has to be in `views.json` with the rest of the
/// workspace — an in-memory one would be empty after every restart.
#[test]
fn agents_seen_in_a_workspace_survive_a_restart() {
let _file = lock_session_file();
pin_config_dir();
let mut entry = remote_view("build-box");
assert!(entry.saw_agent("claude"));
assert!(entry.saw_agent("codex"));
assert!(!entry.saw_agent("codex"), "already the most recent");
assert!(entry.saw_agent("claude"), "moves to the end");
let id = entry.id;
WindowViews {
active: Some(id),
views: vec![entry, view()],
}
.save();
let loaded = WindowViews::load().expect("a saved views file should load back");
assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]);
// A workspace that has seen nothing writes nothing for it.
let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap();
assert_eq!(text.matches("seen_agents").count(), 1);
}
}
+31
View File
@@ -153,6 +153,16 @@ pub struct ForwardRule {
pub bind: HostPort,
pub target: HostPort,
pub description: String,
/// Whether the rule is opened with the connection. A switched-off rule
/// keeps its whole configuration and simply binds nothing, so one local
/// port can be pointed at different targets by switching rules rather
/// than retyping them.
///
/// Missing from every profile saved before the switch existed, and those
/// rules were all live: the container's `#[serde(default)]` fills it from
/// `Default`, which says `true`, or upgrading would silently turn every
/// saved forward off.
pub enabled: bool,
}
impl Default for ForwardRule {
@@ -162,6 +172,7 @@ impl Default for ForwardRule {
bind: HostPort::default(),
target: HostPort::default(),
description: String::new(),
enabled: true,
}
}
}
@@ -569,6 +580,7 @@ mod tests {
bind: HostPort::new("127.0.0.1", 8080),
target: HostPort::new("10.0.0.1", 80),
description: "web".to_string(),
enabled: false,
}];
original.socks_proxy = Some(HostPort::new("proxy", 1080));
original.algorithms.kex = vec!["curve25519-sha256".to_string()];
@@ -597,6 +609,25 @@ mod tests {
let m: AuthMode = serde_json::from_str("\"agent\"").unwrap();
assert_eq!(m, AuthMode::Agent);
}
#[test]
fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() {
let old = r#"{"name":"box","host":"h","forwards":[
{"kind":"local","bind":{"host":"127.0.0.1","port":8080},
"target":{"host":"srv","port":80},"description":"web"}
]}"#;
let p: SshProfile = serde_json::from_str(old).unwrap();
assert_eq!(p.forwards.len(), 1);
assert!(p.forwards[0].enabled, "an old rule must stay live");
assert!(ForwardRule::default().enabled);
let mut off = p.forwards[0].clone();
off.enabled = false;
let back: ForwardRule =
serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap();
assert!(!back.enabled, "a switched-off rule stays off across a save");
assert_eq!(back, off);
}
}
fn new_id() -> Uuid {
+20
View File
@@ -41,6 +41,9 @@ const TRACKED: &[u16] = &[
1, 47, 1047, 1049, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004,
];
/// Bracketed paste: whether a paste may arrive framed as `ESC[200~ … ESC[201~`.
pub const BRACKETED_PASTE: u16 = 2004;
/// A CSI longer than this is not a mode set; keep the buffer bounded.
const MAX_PARAMS: usize = 64;
@@ -85,6 +88,11 @@ impl TerminalModes {
&self.on
}
/// Whether `mode` is on. Only ever true for a mode in [`TRACKED`].
pub fn is_on(&self, mode: u16) -> bool {
self.on.contains(&mode)
}
/// The bytes that put a freshly reset terminal back into these modes, or
/// `None` when there is nothing to restore.
pub fn restore_bytes(&self) -> Option<Vec<u8>> {
@@ -348,4 +356,16 @@ mod tests {
modes.feed(b"\x1bc");
assert!(modes.is_empty());
}
#[test]
fn bracketed_paste_reads_as_the_shell_last_left_it() {
// zle switches it on to read a line and off again before running it,
// so the answer has to follow both edges rather than latch the first.
let mut modes = TerminalModes::new();
assert!(!modes.is_on(BRACKETED_PASTE));
modes.feed(b"\x1b[?2004h");
assert!(modes.is_on(BRACKETED_PASTE));
modes.feed(b"\x1b[?2004l");
assert!(!modes.is_on(BRACKETED_PASTE));
}
}
+31 -2
View File
@@ -53,7 +53,12 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
/// half (#857) — would have stayed on this machine and never reached a remote
/// one. A new filename is what gets them uploaded, at the price of one Update
/// Server per host, which ends the sessions on it.
pub const CONTROL_VERSION: u32 = 10;
///
/// v11 replaces the sidebar's hand-made groups. A tab now names a pinned group
/// by id (`TabSetGroup` carries a `GroupId`, not a string), the groups
/// themselves live on the workspace and move as `WorkspaceSetGroups` and
/// `LayoutDelta::GroupsChanged`, and a v10 peer can decode none of that.
pub const CONTROL_VERSION: u32 = 11;
const DIALECT_MARKER: &str = "speaks control v";
@@ -141,6 +146,10 @@ pub mod feature {
/// simply unknown here: the pane lives in the peer's registry, and the
/// local daemon this client would otherwise ask has never heard of it.
pub const PANE_PROCS: &str = "pane-procs";
/// The peer can put a tab to sleep: mark it in the tree and stop its panes
/// while keeping their screens (#762). Needs both a tree and panes, so a
/// peer serving either alone does not say it.
pub const TAB_HIBERNATE: &str = "tab-hibernate";
}
pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
@@ -290,7 +299,25 @@ pub enum ControlRequest {
TabSetGroup {
workspace: WorkspaceId,
tab: TabId,
group: Option<String>,
group: Option<crate::core::group_key::GroupId>,
/// The auto group the tab last resolved to — see `Tab::last_auto`.
#[serde(default)]
last_auto: Option<crate::core::group_key::AutoKey>,
},
/// The workspace's pinned sidebar groups and folds, replaced whole.
WorkspaceSetGroups {
workspace: WorkspaceId,
groups: crate::core::group_key::WorkspaceGroups,
},
/// Put a tab to sleep or mark it awake. Going to sleep, the peer also
/// stops every pane the tab holds, keeping each one's screen on disk for
/// the wake to restore; waking only clears the mark, because the panes a
/// wake brings up are spawned by the client like any restored pane.
/// Answers the tab's panes. Gated on [`feature::TAB_HIBERNATE`].
TabSetHibernated {
workspace: WorkspaceId,
tab: TabId,
hibernated: bool,
},
PaneSplit {
workspace: WorkspaceId,
@@ -399,6 +426,8 @@ impl ControlRequest {
| TabRename { .. }
| TabMove { .. }
| TabSetGroup { .. }
| WorkspaceSetGroups { .. }
| TabSetHibernated { .. }
| PaneSplit { .. }
| PaneClose { .. }
| PaneSetRatio { .. }
+151 -5
View File
@@ -839,6 +839,10 @@ enum PaneBackend {
}
struct ForegroundProbes {
/// The pty's foreground process group: one ioctl, cheap enough to ask on
/// every read, and a change in it is what says the probes below would now
/// answer differently.
group: Box<dyn Fn() -> Option<i32> + Send>,
remote: Box<dyn Fn() -> Option<RemoteContext> + Send>,
agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>,
cwd: Box<dyn Fn() -> Option<PathBuf> + Send>,
@@ -1680,6 +1684,7 @@ impl DaemonPane {
);
let fg_master = master.clone();
let group_master = master.clone();
let remote_master = master.clone();
let agent_master = master.clone();
let cwd_master = master.clone();
@@ -1691,6 +1696,7 @@ impl DaemonPane {
writer.clone(),
move || foreground_command_running(&fg_master, shell_pid),
ForegroundProbes {
group: Box::new(move || pty_foreground_pgid(&group_master)),
remote: Box::new(move || foreground_remote_context(&remote_master)),
agent: Box::new(move || foreground_agent(&agent_master)),
cwd: Box::new(move || foreground_cwd(&cwd_master, shell_pid)),
@@ -1914,6 +1920,7 @@ impl DaemonPane {
writer.clone(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -1963,6 +1970,7 @@ impl DaemonPane {
death: Arc<DeathReporter>,
) -> JoinHandle<()> {
let ForegroundProbes {
group: foreground_group,
remote: foreground_remote,
agent: foreground_agent_fn,
cwd: foreground_cwd_fn,
@@ -1996,6 +2004,7 @@ impl DaemonPane {
let mut tr_read_t = std::time::Duration::ZERO;
let mut tr_disp_t = std::time::Duration::ZERO;
let mut next_remote_check = std::time::Instant::now();
let mut last_group = None;
loop {
if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) {
@@ -2073,8 +2082,21 @@ impl DaemonPane {
// reads, most visibly) stayed on the far end until
// some unrelated output arrived (#817).
let back_at_prompt = signals.shell.iter().any(|s| s.at_prompt);
let poll_now =
back_at_prompt || std::time::Instant::now() >= next_remote_check;
// A new foreground group is a new program in front
// — probe for it on its first output. The interval
// alone missed an agent that drew its whole first
// screen within it of the prompt it was typed at
// and then sat waiting for a key: a launch that
// types the command the moment the shell is up
// did that every time, and the pane never learned
// it was running an agent until something else
// printed.
let group = foreground_group();
let group_changed = group != last_group;
last_group = group;
let poll_now = back_at_prompt
|| group_changed
|| std::time::Instant::now() >= next_remote_check;
if poll_now {
next_remote_check =
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
@@ -2272,6 +2294,7 @@ impl DaemonPane {
local_pty: matches!(self.backend, PaneBackend::Pty(_)),
at_prompt: st.shell.active.then_some(st.shell.mark_at_prompt),
remote_prompt_seen: st.remote_prompt_seen,
bracketed_paste: Some(st.modes.is_on(crate::core::term_modes::BRACKETED_PASTE)),
}
}
@@ -3013,7 +3036,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
if shell_mark_capture_changed(&st.shell, &shell) {
apply_agent(
st,
agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()),
agent_from_shell_mark(&shell, &crate::core::config::agent_detection_aliases()),
);
}
st.shell = shell.clone();
@@ -3057,6 +3080,14 @@ fn apply_agent_signals(
let before = st.agent_session.clone();
for event in &events {
// A hook names the agent that emitted it. Once this pane has a
// foreground agent, a different one's session id must not land here:
// omp forking with a Claude UUID is "session not found".
if let (Some(current), Some(from)) = (st.agent, event.agent)
&& current != from
{
continue;
}
if st.agent.is_none() && event.agent.is_some() {
st.agent = event.agent;
notify(st, DaemonMsg::Agent(st.agent));
@@ -3183,7 +3214,9 @@ fn apply_agent(
stamp_launch_argv(st, argv);
return;
}
if agent.is_none() && st.agent_session.is_some() {
// The session belongs to whoever was in the foreground. Switching from
// Claude to omp (or back to the shell) must not keep the previous id.
if st.agent_session.is_some() {
st.agent_session = None;
notify(st, DaemonMsg::AgentStatus(None));
}
@@ -3335,7 +3368,7 @@ fn foreground_agent(
let argv = crate::daemon::remote::foreground_argv(pid)?;
let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with(
&argv,
crate::core::config::agent_commands_cached(),
&crate::core::config::agent_detection_aliases(),
)?;
Some((agent, argv))
};
@@ -5238,6 +5271,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| Some(None)),
cwd: Box::new(|| None),
@@ -5299,6 +5333,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| Some(None)),
cwd: Box::new(|| None),
@@ -5378,6 +5413,39 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
}
#[test]
fn a_foreign_agent_hook_does_not_replace_the_foreground_session() {
use crate::core::cli_agent::CLIAgent;
let mut st = test_state(true);
let mut sniffer = OscSniffer::new();
let omp = concat!(
"\x1b]777;notify;tty7://cli-agent;",
r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#,
"\x07",
);
apply_signals(&mut st, sniffer.feed(omp.as_bytes()));
assert_eq!(st.agent, Some(CLIAgent::OhMyPi));
let claude = concat!(
"\x1b]777;notify;tty7://cli-agent;",
r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#,
"\x07",
);
apply_signals(&mut st, sniffer.feed(claude.as_bytes()));
assert_eq!(
st.agent_session.as_ref().unwrap().session_id.as_deref(),
Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"),
"a Claude hook on an omp pane must not steal the session id"
);
apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()])));
assert!(
st.agent_session.is_none(),
"switching the foreground agent drops the previous session"
);
}
#[test]
fn opaque_notifications_only_fall_back_when_no_rich_state() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
@@ -6060,6 +6128,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote,
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6107,6 +6176,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6211,6 +6281,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6308,6 +6379,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6345,6 +6417,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6415,6 +6488,7 @@ mod tests {
writer,
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6508,6 +6582,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6589,6 +6664,7 @@ mod tests {
writer,
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6626,6 +6702,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6677,6 +6754,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| Some(PathBuf::from("/Users/alice/dev/tty7"))),
@@ -6708,6 +6786,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6734,6 +6813,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -7297,4 +7377,70 @@ mod tests {
apply_signals(&mut st, SniffSignals::default());
assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x")));
}
/// A quick launch types the agent's command the moment the shell is up, so
/// the agent's whole first screen can land inside the poll interval of the
/// prompt it was typed at — and then it waits for a key, printing nothing
/// more. Probing only on the interval, the pane never found out an agent
/// was running. A change of foreground process group now probes at once.
#[test]
fn a_new_foreground_group_is_probed_inside_the_poll_interval() {
struct Chunks(std::collections::VecDeque<Vec<u8>>);
impl Read for Chunks {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self.0.pop_front() {
Some(chunk) => {
buf[..chunk.len()].copy_from_slice(&chunk);
Ok(chunk.len())
}
None => Ok(0),
}
}
}
let probes_for = |groups: Vec<i32>| {
let state = Arc::new(Mutex::new(test_state(true)));
let probes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = probes.clone();
let groups = Mutex::new(groups.into_iter());
let handle = DaemonPane::spawn_reader(
state,
Arc::new(AtomicBool::new(false)),
Arc::new(OutputGate::new()),
// Plain output, no prompt marks: only the interval or the
// group can trigger a probe.
Box::new(Chunks(
[b"$ ".to_vec(), b"agent screen".to_vec()]
.into_iter()
.collect(),
)),
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(move || groups.lock().unwrap().next()),
remote: Box::new(|| None),
agent: Box::new(move || {
counted.fetch_add(1, Ordering::SeqCst);
None
}),
cwd: Box::new(|| None),
},
Arc::new(DeathReporter::new(|| {})),
);
handle.join().unwrap();
probes.load(Ordering::SeqCst)
};
assert_eq!(
probes_for(vec![10, 10]),
1,
"same program: the interval rules"
);
assert_eq!(
probes_for(vec![10, 11]),
2,
"a new program is probed at once"
);
}
}
+74 -17
View File
@@ -225,23 +225,15 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
#[cfg(target_os = "macos")]
fn process_table() -> HashMap<u32, Row> {
let mut table = HashMap::new();
let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if bytes <= 0 {
return table;
}
let cap = (bytes as usize / std::mem::size_of::<libc::c_int>()) + 64;
let mut pids = vec![0 as libc::c_int; cap];
let written = unsafe {
libc::proc_listallpids(
pids.as_mut_ptr() as *mut libc::c_void,
(cap * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if written <= 0 {
return table;
}
let n = written as usize / std::mem::size_of::<libc::c_int>();
for &pid in pids.iter().take(n.min(cap)) {
let pids = list_all_pids(|buf| {
let ptr = match buf.is_empty() {
true => std::ptr::null_mut(),
false => buf.as_mut_ptr() as *mut libc::c_void,
};
let bytes = std::mem::size_of_val(buf) as libc::c_int;
unsafe { libc::proc_listallpids(ptr, bytes) }
});
for &pid in &pids {
if pid <= 0 {
continue;
}
@@ -276,6 +268,37 @@ fn process_table() -> HashMap<u32, Row> {
table
}
/// Every pid on the machine, by way of `list`, which has the calling
/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as
/// much of it as it can, and it answers with a *count of pids* — not a byte
/// count, although the size it is given is in bytes. An empty buffer asks
/// only for the count, padded by the kernel for processes born in between.
///
/// Reading that count as bytes was #731. It divided the answer by four twice
/// over, once sizing the buffer and once reading back how much was filled, so
/// only about one pid in sixteen made it into the table — and since the kernel
/// lists its newest processes first, the ones kept were whatever had started
/// most recently. A `go run` server just launched was in; the pane's shell,
/// started long before, was not, and a walk from a root the table has never
/// heard of comes back empty: no processes, no ports, and nothing to say why.
/// A process tree built moments earlier, which is what a quick check of the
/// probe builds, is exactly the case that happened to work.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec<i32> {
let count = list(&mut []);
if count <= 0 {
return Vec::new();
}
let mut pids = vec![0; count as usize + 64];
let written = list(&mut pids);
if written <= 0 {
return Vec::new();
}
pids.truncate(written as usize);
pids.retain(|&pid| pid > 0);
pids
}
#[cfg(target_os = "macos")]
fn cstr_field(buf: &[libc::c_char]) -> String {
let bytes: Vec<u8> = buf
@@ -968,6 +991,40 @@ mod tests {
);
}
/// `proc_listallpids` as libproc implements it: the kernel's newest
/// processes first, a padded estimate when asked with no buffer, and
/// always a count of pids back, never bytes.
fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ {
move |buf: &mut [i32]| {
if buf.is_empty() {
return live.len() as i32 + 20;
}
let n = live.len().min(buf.len());
buf[..n].copy_from_slice(&live[..n]);
n as i32
}
}
/// #731 on macOS: a busy Mac has hundreds of processes, and the pane's
/// shell is an old one. Every pid has to reach the table, or the walk
/// starts from a root it cannot find and the pane shows no processes and
/// no ports at all.
#[test]
fn every_pid_is_listed_not_the_newest_sixteenth() {
// Newest first, the way the kernel lists them: the `go run` server
// just started, and the shell it runs under from an hour ago.
let live: Vec<i32> = (1..=700).rev().collect();
let got = list_all_pids(fake_listallpids(&live));
assert_eq!(got.len(), 700, "the whole machine, not the newest few");
assert!(got.contains(&1), "the oldest process is still there");
}
#[test]
fn a_pid_list_that_cannot_be_read_is_empty() {
assert!(list_all_pids(|_| -1).is_empty());
assert!(list_all_pids(|_| 0).is_empty());
}
#[test]
fn a_pathological_tree_still_stops() {
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh"))].into_iter().collect();
+100
View File
@@ -181,6 +181,10 @@ pub enum WorkspaceOp {
RemoveForward {
forward_id: u64,
},
SetForwardEnabled {
forward_id: u64,
enabled: bool,
},
ListForwards,
TeardownForwards,
SftpList {
@@ -261,6 +265,13 @@ pub struct SshForwardRule {
pub target_port: u16,
#[serde(default)]
pub description: Option<String>,
/// A switched-off rule is registered but binds nothing. Defaults to on,
/// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never
/// sends it and every rule it knows is live, while one that receives it
/// ignores it — the worst case is an older far end opening a rule the
/// user switched off, which is what it always did.
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -420,6 +431,13 @@ pub struct ManagedForward {
#[serde(default)]
pub description: Option<String>,
pub status: ForwardStatus,
/// Whether the forward is switched on. A field rather than a
/// `ForwardStatus` variant for the reason `loop_exit_status` gives: an
/// older peer fails the whole frame on a variant it has never heard of,
/// but skips a field it does not know. A switched-off forward's status is
/// an `Error` saying so, which is what such a peer then draws.
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -551,6 +569,15 @@ pub struct PaneContext {
/// prompt" on a remote pane means nothing: the newest mark is then the
/// near shell's own "I started `ssh`", and it will never be replaced.
pub remote_prompt_seen: bool,
/// Whether the pane's output has switched bracketed paste (mode 2004) on,
/// from the same fold the replay restores modes from. `None` from a daemon
/// built before the field existed — "cannot say", not "off".
///
/// It is here so a client with no terminal of its own — `tty7 send
/// --paste` — can frame a paste the way the GUI does, by asking the pane
/// rather than assuming.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bracketed_paste: Option<bool>,
}
fn default_term() -> String {
@@ -892,6 +919,11 @@ pub enum ClientMsg {
pane_id: u64,
forward_id: u64,
},
SetForwardEnabled {
pane_id: u64,
forward_id: u64,
enabled: bool,
},
ListForwards {
pane_id: u64,
},
@@ -987,6 +1019,7 @@ mod kind {
pub const ADD_FORWARD: u8 = 20;
pub const REMOVE_FORWARD: u8 = 21;
pub const LIST_FORWARDS: u8 = 22;
pub const SET_FORWARD_ENABLED: u8 = 23;
pub const VERSION: u8 = 40;
pub const QUERY_PROCS: u8 = 50;
pub const ON_WORKSPACE: u8 = 52;
@@ -1260,6 +1293,15 @@ impl ClientMsg {
pane_id,
forward_id,
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
} => write_frame(
w,
kind::SET_FORWARD_ENABLED,
&to_json(&(pane_id, forward_id, enabled))?,
),
ClientMsg::QueryProcs { pane_id } => {
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
}
@@ -1390,6 +1432,14 @@ impl ClientMsg {
forward_id,
}
}
kind::SET_FORWARD_ENABLED => {
let (pane_id, forward_id, enabled) = from_json(&payload)?;
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
}
}
kind::LIST_FORWARDS => ClientMsg::ListForwards {
pane_id: from_json(&payload)?,
},
@@ -1758,6 +1808,7 @@ mod tests {
target_host: "10.0.0.5".into(),
target_port: 80,
description: Some("web".into()),
enabled: true,
},
},
ClientMsg::AddForward {
@@ -1769,12 +1820,18 @@ mod tests {
target_host: String::new(),
target_port: 0,
description: None,
enabled: false,
},
},
ClientMsg::RemoveForward {
pane_id: 7,
forward_id: 3,
},
ClientMsg::SetForwardEnabled {
pane_id: 7,
forward_id: 3,
enabled: false,
},
ClientMsg::ListForwards { pane_id: 7 },
ClientMsg::Version,
];
@@ -1934,6 +1991,7 @@ mod tests {
target_port: 80,
description: Some("web".into()),
status: ForwardStatus::Listening,
enabled: true,
},
ManagedForward {
id: 2,
@@ -1945,6 +2003,7 @@ mod tests {
target_port: 3000,
description: None,
status: ForwardStatus::Error("bind refused".into()),
enabled: false,
},
]),
DaemonMsg::Version(DaemonVersion {
@@ -2385,6 +2444,7 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 80,
description: Some("web".into()),
enabled: true,
}],
keepalive_interval_s: Some(30),
keepalive_count_max: Some(3),
@@ -2471,9 +2531,14 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 5432,
description: Some("db".into()),
enabled: true,
},
},
WorkspaceOp::RemoveForward { forward_id: 4 },
WorkspaceOp::SetForwardEnabled {
forward_id: 4,
enabled: true,
},
WorkspaceOp::ListForwards,
WorkspaceOp::TeardownForwards,
WorkspaceOp::SftpList {
@@ -2702,4 +2767,39 @@ mod tests {
"the session daemon must not advertise a dialect it cannot serve"
);
}
/// `enabled` crosses the same peers as `rejected` above and gets the same
/// treatment: a rule or a forward from a peer that predates the switch is
/// a live one, and a peer that predates it reads a frame carrying it.
#[test]
fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() {
let rule: SshForwardRule = serde_json::from_str(
r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080,
"target_host":"srv","target_port":80}"#,
)
.unwrap();
assert!(rule.enabled);
let managed: ManagedForward = serde_json::from_str(
r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1",
"bind_port":8080,"status":"listening"}"#,
)
.unwrap();
assert!(managed.enabled);
#[derive(Deserialize)]
struct LegacyManagedForward {
id: u64,
status: ForwardStatus,
}
let off = serde_json::to_string(&ManagedForward {
enabled: false,
status: ForwardStatus::Error("disabled".into()),
..managed
})
.unwrap();
let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap();
assert_eq!(legacy.id, 1);
assert_eq!(legacy.status, ForwardStatus::Error("disabled".into()));
}
}
+45
View File
@@ -105,6 +105,10 @@ impl crate::host::server::PaneDirectory for Registry {
self.get(pane_id).map(|p| p.procs()).unwrap_or_default()
}
fn hibernate_pane(&self, pane_id: u64) {
hibernate_pane(self, pane_id);
}
fn agent_states(&self) -> Vec<crate::daemon::control::PaneAgentState> {
let panes: Vec<Arc<DaemonPane>> = self.panes.lock().unwrap().values().cloned().collect();
let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect();
@@ -276,6 +280,22 @@ fn kill_pane(registry: &Registry, pane_id: u64) {
crate::daemon::scrollback::forget(pane_id);
}
/// Stop a pane whose tab is going to sleep, keeping its screen.
///
/// [`kill_pane`] with the other half of the bargain: the screen is written one
/// last time, exactly, and left on disk rather than dropped, so the pane that
/// wakes in this one's place opens showing it — the same restore a daemon that
/// died unannounced gets, asked for the same way. The tree still names this
/// pane, which is what keeps the sweeps off the file until then.
fn hibernate_pane(registry: &Registry, pane_id: u64) {
let Some(pane) = registry.remove(pane_id) else {
return;
};
let (segments, title, _) = pane.scrollback_snapshot();
crate::daemon::scrollback::save(pane_id, &segments, title.as_deref());
pane.kill();
}
fn ssh_connection_for(
registry: &Registry,
pane_id: u64,
@@ -1035,6 +1055,31 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
Ok(())
}
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
} => {
let mut w = write_stream;
// Only switching on needs the connection; switching off has to
// work on a pane whose connection is already gone.
let conn = match forward_pane_connection(&registry, pane_id) {
Ok(conn) => Some(conn),
Err(e) if enabled => {
DaemonMsg::Error(e).encode(&mut w)?;
return Ok(());
}
Err(_) => None,
};
match crate::daemon::ssh::SshManager::global()
.set_forward_enabled(pane_id, conn, forward_id, enabled)
{
Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?,
Err(e) => DaemonMsg::Error(e).encode(&mut w)?,
}
Ok(())
}
ClientMsg::QueryProcs { pane_id } => {
let mut w = write_stream;
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
+394 -8
View File
@@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus {
))))
}
/// What a switched-off forward reports. An `Error` for the same reason as
/// `loop_exit_status`, so an older peer reading it draws a stopped forward
/// with a word of explanation; a current one reads `enabled` instead.
fn disabled_status() -> SharedStatus {
Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string())))
}
/// Whether two forwards would want the same listening socket: the same port,
/// on the same side of the connection. Local and dynamic forwards both listen
/// here; a remote forward listens on the far host.
fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool {
let far = |k| k == SshForwardKind::Remote;
a_port != 0 && a_port == b_port && far(a) == far(b)
}
struct ForwardEntry {
id: u64,
kind: SshForwardKind,
bind_host: String,
bind_port: u16,
/// The port the rule asked for, which `bind_port` is not when that was 0
/// and the OS picked one. Switching the forward back on asks again for
/// this, not for whatever it happened to get last time.
requested_port: u16,
target_host: String,
target_port: u16,
description: Option<String>,
status: SharedStatus,
cancel: ForwardCancel,
auto_local: bool,
/// Switched off: the entry, and the rule in it, stay listed, but nothing
/// is bound and `cancel` is `None`.
enabled: bool,
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
@@ -288,6 +310,19 @@ impl ForwardEntry {
target_port: self.target_port,
description: self.description.clone(),
status: self.status.lock().unwrap().clone(),
enabled: self.enabled,
}
}
fn rule(&self) -> SshForwardRule {
SshForwardRule {
kind: self.kind,
bind_host: self.bind_host.clone(),
bind_port: self.requested_port,
target_host: self.target_host.clone(),
target_port: self.target_port,
description: self.description.clone(),
enabled: self.enabled,
}
}
}
@@ -318,6 +353,23 @@ impl SshForwardRegistry {
.await
}
pub async fn set_enabled(
&self,
pane_id: u64,
conn: Option<Arc<SshConnection>>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.set_enabled_owned(
&ForwardOwner::Pane(pane_id),
pane_id,
conn,
forward_id,
enabled,
)
.await
}
pub async fn teardown_pane(&self, pane_id: u64) {
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
}
@@ -347,6 +399,24 @@ impl SshForwardRegistry {
.await
}
pub async fn set_enabled_workspace(
&self,
workspace: WorkspaceId,
view_pane: u64,
conn: Arc<SshConnection>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.set_enabled_owned(
&ForwardOwner::Workspace(workspace),
view_pane,
Some(conn),
forward_id,
enabled,
)
.await
}
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
self.teardown_owned(&ForwardOwner::Workspace(workspace))
.await;
@@ -360,22 +430,24 @@ impl SshForwardRegistry {
rule: &SshForwardRule,
) -> ManagedForward {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (bind_port, status, cancel) = match rule.kind {
SshForwardKind::Local => self.start_local(&conn, rule).await,
SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await,
SshForwardKind::Remote => self.start_remote(&conn, rule).await,
let (bind_port, status, cancel) = if rule.enabled {
self.start(&conn, rule).await
} else {
(rule.bind_port, disabled_status(), ForwardCancel::None)
};
let entry = ForwardEntry {
id,
kind: rule.kind,
bind_host: rule.bind_host.clone(),
bind_port,
requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: false,
enabled: rule.enabled,
};
let managed = entry.to_managed(view_pane);
self.owners
@@ -413,20 +485,145 @@ impl SshForwardRegistry {
})
};
if let Some(entry) = removed {
Self::cancel_entry(entry).await;
Self::cancel(entry.cancel).await;
}
self.list_owned(owner, view_pane)
}
/// Switch one forward off or back on, keeping its entry — and so its
/// place in the list and the rule it was made from — either way.
///
/// Switching on refuses, rather than fails at bind time with whatever the
/// OS says, when another switched-on forward of the same owner already
/// holds the port: the answer then names that forward, which is the one
/// the user has to switch off first. A forward that cannot be started for
/// any other reason stays off, with the reason as the error.
async fn set_enabled_owned(
&self,
owner: &ForwardOwner,
view_pane: u64,
conn: Option<Arc<SshConnection>>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
if !enabled {
let cancel = {
let mut owners = self.owners.lock().unwrap();
let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
entry.enabled = false;
entry.status = disabled_status();
std::mem::replace(&mut entry.cancel, ForwardCancel::None)
};
Self::cancel(cancel).await;
return Ok(self.list_owned(owner, view_pane));
}
let Some(rule) = self.enable_plan(owner, forward_id)? else {
return Ok(self.list_owned(owner, view_pane));
};
let Some(conn) = conn else {
return Err("the SSH connection is gone — reconnect and try again".to_string());
};
let (bound, status, cancel) = self.start(&conn, &rule).await;
if let Some(e) = bind_error(&status) {
Self::cancel(cancel).await;
return Err(e);
}
if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) {
// Removed while it was being started: nobody wants what was bound.
Self::cancel(cancel).await;
}
Ok(self.list_owned(owner, view_pane))
}
fn entry_mut<'a>(
owners: &'a mut HashMap<ForwardOwner, Vec<ForwardEntry>>,
owner: &ForwardOwner,
forward_id: u64,
) -> Result<&'a mut ForwardEntry, String> {
owners
.get_mut(owner)
.and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id))
.ok_or_else(|| format!("no forward {forward_id}"))
}
/// The rule to start for switching `forward_id` on, `None` when it is on
/// already, or why it may not be switched on.
fn enable_plan(
&self,
owner: &ForwardOwner,
forward_id: u64,
) -> Result<Option<SshForwardRule>, String> {
let mut owners = self.owners.lock().unwrap();
let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
if entry.enabled {
return Ok(None);
}
let rule = SshForwardRule {
enabled: true,
..entry.rule()
};
let holder = owners.get(owner).into_iter().flatten().find(|e| {
e.id != forward_id
&& e.enabled
&& same_socket(e.kind, e.bind_port, rule.kind, rule.bind_port)
});
if let Some(h) = holder {
let what = match h.kind {
SshForwardKind::Dynamic => "a SOCKS forward".to_string(),
_ => format!("the forward to {}:{}", h.target_host, h.target_port),
};
return Err(format!(
"port {} is already used by {what}; switch that one off first",
rule.bind_port
));
}
Ok(Some(rule))
}
/// Record a started forward as switched on. Hands `cancel` back when the
/// entry is no longer there to take it.
fn finish_enable(
&self,
owner: &ForwardOwner,
forward_id: u64,
bound: u16,
status: SharedStatus,
cancel: ForwardCancel,
) -> Option<ForwardCancel> {
let mut owners = self.owners.lock().unwrap();
let Ok(entry) = Self::entry_mut(&mut owners, owner, forward_id) else {
return Some(cancel);
};
entry.enabled = true;
entry.bind_port = bound;
entry.status = status;
// Anything already in the slot is a second switch-on that raced this
// one; it is replaced, and has to be stopped rather than leaked.
Some(std::mem::replace(&mut entry.cancel, cancel))
}
async fn start(
&self,
conn: &Arc<SshConnection>,
rule: &SshForwardRule,
) -> (u16, SharedStatus, ForwardCancel) {
match rule.kind {
SshForwardKind::Local => self.start_local(conn, rule).await,
SshForwardKind::Dynamic => self.start_dynamic(conn, rule).await,
SshForwardKind::Remote => self.start_remote(conn, rule).await,
}
}
async fn teardown_owned(&self, owner: &ForwardOwner) {
let entries = self.owners.lock().unwrap().remove(owner);
for entry in entries.into_iter().flatten() {
Self::cancel_entry(entry).await;
Self::cancel(entry.cancel).await;
}
}
async fn cancel_entry(entry: ForwardEntry) {
match entry.cancel {
async fn cancel(cancel: ForwardCancel) {
match cancel {
ForwardCancel::Task(handle) => {
handle.abort();
let _ = handle.await;
@@ -634,6 +831,7 @@ impl SshForwardRegistry {
target_host: remote_host.to_string(),
target_port: remote_port,
description: Some(format!("localhost link → :{remote_port}")),
enabled: true,
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await;
@@ -658,12 +856,14 @@ impl SshForwardRegistry {
kind: SshForwardKind::Local,
bind_host: rule.bind_host.clone(),
bind_port,
requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: true,
enabled: true,
};
self.owners
.lock()
@@ -735,6 +935,20 @@ impl SshManager {
)
}
pub fn set_workspace_forward_enabled(
&self,
workspace: WorkspaceId,
view_pane: u64,
conn: Arc<SshConnection>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.runtime.block_on(
self.forwards
.set_enabled_workspace(workspace, view_pane, conn, forward_id, enabled),
)
}
pub fn list_workspace_forwards(
&self,
workspace: WorkspaceId,
@@ -941,12 +1155,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "h".into(),
target_port: 80,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(task),
auto_local: false,
enabled: true,
}
};
{
@@ -986,12 +1202,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "h".into(),
target_port: 80,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(handle),
auto_local: false,
enabled: true,
}
}
@@ -1047,12 +1265,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "127.0.0.1".into(),
target_port: 3000,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(handle),
auto_local: true,
enabled: true,
};
reg.owners
.lock()
@@ -1143,4 +1363,170 @@ mod tests {
);
assert_eq!(table.lookup("", 0), None);
}
fn push_disabled(
reg: &SshForwardRegistry,
owner: ForwardOwner,
id: u64,
kind: SshForwardKind,
port: u16,
) {
reg.owners
.lock()
.unwrap()
.entry(owner)
.or_default()
.push(ForwardEntry {
id,
kind,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "srv-b".into(),
target_port: 80,
description: None,
status: disabled_status(),
cancel: ForwardCancel::None,
auto_local: false,
enabled: false,
});
}
/// Switching a forward off has to release what it bound — that is the
/// point, so the port can be taken by another rule — while leaving the
/// entry, and the rule in it, exactly where it was.
#[tokio::test]
async fn switching_a_forward_off_frees_its_socket_and_keeps_its_rule() {
let reg = SshForwardRegistry::default();
let guard = push_listener(&reg, ForwardOwner::Pane(7), 3).await;
let port = reg.list(7)[0].bind_port;
let list = reg.set_enabled(7, None, 3, false).await.unwrap();
assert_eq!(list.len(), 1, "the forward is still listed");
assert_eq!(list[0].id, 3);
assert!(!list[0].enabled);
assert_eq!(list[0].bind_port, port);
assert_eq!(
(list[0].target_host.as_str(), list[0].target_port),
("127.0.0.1", 3000)
);
assert!(matches!(list[0].status, ForwardStatus::Error(_)));
assert_eq!(Arc::strong_count(&guard), 1, "…and its listener is gone");
TcpListener::bind(("127.0.0.1", port))
.await
.expect("the port is free for someone else");
// Twice is the same as once, and a forward nobody has is an error.
assert_eq!(reg.set_enabled(7, None, 3, false).await.unwrap(), list);
assert!(reg.set_enabled(7, None, 99, false).await.is_err());
reg.teardown_pane(7).await;
assert!(reg.list(7).is_empty());
}
/// Two rules on one local port, pointed at different targets, is the
/// whole use of the switch. Turning the second on while the first still
/// holds the port is refused by name, not left to fail at bind time.
#[tokio::test]
async fn switching_on_a_forward_whose_port_is_held_is_refused_by_name() {
let reg = SshForwardRegistry::default();
push_listener(&reg, ForwardOwner::Pane(7), 0).await;
let port = reg.list(7)[0].bind_port;
push_disabled(&reg, ForwardOwner::Pane(7), 1, SshForwardKind::Local, port);
let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
assert!(err.contains(&port.to_string()), "{err}");
assert!(err.contains("127.0.0.1:3000"), "names the holder: {err}");
assert!(!reg.list(7)[1].enabled, "the refused forward stays off");
// A SOCKS forward listens here too, so it collides the same way.
push_disabled(
&reg,
ForwardOwner::Pane(7),
2,
SshForwardKind::Dynamic,
port,
);
assert!(reg.set_enabled(7, None, 2, true).await.is_err());
// Once the holder is off, the only thing left in the way is the
// missing connection — which is said, and changes nothing.
reg.set_enabled(7, None, 0, false).await.unwrap();
let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
assert!(err.contains("connection"), "{err}");
assert!(reg.list(7).iter().all(|m| !m.enabled));
// Another owner's forwards are not this one's business.
push_disabled(&reg, ForwardOwner::Pane(8), 0, SshForwardKind::Local, port);
assert!(
reg.enable_plan(&ForwardOwner::Pane(8), 0)
.unwrap()
.is_some()
);
}
#[test]
fn only_the_same_port_on_the_same_side_collides() {
use SshForwardKind::{Dynamic, Local, Remote};
assert!(same_socket(Local, 8080, Local, 8080));
assert!(same_socket(Local, 8080, Dynamic, 8080));
assert!(same_socket(Remote, 8080, Remote, 8080));
assert!(!same_socket(Local, 8080, Remote, 8080), "different hosts");
assert!(!same_socket(Local, 8080, Local, 8081));
assert!(
!same_socket(Local, 0, Local, 0),
"0 asks the OS for a fresh one"
);
}
/// Switching back on asks for the port the rule asked for, not the one
/// the OS last picked for it, and records what it got this time.
#[tokio::test]
async fn switching_on_starts_from_the_rule_and_records_the_new_listener() {
let reg = SshForwardRegistry::default();
push_disabled(&reg, ForwardOwner::Pane(7), 0, SshForwardKind::Local, 0);
reg.owners
.lock()
.unwrap()
.get_mut(&ForwardOwner::Pane(7))
.unwrap()[0]
.bind_port = 41000;
let rule = reg
.enable_plan(&ForwardOwner::Pane(7), 0)
.unwrap()
.expect("an off forward has something to start");
assert_eq!(rule.bind_port, 0);
assert!(rule.enabled);
assert_eq!((rule.target_host.as_str(), rule.target_port), ("srv-b", 80));
let guard = Arc::new(());
let held = guard.clone();
let task = tokio::spawn(async move {
let _held = held;
std::future::pending::<()>().await
});
let status: SharedStatus = Arc::new(Mutex::new(ForwardStatus::Listening));
let leftover = reg.finish_enable(
&ForwardOwner::Pane(7),
0,
42000,
status,
ForwardCancel::Task(task),
);
assert!(matches!(leftover, Some(ForwardCancel::None)));
let now = &reg.list(7)[0];
assert!(now.enabled);
assert_eq!(now.bind_port, 42000);
assert_eq!(now.status, ForwardStatus::Listening);
assert!(
reg.enable_plan(&ForwardOwner::Pane(7), 0)
.unwrap()
.is_none(),
"an on forward has nothing to start"
);
reg.teardown_pane(7).await;
assert_eq!(Arc::strong_count(&guard), 1, "the new listener is owned");
}
}
+13
View File
@@ -115,6 +115,19 @@ impl SshManager {
.block_on(self.forwards.remove(pane_id, forward_id))
}
pub fn set_forward_enabled(
&self,
pane_id: u64,
conn: Option<Arc<SshConnection>>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.runtime.block_on(
self.forwards
.set_enabled(pane_id, conn, forward_id, enabled),
)
}
pub fn list_forwards(&self, pane_id: u64) -> Vec<ManagedForward> {
self.forwards.list(pane_id)
}
@@ -33,6 +33,13 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
WorkspaceOp::RemoveForward { forward_id } => {
DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id))
}
WorkspaceOp::SetForwardEnabled {
forward_id,
enabled,
} => match mgr.set_workspace_forward_enabled(ws, view, conn, *forward_id, *enabled) {
Ok(list) => DaemonMsg::ForwardList(list),
Err(e) => DaemonMsg::Error(e),
},
WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)),
WorkspaceOp::TeardownForwards => {
mgr.teardown_workspace_forwards(ws);
+192 -1
View File
@@ -34,6 +34,10 @@ pub trait PaneDirectory: Send + Sync {
/// is this process and not the client's own daemon: the client asks over
/// the control link precisely because the processes are here.
fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs;
/// Stop a pane for a tab going to sleep: the processes go, the pane's last
/// screen stays on disk for the wake to open with. Unlike closing a pane,
/// which drops that screen because nobody will ask for it again.
fn hibernate_pane(&self, pane_id: u64);
}
#[derive(Clone, Default)]
@@ -383,6 +387,12 @@ fn handshake<R: Read>(
if services.machine.is_some() {
features.push(feature::MACHINE_TREE.to_string());
}
// A sleeping tab is a mark in the tree *and* stopped panes; a peer that can
// do only half of that would leave a tab asleep with its shells running,
// or its shells stopped with nothing saying the tab is meant to wake.
if services.machine.is_some() && services.panes.is_some() {
features.push(feature::TAB_HIBERNATE.to_string());
}
// The pane daemon's features ride along, same as `protocol_version` above:
// a pane connection answers exactly one message, so a client that wanted
// them from `ClientMsg::Version` would pay a whole routed connection — for
@@ -772,11 +782,45 @@ fn run_request(
workspace,
tab,
group,
last_auto,
} => {
conn.machine()?
.tab_set_group(workspace, tab, group, conn.machine_origin)?;
.tab_set_group(workspace, tab, group, last_auto, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::WorkspaceSetGroups { workspace, groups } => {
conn.machine()?
.workspace_set_groups(workspace, groups, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::TabSetHibernated {
workspace,
tab,
hibernated,
} => {
let Some(directory) = conn.panes.as_ref() else {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"this peer serves no panes, so it cannot put a tab to sleep",
));
};
let panes = conn.machine()?.tab_set_hibernated(
workspace,
tab,
hibernated,
conn.machine_origin,
)?;
// After the mark, not before: a pane stopped under a tab the tree
// does not yet call asleep is, to anyone reading the tree in
// between, a pane that died — and a restore reading it that way
// would spawn it straight back.
if hibernated {
for pane in &panes {
directory.hibernate_pane(*pane);
}
}
(ReplyOk::Panes(panes), Vec::new())
}
ControlRequest::PaneSplit {
workspace,
pane,
@@ -1716,6 +1760,8 @@ mod aggregate_tests {
}
}
fn hibernate_pane(&self, _pane_id: u64) {}
fn agent_states(&self) -> Vec<PaneAgentState> {
vec![PaneAgentState {
pane_id: 7,
@@ -1896,6 +1942,151 @@ mod aggregate_tests {
"whatever links exist are named; none are blank"
);
}
/// Records which panes were stopped for a sleeping tab, and in what order
/// relative to the tree: each entry carries whether the tree already
/// called the tab asleep when the pane was stopped.
struct SleepRecorder {
store: Arc<MachineStore>,
stopped: std::sync::Mutex<Vec<(u64, bool)>>,
}
impl PaneDirectory for SleepRecorder {
fn pane_count(&self) -> u64 {
0
}
fn panes(&self) -> Vec<PaneInfo> {
Vec::new()
}
fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs {
Default::default()
}
fn agent_states(&self) -> Vec<PaneAgentState> {
Vec::new()
}
fn hibernate_pane(&self, pane_id: u64) {
let asleep = self.store.machine().workspaces.iter().any(|w| {
w.tabs
.iter()
.any(|t| t.hibernated && t.root.contains(pane_id))
});
self.stopped.lock().unwrap().push((pane_id, asleep));
}
}
fn seed(pane: u64) -> machine::PaneSeed {
machine::PaneSeed {
pane,
cwd: Some("/repo".into()),
ssh_spec: None,
agent: None,
shell: None,
}
}
/// Going to sleep stops every pane of the tab — after the tree says the
/// tab is asleep, so nobody reading the tree in between takes the stopped
/// panes for dead ones — and keeps them in the tree for the wake. Waking
/// only clears the mark: the client brings the panes back.
#[test]
fn a_tab_put_to_sleep_stops_its_panes_and_keeps_them_in_the_tree() {
let dir = tempfile::TempDir::new().unwrap();
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
let ws = store.workspace_create(None, None, None).unwrap();
let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap();
store
.pane_split(
ws.id,
7,
machine::Axis::Horizontal,
0.5,
seed(8),
false,
None,
)
.unwrap();
let other = store.tab_create(ws.id, None, seed(9), None, None).unwrap();
let recorder = Arc::new(SleepRecorder {
store: Arc::clone(&store),
stopped: std::sync::Mutex::new(Vec::new()),
});
let client = client_with(Services {
machine: Some(Arc::clone(&store)),
attachments: Arc::new(AttachRegistry::default()),
panes: Some(recorder.clone()),
});
assert!(client.hello().has_feature(feature::TAB_HIBERNATE));
let ReplyOk::Panes(panes) = client
.call(ControlRequest::TabSetHibernated {
workspace: ws.id,
tab: tab.id,
hibernated: true,
})
.unwrap()
else {
panic!("TabSetHibernated must answer with the tab's panes");
};
assert_eq!(panes, vec![7, 8]);
assert_eq!(
*recorder.stopped.lock().unwrap(),
vec![(7, true), (8, true)],
"both panes stopped, each after the tree marked the tab"
);
let machine = store.machine();
let asleep = &machine.workspaces[0].tabs;
assert!(asleep.iter().find(|t| t.id == tab.id).unwrap().hibernated);
assert!(!asleep.iter().find(|t| t.id == other.id).unwrap().hibernated);
for pane in [7, 8] {
assert!(
machine.panes.iter().any(|p| p.id == pane),
"pane {pane}'s record is what the wake restores from"
);
}
recorder.stopped.lock().unwrap().clear();
let ReplyOk::Panes(_) = client
.call(ControlRequest::TabSetHibernated {
workspace: ws.id,
tab: tab.id,
hibernated: false,
})
.unwrap()
else {
panic!("TabSetHibernated must answer with the tab's panes");
};
assert!(recorder.stopped.lock().unwrap().is_empty());
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
}
/// A peer with a tree and no panes could mark a tab asleep and leave its
/// shells running. It does not offer to, and refuses if asked anyway —
/// before the mark, so the tree is not left claiming a sleep that never
/// happened.
#[test]
fn a_peer_serving_no_panes_cannot_put_a_tab_to_sleep() {
let dir = tempfile::TempDir::new().unwrap();
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
let ws = store.workspace_create(None, None, None).unwrap();
let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap();
let client = client_with(Services::with_machine(Arc::clone(&store)));
assert!(client.hello().has_feature(feature::MACHINE_TREE));
assert!(!client.hello().has_feature(feature::TAB_HIBERNATE));
assert!(
client
.call(ControlRequest::TabSetHibernated {
workspace: ws.id,
tab: tab.id,
hibernated: true,
})
.is_err()
);
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
}
}
#[cfg(test)]
@@ -95,7 +95,7 @@ impl Instance {
{{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": null,
"sidebar_group": null,
"group": null,
"root": {{ "Leaf": {{ "pane": {pane_id} }} }}
}}
],
+40 -3
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,42 @@ 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
Every agent tty7 finds on your `PATH` is one row away in Search Everywhere: **Agent:
Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's
**Launch Agent…** row opens its Terminals tab on that list, and **New Agent Tab**
(<kbd>⌘ ⇧ A</kbd> on macOS, unbound elsewhere) starts whichever agent you used
last.
A launch always opens a new tab in the current tab's directory and types the
agent's command into its shell, so the agent is detected, tracked and resumed
exactly as if you had typed it — and quitting it leaves you at the prompt. Each
`Agent: …` command is bindable too, as `LaunchAgent:<slug>`.
By default an agent launches as its bare binary (`claude`, `codex`,
`cursor-agent`, …). Give it a command line of its own with `agent_launch`:
```json
{
"agent_launch": {
"claude": "claude --dangerously-skip-permissions",
"codex": "cx --profile work"
}
}
```
A wrapper named there (`cx` above) is recognised as that agent without an
`agent_commands` entry. To keep the flags a running agent was started with,
right-click its pane and choose **Set Current Launch Args as Default**; tty7
writes them into `agent_launch`, minus the session they resumed and any prompt.
In a remote workspace tty7 cannot read the far machine's `PATH`, so it offers
the agents it has already seen running there: start one by hand once and it
joins the list. A wrapper named in your local `agent_launch` is only recognised
over there if the remote `config.json` names it too.
## What you get for free
@@ -83,7 +120,7 @@ Just by running an agent in a pane:
flags. [More →](/agents/sessions)
</Card>
<Card title="Context feed" icon="paper-plane">
Palette commands that hand the current selection or the repo's `git diff` to
Actions that hand the current selection or the repo's `git diff` to
the running agent as a prompt.
</Card>
</CardGroup>
+2 -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.
@@ -57,5 +58,5 @@ tty7 itself.
## Copy the session id
**Copy Session ID** — in the tab's right-click menu, beside *Copy Working
Directory*, and in the command palette — puts the agent's native id on the
Directory*, and in Search Everywhere — puts the agent's native id on the
clipboard. Paste it into `codex resume`, a bug report, or another tool.
+10 -3
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
@@ -89,7 +96,7 @@ Turn it off with **Settings → Window & Tabs → Show tray icon**
## Sending an agent some context
Two command-palette entries hand what is in front of you to the agent running in
Two Search Everywhere actions hand what is in front of you to the agent running in
the pane, as a ready-made prompt:
| Command | Sends |
+8 -6
View File
@@ -295,7 +295,7 @@ candidates.
| Command | Effect | JSON |
|---|---|---|
| `ws ls` | Every workspace | `{"workspaces":[...]}` |
| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` |
| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","groups":{"pinned":[…]},"tabs":[{"id","name","group","last_auto","root",…}]}` |
| `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` |
| `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` |
| `ws rm WORKSPACE` | Delete the workspace and hang up its panes | `{"removed"}` |
@@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
| Command | Effect | JSON |
|---|---|---|
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` |
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…],"hibernated"}]}` |
| `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` |
| `tab new [WORKSPACE] --pane %PANE` | Add a tab around a pane already running | `{"tab","pane"}` |
| `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` |
@@ -342,9 +342,11 @@ tree drops a pane's record when the tab holding it closes, and the registry is
what is left. That only matters if the shell later dies, since a restore would
then have nothing to restore from.
`GROUP` is the heading the GUI's sidebar files the tab under, shown by its last
segment. Read-only from here: with the default repo grouping the GUI recomputes
it from the tab's working directory.
`GROUP` is the pinned sidebar group the tab is kept in — its name, or the last
segment of its folder — and `-` for a tab the GUI groups automatically. The JSON
carries the group's `id`, `name` and `folder` (or `null`). Auto groups are not
listed: the GUI works them out from each tab's working directory and stores
nothing about them.
`label` falls back through the best evidence available — the name if someone set
one, else the agent running there, else the last segment of the cwd, else the
@@ -355,7 +357,7 @@ a stand-in.
| Command | Effect | JSON |
|---|---|---|
| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag | `{"panes":[…]}` |
| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag (`asleep` for a hibernated tab's panes) | `{"panes":[…]}` |
| `pane ls --all` | The server's whole pane registry, including orphans | `{"panes":[…],"orphans":N}` |
| `pane split …` | Identical to top-level `split` | `{"pane"}` |
| `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` |
+2 -2
View File
@@ -4,7 +4,7 @@ description: "Rebinding anything, chord sequences, and the tmux preset."
---
**Settings → Keyboard & Mouse → Keyboard shortcuts** (<kbd>⌘ ,</kbd>) lists every shortcut in the app,
grouped the same way the command palette is. The search box at the top of
grouped the same way Search Everywhere is. The search box at the top of
Settings reaches this page too: type what a feature is called — `split`,
`commit`, `sftp` — and the page narrows to the shortcuts for it. Action names
work as well as labels, so a binding you read about in `keybindings.json` is
@@ -38,7 +38,7 @@ no undo for that one.
Some actions ship deliberately unbound, because there is no obvious key left to
take: pane resize and swap, workspace selection, most git commands, SFTP, and
the panel tabs. They are all in the command palette, and all bindable here.
the panel tabs. They are all in Search Everywhere, and all bindable here.
## Editing `config.json` instead
+6 -1
View File
@@ -30,7 +30,8 @@ section something is in.
Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard.
</Card>
<Card title="Window & Tabs" icon="window-maximize">
Tab position and placement, sidebar grouping, and diff previews.
Tab position and placement, sidebar auto grouping, diff previews, and what
SSH tabs are called.
</Card>
<Card title="SSH" icon="server" href="/remote/ssh">
Recent and saved hosts, each opened and edited in place, and host key
@@ -74,6 +75,10 @@ Everything the Settings window writes goes to one file:
| macOS / Linux | `~/.config/tty7/config.json` |
| Windows | `%APPDATA%\tty7\config.json` |
The one exception is your saved SSH hosts, which live beside it in
`servers.json` — so `config.json` can be synced between machines without
carrying your servers. [servers.json →](/reference/configuration#ssh)
Set `TTY7_CONFIG_DIR` to point the whole directory — config, themes, state —
somewhere else.
+2 -2
View File
@@ -3,8 +3,8 @@ title: "Themes"
description: "Thirteen built-ins, your own YAML themes, iTerm2 imports, and a colour editor."
---
**Settings → Appearance → Theme** — or **Change Theme…** in the command
palette — opens the theme picker.
**Settings → Appearance → Theme** — or **Change Theme…** in Search
Everywhere — opens the theme picker.
## Built in
+5 -5
View File
@@ -22,14 +22,14 @@
| `Neutrals.background` | 工作内容、设置内容、输入框 | 安静的基础表面。输入框未聚焦时不能像已选中列表行 |
| `Neutrals.rail` | 左侧标签栏 | 内容底向正文色偏 3%(默认 `#F5F5F3` / `#1E1E20`),细分隔线划界 |
| `Neutrals.sidebar` | 右侧面板 | 与内容同色;只用细分隔线划界 |
| `Neutrals.popover` | 菜单、命令面板、对话框 | 浅色下保持明亮,深色下抬高亮度;只给浮层阴影 |
| `Neutrals.popover` | 菜单、随处搜索、对话框 | 浅色下保持明亮,深色下抬高亮度;只给浮层阴影 |
| `foreground` | 正文、标题、当前项 | 正常文字至少 4.5:1;不把普通正文全部加粗 |
| `muted_foreground` | 描述、路径、快捷键 | 与主文字明确区分,但仍保持可读性 |
| `border` / `divider` | 控件边界 / 区域分隔 | 控件边界较强,区域分隔较弱;不能把页面画成表格线框 |
| `Interactions.input_border` | 输入框与普通按钮轮廓 | 对应组件库的 `input` 边框角色,不能误用内容背景色导致边界消失 |
| `Interactions.primary` | 保存、连接等主操作 | 强调色实底,成对配置前景色;hover/pressed 保持标签对比度。Git 提交按钮例外:可提交时用反色中性(正文色底),见 Git 面板 |
| `Surfaces.rail.selected` | 当前标签页 | 中性 selected 阶 + medium;不用强调色,蓝色留给可按的东西 |
| `Interactions.choice` | 菜单的键盘当前行 | 强调色浅底,区别于鼠标经过的中性 hover。命令面板例外:与工作区切换器同用中性 selected 阶 |
| `Interactions.choice` | 菜单的键盘当前行 | 强调色浅底,区别于鼠标经过的中性 hover。随处搜索例外:与工作区切换器同用中性 selected 阶 |
| 中性 `secondary` | 面板页签、分段选项、普通按钮 | 次级导航不能与主要操作争夺注意力 |
| `Semantics.danger` | 失败、Git 冲突、破坏性动作 | 红;必须同时有文字/图标/确认语义 |
| `Semantics.warning` | 需要留意的状态 | 琥珀;Git 普通修改只用于尾部标记,不能染黄整个文件树 |
@@ -107,7 +107,7 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F`
主机行 42px 两行(标题正文、地址 11.5/16rem 辅助色);主机表单字段名为辅助色右对齐标签列。
主题卡片为浅底无描边,展开时为 selected 阶;主题面板与内容同色、左缘细分隔线,标题立在标题栏带内。
快捷键键帽为浅底无描边,行间 0.5px 分隔线。
- **菜单与命令面板**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。
- **菜单与随处搜索**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。
- **工作区切换器**:760px 浮层,距顶 112px,圆角 12px;搜索行 48px(右侧 `esc` 键帽),
主体 420px 分两栏、只用细线分隔,页脚 40px(左「新建工作区」幽灵按钮,右键帽提示
↑↓ 导航 / ↵ 打开 / ⌘↵ 新窗口)。左栏 340px 工作区行 52px:26px 首字母圆 + 8px 连接
@@ -115,7 +115,7 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F`
「机器 · 路径 · 时间」次级文字,右侧标签数 + 状态词。右栏为预览:28px 标题行,
44px 标签页行(18px 品牌头像、分支与增删数全部次级、「当前」字样、工作中 5px 绿点
与侧栏同步闪烁)。当前行只用中性 selected 阶,不用强调色。
- **命令面板**:切换器的同胞浮层——同样的 12px 圆角、浮层阴影、距顶 112px(矮窗口按比例上移),
- **随处搜索**:切换器的同胞浮层——同样的 12px 圆角、浮层阴影、距顶 112px(矮窗口按比例上移),
最宽 600px;搜索行由组件库列表自带(45px,空时右侧 `esc` 键帽),页脚 40px 细线分隔、右侧键帽提示
↑↓ 导航 / ↵ 打开。命令行 32px、圆角 8、列表四周 8px、行内 10px;当前行为中性 selected 阶 + 标题
medium,不用强调色。分组标题 28px、11.5/16rem medium 辅助色、与行文字同列。快捷键逐键 18px 淡底键帽。
@@ -141,4 +141,4 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F`
`cargo test -p tty7 --bin tty7-app --locked ui::` 覆盖内置主题的文字、语义色、
交互状态对比度及已有 UI 行为。另需在真实窗口检查浅色/深色、宽/窄窗口、侧栏、文件、
Git、设置、菜单与命令面板。测试通过不能替代视觉层级检查。
Git、设置、菜单与随处搜索。测试通过不能替代视觉层级检查。
+8 -2
View File
@@ -33,7 +33,7 @@
"pages": [
"window/tabs-and-splits",
"window/sidebar",
"window/command-palette",
"window/search-everywhere",
"window/search",
"window/side-panel"
]
@@ -138,5 +138,11 @@
"github": "https://github.com/l0ng-ai/tty7",
"discord": "https://discord.gg/s3dethqz2V"
}
}
},
"redirects": [
{
"source": "/window/command-palette",
"destination": "/window/search-everywhere"
}
]
}
+2 -2
View File
@@ -8,7 +8,7 @@ down the left. Everything below is optional — but these are the settings peopl
end up changing anyway, so they are worth five minutes now.
Open Settings with <kbd>⌘ ,</kbd> (<kbd>Ctrl ,</kbd> on Windows and Linux), or
from the command palette (<kbd>⌘ P</kbd> → *Settings*).
from Search Everywhere (<kbd>⌘ P</kbd> → *Settings*).
<Frame caption="Settings → Appearance">
<img src="/images/settings-appearance.webp" alt="tty7 Settings" />
@@ -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
@@ -22,7 +22,7 @@ right-clicks to a menu that fills the window with it if you prefer that —
## Side-by-side or unified
**Toggle Unified / Side-by-Side Diff** in the command palette switches between
**Toggle Unified / Side-by-Side Diff** in Search Everywhere switches between
the two. The choice is global — one setting for every diff, the same call VS
Code's `diffEditor.renderSideBySide` makes — and persists as `diff_view` in
`config.json`.
+1 -1
View File
@@ -56,7 +56,7 @@ The branch name at the top of the panel is a dropdown. It holds:
Beside it, the sync button pulls then pushes — and relabels itself **Publish
Branch** when the branch has no upstream yet.
The command palette carries the verbs under **Git** — *Git: Commit*, *Stage
Search Everywhere carries the verbs under **Git** — *Git: Commit*, *Stage
All*, *Unstage All*, *Discard All*, *Create Branch*, *Sync*, *Push*, *Pull*,
*Fetch* — so those are bindable. Checking out is a pick rather than a verb, so
it lives only in the dropdown.
+1 -1
View File
@@ -8,7 +8,7 @@ working tree. A git worktree is the fix, and tty7 makes it a single dialog.
## Creating one
**New Worktree Tab…** — in the command palette, the tab's right-click menu, and
**New Worktree Tab…** — in Search Everywhere, the tab's right-click menu, and
the application menu — asks three things:
| Field | Default |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

+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">
+28 -6
View File
@@ -92,7 +92,8 @@ their id from the file name. [More about themes →](/customization/themes)
|---|---|---|---|
| `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). |
| `new_tab_position` | enum | `"after-current"` | Or `end`. |
| `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. |
| `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. |
| `sidebar_auto_grouping` | bool | `true` | Groups unpinned tabs by repository (SSH tabs by host). Off: a flat list below the pinned groups, which show either way. Pinned groups and folds are stored with the workspace, not here. |
| `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. |
| `sidebar_width` | number | `220` | Pixels (100–2000). |
| `sidebar_collapsed` | bool | `false` | |
@@ -112,11 +113,12 @@ their id from the file name. [More about themes →](/customization/themes)
| Key | Type | Default | |
|---|---|---|---|
| `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. `args` you write are launched verbatim, which also turns off [shell integration](/reference/shell-integration) for that shell — leave them out to keep it. |
| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. |
| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells — in the menu's **Other Shells…** list until you have opened it, and in Search Everywhere's Terminals tab as *Shell:* followed by its label. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. |
| `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. |
| `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. |
@@ -177,6 +179,8 @@ angle brackets, and a tab:
| Key | Type | Default | |
|---|---|---|---|
| `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. |
| `agent_launch` | object | `{}` | The command line quick launch types for an agent, by slug: `{"claude": "claude --dangerously-skip-permissions"}`. Unset agents launch as their bare binary. [More →](/agents/overview#quick-launch) |
| `agent_frecency` | object | `{}` | Written by tty7: how often and how recently each agent ran, to order quick launch. |
| `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. |
| `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. |
@@ -186,10 +190,28 @@ angle brackets, and a tab:
| Key | Type | Default | |
|---|---|---|---|
| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. |
| `verify_host_keys` | bool | `true` | |
| `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. |
### servers.json
The saved hosts are not in `config.json`: they live in `servers.json` in the
same directory, so `config.json` can be synced between machines without taking
your list of servers along. The file is written with mode `0600` and read as
forgivingly as `config.json`; if it cannot be parsed, tty7 keeps a copy at
`servers.json.corrupt` and saves nothing until it is repaired.
| Key | Type | Default | |
|---|---|---|---|
| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. |
| `ssh_profile_frecency` | object | `{}` | How often and how recently each host is used, for ranking. Written by the app. |
An older `config.json` that still holds `ssh_profiles` is split the first time
a newer tty7 reads it: `servers.json` is written first, and only then are the
two keys removed from `config.json`, leaving the rest of it as it was. If both
files hold hosts, `servers.json` wins, and the copy in `config.json` is dropped
the next time settings are saved.
Each object in `ssh_profiles` can also set:
| Key | Type | Default | |
@@ -209,9 +231,9 @@ Each object in `ssh_profiles` can also set:
## Keys tty7 manages itself
`ssh_profile_frecency` and `command_frecency` record how often and how recently
you use a profile or command, so the pickers can rank them. They are written by
the app; there is no reason to edit them.
`shell_frecency` and `command_frecency` record how often and how recently you
open a shell or run a command, so the new-tab menu and Search Everywhere can rank
them. They are written by the app; there is no reason to edit them.
<Note>
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
+5 -4
View File
@@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default.
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
| New Window | <kbd>⌘ N</kbd> | — |
| New Agent Tab | <kbd>⌘ ⇧ A</kbd> | — |
## Panes
@@ -35,7 +36,7 @@ it shows what *your* copy is bound to. This is the shipped default.
| Action | macOS | Windows / Linux |
|---|---|---|
| Command Palette | <kbd>⌘ P</kbd> | <kbd>Ctrl ⇧ P</kbd> |
| Search Everywhere | <kbd>⌘ P</kbd> | <kbd>Ctrl ⇧ P</kbd> |
| Toggle Left Sidebar | <kbd>⌘ B</kbd> | <kbd>Ctrl ⇧ B</kbd> |
| Toggle Right Panel | <kbd>⌘ J</kbd> | <kbd>Ctrl ⇧ J</kbd> |
| Toggle Code Panel | <kbd>⌘ ⇧ E</kbd> | <kbd>Ctrl ⇧ E</kbd> |
@@ -102,15 +103,15 @@ the font size.
## Actions with no default key
All of these are in the command palette, and all are bindable under **Settings →
All of these are in Search Everywhere, and all are bindable under **Settings →
Keybindings**:
| Group | Actions |
|---|---|
| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `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` |
| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:<slug>` (one per agent, e.g. `LaunchAgent:claude`) |
| Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` |
| Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` |
| Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` |
+2 -1
View File
@@ -55,6 +55,7 @@ Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies)
| | |
|---|---|
| Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) |
| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk |
| Saved SSH hosts | `<config>/servers.json`, mode `0600` on Unix. Addresses, users and key paths — no secrets. |
| SSH passwords and key passphrases | The **OS keychain** — never `config.json` or `servers.json`, never plain text on disk |
| Pane scrollback tails | `<config>/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. |
| Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. |
+20 -2
View File
@@ -56,8 +56,8 @@ later `8080 → 3000` explains nothing.
## Adding one mid-session
The **+** on the Ports section — or *SSH: Port Forwarding* in the command
palette — opens a form asking for one thing: the port the remote is serving on.
The **+** on the Ports section — or *SSH: Port Forwarding* in Search
Everywhere — opens a form asking for one thing: the port the remote is serving on.
It comes out here under the same number, and the form says so before you commit
to it.
@@ -67,6 +67,24 @@ need it about as often as you need `ssh -R`.
These live only as long as the session unless you save them into the profile.
## Switching a rule off
Every forward in the Ports section has a switch. Off, the forward lets go of
its port but keeps its whole rule — kind, ports, target, description — so it
can be switched back on without retyping anything. That is how one local port
is pointed at different places: keep one rule per target on the same port, and
switch on the one you want.
Only one of them can hold the port at a time. Switching one on while another
still has its port is refused, and the notice names the forward to switch off
first.
Rules from a profile carry their switch into it, so the next connection opens
the same set. The same switch sits beside each rule under **Settings → SSH →**
a profile **→ Port forwarding**; a rule saved switched off is listed but not
opened. Rules added mid-session are still gone when the session ends, on or
off.
<Frame caption="The Ports section with a local forward and a dynamic (SOCKS) rule">
<img src="/images/ports.webp" alt="The ports section" />
</Frame>
+1 -1
View File
@@ -3,7 +3,7 @@ title: "SFTP"
description: "A file browser for the machine on the other end of the connection."
---
While a pane is in an SSH session, *SSH: Remote Files* (command palette) slides
While a pane is in an SSH session, *SSH: Remote Files* (Search Everywhere) slides
a file panel in over it. It is a real SFTP client on the same connection — no
second login, no second password.
+8 -6
View File
@@ -19,7 +19,7 @@ instead of a password echoing into your shell.
<AccordionGroup>
<Accordion title="QuickConnect — type an address">
Open the palette (<kbd>⌘ P</kbd>) and type an address. IPv6 works with
Open Search Everywhere (<kbd>⌘ P</kbd>) and type an address. IPv6 works with
brackets.
```
@@ -30,8 +30,8 @@ instead of a password echoing into your shell.
</Accordion>
<Accordion title="A saved profile">
Profiles live in **Settings → SSH → Hosts**. Start typing the name in the
palette, or open the *SSH: Manage Profiles…* command.
Profiles live in **Settings → SSH → Hosts**. Start typing the name in
Search Everywhere's Hosts tab, or open the *SSH: Manage Profiles…* command.
</Accordion>
<Accordion title="An alias from ~/.ssh/config">
@@ -75,8 +75,10 @@ on the tab it was opened on, not on whichever pane is focused. Saving one opens
on the whole live connection — its proxy, keys and forwards as well as its
address — so the host that lands is the one you were already on.
Passwords and key passphrases go in the **OS keychain**, never in
`config.json` and never on disk in plain text. **Forget Password** in a
Saved hosts are kept in `servers.json`, beside `config.json` rather than in
it, so a `config.json` you sync between machines does not carry them.
Passwords and key passphrases go in the **OS keychain**, never in either file
and never on disk in plain text. **Forget Password** in a
profile's menu removes the stored one.
Deleting a profile drops its keychain credentials and forgets the remote
@@ -202,7 +204,7 @@ Also under Security: **Warn before closing** a live connection, off by default.
## Reconnecting
<kbd>⌘ ⇧ R</kbd> — or *SSH: Reconnect* in the palette — restarts the session in
<kbd>⌘ ⇧ R</kbd> — or *SSH: Reconnect* in Search Everywhere — restarts the session in
the current pane. Useful after a laptop sleeps or a network changes.
## What is not supported
+1 -1
View File
@@ -11,7 +11,7 @@ description: "Smart double-click, copy on select, and the settings around them."
| Double-click | Select the thing under the cursor — see below |
| Triple-click | Select the line |
| <kbd>⇧</kbd>-click | Extend the current selection to where you clicked |
| <kbd>⌘ A</kbd> *(macOS)* | Select all — also in the right-click menu and the command palette on every platform |
| <kbd>⌘ A</kbd> *(macOS)* | Select all — also in the right-click menu and Search Everywhere on every platform |
### Smart double-click
-59
View File
@@ -1,59 +0,0 @@
---
title: "Command palette"
description: "One key for every action in the app — including the ones with no shortcut."
---
<kbd>⌘ P</kbd> opens the command palette. Type to filter, <kbd>↑</kbd>
<kbd>↓</kbd> to move, <kbd>⏎</kbd> to run.
Every action tty7 can perform is in here, whether or not it has a keybinding —
which makes it the fastest way to reach the ones that deliberately ship unbound,
like pane resize and swap.
<Frame caption="The command palette over a terminal, filtered to pane actions">
<img src="/images/command-palette.webp" alt="The tty7 command palette" />
</Frame>
## What is in it
Results are grouped, and the groups are the map of the app:
| Group | Examples |
|---|---|
| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session |
| **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… |
| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff |
| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch |
| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All |
| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding |
| **Agents** | Send Selection · Send Git Diff for Review · Copy Session ID |
| **Application** | Settings… · Keyboard Shortcuts · Check for Updates… · Documentation · Join the Discord · Report an Issue… · Restart Server… |
Entries that do something destructive say so under the name — *Delete
Workspace…* is subtitled "ends its shells and forgets the layout", *Restart
Server…* is "ends every running shell; layout is kept".
## Connecting from the palette
Type an SSH address and the palette offers to connect to it:
```
me@devbox
me@devbox:2222
[::1]:22
```
Saved profiles show up the same way — start typing the name. A host that only
exists in `~/.ssh/config` does not: import it into a profile first, or reach it
from the workspace switcher. [SSH →](/remote/ssh)
## Sending context to an agent
Two palette commands hand what is in front of you to the coding agent running in
the pane, as a ready-made prompt:
- **Agent: Send Selection** — the current selection.
- **Agent: Send Git Diff for Review** — the repository's `git diff`.
If no agent is running, tty7 says so rather than typing into your shell.
[Agents →](/agents/overview)
+102
View File
@@ -0,0 +1,102 @@
---
title: "Search Everywhere"
description: "One key for every action, open tab, past agent session and host — each in a tab of its own."
---
<kbd>⌘ P</kbd> (<kbd>Ctrl ⇧ P</kbd> on Linux and Windows) opens Search
Everywhere. Type to filter, <kbd>↑</kbd> <kbd>↓</kbd> to move, <kbd>⏎</kbd> to
run, <kbd>⇥</kbd> / <kbd>⇧ ⇥</kbd> to move between its tabs. Press the key again
or <kbd>Esc</kbd> to close it.
## Tabs
| Tab | What it holds |
|---|---|
| **All** | The best few rows from each tab below, each under the tab's name. The tab holding the best match comes first, so <kbd>⏎</kbd> always runs the best answer anywhere. When a tab has more rows than fit, a *N more in …* row opens that tab with your query still typed. |
| **Actions** | Every action tty7 can perform, whether or not it has a keybinding — the fastest way to reach the ones that deliberately ship unbound, like pane resize and swap. |
| **Terminals** | Every open tab of every workspace, this window's first and most recently used first, then every way to open a new one: the shells this machine has (*Shell: zsh*) and the coding agents on your `PATH` (*Agent: Claude Code*). |
| **Sessions** | Past Claude Code and Codex sessions on this computer — the ones that ran in the focused tab's directory first, then the rest, newest first. <kbd>⏎</kbd> resumes one. |
| **Hosts** | Your saved SSH hosts, most used first — and whatever address you type. |
With nothing typed, **All** leads with this window's other tabs, most recent
first, so <kbd>⌘ P</kbd> <kbd>⏎</kbd> takes you back to the tab you were just
in. The actions you run most follow under *Actions*, and the last few agent
sessions that ran in this directory under *Sessions*.
## Actions
With nothing typed, the Actions tab lists what you use most under *Recent*,
then everything else in groups — the groups are the map of the app:
| Group | Examples |
|---|---|
| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session |
| **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… |
| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff |
| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch |
| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All |
| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding |
| **Agents** | New Agent Tab · Send Selection · Send Git Diff for Review · Copy Session ID |
| **Application** | Settings… · Keyboard Shortcuts · Check for Updates… · Documentation · Join the Discord · Report an Issue… · Restart Server… |
Entries that do something destructive say so under the name — *Delete
Workspace…* is subtitled "ends its shells and forgets the layout", *Restart
Server…* is "ends every running shell; layout is kept".
Every action is found by its name in any language tty7 ships, so an English
query finds a Chinese or Japanese label.
**Change Theme…** opens the theme list in place: moving through it previews
each theme on the window, <kbd>⏎</kbd> keeps it, <kbd>Esc</kbd> puts the old one
back and returns to where you were.
## Resuming a past session
The **Sessions** tab lists the sessions Claude Code and Codex keep on this
computer (`~/.claude/projects`, and `~/.codex/sessions` or `$CODEX_HOME`), each
under the title the agent gave it — or the name you gave it with `/rename`, or
failing both what you first asked — with where it ran, its branch and when it
was last used. Search matches the title, the directory, the branch and the
session id.
<kbd>⏎</kbd> opens a new tab in the directory the session ran in and resumes it
there (`claude --resume <id>`, `codex resume <id>`), with the flags
`agent_launch` gives the agent. An agent only finds a session from the directory
it started in, so a session whose directory is gone is not resumed; tty7 says
so instead.
The list is read in the background when the search opens, so a session started
a moment ago may appear a beat after the rest. Windows on a remote workspace do
not list sessions: they are this computer's, and a new tab there opens on the
other machine.
## Connecting to a host
Type an SSH address — in the **Hosts** tab, or straight into **All** — and it
offers to connect to it, or to save it as a host:
```
me@devbox
me@devbox:2222
[::1]:22
```
A full command line works too — `ssh -p 2222 -J jump me@devbox` connects with
exactly those options, and `ssh devbox` resolves an alias from
`~/.ssh/config` the way `ssh` would. A bare word on its own is a search, not
an address.
Saved hosts are listed by name; <kbd>⌘ E</kbd> on one opens it for editing
instead of connecting. **SSH: Add Connection…** in the Actions tab takes you to
the Hosts tab. [SSH →](/remote/ssh)
## Sending context to an agent
Two actions hand what is in front of you to the coding agent running in the
pane, as a ready-made prompt:
- **Agent: Send Selection** — the current selection.
- **Agent: Send Git Diff for Review** — the repository's `git diff`.
If no agent is running, tty7 says so rather than typing into your shell.
[Agents →](/agents/overview)
+3 -3
View File
@@ -85,13 +85,13 @@ same column.
Drag the divider for any width between a fifth and four fifths of that space —
the range `document_ratio` keeps — or double-click it to cycle a third, a half
and two thirds. **Document: Third / Half / Two-Thirds Width** in the command
palette do the same. On a narrow window the terminal's own floor stops the
and two thirds. **Document: Third / Half / Two-Thirds Width** in Search
Everywhere do the same. On a narrow window the terminal's own floor stops the
divider sooner.
Right-click the document's header for **Fill window**, which is the old
full-workspace overlay, unchanged. **Document: Fill Window** and **Document:
Dock Beside Terminal** in the palette are the same switch.
Dock Beside Terminal** in Search Everywhere are the same switch.
Fill or dock is **per tab**: read a long file over the whole window in one tab
while an agent keeps half of another, and neither moves the other. A fresh tab
+54 -14
View File
@@ -1,6 +1,6 @@
---
title: "The sidebar"
description: "Tabs grouped by repository, with branch, diff counts, and agent status on every row."
description: "Tabs grouped by repository automatically, groups you pin kept above them, and branch, diff counts, and agent status on every row."
---
The left sidebar is tty7's tab bar, and it is the default because a vertical row
@@ -14,18 +14,56 @@ is doing.
<img src="/images/sidebar.webp" alt="The tty7 tab sidebar" />
</Frame>
## Grouped by repository
## Groups
Rows sit under a header per git repository, with everything else collected in a
trailing **Scratch** section. The grouping follows the tab's working directory,
not its history — switching branches or `cd`-ing around inside a repository
never moves a row out from under its header.
The sidebar groups tabs by repository automatically. Pin what you want to keep.
**Settings → Window & Tabs → Sidebar grouping** switches between *By repo* (the
default), *By repo or folder*, and *Flat*. The default collects every non-repo
tab in Scratch; *By repo or folder* groups those tabs under their working
directory instead — for agents running in plain folders — leaving Scratch to
tabs whose shell has not reported a directory yet.
**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 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*
the folder joins it — a tab opened there, a `cd` into it, or a worktree of the
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 ◆
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.
Ways to pin:
| To get | Do this |
|---|---|
| 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 |
Right-click a pinned group's header to rename it, point it at a folder (**Set
Folder…**, or **Use Current Tab's Folder**), clear its folder, open a tab in it,
or delete it. Deleting a group closes nothing: its tabs go back to auto
grouping. An auto group has no rename — pin it first. The **+** on a header
opens a tab in that group's folder (a label group's opens where <kbd>⌘ T</kbd>
would), and <kbd>⌘ T</kbd> itself joins the group of the tab you are in.
Groups, their order, and which ones are folded are kept with the workspace, so
every window onto it — and the next launch — shows the same thing.
**Settings → Window & Tabs → Auto grouping** turns auto groups off. The tabs you
have not pinned then sit in one flat list below your pinned groups, which show
either way.
## What a row tells you
@@ -54,9 +92,11 @@ they simply stop opening the overlay.
## Rearranging
Drag a row to reorder it within its group, or drag a whole group header to move
the group. A row cannot be dragged into a different group: a tab's group comes
from its working directory, so `cd` is what moves it.
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 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:
it lands as a pane of the tab on screen, wherever the highlight says. Dragging a
+7 -1
View File
@@ -23,6 +23,12 @@ A new tab always opens in the current pane's directory. Where it lands in the
list is **Settings → Window & Tabs → New tab position** — *After current* by
default, or *At end*.
An SSH tab is named by whatever the remote shell titles itself, like any other.
To keep it on the host instead, set **Settings → Window & Tabs → SSH tab
title** to *Profile name* — the saved host's name, the `~/.ssh/config` alias, or
the address you typed — or *Hostname*. A tab you renamed keeps its name either
way, and a split tab still follows the pane in front.
Right-click a tab for the rest: rename, split right or down, a new worktree tab,
copy the working directory, copy the session id, close · close others · close to
the right, and — when a coding agent is running there — mark unread and fork its
@@ -43,7 +49,7 @@ A split inherits the current pane's working directory, so splitting inside a
repository keeps you in the repository.
Resizing and swapping panes have no default keys — bind them under **Settings →
Keybindings**, or run them from the command palette (*Resize Pane Left*, *Swap
Keybindings**, or run them from Search Everywhere (*Resize Pane Left*, *Swap
Pane Next*, and friends).
Inactive panes are dimmed slightly so the focused one is obvious. Turn that off
+41 -6
View File
@@ -1,7 +1,7 @@
---
name: tty7
description: >-
Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, run a command in a shell pane you already have and get back its output and exit code, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
---
# Driving tty7 from the command line
@@ -22,7 +22,7 @@ status hooks are installed, and whether `TTY7_CONFIG_DIR` / `TTY7_WS` /
`TTY7_PANE` are set — i.e. whether you are running *inside* a tty7 pane.
Being inside a pane matters for two reasons: the address-taking verbs
(`split`, `send`, `capture`, `procs`, `wait`, `pane close`) default to
(`split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`) default to
`$TTY7_PANE`, and `run --keep` files its pane into `$TTY7_WS`. Outside a tty7
shell you must name a target explicitly, and the error will say so rather than
guessing.
@@ -75,7 +75,7 @@ back in.
Omitting the address inside a tty7 shell means "this pane" / "this workspace".
An explicit address always wins over the environment.
## Running a command: two shapes
## Running a command: three shapes
### Blocking, with a real exit code
@@ -95,6 +95,28 @@ and the JSON object last — the combined stream is *not* parseable as JSON, so
read the last line. And the pane is 120 columns wide with no way to change it,
so output that assumes a wider terminal wraps.
### Blocking, in a pane you already have
```bash
tty7 exec "$PANE" --timeout 900 -- cargo test # prints its output, exits with its code
```
`exec` types the line at the pane's shell prompt — its cwd, its environment,
its history — waits for the prompt to come back, prints what the command
printed (as text, like `capture --plain`; `--raw` keeps the escapes) and exits
with the command's own code. It is how you run the next command in a pane you
set up earlier (a `cd`, an activated venv, an `ssh` session with tty7's shell
integration on the far end) without the send / wait / capture / guess dance.
What it needs, and says when it doesn't have: the pane's shell must have tty7's
shell integration (a pane that has never sent a prompt mark is refused), and
the pane must be sitting at a prompt (a busy one is refused — `wait --until
free` first). The words after `--` become one shell line, so `&&`, pipes and
redirections work if you quote them: `tty7 exec %3 -- 'make && ./run'`. Always
give `--timeout` when nobody is watching: a line the shell never runs — an
unclosed quote waiting on `dquote>` — has no end to wait for. On timeout it
exits 124 and leaves the command running.
### Non-blocking: a pane you talk to over time
This is the one that makes tty7 worth reaching for. Get a pane, send it work,
@@ -135,6 +157,18 @@ for Alt. Repeat it for a sequence; text and keys compose, text first. Typing
`^C` as text does nothing — it arrives as two characters; `--key C-c` is the
real interrupt.
Two things text on the command line cannot do. **Secrets**: `tty7 send %3
"$TOKEN"` puts the token in `ps` for every local user and in your shell
history — pipe it instead, `printf %s "$TOKEN" | tty7 send %3 --stdin --enter`
(or `--from-file PATH`); the bytes go to the pane exactly as read and are never
echoed in `--json`. Echo on the far side is the program's business: `docker
login --password-stdin`, `ssh-add` and `gpg` turn it off themselves.
**Multi-line text**: a newline on a pty *is* Enter, so each line runs as it
arrives. `--paste` sends it the way a paste into the window does — framed in
bracketed paste when the pane has switched that on, so a shell or an agent's
input box takes the whole block as text; `--json` reports `"bracketed"` so you
know which it got.
**A brand-new pane can swallow the Enter.** A shell still working through its
startup files — a prompt framework, `fastfetch`, anything that paints on login —
takes the text you send but loses the carriage return that follows it, and the
@@ -176,8 +210,9 @@ whole ring, which for a pane that was never resized is the same thing.
`--plain` gives you the screen, and a screen is a rectangle: whatever scrolled
past the top of a long build log is gone, and the exit code was never on screen
at all. So when what you want is the *answer* rather than the view, have the
shell write it somewhere clean:
at all. When the pane is a shell sitting at its prompt, `tty7 exec` (above)
hands back both. Otherwise — the pane is busy, or its shell has no tty7
integration — have the shell write the answer somewhere clean:
```bash
tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter
@@ -278,7 +313,7 @@ output on success but never suppresses errors.
The panes on this machine are the user's real work, and some of them are other
coding agents mid-task. Treat anything you did not create as read-only:
- **Never `send` into a pane you didn't open.** Keystrokes into another agent's
- **Never `send` or `exec` into a pane you didn't open.** Keystrokes into another agent's
pane, or into a shell the user is typing in, land in the middle of whatever
is happening there. Check `tty7 agents` before you touch a pane. This goes
double for `--key`: a stray `C-c` kills somebody's work.
+70 -10
View File
@@ -30,7 +30,7 @@ Set inside every tty7 pane, inherited by anything you launch from one.
| Variable | Meaning |
|---|---|
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `tab ls`, `ws tree`. |
| `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. |
@@ -44,13 +44,13 @@ Outside a tty7 shell the address-taking verbs fail with
| 0 | success |
| 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` |
| 2 | usage error (clap) — unknown verb, missing argument, bad type |
| 124 | `tty7 wait` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" |
| 124 | `tty7 wait` or `tty7 exec` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" |
| 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. |
| *other* | only from `tty7 run`, which passes the child's exit code through |
| *other* | only from `tty7 run` and `tty7 exec`, which pass the command's exit code through |
If `run` cannot learn the child's code it prints a note to stderr and exits 1
with `"exit_code_known": false` in the JSON — that is how you tell a real 1
from a stand-in.
If `run` or `exec` cannot learn the command's code it prints a note to stderr
and exits 1 with `"exit_code_known": false` in the JSON — that is how you tell
a real 1 from a stand-in.
## Top-level verbs
@@ -85,6 +85,41 @@ the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo.
JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed
output. The combined stream is not valid JSON; read the last line.
### `tty7 exec [%PANE] [--timeout SECS] [--raw] -- CMD...`
Runs `CMD` in an existing pane's shell and blocks until its prompt comes back:
the words after `--` are joined with spaces and typed at the prompt as one line
followed by Enter, so the shell interprets them — quote `&&`, `|` and `>` to
keep them from your own shell. Prints what the command printed and exits with
its exit code. The pane (default `$TTY7_PANE`) keeps its cwd, environment and
history, which is the difference from `run`: `run` makes a new pane for an
argv, `exec` uses a shell you already have.
The shell integration's OSC 133 marks bound the command: the output is what the
pane printed between the command starting (`C`) and finishing (`D`, which also
carries the exit code). Refused, with nothing typed:
- a pane whose shell has sent no prompt mark — no integration, so nothing would
ever say the command ended; `tty7 doctor`, or fall back to `send` + `wait
--until free`
- a pane that is not at a prompt — something already runs there (your own pane
always is: it is running `tty7 exec`)
- a line holding a control character (newline, tab, ESC), which the line editor
would act on rather than type — join commands with `;` / `&&`
| Flag | |
|---|---|
| `--timeout SECS` | Give up after SECS, exit 124. The command is left running; `send %N --key C-c` stops it. With no timeout it waits for as long as the command runs |
| `--raw` | The output as the pane received it, escapes intact. By default it is replayed through a terminal to text, the way `capture --plain` reads it — which also erases zsh's PROMPT_SP, drawn before the finish mark |
A line the shell never starts — an unclosed quote waiting on a continuation
prompt — never finishes; the timeout message says that is the likely cause.
A line the shell rejects outright (a syntax error) exits 1 with
`"exit_code_known": false` and the shell's complaint as the output. Output past
8 MiB is dropped from the front, with a note on stderr.
JSON: `{"pane","command","exit","exit_code_known","timed_out","pane_exited","output","bytes","elapsed_ms"}`
— `output` is the printed form, `bytes` the raw length.
### `tty7 new [PATH] [--open]`
Creates a workspace plus its first tab and shell, at `PATH` if given. Prints
the workspace id. JSON: `{"id","pane","opened"}`.
@@ -100,7 +135,7 @@ pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the
share kept by the *existing* pane, clamped to 0.05–0.95 — a `--ratio 70`
silently becomes 0.95, not an error. Prints `%NN`. JSON: `{"pane"}`.
### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…`
### `tty7 send [%PANE] [TEXT | --stdin | --from-file PATH] [--paste] [--enter] [--key KEY]…`
Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key
enter` — it appends CR to the text, or presses Enter on its own when there is
none, so `tty7 send %42 --enter` runs whatever pane 42 already has typed. With
@@ -115,7 +150,28 @@ that still doesn't parse (`%3x`) is an address error, never text for your own
pane — while text that merely starts with `%` (`%s/foo/bar/`, `%!sort`) types
as given, as does anything unmarked that is not a plain number (`3x`, `+5`). To
type an address-shaped string, name the pane as well: `tty7 send %42 %3x`.
JSON: `{"pane","sent","enter","keys"}`.
JSON: `{"pane","sent","source","bytes","paste","bracketed","bracketed_paste_mode","enter","keys"}`
— `source` is `argument`, `stdin`, `file` or `none`.
`--stdin` / `--from-file PATH` take the text from there instead of an argument
— for a secret, which as an argument sits in `ps` for every local user and in
your shell history. The bytes go to the pane exactly as read (no escape
interpretation, no newline conversion, not required to be UTF-8), and `sent` is
`null` in the JSON so they are not echoed into a log either. With either, the
one positional is the pane and must be a valid address. Use `printf %s`, not
`echo`, unless you mean the trailing newline — on a pty that newline is Enter.
Whether the far side echoes the secret is up to the program reading it, not
tty7: `docker login --password-stdin`, `ssh-add` and `gpg` turn echo off.
`--paste` sends the text as the GUI sends a paste. When the pane has switched on
bracketed paste (mode 2004 — shells at their prompt and most TUIs do), the text
is wrapped in `ESC[200~ … ESC[201~` with CRLF folded to LF and every ESC byte
stripped, so the text cannot end the paste early; multi-line text then lands as
one block instead of running a line at a time. When the pane has not, the text
goes unframed with each line break sent as Enter — what a paste into such a pane
has always done — and a note goes to stderr. `bracketed` says which was sent;
`bracketed_paste_mode` is what the pane reported (`null` from a server too old
to say). `--enter` after `--paste` submits the pasted block.
`--key` presses a key instead of typing characters — the arrow keys a
permission prompt wants, the `escape` that closes a TUI, the `C-c` that stops a
@@ -196,9 +252,13 @@ The process tree inside the pane, indented by depth, `*` on the foreground
process — then a second table of ports those processes are listening on.
Prints `nothing running in this pane` when both are empty.
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}`,
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}],"context":{…}}`,
where `addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`,
`[::1]`, or a specific interface).
`[::1]`, or a specific interface). `context` is what the pane says about itself:
`local_pty`, `remote` (the host it is pointed at), `at_prompt` (from its shell
integration's marks; absent until the first one), `remote_prompt_seen`, and
`bracketed_paste` (whether the pane has mode 2004 on — what `send --paste`
reads).
Nothing below the depth-0 shell means the foreground command has exited — but
you rarely need to check that by hand, because that is exactly what
+16
View File
@@ -27,15 +27,19 @@ actions!(
CloseTabsToTheRight,
CopyWorkingDirectory,
OpenLinkUnderPointer,
OpenLinkWithDefaultApp,
RevealLinkUnderPointer,
CopyLinkPathUnderPointer,
MarkTabUnread,
HibernateTab,
ForkAgentSession,
ForkAgentSessionRight,
ForkAgentSessionLeft,
ForkAgentSessionDown,
ForkAgentSessionUp,
CopyAgentSessionId,
NewAgentTab,
SaveAgentLaunchArgs,
SplitRight,
SplitDown,
FocusNextPane,
@@ -54,6 +58,8 @@ actions!(
PrevTab,
SelectNextTab,
SelectPrevTab,
MoveTabLeft,
MoveTabRight,
ActivateTab1,
ActivateTab2,
ActivateTab3,
@@ -122,6 +128,16 @@ actions!(
SendBackTab,
SwitcherAcross,
SwitcherAcrossBack,
SearchNextTab,
SearchPrevTab,
Quit
]
);
/// Open a new tab running one particular coding agent. The keymap names one of
/// these per agent (`LaunchAgent:claude`, …); see `ui::agent_launch`.
#[derive(Clone, Debug, PartialEq, gpui::Action)]
#[action(namespace = tty7, no_json)]
pub struct LaunchAgent {
pub agent: tty7_core::core::cli_agent::CLIAgent,
}
+640
View File
@@ -0,0 +1,640 @@
//! Past coding-agent sessions on this computer, read off the agents' own
//! history files, so one can be picked and resumed.
//!
//! Only the ends of each file are read: the head carries where the session
//! ran and what it was first asked, the tail the title the agent last gave
//! it. A transcript runs to megabytes, and reading the whole of hundreds of
//! them to list their names would make opening the list the slow part.
//!
//! Parsed files are remembered by path, size and modification time, so after
//! the first scan only the sessions that moved are read again.
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
use crate::core::cli_agent::CLIAgent;
/// How many sessions are listed, newest first. Past this the list is a
/// search target, not something to scroll, and older sessions are rarely
/// worth resuming.
const MAX_SESSIONS: usize = 500;
/// How much of a file's head and tail is read.
const HEAD_BYTES: u64 = 64 * 1024;
const TAIL_BYTES: u64 = 128 * 1024;
/// How much of a prompt a title keeps.
const TITLE_CHARS: usize = 120;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PastSession {
pub agent: CLIAgent,
pub id: String,
/// Where it ran — where it has to be resumed, since agents key their
/// history by directory.
pub cwd: Option<PathBuf>,
pub title: String,
pub branch: Option<String>,
/// Last written, in Unix seconds.
pub updated: u64,
}
/// Every past session found under `home`, most recently used first.
///
/// `codex_home` is `$CODEX_HOME` when set; Codex keeps its sessions under
/// `~/.codex` otherwise.
pub fn scan(home: &Path, codex_home: Option<&Path>) -> Vec<PastSession> {
let codex_home = codex_home
.map(Path::to_path_buf)
.unwrap_or_else(|| home.join(".codex"));
let mut files = claude_files(home);
files.extend(codex_files(&codex_home));
files.sort_by_key(|f| std::cmp::Reverse(f.modified));
files.truncate(MAX_SESSIONS);
// Codex names a thread after the fact, in an index beside the sessions
// rather than in the session's own file.
let codex_titles = codex_thread_names(&codex_home);
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut out = Vec::new();
let mut seen = HashMap::with_capacity(files.len());
for file in files {
let hit = cache
.get(&file.path)
.filter(|c| c.len == file.len && c.modified == file.modified)
.map(|c| c.session.clone());
let session = match hit {
Some(session) => session,
None => match file.agent {
CLIAgent::Codex => read_codex(&file.path, unix(file.modified)),
_ => read_claude(&file.path, file.len, unix(file.modified)),
},
};
seen.insert(
file.path.clone(),
Cached {
len: file.len,
modified: file.modified,
session: session.clone(),
},
);
out.extend(session.map(|mut s| {
if let Some(name) = codex_titles
.get(&s.id)
.filter(|_| s.agent == CLIAgent::Codex)
{
s.title = name.clone();
}
s
}));
}
// What was not seen this time was deleted or fell off the end.
*cache = seen;
out
}
/// What the last [`scan`] found, without touching the disk — what the list
/// shows while a new scan runs.
pub fn cached() -> Vec<PastSession> {
let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<PastSession> = cache.values().filter_map(|c| c.session.clone()).collect();
out.sort_by_key(|s| std::cmp::Reverse(s.updated));
out
}
/// `$CODEX_HOME`, if set.
pub fn codex_home() -> Option<PathBuf> {
std::env::var_os("CODEX_HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from)
}
/// This user's home, where the agents keep their history.
pub fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.filter(|h| !h.is_empty())
.map(PathBuf::from)
}
struct Cached {
len: u64,
modified: SystemTime,
/// `None` for a file that holds no session worth listing — remembered
/// too, so it is not read again until it changes.
session: Option<PastSession>,
}
static CACHE: LazyLock<Mutex<HashMap<PathBuf, Cached>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
struct Found {
agent: CLIAgent,
path: PathBuf,
len: u64,
modified: SystemTime,
}
fn unix(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Claude Code keeps one `<session id>.jsonl` per session under
/// `~/.claude/projects/<cwd with separators as dashes>/`. The directories
/// beside those files hold a session's subagent transcripts, which are not
/// sessions of their own.
fn claude_files(home: &Path) -> Vec<Found> {
let root = home.join(".claude").join("projects");
let Ok(projects) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut out = Vec::new();
for project in projects.flatten() {
let Ok(entries) = std::fs::read_dir(project.path()) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "jsonl") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
out.push(Found {
agent: CLIAgent::Claude,
path,
len: meta.len(),
modified: meta.modified().unwrap_or(UNIX_EPOCH),
});
}
}
out
}
/// Codex writes `sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl`.
fn codex_files(codex_home: &Path) -> Vec<Found> {
let mut out = Vec::new();
let mut dirs = vec![codex_home.join("sessions")];
while let Some(dir) = dirs.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
let path = entry.path();
if meta.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == "jsonl") {
out.push(Found {
agent: CLIAgent::Codex,
path,
len: meta.len(),
modified: meta.modified().unwrap_or(UNIX_EPOCH),
});
}
}
}
out
}
/// `session_index.jsonl`: one `{id, thread_name}` line per naming, the
/// last one for an id being its name now.
fn codex_thread_names(codex_home: &Path) -> HashMap<String, String> {
let Ok(text) = std::fs::read_to_string(codex_home.join("session_index.jsonl")) else {
return HashMap::new();
};
records(&text)
.filter_map(|r| {
let id = str_field(&r, "id")?.to_string();
let name = str_field(&r, "thread_name")?.to_string();
Some((id, name))
})
.collect()
}
/// How far into a rollout the first prompt is looked for. The first record
/// alone can run to hundreds of kilobytes — it carries the instructions
/// Codex started with — so this reads whole lines rather than a fixed head.
const CODEX_HEAD_BYTES: u64 = 2 * 1024 * 1024;
fn read_codex(path: &Path, updated: u64) -> Option<PastSession> {
use std::io::BufRead;
// Everything Codex says about the session is at its start.
let file = File::open(path).ok()?;
let mut reader = std::io::BufReader::new(file.take(CODEX_HEAD_BYTES));
let mut head = String::new();
let mut line = String::new();
while reader.read_line(&mut line).ok()? > 0 {
let prompt = line.contains("\"user_message\"");
head.push_str(&line);
line.clear();
// The prompt comes after the metadata, so once one is in, both are.
if prompt {
break;
}
}
parse_codex(&head, updated)
}
pub(crate) fn parse_codex(head: &str, updated: u64) -> Option<PastSession> {
let mut id = None;
let mut cwd = None;
let mut branch = None;
let mut title = None;
let mut first_prompt = None;
for record in records(head) {
let Some(payload) = record.get("payload") else {
continue;
};
match record.get("type").and_then(Value::as_str) {
Some("session_meta") => {
// A review or a subagent Codex ran for itself is not a
// session anyone started, and there is nothing to go back to.
if codex_not_the_users(payload) {
return None;
}
id = str_field(payload, "id").map(str::to_string);
cwd = str_field(payload, "cwd").map(PathBuf::from);
branch = payload.get("git").and_then(|git| {
str_field(git, "branch")
.or_else(|| str_field(git, "current_branch"))
.map(str::to_string)
});
title = str_field(payload, "title")
.or_else(|| str_field(payload, "thread_name"))
.or_else(|| str_field(payload, "threadName"))
.map(str::to_string);
}
Some("turn_context") if cwd.is_none() => {
cwd = str_field(payload, "cwd").map(PathBuf::from);
}
// What the user typed, as the event stream records it. The
// `response_item` of the same turn also carries the context
// Codex injects around it (`<environment_context>`, AGENTS.md).
Some("event_msg")
if first_prompt.is_none()
&& payload.get("type").and_then(Value::as_str) == Some("user_message") =>
{
first_prompt = str_field(payload, "message").and_then(prompt_title);
}
_ => {}
}
if id.is_some() && first_prompt.is_some() {
break;
}
}
let title = title.or(first_prompt)?;
Some(PastSession {
agent: CLIAgent::Codex,
id: id?,
cwd,
title,
branch,
updated,
})
}
fn codex_not_the_users(meta: &Value) -> bool {
let source = match meta.get("source") {
Some(Value::String(s)) => matches!(s.as_str(), "subagent" | "internal"),
// `{"subagent": …}`: a spawned one, described.
Some(Value::Object(_)) => true,
_ => false,
};
let thread = str_field(meta, "thread_source").is_some_and(|s| s != "user");
source || thread
}
fn read_claude(path: &Path, len: u64, updated: u64) -> Option<PastSession> {
let id = path.file_stem()?.to_str()?.to_string();
let (head, tail) = ends(path, len).ok()?;
parse_claude(id, &head, &tail, updated)
}
/// The file's first [`HEAD_BYTES`] and last [`TAIL_BYTES`], as text. The two
/// overlap on a short file, which is harmless: the head is read for the first
/// of things and the tail for the last.
fn ends(path: &Path, len: u64) -> std::io::Result<(String, String)> {
let mut file = File::open(path)?;
let mut head = Vec::new();
(&mut file).take(HEAD_BYTES).read_to_end(&mut head)?;
let mut tail = Vec::new();
if len > HEAD_BYTES {
file.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?;
file.take(TAIL_BYTES).read_to_end(&mut tail)?;
}
Ok((
String::from_utf8_lossy(&head).into_owned(),
String::from_utf8_lossy(&tail).into_owned(),
))
}
/// Complete JSON records in `text`. A cut at either end leaves a partial
/// line, which does not parse and is skipped.
fn records(text: &str) -> impl DoubleEndedIterator<Item = Value> + '_ {
text.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
}
fn str_field<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key)?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
}
pub(crate) fn parse_claude(
id: String,
head: &str,
tail: &str,
updated: u64,
) -> Option<PastSession> {
let mut cwd = None;
let mut branch = None;
let mut first_prompt = None;
for record in records(head) {
if cwd.is_none() {
cwd = str_field(&record, "cwd").map(PathBuf::from);
}
if branch.is_none() {
branch = str_field(&record, "gitBranch")
.filter(|b| *b != "HEAD")
.map(str::to_string);
}
if first_prompt.is_none() {
first_prompt = claude_user_prompt(&record);
}
if cwd.is_some() && branch.is_some() && first_prompt.is_some() {
break;
}
}
// A name the user gave it outranks the one the agent made up, which
// outranks what it was first asked. The last of each wins: a session is
// renamed, and retitled as it goes.
let mut named = None;
let mut titled = None;
for record in records(tail).rev().chain(records(head).rev()) {
match record.get("type").and_then(Value::as_str) {
Some("custom-title") if named.is_none() => {
named = str_field(&record, "customTitle").map(str::to_string);
}
Some("ai-title") if titled.is_none() => {
titled = str_field(&record, "aiTitle").map(str::to_string);
}
Some("summary") if titled.is_none() => {
titled = str_field(&record, "summary").map(str::to_string);
}
_ => {}
}
if named.is_some() {
break;
}
}
// A session that was opened and never asked anything has nothing to
// resume.
let title = named.or(titled).or(first_prompt)?;
Some(PastSession {
agent: CLIAgent::Claude,
id,
cwd,
title,
branch,
updated,
})
}
/// What the user typed, from a user record — not a tool result, not the
/// harness's own injections (`<command-name>`, `<local-command-caveat>`, …).
fn claude_user_prompt(record: &Value) -> Option<String> {
if record.get("type").and_then(Value::as_str) != Some("user")
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|| record.get("isSidechain").and_then(Value::as_bool) == Some(true)
{
return None;
}
let content = record.get("message")?.get("content")?;
let text = match content {
Value::String(s) => s.as_str(),
Value::Array(parts) => parts.iter().find_map(|p| {
(p.get("type").and_then(Value::as_str) == Some("text"))
.then(|| p.get("text").and_then(Value::as_str))
.flatten()
})?,
_ => return None,
};
prompt_title(text)
}
/// The first line of `text` worth reading as a title, or `None` for text
/// the harness wrote rather than the user.
fn prompt_title(text: &str) -> Option<String> {
let text = text.trim();
if text.is_empty() || text.starts_with('<') || text.starts_with("Caveat:") {
return None;
}
let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
let mut title: String = line.chars().take(TITLE_CHARS).collect();
if line.chars().count() > TITLE_CHARS {
title.push('…');
}
Some(title)
}
#[cfg(test)]
mod tests {
use super::*;
fn line(v: serde_json::Value) -> String {
format!("{v}\n")
}
fn user(text: &str) -> String {
line(serde_json::json!({
"type": "user",
"cwd": "/repo",
"gitBranch": "main",
"message": {"role": "user", "content": text},
}))
}
#[test]
fn the_harness_is_not_the_user() {
let head = [
line(serde_json::json!({"type": "mode", "sessionId": "s"})),
user("<command-name>/clear</command-name>"),
user("Caveat: The messages below were generated by the user"),
user("fix the flaky test\nit fails on CI"),
]
.concat();
let s = parse_claude("s".into(), &head, "", 7).expect("a session");
assert_eq!(s.title, "fix the flaky test");
assert_eq!(s.cwd.as_deref(), Some(Path::new("/repo")));
assert_eq!(s.branch.as_deref(), Some("main"));
assert_eq!(s.updated, 7);
}
#[test]
fn a_name_beats_a_generated_title_beats_the_first_prompt() {
let head = user("first thing asked");
let titled = [
line(serde_json::json!({"type": "ai-title", "aiTitle": "Old title"})),
line(serde_json::json!({"type": "ai-title", "aiTitle": "Flaky test fix"})),
]
.concat();
let s = parse_claude("s".into(), &head, &titled, 0).unwrap();
assert_eq!(s.title, "Flaky test fix", "the last title wins");
let named = format!(
"{}{titled}",
line(serde_json::json!({"type": "custom-title", "customTitle": "ci work"}))
);
let s = parse_claude("s".into(), &head, &named, 0).unwrap();
assert_eq!(s.title, "ci work");
let s = parse_claude("s".into(), &head, "", 0).unwrap();
assert_eq!(s.title, "first thing asked");
}
#[test]
fn a_session_never_asked_anything_is_not_listed() {
let head = user("<command-name>/clear</command-name>");
assert!(parse_claude("s".into(), &head, "", 0).is_none());
}
#[test]
fn a_line_cut_by_the_read_is_skipped_not_fatal() {
let head = format!("{}{{\"type\":\"user\",\"mess", user("real prompt"));
let tail = format!("t\":1}}\n{}", user("later"));
let s = parse_claude("s".into(), &head, &tail, 0).unwrap();
assert_eq!(s.title, "real prompt");
}
#[test]
fn prompt_parts_and_tool_results() {
let tool = line(serde_json::json!({
"type": "user",
"message": {"content": [{"type": "tool_result", "content": "ok"}]},
}));
let parts = line(serde_json::json!({
"type": "user",
"message": {"content": [{"type": "text", "text": "look at this"}]},
}));
let s = parse_claude("s".into(), &format!("{tool}{parts}"), "", 0).unwrap();
assert_eq!(s.title, "look at this");
}
#[test]
fn a_long_prompt_is_cut_to_a_title() {
let long = "x".repeat(TITLE_CHARS + 10);
let s = parse_claude("s".into(), &user(&long), "", 0).unwrap();
assert_eq!(s.title.chars().count(), TITLE_CHARS + 1);
assert!(s.title.ends_with('…'));
}
#[test]
fn scan_reads_sessions_not_subagent_transcripts() {
let home = tempfile::tempdir().unwrap();
let project = home.path().join(".claude/projects/-repo");
std::fs::create_dir_all(project.join("abc/subagents")).unwrap();
std::fs::write(project.join("abc.jsonl"), user("hello")).unwrap();
std::fs::write(project.join("abc/subagents/agent-1.jsonl"), user("sub")).unwrap();
std::fs::write(project.join("empty.jsonl"), "").unwrap();
let found = scan(home.path(), None);
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, "abc");
assert_eq!(found[0].title, "hello");
}
fn codex_meta(extra: serde_json::Value) -> String {
let mut payload = serde_json::json!({
"id": "0199-abc",
"cwd": "/work",
"git": {"branch": "feature"},
});
payload
.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
line(serde_json::json!({"type": "session_meta", "payload": payload}))
}
fn codex_user(text: &str) -> String {
line(serde_json::json!({
"type": "event_msg",
"payload": {"type": "user_message", "message": text},
}))
}
#[test]
fn a_codex_rollout_is_named_by_what_was_typed_not_the_injected_context() {
let injected = line(serde_json::json!({
"type": "response_item",
"payload": {"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "<environment_context>…"}]},
}));
let head = [
codex_meta(serde_json::json!({})),
injected,
codex_user("add a retry"),
]
.concat();
let s = parse_codex(&head, 3).expect("a session");
assert_eq!(s.agent, CLIAgent::Codex);
assert_eq!(s.id, "0199-abc");
assert_eq!(s.title, "add a retry");
assert_eq!(s.cwd.as_deref(), Some(Path::new("/work")));
assert_eq!(s.branch.as_deref(), Some("feature"));
}
#[test]
fn a_rollout_codex_ran_for_itself_is_not_listed() {
for extra in [
serde_json::json!({"source": "subagent"}),
serde_json::json!({"source": {"subagent": "review"}}),
serde_json::json!({"thread_source": "automation"}),
] {
let head = format!("{}{}", codex_meta(extra.clone()), codex_user("x"));
assert!(parse_codex(&head, 0).is_none(), "{extra}");
}
let head = format!(
"{}{}",
codex_meta(serde_json::json!({"source": "cli"})),
codex_user("x")
);
assert!(parse_codex(&head, 0).is_some());
}
#[test]
fn codex_sessions_take_the_name_from_its_index() {
let home = tempfile::tempdir().unwrap();
let codex = home.path().join(".codex");
let day = codex.join("sessions/2026/09/26");
std::fs::create_dir_all(&day).unwrap();
std::fs::write(
day.join("rollout-2026-09-26T10-00-00-0199-abc.jsonl"),
format!("{}{}", codex_meta(serde_json::json!({})), codex_user("hi")),
)
.unwrap();
std::fs::write(
codex.join("session_index.jsonl"),
[
line(serde_json::json!({"id": "0199-abc", "thread_name": "Old"})),
line(serde_json::json!({"id": "0199-abc", "thread_name": "Retry logic"})),
]
.concat(),
)
.unwrap();
let found = scan(home.path(), None);
assert_eq!(found.len(), 1);
assert_eq!(found[0].title, "Retry logic");
}
}
+2 -3
View File
@@ -51,9 +51,8 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String>
}
pub fn submit_bytes(prompt: &str) -> Vec<u8> {
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend(prompt.bytes().filter(|&b| b != 0x1b));
bytes.extend_from_slice(b"\x1b[201~\r");
let mut bytes = tty7_core::core::paste::bracket(prompt.as_bytes());
bytes.push(b'\r');
bytes
}
+1
View File
@@ -1,6 +1,7 @@
pub use tty7_core::core::*;
pub mod actions;
pub mod agent_history;
pub mod agent_prompt;
#[cfg(target_os = "windows")]
pub mod aumid;
+18
View File
@@ -161,6 +161,24 @@ impl WorkspaceStore {
store.views.save();
}
/// Note that `agent` ran in workspace `id` (see `WindowView::seen_agents`).
pub fn record_agent_seen(
cx: &mut gpui::App,
id: WorkspaceId,
agent: tty7_core::core::cli_agent::CLIAgent,
) {
let Some(store) = Self::try_store(cx) else {
return;
};
if store
.views
.get_mut(id)
.is_some_and(|view| view.saw_agent(agent.slug()))
{
store.views.save();
}
}
pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId {
host_for(Self::all(cx), id)
}
+1
View File
@@ -883,6 +883,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
bind,
target,
description: String::new(),
enabled: true,
})
}
+8 -4
View File
@@ -48,10 +48,13 @@ fn spawn_config_watcher(cx: &mut App) {
if !tty7_core::host::is_content_change(&event.kind) {
return;
}
let hit = event
.paths
.iter()
.any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p));
// The saved SSH hosts live beside config.json in a file of their own
// (#911), and a hand edit there has to land the same way.
let hit = event.paths.iter().any(|p| {
p.file_name() == watched_file.file_name()
|| p.file_name() == Some(std::ffi::OsStr::new(crate::core::config::SERVERS_FILE))
|| is_theme_file(p)
});
if hit {
let _ = tx.try_send(());
}
@@ -705,6 +708,7 @@ fn main() {
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
crate::ui::prompt::install(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
+452 -30
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::{
@@ -884,8 +885,8 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
thread_local! {
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new());
/// Measured ink extents and the font size they were measured at.
static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option<Pixels>>)> =
/// Measured ink bounds and the font size they were measured at.
static INK_BOUNDS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option<Bounds<Pixels>>>)> =
RefCell::new((px(0.), HashMap::new()));
}
@@ -1089,6 +1090,9 @@ fn seg_budget(solo: bool, measured: bool, cells: usize, room: bool, cell_width:
}
/// How much to shrink a segment so its glyph stops inside its budget.
///
/// Never more than 1. A fallback icon is not held to this at all:
/// [`icon_fit`] sizes it, up or down.
fn fit_scale(ink: Pixels, budget: Pixels) -> f32 {
if ink <= budget || ink <= px(0.) {
1.
@@ -1097,22 +1101,157 @@ fn fit_scale(ink: Pixels, budget: Pixels) -> f32 {
}
}
/// Where a segment's ink ends, measured from the left edge of its first cell.
/// Whether the platform's `typographic_bounds` is the glyph's ink.
///
/// CoreText and DirectWrite answer with the ink box. cosmic-text answers with
/// the advance box pinned to the baseline, which says nothing about where an
/// icon's ink sits; sizing and centring on that would misplace it, so the
/// Linux build keeps the shrink-only rule for icons too.
const INK_BOUNDS_ARE_INK: bool = cfg!(any(target_os = "macos", target_os = "windows"));
/// The most an icon is ever enlarged by.
///
/// The cap keeps a glyph that is small by design — a dot, a bullet — from
/// being blown up to a cell-sized blob.
const MAX_ICON_GROWTH: f32 = 2.;
/// Whether a character is an icon: a code point in one of Unicode's Private
/// Use Areas, where Nerd Fonts, Font Awesome, Codicons and Material Design
/// icons all live.
///
/// Nothing in these ranges is text, so sizing one to its cells cannot change
/// how prose reads — the reason the rule is keyed on the code point and not
/// on the face: a CJK or emoji fallback keeps its own metrics.
///
/// The Powerline separators (`U+E0B0`–`U+E0D7`) are not icons. They are
/// shapes meant to abut the cells around them, the solid ones are painted
/// natively, and the thin ones already reach the row's full height, so
/// fitting them to an em and centring them would only pull them away from
/// the segments they join.
fn is_icon(c: char) -> bool {
matches!(
c as u32,
0xE000..=0xE0AF | 0xE0D8..=0xF8FF | 0xF_0000..=0xF_FFFD | 0x10_0000..=0x10_FFFD
)
}
/// Whether the cell after an icon can lend it room, the way kitty lets a
/// Private Use Area glyph followed by a space take two cells.
///
/// Everything [`has_room_after`] lends, and one thing more: a blank that
/// paints the *same* background as the icon's own cell. That is the space
/// after every icon in a coloured prompt segment, and backgrounds are painted
/// before any glyph, so the icon lands on its own colour. Held back to one
/// cell there, a Nerd Font icon is shrunk to about two thirds of the text's
/// height, which is #866.
fn lends_to_icon(row: &[RenderCell], start: usize, cells: usize) -> bool {
if has_room_after(row, start, cells) {
return true;
}
let (Some(icon), Some(next)) = (row.get(start), row.get(start + cells)) else {
return false;
};
is_blank(next)
&& !next.selected
&& icon.draw_bg
&& next.draw_bg
&& next.bg == icon.bg
&& next.match_hit == icon.match_hit
&& next.match_current == icon.match_current
&& !GlyphStyle::of(next).draws_on_blanks()
}
/// How to size an icon a fallback face supplied, and how many cells it spans.
///
/// A fallback face is drawn at the primary's font size on its own metrics, so
/// what an icon looks like beside the text depends on how the two faces'
/// cells compare. Symbols Nerd Font Mono inks a full em per icon, which is
/// 1.6 cells of a 0.6em primary; a face with a narrower cell than the
/// primary's inks less than one. Neither is the icon at the text's size.
///
/// So fit the ink, aspect ratio kept, to the cells it is given and one em of
/// height — the size the icon was drawn at in its own face, and never taller
/// than the text's line. That may shrink or grow it, growth capped at
/// [`MAX_ICON_GROWTH`] and ignored under 2%, which is not worth a reshape.
/// When `lend` says the next cell is free the icon may take it too, but only
/// if that lets it come out bigger: a narrow glyph that already fits one cell
/// keeps to it and stays beside what follows.
fn icon_fit(
ink: Bounds<Pixels>,
cell_width: Pixels,
cells: usize,
lend: bool,
height: Pixels,
) -> (f32, usize) {
let (w, h) = (ink.size.width.as_f32(), ink.size.height.as_f32());
if !(w > 0. && h > 0.) {
return (1., cells);
}
let fit = |cells: usize| {
let scale = (cell_width.as_f32() * cells as f32 / w)
.min(height.as_f32() / h)
.min(MAX_ICON_GROWTH);
if (1. ..=1.02).contains(&scale) {
1.
} else {
scale
}
};
let own = fit(cells);
if lend && fit(cells + 1) > own {
(fit(cells + 1), cells + 1)
} else {
(own, cells)
}
}
/// Where to paint a fitted icon, relative to its cell's top-left corner, so
/// its ink sits centred in `room` — vertically always, and horizontally only
/// when `centre_x`. An icon spilling into a borrowed cell stays where its
/// face put it instead, as it would unfitted, so the gap before the text
/// after it is the one its face drew.
///
/// `ink` is the glyph's bounds at its native size (y up from the baseline);
/// `ascent` and `descent` are the line's as shaped at the fitted size, which
/// is what gpui centres the baseline in the row with.
fn icon_offset(
ink: Bounds<Pixels>,
scale: f32,
room: gpui::Size<Pixels>,
centre_x: bool,
ascent: Pixels,
descent: Pixels,
) -> Point<Pixels> {
let baseline = (room.height - ascent - descent) / 2. + ascent;
let ink_top = baseline - (ink.origin.y + ink.size.height) * scale;
let x = if centre_x {
(room.width - ink.size.width * scale) / 2. - ink.origin.x * scale
} else {
// Nudged only as far as it takes to keep the ink inside the room.
px(0.)
.min(room.width - (ink.origin.x + ink.size.width) * scale)
.max(-ink.origin.x * scale)
};
point(x, (room.height - ink.size.height * scale) / 2. - ink_top)
}
/// The ink bounds of a segment's first glyph, relative to its pen position,
/// y up from the baseline.
///
/// Advance is the wrong yardstick: Apple Color Emoji advances 1.31em but only
/// inks 1.25em, so going by advance shrinks glyphs that would have fit. Ask
/// for the glyph's own bounds instead, cached per (face, char) because the
/// lookup is a font query a screenful of CJK would otherwise repeat on every
/// cell of every frame.
fn ink_extent(
fn ink_bounds(
cx: &App,
shaped: &gpui::ShapedLine,
text: &str,
font_size: Pixels,
) -> Option<Pixels> {
) -> Option<Bounds<Pixels>> {
let font_id = shaped.runs.first()?.font_id;
let c = text.chars().next()?;
INK_EXTENTS.with(|cache| {
INK_BOUNDS.with(|cache| {
let hit = {
let mut cache = cache.borrow_mut();
if cache.0 != font_size {
@@ -1127,13 +1266,12 @@ fn ink_extent(
if let Some(extent) = hit {
return extent;
}
let extent = cx
let bounds = cx
.text_system()
.typographic_bounds(font_id, font_size, c)
.ok()
.map(|bounds| bounds.origin.x + bounds.size.width);
cache.borrow_mut().1.insert((font_id, c), extent);
extent
.ok();
cache.borrow_mut().1.insert((font_id, c), bounds);
bounds
})
}
@@ -1234,7 +1372,7 @@ fn shape_piece(
.shape_line(piece, font_size, run_buf, force_width)
}
/// Whether [`ink_extent`]'s answer speaks for the whole segment.
/// Whether [`ink_bounds`]'s answer speaks for the whole segment.
///
/// It measures the segment's first character in the run's first face. That is
/// all of a one-character segment and only part of anything longer: a
@@ -1284,6 +1422,9 @@ fn paint_glyphs(
build_font(bold_font.unwrap_or(base_font), true, true),
];
// Resolved only once an icon asks, so a pane without one pays nothing.
let mut primary_ids: [Option<gpui::FontId>; 4] = [None; 4];
let run_buf = &mut [TextRun {
len: 0,
font: base_font.clone(),
@@ -1435,17 +1576,40 @@ fn paint_glyphs(
.shape_line(text.clone(), font_size, run_buf, force_width);
// Measured before the budget is set, because how far a solo glyph
// may reach turns on whether its ink is known at all.
let ink = ink_extent(cx, &shaped, &text, font_size);
let bounds = ink_bounds(cx, &shaped, &text, font_size);
let ink = bounds.map(|b| b.origin.x + b.size.width);
let measured = ink_covers_segment(ink, &text);
let budget = seg_budget(
solo,
ink_covers_segment(ink, &text),
measured,
cells,
has_room_after(row_cells, start, cells),
geom.cell_width,
);
if let Some(ink) = ink {
let scale = fit_scale(ink, budget);
if scale < 1. {
let mut origin = point(x, y);
let mut clip_width = budget;
if let (Some(ink), Some(bounds)) = (ink, bounds) {
// Only a lone icon that a fallback face supplied is fitted:
// the primary's own icons were drawn for its cell already.
let icon = INK_BOUNDS_ARE_INK
&& measured
&& text.chars().next().is_some_and(is_icon)
&& shaped.runs.first().is_some_and(|r| {
r.font_id
!= *primary_ids[face_ix].get_or_insert_with(|| {
window.text_system().resolve_font(&faces[face_ix])
})
});
let (scale, room) = if icon {
let lend = lends_to_icon(row_cells, start, cells);
let height = font_size.min(geom.line_height);
let (scale, spans) = icon_fit(bounds, geom.cell_width, cells, lend, height);
let room = size(geom.cell_width * spans as f32, geom.line_height);
(scale, Some((room, spans == cells)))
} else {
(fit_scale(ink, budget), None)
};
if scale != 1. {
shaped = window.text_system().shape_line(
text.clone(),
font_size * scale,
@@ -1453,18 +1617,16 @@ fn paint_glyphs(
force_width,
);
}
if let Some((room, centre_x)) = room {
origin +=
icon_offset(bounds, scale, room, centre_x, shaped.ascent, shaped.descent);
clip_width = room.width;
}
}
let clip = Bounds::new(point(x, y), size(budget, geom.line_height));
let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height));
window.with_content_mask(Some(ContentMask { bounds: clip }), |window| {
_ = shaped.paint(
point(x, y),
geom.line_height,
TextAlign::Left,
None,
window,
cx,
);
_ = shaped.paint(origin, geom.line_height, TextAlign::Left, None, window, cx);
});
}
}
@@ -1562,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,
@@ -1573,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 {
@@ -1640,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,
@@ -1657,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>() {
@@ -1772,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)),
});
}
}
@@ -2553,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 });
@@ -2675,11 +2924,12 @@ mod tests {
}
#[test]
fn fit_scale_only_shrinks_glyphs_that_overflow_their_budget() {
fn fit_scale_shrinks_what_overflows_and_never_enlarges() {
let budget = px(15.);
assert_eq!(fit_scale(px(19.2), budget), 15. / 19.2);
// Whatever already fits keeps its own size, including a glyph that
// lands exactly on the edge and a run that measured as empty.
// lands exactly on the edge and a run that measured as empty. Making
// a small glyph bigger is `icon_fit`'s rule, not this one's.
assert_eq!(fit_scale(px(15.), budget), 1.);
assert_eq!(fit_scale(px(12.), budget), 1.);
assert_eq!(fit_scale(px(0.), budget), 1.);
@@ -4452,4 +4702,176 @@ mod tests {
Some(&['\u{FE0F}'][..]),
);
}
fn ink(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
Bounds::new(point(px(x), px(y)), size(px(w), px(h)))
}
/// Menlo at 22px: a 13.2px cell in a 30.8px row (line height 1.4).
const CELL: f32 = 13.2;
const EM: f32 = 22.;
fn fit(ink: Bounds<Pixels>, cells: usize, lend: bool) -> (f32, usize) {
icon_fit(ink, px(CELL), cells, lend, px(EM))
}
#[test]
fn only_private_use_code_points_count_as_icons() {
for c in [
'\u{e000}', // first of the BMP Private Use Area
'\u{e0a0}', // Powerline branch
'\u{e0d8}', // first past the Powerline separators
'\u{f179}', // Font Awesome apple
'\u{f8ff}', // last of the BMP Private Use Area
'\u{f0001}', // Material Design icon, Supplementary PUA-A
'\u{10fffd}', // end of Supplementary PUA-B
] {
assert!(is_icon(c), "U+{:04X}", c as u32);
}
// Text a fallback face supplies keeps its own metrics: CJK, emoji,
// Latin, and the drawing characters painted natively anyway.
for c in [
'中',
'あ',
'한',
'\u{1F600}',
'\u{2764}',
'M',
'─',
'█',
'\u{f900}',
] {
assert!(!is_icon(c), "U+{:04X}", c as u32);
}
// Nor do the Powerline separators, which are shapes, not icons.
for c in ['\u{e0b0}', '\u{e0b1}', '\u{e0b3}', '\u{e0d7}'] {
assert!(!is_icon(c), "U+{:04X}", c as u32);
}
}
#[test]
fn a_nerd_font_icon_before_a_space_takes_two_cells_at_full_size() {
// Symbols Nerd Font Mono's apple, as CoreText measures it at 22px: a
// full em tall and 1.4 cells wide. Held to one cell it is shrunk to
// 0.71 of the text's size, which is what #866 saw; with the space
// after it lent, it keeps its whole em.
let apple = ink(1.76, -4.43, 18.48, 22.03);
let (held, cells) = fit(apple, 1, false);
assert_eq!(cells, 1);
assert!((apple.size.width.as_f32() * held - CELL).abs() < 1e-3);
let (lent, cells) = fit(apple, 1, true);
assert_eq!(cells, 2);
assert!((apple.size.height.as_f32() * lent - EM).abs() < 1e-3);
assert!(apple.size.width.as_f32() * lent <= 2. * CELL);
}
#[test]
fn a_narrow_icon_keeps_to_its_own_cell_even_when_the_next_is_free() {
// The branch symbol is an em tall and 0.4em wide: one cell already
// holds it at full height, so borrowing would only push it off
// towards the text after it.
let branch = ink(6.52, -4.4, 8.96, 22.);
assert_eq!(fit(branch, 1, true), (1., 1));
}
#[test]
fn a_small_icon_from_a_narrow_face_grows_to_fill_its_cell() {
// A face with a narrower cell than the primary inks well under it.
let icon = ink(0.5, -1., 8.8, 9.);
let (scale, cells) = fit(icon, 1, false);
assert_eq!(cells, 1);
assert!(scale > 1.);
assert!((icon.size.width.as_f32() * scale - CELL).abs() < 1e-3);
// The same icon two cells wide may use both.
let (two, cells) = fit(icon, 2, false);
assert_eq!(cells, 2);
assert!(two > scale);
}
#[test]
fn a_fitted_icon_is_centred_in_its_room() {
let icon = ink(0.4, -0.6, 5.3, 6.);
let room = size(px(CELL), px(30.8));
let (scale, _) = fit(icon, 1, false);
let (ascent, descent) = (px(20.4 * scale), px(5.2 * scale));
let at = icon_offset(icon, scale, room, true, ascent, descent);
// Spilling into a borrowed cell, it stays where its face put it,
// unless that would take its ink past either edge of the room.
assert_eq!(
icon_offset(icon, scale, room, false, ascent, descent).x,
px(0.)
);
let lent = size(px(2. * CELL), px(30.8));
let overhang = ink(-1., 0., 20., 20.);
assert_eq!(
icon_offset(overhang, 1., lent, false, ascent, descent).x,
px(1.)
);
let pushed = ink(8., 0., 20., 20.);
let x = icon_offset(pushed, 1., lent, false, ascent, descent).x;
assert!((x.as_f32() - (2. * CELL - 28.)).abs() < 1e-4, "{x:?}");
let left = at.x.as_f32() + icon.origin.x.as_f32() * scale;
let right = left + icon.size.width.as_f32() * scale;
assert!((left - (CELL - right)).abs() < 1e-3, "{left}..{right}");
let baseline = at.y + (room.height - ascent - descent) / 2. + ascent;
let top = (baseline - (icon.origin.y + icon.size.height) * scale).as_f32();
let bottom = (baseline - icon.origin.y * scale).as_f32();
assert!((top - (30.8 - bottom)).abs() < 1e-3, "{top}..{bottom}");
}
#[test]
fn icon_fit_leaves_what_is_already_the_right_size_alone() {
// Within 2% of the cell: not worth shaping the glyph again.
assert_eq!(fit(ink(0., 0., 13., 13.), 1, false), (1., 1));
// Nothing measured, nothing to scale by.
assert_eq!(fit(ink(0., 0., 0., 0.), 1, true), (1., 1));
// A dot is small by design and grows no more than the cap.
assert_eq!(fit(ink(1., 2., 2., 2.), 1, false).0, MAX_ICON_GROWTH);
}
fn icon_row(next: RenderCell) -> Vec<RenderCell> {
let icon = RenderCell {
c: '\u{f179}',
..RenderCell::default()
};
vec![icon, next]
}
#[test]
fn a_blank_in_the_same_colour_lends_an_icon_its_cell() {
let blank = RenderCell::default();
assert!(lends_to_icon(&icon_row(blank.clone()), 0, 1));
// Whatever `has_room_after` lends, the end of the row included.
assert!(lends_to_icon(&icon_row(blank.clone())[..1], 0, 1));
// A prompt segment: icon and the space after it on one background.
let bg = Hsla::blue();
let mut row = icon_row(RenderCell {
bg,
draw_bg: true,
..RenderCell::default()
});
row[0].bg = bg;
row[0].draw_bg = true;
assert!(lends_to_icon(&row, 0, 1));
// The next segment's colour, a glyph or a selection do not.
row[1].bg = Hsla::red();
assert!(!lends_to_icon(&row, 0, 1));
let letter = RenderCell {
c: 'x',
..RenderCell::default()
};
assert!(!lends_to_icon(&icon_row(letter), 0, 1));
let selected = RenderCell {
selected: true,
..RenderCell::default()
};
assert!(!lends_to_icon(&icon_row(selected), 0, 1));
// Nor does a coloured blank after an icon on the default background.
let coloured = RenderCell {
bg,
draw_bg: true,
..blank
};
assert!(!lends_to_icon(&icon_row(coloured), 0, 1));
}
}
+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);
+33
View File
@@ -571,6 +571,10 @@ pub struct RemoteTerminal {
pub exited: bool,
size: TermSize,
synced_size: bool,
/// This link reattached to a pane that was already running rather than
/// spawning a fresh one, so whatever it reports first — an agent in the
/// foreground included — predates this client.
reattached: bool,
/// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked
/// alongside `size` so a display-scale change still reaches the child even
/// when the grid dimensions are unchanged.
@@ -1087,6 +1091,7 @@ impl RemoteTerminal {
exited: false,
size,
synced_size: false,
reattached: awaiting_replay,
synced_cell: (0, 0),
resize_sent_at: None,
link,
@@ -1878,6 +1883,11 @@ impl RemoteTerminal {
self.remote_context.lock().ok().and_then(|g| g.clone())
}
/// See the field: an attach to a running pane, not a fresh spawn.
pub fn reattached(&self) -> bool {
self.reattached
}
pub fn at_prompt(&self) -> bool {
self.shell_state
.lock()
@@ -2296,6 +2306,29 @@ impl RemoteTerminal {
.ok()
}
/// The list after switching one forward on or off, or why it was not
/// switched — the daemon's own refusal, which names what is in the way.
pub fn set_forward_enabled(
pane_id: u64,
forward_id: u64,
enabled: bool,
) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
}
.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::ForwardList(list) => Ok(list),
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
other => Err(anyhow::anyhow!(
"unexpected reply to SetForwardEnabled: {other:?}"
)),
}
}
pub fn list_forwards(pane_id: u64) -> Vec<ManagedForward> {
fn query(pane_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
+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:?}");
}
}
}
+537 -101
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, 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;
@@ -140,6 +140,12 @@ pub struct AgentSessionChanged;
impl gpui::EventEmitter<AgentSessionChanged> for TerminalView {}
/// A coding agent started running in this pane: its foreground went from no
/// agent to this one. What quick launch counts as the agent being used.
pub struct AgentDetected(pub crate::core::cli_agent::CLIAgent);
impl gpui::EventEmitter<AgentDetected> for TerminalView {}
/// A file link the user clicked, on its way to whoever can show it. The
/// terminal resolves the path — it is the only thing that knows the pane's
/// directory and host — and the app opens it, because the editor and the file
@@ -263,23 +269,6 @@ fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool {
}
}
/// The cwd a native SSH pane's remote shell reported, for the few readers that
/// only need a name for it and not a host to act on it.
///
/// Such a pane belongs to this machine's daemon, so [`cwd_is_on_host`] rightly
/// turns its paths away from every `Host` call — there is no host to hand them
/// to. But the shell on the far end states them itself (OSC 7), unlike a shell
/// that ssh'd onward from a local prompt, whose directory is only ever a guess.
/// Only an absolute POSIX path counts: that is what a remote sshd's shell
/// reports, and anything else is not a directory worth naming.
fn native_ssh_cwd(
remote: Option<&RemoteContext>,
cwd: Option<std::path::PathBuf>,
) -> Option<std::path::PathBuf> {
remote.filter(|r| r.kind == crate::daemon::protocol::RemoteKind::NativeSsh)?;
cwd.filter(|c| c.to_string_lossy().starts_with('/'))
}
/// Which path dialect a pane's output is written in.
///
/// A pane running on this machine spells paths the way this OS does, and that
@@ -436,6 +425,15 @@ pub struct TerminalView {
running_since: Option<std::time::Instant>,
running_title: String,
running_agent: Option<crate::core::cli_agent::CLIAgent>,
/// The foreground agent as of the last poll, so a new one is reported
/// once ([`AgentDetected`]) rather than on every frame it keeps running.
seen_agent: Option<crate::core::cli_agent::CLIAgent>,
/// Whether an agent appearing here is news. A pane this view spawned is
/// armed from the start; one it reattached to — an app restart, a
/// workspace switched back to — may already be running an agent that was
/// counted when it started, so it arms only once its shell is seen back
/// at the prompt with no agent in front.
agent_detection_armed: bool,
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
last_agent_session: (Option<String>, Option<Vec<String>>),
agent_turn_started: Option<std::time::Instant>,
@@ -460,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
@@ -850,15 +847,7 @@ fn ring_system_bell() -> bool {
}
fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
let text = text.replace("\r\n", "\n");
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend(text.bytes().filter(|&b| b != 0x1b));
bytes.extend_from_slice(b"\x1b[201~");
bytes
} else {
text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
}
tty7_core::core::paste::paste_bytes(text.as_bytes(), bracketed)
}
/// A line the shell can be handed byte for byte, as if it had been typed at its
@@ -1452,6 +1441,7 @@ impl TerminalView {
.map(crate::core::config::gpui_font_features);
let report_mouse = config.mouse_reporting;
let prompt_editor = config.prompt_editor;
let agent_detection_armed = !terminal.reattached();
let mut font = gpui::font(font_family);
font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone()));
if let Some(features) = &font_features {
@@ -1588,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);
@@ -1666,6 +1650,8 @@ impl TerminalView {
running_since: None,
running_title: String::new(),
running_agent: None,
seen_agent: None,
agent_detection_armed,
last_agent_status: None,
last_agent_session: (None, None),
agent_turn_started: None,
@@ -1683,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(),
@@ -1774,6 +1759,35 @@ impl TerminalView {
stated_title(&self.title)
}
/// The title this pane gives its tab: [`Self::stated_title`], unless this
/// is an SSH pane and Settings pins its tab to the host's name instead
/// (#726). The pane's own title is untouched either way — OSC 0/2 keep
/// landing in it, and it is back on the tab the moment the setting is.
///
/// A pane that has ended still says so: the pinned name takes the same
/// suffix the pane's own title would have.
pub(crate) fn tab_title(&self, cx: &App) -> Option<String> {
let pinned = self.ssh_spec.as_deref().and_then(|spec| {
let cfg = cx.try_global::<Config>()?;
crate::ui::ssh_connect::pinned_ssh_title(cfg.ssh_tab_title, spec, &cfg.ssh_profiles)
});
match pinned {
Some(name) if self.terminal.exited => Some(self.ended_title(&name)),
Some(name) => Some(name),
None => self.stated_title().map(str::to_string),
}
}
/// `name` with the suffix that says how this pane ended.
fn ended_title(&self, name: &str) -> String {
let key = if self.workspace().is_some() && !self.terminal.child_exited() {
L10nKey::PaneTitleDisconnected
} else {
L10nKey::PaneTitleProcessExited
};
t_fmt(key, &[("title", name)])
}
/// Sets how opaque the pane wants this terminal painted; the pane leaf
/// calls this every frame while rendering, and the terminal element
/// blends its colours toward the window background during paint (see
@@ -1996,12 +2010,6 @@ impl TerminalView {
self.git_status_cwd.as_deref()
}
/// See [`native_ssh_cwd`]. `None` for every pane that is not a native SSH
/// one — those either have a `git_status_cwd` or have no cwd to name.
pub fn native_ssh_cwd(&self) -> Option<std::path::PathBuf> {
native_ssh_cwd(self.remote_context().as_ref(), self.cwd())
}
/// Plant the cwd the git-status poll would have found. For tests that
/// need a pane to look like it is sitting somewhere known — a real poll
/// needs a live shell reporting a directory, which a quiet test pane has
@@ -2257,17 +2265,7 @@ impl TerminalView {
self.pending_title = None;
// The pane keeps answering to its own name (an SSH pane's
// host, #438) — only the state suffix is localized (#602).
self.title = if self.workspace().is_some() && !self.terminal.child_exited() {
t_fmt(
L10nKey::PaneTitleDisconnected,
&[("title", &self.default_title)],
)
} else {
t_fmt(
L10nKey::PaneTitleProcessExited,
&[("title", &self.default_title)],
)
};
self.title = self.ended_title(&self.default_title);
if self.terminal.child_exited() {
cx.emit(ChildExited);
}
@@ -3774,6 +3772,8 @@ impl TerminalView {
_ => {}
}
self.poll_agent_detection(at_prompt, cx);
let turn_finished = self.poll_agent_status(notify_allowed, window, cx);
let session = self.terminal.agent_session();
@@ -3900,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;
@@ -4034,6 +4033,24 @@ impl TerminalView {
);
}
/// Report an agent that has just started in this pane ([`AgentDetected`]),
/// once per start, and never one that was already running when this view
/// reattached to the pane.
fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context<Self>) {
let agent = self.terminal.foreground_agent();
if agent != self.seen_agent {
self.seen_agent = agent;
if let Some(agent) = agent
&& self.agent_detection_armed
{
cx.emit(AgentDetected(agent));
}
}
if agent.is_none() && at_prompt {
self.agent_detection_armed = true;
}
}
fn poll_agent_status(
&mut self,
notify_allowed: bool,
@@ -4658,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(),
@@ -4670,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();
@@ -4780,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,
@@ -4810,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>) {
@@ -5949,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;
@@ -6540,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);
@@ -7227,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);
}))
@@ -7274,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.
@@ -7328,6 +7402,12 @@ impl Render for TerminalView {
let fork_ready = can_fork
&& view.remote_context().is_none()
&& view.agent_session().is_some_and(|s| s.session_id.is_some());
// `None` over a pane with no agent; otherwise whether it knows
// the command line it was started with.
let launch_argv_known = view.agent().map(|_| {
view.agent_session()
.is_some_and(|s| s.launch_argv.is_some())
});
let menu = match (can_fork, fork_ready) {
(true, true) => {
@@ -7361,6 +7441,21 @@ impl Render for TerminalView {
(false, _) => menu,
};
// Only over a running agent, and only live when the pane knows
// the command line it was started with — there is nothing to
// save otherwise. Beside Fork when both are there.
let menu = match launch_argv_known {
Some(known) => {
let menu = if can_fork { menu } else { menu.separator() };
menu.menu_with_disabled(
t(L10nKey::AppMenuSaveAgentLaunchArgs),
Box::new(SaveAgentLaunchArgs),
!known,
)
}
None => menu,
};
menu.separator()
.menu(t(L10nKey::AppMenuSplitRight), Box::new(SplitRight))
.menu(t(L10nKey::AppMenuSplitDown), Box::new(SplitDown))
@@ -7602,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) {
@@ -7622,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");
@@ -10010,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));
@@ -10019,37 +10153,6 @@ mod tests {
assert!(!cwd_is_on_host(false, false));
}
#[test]
fn only_a_native_ssh_pane_names_its_remote_cwd() {
use super::{RemoteContext, native_ssh_cwd};
use std::path::PathBuf;
let native = RemoteContext {
kind: RemoteKind::NativeSsh,
argv: Vec::new(),
target: "ubuntu@box".into(),
};
let home = || Some(PathBuf::from("/home/ubuntu"));
assert_eq!(native_ssh_cwd(Some(&native), home()), home());
// `ssh` typed at a local prompt: the directory is a guess, not a
// report from the far end.
let typed = RemoteContext {
kind: RemoteKind::Ssh,
..native.clone()
};
assert_eq!(native_ssh_cwd(Some(&typed), home()), None);
assert_eq!(native_ssh_cwd(Some(&wsl_context("Ubuntu")), home()), None);
// A local pane has its own path through `git_status_cwd`.
assert_eq!(native_ssh_cwd(None, home()), None);
assert_eq!(native_ssh_cwd(Some(&native), None), None);
assert_eq!(
native_ssh_cwd(Some(&native), Some(PathBuf::from("~"))),
None,
"only an absolute path names a directory"
);
}
/// Which machine's spelling a pane's paths are read in. Ungated on
/// purpose: the bug this settles was a Windows-only one that hid behind a
/// `#[cfg(unix)]` on the test that covered it.
@@ -14022,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);
@@ -14046,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);
@@ -16262,6 +16365,339 @@ mod gpui_tests {
})
.unwrap();
}
/// Quick launch counts an agent each time one starts. Reattaching to a pane
/// whose agent was already running — every agent tab after an app restart
/// — is not a start, and counting it bumped every agent once per launch of
/// the app. Once that pane's agent exits, the next one is a start again.
#[gpui::test]
fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) {
use crate::core::cli_agent::CLIAgent;
crate::core::config::pin_test_config_dir();
let (window, _root_daemon) = harness(cx);
let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let pane = |reattached: bool, cx: &mut TestAppContext| {
let (pane, daemon) = window
.update(cx, |_, window, cx| {
if reattached {
super::quiet_reattached_test_pane(2, window, cx)
} else {
super::quiet_test_pane(3, window, cx)
}
})
.unwrap();
let seen = detected.clone();
cx.update(|cx| {
cx.subscribe(&pane, move |_, ev: &AgentDetected, _| {
seen.borrow_mut().push(ev.0)
})
.detach()
});
(pane, daemon)
};
let report = |agent: Option<CLIAgent>,
at_prompt: bool,
pane: &Entity<TerminalView>,
daemon: &mut Stream,
cx: &mut TestAppContext| {
DaemonMsg::Agent(agent).encode(daemon).unwrap();
for _ in 0..200 {
if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx));
cx.run_until_parked();
};
let (restored, mut restored_daemon) = pane(true, cx);
report(
Some(CLIAgent::Claude),
false,
&restored,
&mut restored_daemon,
cx,
);
report(
Some(CLIAgent::Claude),
false,
&restored,
&mut restored_daemon,
cx,
);
assert_eq!(
*detected.borrow(),
vec![],
"already running when reattached"
);
report(None, true, &restored, &mut restored_daemon, cx);
report(
Some(CLIAgent::Codex),
false,
&restored,
&mut restored_daemon,
cx,
);
assert_eq!(
*detected.borrow(),
vec![CLIAgent::Codex],
"started after the reattach"
);
let (fresh, mut fresh_daemon) = pane(false, cx);
report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx);
report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx);
assert_eq!(
*detected.borrow(),
vec![CLIAgent::Codex, CLIAgent::Claude],
"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
+533
View File
@@ -0,0 +1,533 @@
//! Quick launch for the coding agents this machine can run (#955).
//!
//! Launching an agent never types into a pane that already exists. It opens a
//! new shell — a tab in the active tab's directory, or a split when the entry
//! point was asked for one — and types the agent's command line into *that*
//! shell once it is there. The agent then runs the way a hand-typed one does:
//! the daemon detects it from the pane's foreground, status and resume work as
//! usual, and quitting it leaves the shell behind.
//!
//! Which agents are offered: on this computer, those whose launch program is
//! on `PATH`. A remote workspace's `PATH` is not something the `Host` trait can
//! answer — it has no way to run a command or read the far shell's
//! environment — so there the list is the agents seen running in that
//! workspace before (`WindowView::seen_agents`). Either way the order is
//! frecency, recorded the way saved SSH hosts' is.
use std::collections::HashMap;
use std::sync::LazyLock;
use gpui::{App, Axis, Context, Window};
use gpui_component::WindowExt as _;
use crate::core::cli_agent::{CLIAgent, launch_program, program_on_path};
use crate::core::config::{Config, ProfileUsage, unix_now};
use crate::core::session::WorkspaceStore;
use crate::ui::app::{SpawnAs, SpawnWhere, Tty7App, join_shell_args};
use crate::ui::i18n::{L10nKey, t_fmt};
use crate::ui::pane::PaneSlot;
/// What the New Tab menu's "Launch Agent…" row types into the search's
/// Terminals tab: every quick-launch row is titled `Agent: {name}` (the same
/// word in every language we ship), so this lands on the list with the cursor
/// ready to narrow it — the same seam `SEARCH_SHELL_QUERY` is for shells.
pub(crate) const SEARCH_AGENT_QUERY: &str = "agent";
/// How the keymap spells "launch this agent": `LaunchAgent:claude`.
const LAUNCH_ACTION_PREFIX: &str = "LaunchAgent:";
/// One keymap action name per agent, in [`CLIAgent::ALL`] order.
pub(crate) fn launch_action_names() -> &'static [(CLIAgent, String)] {
static NAMES: LazyLock<Vec<(CLIAgent, String)>> = LazyLock::new(|| {
CLIAgent::ALL
.into_iter()
.map(|agent| (agent, format!("{LAUNCH_ACTION_PREFIX}{}", agent.slug())))
.collect()
});
&NAMES
}
pub(crate) fn launch_action_name(agent: CLIAgent) -> &'static str {
launch_action_names()
.iter()
.find(|(a, _)| *a == agent)
.map(|(_, name)| name.as_str())
.expect("every agent has a launch action")
}
pub(crate) fn agent_for_launch_action(action: &str) -> Option<CLIAgent> {
action
.strip_prefix(LAUNCH_ACTION_PREFIX)
.and_then(CLIAgent::from_slug)
}
/// The agents whose launch program is on `path`, in [`CLIAgent::ALL`] order.
/// An agent with an `agent_launch` override is looked up by the override's
/// program, since that is what the launch will run.
pub(crate) fn installed_on(
path: &std::ffi::OsStr,
overrides: &HashMap<String, String>,
) -> Vec<CLIAgent> {
CLIAgent::ALL
.into_iter()
.filter(|agent| {
launch_program(&agent.launch_command(overrides))
.is_some_and(|program| program_on_path(&program, path))
})
.collect()
}
/// `agents` most-used-first by frecency score; agents with the same score
/// keep the order they came in.
pub(crate) fn by_frecency(
agents: impl IntoIterator<Item = CLIAgent>,
usage: &HashMap<String, ProfileUsage>,
now: u64,
) -> Vec<CLIAgent> {
let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0);
let mut agents: Vec<CLIAgent> = agents.into_iter().collect();
agents.sort_by(|a, b| {
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
agents
}
/// The agent "New Agent Tab" opens: of `offered`, the one launched or seen
/// most recently; the first offered when none has been used yet.
pub(crate) fn most_recent(
offered: &[CLIAgent],
usage: &HashMap<String, ProfileUsage>,
) -> Option<CLIAgent> {
let last_used = |agent: &CLIAgent| usage.get(agent.slug()).map_or(0, |u| u.last_used);
offered
.iter()
.copied()
.filter(|agent| last_used(agent) > 0)
// `max_by_key` keeps the last of equals; reversed, that is the first.
.rev()
.max_by_key(last_used)
.or_else(|| offered.first().copied())
}
/// The `agent_launch` line "Set Current Launch Args as Default" writes for a
/// pane that `agent` is running in with `argv`.
pub(crate) fn launch_line_to_save(agent: CLIAgent, argv: &[String]) -> Option<String> {
agent
.launch_argv_for_default(argv)
.map(|argv| join_shell_args(&argv))
}
/// Make `line` the command `agent` is launched with from now on.
pub(crate) fn remember_launch_line(cfg: &mut Config, agent: CLIAgent, line: String) {
// One entry per agent, whatever case an existing key was written in.
cfg.agent_launch
.retain(|slug, _| CLIAgent::from_slug(slug) != Some(agent));
cfg.agent_launch.insert(agent.slug().to_string(), line);
}
/// The line that resumes `agent`'s session `session_id`, carrying the flags
/// `agent_launch` gives the agent: a session started with
/// `--dangerously-skip-permissions` comes back with it.
pub(crate) fn resume_line(
agent: CLIAgent,
session_id: &str,
overrides: &HashMap<String, String>,
) -> Option<String> {
let launch = agent.launch_command(overrides);
let argv: Vec<String> = launch.split_whitespace().map(str::to_string).collect();
agent.resume_command(session_id, Some(&argv))
}
/// Type `command` into the shell `slot` holds — now if it is up, or the
/// moment it lands if it is still connecting.
fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) {
match slot {
PaneSlot::Ready(view) => view.read(cx).run_command_line(&command),
PaneSlot::Connecting(pending) => {
pending.update(cx, |pending, _| pending.spawn.run_on_land = Some(command));
}
}
}
impl Tty7App {
/// The agents this window's quick launch offers, most likely first.
pub(crate) fn offered_agents(&self, cx: &App) -> Vec<CLIAgent> {
let cfg = cx.global::<Config>();
let candidates = match WorkspaceStore::all(cx)
.get(self.workspace)
.filter(|view| view.is_remote())
{
Some(view) => view
.seen_agents
.iter()
.filter_map(|slug| CLIAgent::from_slug(slug))
.collect(),
None => installed_on(
&std::env::var_os("PATH").unwrap_or_default(),
&cfg.agent_launch,
),
};
by_frecency(candidates, &cfg.agent_frecency, unix_now())
}
/// Open a new shell for `agent` and start it there.
pub(crate) fn launch_agent(
&mut self,
agent: CLIAgent,
at: SpawnWhere,
window: &mut Window,
cx: &mut Context<Self>,
) {
let command = agent.launch_command(&cx.global::<Config>().agent_launch);
let slot = match at {
SpawnWhere::NewTab => {
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
});
self.new_tab_slot(cwd, None, window, cx)
}
SpawnWhere::Split => {
self.split_slot(Axis::Horizontal, Some(SpawnAs::Shell(None)), window, cx)
}
};
// Nothing opened (the spawn failed, or this workspace cannot host a
// shell right now), and the reason is already on screen. The command
// goes nowhere rather than into whatever pane was focused before.
let Some(slot) = slot else {
log::warn!("no pane opened for {}; not launching it", agent.slug());
return;
};
run_when_ready(&slot, command, cx);
// Recency only: the run is counted when the pane reports the agent
// actually running, so a launch and its detection are one use, not two.
self.update_config(cx, |cfg| {
cfg.agent_frecency
.entry(agent.slug().to_string())
.or_default()
.last_used = unix_now();
});
}
/// Reopen `agent`'s session `session_id` in a new tab in `cwd`, the
/// directory it ran in — an agent keys its history by directory, and
/// `--resume` elsewhere finds nothing. The resume carries the flags the
/// agent is configured to launch with (`agent_launch`), so a session
/// started with them comes back with them.
pub(crate) fn resume_session(
&mut self,
agent: CLIAgent,
session_id: &str,
cwd: Option<std::path::PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(command) = resume_line(agent, session_id, &cx.global::<Config>().agent_launch)
else {
window.push_notification(
t_fmt(
L10nKey::AppSessionNotResumable,
&[("name", agent.display_name())],
),
cx,
);
return;
};
// A directory that is gone cannot hold the session either; say so
// rather than resume into a history the agent will not find.
if let Some(dir) = cwd.as_ref().filter(|dir| !dir.is_dir()) {
window.push_notification(
t_fmt(
L10nKey::AppSessionDirectoryGone,
&[("path", &dir.display().to_string())],
),
cx,
);
return;
}
let Some(slot) = self.new_tab_slot(cwd, None, window, cx) else {
log::warn!("no pane opened to resume {} {session_id}", agent.slug());
return;
};
run_when_ready(&slot, command, cx);
}
/// "New Agent Tab": launch the agent used most recently.
pub(crate) fn new_agent_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let offered = self.offered_agents(cx);
match most_recent(&offered, &cx.global::<Config>().agent_frecency) {
Some(agent) => self.launch_agent(agent, SpawnWhere::NewTab, window, cx),
None => {
let remote = WorkspaceStore::remote_ref(cx, self.workspace).is_some();
window.push_notification(
crate::ui::i18n::t(if remote {
L10nKey::AppNoAgentSeenHere
} else {
L10nKey::AppNoAgentOnPath
}),
cx,
);
}
}
}
/// A pane in this window started running `agent`.
pub(crate) fn note_agent_detected(&mut self, agent: CLIAgent, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
let entry = cfg
.agent_frecency
.entry(agent.slug().to_string())
.or_default();
entry.count = entry.count.saturating_add(1);
entry.last_used = unix_now();
});
if WorkspaceStore::remote_ref(cx, self.workspace).is_some() {
WorkspaceStore::record_agent_seen(cx, self.workspace, agent);
}
}
/// "Set Current Launch Args as Default": make the focused pane's agent
/// launch the way this one was started.
pub(crate) fn save_agent_launch_args(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(view) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
let view = view.read(cx);
let Some(agent) = view.agent() else {
window.push_notification(crate::ui::i18n::t(L10nKey::AppPaneNoCodingAgent), cx);
return;
};
let name = agent.display_name();
let Some(line) = view
.agent_session()
.and_then(|s| s.launch_argv)
.and_then(|argv| launch_line_to_save(agent, &argv))
else {
window.push_notification(
t_fmt(L10nKey::AppAgentLaunchArgsUnknown, &[("name", name)]),
cx,
);
return;
};
let saved = line.clone();
self.update_config(cx, |cfg| remember_launch_line(cfg, agent, saved));
window.push_notification(
t_fmt(
L10nKey::AppAgentLaunchSaved,
&[("name", name), ("command", &line)],
),
cx,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(pairs: &[(&str, u32, u64)]) -> HashMap<String, ProfileUsage> {
pairs
.iter()
.map(|(slug, count, last_used)| {
(
slug.to_string(),
ProfileUsage {
count: *count,
last_used: *last_used,
},
)
})
.collect()
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
const NOW: u64 = 1_800_000_000;
const DAY: u64 = 86_400;
#[test]
fn agents_are_offered_most_used_first() {
let used = usage(&[
("codex", 3, NOW - DAY),
("claude", 12, NOW - 2 * DAY),
// Used a lot, but so long ago that it has decayed below Codex.
("gemini", 20, NOW - 200 * DAY),
]);
let offered = by_frecency(
[
CLIAgent::Aider,
CLIAgent::Gemini,
CLIAgent::Codex,
CLIAgent::Claude,
CLIAgent::Amp,
],
&used,
NOW,
);
assert_eq!(
offered,
vec![
CLIAgent::Claude,
CLIAgent::Codex,
CLIAgent::Gemini,
// Never used: they keep the order they were found in.
CLIAgent::Aider,
CLIAgent::Amp,
]
);
}
#[test]
fn new_agent_tab_opens_the_most_recently_used_agent() {
let offered = [CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini];
// Claude is used more, but Codex was the last one run or launched.
let used = usage(&[
("claude", 40, NOW - DAY),
("codex", 1, NOW - 60),
("aider", 5, NOW),
]);
assert_eq!(most_recent(&offered, &used), Some(CLIAgent::Codex));
// Aider was more recent still, but it is not offered here.
// Nothing used yet: the first one offered.
assert_eq!(
most_recent(&offered, &HashMap::new()),
Some(CLIAgent::Claude)
);
assert_eq!(most_recent(&[], &used), None);
// A launch that only stamped recency counts as the most recent.
let launched = usage(&[("claude", 40, NOW - DAY), ("gemini", 0, NOW)]);
assert_eq!(most_recent(&offered, &launched), Some(CLIAgent::Gemini));
}
#[test]
fn saved_launch_args_survive_a_round_trip_through_the_command_line() {
let run = argv(&[
"/opt/homebrew/bin/claude",
"--append-system-prompt",
"be terse, don't guess",
"--add-dir",
"/Users/me/My Projects",
"--model",
"opus",
"--resume",
"abc-123",
]);
let line = launch_line_to_save(CLIAgent::Claude, &run).unwrap();
assert_eq!(
crate::ui::app::split_shell_args(&line).unwrap(),
argv(&[
"claude",
"--append-system-prompt",
"be terse, don't guess",
"--add-dir",
"/Users/me/My Projects",
"--model",
"opus",
])
);
// And the saved line is recognised as the agent it launches.
assert_eq!(
CLIAgent::detect_from_command_with(&line, &HashMap::new()),
Some(CLIAgent::Claude)
);
}
#[test]
fn set_current_launch_args_as_default_writes_the_agent_launch_entry() {
let mut cfg = Config::default();
cfg.agent_launch
.insert("Claude".to_string(), "claude --old".to_string());
cfg.agent_launch
.insert("codex".to_string(), "codex --model o3".to_string());
let line = launch_line_to_save(
CLIAgent::Claude,
&argv(&["claude", "--dangerously-skip-permissions", "--continue"]),
)
.unwrap();
remember_launch_line(&mut cfg, CLIAgent::Claude, line);
let json: serde_json::Value = serde_json::to_value(&cfg.0).unwrap();
assert_eq!(
json["agent_launch"],
serde_json::json!({
"claude": "claude --dangerously-skip-permissions",
"codex": "codex --model o3",
})
);
assert_eq!(
CLIAgent::Claude.launch_command(&cfg.agent_launch),
"claude --dangerously-skip-permissions"
);
}
#[test]
fn every_agent_has_a_bindable_launch_action() {
for agent in CLIAgent::ALL {
let name = launch_action_name(agent);
assert_eq!(agent_for_launch_action(name), Some(agent), "{name}");
}
assert_eq!(agent_for_launch_action("LaunchAgent:nobody"), None);
assert_eq!(agent_for_launch_action("NewAgentTab"), None);
}
#[test]
fn only_agents_whose_launch_program_is_on_path_are_offered() {
let dir = tempfile::TempDir::new().unwrap();
for name in ["codex", "cc"] {
let path = dir.path().join(name);
std::fs::write(&path, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let path = dir.path().as_os_str();
assert_eq!(installed_on(path, &HashMap::new()), vec![CLIAgent::Codex]);
// With an override, the override's program is what has to be there.
let overrides: HashMap<String, String> = [
("claude".to_string(), "cc --fast".to_string()),
("codex".to_string(), "codex-nightly".to_string()),
]
.into_iter()
.collect();
assert_eq!(installed_on(path, &overrides), vec![CLIAgent::Claude]);
}
#[test]
fn a_resume_carries_the_configured_launch_flags() {
let none = HashMap::new();
assert_eq!(
resume_line(CLIAgent::Claude, "abc-1", &none).as_deref(),
Some("claude --resume abc-1")
);
assert_eq!(
resume_line(CLIAgent::Codex, "0199-x", &none).as_deref(),
Some("codex resume 0199-x")
);
let flagged = HashMap::from([(
"claude".to_string(),
"claude --dangerously-skip-permissions".to_string(),
)]);
assert_eq!(
resume_line(CLIAgent::Claude, "abc-1", &flagged).as_deref(),
Some("claude --dangerously-skip-permissions --resume abc-1")
);
}
#[test]
fn an_id_that_could_smuggle_a_command_is_not_resumed() {
let none = HashMap::new();
assert_eq!(resume_line(CLIAgent::Claude, "x; rm -rf ~", &none), None);
assert_eq!(resume_line(CLIAgent::Claude, "", &none), None);
}
}
+1115 -145
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -47,6 +47,9 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"),
"icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"),
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
"icons/power.svg" => include_bytes!("../../assets/icons/power.svg"),
"icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"),
"icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"),
"icons/settings/about.svg" => include_bytes!("../../assets/icons/settings/about.svg"),
"icons/settings/appearance.svg" => {
include_bytes!("../../assets/icons/settings/appearance.svg")
@@ -144,6 +147,8 @@ mod tests {
"icons/git-branch.svg",
"icons/git-sync.svg",
"icons/git-commit.svg",
"icons/list-tree.svg",
"icons/list-flat.svg",
] {
assert!(
Assets.load(path).unwrap().is_some(),
+1
View File
@@ -3449,6 +3449,7 @@ mod selection_gpui_tests {
agent: None,
agent_session_id: None,
agent_launch_argv: None,
run_on_land: None,
owner: None,
font_size: 14.0,
},
+4 -3
View File
@@ -569,6 +569,7 @@ mod gpui_tests {
agent: None,
agent_session_id: None,
agent_launch_argv: None,
run_on_land: None,
owner: None,
font_size: 14.0,
},
@@ -721,16 +722,16 @@ mod gpui_tests {
#[gpui::test]
fn the_palette_offers_the_active_tabs_layout_not_the_configs(cx: &mut TestAppContext) {
use crate::ui::i18n::L10nKey;
use crate::ui::palette::CommandKind;
use crate::ui::search::CommandKind;
let (app, mut vcx) = window_with(cx, 1440., 2);
crate::ui::i18n::set_locale("en");
let row = |app: &Entity<Tty7App>, vcx: &mut VisualTestContext| {
app.update_in(vcx, |app, window, cx| {
app.palette_commands(window, cx)
app.search_actions(window, cx)
.into_iter()
.find(|c| c.kind == CommandKind::ToggleDocumentFill)
.expect("the palette offers the fill toggle")
.expect("the search offers the fill toggle")
.title
})
};
+22 -3
View File
@@ -2009,7 +2009,6 @@ impl Tty7App {
let show_hidden = self.file_tree.show_hidden;
let paths_are_local = self.spawn_host(cx).is_local();
move |menu, _window, cx| {
let danger = cx.theme().danger;
Self::tree_row_context_menu(
menu,
&path,
@@ -2017,8 +2016,8 @@ impl Tty7App {
is_root,
show_hidden,
paths_are_local,
danger,
&app,
cx,
)
}
});
@@ -2059,9 +2058,16 @@ impl Tty7App {
// here — silently opening nothing, or the wrong thing if a local path
// happens to collide.
paths_are_local: bool,
danger: gpui::Hsla,
app: &gpui::WeakEntity<Self>,
cx: &App,
) -> PopupMenu {
let danger = cx.theme().danger;
// Whether "Pin as Group" has a sidebar to put the group in: with the
// tabs along the top there is nowhere to show one. Offered on a remote
// workspace too, unlike the file manager above — the tree and a pinned
// folder are both on the workspace's own host.
let groups_shown = cx.global::<crate::core::config::Config>().tab_bar_position
== crate::core::config::TabBarPosition::Left;
let mut menu = menu.min_w(px(200.));
let p = path.to_path_buf();
@@ -2086,6 +2092,19 @@ impl Tty7App {
}
}),
);
if groups_shown {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextPinAsGroup)).on_click({
let app = app.clone();
let p = p.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| {
this.pin_folder(p.clone(), cx);
});
}
}),
);
}
}
menu = menu
.item(
+110 -5
View File
@@ -3,6 +3,7 @@ use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
use crate::core::ssh_profile::ForwardRule;
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule};
use crate::terminal::view::TerminalView;
use crate::ui::app::Tty7App;
@@ -70,6 +71,7 @@ impl ForwardFields {
target_host,
target_port,
description: (!description.is_empty()).then(|| description.to_string()),
enabled: true,
})
}
@@ -93,6 +95,7 @@ impl ForwardFields {
// left over from a trip through the advanced form is not something
// this rule was given.
description: None,
enabled: true,
})
}
@@ -140,9 +143,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule {
target_host: forward.target_host.clone(),
target_port: forward.target_port,
description: forward.description.clone(),
enabled: forward.enabled,
}
}
/// Which of a saved host's rules a live forward was opened from, among
/// those not already switched to `enabled` — the one a switch in the panel
/// has to be written back to.
///
/// Matched on the whole mapping, since a live forward carries no reference to
/// the rule it came from. A saved rule always names its port, so the port the
/// forward bound is the one the rule asked for. A forward added in the panel
/// matches nothing and stays as temporary as it always was.
pub(crate) fn saved_rule_index(
rules: &[ForwardRule],
forward: &ManagedForward,
enabled: bool,
) -> Option<usize> {
rules.iter().position(|r| {
let saved = crate::ui::ssh_connect::map_forward(r);
saved.enabled != enabled
&& saved.kind == forward.kind
&& saved.bind_host == forward.bind_host
&& saved.bind_port == forward.bind_port
&& saved.target_host == forward.target_host
&& saved.target_port == forward.target_port
})
}
impl Tty7App {
pub(crate) fn render_ssh_status_strip(
&self,
@@ -258,18 +286,23 @@ impl Tty7App {
SshForwardKind::Remote => "R",
SshForwardKind::Dynamic => "D",
};
let errored = matches!(forward.status, ForwardStatus::Error(_));
// A switched-off forward has an error status only because it binds
// nothing; it is drawn faded, not red.
let off = !forward.enabled;
let errored = !off && matches!(forward.status, ForwardStatus::Error(_));
let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") {
forward.bind_port.to_string()
} else {
format!("{}:{}", forward.bind_host, forward.bind_port)
};
let mapping = match forward.kind {
SshForwardKind::Dynamic => "SOCKS".to_string(),
_ => format!("→ {}:{}", forward.target_host, forward.target_port),
};
let tail = match &forward.status {
_ if off => mapping,
ForwardStatus::Error(msg) => msg.clone(),
ForwardStatus::Listening => match forward.kind {
SshForwardKind::Dynamic => "SOCKS".to_string(),
_ => format!("→ {}:{}", forward.target_host, forward.target_port),
},
ForwardStatus::Listening => mapping,
};
let pane_id = forward.pane_id;
let forward_id = forward.id;
@@ -304,6 +337,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.gap(px(1.))
.when(off, |col| col.opacity(0.5))
.child(
h_flex()
.items_center()
@@ -335,6 +369,37 @@ impl Tty7App {
)
}),
)
// Always shown, unlike Remove beside it: whether a forward is on is
// state to read at a glance, not an action to go looking for.
.child({
let tip = if off {
t(L10nKey::ForwardTooltipTurnOn)
} else {
t(L10nKey::ForwardTooltipTurnOff)
};
div()
.id(("panel-forward-enabled-tip", forward_id as usize))
.flex_shrink_0()
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(tip).build(window, cx)
})
.child(
crate::ui::settings::kit::switch((
"panel-forward-enabled",
forward_id as usize,
))
.checked(forward.enabled)
.small()
.on_click(cx.listener(
move |this, on: &bool, window, cx| {
this.set_managed_forward_enabled(
pane_id, forward_id, *on, window, cx,
)
},
)),
)
})
.child(
div()
.flex_shrink_0()
@@ -600,6 +665,7 @@ mod tests {
target_port: 80,
description: Some("the staging box".to_string()),
status: ForwardStatus::Listening,
enabled: true,
}
}
@@ -732,4 +798,43 @@ mod tests {
"nothing was added, so there is nothing to point at"
);
}
#[test]
fn a_switched_off_forward_keeps_its_switch_through_an_edit() {
let mut off = managed(3, 8080);
off.enabled = false;
assert!(!rule_of(&off).enabled);
assert!(rule_of(&managed(3, 8080)).enabled);
}
/// The panel's switch is written back to the saved host only for the rule
/// the forward was opened from, and a rule added in the panel — which no
/// saved rule describes — is left as temporary as it always was.
#[test]
fn a_switch_is_written_back_to_the_saved_rule_it_came_from() {
use crate::core::ssh_profile::{ForwardKind, HostPort};
let saved = |target: &str, enabled: bool| ForwardRule {
kind: ForwardKind::Local,
bind: HostPort::new("127.0.0.1", 8080),
target: HostPort::new(target, 80),
description: "the staging box".to_string(),
enabled,
};
let rules = vec![saved("10.0.0.9", true), saved("10.0.0.5", true)];
let live = managed(3, 8080);
assert_eq!(saved_rule_index(&rules, &live, false), Some(1));
assert_eq!(
saved_rule_index(&rules, &live, true),
None,
"already on: nothing to write"
);
let mut elsewhere = managed(3, 8081);
elsewhere.description = None;
assert_eq!(saved_rule_index(&rules, &elsewhere, false), None);
let mut remote = managed(3, 8080);
remote.kind = SshForwardKind::Remote;
assert_eq!(saved_rule_index(&rules, &remote, false), None);
}
}
+29 -8
View File
@@ -171,7 +171,7 @@ fn home_shortcut_label(action: &str, closed: Option<&str>) -> String {
"NewTab" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeNewTab),
"ReopenClosedTab" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeReopenClosedTab),
"ToggleSwitcher" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSwitchWorkspace),
"TogglePalette" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeCommandPalette),
"TogglePalette" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSearchEverywhere),
"SplitRight" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSplitRight),
"SplitDown" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSplitDown),
"OpenSettings" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSettings),
@@ -513,7 +513,10 @@ mod tests {
let tab = SessionTab {
name: Some("build".into()),
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("build"));
@@ -524,7 +527,10 @@ mod tests {
let tab = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
@@ -532,7 +538,10 @@ mod tests {
let tab = SessionTab {
name: Some(" ".into()),
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
@@ -543,7 +552,10 @@ mod tests {
let tab = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: SessionPane::Split {
axis: crate::core::session::SessionAxis::Horizontal,
ratio: 0.5,
@@ -559,14 +571,20 @@ mod tests {
let unnamed = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(None),
};
assert_eq!(closed_tab_label(&unnamed), None);
let root = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/")),
};
assert_eq!(closed_tab_label(&root), None);
@@ -577,7 +595,10 @@ mod tests {
let tab = SessionTab {
name: Some("a".repeat(40)),
tree_id: None,
sidebar_group: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(None),
};
let label = closed_tab_label(&tab).unwrap();
+99 -14
View File
@@ -93,8 +93,27 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SearchThemes => "Search themes…",
L10nKey::SearchSettings => "Search settings…",
L10nKey::FilterHosts => "Filter hosts…",
L10nKey::SearchCommandsOrHost => "Search or type user@host to connect…",
L10nKey::SearchTheme => "Search…",
L10nKey::SearchTabAll => "All",
L10nKey::SearchTabActions => "Actions",
L10nKey::SearchTabTerminals => "Terminals",
L10nKey::SearchTabHosts => "Hosts",
L10nKey::SearchPlaceholderAll => "Search actions, terminals and hosts…",
L10nKey::SearchPlaceholderActions => "Search actions…",
L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…",
L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…",
L10nKey::SearchTabSessions => "Sessions",
L10nKey::SearchPlaceholderSessions => "Search past agent sessions…",
L10nKey::SearchSessionsEmptyHint => {
"Claude Code and Codex sessions on this computer are listed here."
}
L10nKey::SearchSectionSessionsHere => "In {dir}",
L10nKey::SearchSectionSessionsRecent => "Recent",
L10nKey::AppSessionNotResumable => "{name} cannot resume a session by id.",
L10nKey::AppSessionDirectoryGone => "The session's directory no longer exists: {path}",
L10nKey::SearchMoreIn => "{count} more in {tab}",
L10nKey::SearchNoResults => "No results",
L10nKey::SearchSectionNewTerminal => "New Terminal",
L10nKey::SearchWorkspacesAndMachines => "Search workspaces, tabs, and machines…",
L10nKey::SearchFonts => "Search fonts…",
L10nKey::SearchFind => "Find…",
@@ -105,7 +124,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::HomeNewTab => "New Tab",
L10nKey::HomeReopenClosedTab => "Reopen Closed Tab",
L10nKey::HomeSwitchWorkspace => "Switch Workspace…",
L10nKey::HomeCommandPalette => "Command Palette…",
L10nKey::HomeSearchEverywhere => "Search Everywhere…",
L10nKey::HomeSplitRight => "Split Right",
L10nKey::HomeSplitDown => "Split Down",
L10nKey::HomeSettings => "Settings…",
@@ -116,7 +135,6 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::Refreshing => "refreshing…",
L10nKey::Binary => "binary",
L10nKey::Delete => "Delete",
L10nKey::NoMatchingCommands => "No matching commands",
L10nKey::ConnectSshHint => "Type user@host to connect over SSH instead.",
L10nKey::EditHint => "edit",
L10nKey::OpenFileFromTree => "Open a file from the file tree",
@@ -193,6 +211,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",
@@ -627,12 +649,19 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsNewTabPositionDesc => "Where a freshly opened tab is inserted.",
L10nKey::SettingsTabBarPosition => "Tab bar position",
L10nKey::SettingsTabBarPositionDesc => "A strip on top or a sidebar on the left.",
L10nKey::SettingsSidebarGrouping => "Sidebar grouping",
L10nKey::SettingsSidebarGrouping => "Auto grouping",
L10nKey::SettingsSidebarGroupingDesc => {
"Group tabs by git repository; others go under Scratch."
"Group unpinned tabs by git repository, SSH tabs by host."
}
L10nKey::SettingsDiffPreviewFromCounts => "Open diff preview from sidebar counts",
L10nKey::SettingsDiffPreviewFromCountsDesc => "Click a row's +N −N to open its diff.",
L10nKey::SettingsSshTabTitle => "SSH tab title",
L10nKey::SettingsSshTabTitleDesc => {
"Dynamic follows the remote title; the others pin it to the host."
}
L10nKey::SettingsSshTabTitleDynamic => "Dynamic",
L10nKey::SettingsSshTabTitleProfileName => "Profile name",
L10nKey::SettingsSshTabTitleHostname => "Hostname",
L10nKey::DocumentDock => "Dock beside terminal",
L10nKey::DocumentFill => "Fill window",
L10nKey::SettingsNotifications => "Notifications",
@@ -655,9 +684,6 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsAtEnd => "At end",
L10nKey::SettingsTop => "Top",
L10nKey::SettingsLeft => "Left",
L10nKey::SettingsByRepo => "By repo",
L10nKey::SettingsByRepoOrFolder => "By repo or folder",
L10nKey::SettingsFlat => "Flat",
L10nKey::SettingsPreset => "Preset",
L10nKey::SettingsPresetDesc => "tmux maps pane and tab actions to prefix keys (Ctrl-B C).",
L10nKey::SettingsPrefix => "Prefix",
@@ -806,6 +832,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"
@@ -838,6 +866,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"
}
@@ -912,6 +943,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"
@@ -946,7 +983,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"tray menu bar status item agent attention system icon"
}
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"tabs group repo repository git scratch header sidebar flat folder directory cwd"
"tabs group grouping auto repo repository git ssh host pinned pin ungrouped header sidebar flat folder"
}
L10nKey::SettingsSearchSshTabTitleKeywords => {
"ssh tab title name host hostname profile alias pin fixed osc remote"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"double click word url path select semantic bracket email"
@@ -1026,6 +1066,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SshEditProfile => "Edit connection…",
L10nKey::ForwardTooltipAdd => "Add forward",
L10nKey::ForwardTooltipRemove => "Remove",
L10nKey::ForwardTooltipTurnOn => "Turn on",
L10nKey::ForwardTooltipTurnOff => "Turn off — the rule is kept",
L10nKey::ForwardSwitchFailed => "Couldn't switch the forward — {error}",
L10nKey::SettingsFwdEnabled => "Open this rule with the connection",
L10nKey::ForwardLocal => "Local",
L10nKey::ForwardRemote => "Remote",
L10nKey::ForwardDynamic => "Dynamic",
@@ -1061,6 +1105,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
}
L10nKey::FileTreeContextOpen => "Open",
L10nKey::FileTreeContextCdHere => "cd Here",
L10nKey::FileTreeContextPinAsGroup => "Pin as Group",
L10nKey::FileTreeContextInsertPath => "Insert Path in Terminal",
L10nKey::FileTreeContextAttachAgent => "Attach to Agent",
L10nKey::FileTreeContextNewFile => "New File",
@@ -1221,6 +1266,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::ScmShowMore => "Show more",
L10nKey::ScmShowLess => "Show less",
L10nKey::ScmCommitNotFound => "This commit is not in this repository.",
L10nKey::ScmFilterChanges => "Filter changed files…",
L10nKey::ScmViewAsTree => "View as Tree",
L10nKey::ScmViewAsList => "View as List",
L10nKey::ScmNoMatchingChanges => "No changed files match the filter.",
L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.",
L10nKey::ScmOpenChanges => "Open Changes",
L10nKey::ScmDiscardAllConfirm => {
@@ -1492,6 +1541,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdNewWindow => "New Window",
L10nKey::CmdNewWorktreeTab => "New Worktree Tab…",
L10nKey::CmdNewWorktreeTabSubtitle => "isolated checkout on a fresh branch",
L10nKey::CmdNewGroup => "New Group",
L10nKey::CmdNewGroupSubtitle => "an empty pinned group in the sidebar",
L10nKey::CmdOpenFolderAsGroup => "Open Folder as Group…",
L10nKey::CmdOpenFolderAsGroupSubtitle => "pin a folder; tabs opened in it join",
L10nKey::CmdRenameTab => "Rename Tab…",
L10nKey::CmdSplitRight => "Split Right",
L10nKey::CmdSplitDown => "Split Down",
@@ -1510,14 +1563,22 @@ 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",
L10nKey::CmdCopySessionId => "Copy Session ID",
L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id",
L10nKey::CmdNewAgentTab => "New Agent Tab",
L10nKey::CmdNewAgentTabSubtitle => "open the coding agent you used last in a new tab",
L10nKey::CmdForkSession => "Fork Session",
L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab",
L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread",
L10nKey::CmdHibernateTab => "Hibernate Tab",
L10nKey::CmdHibernateTabSubtitle => {
"stop its processes to free memory; selecting it wakes it"
}
L10nKey::CmdClosePaneTab => "Close Pane / Tab",
L10nKey::CmdCloseWindow => "Close Window",
L10nKey::CmdCloseWindowSubtitle => "shells keep running",
@@ -1675,8 +1736,16 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::AppNoUncommittedChanges => {
"No uncommitted changes in {cwd} (or not a git repository)."
}
L10nKey::AppCmdSshProfileTitle => "SSH: {title}",
L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}",
L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}",
L10nKey::AppNoAgentOnPath => "No coding agent was found on this machine's PATH",
L10nKey::AppNoAgentSeenHere => {
"No coding agent has run in this workspace yet — start one by hand once and it will be offered here"
}
L10nKey::AppAgentLaunchSaved => "{name} will now launch as: {command}",
L10nKey::AppAgentLaunchArgsUnknown => {
"{name} did not report the arguments it was started with"
}
L10nKey::AppCmdShellTitle => "Shell: {title}",
L10nKey::AppPlaceholderDescription => "description",
L10nKey::AppPlaceholderSshQuickConnect => "user@host or user@host:port",
L10nKey::AppPlaceholderLoginShell => "login shell",
@@ -1787,6 +1856,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}",
@@ -1834,6 +1904,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory",
L10nKey::AppMenuCopySessionId => "Copy Session ID",
L10nKey::AppMenuForkSession => "Fork Session",
L10nKey::AppMenuSaveAgentLaunchArgs => "Set Current Launch Args as Default",
L10nKey::AppMenuClosePaneTab => "Close",
L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs",
L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right",
@@ -1850,7 +1921,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::AppMenuFind => "Find…",
L10nKey::AppMenuFindNext => "Find Next",
L10nKey::AppMenuFindPrevious => "Find Previous",
L10nKey::AppMenuCommandPalette => "Command Palette…",
L10nKey::AppMenuSearchEverywhere => "Search Everywhere…",
L10nKey::AppMenuIncreaseFontSize => "Increase Font Size",
L10nKey::AppMenuDecreaseFontSize => "Decrease Font Size",
L10nKey::AppMenuResetFontSize => "Reset Font Size",
@@ -1863,6 +1934,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",
@@ -1892,15 +1964,24 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::TabMenuLocalShells => "Local",
L10nKey::TabMenuAddHost => "Add SSH Host…",
L10nKey::TabMenuAllHosts => "All SSH Hosts…",
L10nKey::TabMenuLaunchAgent => "Launch Agent…",
L10nKey::TabMenuOtherShells => "Other Shells…",
L10nKey::TabMenuSplitHint => "Hold {key} to split",
L10nKey::TabUnnamedShell => "Shell {n}",
L10nKey::ShellDefault => "default",
L10nKey::SidebarScratchGroup => "Scratch",
L10nKey::SidebarUngroupedGroup => "Ungrouped",
L10nKey::SidebarMoveToGroup => "Move to Group",
L10nKey::SidebarNewGroup => "New Group…",
L10nKey::SidebarAutoGroup => "Group Automatically",
L10nKey::SidebarNewGroupName => "New Group",
L10nKey::SidebarRenameGroup => "Rename Group",
L10nKey::SidebarPinGroup => "Pin Group",
L10nKey::SidebarUnpinGroup => "Unpin",
L10nKey::SidebarGroupNewTab => "New Tab",
L10nKey::SidebarSetGroupFolder => "Set Folder…",
L10nKey::SidebarUseCurrentTabFolder => "Use Current Tab's Folder",
L10nKey::SidebarClearGroupFolder => "Clear Folder",
L10nKey::SidebarDeleteGroup => "Delete Group",
L10nKey::SidebarDropToPin => "Drop here to pin",
L10nKey::TabContextCloseTab => "Close Tab",
L10nKey::TabContextCloseTabsBelow => "Close Tabs Below",
L10nKey::AppAgentHooksOpFailed => "Failed: {error}",
@@ -1911,6 +1992,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsNoneLower => "none",
L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool",
L10nKey::TabContextMarkUnread => "Mark as Unread",
L10nKey::TabContextHibernate => "Hibernate",
L10nKey::TabContextWake => "Wake",
L10nKey::TabTooltipAsleep => "Hibernated — select to wake",
L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started",
}
}
+97 -14
View File
@@ -94,8 +94,27 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchThemes => "テーマを検索…",
L10nKey::SearchSettings => "設定を検索…",
L10nKey::FilterHosts => "ホストを絞り込み…",
L10nKey::SearchCommandsOrHost => "コマンドを検索するか、user@host を入力して接続…",
L10nKey::SearchTheme => "検索…",
L10nKey::SearchTabAll => "すべて",
L10nKey::SearchTabActions => "アクション",
L10nKey::SearchTabTerminals => "ターミナル",
L10nKey::SearchTabHosts => "ホスト",
L10nKey::SearchPlaceholderAll => "アクション、ターミナル、ホストを検索…",
L10nKey::SearchPlaceholderActions => "アクションを検索…",
L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…",
L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…",
L10nKey::SearchTabSessions => "セッション",
L10nKey::SearchPlaceholderSessions => "過去のエージェントセッションを検索…",
L10nKey::SearchSessionsEmptyHint => {
"このコンピューター上の Claude Code と Codex のセッションがここに表示されます。"
}
L10nKey::SearchSectionSessionsHere => "{dir} 内",
L10nKey::SearchSectionSessionsRecent => "最近",
L10nKey::AppSessionNotResumable => "{name} は ID によるセッション再開に対応していません。",
L10nKey::AppSessionDirectoryGone => "セッションのディレクトリが存在しません: {path}",
L10nKey::SearchMoreIn => "{tab} にさらに {count} 件",
L10nKey::SearchNoResults => "結果がありません",
L10nKey::SearchSectionNewTerminal => "新しいターミナル",
L10nKey::SearchWorkspacesAndMachines => "ワークスペース、タブ、マシンを検索…",
L10nKey::SearchFonts => "フォントを検索…",
L10nKey::SearchFind => "検索…",
@@ -106,7 +125,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::HomeNewTab => "新規タブ",
L10nKey::HomeReopenClosedTab => "閉じたタブをもう一度開く",
L10nKey::HomeSwitchWorkspace => "ワークスペースを切り替える…",
L10nKey::HomeCommandPalette => "コマンドパレット…",
L10nKey::HomeSearchEverywhere => "どこでも検索…",
L10nKey::HomeSplitRight => "右に分割",
L10nKey::HomeSplitDown => "下に分割",
L10nKey::HomeSettings => "設定…",
@@ -117,7 +136,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::Refreshing => "更新中…",
L10nKey::Binary => "バイナリファイル",
L10nKey::Delete => "削除",
L10nKey::NoMatchingCommands => "一致するコマンドがありません",
L10nKey::ConnectSshHint => "SSH で接続するには user@host を入力してください",
L10nKey::EditHint => "編集",
L10nKey::OpenFileFromTree => "ファイルツリーからファイルを開く",
@@ -198,6 +216,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsCursor => "カーソル",
L10nKey::SettingsCursorShape => "カーソルの形状",
L10nKey::SettingsCursorShapeDesc => "ターミナルカーソルの描画方法",
L10nKey::SettingsPromptCursorShape => "プロンプトのカーソル形状",
L10nKey::SettingsPromptCursorShapeDesc => {
"シェルプロンプトでのカーソル。「追従」は上のカーソル形状をどこでも使う"
}
L10nKey::SettingsCursorBlink => "カーソルの点滅",
L10nKey::SettingsCursorBlinkDesc => {
"ターミナルがフォーカスされている間、カーソルを点滅させる"
@@ -622,10 +644,19 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsTabBarPositionDesc => {
"タブを上部の横一列または左側の縦サイドバーとして表示"
}
L10nKey::SettingsSidebarGrouping => "サイドバーのグループ化",
L10nKey::SettingsSidebarGroupingDesc => "git リポジトリごとにまとめ、他はスクラッチへ",
L10nKey::SettingsSidebarGrouping => "自動グループ化",
L10nKey::SettingsSidebarGroupingDesc => {
"固定していないタブを git リポジトリごと、SSH タブをホストごとにまとめます"
}
L10nKey::SettingsDiffPreviewFromCounts => "サイドバーのカウントから Diff プレビューを開く",
L10nKey::SettingsDiffPreviewFromCountsDesc => "行の +N −N をクリックして Diff を開きます",
L10nKey::SettingsSshTabTitle => "SSH タブのタイトル",
L10nKey::SettingsSshTabTitleDesc => {
"「動的」はリモートのタイトルに従い、ほかはホストに固定します"
}
L10nKey::SettingsSshTabTitleDynamic => "動的",
L10nKey::SettingsSshTabTitleProfileName => "プロファイル名",
L10nKey::SettingsSshTabTitleHostname => "ホスト名",
L10nKey::DocumentDock => "ターミナルの隣にドック",
L10nKey::DocumentFill => "ウィンドウ全体",
L10nKey::SettingsNotifications => "通知",
@@ -646,9 +677,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAtEnd => "末尾",
L10nKey::SettingsTop => "上部",
L10nKey::SettingsLeft => "左側",
L10nKey::SettingsByRepo => "リポジトリ別",
L10nKey::SettingsByRepoOrFolder => "リポジトリ/フォルダ別",
L10nKey::SettingsFlat => "フラット表示",
L10nKey::SettingsPreset => "プリセット",
L10nKey::SettingsPresetDesc => "tmux はペイン・タブ操作をプレフィックスキーに(Ctrl-B C)",
L10nKey::SettingsPrefix => "プレフィックスキー",
@@ -793,6 +821,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"
}
@@ -841,6 +871,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"
}
@@ -945,6 +978,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"
}
@@ -985,7 +1024,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"トレイ メニューバー ステータス アイコン エージェント 通知 システム tray icon menu bar status system attention"
}
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory"
"タブ グループ 自動 リポジトリ git ssh ホスト 固定 未分類 サイドバー sidebar auto grouping tabs repo repository pinned pin host ungrouped"
}
L10nKey::SettingsSearchSshTabTitleKeywords => {
"SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email"
@@ -1069,6 +1111,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SshEditProfile => "接続を編集…",
L10nKey::ForwardTooltipAdd => "フォワードを追加",
L10nKey::ForwardTooltipRemove => "削除",
L10nKey::ForwardTooltipTurnOn => "オンにする",
L10nKey::ForwardTooltipTurnOff => "オフにする — ルールは残ります",
L10nKey::ForwardSwitchFailed => "フォワードを切り替えられませんでした — {error}",
L10nKey::SettingsFwdEnabled => "接続時にこのルールを開く",
L10nKey::ForwardLocal => "ローカル",
L10nKey::ForwardRemote => "リモート",
L10nKey::ForwardDynamic => "ダイナミック",
@@ -1104,6 +1150,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
}
L10nKey::FileTreeContextOpen => "開く",
L10nKey::FileTreeContextCdHere => "ここで cd",
L10nKey::FileTreeContextPinAsGroup => "グループとして固定",
L10nKey::FileTreeContextInsertPath => "ターミナルにパスを挿入",
L10nKey::FileTreeContextAttachAgent => "エージェントをアタッチ",
L10nKey::FileTreeContextNewFile => "新しいファイル",
@@ -1272,6 +1319,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "続きを表示",
L10nKey::ScmShowLess => "折りたたむ",
L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。",
L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…",
L10nKey::ScmViewAsTree => "ツリーで表示",
L10nKey::ScmViewAsList => "リストで表示",
L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。",
L10nKey::ScmTooManyChanges => {
"変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。"
}
@@ -1533,6 +1584,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdNewWindow => "新しいウィンドウ",
L10nKey::CmdNewWorktreeTab => "新しいワークツリータブ…",
L10nKey::CmdNewWorktreeTabSubtitle => "新しいブランチでの独立したチェックアウト",
L10nKey::CmdNewGroup => "新規グループ",
L10nKey::CmdNewGroupSubtitle => "サイドバーに空の固定グループを作成",
L10nKey::CmdOpenFolderAsGroup => "フォルダをグループとして開く…",
L10nKey::CmdOpenFolderAsGroupSubtitle => "フォルダを固定し、そこで開いたタブをまとめる",
L10nKey::CmdRenameTab => "タブの名前を変更…",
L10nKey::CmdSplitRight => "右に分割",
L10nKey::CmdSplitDown => "下に分割",
@@ -1551,14 +1606,20 @@ 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 => "作業ディレクトリをコピー",
L10nKey::CmdCopySessionId => "セッション ID をコピー",
L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID",
L10nKey::CmdNewAgentTab => "新しいエージェントタブ",
L10nKey::CmdNewAgentTabSubtitle => "最後に使ったコーディングエージェントを新しいタブで開く",
L10nKey::CmdForkSession => "セッションをフォーク",
L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク",
L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク",
L10nKey::CmdHibernateTab => "タブを休止",
L10nKey::CmdHibernateTabSubtitle => "プロセスを停止してメモリを解放。選択すると復帰",
L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる",
L10nKey::CmdCloseWindow => "ウィンドウを閉じる",
L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続",
@@ -1724,8 +1785,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::AppNoUncommittedChanges => {
"{cwd} に未コミットの変更はありません(または git リポジトリではありません)"
}
L10nKey::AppCmdSshProfileTitle => "SSH: {title}",
L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}",
L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}",
L10nKey::AppNoAgentOnPath => "このマシンの PATH にコーディングエージェントが見つかりません",
L10nKey::AppNoAgentSeenHere => {
"このワークスペースではまだコーディングエージェントが実行されていません — 一度手動で起動すると、ここに表示されます"
}
L10nKey::AppAgentLaunchSaved => "{name} は今後このコマンドで起動します: {command}",
L10nKey::AppAgentLaunchArgsUnknown => "{name} は起動時の引数を報告していません",
L10nKey::AppCmdShellTitle => "Shell: {title}",
L10nKey::AppPlaceholderDescription => "説明",
L10nKey::AppPlaceholderSshQuickConnect => "user@host または user@host:port",
L10nKey::AppPlaceholderLoginShell => "ログインシェル",
@@ -1838,6 +1905,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}",
@@ -1885,6 +1953,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー",
L10nKey::AppMenuCopySessionId => "セッション ID をコピー",
L10nKey::AppMenuForkSession => "セッションをフォーク",
L10nKey::AppMenuSaveAgentLaunchArgs => "現在の起動引数をデフォルトに設定",
L10nKey::AppMenuClosePaneTab => "閉じる",
L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる",
L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる",
@@ -1901,7 +1970,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuFind => "検索…",
L10nKey::AppMenuFindNext => "次を検索",
L10nKey::AppMenuFindPrevious => "前を検索",
L10nKey::AppMenuCommandPalette => "コマンドパレット…",
L10nKey::AppMenuSearchEverywhere => "どこでも検索…",
L10nKey::AppMenuIncreaseFontSize => "フォントサイズを拡大",
L10nKey::AppMenuDecreaseFontSize => "フォントサイズを縮小",
L10nKey::AppMenuResetFontSize => "フォントサイズをリセット",
@@ -1914,6 +1983,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 => "パスをコピー",
@@ -1943,15 +2013,24 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::TabMenuLocalShells => "ローカル",
L10nKey::TabMenuAddHost => "SSH ホストを追加…",
L10nKey::TabMenuAllHosts => "すべての SSH ホスト…",
L10nKey::TabMenuLaunchAgent => "エージェントを起動…",
L10nKey::TabMenuOtherShells => "その他のシェル…",
L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割",
L10nKey::TabUnnamedShell => "シェル {n}",
L10nKey::ShellDefault => "デフォルト",
L10nKey::SidebarScratchGroup => "スクラッチ",
L10nKey::SidebarUngroupedGroup => "未分類",
L10nKey::SidebarMoveToGroup => "グループへ移動",
L10nKey::SidebarNewGroup => "新規グループ…",
L10nKey::SidebarAutoGroup => "自動グループ化に戻す",
L10nKey::SidebarNewGroupName => "新規グループ",
L10nKey::SidebarRenameGroup => "グループ名を変更",
L10nKey::SidebarPinGroup => "グループを固定",
L10nKey::SidebarUnpinGroup => "固定を解除",
L10nKey::SidebarGroupNewTab => "新規タブ",
L10nKey::SidebarSetGroupFolder => "フォルダを設定…",
L10nKey::SidebarUseCurrentTabFolder => "現在のタブのフォルダを使用",
L10nKey::SidebarClearGroupFolder => "フォルダを解除",
L10nKey::SidebarDeleteGroup => "グループを削除",
L10nKey::SidebarDropToPin => "ここにドロップして固定",
L10nKey::TabContextCloseTab => "タブを閉じる",
L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる",
L10nKey::AppAgentHooksOpFailed => "失敗: {error}",
@@ -1962,6 +2041,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNoneLower => "なし",
L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール",
L10nKey::TabContextMarkUnread => "未読としてマーク",
L10nKey::TabContextHibernate => "休止",
L10nKey::TabContextWake => "復帰",
L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰",
L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません",
})
}
+82 -12
View File
@@ -149,8 +149,25 @@ l10n_keys! {
SearchThemes,
SearchSettings,
FilterHosts,
SearchCommandsOrHost,
SearchTheme,
SearchTabAll,
SearchTabActions,
SearchTabTerminals,
SearchTabHosts,
SearchPlaceholderAll,
SearchPlaceholderActions,
SearchPlaceholderTerminals,
SearchPlaceholderHosts,
SearchTabSessions,
SearchPlaceholderSessions,
SearchSessionsEmptyHint,
SearchSectionSessionsHere,
SearchSectionSessionsRecent,
AppSessionNotResumable,
AppSessionDirectoryGone,
SearchMoreIn,
SearchNoResults,
SearchSectionNewTerminal,
Search,
SearchWorkspacesAndMachines,
SearchFonts,
@@ -162,7 +179,7 @@ l10n_keys! {
HomeNewTab,
HomeReopenClosedTab,
HomeSwitchWorkspace,
HomeCommandPalette,
HomeSearchEverywhere,
HomeSplitRight,
HomeSplitDown,
HomeSettings,
@@ -173,7 +190,6 @@ l10n_keys! {
Refreshing,
Binary,
Delete,
NoMatchingCommands,
ConnectSshHint,
EditHint,
OpenFileFromTree,
@@ -244,6 +260,8 @@ l10n_keys! {
SettingsCursor,
SettingsCursorShape,
SettingsCursorShapeDesc,
SettingsPromptCursorShape,
SettingsPromptCursorShapeDesc,
SettingsCursorBlink,
SettingsCursorBlinkDesc,
SettingsLanguage,
@@ -556,6 +574,11 @@ l10n_keys! {
SettingsSidebarGroupingDesc,
SettingsDiffPreviewFromCounts,
SettingsDiffPreviewFromCountsDesc,
SettingsSshTabTitle,
SettingsSshTabTitleDesc,
SettingsSshTabTitleDynamic,
SettingsSshTabTitleProfileName,
SettingsSshTabTitleHostname,
DocumentDock,
DocumentFill,
SettingsNotifications,
@@ -574,9 +597,6 @@ l10n_keys! {
SettingsAtEnd,
SettingsTop,
SettingsLeft,
SettingsByRepo,
SettingsByRepoOrFolder,
SettingsFlat,
SettingsPreset,
SettingsPresetDesc,
SettingsPrefix,
@@ -676,6 +696,8 @@ l10n_keys! {
SettingsAgentCrush,
SettingsAgentCodeBuddy,
SettingsAgentCursorCli,
SettingsAgentPrimeAgent,
SettingsAgentAntigravity,
SettingsSearchAppHttpProxyKeywords,
SettingsSearchAboutKeywords,
SettingsSearchAutoDownloadKeywords,
@@ -696,6 +718,7 @@ l10n_keys! {
SettingsSearchCopyOnSelectKeywords,
SettingsSearchCursorBlinkKeywords,
SettingsSearchCursorShapeKeywords,
SettingsSearchPromptCursorShapeKeywords,
SettingsSearchCustomThemesKeywords,
SettingsSearchDetectUrlsKeywords,
SettingsSearchDiffPreviewFromCountsKeywords,
@@ -735,6 +758,8 @@ l10n_keys! {
SettingsSearchCrushKeywords,
SettingsSearchCodeBuddyKeywords,
SettingsSearchCursorCliKeywords,
SettingsSearchPrimeAgentKeywords,
SettingsSearchAntigravityKeywords,
SettingsSearchRememberWindowSizeKeywords,
SettingsSearchReportMouseToAppsKeywords,
SettingsSearchRestoreLastLayoutKeywords,
@@ -743,6 +768,7 @@ l10n_keys! {
SettingsSearchScrollbackKeywords,
SettingsSearchShowTrayIconKeywords,
SettingsSearchSidebarGroupingKeywords,
SettingsSearchSshTabTitleKeywords,
SettingsSearchSmartSelectionKeywords,
SettingsSearchStartInKeywords,
SettingsSearchSyncWithSystemKeywords,
@@ -796,6 +822,10 @@ l10n_keys! {
SshEditProfile,
ForwardTooltipAdd,
ForwardTooltipRemove,
ForwardTooltipTurnOn,
ForwardTooltipTurnOff,
ForwardSwitchFailed,
SettingsFwdEnabled,
ForwardLocal,
ForwardRemote,
ForwardDynamic,
@@ -825,6 +855,7 @@ l10n_keys! {
FileTreeDownloadTooLarge,
FileTreeContextOpen,
FileTreeContextCdHere,
FileTreeContextPinAsGroup,
FileTreeContextInsertPath,
FileTreeContextAttachAgent,
FileTreeContextNewFile,
@@ -971,6 +1002,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
ScmFilterChanges,
ScmViewAsTree,
ScmViewAsList,
ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
@@ -1058,6 +1093,7 @@ l10n_keys! {
AppMenuCopyWorkingDirectory,
AppMenuCopySessionId,
AppMenuForkSession,
AppMenuSaveAgentLaunchArgs,
AppMenuClosePaneTab,
AppMenuCloseOtherTabs,
AppMenuCloseTabsRight,
@@ -1074,7 +1110,7 @@ l10n_keys! {
AppMenuFind,
AppMenuFindNext,
AppMenuFindPrevious,
AppMenuCommandPalette,
AppMenuSearchEverywhere,
AppMenuIncreaseFontSize,
AppMenuDecreaseFontSize,
AppMenuResetFontSize,
@@ -1087,6 +1123,7 @@ l10n_keys! {
AppMenuZoomPane,
AppMenuClearScrollback,
AppMenuOpenLink,
AppMenuOpenLinkWithDefaultApp,
AppMenuRevealInFinder,
AppMenuRevealInFolder,
AppMenuCopyLinkPath,
@@ -1117,18 +1154,31 @@ l10n_keys! {
TabMenuLocalShells,
TabMenuAddHost,
TabMenuAllHosts,
TabMenuLaunchAgent,
TabMenuOtherShells,
TabMenuSplitHint,
TabUnnamedShell,
ShellDefault,
SidebarScratchGroup,
SidebarUngroupedGroup,
SidebarMoveToGroup,
SidebarNewGroup,
SidebarAutoGroup,
SidebarNewGroupName,
SidebarRenameGroup,
SidebarPinGroup,
SidebarUnpinGroup,
SidebarGroupNewTab,
SidebarSetGroupFolder,
SidebarUseCurrentTabFolder,
SidebarClearGroupFolder,
SidebarDeleteGroup,
SidebarDropToPin,
TabContextCloseTab,
TabContextCloseTabsBelow,
TabContextMarkUnread,
TabContextHibernate,
TabContextWake,
TabTooltipAsleep,
TabWakeFailed,
RemoteStripDisconnected,
RemoteStripConnecting,
RemoteStripReconnecting,
@@ -1251,6 +1301,10 @@ l10n_keys! {
CmdNewWindow,
CmdNewWorktreeTab,
CmdNewWorktreeTabSubtitle,
CmdNewGroup,
CmdNewGroupSubtitle,
CmdOpenFolderAsGroup,
CmdOpenFolderAsGroupSubtitle,
CmdRenameTab,
CmdSplitRight,
CmdSplitDown,
@@ -1269,14 +1323,20 @@ l10n_keys! {
CmdSwapPanePrevious,
CmdNextTab,
CmdPreviousTab,
CmdMoveTabLeft,
CmdMoveTabRight,
CmdRecentTabSwitcher,
CmdRecentTabSwitcherReverse,
CmdCopyWorkingDirectory,
CmdCopySessionId,
CmdCopySessionIdSubtitle,
CmdNewAgentTab,
CmdNewAgentTabSubtitle,
CmdForkSession,
CmdForkSessionSubtitle,
CmdMarkTabAsUnread,
CmdHibernateTab,
CmdHibernateTabSubtitle,
CmdClosePaneTab,
CmdCloseWindow,
CmdCloseWindowSubtitle,
@@ -1402,8 +1462,12 @@ l10n_keys! {
AppNothingSelected,
AppPaneNoKnownDirectory,
AppNoUncommittedChanges,
AppCmdSshProfileTitle,
AppCmdSwitchToTab,
AppCmdAgentLaunchTitle,
AppNoAgentOnPath,
AppNoAgentSeenHere,
AppAgentLaunchSaved,
AppAgentLaunchArgsUnknown,
AppCmdShellTitle,
AppPlaceholderDescription,
AppPlaceholderSshQuickConnect,
AppPlaceholderLoginShell,
@@ -1489,6 +1553,7 @@ l10n_keys! {
CursorShapeBlock,
CursorShapeBar,
CursorShapeUnderline,
PromptCursorShapeFollow,
PaletteTryDifferentSearch,
CompletionListingRemote,
CompletionRemoteListingFailed,
@@ -1662,7 +1727,7 @@ mod tests {
// Every locale spells this button the same way.
L10nKey::Ok,
// Pure templates: every word in them is a placeholder.
L10nKey::AppCmdSshProfileTitle,
L10nKey::AppCmdShellTitle,
L10nKey::HostOpsError,
L10nKey::SftpTransferProgress,
// Product names.
@@ -1684,6 +1749,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
@@ -1704,6 +1771,9 @@ mod tests {
// translation here would be less clear, not more.
L10nKey::SettingsShell,
L10nKey::CmdGroupAgents,
// The quick-launch rows, which the New Tab menu finds by typing
// this one word into the palette in every locale.
L10nKey::AppCmdAgentLaunchTitle,
];
for &key in KEPT_IN_ENGLISH {
+93 -14
View File
@@ -86,8 +86,25 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchThemes => "搜索主题…",
L10nKey::SearchSettings => "搜索设置…",
L10nKey::FilterHosts => "筛选主机…",
L10nKey::SearchCommandsOrHost => "搜索或输入 user@host 连接…",
L10nKey::SearchTheme => "搜索…",
L10nKey::SearchTabAll => "全部",
L10nKey::SearchTabActions => "操作",
L10nKey::SearchTabTerminals => "终端",
L10nKey::SearchTabHosts => "主机",
L10nKey::SearchPlaceholderAll => "搜索操作、终端和主机…",
L10nKey::SearchPlaceholderActions => "搜索操作…",
L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…",
L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…",
L10nKey::SearchTabSessions => "会话",
L10nKey::SearchPlaceholderSessions => "搜索历史 Agent 会话…",
L10nKey::SearchSessionsEmptyHint => "这里列出本机上的 Claude Code 和 Codex 会话。",
L10nKey::SearchSectionSessionsHere => "在 {dir}",
L10nKey::SearchSectionSessionsRecent => "最近",
L10nKey::AppSessionNotResumable => "{name} 不支持按 ID 恢复会话。",
L10nKey::AppSessionDirectoryGone => "会话所在的目录已不存在:{path}",
L10nKey::SearchMoreIn => "{tab}中还有 {count} 项",
L10nKey::SearchNoResults => "没有结果",
L10nKey::SearchSectionNewTerminal => "新建终端",
L10nKey::SearchWorkspacesAndMachines => "搜索工作区、标签页和机器…",
L10nKey::SearchFonts => "搜索字体…",
L10nKey::SearchFind => "查找…",
@@ -98,7 +115,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::HomeNewTab => "新标签页",
L10nKey::HomeReopenClosedTab => "重新打开已关闭的标签页",
L10nKey::HomeSwitchWorkspace => "切换工作区…",
L10nKey::HomeCommandPalette => "命令面板…",
L10nKey::HomeSearchEverywhere => "随处搜索…",
L10nKey::HomeSplitRight => "向右分屏",
L10nKey::HomeSplitDown => "向下分屏",
L10nKey::HomeSettings => "设置…",
@@ -109,7 +126,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::Refreshing => "正在刷新…",
L10nKey::Binary => "二进制文件",
L10nKey::Delete => "删除",
L10nKey::NoMatchingCommands => "没有匹配的命令",
L10nKey::ConnectSshHint => "输入 user@host 改为通过 SSH 连接。",
L10nKey::EditHint => "编辑",
L10nKey::OpenFileFromTree => "从文件树打开文件",
@@ -182,6 +198,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 => "语言",
@@ -552,10 +572,17 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNewTabPositionDesc => "新打开的标签页插入的位置。",
L10nKey::SettingsTabBarPosition => "标签栏位置",
L10nKey::SettingsTabBarPositionDesc => "将标签页显示为顶部横向条或左侧垂直侧栏。",
L10nKey::SettingsSidebarGrouping => "侧栏分组",
L10nKey::SettingsSidebarGroupingDesc => "按 git 仓库分组侧栏标签页,其余归入“草稿”。",
L10nKey::SettingsSidebarGrouping => "自动分组",
L10nKey::SettingsSidebarGroupingDesc => {
"未固定的标签页按 git 仓库分组,SSH 标签页按主机分组。"
}
L10nKey::SettingsDiffPreviewFromCounts => "从侧栏计数打开 diff 预览",
L10nKey::SettingsDiffPreviewFromCountsDesc => "点击行上的 +N −N 打开 diff。",
L10nKey::SettingsSshTabTitle => "SSH 标签页标题",
L10nKey::SettingsSshTabTitleDesc => "“动态”跟随远端标题;另外两项固定为主机。",
L10nKey::SettingsSshTabTitleDynamic => "动态",
L10nKey::SettingsSshTabTitleProfileName => "配置名称",
L10nKey::SettingsSshTabTitleHostname => "主机名",
L10nKey::DocumentDock => "停靠在终端旁",
L10nKey::DocumentFill => "铺满窗口",
L10nKey::SettingsNotifications => "通知",
@@ -574,9 +601,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAtEnd => "末尾",
L10nKey::SettingsTop => "顶部",
L10nKey::SettingsLeft => "左侧",
L10nKey::SettingsByRepo => "按仓库",
L10nKey::SettingsByRepoOrFolder => "按仓库或文件夹",
L10nKey::SettingsFlat => "平铺",
L10nKey::SettingsPreset => "预设",
L10nKey::SettingsPresetDesc => "tmux 预设把操作映射为前缀键(如 Ctrl-B C)。",
L10nKey::SettingsPrefix => "前缀",
@@ -705,6 +729,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"
}
@@ -751,6 +777,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"
}
@@ -851,6 +880,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"
}
@@ -891,7 +926,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"显示托盘图标 托盘 菜单栏 状态 图标 show tray icon menu bar status"
}
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory"
"自动分组 标签页 分组 仓库 git ssh 主机 固定 未分组 侧栏 sidebar auto grouping tabs repo repository pinned pin host ungrouped"
}
L10nKey::SettingsSearchSshTabTitleKeywords => {
"SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click"
@@ -973,6 +1011,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SshEditProfile => "编辑连接…",
L10nKey::ForwardTooltipAdd => "添加转发",
L10nKey::ForwardTooltipRemove => "移除",
L10nKey::ForwardTooltipTurnOn => "开启",
L10nKey::ForwardTooltipTurnOff => "关闭——规则会保留",
L10nKey::ForwardSwitchFailed => "无法切换这条转发——{error}",
L10nKey::SettingsFwdEnabled => "连接时开启这条规则",
L10nKey::ForwardLocal => "本地",
L10nKey::ForwardRemote => "远程",
L10nKey::ForwardDynamic => "动态",
@@ -1004,6 +1046,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::FileTreeDownloadTooLarge => "超过 {limit} MB,请改用 scp 或 rsync 下载。",
L10nKey::FileTreeContextOpen => "打开",
L10nKey::FileTreeContextCdHere => "cd 到此处",
L10nKey::FileTreeContextPinAsGroup => "固定为分组",
L10nKey::FileTreeContextInsertPath => "在终端中插入路径",
L10nKey::FileTreeContextAttachAgent => "附加到 agent",
L10nKey::FileTreeContextNewFile => "新建文件",
@@ -1153,6 +1196,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "展开",
L10nKey::ScmShowLess => "收起",
L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。",
L10nKey::ScmFilterChanges => "筛选变更文件…",
L10nKey::ScmViewAsTree => "以树形显示",
L10nKey::ScmViewAsList => "以列表显示",
L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。",
L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。",
L10nKey::ScmOpenChanges => "查看改动",
L10nKey::ScmDiscardAllConfirm => {
@@ -1391,6 +1438,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdNewWindow => "新建窗口",
L10nKey::CmdNewWorktreeTab => "新建 worktree 标签页…",
L10nKey::CmdNewWorktreeTabSubtitle => "在全新分支上独立检出",
L10nKey::CmdNewGroup => "新建分组",
L10nKey::CmdNewGroupSubtitle => "在侧栏新建一个空的固定分组",
L10nKey::CmdOpenFolderAsGroup => "将文件夹作为分组打开…",
L10nKey::CmdOpenFolderAsGroupSubtitle => "固定一个文件夹;在其中打开的标签页会加入",
L10nKey::CmdRenameTab => "重命名标签页…",
L10nKey::CmdSplitRight => "向右分屏",
L10nKey::CmdSplitDown => "向下分屏",
@@ -1409,14 +1460,20 @@ 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 => "复制工作目录",
L10nKey::CmdCopySessionId => "复制会话 ID",
L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID",
L10nKey::CmdNewAgentTab => "新建 Agent 标签页",
L10nKey::CmdNewAgentTabSubtitle => "在新标签页中打开上次使用的编码 agent",
L10nKey::CmdForkSession => "Fork 会话",
L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页",
L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读",
L10nKey::CmdHibernateTab => "休眠标签页",
L10nKey::CmdHibernateTabSubtitle => "停止其中的进程以释放内存,选中即可唤醒",
L10nKey::CmdClosePaneTab => "关闭窗格/标签页",
L10nKey::CmdCloseWindow => "关闭窗口",
L10nKey::CmdCloseWindowSubtitle => "shell 保持运行",
@@ -1568,8 +1625,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppNothingSelected => "未选择任何内容——请先选择一些终端输出。",
L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。",
L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。",
L10nKey::AppCmdSshProfileTitle => "SSH:{title}",
L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}",
L10nKey::AppCmdAgentLaunchTitle => "Agent:{name}",
L10nKey::AppNoAgentOnPath => "在本机 PATH 中未找到编码 agent",
L10nKey::AppNoAgentSeenHere => {
"此工作区还没有运行过编码 agent——手动启动一次后,这里就会列出它"
}
L10nKey::AppAgentLaunchSaved => "{name} 以后将以此命令启动:{command}",
L10nKey::AppAgentLaunchArgsUnknown => "{name} 未报告其启动参数",
L10nKey::AppCmdShellTitle => "Shell:{title}",
L10nKey::AppPlaceholderDescription => "描述",
L10nKey::AppPlaceholderSshQuickConnect => "user@host 或 user@host:port",
L10nKey::AppPlaceholderLoginShell => "登录 shell",
@@ -1676,6 +1739,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}",
@@ -1719,6 +1783,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录",
L10nKey::AppMenuCopySessionId => "复制会话 ID",
L10nKey::AppMenuForkSession => "Fork 会话",
L10nKey::AppMenuSaveAgentLaunchArgs => "将当前启动参数设为默认",
L10nKey::AppMenuClosePaneTab => "关闭",
L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页",
L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页",
@@ -1735,7 +1800,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuFind => "查找…",
L10nKey::AppMenuFindNext => "查找下一个",
L10nKey::AppMenuFindPrevious => "查找上一个",
L10nKey::AppMenuCommandPalette => "命令面板…",
L10nKey::AppMenuSearchEverywhere => "随处搜索…",
L10nKey::AppMenuIncreaseFontSize => "增大字号",
L10nKey::AppMenuDecreaseFontSize => "减小字号",
L10nKey::AppMenuResetFontSize => "重置字号",
@@ -1748,6 +1813,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuZoomPane => "缩放窗格",
L10nKey::AppMenuClearScrollback => "清除回滚内容",
L10nKey::AppMenuOpenLink => "打开",
L10nKey::AppMenuOpenLinkWithDefaultApp => "用默认程序打开",
L10nKey::AppMenuRevealInFinder => "在访达中显示",
L10nKey::AppMenuRevealInFolder => "打开所在文件夹",
L10nKey::AppMenuCopyLinkPath => "复制路径",
@@ -1777,15 +1843,24 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::TabMenuLocalShells => "本地",
L10nKey::TabMenuAddHost => "添加 SSH 主机…",
L10nKey::TabMenuAllHosts => "所有 SSH 主机…",
L10nKey::TabMenuLaunchAgent => "启动 Agent…",
L10nKey::TabMenuOtherShells => "其他 Shell…",
L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开",
L10nKey::TabUnnamedShell => "终端 {n}",
L10nKey::ShellDefault => "默认",
L10nKey::SidebarScratchGroup => "草稿",
L10nKey::SidebarUngroupedGroup => "未分组",
L10nKey::SidebarMoveToGroup => "移到分组",
L10nKey::SidebarNewGroup => "新建分组…",
L10nKey::SidebarAutoGroup => "恢复自动分组",
L10nKey::SidebarNewGroupName => "新建分组",
L10nKey::SidebarRenameGroup => "重命名分组",
L10nKey::SidebarPinGroup => "固定分组",
L10nKey::SidebarUnpinGroup => "取消固定",
L10nKey::SidebarGroupNewTab => "新建标签页",
L10nKey::SidebarSetGroupFolder => "设置文件夹…",
L10nKey::SidebarUseCurrentTabFolder => "使用当前标签页的文件夹",
L10nKey::SidebarClearGroupFolder => "清除文件夹",
L10nKey::SidebarDeleteGroup => "删除分组",
L10nKey::SidebarDropToPin => "拖到此处固定",
L10nKey::TabContextCloseTab => "关闭标签页",
L10nKey::TabContextCloseTabsBelow => "关闭下方标签页",
L10nKey::AppAgentHooksOpFailed => "失败:{error}",
@@ -1796,6 +1871,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNoneLower => "无",
L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具",
L10nKey::TabContextMarkUnread => "标记为未读",
L10nKey::TabContextHibernate => "休眠",
L10nKey::TabContextWake => "唤醒",
L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒",
L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动",
})
}
+112 -9
View File
@@ -1,4 +1,4 @@
use gpui::{App, Global, KeyBinding, Keystroke, NoAction};
use gpui::{App, Global, KeyBinding, Keystroke};
use crate::core::actions::*;
use crate::core::config::{Config, KeybindingOverride};
@@ -7,7 +7,7 @@ use crate::terminal::view::{
InsertNewline, InsertNewlineFallback, PasteText,
};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::palette::CommandGroup;
use crate::ui::search::CommandGroup;
use crate::ui::settings::humanize_action;
use crate::ui::theme::set_menus;
@@ -64,11 +64,12 @@ fn fixed_bindings() -> Vec<KeyBinding> {
SwitcherAcrossBack,
Some("Switcher"),
));
// The palette has nowhere for Tab to go — the arrows walk the list and
// Enter runs it — but Root's focus walker still had somewhere to send it:
// out of the modal, onto whichever chrome tile is behind it, ring and all.
bindings.push(KeyBinding::new("tab", NoAction {}, Some("Palette")));
bindings.push(KeyBinding::new("shift-tab", NoAction {}, Some("Palette")));
// Tab walks the search's tabs. Bound on the search's own context for the
// switcher's reason: Root's focus walker would otherwise take it out of
// the modal, onto whichever chrome tile is behind it, ring and all.
let search = Some(crate::ui::search::KEY_CONTEXT);
bindings.push(KeyBinding::new("tab", SearchNextTab, search));
bindings.push(KeyBinding::new("shift-tab", SearchPrevTab, search));
bindings
}
@@ -301,6 +302,24 @@ fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
}
pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
let mut bindings = shipped_bindings();
// One slot per agent, unbound: the palette's `Agent: …` rows are what these
// run, and each takes a key the same way any other action does. Right
// after the other agent actions, so the Keybindings page keeps them together.
let at = bindings
.iter()
.position(|(action, _)| *action == "NewAgentTab")
.map_or(bindings.len(), |i| i + 1);
bindings.splice(
at..at,
crate::ui::agent_launch::launch_action_names()
.iter()
.map(|(_, name)| (name.as_str(), "")),
);
bindings
}
fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
vec![
("NewTab", per_platform("secondary-t", "secondary-shift-t")),
("NewWorkspace", "secondary-shift-n"),
@@ -325,12 +344,18 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("CloseTabsToTheRight", ""),
("CopyWorkingDirectory", ""),
("MarkTabUnread", ""),
("HibernateTab", ""),
("ForkAgentSession", ""),
("ForkAgentSessionRight", ""),
("ForkAgentSessionLeft", ""),
("ForkAgentSessionDown", ""),
("ForkAgentSessionUp", ""),
("CopyAgentSessionId", ""),
// ⌘⇧A for "agent" on macOS, where nothing else in the table or the
// terminal holds it. Off macOS Ctrl+Shift+A is select-all in every
// Linux terminal and in this prompt editor's Ctrl+A family, so it ships
// unbound there rather than on a chord nobody would guess.
("NewAgentTab", per_platform("secondary-shift-a", "")),
("StopWorkspace", ""),
("DeleteWorkspace", ""),
("RenameWorkspace", ""),
@@ -386,6 +411,11 @@ pub(crate) fn default_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")),
@@ -578,6 +608,15 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]),
));
}
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
return Some((
CommandGroup::Agents,
t_fmt(
L10nKey::AppCmdAgentLaunchTitle,
&[("name", agent.display_name())],
),
));
}
if let Some(n) = action.strip_prefix("SelectWorkspace") {
return Some((
CommandGroup::Workspaces,
@@ -614,6 +653,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::TabsPanes,
t(L10nKey::CmdMarkTabAsUnread).to_string(),
),
"HibernateTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdHibernateTab).to_string(),
),
"ReopenClosedTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdReopenClosedTab).to_string(),
@@ -689,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(),
@@ -826,9 +877,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Agents,
t(L10nKey::CmdCopySessionId).to_string(),
),
"NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()),
"TogglePalette" => (
CommandGroup::Application,
t(L10nKey::AppMenuCommandPalette).to_string(),
t(L10nKey::AppMenuSearchEverywhere).to_string(),
),
"NewWindow" => (
CommandGroup::Application,
@@ -1343,6 +1395,9 @@ fn action_context(action: &str) -> Option<&'static str> {
}
fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None));
}
Some(match action {
"NewTab" => KeyBinding::new(keystroke, NewTab, None),
"NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None),
@@ -1359,12 +1414,14 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None),
"CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None),
"MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None),
"HibernateTab" => KeyBinding::new(keystroke, HibernateTab, None),
"ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None),
"ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None),
"ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None),
"ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None),
"ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None),
"CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None),
"NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, None),
"SplitRight" => KeyBinding::new(keystroke, SplitRight, None),
"SplitDown" => KeyBinding::new(keystroke, SplitDown, None),
"FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None),
@@ -1383,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),
@@ -1568,7 +1627,7 @@ mod tests {
assert_eq!(action_entry("NewWindow").1, "New Window");
assert_eq!(action_entry("CloseWindow").1, "Close Window");
assert_eq!(action_entry("ClearScrollback").1, "Clear Scrollback");
assert_eq!(action_entry("TogglePalette").1, "Command Palette…");
assert_eq!(action_entry("TogglePalette").1, "Search Everywhere…");
assert_eq!(action_entry("ToggleSwitcher").1, "Switch Workspace…");
// The numbered families are templated, not nine strings per locale.
assert_eq!(action_entry("ActivateTab3").1, "Go to Tab 3");
@@ -2315,6 +2374,50 @@ mod tests {
let ks = Keystroke::parse("secondary").unwrap();
assert_eq!(spec_from_keystroke(&ks), None);
}
#[test]
fn new_agent_tab_ships_a_chord_only_where_one_is_free() {
let mut effective: Vec<(String, String)> = default_bindings()
.into_iter()
.map(|(a, k)| (a.to_string(), k.to_string()))
.collect();
let default = effective
.iter()
.find(|(action, _)| action == "NewAgentTab")
.map(|(_, key)| key.clone())
.expect("NewAgentTab has to be listed or it cannot be bound");
if cfg!(target_os = "macos") {
assert_eq!(default, "secondary-shift-a");
assert_eq!(
dispatched(&effective, &default, "Terminal"),
vec![NewAgentTab::name_for_type()],
"{default} must reach NewAgentTab, and nothing else may answer it"
);
} else {
assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS");
}
// Each agent's own launch is a slot of its own, and a key bound to it
// dispatches that agent.
let name = crate::ui::agent_launch::launch_action_name(
tty7_core::core::cli_agent::CLIAgent::Codex,
);
effective
.iter_mut()
.find(|(action, _)| action == name)
.expect("every agent has a keymap slot")
.1 = "ctrl-alt-shift-c".to_string();
let mut keymap = gpui::Keymap::default();
keymap.add_bindings(action_bindings(&effective));
let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()];
let context = [gpui::KeyContext::parse("Terminal").unwrap()];
let hits = keymap.bindings_for_input(&typed, &context).0;
assert_eq!(hits.len(), 1);
assert!(hits[0].action().partial_eq(&LaunchAgent {
agent: tty7_core::core::cli_agent::CLIAgent::Codex,
}));
assert_eq!(action_entry(name).0, CommandGroup::Agents);
}
}
#[cfg(test)]
+9
View File
@@ -49,6 +49,15 @@ impl LocalLink {
link.client.as_ref().filter(|c| c.is_connected()).cloned()
}
/// Whether the local daemon advertised `feature` on its control hello —
/// [`HostLinks::peer_supports`](crate::ui::remote_connect::HostLinks::peer_supports)
/// for this machine. `false` while the link is down, for the same reason.
pub fn supports(cx: &App, feature: &str) -> bool {
cx.try_global::<LocalLink>()
.and_then(|link| link.client.as_ref())
.is_some_and(|c| c.is_connected() && c.hello().has_feature(feature))
}
/// Drops the cached client without waiting for its reader to notice.
///
/// `ControlClient::is_connected` only flips once the reader sees EOF, so
+117 -30
View File
@@ -1,11 +1,11 @@
use std::collections::HashMap;
use gpui::{App, Global};
use tty7_core::core::group_key::AutoKey;
use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace};
use tty7_core::daemon::control::{ControlRequest, ReplyOk};
use tty7_core::host::HostId;
use crate::core::group_key::GroupKey;
use crate::core::session::WorkspaceId;
use crate::ui::i18n::{L10nKey, t};
@@ -300,11 +300,20 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) ->
t.name = name.clone();
true
}
LayoutDelta::TabRegrouped { tab, group } => {
LayoutDelta::TabRegrouped {
tab,
group,
last_auto,
} => {
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
t.sidebar_group = group.clone();
t.group = *group;
t.last_auto = last_auto.clone();
true
}
LayoutDelta::GroupsChanged { groups } => {
ws.groups = groups.clone();
true
}
LayoutDelta::TabMoved { tab, to } => {
@@ -375,23 +384,31 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
}
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
// Repo groups only. This answers with a *path*, and its callers treat it
// as one — `display_name_of` names the window after its last component.
// A custom group is a name the user typed, so putting one here would
// title a window `custom:work`, or chop `work/urgent` down to `urgent`.
// A workspace grouped entirely by hand falls through to a pane's cwd,
// which is a real path and is what the window showed before any of this.
for group in ws.tabs.iter().filter_map(|t| {
let key = GroupKey::decode(t.sidebar_group.as_deref()?)?;
Some(key.repo_root()?.to_string_lossy().into_owned())
}) {
match counts.iter_mut().find(|(g, _)| *g == group) {
Some((_, n)) => *n += 1,
None => counts.push((group, 1)),
// Paths only. This answers with a *path*, and its callers treat it as one
// — `display_name_of` names the window after its last component. A label
// group is a name the user typed, so putting one here would chop
// `work/urgent` down to `urgent`.
//
// A pinned folder is the strongest statement of what a workspace is
// about, so the most common one wins. Failing that, the repo most of the
// auto-grouped tabs were last filed under — a worktree counts toward the
// repo it belongs to, since the hint is the repo home — which is what the
// title read before groups could be pinned. A tab kept in a pinned group
// casts no repo vote: it was put there by hand, as a hand-made group's tab
// was before. Failing both, a pane's cwd.
let folders = ws.tabs.iter().filter_map(|t| {
let group = ws.groups.get(t.group?)?;
group.folder.clone()
});
let repos = ws.tabs.iter().filter_map(|t| {
if t.group.is_some_and(|g| ws.groups.contains(g)) {
return None;
}
}
let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g);
match t.last_auto.as_ref()? {
AutoKey::Repo(home) => Some(home.to_string_lossy().into_owned()),
AutoKey::SshHost(_) => None,
}
});
let first_cwd = ws
.tabs
.iter()
@@ -402,7 +419,22 @@ pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
.find(|p| p.id == id)
.and_then(|p| p.cwd.as_deref())
});
dominant.or_else(|| first_cwd.map(str::to_string))
most_common(folders)
.or_else(|| most_common(repos))
.or_else(|| first_cwd.map(str::to_string))
}
/// The value seen most often, ties going to the one that reached the count
/// last — the rule `max_by_key` has always settled titles by here.
fn most_common(values: impl Iterator<Item = String>) -> Option<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
for value in values {
match counts.iter_mut().find(|(v, _)| *v == value) {
Some((_, n)) => *n += 1,
None => counts.push((value, 1)),
}
}
counts.into_iter().max_by_key(|(_, n)| *n).map(|(v, _)| v)
}
pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option<String> {
@@ -506,6 +538,7 @@ pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option<
#[cfg(test)]
mod tests {
use tty7_core::core::group_key::PinnedGroup;
use tty7_core::core::machine::{Axis, PaneNode, PaneSeed, Tab, TabId};
use super::*;
@@ -928,13 +961,15 @@ mod tests {
let restructured = Tab {
id: tab_id,
name: None,
sidebar_group: None,
group: None,
last_auto: None,
root: PaneNode::Split {
axis: Axis::Vertical,
ratio: 0.5,
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
hibernated: false,
};
assert!(apply(
&mut machine,
@@ -1033,11 +1068,13 @@ mod tests {
"a pane's process title must not rename its workspace"
);
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
let tty7 = PinnedGroup::folder(std::path::Path::new("/repo/tty7"));
ws.tabs[0].group = Some(tty7.id);
ws.groups.pinned.push(tty7);
assert_eq!(
display_name_of(&ws, &panes),
"tty7",
"the repo group wins over the raw cwd"
"a pinned folder group wins over the raw cwd"
);
ws.name = Some(" Release prep ".into());
@@ -1046,12 +1083,11 @@ mod tests {
assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled");
}
/// A custom group is a name, not a path, and this answers with a path —
/// A label group is a name, not a path, and this answers with a path —
/// its caller names the window after the last component. Left in, a
/// workspace grouped by hand would be titled `custom:work`, and one
/// grouped as `work/urgent` would be titled `urgent`.
/// workspace grouped as `work/urgent` would be titled `urgent`.
#[test]
fn a_custom_group_is_not_a_subject_path() {
fn a_label_group_is_not_a_subject_path() {
let mut ws = Workspace::default();
let panes = vec![PaneRecord {
cwd: Some("/home/me/scratch".into()),
@@ -1059,18 +1095,69 @@ mod tests {
}];
ws.tabs = vec![leaf_tab(1)];
ws.tabs[0].sidebar_group = Some("custom:work/urgent".into());
let work = PinnedGroup::label("work/urgent");
ws.tabs[0].group = Some(work.id);
ws.groups.pinned.push(work);
assert_eq!(
display_name_of(&ws, &panes),
"scratch",
"the cwd answers instead, the way it did before groups existed"
);
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
let tty7 = PinnedGroup::folder(std::path::Path::new("/repo/tty7"));
ws.tabs[0].group = Some(tty7.id);
ws.groups.pinned.push(tty7);
assert_eq!(
display_name_of(&ws, &panes),
"tty7",
"and a repo group still outranks the cwd"
"and a folder group still outranks the cwd"
);
}
/// With nothing pinned the title falls back the way it did before groups
/// could be pinned: to the repo most tabs are in — a worktree counting
/// toward its repo home, which is what the hint holds — and only then to
/// a pane's cwd.
#[test]
fn with_nothing_pinned_the_most_common_repo_names_the_workspace() {
let mut ws = Workspace::default();
let panes = vec![PaneRecord {
cwd: Some("/home/me/scratch".into()),
..PaneRecord::new(1)
}];
ws.tabs = vec![leaf_tab(1), leaf_tab(2), leaf_tab(3)];
assert_eq!(display_name_of(&ws, &panes), "scratch", "no hints: the cwd");
// Two tabs in tty7 (one of them a worktree, filed under its home) and
// one in api: tty7 wins.
ws.tabs[0].last_auto = Some(AutoKey::Repo("/repo/api".into()));
ws.tabs[1].last_auto = Some(AutoKey::Repo("/repo/tty7".into()));
ws.tabs[2].last_auto = Some(AutoKey::Repo("/repo/tty7".into()));
assert_eq!(display_name_of(&ws, &panes), "tty7");
// A tab kept in a label group casts no repo vote.
let work = PinnedGroup::label("work");
ws.tabs[2].group = Some(work.id);
ws.groups.pinned.push(work);
ws.tabs[0].last_auto = Some(AutoKey::Repo("/repo/api".into()));
ws.tabs.push(leaf_tab(4));
ws.tabs[3].last_auto = Some(AutoKey::Repo("/repo/api".into()));
assert_eq!(display_name_of(&ws, &panes), "api");
}
/// A pinned folder outranks any number of repo hints.
#[test]
fn a_pinned_folder_outranks_the_repo_majority() {
let mut ws = Workspace {
tabs: vec![leaf_tab(1), leaf_tab(2), leaf_tab(3)],
..Default::default()
};
for t in &mut ws.tabs {
t.last_auto = Some(AutoKey::Repo("/repo/tty7".into()));
}
let site = PinnedGroup::folder(std::path::Path::new("/w/site"));
ws.tabs[0].group = Some(site.id);
ws.groups.pinned.push(site);
assert_eq!(display_name_of(&ws, &[]), "site");
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
pub mod agent_launch;
pub mod app;
pub mod assets;
pub mod code_editor;
@@ -20,7 +21,6 @@ pub mod keymap;
pub mod local_link;
pub mod machine_mirror;
pub mod notice;
pub mod palette;
pub mod pane;
pub mod pane_drag;
pub mod path_display;
@@ -28,6 +28,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
pub mod prompt;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
@@ -35,6 +36,7 @@ pub mod right_panel;
pub mod rounding;
pub mod scm;
pub mod scrollbar;
pub mod search;
pub mod settings;
pub mod settings_window;
pub mod sftp;
-1877
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -20,6 +20,10 @@ pub struct PendingSpawn {
pub agent: Option<crate::core::cli_agent::CLIAgent>,
pub agent_session_id: Option<String>,
pub agent_launch_argv: Option<Vec<String>>,
/// A command line to type into the shell once the pane exists, for a pane
/// opened to run something (a quick-launched agent). Typed only into this
/// pane, and only when it is a fresh shell rather than a restored one.
pub run_on_land: Option<String>,
pub owner: Option<crate::core::session::WorkspaceId>,
pub font_size: f32,
}
+180
View File
@@ -0,0 +1,180 @@
//! The confirmation dialog on platforms with no native one.
//!
//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux
//! has neither, so gpui falls back to a renderer of its own — and that one sets
//! the message and the detail as single unbreakable lines inside a fixed-width
//! box that clips its overflow. Any sentence longer than the box is cut off
//! mid-word, which for the quit-and-stop warning is exactly the part that says
//! what will be lost (#920). This is the same dialog laid out as a card that
//! wraps its text, in the app's own surfaces, with Return and Escape answering
//! the way they do on the native dialogs.
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
/// Route every `window.prompt` through [`TextPrompt`] where the platform has
/// no dialog of its own to show.
pub(crate) fn install(cx: &mut App) {
if cfg!(any(target_os = "macos", target_os = "windows")) {
return;
}
cx.set_prompt_builder(build);
}
fn build(
_level: PromptLevel,
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
handle: PromptHandle,
window: &mut Window,
cx: &mut App,
) -> RenderablePromptHandle {
let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx));
handle.with_view(prompt, window, cx)
}
pub(crate) struct TextPrompt {
message: String,
detail: Option<String>,
answers: Vec<PromptButton>,
focus: FocusHandle,
}
impl TextPrompt {
fn new(
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
cx: &mut Context<Self>,
) -> Self {
Self {
message: message.to_string(),
detail: detail.map(str::to_string),
answers: answers.to_vec(),
focus: cx.focus_handle(),
}
}
/// The answer a key picks. Return is answer 0 — `confirm_answers` puts the
/// action there for exactly this — and Escape is the one marked cancel;
/// a prompt with no cancel answer has nothing Escape can safely mean.
fn answer_for_key(&self, key: &str) -> Option<usize> {
match key {
"enter" if !self.answers.is_empty() => Some(0),
"escape" => self.answers.iter().position(PromptButton::is_cancel),
_ => None,
}
}
}
impl EventEmitter<PromptResponse> for TextPrompt {}
impl Focusable for TextPrompt {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl Render for TextPrompt {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let muted = cx.theme().muted_foreground;
// Answer 0 goes rightmost, where the native dialogs put it and where
// `confirm_answers` expects it to land.
let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| {
Button::new(("prompt-answer", ix))
.label(answer.label().clone())
.small()
.when(ix == 0 && !answer.is_cancel(), |b| b.primary())
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}))
});
let card = v_flex()
.occlude()
.w(px(420.))
.max_w(relative(0.9))
.gap_3()
.p_5()
.map(|card| crate::ui::theme::floating_surface(card, cx))
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(self.message.clone()),
)
.children(
self.detail
.clone()
.map(|detail| div().text_sm().text_color(muted).child(detail)),
)
.child(
h_flex()
.pt_2()
.justify_end()
.flex_wrap()
.gap_2()
.children(buttons),
);
div()
.id("text-prompt")
.track_focus(&self.focus)
.size_full()
.cursor_default()
.bg(crate::ui::presets::scrim_fill(cx))
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| {
if let Some(ix) = this.answer_for_key(&ev.keystroke.key) {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}
}))
.flex()
.items_center()
.justify_center()
.child(card)
}
}
#[cfg(test)]
mod tests {
use super::TextPrompt;
use gpui::{AppContext as _, PromptButton, TestAppContext};
fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity<TextPrompt> {
cx.update(|cx| {
cx.new(|cx| {
TextPrompt::new(
"Quit and stop tty7 server?",
Some("a detail long enough to need more than one line"),
answers,
cx,
)
})
})
}
#[gpui::test]
fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel"));
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("enter"), Some(0));
assert_eq!(p.answer_for_key("escape"), Some(1));
assert_eq!(p.answer_for_key("a"), None);
});
}
#[gpui::test]
fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]);
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("escape"), None);
});
}
}
+2 -2
View File
@@ -157,8 +157,8 @@ pub fn filter_hosts(hosts: &[HostChoice], query: &str) -> Vec<HostChoice> {
}
fn host_score(query: &str, host: &HostChoice) -> Option<i32> {
let label = crate::ui::palette::fuzzy_score(query, &host.label);
let detail = crate::ui::palette::fuzzy_score(query, &host.detail).map(|score| score - 3);
let label = crate::ui::search::fuzzy_score(query, &host.label);
let detail = crate::ui::search::fuzzy_score(query, &host.detail).map(|score| score - 3);
label.into_iter().chain(detail).max()
}
+117 -13
View File
@@ -1,7 +1,7 @@
use gpui::{Axis, Bounds, Pixels, Point, Styled, px};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use tty7_core::core::group_key::GroupKey;
use tty7_core::core::group_key::{AutoKey, GroupId, GroupKey};
use tty7_core::core::machine::TabId;
pub(crate) type ReorderState = Rc<RefCell<Option<Reorder>>>;
@@ -63,28 +63,53 @@ pub(crate) fn clear_pending(state: &ReorderState) {
if let Some(r) = state.borrow().as_ref() {
r.pending.borrow_mut().take();
r.regroup.borrow_mut().take();
r.pin.set(false);
}
}
/// Offer the custom group the pointer is currently over.
/// Where a tab being dragged in the sidebar would go if it were let go now,
/// when that is somewhere other than a new place in its own group.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Regroup {
/// Into this pinned group.
Into(GroupId),
/// Below the divider: out of its pinned group, back to auto grouping.
ToAuto,
}
/// Offer the group the pointer is currently over.
///
/// Reordering answers "where in this group", and this answers "which group" —
/// two questions one drag can ask, so they are recorded side by side and
/// resolved together when it lands. A drag held over a group it did not come
/// from stops reordering (the surface it belongs to no longer sees the
/// pointer) and starts offering this instead.
pub(crate) fn set_regroup(state: &ReorderState, key: GroupKey) {
pub(crate) fn set_regroup(state: &ReorderState, target: Regroup) {
if let Some(r) = state.borrow().as_ref().filter(|r| !r.suspended.get()) {
*r.regroup.borrow_mut() = Some(key);
*r.regroup.borrow_mut() = Some(target);
}
}
/// Offer to pin the auto group being dragged — its header is above the
/// divider. Cleared with everything else each frame, so a header carried back
/// down before it is let go reorders instead.
pub(crate) fn set_pin(state: &ReorderState) {
if let Some(r) = state.borrow().as_ref().filter(|r| !r.suspended.get()) {
r.pin.set(r.auto.is_some());
}
}
/// What a finished drag asks for.
pub(crate) struct Landed {
/// A new order for the tabs, from the surface the drag ran over.
/// A new order, from the surface the drag ran over — of the tabs, or for
/// [`Surface::PinnedGroups`] of the pinned groups.
pub(crate) order: Option<Vec<usize>>,
/// The surface `order` is an order of.
pub(crate) surface: Option<Surface>,
/// A tab to put in another group, from the group it was held over.
pub(crate) regroup: Option<(TabId, GroupKey)>,
pub(crate) regroup: Option<(TabId, Regroup)>,
/// An auto group to pin, from its header being let go above the divider.
pub(crate) pin: Option<AutoKey>,
}
/// Ends the drag and answers what it was asking for when it ended.
@@ -92,13 +117,18 @@ pub(crate) fn take_landed(state: &ReorderState) -> Landed {
let Some(r) = state.borrow_mut().take() else {
return Landed {
order: None,
surface: None,
regroup: None,
pin: None,
};
};
let regroup = r.regroup.into_inner();
let pin = r.pin.get().then_some(r.auto).flatten();
Landed {
order: r.pending.into_inner(),
surface: Some(r.surface),
regroup: r.tab.zip(regroup),
pin,
}
}
@@ -111,6 +141,12 @@ pub(crate) fn dragged_tab(state: &ReorderState) -> Option<TabId> {
state.borrow().as_ref()?.tab
}
/// The auto group a *sidebar header* drag picked up, if it is one.
pub(crate) fn dragged_auto_group(state: &ReorderState) -> Option<AutoKey> {
let state = state.borrow();
state.as_ref().filter(|r| !r.suspended.get())?.auto.clone()
}
/// The tab a *sidebar row* drag picked up.
///
/// `None` for a drag that started anywhere else. The strip drags tabs too,
@@ -142,7 +178,13 @@ pub(crate) fn suspend(state: &ReorderState, yes: bool) {
pub(crate) enum Surface {
Strip,
SidebarRows(Option<GroupKey>),
/// The auto groups' headers, below the divider.
SidebarGroups,
/// The pinned groups' headers, above it. A surface of its own so a
/// pinned header can only be dropped among the pinned ones: the two
/// halves of the sidebar are ordered by different things — the user's
/// hand above, first appearance below.
PinnedGroups,
}
pub(crate) struct Reorder {
@@ -155,9 +197,14 @@ pub(crate) struct Reorder {
prev: Cell<usize>,
generation: Cell<usize>,
pending: RefCell<Option<Vec<usize>>>,
/// The custom group this drag is being held over, when the pointer has
/// left the group the tab came from.
regroup: RefCell<Option<GroupKey>>,
/// The group this drag is being held over, when the pointer has left the
/// group the tab came from.
regroup: RefCell<Option<Regroup>>,
/// The auto group whose header this drag picked up, for the one gesture
/// that pins: carrying it above the divider.
auto: Option<AutoKey>,
/// Set on the frames the carried auto header is above the divider.
pin: Cell<bool>,
/// The tab this drag picked up, for the surfaces that drag tabs. `None` on
/// a surface that drags something else — a sidebar group, say, which is
/// several tabs and cannot be merged into one.
@@ -185,6 +232,8 @@ impl Reorder {
generation: Cell::new(0),
pending: RefCell::new(None),
regroup: RefCell::new(None),
auto: None,
pin: Cell::new(false),
tab: None,
suspended: Cell::new(false),
}
@@ -196,6 +245,12 @@ impl Reorder {
self
}
/// Names the auto group whose header this drag is carrying.
pub(crate) fn of_auto(mut self, key: AutoKey) -> Self {
self.auto = Some(key);
self
}
pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool {
self.surface == *surface && self.rects.len() == len && self.from < len
}
@@ -448,18 +503,18 @@ mod tests {
#[test]
fn a_regroup_lasts_only_as_long_as_the_pointer_is_over_the_group() {
let tab = TabId::new();
let work = GroupKey::custom("work").expect("non-blank");
let work = Regroup::Into(GroupId::new());
let fresh = || Some(column(3, 30., 2., 0).of_tab(tab));
let state: ReorderState = Rc::new(RefCell::new(fresh()));
clear_pending(&state);
set_regroup(&state, work.clone());
set_regroup(&state, work);
clear_pending(&state);
assert_eq!(take_landed(&state).regroup, None, "the frame moved on");
*state.borrow_mut() = fresh();
clear_pending(&state);
set_regroup(&state, work.clone());
set_regroup(&state, work);
assert_eq!(take_landed(&state).regroup, Some((tab, work)));
}
@@ -469,10 +524,59 @@ mod tests {
fn a_drag_with_no_tab_never_lands_in_a_group() {
let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0))));
clear_pending(&state);
set_regroup(&state, GroupKey::custom("work").expect("non-blank"));
set_regroup(&state, Regroup::Into(GroupId::new()));
assert_eq!(take_landed(&state).regroup, None);
}
/// Pinning is offered frame by frame, like a regroup: a header carried
/// above the divider and back down before it is let go only reorders.
#[test]
fn a_pin_lasts_only_as_long_as_the_header_is_above_the_divider() {
let key = AutoKey::Repo("/w/r".into());
let fresh = || Some(column(3, 30., 2., 0).of_auto(key.clone()));
let state: ReorderState = Rc::new(RefCell::new(fresh()));
clear_pending(&state);
set_pin(&state);
clear_pending(&state);
assert_eq!(take_landed(&state).pin, None, "carried back down");
*state.borrow_mut() = fresh();
clear_pending(&state);
set_pin(&state);
assert_eq!(take_landed(&state).pin, Some(key));
}
/// A finished drag says which surface its order is for: the pinned
/// headers' order is the list's, not the tabs'.
#[test]
fn a_landing_names_the_surface_its_order_is_for() {
let r = Reorder::new(
Surface::PinnedGroups,
0,
column(3, 30., 2., 0).rects,
Axis::Vertical,
px(2.),
point(px(0.), px(0.)),
);
let state: ReorderState = Rc::new(RefCell::new(Some(r)));
clear_pending(&state);
set_pending(&state, &Surface::PinnedGroups, vec![1, 0, 2]);
let landed = take_landed(&state);
assert_eq!(landed.surface, Some(Surface::PinnedGroups));
assert_eq!(landed.order, Some(vec![1, 0, 2]));
}
/// Only an auto header can be pinned by dragging; a drag carrying
/// anything else offers nothing.
#[test]
fn a_drag_with_no_auto_group_never_pins() {
let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0))));
clear_pending(&state);
set_pin(&state);
assert_eq!(take_landed(&state).pin, None);
}
#[test]
fn begin_frame_bumps_the_generation_only_on_change() {
let r = column(3, 30., 2., 0);
+48 -2
View File
@@ -11,6 +11,7 @@ use crate::core::config::{Config, RightPanelTab};
use crate::daemon::protocol::{ManagedForward, PaneProcs, PortProbe};
use crate::ui::app::{
CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_SM, TILE_SIZE_XS, Tty7App, tile_trailing_inset,
tile_trailing_inset_sm,
};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::scrollbar::with_vertical_scrollbar;
@@ -174,8 +175,11 @@ const HEADING_TILE_GLYPH: f32 = 12.;
/// Local forwards only, and only those aimed at the far host's own loopback:
/// a forward to some third machine happens to carry the same number, and
/// pairing it with the port row would claim it leads somewhere it does not.
/// Nor does a switched-off forward: it leads nowhere until it is switched back
/// on, and its own row is where that switch is.
pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool {
m.kind == crate::daemon::protocol::SshForwardKind::Local
m.enabled
&& m.kind == crate::daemon::protocol::SshForwardKind::Local
&& m.target_port == port
&& crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host)
}
@@ -804,6 +808,18 @@ impl Tty7App {
input: &gpui::Entity<gpui_component::input::InputState>,
cx: &mut Context<Self>,
) -> AnyElement {
self.panel_search_with(input, None, cx)
}
/// [`Self::panel_search`] with a tile at its far end, inset the way the
/// branch row's tile is so the two stack in one column.
pub(crate) fn panel_search_with(
&self,
input: &gpui::Entity<gpui_component::input::InputState>,
trailing: Option<AnyElement>,
cx: &mut Context<Self>,
) -> AnyElement {
let has_trailing = trailing.is_some();
// A 28px well on the faint fill, the same shape as the sidebar's
// search. It is a list-width box — `CONTENT_INSET` from both edges,
// like a row's hover fill — with its glyph on the text column.
@@ -840,6 +856,8 @@ impl Tty7App {
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
),
)
.when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm())))
.children(trailing)
.into_any_element()
}
@@ -1432,8 +1450,26 @@ impl Tty7App {
}),
);
if let Some(f) = forward {
tiles_wide += 1;
tiles_wide += 2;
let forward_id = f.id;
// Switched off, the forward stops speaking for this row and
// comes out as a row of its own with the switch to turn it
// back on.
actions = actions.child(
crate::ui::tab_strip::chrome_tile_sized(
Button::new(("panel-port-switch-off", i))
.icon(Icon::empty().path("icons/power.svg")),
TILE_SIZE_XS,
TILE_GLYPH_XS,
false,
cx,
)
.rounded(px(4.))
.tooltip(t(L10nKey::ForwardTooltipTurnOff))
.on_click(cx.listener(move |this, _, window, cx| {
this.set_managed_forward_enabled(pane_id, forward_id, false, window, cx)
})),
);
actions = actions.child(
self.info_tile(
("panel-port-unforward", i),
@@ -2056,6 +2092,7 @@ mod tests {
target_port,
description: None,
status: ForwardStatus::Listening,
enabled: true,
}
}
@@ -2087,6 +2124,15 @@ mod tests {
);
}
/// A switched-off forward reaches nothing, so it must not take over the
/// port row — its own row is the only place its switch is.
#[test]
fn a_switched_off_forward_is_not_the_way_to_a_port() {
let mut off = forward(SshForwardKind::Local, "localhost", 3000);
off.enabled = false;
assert!(!forwards_port(&off, 3000));
}
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
InfoRow {
label: "changes",
+10
View File
@@ -71,6 +71,16 @@ impl Tty7App {
cx.notify();
}
/// Switch the changed-file list between flat and tree, and remember it.
///
/// Global like `diff_view`: someone who reads changes by directory reads
/// every repository's that way.
pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context<Self>) {
let next = !cx.global::<crate::core::config::Config>().scm_changes_tree;
self.update_config(cx, |cfg| cfg.scm_changes_tree = next);
cx.notify();
}
/// Flip the diff overlay between side-by-side and unified.
///
/// Global rather than per-overlay, matching `diffEditor.renderSideBySide`:

Some files were not shown because too many files have changed in this diff Show More