diff --git a/CHANGELOG.md b/CHANGELOG.md index f77640ff..d92dc9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`. "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/`. 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.** ⌘ P + (Ctrl ⇧ P elsewhere) opens one search over four tabs — **All**, + **Actions**, **Terminals** and **Hosts** — walked with ⇥ / + ⇧ ⇥, 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, ⌘ P + ⏎ 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 ⏎ 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 diff --git a/README.md b/README.md index a29edb91..23f4ad34 100644 --- a/README.md +++ b/README.md @@ -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 · ⌃ R fuzzy history | -| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | +| **Window** | tabs & splits · ⌘ P Search Everywhere · ⌘ F scrollback search · ⌘ J 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.
-The full support matrix, all twenty-three +The full support matrix, all twenty-five | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -79,6 +79,7 @@ that tells tty7 which session to fork. | **Grok** | ✓ | ✓ | ✓ | | **OpenCode** | ✓ | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | +| **Prime Agent** | ✓ | ✓ | ✓ | | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | @@ -89,19 +90,22 @@ that tells tty7 which session to fork. | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | | **Crush** | ✓ | ✓ | | +| **Antigravity** | ✓ | ✓ | | | **Cursor** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Auggie | ✓ | | | | Hermes | ✓ | | | | Vibe | ✓ | | | -| Antigravity | ✓ | | | +| Empryo | ✓ | | |
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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 67a78a13..64e9429b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊搜索历史 | -| **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · ⌘ J 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 | +| **窗口** | 标签页与分屏 · ⌘ P 随处搜索 · ⌘ F 回滚搜索 · ⌘ J 侧栏列出进程树和监听端口 · 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 | ✓ | | | tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。 如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。 +PATH 上的每个 agent 也都能在随处搜索里找到(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。 ## 文档 diff --git a/assets/icons/list-flat.svg b/assets/icons/list-flat.svg new file mode 100644 index 00000000..b6249b13 --- /dev/null +++ b/assets/icons/list-flat.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/list-tree.svg b/assets/icons/list-tree.svg new file mode 100644 index 00000000..04086a1a --- /dev/null +++ b/assets/icons/list-tree.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/power.svg b/assets/icons/power.svg new file mode 100644 index 00000000..16fc83f7 --- /dev/null +++ b/assets/icons/power.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 448cfc66..7807524d 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -61,6 +61,17 @@ pub trait Backend { fn run_wait(&mut self) -> Result>; + /// 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, + timeout: Option, + ) -> Result; + 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, /// Set to make `hello` fail — doctor's unreachable-server branch. pub unreachable: bool, + pub execs: Vec<(u64, Vec, Option)>, + 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, + timeout: Option, + ) -> Result { + 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)?; diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index 9447eac6..8723d2ea 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -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, timeout: Option) -> Result { + 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 { + 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() { diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 7cfd0b73..368d3be2 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -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, } +#[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, + + #[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, + + // 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, +} + #[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, + + // 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, + + #[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"); + } + } } diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index e251c486..45198d3f 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -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 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 { - 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 { + 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 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, + }, +} + +fn deliver( + pane: u64, + payload: Payload, + args: &SendArgs, + pressed: Vec, + backend: &mut dyn Backend, +) -> Result { + 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 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::>(), }), @@ -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 { + 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 { 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, @@ -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 = 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) -> 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()); + } } diff --git a/crates/tty7-cli/src/exec.rs b/crates/tty7-cli/src/exec.rs new file mode 100644 index 00000000..623ab544 --- /dev/null +++ b/crates/tty7-cli/src/exec.rs @@ -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;` 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, 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), +} + +/// 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, + /// 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, + /// 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, + /// Where the newest `D` after `started` begins: where the output ends. + ended: Option, + /// Where an `A` that arrived with no `C` before it begins — the shell drew + /// a fresh prompt without running the line. + reprompted: Option, + exit_mark: Option, +} + +/// What the daemon's `Prompt` report made of the pane. +pub struct Finished { + pub exit: Option, + 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) -> Option { + 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, 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); + } +} diff --git a/crates/tty7-cli/src/keys.rs b/crates/tty7-cli/src/keys.rs index 6762cf05..545bded9 100644 --- a/crates/tty7-cli/src/keys.rs +++ b/crates/tty7-cli/src/keys.rs @@ -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(", ") diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index b4ef3f1b..b9f8a9c9 100644 --- a/crates/tty7-cli/src/main.rs +++ b/crates/tty7-cli/src/main.rs @@ -2,6 +2,7 @@ mod address; mod backend; mod cli; mod commands; +mod exec; mod gui; mod keys; mod output; diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index d92ab7ef..85639207 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -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) -> 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}"); + } } diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 22cf5c24..84482064 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -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, diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index d06c050e..89f5e70e 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -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:?}"); +} diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 8af07878..6136f61e 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,6 +43,26 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { + // Antigravity has no turn-start event, only `PreInvocation` before every + // model call. The first call of a turn starts it; later ones are the same + // turn still working, and must not count as new prompts. `Stop` with + // `fullyIdle: false` ends one execution loop while more are queued. + if agent == "antigravity" + && let Ok(payload) = serde_json::from_str::(stdin_json) + { + if event == "prompt-submit" + && payload + .get("invocationNum") + .and_then(|n| n.as_u64()) + .unwrap_or(0) + > 0 + { + return Some("tool-complete"); + } + if event == "stop" && payload.get("fullyIdle").and_then(|v| v.as_bool()) == Some(false) { + return None; + } + } // Qoder and CodeBuddy also emit SessionStart after compacting the active // turn. Preserve its status until a real turn or session boundary arrives. if matches!(agent, "qodercli" | "codebuddy") @@ -90,11 +110,25 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { body[key] = serde_json::Value::String(v.to_string()); } } + // Antigravity names the session after the conversation, in camelCase. + // Scoped to it: the alias table is last-write-wins, and another agent's + // unrelated `conversationId` must not replace its real session id. + if agent == "antigravity" + && body.get("session_id").is_none() + && let Some(id) = payload + .get("conversationId") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + { + body["session_id"] = serde_json::Value::String(id.to_string()); + } // Cursor puts `cwd` only on its tool events; the rest carry the workspace // roots instead, and the first of those is where the session runs. + // Antigravity's `workspacePaths` is the same list under its own name. if body.get("cwd").is_none() && let Some(root) = payload .get("workspace_roots") + .or_else(|| payload.get("workspacePaths")) .and_then(|r| r.as_array()) .and_then(|r| r.first()) .and_then(|r| r.as_str()) @@ -285,10 +319,12 @@ pub enum HookAgent { Crush, CodeBuddy, Cursor, + PrimeAgent, + Antigravity, } impl HookAgent { - pub const ALL: [HookAgent; 17] = [ + pub const ALL: [HookAgent; 19] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -306,6 +342,8 @@ impl HookAgent { HookAgent::Crush, HookAgent::CodeBuddy, HookAgent::Cursor, + HookAgent::PrimeAgent, + HookAgent::Antigravity, ]; /// The hooks behind a detected agent process, if it has any. @@ -323,6 +361,8 @@ impl HookAgent { CLIAgent::Pi => Some(HookAgent::Pi), CLIAgent::Grok => Some(HookAgent::Grok), CLIAgent::OhMyPi => Some(HookAgent::OhMyPi), + CLIAgent::PrimeAgent => Some(HookAgent::PrimeAgent), + CLIAgent::Antigravity => Some(HookAgent::Antigravity), CLIAgent::Gemini => Some(HookAgent::Gemini), CLIAgent::Droid => Some(HookAgent::Droid), CLIAgent::Qwen => Some(HookAgent::Qwen), @@ -337,7 +377,7 @@ impl HookAgent { | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity => None, + | CLIAgent::Empryo => None, } } @@ -361,6 +401,8 @@ impl HookAgent { | HookAgent::Pi | HookAgent::Grok | HookAgent::OhMyPi + | HookAgent::PrimeAgent + | HookAgent::Antigravity | HookAgent::Goose | HookAgent::Kimi => None, } @@ -406,6 +448,8 @@ impl HookAgent { HookAgent::Pi => "pi", HookAgent::Grok => "grok", HookAgent::OhMyPi => "omp", + HookAgent::PrimeAgent => "prime-agent", + HookAgent::Antigravity => "antigravity", HookAgent::Gemini => "gemini", HookAgent::Droid => "droid", HookAgent::Qwen => "qwen", @@ -428,6 +472,8 @@ impl HookAgent { HookAgent::Pi => "Pi", HookAgent::Grok => "Grok Build", HookAgent::OhMyPi => "Oh My Pi", + HookAgent::PrimeAgent => "Prime Agent", + HookAgent::Antigravity => "Antigravity", HookAgent::Gemini => "Gemini", HookAgent::Droid => "Droid", HookAgent::Qwen => "Qwen Code", @@ -455,6 +501,10 @@ impl HookAgent { &["opencode", "plugins", OWNED_FILE_STEM_JS], ), HookAgent::Pi => target.under_home(&[".pi", "agent", "extensions", "tty7", "index.ts"]), + HookAgent::PrimeAgent => { + target.under_home(&[".prime", "agent", "extensions", "tty7", "index.ts"]) + } + HookAgent::Antigravity => target.under_home(&[".gemini", "config", "hooks.json"]), HookAgent::Grok => target.under_home(&[".grok", "hooks", OWNED_FILE_STEM_JSON]), HookAgent::OhMyPi => { target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"]) @@ -683,6 +733,9 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + return named_hook_set_state(target, &path, &antigravity_hook_set(target)); + } if let Some(events) = agent.toml_hook_events() { return toml_hooks_state(target, &path, agent, events); } @@ -719,6 +772,10 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + named_hook_set_install(target, &path, antigravity_hook_set(target))?; + return Ok(HookOutcome::Installed); + } if let Some(events) = agent.toml_hook_events() { toml_hooks_install(target, &path, agent, events)?; return Ok(HookOutcome::Installed); @@ -744,6 +801,9 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + return named_hook_set_uninstall(target, &path); + } if agent.toml_hook_events().is_some() { return toml_hooks_uninstall(target, &path, agent); } @@ -1324,11 +1384,108 @@ fn enable_codex_hooks_feature() -> Result<(), String> { } } +/// The key tty7's entry lives under in Antigravity's `hooks.json`. +/// +/// Antigravity names every hook set at the root of the file — +/// `{"": {"": [...]}}` — rather than grouping handlers under a +/// shared `hooks` object the way Claude does, so tty7 owns one named set and +/// never has to pick its commands out of the user's lists. +const ANTIGRAVITY_HOOK_SET: &str = "tty7"; + +/// Antigravity CLI's lifecycle events, mapped onto tty7's. +/// +/// It has no session-start or session-end event. `PreInvocation` fires before +/// every model call of a turn, so `effective_event` keeps only the first one +/// as the turn start; `Stop` fires when the execution loop ends. The +/// `PreToolUse` gate is left alone: it must answer with a `decision`, and an +/// empty reply from a status hook has no business deciding anything. +fn antigravity_hook_set(target: &HookTarget) -> serde_json::Value { + let handler = |event: &str| { + serde_json::json!({ + "type": "command", + "command": target.hook_command(HookAgent::Antigravity, event), + "timeout": 5, + }) + }; + serde_json::json!({ + "PreInvocation": [handler("prompt-submit")], + "PostToolUse": [{ "matcher": "*", "hooks": [handler("tool-complete")] }], + "Stop": [handler("stop")], + }) +} + +fn read_json_object(target: &HookTarget, path: &Path) -> anyhow::Result> { + let text = match target.read(path) { + Ok(text) => text, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + if text.trim().is_empty() { + return Ok(Some(serde_json::json!({}))); + } + let root: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not valid JSON ({e}); not touching it", + path.display() + ) + })?; + if !root.is_object() { + return Err(anyhow::anyhow!( + "{} is not a JSON object; not touching it", + path.display() + )); + } + Ok(Some(root)) +} + +fn named_hook_set_state( + target: &HookTarget, + path: &Path, + expected: &serde_json::Value, +) -> HooksState { + match read_json_object(target, path) { + Ok(Some(root)) => match root.get(ANTIGRAVITY_HOOK_SET) { + None => HooksState::NotInstalled, + Some(set) if set == expected => HooksState::Installed, + Some(_) => HooksState::Outdated, + }, + _ => HooksState::NotInstalled, + } +} + +fn named_hook_set_install( + target: &HookTarget, + path: &Path, + set: serde_json::Value, +) -> anyhow::Result<()> { + let mut root = read_json_object(target, path)?.unwrap_or_else(|| serde_json::json!({})); + root.as_object_mut() + .unwrap() + .insert(ANTIGRAVITY_HOOK_SET.to_string(), set); + target.write(path, serde_json::to_string_pretty(&root)?.as_bytes()) +} + +fn named_hook_set_uninstall(target: &HookTarget, path: &Path) -> anyhow::Result { + let Some(mut root) = read_json_object(target, path)? else { + return Ok(HookOutcome::NothingInstalled); + }; + if root + .as_object_mut() + .unwrap() + .remove(ANTIGRAVITY_HOOK_SET) + .is_none() + { + return Ok(HookOutcome::NoTty7Hooks); + } + target.write(path, serde_json::to_string_pretty(&root)?.as_bytes())?; + Ok(HookOutcome::Removed) +} + fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { match agent { HookAgent::Copilot => copilot_hooks_json(target), HookAgent::OpenCode => opencode_plugin_js(target), - HookAgent::Pi | HookAgent::OhMyPi => pi_extension_ts(target, agent), + HookAgent::Pi | HookAgent::OhMyPi | HookAgent::PrimeAgent => pi_extension_ts(target, agent), HookAgent::Grok => grok_hooks_json(target), HookAgent::Goose => goose_hooks_json(target), HookAgent::Claude @@ -1341,6 +1498,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Crush | HookAgent::CodeBuddy | HookAgent::Cursor + | HookAgent::Antigravity | HookAgent::Kimi => None, } } @@ -1568,6 +1726,7 @@ fn pi_extension_ts(target: &HookTarget, agent: HookAgent) -> Option { let (slug, package) = match agent { HookAgent::Pi => ("pi", "@mariozechner/pi-coding-agent"), HookAgent::OhMyPi => ("omp", "@oh-my-pi/pi-coding-agent"), + HookAgent::PrimeAgent => ("prime-agent", "@earendil-works/pi-coding-agent"), _ => return None, }; let exe = serde_json::to_string(&target.exe.display().to_string()).ok()?; @@ -1838,6 +1997,10 @@ mod tests { (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), + ( + HookAgent::PrimeAgent, + "/home/me/.prime/agent/extensions/tty7/index.ts", + ), ] { assert_eq!( agent.target_path(&t), @@ -2582,6 +2745,127 @@ mod tests { ); } + #[test] + fn antigravity_owns_a_named_hook_set_beside_the_users() { + let dir = std::env::temp_dir().join(format!("tty7-agy-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let host = FakeRemote::shared(); + let target = HookTarget::remote(&*host, dir.clone()); + let path = HookAgent::Antigravity.target_path(&target); + assert_eq!(path, dir.join(".gemini/config/hooks.json")); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let theirs = serde_json::json!({ + "my-linter-hook": { + "PostToolUse": [{ "matcher": "run_command", "hooks": [{ "command": "./lint.sh" }] }] + } + }); + std::fs::write(&path, theirs.to_string()).unwrap(); + + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::NotInstalled + ); + install_hooks(&target, HookAgent::Antigravity).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Installed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root["my-linter-hook"], theirs["my-linter-hook"], + "the user's set survives" + ); + let set = &root[ANTIGRAVITY_HOOK_SET]; + assert!( + set["PreInvocation"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook antigravity prompt-submit") + ); + assert_eq!(set["PostToolUse"][0]["matcher"], "*"); + assert!( + set["Stop"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook antigravity stop") + ); + assert!( + set.get("PreToolUse").is_none(), + "a status hook must not gate tools" + ); + + root_set_to(&path, serde_json::json!({ "Stop": [] }), &root); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Outdated + ); + install_hooks(&target, HookAgent::Antigravity).expect("reinstall succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Installed + ); + + assert_eq!( + uninstall_hooks(&target, HookAgent::Antigravity).expect("uninstall succeeds"), + HookOutcome::Removed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root, theirs, + "uninstall leaves exactly what was there before" + ); + assert_eq!( + uninstall_hooks(&target, HookAgent::Antigravity).expect("second uninstall"), + HookOutcome::NoTty7Hooks + ); + let _ = std::fs::remove_dir_all(&dir); + } + + fn root_set_to(path: &Path, set: serde_json::Value, root: &serde_json::Value) { + let mut root = root.clone(); + root[ANTIGRAVITY_HOOK_SET] = set; + std::fs::write(path, root.to_string()).unwrap(); + } + + #[test] + fn antigravity_payloads_map_onto_turns() { + let first = r#"{"conversationId":"c-1","invocationNum":0,"workspacePaths":["/repo"]}"#; + let later = r#"{"conversationId":"c-1","invocationNum":2,"workspacePaths":["/repo"]}"#; + assert_eq!( + effective_event("antigravity", "prompt-submit", first), + Some("prompt-submit") + ); + assert_eq!( + effective_event("antigravity", "prompt-submit", later), + Some("tool-complete") + ); + assert_eq!( + effective_event( + "antigravity", + "stop", + r#"{"conversationId":"c-1","fullyIdle":true}"# + ), + Some("stop") + ); + assert_eq!( + effective_event( + "antigravity", + "stop", + r#"{"conversationId":"c-1","fullyIdle":false}"# + ), + None + ); + let ev = round_trip("antigravity", "prompt-submit", first); + assert_eq!(ev.session_id.as_deref(), Some("c-1")); + assert_eq!(ev.cwd.as_deref(), Some(Path::new("/repo"))); + assert_eq!( + ev.agent, + Some(crate::core::cli_agent::CLIAgent::Antigravity) + ); + } + #[test] fn a_remote_install_round_trips_through_the_host() { let dir = std::env::temp_dir().join(format!("tty7-remote-hooks-{}", std::process::id())); @@ -2679,6 +2963,11 @@ mod tests { for (agent, slug, package) in [ (HookAgent::Pi, "pi", "@mariozechner/pi-coding-agent"), (HookAgent::OhMyPi, "omp", "@oh-my-pi/pi-coding-agent"), + ( + HookAgent::PrimeAgent, + "prime-agent", + "@earendil-works/pi-coding-agent", + ), ] { let bridge = pi_extension_ts(&target, agent).unwrap_or_else(|| panic!("{slug} content builds")); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index eb7d5865..0620a50b 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -29,10 +29,12 @@ pub enum CLIAgent { QoderCLI, Crush, CodeBuddy, + Empryo, + PrimeAgent, } impl CLIAgent { - pub const ALL: [CLIAgent; 23] = [ + pub const ALL: [CLIAgent; 25] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -56,6 +58,8 @@ impl CLIAgent { CLIAgent::QoderCLI, CLIAgent::Crush, CLIAgent::CodeBuddy, + CLIAgent::Empryo, + CLIAgent::PrimeAgent, ]; fn aliases(self) -> &'static [&'static str] { @@ -106,6 +110,8 @@ impl CLIAgent { // `cbc` also names the COIN-OR solver; a solver run wearing the // avatar until it exits is the cost of catching the short name. CLIAgent::CodeBuddy => &["codebuddy", "codebuddy-code", "cbc"], + CLIAgent::Empryo => &["empryo"], + CLIAgent::PrimeAgent => &["prime-agent"], } } @@ -134,6 +140,8 @@ impl CLIAgent { CLIAgent::QoderCLI => "qodercli", CLIAgent::Crush => "crush", CLIAgent::CodeBuddy => "codebuddy", + CLIAgent::Empryo => "empryo", + CLIAgent::PrimeAgent => "prime-agent", } } @@ -167,6 +175,8 @@ impl CLIAgent { CLIAgent::QoderCLI => "Qoder CLI", CLIAgent::Crush => "Crush", CLIAgent::CodeBuddy => "CodeBuddy", + CLIAgent::Empryo => "Empryo", + CLIAgent::PrimeAgent => "Prime Agent", } } @@ -204,13 +214,15 @@ impl CLIAgent { CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), CLIAgent::CodeBuddy => Some(format!("codebuddy{flags} --resume {session_id}")), + CLIAgent::Empryo => Some(format!("empryo{flags} --session {session_id}")), + CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --resume {session_id}")), _ => None, } } fn opts_out_of_sessions(self, argv: &[String]) -> bool { let ephemeral: &[&str] = match self { - CLIAgent::Pi | CLIAgent::OhMyPi => &["--no-session"], + CLIAgent::Pi | CLIAgent::OhMyPi | CLIAgent::PrimeAgent => &["--no-session"], // "Do not save conversation history" — nothing is persisted, so // there is no session left to resume from. CLIAgent::Auggie => &["--dont-save-session"], @@ -245,6 +257,7 @@ impl CLIAgent { )), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), + CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --fork {session_id}")), // Droid forks with a standalone flag rather than resume-plus-a-switch. CLIAgent::Droid => Some(format!("droid{flags} --fork {session_id}")), // `fork` is missing from `amp threads --help`, but the subcommand is @@ -267,6 +280,7 @@ impl CLIAgent { | CLIAgent::Grok | CLIAgent::OpenCode | CLIAgent::OhMyPi + | CLIAgent::PrimeAgent | CLIAgent::Droid | CLIAgent::Amp | CLIAgent::Qwen @@ -304,6 +318,32 @@ impl CLIAgent { } fn replay_flags(self, argv: &[String]) -> Option> { + 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> { 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 `; `--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 { + 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> { + 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 = 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 { 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 { + 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) -> HashMap { + 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 { + 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)); + } } diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index c687d1a7..11a56a5b 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -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, + /// 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, - #[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, #[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, + /// 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, #[serde(default)] pub command_frecency: HashMap, #[serde(default)] pub agent_commands: HashMap, + /// 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, + /// 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, #[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, + ssh_profile_frecency: HashMap, +} + +#[derive(Serialize)] +struct ServersFileRef<'a> { + ssh_profiles: &'a [crate::core::ssh_profile::SshProfile], + ssh_profile_frecency: &'a HashMap, +} + +/// 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(default)] + ssh_profile_frecency: Option, +} + +fn has_legacy_server_keys(text: &str) -> bool { + serde_json::from_str::(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 { + 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) { + 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::(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::(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::(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 { @@ -1194,15 +1438,44 @@ pub fn extra_env() -> HashMap { Config::load().env } -pub fn agent_commands_cached() -> &'static HashMap { - static CACHE: std::sync::OnceLock> = 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> { + type Stamp = Option<(std::time::SystemTime, u64)>; + static CACHE: std::sync::Mutex>)>> = + 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, + launch: &HashMap, +) -> HashMap { + 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); + } } diff --git a/crates/tty7-core/src/core/group_key.rs b/crates/tty7-core/src/core/group_key.rs index 5992fbc3..99a8f0b4 100644 --- a/crates/tty7-core/src/core/group_key.rs +++ b/crates/tty7-core/src/core/group_key.rs @@ -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, + /// 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, + #[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) -> 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 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 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 { - 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 { 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 { - 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(&self, ser: S) -> Result { - 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, + /// 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, + #[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 { + pinned_folder_for(&self.pinned, cwd, repo_home) } } -impl<'de> serde::Deserialize<'de> for GroupKey { - fn deserialize>(de: D) -> Result { - 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 { + 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> { + 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, + groups: &WorkspaceGroups, + auto_grouping: bool, + auto: Option, +) -> Option { + 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>, +} + +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) -> Option { + 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 { + 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")); } } diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 2a0fdb29..7040d3ab 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -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, + /// 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, - #[serde(default)] - pub sidebar_group: Option, + /// 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, + /// 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, 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, + group: Option, + #[serde(default)] + last_auto: Option, + }, + 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, + ) -> io::Result> { + 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, + group: Option, + last_auto: Option, origin: Option, ) -> 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, + ) -> 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![ + 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); + } } diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 43a72d97..e2ed7f97 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -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; diff --git a/crates/tty7-core/src/core/paste.rs b/crates/tty7-core/src/core/paste.rs new file mode 100644 index 00000000..692dc32a --- /dev/null +++ b/crates/tty7-core/src/core/paste.rs @@ -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 { + 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 { + 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~" + ); + } +} diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index f316a2af..4eed1d90 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -50,10 +50,25 @@ pub struct SessionTab { #[serde(default)] pub name: Option, 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, + pub group: Option, + /// 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, #[serde(skip)] pub tree_id: Option, + /// 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, } #[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, } 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); + } } diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 97a43d5c..651aaea0 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -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 { diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs index b23763df..74bdf6e9 100644 --- a/crates/tty7-core/src/core/term_modes.rs +++ b/crates/tty7-core/src/core/term_modes.rs @@ -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> { @@ -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)); + } } diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 136a3164..3587f55c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -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, + group: Option, + /// The auto group the tab last resolved to — see `Tab::last_auto`. + #[serde(default)] + last_auto: Option, + }, + /// 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 { .. } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 8c9650a8..6492ca1f 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -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 Option + Send>, remote: Box Option + Send>, agent: Box Option)>> + Send>, cwd: Box Option + 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, ) -> 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>); + + impl Read for Chunks { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + 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| { + 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" + ); + } } diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs index 217a530b..e55581fb 100644 --- a/crates/tty7-core/src/daemon/procinfo.rs +++ b/crates/tty7-core/src/daemon/procinfo.rs @@ -225,23 +225,15 @@ fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec< #[cfg(target_os = "macos")] fn process_table() -> HashMap { 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::()) + 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::()) as libc::c_int, - ) - }; - if written <= 0 { - return table; - } - let n = written as usize / std::mem::size_of::(); - 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 { 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 { + 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 = 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 = (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 = [(100, row(1, "zsh"))].into_iter().collect(); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 3f9632f4..fad170f6 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -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, + /// 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, 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, } 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())); + } } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index d2a43c73..de4c4f5f 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -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 { let panes: Vec> = 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) -> 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(®istry, 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(); diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs index 726ff9f1..e6d055ef 100644 --- a/crates/tty7-core/src/daemon/ssh/forward.rs +++ b/crates/tty7-core/src/daemon/ssh/forward.rs @@ -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, 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>, + forward_id: u64, + enabled: bool, + ) -> Result, 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, + forward_id: u64, + enabled: bool, + ) -> Result, 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>, + forward_id: u64, + enabled: bool, + ) -> Result, 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>, + 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, 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 { + 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, + 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, + forward_id: u64, + enabled: bool, + ) -> Result, 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(®, 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(®, ForwardOwner::Pane(7), 0).await; + let port = reg.list(7)[0].bind_port; + push_disabled(®, 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( + ®, + 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(®, 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(®, 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 = ®.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"); + } } diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 6cfddade..937c06ef 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -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>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.runtime.block_on( + self.forwards + .set_enabled(pane_id, conn, forward_id, enabled), + ) + } + pub fn list_forwards(&self, pane_id: u64) -> Vec { self.forwards.list(pane_id) } diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs index e022de4e..657faffb 100644 --- a/crates/tty7-core/src/daemon/ssh/workspace.rs +++ b/crates/tty7-core/src/daemon/ssh/workspace.rs @@ -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); diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 6913a5e8..6eb9bb9d 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -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( 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 { 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, + stopped: std::sync::Mutex>, + } + + impl PaneDirectory for SleepRecorder { + fn pane_count(&self) -> u64 { + 0 + } + + fn panes(&self) -> Vec { + Vec::new() + } + + fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs { + Default::default() + } + + fn agent_states(&self) -> Vec { + 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)] diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 22ae262a..a6d90cfa 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -95,7 +95,7 @@ impl Instance { {{ "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "name": null, - "sidebar_group": null, + "group": null, "root": {{ "Leaf": {{ "pane": {pane_id} }} }} }} ], diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index a3b446f7..05744bf9 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty-three CLIs are recognised on sight, by the command running in the pane: +Twenty-five CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -42,6 +42,8 @@ Twenty-three CLIs are recognised on sight, by the command running in the pane: | Antigravity | `agy`, `antigravity` | | Pi | `pi` | | Oh My Pi | `omp` | +| Prime Agent | `prime-agent` | +| Empryo | `empryo` | Detection sees through the usual disguises: a full path, a `.cmd` or `.exe` on Windows, leading environment assignments, and an interpreter in front @@ -64,7 +66,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** +(⌘ ⇧ A 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:`. + +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) - 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. diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index 90703839..4a25c8d4 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -43,6 +43,7 @@ or right-click the **tab or sidebar row** to open the fork in a new tab. | Grok | `grok --resume --fork-session` | | OpenCode | `opencode --session --fork` | | Oh My Pi | `omp --fork ` | +| Prime Agent | `prime-agent --fork ` | It is the agent's own fork command, run in a new pane — nothing is copied by tty7 itself. @@ -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. diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 34dcebf8..40e917e7 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,8 +14,8 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI | Hooks available | -| Aider · Amp · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity | Hooks available | +| Aider · Amp · Auggie · Hermes · Vibe · Empryo | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed the row grows a second **Uninstall** button beside the first, which itself @@ -28,6 +28,13 @@ ships a newer hook. carries no status dot and `tty7 wait` times out rather than returning. + + Antigravity has no session-start or permission event, so its pane goes + straight from no dot to working on the first model call, and a pending + permission prompt shows as working rather than amber. Its hooks live in a + `tty7` set inside `~/.gemini/config/hooks.json`, beside any sets of your own. + + `cursor-agent -p` (print mode) sends neither of Cursor's turn hooks, so a headless run records its session id — Copy Session ID and resume still @@ -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 | diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 6c0fac7f..9846cbb2 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -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: `@`. | 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":[…]}` | diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx index 821cec0e..12c3a3d3 100644 --- a/docs/customization/keybindings.mdx +++ b/docs/customization/keybindings.mdx @@ -4,7 +4,7 @@ description: "Rebinding anything, chord sequences, and the tmux preset." --- **Settings → Keyboard & Mouse → Keyboard shortcuts** (⌘ ,) 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 diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index d894d4db..1849e607 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -30,7 +30,8 @@ section something is in. Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard. - Tab position and placement, sidebar grouping, and diff previews. + Tab position and placement, sidebar auto grouping, diff previews, and what + SSH tabs are called. 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. diff --git a/docs/customization/themes.mdx b/docs/customization/themes.mdx index 6dd545c2..d8f0e4ab 100644 --- a/docs/customization/themes.mdx +++ b/docs/customization/themes.mdx @@ -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 diff --git a/docs/design-system.md b/docs/design-system.md index 295ffcb2..cb8c4a61 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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、设置、菜单与随处搜索。测试通过不能替代视觉层级检查。 diff --git a/docs/docs.json b/docs/docs.json index 762af9e8..3d23da68 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" + } + ] } diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index ae7893dc..74340d52 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -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 ⌘ , (Ctrl , on Windows and Linux), or -from the command palette (⌘ P → *Settings*). +from Search Everywhere (⌘ P → *Settings*). 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. diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx index 5d55cd44..1c277df1 100644 --- a/docs/git/diffs.mdx +++ b/docs/git/diffs.mdx @@ -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`. diff --git a/docs/git/source-control.mdx b/docs/git/source-control.mdx index b8260887..2df89351 100644 --- a/docs/git/source-control.mdx +++ b/docs/git/source-control.mdx @@ -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. diff --git a/docs/git/worktrees.mdx b/docs/git/worktrees.mdx index f9521348..458fcda5 100644 --- a/docs/git/worktrees.mdx +++ b/docs/git/worktrees.mdx @@ -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 | diff --git a/docs/images/command-palette.webp b/docs/images/command-palette.webp deleted file mode 100644 index b20672c3..00000000 Binary files a/docs/images/command-palette.webp and /dev/null differ diff --git a/docs/index.mdx b/docs/index.mdx index f14d99aa..88a3b375 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 23 coding CLIs are recognised on sight. Per-pane status dots, notifications + 25 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index aae869b6..9b439e3a 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -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. If the file cannot be parsed, tty7 starts on defaults, keeps your original at diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 8013f2b1..0cb6b008 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default. | New Workspace | ⌘ ⇧ N | Ctrl ⇧ N | | Switch Workspace | ⌘ ⇧ O | Ctrl ⇧ O | | New Window | ⌘ N | — | +| New Agent Tab | ⌘ ⇧ A | — | ## Panes @@ -35,7 +36,7 @@ it shows what *your* copy is bound to. This is the shipped default. | Action | macOS | Windows / Linux | |---|---|---| -| Command Palette | ⌘ P | Ctrl ⇧ P | +| Search Everywhere | ⌘ P | Ctrl ⇧ P | | Toggle Left Sidebar | ⌘ B | Ctrl ⇧ B | | Toggle Right Panel | ⌘ J | Ctrl ⇧ J | | Toggle Code Panel | ⌘ ⇧ E | Ctrl ⇧ E | @@ -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:` (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` | diff --git a/docs/reference/privacy.mdx b/docs/reference/privacy.mdx index 9b405502..c1de9221 100644 --- a/docs/reference/privacy.mdx +++ b/docs/reference/privacy.mdx @@ -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 | `/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 | `/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. | diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx index 4002d9cb..59dc66cc 100644 --- a/docs/remote/port-forwarding.mdx +++ b/docs/remote/port-forwarding.mdx @@ -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. + The ports section diff --git a/docs/remote/sftp.mdx b/docs/remote/sftp.mdx index 152f7ba7..882a4d6f 100644 --- a/docs/remote/sftp.mdx +++ b/docs/remote/sftp.mdx @@ -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. diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index 138d5b96..b8321a30 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -19,7 +19,7 @@ instead of a password echoing into your shell. - Open the palette (⌘ P) and type an address. IPv6 works with + Open Search Everywhere (⌘ P) and type an address. IPv6 works with brackets. ``` @@ -30,8 +30,8 @@ instead of a password echoing into your shell. - 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. @@ -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 -⌘ ⇧ R — or *SSH: Reconnect* in the palette — restarts the session in +⌘ ⇧ R — 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 diff --git a/docs/terminal/selection-and-clipboard.mdx b/docs/terminal/selection-and-clipboard.mdx index ef3547c0..4d38b371 100644 --- a/docs/terminal/selection-and-clipboard.mdx +++ b/docs/terminal/selection-and-clipboard.mdx @@ -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 | | ⇧-click | Extend the current selection to where you clicked | -| ⌘ A *(macOS)* | Select all — also in the right-click menu and the command palette on every platform | +| ⌘ A *(macOS)* | Select all — also in the right-click menu and Search Everywhere on every platform | ### Smart double-click diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx deleted file mode 100644 index a52aa348..00000000 --- a/docs/window/command-palette.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Command palette" -description: "One key for every action in the app — including the ones with no shortcut." ---- - -⌘ P opens the command palette. Type to filter, ↑ -↓ to move, ⏎ 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. - - - The tty7 command palette - - -## 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) diff --git a/docs/window/search-everywhere.mdx b/docs/window/search-everywhere.mdx new file mode 100644 index 00000000..27a7566f --- /dev/null +++ b/docs/window/search-everywhere.mdx @@ -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." +--- + +⌘ P (Ctrl ⇧ P on Linux and Windows) opens Search +Everywhere. Type to filter, ↑ ↓ to move, ⏎ to +run, ⇥ / ⇧ ⇥ to move between its tabs. Press the key again +or Esc 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 ⏎ 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. ⏎ 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 ⌘ P ⏎ 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, ⏎ keeps it, Esc 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. + +⏎ opens a new tab in the directory the session ran in and resumes it +there (`claude --resume `, `codex resume `), 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; ⌘ E 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) diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx index a5a1a749..d33bea5f 100644 --- a/docs/window/side-panel.mdx +++ b/docs/window/side-panel.mdx @@ -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 diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index 1eacf956..51c1fc3a 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -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. The tty7 tab sidebar -## 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 ⌘ T +would), and ⌘ T 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 diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index dea2382a..8628f78f 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -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 diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 40ead057..dc818b0d 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -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. diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md index 4ed7ac13..9c42c734 100644 --- a/skills/tty7/references/commands.md +++ b/skills/tty7/references/commands.md @@ -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 diff --git a/src/core/actions.rs b/src/core/actions.rs index 836838a9..a13dbbca 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -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, +} diff --git a/src/core/agent_history.rs b/src/core/agent_history.rs new file mode 100644 index 00000000..adb67229 --- /dev/null +++ b/src/core/agent_history.rs @@ -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, + pub title: String, + pub branch: Option, + /// 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 { + 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 { + let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut out: Vec = 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 { + 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 { + 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, +} + +static CACHE: LazyLock>> = + 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 `.jsonl` per session under +/// `~/.claude/projects//`. The directories +/// beside those files hold a session's subagent transcripts, which are not +/// sessions of their own. +fn claude_files(home: &Path) -> Vec { + 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-