Merge remote-tracking branch 'origin/main' into feat/pinned-sidebar-groups

# Conflicts:
#	CHANGELOG.md
#	crates/tty7-core/src/daemon/control.rs
#	docs/customization/settings.mdx
#	docs/reference/configuration.mdx
#	src/ui/app.rs
#	src/ui/home.rs
#	src/ui/settings.rs
This commit is contained in:
l0ng-ai
2026-09-25 10:53:14 +02:00
82 changed files with 8030 additions and 251 deletions
+133
View File
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Quick launch for the coding agents on your PATH** (#955). Every agent tty7
recognises whose binary is on `PATH` is a palette command — "Agent: Claude
Code", "Agent: Codex", … — ordered by how often and how recently it was
launched or seen running, and each is bindable as `LaunchAgent:<slug>`. "New
Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is
select-all) starts the one used last, and the New Tab menu's "Launch Agent…"
row opens the palette on the list. A launch always opens a new tab in the
current tab's directory and types the agent's command into its shell once the
pane exists, never into a pane that was already there, so detection, status
and resume work as for a hand-typed agent and quitting it returns to the
shell. The command is the agent's bare binary unless `agent_launch` in
`config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`);
a wrapper named there is detected as that agent without an `agent_commands`
entry, including a script run under its interpreter, and the daemon picks up
an edit without a restart. A running agent's pane menu has "Set Current Launch
Args as Default", which writes the flags it was started with — minus the
session it resumed and any prompt — into `agent_launch`. A remote workspace
cannot be asked for its `PATH`, so there the list is the agents already seen
running in that workspace.
- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a
tab → Hibernate, or "Hibernate Tab" in the command palette, stops every
process in the tab to give its memory back while the tab keeps its place in
the sidebar, faded and marked with a moon. Selecting it wakes it — or
right-click → Wake to warm it up without switching. Waking is the restore a
reboot already runs: each pane comes back in its old directory and shell,
opens on the screen it left, and a supported agent with a captured session
is resumed. A sleeping tab stays asleep across app and daemon restarts; the
tab on screen is always awake, so the last awake tab cannot be put to sleep.
`tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane
ls --json` carry a `hibernated` flag. Offered only where the machine's server
can do it (this build or newer).
- **A port forward can be switched off without losing its rule** (#439). A
forward could only be removed, so pointing one local port at a different
remote target meant deleting the rule and typing the other one in again. Each
forward in the Ports section, and each rule under Settings → SSH → Port
forwarding, now has a switch: off releases the listener and keeps the rule in
the list, on binds it again from the rule it was made from. Switching one on
while another switched-on forward holds its port is refused with a notice
naming that forward, rather than failing at bind time. A switch flipped in the
panel on a rule that came from a saved host is written back to that host, so
the next connection opens the same set; a rule saved switched off is listed
but not opened. Profiles saved before this load with every rule on.
- **SSH tabs can be named after the host instead of whatever the remote shell
titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is
*Dynamic* (the default, and what tty7 always did), *Profile name* — the saved
host's name, the alias for a `~/.ssh/config` host, the address typed for a
quick connect — or *Hostname*, the address dialled. Only the tab's name is
pinned: OSC 0/2 titles are still tracked and come back the moment it is
*Dynamic* again, a tab you renamed keeps its name, a split tab follows the
pane in front as before, and an ended session still says so. The key is
`ssh_tab_title` (`dynamic`, `profile-name`, `hostname`).
### Changed
- **The sidebar groups tabs by repo automatically; pin what you want to keep**
@@ -29,6 +86,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
### Removed
- **The *By repo or folder* grouping mode, "Group Automatically", and groups
@@ -38,6 +154,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
moves to v11, so each remote host needs one Update Server, which ends the
sessions on it.
### Fixed
- **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
+2
View File
@@ -102,6 +102,8 @@ that tells tty7 which session to fork.
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 palette command (**Agent: Claude Code**, …)
that opens it in a new tab; `agent_launch` sets the command line it starts with.
## Documentation
+1
View File
@@ -96,6 +96,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。
如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。
PATH 上的每个 agent 也都是一条命令面板命令(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。
## 文档
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v8.6"/><path d="M17.9 6.6a8.4 8.4 0 1 1-11.8 0"/></svg>

After

Width:  |  Height:  |  Size: 253 B

+39
View File
@@ -61,6 +61,17 @@ pub trait Backend {
fn run_wait(&mut self) -> Result<Option<i32>>;
/// Type `line` at the pane's shell prompt and follow the pane until its
/// prompt comes back, the deadline passes, or it exits. Refuses — sending
/// nothing — a pane with no shell integration or one that is not at a
/// prompt; see [`ExecEnd`](crate::exec::ExecEnd).
fn exec(
&mut self,
pane: u64,
line: Vec<u8>,
timeout: Option<std::time::Duration>,
) -> Result<crate::exec::ExecRun>;
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>;
}
@@ -78,6 +89,7 @@ pub mod mock {
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs};
use super::{Backend, CaptureSegment, RunSpec};
use crate::exec::{ExecEnd, ExecRun};
pub struct MockBackend {
pub machine: Machine,
@@ -103,6 +115,8 @@ pub mod mock {
pub events: Vec<ControlEvent>,
/// Set to make `hello` fail — doctor's unreachable-server branch.
pub unreachable: bool,
pub execs: Vec<(u64, Vec<u8>, Option<std::time::Duration>)>,
pub exec_run: ExecRun,
}
impl Default for MockBackend {
@@ -127,6 +141,21 @@ pub mod mock {
run_exit: Some(0),
events: Vec::new(),
unreachable: false,
execs: Vec::new(),
exec_run: ExecRun {
end: ExecEnd::Finished {
exit: Some(0),
ran: true,
},
output: Vec::new(),
dropped: 0,
size: tty7_core::daemon::protocol::WinSize {
cols: 80,
rows: 24,
cell_w: 8,
cell_h: 16,
},
},
}
}
}
@@ -219,6 +248,16 @@ pub mod mock {
Ok(self.run_exit)
}
fn exec(
&mut self,
pane: u64,
line: Vec<u8>,
timeout: Option<std::time::Duration>,
) -> Result<ExecRun> {
self.execs.push((pane, line, timeout));
Ok(self.exec_run.clone())
}
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
for event in self.events.drain(..) {
on_event(event)?;
+111
View File
@@ -13,6 +13,7 @@ use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, Win
use tty7_core::daemon::router::RouteTarget;
use super::{Backend, CaptureSegment, RunSpec};
use crate::exec::{ExecEnd, ExecRun, Transcript};
const SESSION_SIZE: WinSize = WinSize {
cols: 120,
@@ -23,6 +24,9 @@ const SESSION_SIZE: WinSize = WinSize {
const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10);
const REPLAY_SETTLE: Duration = Duration::from_millis(300);
/// A backstop on reading the replay: it ends on the first live output or a
/// quiet spell, and this only bounds a daemon that keeps sending status.
const EXEC_REPLAY_MAX: Duration = Duration::from_secs(10);
const NOT_RUNNING: &str =
"could not reach the tty7 server on this machine — `tty7 server start` brings one up";
@@ -275,6 +279,113 @@ impl Backend for RealBackend {
Ok(code)
}
fn exec(&mut self, pane: u64, line: Vec<u8>, timeout: Option<Duration>) -> Result<ExecRun> {
let deadline = timeout.and_then(|t| std::time::Instant::now().checked_add(t));
// Watching starts before anything is typed, so no byte the command
// prints can go past unseen. The replay that comes first is only read
// for the pane's state — the daemon sends its prompt report last.
let mut session = self
.pane_client()?
.observe(pane, SESSION_SIZE)
.with_context(|| format!("observing pane %{pane}"))?;
let mut size = SESSION_SIZE;
// (integration active, at a prompt), as the daemon last reported them.
let mut prompt: Option<(bool, bool)> = None;
let until = std::time::Instant::now() + EXEC_REPLAY_MAX;
let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT));
let finish = |session: PaneSession, end, size| -> Result<ExecRun> {
let _ = session.detach();
Ok(ExecRun {
end,
output: Vec::new(),
dropped: 0,
size,
})
};
loop {
match session.recv() {
Ok(DaemonMsg::Size(seen)) => size = seen,
Ok(DaemonMsg::Prompt {
active, at_prompt, ..
}) => prompt = Some((active, at_prompt)),
Ok(DaemonMsg::Exited { code }) => {
return finish(session, ExecEnd::PaneExited(code), size);
}
// Live output: the replay is over, and so is `capture`'s
// reading of it. A pane still printing is not at a prompt,
// and its report has already said so.
Ok(DaemonMsg::Output(_)) => break,
Ok(_) => {}
Err(e) if timed_out(&e) => break,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return finish(session, ExecEnd::PaneExited(None), size);
}
Err(e) => return Err(anyhow!(e).context("reading the pane replay")),
}
if std::time::Instant::now() >= until {
break;
}
let _ = session.set_recv_timeout(Some(REPLAY_SETTLE));
}
match prompt {
Some((true, true)) => {}
Some((true, false)) => return finish(session, ExecEnd::Busy, size),
_ => return finish(session, ExecEnd::NoIntegration, size),
}
self.send_input(pane, line)?;
let mut transcript = Transcript::new();
let end = loop {
let wait = match deadline {
Some(d) => {
let left = d.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
break ExecEnd::TimedOut {
started: transcript.started(),
};
}
Some(left)
}
None => None,
};
let _ = session.set_recv_timeout(wait);
match session.recv() {
Ok(DaemonMsg::Output(bytes)) => {
transcript.output(&bytes);
transcript.compact();
}
Ok(DaemonMsg::Size(seen)) => size = seen,
Ok(DaemonMsg::Prompt {
at_prompt,
last_exit,
..
}) => {
if let Some(done) = transcript.prompt(at_prompt, last_exit) {
break ExecEnd::Finished {
exit: done.exit,
ran: done.ran,
};
}
}
Ok(DaemonMsg::Exited { code }) => break ExecEnd::PaneExited(code),
Ok(_) => {}
Err(e) if timed_out(&e) => continue,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break ExecEnd::PaneExited(None);
}
Err(e) => return Err(anyhow!(e).context("following the command's output")),
}
};
let _ = session.detach();
let (output, dropped) = transcript.take();
Ok(ExecRun {
end,
output,
dropped,
size,
})
}
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
let client = self.control_client()?;
for event in client.events() {
+144 -1
View File
@@ -72,11 +72,35 @@ pub enum Command {
// written out here: a hand-copied vocabulary drifts the first time a key
// is added, and this is the text a caller reaches for to learn the names.
#[command(
about = "Type text into a pane, or send it keystrokes with --key",
about = "Type text into a pane — from an argument, --stdin or --from-file — or send \
it keystrokes with --key",
long_about = crate::keys::send_long_help()
)]
Send(SendArgs),
// `run` is the other half of this pair, and the split is docker's: `run`
// makes a new place for a command, `exec` uses one that already exists.
// Not a flag on `run` — that verb execs an argv in a fresh pty, while this
// types a line into a shell that is already sitting at its prompt, and
// none of `run`'s flags (`--keep`, `--cwd`, `--ws`) mean anything here.
#[command(
about = "Run a command line in an existing pane's shell, wait for its prompt to \
come back, print what the command printed, and exit with its exit code",
long_about = "Run a command line in an existing pane's shell, wait for its prompt to \
come back, print what the command printed, and exit with its exit code.\n\n\
The line is typed at the shell's prompt, exactly as `send … --enter` \
would, so it runs with that shell's cwd, environment and history. The \
pane's shell integration is what says where the command's output starts \
and ends and what its exit code was: a pane with none (no OSC 133 prompt \
marks yet) is refused rather than waited on, and so is a pane that is not \
at a prompt. The output is what the pane printed between the command \
starting and the prompt coming back, replayed through a terminal to \
text the way `capture --plain` does — or as raw bytes with `--raw`.\n\n\
A line the shell never runs — an unclosed quote waiting on a continuation \
prompt — has no end to wait for; pass --timeout in anything unattended."
)]
Exec(ExecArgs),
#[command(
about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \
escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \
@@ -158,6 +182,47 @@ pub struct RunArgs {
pub cmd: Vec<String>,
}
#[derive(Debug, Args)]
pub struct ExecArgs {
#[arg(
value_name = "%PANE",
help = "Pane whose shell runs the command; defaults to $TTY7_PANE inside a tty7 shell"
)]
pub target: Option<String>,
#[arg(
long,
value_name = "SECS",
help = "Give up after this many seconds, with exit code 124; the command is left \
running in the pane"
)]
pub timeout: Option<u64>,
// Text is the default here, unlike `capture`: what a caller of this verb
// wants is the answer, and the raw bytes between the marks are not only
// the command's — zsh draws its PROMPT_SP (the `%` that marks a missing
// newline, then a row of spaces and a CR) *before* its precmd hooks run,
// so before the `D` mark. Replayed through a grid that erases itself, as
// it does on screen; left raw it is a line of noise after every command.
#[arg(
long,
help = "Print the output as the pane received it, escapes intact, instead of \
replaying it through a terminal to text (the `capture` default)"
)]
pub raw: bool,
// Joined with spaces and handed to the pane's shell as one line, which is
// what separates this from `run`: `tty7 exec %3 -- 'make && ls'` means the
// shell's `&&`, where `run` would look for a program called `make && ls`.
#[arg(
last = true,
required = true,
value_name = "CMD",
help = "The command line, after `--`: tty7 exec %3 -- cargo test"
)]
pub cmd: Vec<String>,
}
#[derive(Debug, Args)]
#[command(group = ArgGroup::new("axis").required(true))]
pub struct SplitArgs {
@@ -221,6 +286,34 @@ pub struct SendArgs {
(C-c, escape, up, enter, …). See `tty7 send --help`"
)]
pub keys: Vec<crate::keys::Key>,
// The other place the text can come from. A secret in an argument is in
// this process's command line, where every local user can read it in
// `ps`, and in the caller's own shell history; reading it from a pipe or a
// file keeps it out of both. The bytes go to the pty exactly as read.
#[arg(
long,
conflicts_with_all = ["from_file", "second"],
help = "Send what stdin holds, byte for byte, instead of TEXT — keeps a secret \
out of `ps` and your shell history"
)]
pub stdin: bool,
#[arg(
long,
value_name = "PATH",
conflicts_with = "second",
help = "Send the file's contents, byte for byte, instead of TEXT"
)]
pub from_file: Option<std::path::PathBuf>,
#[arg(
long,
help = "Send the text as a paste, the way the GUI pastes: framed in bracketed \
paste when the pane has switched it on, so the lines of a multi-line text \
arrive as text rather than each running on its own"
)]
pub paste: bool,
}
/// One resting place a `wait` can end on. Three ontologies meet here, which is
@@ -945,4 +1038,54 @@ mod tests {
let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err();
assert_eq!(err.exit_code(), 2);
}
#[test]
fn exec_takes_an_address_then_the_command_after_the_separator() {
let Some(Command::Exec(args)) = parse(&[
"tty7",
"exec",
"%3",
"--timeout",
"5",
"--",
"make",
"--raw",
])
.command
else {
panic!("exec did not parse");
};
assert_eq!(args.target.as_deref(), Some("%3"));
assert_eq!(args.timeout, Some(5));
assert!(!args.raw, "after `--` a flag belongs to the command");
assert_eq!(args.cmd, ["make", "--raw"]);
let Some(Command::Exec(args)) = parse(&["tty7", "exec", "--", "ls"]).command else {
panic!("exec without an address did not parse");
};
assert_eq!(args.target, None, "the pane then comes from $TTY7_PANE");
let err = Cli::try_parse_from(["tty7", "exec", "%3"]).unwrap_err();
assert_eq!(err.exit_code(), 2, "a command is required");
}
#[test]
fn send_reads_its_text_from_one_place() {
let Some(Command::Send(args)) =
parse(&["tty7", "send", "%3", "--stdin", "--paste", "--enter"]).command
else {
panic!("send --stdin did not parse");
};
assert!(args.stdin && args.paste && args.enter);
assert_eq!(args.first.as_deref(), Some("%3"));
for bad in [
vec!["tty7", "send", "%3", "text", "--stdin"],
vec!["tty7", "send", "%3", "text", "--from-file", "f"],
vec!["tty7", "send", "%3", "--stdin", "--from-file", "f"],
] {
let err = Cli::try_parse_from(&bad).unwrap_err();
assert_eq!(err.exit_code(), 2, "{bad:?} should be a usage error");
}
}
}
+623 -7
View File
@@ -11,9 +11,10 @@ use tty7_core::daemon::protocol::PROTOCOL_VERSION;
use crate::address::{self, Context, WorkspaceAddress};
use crate::backend::{Backend, RunSpec};
use crate::cli::{
CaptureArgs, Cli, Command, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, SplitArgs,
TabCmd, WaitArgs, WaitState, WsCmd,
CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd,
SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd,
};
use crate::exec::ExecEnd;
use crate::output;
use crate::resolve;
use crate::screen;
@@ -78,6 +79,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
pane_split(args, ctx, backend)
}
Some(Command::Send(args)) => send(args, ctx, backend),
Some(Command::Exec(args)) => exec(args, ctx, backend),
Some(Command::Capture(args)) => capture(args, ctx, backend),
Some(Command::Procs { target }) => procs(target.as_deref(), ctx, backend),
Some(Command::Tab(TabCmd::Ls { ws })) => tab_ls(ws.as_deref(), ctx, backend),
@@ -486,8 +488,6 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu
}
fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
const KEY_GAP: Duration = Duration::from_millis(200);
// `--enter` is the same thing as `--key enter`, and predates it. Keeping it
// as sugar rather than deprecating it: it reads better for the overwhelming
// case, which is typing one command and running it. Going through the same
@@ -500,6 +500,43 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
pressed.push(crate::keys::parse("enter").expect("enter is in the vocabulary"));
}
// Text from stdin or a file has no positional of its own, so the one
// positional there is can only be an address — and a word that is not one
// is a mistake, not text to type into the caller's own pane.
if args.stdin || args.from_file.is_some() {
let source = if args.stdin { "--stdin" } else { "--from-file" };
if let Some(first) = &args.first {
address::parse_pane(first).with_context(|| {
format!(
"with {source} the text comes from there, so '{first}' has to be the \
pane to send it to"
)
})?;
}
let pane = address::pane_or_context(args.first.as_deref(), ctx)?;
let payload = match &args.from_file {
Some(path) => {
std::fs::read(path).with_context(|| format!("reading {}", path.display()))?
}
None => {
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut std::io::stdin(), &mut bytes)
.context("reading stdin")?;
bytes
}
};
return deliver(
pane,
Payload::Piped {
source: if args.stdin { "stdin" } else { "file" },
bytes: payload,
},
&args,
pressed,
backend,
);
}
// Three shapes reach here, and only the address is ever ambiguous:
// `send %3 "text"`, `send "text"` (this pane), and — new with --key —
// `send %3 --key C-c`, where there is no text at all and the lone
@@ -555,9 +592,75 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
};
let pane = address::pane_or_context(target, ctx)?;
let payload = match text {
Some(text) => Payload::Typed(text.to_string()),
None => Payload::None,
};
deliver(pane, payload, &args, pressed, backend)
}
/// What `send` has to put in front of the keys.
enum Payload {
None,
/// From the command line — echoed back in `--json`, as it always was.
Typed(String),
/// From stdin or a file. Never echoed: the reason to use these is to keep
/// the bytes out of places other people can read, and a JSON line in a
/// log is one of them.
Piped {
source: &'static str,
bytes: Vec<u8>,
},
}
fn deliver(
pane: u64,
payload: Payload,
args: &SendArgs,
pressed: Vec<crate::keys::Key>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
const KEY_GAP: Duration = Duration::from_millis(200);
let (source, bytes) = match &payload {
Payload::None => ("none", &[][..]),
Payload::Typed(text) => ("argument", text.as_bytes()),
Payload::Piped { source, bytes } => (*source, &bytes[..]),
};
if args.paste && bytes.is_empty() {
bail!("--paste needs text to paste — TEXT, --stdin or --from-file");
}
// `--paste` frames the way the GUI's paste does, which is to ask the pane
// first: brackets for a pane that switched mode 2004 on, and for one that
// did not, the line breaks a keyboard would send. Forcing the brackets on
// a pane that never asked for them would put `ESC[200~` in front of a
// program that reads it as keystrokes. `None` is a server that cannot say.
let mode = if args.paste {
backend.procs(pane)?.context.and_then(|c| c.bracketed_paste)
} else {
None
};
let bracketed = mode == Some(true);
if args.paste && !bracketed {
eprintln!(
"tty7: pane %{pane} {} bracketed paste, so the text went unframed — each line \
break was sent as Enter",
if mode.is_none() {
"is on a server too old to say whether it has"
} else {
"has not switched on"
}
);
}
let wire = if args.paste {
tty7_core::core::paste::paste_bytes(bytes, bracketed)
} else {
bytes.to_vec()
};
let mut already_wrote = false;
if let Some(text) = text {
backend.send_input(pane, text.as_bytes().to_vec())?;
if !wire.is_empty() {
backend.send_input(pane, wire)?;
already_wrote = true;
}
for key in &pressed {
@@ -576,7 +679,18 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
"",
json!({
"pane": pane,
"sent": text.unwrap_or_default(),
"sent": match &payload {
Payload::Typed(text) => Some(text.as_str()),
Payload::None => Some(""),
Payload::Piped { .. } => None,
},
"source": source,
"bytes": bytes.len(),
"paste": args.paste,
// Whether the paste went framed, and what the pane said about the
// mode — `null` when nothing was asked or the server cannot say.
"bracketed": bracketed,
"bracketed_paste_mode": mode,
"enter": args.enter,
"keys": pressed.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(),
}),
@@ -594,6 +708,132 @@ fn tried_to_write_an_address(s: &str) -> bool {
.is_some_and(|c| c.is_ascii_digit())
}
fn exec(args: ExecArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
let line = args.cmd.join(" ");
if line.trim().is_empty() {
bail!("exec needs a command after `--`");
}
// The line is typed, so every byte of it goes through the shell's line
// editor: a newline would submit half of it and a Tab would complete. One
// line of printable text is the only thing that runs as written — the same
// test the GUI applies before it types a line rather than pasting it.
if line.chars().any(char::is_control) {
bail!(
"exec types one line at the shell's prompt, and this one holds a control \
character (a newline, a tab, an escape) the line editor would act on — join \
the commands with `;` or `&&`, or put them in a script and exec that"
);
}
let mut wire = line.clone().into_bytes();
wire.push(b'\r');
let started = std::time::Instant::now();
let run = backend.exec(pane, wire, args.timeout.map(Duration::from_secs))?;
let elapsed_ms = started.elapsed().as_millis() as u64;
// The caller's own pane is the one pane that can never be at a prompt
// while this runs: its shell is busy running `tty7 exec`.
let own = ctx
.pane
.as_deref()
.and_then(|p| address::parse_pane(p).ok())
== Some(pane);
match run.end {
ExecEnd::NoIntegration => bail!(
"pane %{pane} has sent no shell-integration prompt marks, so nothing would say \
when a command there ends — exec needs tty7's shell integration loaded in the \
pane's shell (`tty7 doctor`); without it, `send … --enter` and \
`wait --until free` are the way"
),
ExecEnd::Busy if own => bail!(
"pane %{pane} is this shell, and it is busy running this very command — exec \
into another pane"
),
ExecEnd::Busy => bail!(
"pane %{pane} is not at a shell prompt — something is already running there. \
`tty7 wait %{pane} --until free` waits for it"
),
_ => {}
}
let text = if args.raw {
String::from_utf8_lossy(&run.output).into_owned()
} else {
screen::render(&[crate::backend::CaptureSegment {
size: run.size,
bytes: run.output.clone(),
}])
};
if run.dropped > 0 {
eprintln!(
"tty7: exec %{pane}: the first {} bytes of output were dropped to keep the rest \
in memory — the pane's own scrollback may still hold them",
run.dropped
);
}
let (code, known, timed_out) = match run.end {
ExecEnd::Finished {
exit: Some(code),
ran: true,
} => (code, true, false),
ExecEnd::Finished {
exit: None,
ran: true,
} => {
eprintln!("tty7: {EXIT_CODE_UNKNOWN}");
(1, false, false)
}
ExecEnd::Finished { ran: false, .. } => {
eprintln!(
"tty7: pane %{pane}: the shell came back to its prompt without running the \
line — a syntax error, most likely; what it printed is the output"
);
(1, false, false)
}
ExecEnd::TimedOut { started: true } => {
eprintln!(
"tty7: pane %{pane}: still running after {}s — timed out, and left running \
(`tty7 send %{pane} --key C-c` interrupts it)",
args.timeout.unwrap_or_default()
);
(124, false, true)
}
ExecEnd::TimedOut { started: false } => {
eprintln!(
"tty7: pane %{pane}: timed out after {}s, and the shell never started the \
line — it is most likely waiting on a continuation prompt (an unclosed \
quote or bracket); `tty7 send %{pane} --key C-c` abandons it",
args.timeout.unwrap_or_default()
);
(124, false, true)
}
ExecEnd::PaneExited(_) => {
eprintln!("tty7: pane %{pane} exited before its shell came back to a prompt");
(1, false, false)
}
ExecEnd::NoIntegration | ExecEnd::Busy => unreachable!("refused above"),
};
// `-q` keeps the exit code and drops the output, which is what the global
// flag already does to any report.
Ok(Outcome::Exit(
code,
Report {
human: text.clone(),
json: json!({
"pane": pane,
"command": line,
"exit": code,
"exit_code_known": known,
"timed_out": timed_out,
"pane_exited": matches!(run.end, ExecEnd::PaneExited(_)),
"output": text,
"bytes": run.output.len(),
"elapsed_ms": elapsed_ms,
}),
},
))
}
fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
let segments = backend.capture(pane, args.scrollback)?;
@@ -707,6 +947,7 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
"folder": g.folder,
})),
"panes": tab.root.pane_ids(),
"hibernated": tab.hibernated,
})
})
.collect();
@@ -920,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,
}));
}
}
@@ -3511,6 +3753,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen: false,
bracketed_paste: None,
}
}
@@ -3537,6 +3780,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen,
bracketed_paste: None,
}),
}
}
@@ -3558,6 +3802,7 @@ mod tests {
local_pty: false,
at_prompt,
remote_prompt_seen: at_prompt == Some(true),
bracketed_paste: None,
}),
}
}
@@ -4537,4 +4782,375 @@ mod tests {
backend.control_calls
);
}
#[test]
fn tab_ls_and_pane_ls_say_which_tabs_are_asleep() {
let mut backend = mock();
backend.machine.workspaces[0].tabs[1].hibernated = true;
let Outcome::Report(tabs) = run_cli(
&["tty7", "tab", "ls", "api"],
&Context::default(),
&mut backend,
) else {
panic!("tab ls must report");
};
let tabs = tabs.json["tabs"].as_array().expect("tabs").clone();
assert_eq!(tabs[0]["hibernated"], false);
assert_eq!(tabs[1]["hibernated"], true);
let Outcome::Report(panes) = run_cli(
&["tty7", "pane", "ls", "api"],
&Context::default(),
&mut backend,
) else {
panic!("pane ls must report");
};
let panes = panes.json["panes"].as_array().expect("panes").clone();
let asleep: Vec<u64> = panes
.iter()
.filter(|p| p["hibernated"] == true)
.map(|p| p["pane"].as_u64().unwrap())
.collect();
assert_eq!(asleep, vec![2, 3]);
}
fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun {
crate::exec::ExecRun {
end,
output: output.to_vec(),
dropped: 0,
size: segment(b"").size,
}
}
fn exec_outcome(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> (i32, Report) {
match execute(cli(args), ctx, backend) {
Ok(Outcome::Exit(code, report)) => (code, report),
other => panic!("exec stands in for the command, so it exits: {other:?}"),
}
}
#[test]
fn exec_types_one_line_and_passes_the_exit_code_through() {
let mut backend = mock();
backend.exec_run = exec_run(
ExecEnd::Finished {
exit: Some(3),
ran: true,
},
b"built\r\n",
);
let (code, report) = exec_outcome(
&[
"tty7",
"exec",
"%1",
"--raw",
"--timeout",
"30",
"--",
"make",
"&&",
"ls",
],
&Context::default(),
&mut backend,
);
assert_eq!(code, 3, "the command's own code, not ours");
assert_eq!(
backend.execs,
vec![(1, b"make && ls\r".to_vec(), Some(Duration::from_secs(30)))],
"the words are one shell line, typed and submitted"
);
assert_eq!(report.human, "built\r\n");
assert_eq!(report.json["exit"], 3);
assert_eq!(report.json["exit_code_known"], true);
assert_eq!(report.json["command"], "make && ls");
assert_eq!(report.json["output"], "built\r\n");
assert_eq!(report.json["timed_out"], false);
// Nothing was sent anywhere but through the exec seam.
assert!(backend.sent.is_empty());
}
#[test]
fn exec_renders_the_output_the_way_capture_plain_does() {
let mut backend = mock();
backend.exec_run = exec_run(
ExecEnd::Finished {
exit: Some(0),
ran: true,
},
b"\x1b[31mred\x1b[0m\r\nprogress 10%\rprogress 100%\r\n",
);
let (code, report) = exec_outcome(
&["tty7", "exec", "%1", "--", "x"],
&Context::default(),
&mut backend,
);
assert_eq!(code, 0);
assert!(!report.human.contains('\u{1b}'), "{:?}", report.human);
assert!(report.human.contains("red"), "{:?}", report.human);
assert!(report.human.contains("progress 100%"), "{:?}", report.human);
assert!(
!report.human.contains("progress 10%\n"),
"{:?}",
report.human
);
}
#[test]
fn exec_refuses_a_pane_it_cannot_follow_before_typing_anything() {
// The backend sends nothing on these; the verb has to turn them into
// an error that says what to do instead of an exit code that says
// nothing.
let mut backend = mock();
backend.exec_run = exec_run(ExecEnd::NoIntegration, b"");
let err = execute(
cli(&["tty7", "exec", "%1", "--", "ls"]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("shell-integration"), "{err}");
assert!(err.contains("wait --until free"), "{err}");
let mut backend = mock();
backend.exec_run = exec_run(ExecEnd::Busy, b"");
let err = execute(
cli(&["tty7", "exec", "%1", "--", "ls"]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("not at a shell prompt"), "{err}");
// The caller's own pane can only ever be busy — running this.
let ctx = Context {
pane: Some("%1".into()),
..Context::default()
};
let err = execute(cli(&["tty7", "exec", "--", "ls"]), &ctx, &mut backend)
.unwrap_err()
.to_string();
assert!(err.contains("this shell"), "{err}");
}
#[test]
fn exec_refuses_a_line_the_editor_would_act_on() {
for line in ["make\nrm -rf build", "ls\tx", "a\u{1b}b"] {
let mut backend = mock();
let err = execute(
cli(&["tty7", "exec", "%1", "--", line]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("control"), "{line:?}: {err}");
assert!(backend.execs.is_empty(), "nothing may be typed: {line:?}");
}
}
#[test]
fn exec_reports_every_way_it_can_end_without_an_exit_code() {
let cases = [
(ExecEnd::TimedOut { started: true }, 124, true, false),
(ExecEnd::TimedOut { started: false }, 124, true, false),
(ExecEnd::PaneExited(Some(0)), 1, false, true),
(
ExecEnd::Finished {
exit: None,
ran: true,
},
1,
false,
false,
),
(
ExecEnd::Finished {
exit: None,
ran: false,
},
1,
false,
false,
),
];
for (end, want, timed_out, pane_exited) in cases {
let mut backend = mock();
backend.exec_run = exec_run(end.clone(), b"partial\r\n");
let (code, report) = exec_outcome(
&["tty7", "exec", "%1", "--raw", "--timeout", "1", "--", "x"],
&Context::default(),
&mut backend,
);
assert_eq!(code, want, "{end:?}");
assert_eq!(report.json["exit_code_known"], false, "{end:?}");
assert_eq!(report.json["timed_out"], timed_out, "{end:?}");
assert_eq!(report.json["pane_exited"], pane_exited, "{end:?}");
assert_eq!(
report.human, "partial\r\n",
"what did arrive is still the answer: {end:?}"
);
}
}
fn procs_with_paste_mode(mode: Option<bool>) -> tty7_core::daemon::protocol::PaneProcs {
tty7_core::daemon::protocol::PaneProcs {
context: Some(tty7_core::daemon::protocol::PaneContext {
local_pty: true,
bracketed_paste: mode,
..Default::default()
}),
..Default::default()
}
}
#[test]
fn send_from_file_sends_the_bytes_verbatim_and_never_echoes_them() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, b"s3cr\\et\n\xff").unwrap();
let mut backend = mock();
let json = json_of(run_cli(
&["tty7", "send", "%1", "--from-file", path.to_str().unwrap()],
&Context::default(),
&mut backend,
));
assert_eq!(backend.sent, vec![(1, b"s3cr\\et\n\xff".to_vec())]);
assert!(
json["sent"].is_null(),
"the secret must not be echoed: {json}"
);
assert!(!json.to_string().contains("s3cr"), "{json}");
assert_eq!(json["source"], "file");
assert_eq!(json["bytes"], 9);
assert!(
backend.procs_calls.is_empty(),
"without --paste the pane is not asked anything"
);
// Keys still follow the payload, the way they follow typed text.
let mut backend = mock();
run_cli(
&[
"tty7",
"send",
"%1",
"--from-file",
path.to_str().unwrap(),
"--enter",
],
&Context::default(),
&mut backend,
);
assert_eq!(
backend.sent,
vec![(1, b"s3cr\\et\n\xff".to_vec()), (1, b"\r".to_vec())]
);
}
#[test]
fn send_from_file_takes_its_positional_as_an_address_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("payload");
std::fs::write(&path, b"x").unwrap();
let mut backend = mock();
let err = execute(
cli(&[
"tty7",
"send",
"echo",
"--from-file",
path.to_str().unwrap(),
]),
&Context::default(),
&mut backend,
)
.unwrap_err();
let err = format!("{err:#}");
assert!(err.contains("has to be the pane"), "{err}");
assert!(backend.sent.is_empty(), "nothing may be typed");
// With no address it is this pane, as it is for text.
let ctx = Context {
pane: Some("%3".into()),
..Context::default()
};
run_cli(
&["tty7", "send", "--from-file", path.to_str().unwrap()],
&ctx,
&mut backend,
);
assert_eq!(backend.sent, vec![(3, b"x".to_vec())]);
}
#[test]
fn send_paste_frames_only_for_a_pane_that_switched_bracketed_paste_on() {
let mut backend = mock();
backend.procs_reply = procs_with_paste_mode(Some(true));
let json = json_of(run_cli(
&[
"tty7",
"send",
"%1",
"a\r\nb\x1b[201~c",
"--paste",
"--enter",
],
&Context::default(),
&mut backend,
));
assert_eq!(
backend.sent,
vec![
(1, b"\x1b[200~a\nb[201~c\x1b[201~".to_vec()),
(1, b"\r".to_vec())
],
"the GUI's framing: CRLF folded, ESC stripped so the text cannot close the paste"
);
assert_eq!(json["bracketed"], true);
assert_eq!(json["bracketed_paste_mode"], true);
// Off: brackets would reach the program as keystrokes, so the text goes
// the way a paste into such a pane always has — a CR per line break.
let mut backend = mock();
backend.procs_reply = procs_with_paste_mode(Some(false));
let json = json_of(run_cli(
&["tty7", "send", "%1", "a\nb", "--paste"],
&Context::default(),
&mut backend,
));
assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]);
assert_eq!(json["bracketed"], false);
assert_eq!(json["bracketed_paste_mode"], false);
// A server that cannot say is not taken as a yes.
let mut backend = mock();
backend.procs_reply = procs_with_paste_mode(None);
let json = json_of(run_cli(
&["tty7", "send", "%1", "a\nb", "--paste"],
&Context::default(),
&mut backend,
));
assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]);
assert!(json["bracketed_paste_mode"].is_null(), "{json}");
}
#[test]
fn send_paste_needs_something_to_paste() {
let mut backend = mock();
let err = execute(
cli(&["tty7", "send", "%1", "--paste", "--enter"]),
&Context::default(),
&mut backend,
)
.unwrap_err()
.to_string();
assert!(err.contains("--paste needs text"), "{err}");
assert!(backend.sent.is_empty());
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Finding one command's output and exit code in a pane's byte stream.
//!
//! `tty7 exec` types a line at a shell's prompt and has to know two things the
//! pty alone never says: where the command's output starts and ends, and how it
//! exited. The shell integration says both, in OSC 133 marks: `C` right before
//! the command runs, `D;<code>` the moment it is over, `A` when the next prompt
//! opens. The daemon already reads those marks — it is what sends `Prompt` —
//! but only as a state, not as a position in the bytes, so the positions are
//! found here, in the same stream the daemon read them from.
//!
//! Which of the two gets the last word is deliberate. The byte marks say
//! *where*; the daemon's `Prompt` report says *when*, because it is the one
//! that has already set aside marks relayed by a program running in the
//! foreground (a nested shell, an `ssh`) — a `D` in the bytes is only the end
//! once the daemon agrees the shell is back at its prompt.
use tty7_core::core::osc::OscTokenizer;
use tty7_core::daemon::protocol::WinSize;
/// Output kept past this is dropped from the front: the end of a long build is
/// the part worth reading, and a command that prints without bound must not
/// take the CLI's memory with it. The pane's own scrollback is the same size.
pub const KEEP_OUTPUT: usize = 8 * 1024 * 1024;
const MARK_D: &[u8] = b"\x1b]133;D";
const MARK_A: &[u8] = b"\x1b]133;A";
/// How an `exec` ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecEnd {
/// The pane's shell has never sent a prompt mark, so nothing would ever
/// say the command is over. Nothing was sent.
NoIntegration,
/// The pane is not at a prompt — something already runs there. Nothing
/// was sent.
Busy,
/// The shell is back at its prompt. `ran` is false when it came back
/// without running anything — a syntax error, most likely — in which case
/// there is no exit code to report and the output is what the shell said
/// about the line.
Finished { exit: Option<i32>, ran: bool },
/// The deadline passed first. `started` says whether the shell ever
/// began the command: when it did not, the line is most likely sitting on
/// a continuation prompt (an unclosed quote) rather than running.
TimedOut { started: bool },
/// The pane went away before its shell came back to a prompt.
PaneExited(Option<i32>),
}
/// What an `exec` saw: how it ended, and the output that belongs to it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecRun {
pub end: ExecEnd,
pub output: Vec<u8>,
/// Bytes of output dropped from the front to stay under [`KEEP_OUTPUT`].
pub dropped: usize,
/// The pane's size, which is what `--plain` replays the output at.
pub size: WinSize,
}
/// The pane's output from the moment the line was sent, and where the marks
/// that bound the command fell in it.
pub struct Transcript {
tok: OscTokenizer,
bytes: Vec<u8>,
/// Absolute offset of `bytes[0]` — everything before it was dropped.
base: usize,
/// One past the command's `C` mark: where its output starts.
started: Option<usize>,
/// Where the newest `D` after `started` begins: where the output ends.
ended: Option<usize>,
/// Where an `A` that arrived with no `C` before it begins — the shell drew
/// a fresh prompt without running the line.
reprompted: Option<usize>,
exit_mark: Option<i32>,
}
/// What the daemon's `Prompt` report made of the pane.
pub struct Finished {
pub exit: Option<i32>,
pub ran: bool,
}
impl Default for Transcript {
fn default() -> Transcript {
Transcript::new()
}
}
impl Transcript {
pub fn new() -> Transcript {
Transcript {
tok: OscTokenizer::new(&[b"133"]),
bytes: Vec::new(),
base: 0,
started: None,
ended: None,
reprompted: None,
exit_mark: None,
}
}
/// Take the next chunk of the pane's output.
pub fn output(&mut self, chunk: &[u8]) {
let at = self.base + self.bytes.len();
self.bytes.extend_from_slice(chunk);
let (bytes, base) = (&self.bytes, self.base);
let (mut started, mut ended, mut reprompted, mut exit_mark) =
(self.started, self.ended, self.reprompted, self.exit_mark);
self.tok.feed_at(chunk, |end, payload| {
let end = at + end;
// Where the sequence that just ended began: the newest copy of its
// introducer before its terminator. Payloads carry no ESC, so the
// search cannot land inside a different sequence.
let begin = |intro: &[u8]| {
let upto = end - base;
bytes[..upto]
.windows(intro.len())
.rposition(|w| w == intro)
.map_or(end, |at| base + at)
};
match payload.get(4) {
// Only the first: a command that prints a `C` of its own must
// not move the start of its own output.
Some(b'C') if started.is_none() => started = Some(end),
Some(b'D') if started.is_some() => {
ended = Some(begin(MARK_D));
exit_mark = payload
.strip_prefix(b"133;D;")
.and_then(|c| std::str::from_utf8(c).ok())
.and_then(|c| c.trim().parse().ok());
}
Some(b'A') if started.is_none() && reprompted.is_none() => {
reprompted = Some(begin(MARK_A));
}
_ => {}
}
});
(self.started, self.ended, self.reprompted, self.exit_mark) =
(started, ended, reprompted, exit_mark);
}
/// The daemon's report that the pane's shell is (or is not) at a prompt.
/// `Some` once the command is over.
pub fn prompt(&self, at_prompt: bool, last_exit: Option<i32>) -> Option<Finished> {
if !at_prompt {
return None;
}
if self.started.is_some() {
// The byte mark and the report are the same `D`, read twice; the
// report is preferred only because the daemon parsed it first.
return self.ended.map(|_| Finished {
exit: last_exit.or(self.exit_mark),
ran: true,
});
}
self.reprompted.map(|_| Finished {
exit: None,
ran: false,
})
}
/// Whether the shell has begun running the command.
pub fn started(&self) -> bool {
self.started.is_some()
}
/// The output that belongs to the command, as far as it has got, and how
/// much of it was dropped from the front to keep it bounded.
pub fn take(self) -> (Vec<u8>, usize) {
let all = self.base + self.bytes.len();
let (from, to) = match (self.started, self.ended, self.reprompted) {
(Some(start), Some(end), _) => (start, end),
(Some(start), None, _) => (start, all),
// Never ran: what the shell printed about the line, up to the new
// prompt — the echo of the line itself and whatever error it drew.
(None, _, Some(end)) => (0, end),
(None, _, None) => (0, all),
};
let to = to.clamp(from, all);
let kept = from.max(self.base).max(to.saturating_sub(KEEP_OUTPUT));
(
self.bytes[kept - self.base..to - self.base].to_vec(),
kept - from,
)
}
/// Bound the memory held: once twice [`KEEP_OUTPUT`] has piled up, drop
/// all but the last [`KEEP_OUTPUT`]. Only ever what [`take`](Self::take)
/// would not return anyway, which keeps the newest that much.
pub fn compact(&mut self) {
let len = self.bytes.len();
if len <= KEEP_OUTPUT * 2 {
return;
}
let drop = len - KEEP_OUTPUT;
self.bytes.drain(..drop);
self.base += drop;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What zsh with tty7's integration prints for one `echo hi` typed at its
/// prompt: the echo of the line, `C` with the line, the output, `D` with
/// the exit code, then the next prompt.
const ONE_COMMAND: &[u8] = b"echo hi\r\n\x1b]133;C;echo hi\x07hi\r\n\x1b]133;D;0\x07\
\x1b]7;file://host/tmp\x07\x1b]133;A\x07% \x1b]133;B\x07";
#[test]
fn the_output_is_what_lies_between_c_and_d() {
let mut t = Transcript::new();
t.output(ONE_COMMAND);
let done = t.prompt(true, Some(0)).expect("D then a prompt is the end");
assert_eq!((done.exit, done.ran), (Some(0), true));
assert_eq!(t.take(), (b"hi\r\n".to_vec(), 0));
}
#[test]
fn marks_split_across_reads_still_bound_the_output() {
// The pty hands the stream over in whatever chunks it likes, including
// through the middle of a mark.
for split in 1..ONE_COMMAND.len() {
let mut t = Transcript::new();
t.output(&ONE_COMMAND[..split]);
t.output(&ONE_COMMAND[split..]);
assert!(t.prompt(true, Some(0)).is_some(), "split at {split}");
assert_eq!(t.take().0, b"hi\r\n".to_vec(), "split at {split}");
}
}
#[test]
fn a_prompt_report_before_the_command_has_ended_is_not_the_end() {
let mut t = Transcript::new();
// The shell has only echoed the line; a prompt redraw (a `B` in PS1
// repainted by a transient-prompt theme) reports "at a prompt" too.
t.output(b"make\r\n\x1b]133;B\x07");
assert!(t.prompt(true, None).is_none());
// Running: the daemon reports "not at a prompt".
t.output(b"\x1b]133;C;make\x07building\r\n");
assert!(t.prompt(false, None).is_none());
// A `D` the daemon did not believe — relayed by something running in
// the foreground, reported as "not at a prompt" — ends nothing.
t.output(b"\x1b]133;D;7\x07more\r\n");
assert!(t.prompt(false, Some(7)).is_none());
t.output(b"done\r\n\x1b]133;D;2\x07\x1b]133;A\x07$ ");
let done = t.prompt(true, Some(2)).expect("the real end");
assert_eq!(done.exit, Some(2));
assert_eq!(
t.take().0,
b"building\r\n\x1b]133;D;7\x07more\r\ndone\r\n".to_vec(),
"the output runs to the D the shell's own prompt followed"
);
}
#[test]
fn a_prompt_with_no_command_before_it_means_the_line_never_ran() {
let mut t = Transcript::new();
t.output(b"echo (\r\nzsh: parse error near `('\r\n\x1b]133;A\x07% ");
let done = t.prompt(true, None).expect("the shell is back");
assert_eq!((done.exit, done.ran), (None, false));
assert_eq!(
t.take().0,
b"echo (\r\nzsh: parse error near `('\r\n".to_vec(),
"what the shell said about the line is the only output there is"
);
}
#[test]
fn the_exit_code_falls_back_to_the_mark_itself() {
let mut t = Transcript::new();
t.output(b"\x1b]133;C\x07\x1b]133;D;5\x1b\\");
let done = t
.prompt(true, None)
.expect("ST terminates a mark as well as BEL");
assert_eq!(done.exit, Some(5));
assert!(t.take().0.is_empty());
}
#[test]
fn a_timeout_keeps_what_the_command_has_printed_so_far() {
let mut t = Transcript::new();
t.output(b"sleep 9\r\n\x1b]133;C;sleep 9\x07tick\r\n");
assert_eq!(t.take().0, b"tick\r\n".to_vec());
}
#[test]
fn output_past_the_bound_is_dropped_from_the_front() {
let mut t = Transcript::new();
t.output(b"\x1b]133;C\x07");
let chunk = vec![b'x'; KEEP_OUTPUT];
for _ in 0..3 {
t.output(&chunk);
t.compact();
}
t.output(b"end\x1b]133;D;0\x07");
assert!(t.prompt(true, Some(0)).is_some());
let (out, dropped) = t.take();
assert_eq!(out.len(), KEEP_OUTPUT);
assert!(out.ends_with(b"end"));
assert_eq!(out.len() + dropped, KEEP_OUTPUT * 3 + 3);
}
}
+6
View File
@@ -90,6 +90,12 @@ pub fn send_long_help() -> String {
when there is none, so `send %42 --enter` runs whatever is already typed in pane 42. \
An unmarked id is not a target for it — `send 83 --enter` is refused, because it \
reads just as much like typing 83 into your own pane; write %83 to mean the pane.\n\n\
--stdin and --from-file take the text from there instead, byte for byte — the way \
to send a secret, which as TEXT is visible to every local user in `ps` and lands in \
your shell history. The one positional is then the pane.\n\n\
--paste sends the text the way the GUI pastes: framed in bracketed paste when the \
pane has switched it on, so a multi-line text arrives as one block of text instead \
of running line by line. --json reports whether it was framed.\n\n\
Keys: {}. Aliases: {}.",
vocabulary(),
aliases.join(", ")
+1
View File
@@ -2,6 +2,7 @@ mod address;
mod backend;
mod cli;
mod commands;
mod exec;
mod gui;
mod keys;
mod output;
+33 -3
View File
@@ -151,9 +151,14 @@ pub fn pane_table(machine: &Machine, only: Option<WorkspaceId>) -> String {
record
.and_then(|r| r.cwd.clone())
.unwrap_or_else(|| "-".to_string()),
record
.map(|r| if r.live { "yes" } else { "no" }.to_string())
.unwrap_or_else(|| "?".to_string()),
// Not running, and meant not to be: a sleeping tab's panes
// are waiting for their tab to be woken, not dead.
match record {
_ if tab.hibernated => "asleep".to_string(),
Some(r) if r.live => "yes".to_string(),
Some(_) => "no".to_string(),
None => "?".to_string(),
},
]);
}
}
@@ -659,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}");
}
}
+3
View File
@@ -13,6 +13,7 @@ pub fn two_workspace_machine() -> Machine {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
},
Tab {
id: TabId::new(),
@@ -25,6 +26,7 @@ pub fn two_workspace_machine() -> Machine {
a: Box::new(PaneNode::Leaf { pane: 2 }),
b: Box::new(PaneNode::Leaf { pane: 3 }),
},
hibernated: false,
},
],
active_tab: None,
@@ -41,6 +43,7 @@ pub fn two_workspace_machine() -> Machine {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 5 },
hibernated: false,
}],
active_tab: None,
attachment: None,
+223
View File
@@ -87,6 +87,22 @@ fn main() {
"procs_says_where_the_panes_session_lives",
procs_says_where_the_panes_session_lives,
),
(
"exec_returns_the_commands_output_and_exit_code",
exec_returns_the_commands_output_and_exit_code,
),
(
"exec_refuses_a_pane_with_no_prompt_marks",
exec_refuses_a_pane_with_no_prompt_marks,
),
(
"send_stdin_delivers_bytes_it_never_echoes",
send_stdin_delivers_bytes_it_never_echoes,
),
(
"send_paste_asks_the_pane_for_its_bracketed_paste_mode",
send_paste_asks_the_pane_for_its_bracketed_paste_mode,
),
];
let mut failed = 0;
@@ -1052,3 +1068,210 @@ fn capture_tail_trims_a_real_panes_answer(daemon: &Daemon) {
std::thread::sleep(Duration::from_millis(200));
}
}
/// A pane running `program` with no shell in front of it — so no prompt marks
/// and no line editor, just the pty's own echo. What the tests below type into
/// it comes back exactly as the pane received it.
fn bare_pane(daemon: &Daemon, program: &str) -> u64 {
let shell = ShellSpec {
program: program.into(),
args: Vec::new(),
args_are_tty7_defaults: false,
};
let session = PaneClient::at(daemon.pane_endpoint())
.spawn(
None,
WinSize {
cols: 80,
rows: 24,
cell_w: 8,
cell_h: 16,
},
Some(shell),
Some("bare-pane-e2e".into()),
None,
)
.unwrap_or_else(|e| panic!("spawn {program}: {e}"));
let pane = session.pane_id();
session.detach().expect("leave the bare pane running");
pane
}
/// Block until the pane's shell has drawn its first prompt: before that there
/// are no marks for `exec` to follow, and it refuses rather than guesses.
fn await_prompt(daemon: &Daemon, address: &str) {
let deadline = Instant::now() + SETTLE_WITHIN;
loop {
let procs = daemon.run_json(&["procs", address]);
if procs["context"]["at_prompt"].as_bool() == Some(true) {
return;
}
assert!(
Instant::now() < deadline,
"the shell never reported a prompt; last procs: {procs}"
);
std::thread::sleep(Duration::from_millis(200));
}
}
fn exec_returns_the_commands_output_and_exit_code(daemon: &Daemon) {
// The marks come from tty7's integration for zsh/bash/fish/nushell/pwsh;
// the shell a Windows runner defaults to is not one this can count on.
if cfg!(windows) {
return;
}
let created = daemon.run_json(&["new", &workdir()]);
let pane = created["pane"].as_u64().expect("new prints the pane id");
let address = format!("%{pane}");
await_prompt(daemon, &address);
// `sh -c` so the exit code does not depend on which shell the pane runs.
let out = daemon.run(&[
"exec",
&address,
"--timeout",
"60",
"--",
"sh",
"-c",
"'echo tty7_e2e_exec_$((6*7)); exit 3'",
]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
out.status.code(),
Some(3),
"exec exits with the command's code; stdout {stdout:?}, stderr {:?}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout.contains("tty7_e2e_exec_42"), "{stdout:?}");
assert!(
!stdout.contains("6*7") && !stdout.contains("exit 3"),
"the typed line is not the command's output: {stdout:?}"
);
// The pane is back at its prompt, so it takes the next one straight away.
// `--json` ahead of `--`: after it, it would be an argument to `true`.
let out = daemon.run_ok(&["exec", &address, "--json", "--timeout", "60", "--", "true"]);
let json: serde_json::Value = serde_json::from_str(&out).expect("--json prints JSON");
assert_eq!(json["exit"], 0, "{json}");
assert_eq!(json["exit_code_known"], true, "{json}");
assert_eq!(json["pane"], pane, "{json}");
assert_eq!(
json["output"], "",
"a command that prints nothing has nothing to show — not the shell's \
prompt preparation: {json}"
);
}
fn exec_refuses_a_pane_with_no_prompt_marks(daemon: &Daemon) {
if cfg!(windows) {
return;
}
let pane = bare_pane(daemon, "/bin/cat");
let out = daemon.run(&["exec", &format!("%{pane}"), "--", "echo", "never"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("shell-integration"), "{stderr}");
// Refused before typing: nothing reached the pane.
let seen = daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"]);
assert!(!seen.contains("never"), "{seen:?}");
}
fn send_via_stdin(daemon: &Daemon, args: &[&str], input: &[u8]) -> Output {
let mut child = daemon
.cli(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("could not run tty7 {args:?}: {e}"));
child
.stdin
.take()
.expect("stdin was piped")
.write_all(input)
.expect("feed tty7's stdin");
child.wait_with_output().expect("tty7 ran to completion")
}
fn await_capture(daemon: &Daemon, address: &str, marker: &str) -> String {
let deadline = Instant::now() + SETTLE_WITHIN;
loop {
let seen = daemon.run_ok(&["capture", address, "--scrollback"]);
if seen.contains(marker) {
return seen;
}
assert!(
Instant::now() < deadline,
"{marker:?} never showed up; last capture:\n{seen}"
);
std::thread::sleep(Duration::from_millis(200));
}
}
fn send_stdin_delivers_bytes_it_never_echoes(daemon: &Daemon) {
if cfg!(windows) {
return;
}
let pane = bare_pane(daemon, "/bin/cat");
let address = format!("%{pane}");
let out = send_via_stdin(
daemon,
&["send", &address, "--stdin", "--json"],
b"tty7_e2e_stdin_secret\n",
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"{stdout}{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!stdout.contains("tty7_e2e_stdin_secret"),
"the payload must not come back in the report: {stdout}"
);
let json: serde_json::Value = serde_json::from_str(&stdout).expect("--json prints JSON");
assert_eq!(json["source"], "stdin", "{json}");
assert_eq!(json["bytes"], 22, "{json}");
await_capture(daemon, &address, "tty7_e2e_stdin_secret");
}
fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) {
if cfg!(windows) {
return;
}
let pane = bare_pane(daemon, "/bin/cat");
let address = format!("%{pane}");
// Off to begin with: nothing in the pane has asked for it.
let json = daemon.run_json(&["send", &address, "tty7_e2e_one\ntty7_e2e_two", "--paste"]);
assert_eq!(json["bracketed_paste_mode"], false, "{json}");
assert_eq!(json["bracketed"], false, "{json}");
await_capture(daemon, &address, "tty7_e2e_two");
// `cat` hands back what it reads, so the program in the pane is now the
// one that switched mode 2004 on — the daemon sees it in the output.
let out = send_via_stdin(daemon, &["send", &address, "--stdin"], b"\x1b[?2004h\n");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let deadline = Instant::now() + SETTLE_WITHIN;
loop {
let procs = daemon.run_json(&["procs", &address]);
if procs["context"]["bracketed_paste"].as_bool() == Some(true) {
break;
}
assert!(
Instant::now() < deadline,
"the pane never reported bracketed paste on: {procs}"
);
std::thread::sleep(Duration::from_millis(200));
}
let json = daemon.run_json(&["send", &address, "tty7_e2e_framed", "--paste"]);
assert_eq!(json["bracketed"], true, "{json}");
// The frame reached the pty: cat's echo of it carries the opener's tail.
let seen = await_capture(daemon, &address, "tty7_e2e_framed");
assert!(seen.contains("[200~"), "{seen:?}");
}
+342 -18
View File
@@ -304,6 +304,32 @@ impl CLIAgent {
}
fn replay_flags(self, argv: &[String]) -> Option<Vec<String>> {
let tail = self.session_free_tail(argv)?;
let safe = |t: &str| {
!t.is_empty()
&& t.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b))
};
if !tail.iter().all(|t| safe(t)) {
return None;
}
let mut prev_was_flag = false;
for t in &tail {
let is_flag = t.starts_with('-');
if !is_flag && !prev_was_flag {
return None;
}
prev_was_flag = is_flag;
}
Some(tail.into_iter().map(String::from).collect())
}
/// What follows the agent's own name in `argv`, with everything that names
/// a session taken out: the resume/fork subcommands and the flags listed as
/// stale for this agent, each with its value. `None` when no token in
/// `argv` names this agent — a wrapper, where there is no telling which
/// arguments are the agent's.
fn session_free_tail(self, argv: &[String]) -> Option<Vec<&str>> {
let names_self = |token: &str| {
token.split(['/', '\\']).any(|seg| {
CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self)
@@ -490,24 +516,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 {
@@ -598,6 +607,58 @@ impl CLIAgent {
.find(|a| a.aliases().contains(&token))
}
/// The program a launch with no override runs: the first name the agent
/// is detected by, which is the one its installer puts on `PATH`.
pub fn binary(self) -> &'static str {
self.aliases()[0]
}
/// The command line a quick launch types for this agent: the user's
/// `agent_launch` entry when there is one, the bare binary otherwise.
///
/// Keys are matched the way [`CLIAgent::from_slug`] reads them, so a
/// hand-written `"Claude"` still counts. A blank entry is no override.
pub fn launch_command(self, overrides: &HashMap<String, String>) -> String {
overrides
.iter()
.find(|(slug, _)| CLIAgent::from_slug(slug) == Some(self))
.map(|(_, line)| line.trim())
.filter(|line| !line.is_empty())
.map_or_else(|| self.binary().to_string(), str::to_string)
}
/// `argv` as a command line worth launching this agent with again: its
/// binary followed by the flags it ran with, minus everything that names
/// one particular session (a resume, a fork, a session id) and minus
/// positional arguments such as a prompt, which belong to that one run.
///
/// A run that went through something that does not name the agent — a
/// wrapper script mapped by `agent_commands` — cannot be taken apart, so
/// it comes back whole: it is what was typed, and it launched the agent.
pub fn launch_argv_for_default(self, argv: &[String]) -> Option<Vec<String>> {
let env = argv.iter().take_while(|t| is_env_assignment(t)).count();
if argv.len() == env {
return None;
}
let Some(tail) = self.session_free_tail(argv) else {
return Some(argv.to_vec());
};
let mut out: Vec<String> = argv[..env].to_vec();
out.push(self.binary().to_string());
let mut takes_value = false;
for t in tail {
let is_flag = t.starts_with('-') && t.len() > 1;
if is_flag {
out.push(t.to_string());
takes_value = !t.contains('=');
} else if takes_value {
out.push(t.to_string());
takes_value = false;
}
}
Some(out)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn detect_from_argv(argv: &[String]) -> Option<CLIAgent> {
Self::detect_from_argv_with(argv, &HashMap::new())
@@ -625,6 +686,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 +794,99 @@ fn is_interpreter(stem: &str) -> bool {
)
}
fn is_shell(stem: &str) -> bool {
matches!(
stem.to_ascii_lowercase().as_str(),
"sh" | "bash" | "zsh" | "dash" | "ksh" | "fish"
)
}
/// The program a launch command line runs: its first word that is not an
/// environment assignment.
pub fn launch_program(command: &str) -> Option<String> {
command_argv(command)
.into_iter()
.find(|t| !is_env_assignment(t))
}
/// The wrapper names `agent_launch` implies, in the shape `agent_commands`
/// takes: `{"claude": "cc --fast"}` means a `cc` in a pane is Claude Code.
///
/// This is how a pane opened by a quick launch through a wrapper is still
/// recognised without the user also writing an `agent_commands` entry: the
/// launch typed exactly this command, so its program is known to be that
/// agent. A program that is already an agent's own name needs no entry, and
/// an interpreter (`npx`, `node`, `env`, …) or a shell is never mapped
/// wholesale — `npx` is not Claude Code just because Claude Code is one of the
/// things it can run.
pub fn launch_aliases(overrides: &HashMap<String, String>) -> HashMap<String, String> {
let mut out = HashMap::new();
for (slug, line) in overrides {
let Some(agent) = CLIAgent::from_slug(slug) else {
continue;
};
let Some(program) = launch_program(line) else {
continue;
};
let stem = base_stem(&program).to_ascii_lowercase();
if stem.is_empty()
|| CLIAgent::match_token(&stem).is_some()
|| is_interpreter(&stem)
|| is_shell(&stem)
{
continue;
}
out.insert(stem, agent.slug().to_string());
}
out
}
/// Whether `program` names an executable file, either directly (it has a
/// directory in it) or through one of the directories in `path`.
///
/// On Windows a bare name is also tried with the extensions an npm or winget
/// install leaves behind, since `claude` there is really `claude.cmd`.
pub fn program_on_path(program: &str, path: &std::ffi::OsStr) -> bool {
let program = program.trim();
if program.is_empty() {
return false;
}
if program.contains(['/', '\\']) {
// The launch is typed into a shell, which expands a leading `~/`.
let home = program
.strip_prefix("~/")
.and_then(|rest| Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join(rest)));
return is_executable(home.as_deref().unwrap_or(std::path::Path::new(program)));
}
let exts: &[&str] = if cfg!(windows) {
&["", ".exe", ".cmd", ".bat", ".ps1"]
} else {
&[""]
};
std::env::split_paths(path).any(|dir| {
exts.iter()
.any(|ext| is_executable(&dir.join(format!("{program}{ext}"))))
})
}
fn is_executable(path: &std::path::Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -2253,4 +2421,160 @@ mod tests {
"\"waiting\""
);
}
fn overrides(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn a_launch_is_the_bare_binary_unless_overridden() {
let none = HashMap::new();
assert_eq!(CLIAgent::Claude.launch_command(&none), "claude");
assert_eq!(CLIAgent::Codex.launch_command(&none), "codex");
assert_eq!(CLIAgent::Cursor.launch_command(&none), "cursor-agent");
assert_eq!(CLIAgent::Antigravity.launch_command(&none), "agy");
assert_eq!(CLIAgent::QoderCLI.launch_command(&none), "qoder");
let set = overrides(&[
("claude", " claude --dangerously-skip-permissions "),
// Keys are read the way `from_slug` reads them.
("Codex", "codex --model o3"),
// A blank entry is no override at all.
("gemini", " "),
]);
assert_eq!(
CLIAgent::Claude.launch_command(&set),
"claude --dangerously-skip-permissions"
);
assert_eq!(CLIAgent::Codex.launch_command(&set), "codex --model o3");
assert_eq!(CLIAgent::Gemini.launch_command(&set), "gemini");
assert_eq!(CLIAgent::Aider.launch_command(&set), "aider");
}
#[test]
fn every_agent_launches_as_a_name_it_is_detected_by() {
let none = HashMap::new();
for agent in CLIAgent::ALL {
let argv = command_argv(&agent.launch_command(&none));
assert_eq!(
CLIAgent::detect_from_argv(&argv),
Some(agent),
"{agent:?} would launch as something detection does not know"
);
}
}
#[test]
fn a_wrapper_named_by_a_launch_override_is_detected_as_its_agent() {
let aliases = launch_aliases(&overrides(&[("claude", "FOO=1 cc --fast")]));
// Run as a binary, and run as a script under its interpreter.
for run in [
argv(&["cc", "--fast"]),
argv(&["/Users/me/bin/cc", "--fast"]),
argv(&["/bin/bash", "/Users/me/bin/cc", "--fast"]),
argv(&["node", "/Users/me/bin/cc.js", "--fast"]),
] {
assert_eq!(
CLIAgent::detect_from_argv_with(&run, &aliases),
Some(CLIAgent::Claude),
"{run:?}"
);
}
// Without the override the same wrapper is nobody.
assert_eq!(
CLIAgent::detect_from_argv_with(&argv(&["cc", "--fast"]), &HashMap::new()),
None
);
}
#[test]
fn a_launch_override_never_maps_an_interpreter_or_a_real_agent_name() {
let aliases = launch_aliases(&overrides(&[
("claude", "npx @anthropic-ai/claude-code"),
("codex", "bash -lc codex"),
// Already detected as itself; mapping it would add nothing.
("gemini", "gemini --yolo"),
("not-an-agent", "cc"),
]));
assert!(aliases.is_empty(), "{aliases:?}");
// A shell running something else is still nobody.
assert_eq!(
CLIAgent::detect_from_argv_with(
&argv(&["bash", "./build.sh"]),
&launch_aliases(&overrides(&[("claude", "cc")]))
),
None
);
}
#[test]
fn launch_args_drop_the_session_and_the_prompt_but_keep_the_flags() {
assert_eq!(
CLIAgent::Claude.launch_argv_for_default(&argv(&[
"node",
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"--model",
"opus",
"--resume",
"abc-123",
"--append-system-prompt",
"be terse",
"fix the bug",
])),
Some(argv(&[
"claude",
"--model",
"opus",
"--append-system-prompt",
"be terse",
]))
);
assert_eq!(
CLIAgent::Codex.launch_argv_for_default(&argv(&[
"codex",
"resume",
"019a",
"--model=o3",
"prompt",
])),
Some(argv(&["codex", "--model=o3"]))
);
// A wrapper cannot be taken apart; what was typed is what launched it.
assert_eq!(
CLIAgent::Claude.launch_argv_for_default(&argv(&["cc", "--fast", "x"])),
Some(argv(&["cc", "--fast", "x"]))
);
assert_eq!(CLIAgent::Claude.launch_argv_for_default(&[]), None);
}
#[test]
fn program_on_path_finds_only_executables() {
let dir = tempfile::TempDir::new().unwrap();
let bin = dir.path().join("claude");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::write(dir.path().join("codex"), "not executable").unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!program_on_path("codex", dir.path().as_os_str()));
}
std::fs::create_dir(dir.path().join("gemini")).unwrap();
let path =
std::env::join_paths([std::path::Path::new("/nonexistent"), dir.path()]).unwrap();
assert!(program_on_path("claude", &path));
assert!(
!program_on_path("gemini", &path),
"a directory is not a program"
);
assert!(!program_on_path("aider", &path));
assert!(program_on_path(
bin.to_str().unwrap(),
std::ffi::OsStr::new("")
));
assert!(!program_on_path("", &path));
}
}
+565 -25
View File
@@ -213,6 +213,11 @@ pub struct Config {
pub new_tab_position: NewTabPosition,
#[serde(default, deserialize_with = "de_lenient")]
pub tab_bar_position: TabBarPosition,
/// What an SSH pane's tab is called (#726). Only the name the tab shows:
/// the titles the remote side sets are still read and kept, and win again
/// the moment this is back on [`SshTabTitle::Dynamic`].
#[serde(default, deserialize_with = "de_lenient")]
pub ssh_tab_title: SshTabTitle,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
#[serde(default)]
@@ -246,6 +251,12 @@ pub struct Config {
/// impression than one they asked for.
#[serde(default)]
pub scm_graph_expanded: bool,
/// Whether the source control panel lists changed files as a directory
/// tree rather than one flat run per group. Off by default: the flat list
/// is what a handful of changes reads best as, and the tree is for the
/// agent run that touched sixty files across a dozen modules.
#[serde(default)]
pub scm_changes_tree: bool,
/// Whether a file opens in the code panel with soft wrap on. Not a
/// setting anyone picks up front: it is whatever the status bar's Wrap
/// toggle (or `ToggleDocumentWrap`) was last left at, so the next file
@@ -365,20 +376,45 @@ pub struct Config {
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
/// The saved SSH hosts. They live in [`SERVERS_FILE`], not in
/// `config.json` (#911): people sync `config.json` between machines for its
/// colours and keys, and a list of servers is not something every machine
/// should carry. Still read from `config.json` so [`Config::load`] can
/// move an older file's hosts across, never written back to it.
#[serde(default, skip_serializing)]
pub ssh_profiles: Vec<crate::core::ssh_profile::SshProfile>,
#[serde(default = "default_true")]
pub verify_host_keys: bool,
#[serde(default)]
pub ssh_warn_on_close: bool,
#[serde(default)]
/// How often each saved host is used, keyed by its id — which means
/// nothing without the hosts it counts, so it lives in [`SERVERS_FILE`]
/// beside them.
#[serde(default, skip_serializing)]
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
/// How often and how lately each shell was opened from the New Tab menu
/// or its palette command, keyed by the label the inventory shows it
/// under. Orders the menu's short list of shells the way
/// `ssh_profile_frecency` orders its hosts.
#[serde(default)]
pub shell_frecency: HashMap<String, ProfileUsage>,
#[serde(default)]
pub command_frecency: HashMap<String, ProfileUsage>,
#[serde(default)]
pub agent_commands: HashMap<String, String>,
/// The command line a quick launch types for an agent, keyed by its slug:
/// `{"claude": "claude --dangerously-skip-permissions"}`. An agent with no
/// entry is launched as its bare binary. A wrapper named here is detected
/// as that agent without an `agent_commands` entry of its own.
#[serde(default)]
pub agent_launch: HashMap<String, String>,
/// How often and how recently each agent (by slug) was launched or seen
/// running — what orders the quick-launch commands and picks the agent
/// "New Agent Tab" opens.
#[serde(default)]
pub agent_frecency: HashMap<String, ProfileUsage>,
#[serde(default = "default_true")]
pub restore_agent_sessions: bool,
/// Give each pane its own shell history instead of one file every pane
@@ -400,6 +436,45 @@ pub struct Config {
/// permanent data loss (#537). Cleared only by a load that parses.
#[serde(skip)]
pub quarantined: bool,
/// [`SERVERS_FILE`] is there but could not be read or parsed. The hosts
/// in hand are not the ones in it, so [`Config::save`] refuses to run
/// rather than write them over it — the same rule, for the same reason,
/// as [`Self::quarantined`].
#[serde(skip)]
pub servers_unreadable: bool,
}
/// Where the saved SSH hosts live, beside `config.json` (#911).
pub const SERVERS_FILE: &str = "servers.json";
/// What [`SERVERS_FILE`] holds. The keys are the ones `config.json` used to
/// carry, so a hand-moved block pastes straight across.
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
struct ServersFile {
ssh_profiles: Vec<crate::core::ssh_profile::SshProfile>,
ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
}
#[derive(Serialize)]
struct ServersFileRef<'a> {
ssh_profiles: &'a [crate::core::ssh_profile::SshProfile],
ssh_profile_frecency: &'a HashMap<uuid::Uuid, ProfileUsage>,
}
/// Whether a `config.json` still carries the keys that moved to
/// [`SERVERS_FILE`] — the cue for the one-time move.
#[derive(Deserialize)]
struct LegacyServerKeys {
#[serde(default)]
ssh_profiles: Option<serde::de::IgnoredAny>,
#[serde(default)]
ssh_profile_frecency: Option<serde::de::IgnoredAny>,
}
fn has_legacy_server_keys(text: &str) -> bool {
serde_json::from_str::<LegacyServerKeys>(strip_bom(text))
.is_ok_and(|keys| keys.ssh_profiles.is_some() || keys.ssh_profile_frecency.is_some())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
@@ -477,6 +552,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
@@ -644,6 +734,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,
@@ -653,6 +744,7 @@ 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_auto_grouping: true,
@@ -691,11 +783,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,
}
}
}
@@ -742,13 +838,42 @@ impl Config {
/// app onto the result needs the outcome to keep a broken file from
/// evicting the settings the app is running on.
pub fn load_with_outcome() -> (Self, LoadOutcome) {
let Some(path) = Self::path() else {
return (Config::default(), LoadOutcome::Absent);
};
let text = match std::fs::read_to_string(&path) {
match Self::path() {
Some(path) => Self::load_from(&path),
None => (Config::default(), LoadOutcome::Absent),
}
}
/// [`Config::load_with_outcome`] for the `config.json` at `path`, with the
/// saved hosts read from the [`SERVERS_FILE`] beside it.
///
/// A `config.json` from before the split still holds the hosts itself.
/// When there is no [`SERVERS_FILE`] yet they are moved into one, once:
/// the new file is written first and `config.json` rewritten without them
/// only after that has landed, so a failure anywhere leaves them where
/// they were. When both files are there the new one wins and the copy in
/// `config.json` is not read — it is what an older tty7 wrote after the
/// move, or a synced file brought in, and it falls out of `config.json`
/// at its next save.
pub(crate) fn load_from(path: &Path) -> (Self, LoadOutcome) {
let (mut cfg, outcome, text) = Self::load_config_file(path);
let servers = path.with_file_name(SERVERS_FILE);
let found = cfg.load_servers(&servers);
if !found
&& let Some(text) = text
&& has_legacy_server_keys(&text)
{
cfg.move_servers_out(&text, path, &servers);
}
(cfg, outcome)
}
/// `config.json` alone, with the text it parsed from.
fn load_config_file(path: &Path) -> (Self, LoadOutcome, Option<String>) {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (Config::default(), LoadOutcome::Absent);
return (Config::default(), LoadOutcome::Absent, None);
}
Err(e) => {
// Unreadable is not unparseable, but the rule is the same:
@@ -761,13 +886,13 @@ impl Config {
);
let mut cfg = Config::default();
cfg.quarantined = true;
return (cfg, LoadOutcome::Unreadable);
return (cfg, LoadOutcome::Unreadable, None);
}
};
match serde_json::from_str::<Config>(strip_bom(&text)) {
Ok(mut cfg) => {
cfg.sanitize();
(cfg, LoadOutcome::Parsed)
(cfg, LoadOutcome::Parsed, Some(text))
}
Err(e) => {
// The next `save` overwrites this file wholesale, so handing
@@ -779,14 +904,83 @@ impl Config {
"failed to parse config at {}: {e}; keeping it aside and using defaults",
path.display()
);
quarantine(&path);
quarantine(path);
let mut cfg = Config::default();
cfg.quarantined = true;
(cfg, LoadOutcome::Quarantined)
(cfg, LoadOutcome::Quarantined, None)
}
}
}
/// Takes the hosts from the [`SERVERS_FILE`] at `path`, if there is one.
/// Returns whether there was — a file that is there but broken counts, so
/// that nothing is moved over the top of it.
fn load_servers(&mut self, path: &Path) -> bool {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false,
Err(e) => {
log::warn!(
"failed to read {}: {e}; saved SSH hosts unavailable, writes suppressed",
path.display()
);
self.servers_unreadable = true;
return true;
}
};
match serde_json::from_str::<ServersFile>(strip_bom(&text)) {
Ok(servers) => {
self.ssh_profiles = servers.ssh_profiles;
self.ssh_profile_frecency = servers.ssh_profile_frecency;
}
Err(e) => {
log::warn!(
"failed to parse {}: {e}; keeping it aside, writes suppressed",
path.display()
);
quarantine(path);
self.servers_unreadable = true;
}
}
true
}
/// The one-time move of the hosts out of `config.json` (#911). New file
/// first: until it has landed, `config.json` is the only copy.
///
/// `config.json` is then rewritten from its own `text` with just those
/// two keys taken out, not from `self` the way a save writes it. Nobody
/// asked for this write, so it should change nothing else: a hand-kept
/// file stays the handful of keys it was rather than growing every
/// default, and a key this build does not know — one a newer tty7 on a
/// synced machine wrote — survives.
fn move_servers_out(&self, text: &str, config: &Path, servers: &Path) {
if let Err(e) = self.write_servers(servers) {
log::warn!(
"could not move SSH hosts to {}: {e}; leaving them in config.json",
servers.display()
);
return;
}
let rewrite = || -> std::io::Result<()> {
let mut value: serde_json::Value =
serde_json::from_str(strip_bom(text)).map_err(std::io::Error::other)?;
if let Some(object) = value.as_object_mut() {
object.remove("ssh_profiles");
object.remove("ssh_profile_frecency");
}
let text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
write_atomic(config, text.as_bytes())
};
match rewrite() {
Ok(()) => log::info!("moved SSH hosts from config.json to {}", servers.display()),
Err(e) => log::warn!(
"moved SSH hosts to {}, but could not rewrite config.json: {e}",
servers.display()
),
}
}
/// What a clicked file link should open in, with the pre-setting default
/// filled in for a `Config` that never went through [`Self::sanitize`].
pub fn file_open_mode(&self) -> LinkFileOpen {
@@ -874,18 +1068,62 @@ impl Config {
/// Persist without hiding a failure from an interactive settings editor.
pub fn try_save(&self) -> std::io::Result<()> {
let path = Self::path()
.ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?;
self.try_save_to(&path)
}
/// [`Config::try_save`] for the `config.json` at `path`, and the
/// [`SERVERS_FILE`] beside it.
pub(crate) fn try_save_to(&self, path: &Path) -> std::io::Result<()> {
if self.quarantined {
return Err(std::io::Error::other(
"the existing configuration could not be read; repair it before saving",
));
}
let path = Self::path()
.ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?;
if self.servers_unreadable {
return Err(std::io::Error::other(format!(
"{SERVERS_FILE} could not be read; repair it before saving"
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// Hosts first, for the same reason the move out of `config.json`
// writes them first: a `config.json` that lands without them while
// their own file failed to would have dropped them.
self.write_servers(&path.with_file_name(SERVERS_FILE))?;
self.write_config(path)
}
fn write_config(&self, path: &Path) -> std::io::Result<()> {
let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
write_atomic(&path, text.as_bytes())
write_atomic(path, text.as_bytes())
}
/// Writes the hosts to their own file — unless it already says exactly
/// this, which is nearly every save: a dragged divider has nothing to do
/// with the hosts, and rewriting the file anyway would wake the watcher a
/// second time. No hosts and no file stays no file.
fn write_servers(&self, path: &Path) -> std::io::Result<()> {
let on_disk = match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str::<ServersFile>(strip_bom(&text)).ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ServersFile::default()),
Err(_) => None,
};
if on_disk.as_ref().is_some_and(|held| {
held.ssh_profiles == self.ssh_profiles
&& held.ssh_profile_frecency == self.ssh_profile_frecency
}) {
return Ok(());
}
let text = serde_json::to_string_pretty(&ServersFileRef {
ssh_profiles: &self.ssh_profiles,
ssh_profile_frecency: &self.ssh_profile_frecency,
})
.map_err(std::io::Error::other)?;
// Host names, users and key paths: nobody else's business.
write_atomic_private(path, text.as_bytes())
}
fn path() -> Option<PathBuf> {
@@ -1170,15 +1408,44 @@ pub fn extra_env() -> HashMap<String, String> {
Config::load().env
}
pub fn agent_commands_cached() -> &'static HashMap<String, String> {
static CACHE: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
Config::load()
.agent_commands
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect()
})
/// Every wrapper name the daemon should read as an agent: `agent_commands`,
/// plus the programs `agent_launch` entries run (see
/// [`crate::core::cli_agent::launch_aliases`]). An explicit `agent_commands`
/// entry wins over an implied one.
///
/// Read again whenever `config.json` changes rather than once per process: the
/// daemon outlives any number of edits, and a launch override added while it
/// runs has to be recognised by the very next launch that uses it.
pub fn agent_detection_aliases() -> std::sync::Arc<HashMap<String, String>> {
type Stamp = Option<(std::time::SystemTime, u64)>;
static CACHE: std::sync::Mutex<Option<(Stamp, std::sync::Arc<HashMap<String, String>>)>> =
std::sync::Mutex::new(None);
let stamp: Stamp = Config::path()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| Some((m.modified().ok()?, m.len())));
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((seen, aliases)) = cache.as_ref()
&& *seen == stamp
{
return aliases.clone();
}
let cfg = Config::load();
let aliases = std::sync::Arc::new(detection_aliases(&cfg.agent_commands, &cfg.agent_launch));
*cache = Some((stamp, aliases.clone()));
aliases
}
/// [`agent_detection_aliases`] over the two maps it reads, for a caller that
/// already holds them.
pub fn detection_aliases(
commands: &HashMap<String, String>,
launch: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut aliases = crate::core::cli_agent::launch_aliases(launch);
for (name, slug) in commands {
aliases.insert(name.to_ascii_lowercase(), slug.clone());
}
aliases
}
fn default_preset() -> String {
@@ -1405,8 +1672,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);
}
@@ -2517,4 +2788,273 @@ 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);
}
}
}
+148
View File
@@ -177,6 +177,19 @@ pub struct Tab {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_auto: Option<AutoKey>,
pub root: PaneNode,
/// The tab was put to sleep: its panes were stopped to give their memory
/// back, and it stays in the workspace to be woken later (#762).
///
/// The panes keep their ids in `root` and their records in
/// [`Machine::panes`] — cwd, shell, agent session — because waking is a
/// restore of exactly those panes, through the same path a reboot takes.
/// That is also what keeps the sweeps off them: a pane the tree still
/// names is one whose stored screen and history are kept.
///
/// Left out of the document while false, so a tree written by this build
/// reads the same to one that predates the field.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hibernated: bool,
}
impl Tab {
@@ -187,6 +200,7 @@ impl Tab {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane },
hibernated: false,
}
}
}
@@ -772,6 +786,43 @@ impl MachineStore {
})
}
/// Put a tab to sleep, or mark it awake again. Answers the panes the tab
/// holds, which for a tab going to sleep are the ones whoever asked is now
/// expected to stop — see [`ControlRequest::TabSetHibernated`].
///
/// Only the mark changes here. The panes stay in the tab and in the pane
/// list, so the facts a wake needs survive the processes: waking hands the
/// same ids to the ordinary restore, which spawns successors and swaps
/// them in with `pane_replace`. Setting the mark a tab already has is not
/// a change and raises no delta.
///
/// The delta is `TabRestructured` carrying the whole tab rather than a
/// variant of its own, so a client that predates the mark still decodes
/// it — it reads the same tab back, minus a field it does not know.
///
/// [`ControlRequest::TabSetHibernated`]: crate::daemon::control::ControlRequest::TabSetHibernated
pub fn tab_set_hibernated(
&self,
workspace: WorkspaceId,
tab: TabId,
hibernated: bool,
origin: Option<SubscriberId>,
) -> io::Result<Vec<u64>> {
self.mutate(origin, |m| {
let t = find_tab(m, workspace, tab)?;
let panes = t.root.pane_ids();
if t.hibernated == hibernated {
return Ok((panes, Vec::new()));
}
t.hibernated = hibernated;
let delta = LayoutDelta::TabRestructured {
tab: t.clone(),
pane: None,
};
Ok((panes, vec![(workspace, delta)]))
})
}
pub fn tab_set_group(
&self,
workspace: WorkspaceId,
@@ -2921,4 +2972,101 @@ mod tests {
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]);
assert!(machine.panes.is_empty());
}
/// A tab put to sleep keeps its panes — ids in the layout, records in the
/// pane list — because waking is a restore of exactly those. Only the
/// orphan collection a close runs could take them, and a sleeping tab
/// still names them.
#[test]
fn a_sleeping_tab_keeps_its_panes_and_says_so_in_a_delta() {
let (store, _dir, ws, tab) = store_with_tab();
store
.pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/work"), false, None)
.unwrap();
let (_sub, heard) = recorded(&store);
let panes = store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
assert_eq!(panes, vec![1, 2]);
let m = store.machine();
let asleep = &m.workspaces[0].tabs[0];
assert!(asleep.hibernated);
assert_eq!(asleep.root.pane_ids(), vec![1, 2]);
assert!(m.panes.iter().any(|p| p.id == 1) && m.panes.iter().any(|p| p.id == 2));
let heard = heard.lock().unwrap();
assert_eq!(heard.len(), 1);
match &heard[0].1 {
LayoutDelta::TabRestructured { tab: t, pane: None } => {
assert_eq!(t.id, tab.id);
assert!(t.hibernated, "the delta carries the mark");
}
other => panic!("expected TabRestructured, heard {other:?}"),
}
}
#[test]
fn setting_the_mark_a_tab_already_has_raises_nothing() {
let (store, _dir, ws, tab) = store_with_tab();
let (_sub, heard) = recorded(&store);
store.tab_set_hibernated(ws, tab.id, false, None).unwrap();
assert!(heard.lock().unwrap().is_empty());
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
assert_eq!(heard.lock().unwrap().len(), 1);
store.tab_set_hibernated(ws, tab.id, false, None).unwrap();
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
assert_eq!(heard.lock().unwrap().len(), 2);
}
#[test]
fn hibernating_a_tab_that_is_not_there_is_refused() {
let (store, _dir, ws, _tab) = store_with_tab();
let err = store
.tab_set_hibernated(ws, TabId::new(), true, None)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
/// Asleep has to outlive the daemon: a restart reads the tree back from
/// disk, and a tab that came back awake would be respawned on the spot —
/// every process the user put to sleep, started again unasked.
#[test]
fn a_sleeping_tab_is_still_asleep_after_the_tree_is_read_back() {
let (store, dir, ws, tab) = store_with_tab();
let other = store
.tab_create(ws, None, seed(5, "/else"), None, None)
.unwrap();
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
drop(store);
let reopened = MachineStore::open(dir.path().join(MACHINE_FILE));
let m = reopened.machine();
let tabs = &m.workspaces[0].tabs;
assert!(tabs.iter().find(|t| t.id == tab.id).unwrap().hibernated);
assert!(!tabs.iter().find(|t| t.id == other.id).unwrap().hibernated);
let record = m.panes.iter().find(|p| p.id == 1).unwrap();
assert_eq!(record.cwd.as_deref(), Some("/work"));
assert!(!record.live);
}
/// Awake is the default and stays out of the document, so a tree this
/// build writes for a tab that never slept reads the same to one that
/// predates the mark, and a tree from before it decodes as awake.
#[test]
fn the_mark_is_only_written_while_it_is_set() {
let tab = Tab::leaf(4);
let json = serde_json::to_string(&tab).unwrap();
assert!(!json.contains("hibernated"), "{json}");
let back: Tab = serde_json::from_str(&json).unwrap();
assert!(!back.hibernated);
let asleep = Tab {
hibernated: true,
..Tab::leaf(4)
};
let back: Tab = serde_json::from_str(&serde_json::to_string(&asleep).unwrap()).unwrap();
assert!(back.hibernated);
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod kitty_graphics;
pub mod logfile;
pub mod machine;
pub mod osc;
pub mod paste;
pub mod path_spelling;
pub mod proc;
pub mod session;
+99
View File
@@ -0,0 +1,99 @@
//! What a paste puts on the wire.
//!
//! Lives here rather than beside the GUI's clipboard handling because the GUI
//! is not its only sender: `tty7 send --paste` has to frame a payload exactly
//! the way a paste into the window does, and a second copy of the framing is
//! how the two would come to disagree about what sending multi-line text means.
/// Opens a bracketed paste (DEC mode 2004).
pub const START: &[u8] = b"\x1b[200~";
/// Closes one.
pub const END: &[u8] = b"\x1b[201~";
/// `payload` inside paste brackets, with every ESC taken out.
///
/// The stripping is the point, not a cleanup: a payload that carries its own
/// `ESC[201~` would otherwise end the paste early, and whatever follows it
/// would reach the shell as typed input — which is how pasted text turns into
/// commands. No escape sequence survives the strip, so none can close the
/// bracket from inside.
pub fn bracket(payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(START.len() + payload.len() + END.len());
bytes.extend_from_slice(START);
bytes.extend(payload.iter().copied().filter(|&b| b != 0x1b));
bytes.extend_from_slice(END);
bytes
}
/// The bytes a paste of `text` sends, for a pane that has (`bracketed`) or has
/// not switched bracketed paste on.
///
/// CRLF folds to one line break either way, so a Windows clipboard pastes like
/// any other. Framed, the breaks stay `\n` and the shell inserts them as text.
/// Unframed there is no way to say "text" to a pty, so each break goes as the
/// `\r` a keyboard's Enter sends — the lines run one at a time, which is what
/// pasting into a terminal without the mode has always meant.
pub fn paste_bytes(text: &[u8], bracketed: bool) -> Vec<u8> {
let mut folded = Vec::with_capacity(text.len());
let mut i = 0;
while i < text.len() {
if text[i] == b'\r' && text.get(i + 1) == Some(&b'\n') {
folded.push(b'\n');
i += 2;
} else {
folded.push(text[i]);
i += 1;
}
}
if bracketed {
bracket(&folded)
} else {
for b in &mut folded {
if *b == b'\n' {
*b = b'\r';
}
}
folded
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_payload_cannot_close_its_own_bracket() {
let out = bracket(b"a\x1b[201~; rm -rf /\nb");
let inner = &out[START.len()..out.len() - END.len()];
assert!(out.starts_with(START) && out.ends_with(END));
assert!(!inner.contains(&0x1b), "{inner:?}");
assert_eq!(inner, b"a[201~; rm -rf /\nb");
}
#[test]
fn crlf_folds_before_either_framing() {
assert_eq!(paste_bytes(b"a\r\nb\n", true), b"\x1b[200~a\nb\n\x1b[201~");
assert_eq!(paste_bytes(b"a\r\nb\nc", false), b"a\rb\rc");
}
#[test]
fn a_lone_cr_is_left_alone() {
assert_eq!(paste_bytes(b"a\rb", true), b"\x1b[200~a\rb\x1b[201~");
assert_eq!(paste_bytes(b"a\rb", false), b"a\rb");
}
#[test]
fn unframed_paste_keeps_escapes() {
// Only the frame needs protecting; without one there is nothing to
// close, and the GUI has always sent these bytes through untouched.
assert_eq!(paste_bytes(b"a\x1b[201~b", false), b"a\x1b[201~b");
}
#[test]
fn bytes_that_are_not_utf8_survive() {
assert_eq!(
paste_bytes(b"\xff\xfe", true),
b"\x1b[200~\xff\xfe\x1b[201~"
);
}
}
+52
View File
@@ -59,6 +59,16 @@ pub struct SessionTab {
pub last_auto: Option<crate::core::group_key::AutoKey>,
#[serde(skip)]
pub tree_id: Option<crate::core::machine::TabId>,
/// Asleep in the tree: comes back as a placeholder holding `pane`, and
/// nothing is spawned for it until it is woken. See
/// [`Tab::hibernated`](crate::core::machine::Tab::hibernated).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hibernated: bool,
/// What a sleeping tab is called while nothing in it runs to say — read
/// off the tree's pane records, the same way the switcher names a tab of
/// a window it does not own.
#[serde(skip)]
pub asleep_view: Option<crate::core::tab_view::TabView>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -379,6 +389,11 @@ pub struct WindowView {
/// is another client's activity, not ours); opening one clears the mark.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub synced: bool,
/// Slugs of the coding agents seen running in this workspace, most recent
/// last. A remote workspace cannot be asked what is on its `PATH`, so
/// this is what its quick-launch list is made of.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub seen_agents: Vec<String>,
}
impl Default for WindowView {
@@ -392,6 +407,7 @@ impl Default for WindowView {
label: None,
subject: None,
synced: false,
seen_agents: Vec::new(),
}
}
}
@@ -412,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(),
@@ -1167,4 +1194,29 @@ mod tests {
views.views.retain(|w| w.id != b_id);
assert_eq!(numbers(&views), vec![a_id, c_id]);
}
/// A remote workspace's quick-launch list is the agents seen running in
/// it, so the list has to be in `views.json` with the rest of the
/// workspace — an in-memory one would be empty after every restart.
#[test]
fn agents_seen_in_a_workspace_survive_a_restart() {
let _file = lock_session_file();
pin_config_dir();
let mut entry = remote_view("build-box");
assert!(entry.saw_agent("claude"));
assert!(entry.saw_agent("codex"));
assert!(!entry.saw_agent("codex"), "already the most recent");
assert!(entry.saw_agent("claude"), "moves to the end");
let id = entry.id;
WindowViews {
active: Some(id),
views: vec![entry, view()],
}
.save();
let loaded = WindowViews::load().expect("a saved views file should load back");
assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]);
// A workspace that has seen nothing writes nothing for it.
let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap();
assert_eq!(text.matches("seen_agents").count(), 1);
}
}
+31
View File
@@ -153,6 +153,16 @@ pub struct ForwardRule {
pub bind: HostPort,
pub target: HostPort,
pub description: String,
/// Whether the rule is opened with the connection. A switched-off rule
/// keeps its whole configuration and simply binds nothing, so one local
/// port can be pointed at different targets by switching rules rather
/// than retyping them.
///
/// Missing from every profile saved before the switch existed, and those
/// rules were all live: the container's `#[serde(default)]` fills it from
/// `Default`, which says `true`, or upgrading would silently turn every
/// saved forward off.
pub enabled: bool,
}
impl Default for ForwardRule {
@@ -162,6 +172,7 @@ impl Default for ForwardRule {
bind: HostPort::default(),
target: HostPort::default(),
description: String::new(),
enabled: true,
}
}
}
@@ -569,6 +580,7 @@ mod tests {
bind: HostPort::new("127.0.0.1", 8080),
target: HostPort::new("10.0.0.1", 80),
description: "web".to_string(),
enabled: false,
}];
original.socks_proxy = Some(HostPort::new("proxy", 1080));
original.algorithms.kex = vec!["curve25519-sha256".to_string()];
@@ -597,6 +609,25 @@ mod tests {
let m: AuthMode = serde_json::from_str("\"agent\"").unwrap();
assert_eq!(m, AuthMode::Agent);
}
#[test]
fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() {
let old = r#"{"name":"box","host":"h","forwards":[
{"kind":"local","bind":{"host":"127.0.0.1","port":8080},
"target":{"host":"srv","port":80},"description":"web"}
]}"#;
let p: SshProfile = serde_json::from_str(old).unwrap();
assert_eq!(p.forwards.len(), 1);
assert!(p.forwards[0].enabled, "an old rule must stay live");
assert!(ForwardRule::default().enabled);
let mut off = p.forwards[0].clone();
off.enabled = false;
let back: ForwardRule =
serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap();
assert!(!back.enabled, "a switched-off rule stays off across a save");
assert_eq!(back, off);
}
}
fn new_id() -> Uuid {
+20
View File
@@ -41,6 +41,9 @@ const TRACKED: &[u16] = &[
1, 47, 1047, 1049, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004,
];
/// Bracketed paste: whether a paste may arrive framed as `ESC[200~ … ESC[201~`.
pub const BRACKETED_PASTE: u16 = 2004;
/// A CSI longer than this is not a mode set; keep the buffer bounded.
const MAX_PARAMS: usize = 64;
@@ -85,6 +88,11 @@ impl TerminalModes {
&self.on
}
/// Whether `mode` is on. Only ever true for a mode in [`TRACKED`].
pub fn is_on(&self, mode: u16) -> bool {
self.on.contains(&mode)
}
/// The bytes that put a freshly reset terminal back into these modes, or
/// `None` when there is nothing to restore.
pub fn restore_bytes(&self) -> Option<Vec<u8>> {
@@ -348,4 +356,16 @@ mod tests {
modes.feed(b"\x1bc");
assert!(modes.is_empty());
}
#[test]
fn bracketed_paste_reads_as_the_shell_last_left_it() {
// zle switches it on to read a line and off again before running it,
// so the answer has to follow both edges rather than latch the first.
let mut modes = TerminalModes::new();
assert!(!modes.is_on(BRACKETED_PASTE));
modes.feed(b"\x1b[?2004h");
assert!(modes.is_on(BRACKETED_PASTE));
modes.feed(b"\x1b[?2004l");
assert!(!modes.is_on(BRACKETED_PASTE));
}
}
+15
View File
@@ -146,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};
@@ -305,6 +309,16 @@ pub enum ControlRequest {
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,
pane: u64,
@@ -413,6 +427,7 @@ impl ControlRequest {
| TabMove { .. }
| TabSetGroup { .. }
| WorkspaceSetGroups { .. }
| TabSetHibernated { .. }
| PaneSplit { .. }
| PaneClose { .. }
| PaneSetRatio { .. }
+151 -5
View File
@@ -839,6 +839,10 @@ enum PaneBackend {
}
struct ForegroundProbes {
/// The pty's foreground process group: one ioctl, cheap enough to ask on
/// every read, and a change in it is what says the probes below would now
/// answer differently.
group: Box<dyn Fn() -> Option<i32> + Send>,
remote: Box<dyn Fn() -> Option<RemoteContext> + Send>,
agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>,
cwd: Box<dyn Fn() -> Option<PathBuf> + Send>,
@@ -1680,6 +1684,7 @@ impl DaemonPane {
);
let fg_master = master.clone();
let group_master = master.clone();
let remote_master = master.clone();
let agent_master = master.clone();
let cwd_master = master.clone();
@@ -1691,6 +1696,7 @@ impl DaemonPane {
writer.clone(),
move || foreground_command_running(&fg_master, shell_pid),
ForegroundProbes {
group: Box::new(move || pty_foreground_pgid(&group_master)),
remote: Box::new(move || foreground_remote_context(&remote_master)),
agent: Box::new(move || foreground_agent(&agent_master)),
cwd: Box::new(move || foreground_cwd(&cwd_master, shell_pid)),
@@ -1914,6 +1920,7 @@ impl DaemonPane {
writer.clone(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -1963,6 +1970,7 @@ impl DaemonPane {
death: Arc<DeathReporter>,
) -> JoinHandle<()> {
let ForegroundProbes {
group: foreground_group,
remote: foreground_remote,
agent: foreground_agent_fn,
cwd: foreground_cwd_fn,
@@ -1996,6 +2004,7 @@ impl DaemonPane {
let mut tr_read_t = std::time::Duration::ZERO;
let mut tr_disp_t = std::time::Duration::ZERO;
let mut next_remote_check = std::time::Instant::now();
let mut last_group = None;
loop {
if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) {
@@ -2073,8 +2082,21 @@ impl DaemonPane {
// reads, most visibly) stayed on the far end until
// some unrelated output arrived (#817).
let back_at_prompt = signals.shell.iter().any(|s| s.at_prompt);
let poll_now =
back_at_prompt || std::time::Instant::now() >= next_remote_check;
// A new foreground group is a new program in front
// — probe for it on its first output. The interval
// alone missed an agent that drew its whole first
// screen within it of the prompt it was typed at
// and then sat waiting for a key: a launch that
// types the command the moment the shell is up
// did that every time, and the pane never learned
// it was running an agent until something else
// printed.
let group = foreground_group();
let group_changed = group != last_group;
last_group = group;
let poll_now = back_at_prompt
|| group_changed
|| std::time::Instant::now() >= next_remote_check;
if poll_now {
next_remote_check =
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
@@ -2272,6 +2294,7 @@ impl DaemonPane {
local_pty: matches!(self.backend, PaneBackend::Pty(_)),
at_prompt: st.shell.active.then_some(st.shell.mark_at_prompt),
remote_prompt_seen: st.remote_prompt_seen,
bracketed_paste: Some(st.modes.is_on(crate::core::term_modes::BRACKETED_PASTE)),
}
}
@@ -3013,7 +3036,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
if shell_mark_capture_changed(&st.shell, &shell) {
apply_agent(
st,
agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()),
agent_from_shell_mark(&shell, &crate::core::config::agent_detection_aliases()),
);
}
st.shell = shell.clone();
@@ -3057,6 +3080,14 @@ fn apply_agent_signals(
let before = st.agent_session.clone();
for event in &events {
// A hook names the agent that emitted it. Once this pane has a
// foreground agent, a different one's session id must not land here:
// omp forking with a Claude UUID is "session not found".
if let (Some(current), Some(from)) = (st.agent, event.agent)
&& current != from
{
continue;
}
if st.agent.is_none() && event.agent.is_some() {
st.agent = event.agent;
notify(st, DaemonMsg::Agent(st.agent));
@@ -3183,7 +3214,9 @@ fn apply_agent(
stamp_launch_argv(st, argv);
return;
}
if agent.is_none() && st.agent_session.is_some() {
// The session belongs to whoever was in the foreground. Switching from
// Claude to omp (or back to the shell) must not keep the previous id.
if st.agent_session.is_some() {
st.agent_session = None;
notify(st, DaemonMsg::AgentStatus(None));
}
@@ -3335,7 +3368,7 @@ fn foreground_agent(
let argv = crate::daemon::remote::foreground_argv(pid)?;
let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with(
&argv,
crate::core::config::agent_commands_cached(),
&crate::core::config::agent_detection_aliases(),
)?;
Some((agent, argv))
};
@@ -5238,6 +5271,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| Some(None)),
cwd: Box::new(|| None),
@@ -5299,6 +5333,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| Some(None)),
cwd: Box::new(|| None),
@@ -5378,6 +5413,39 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
}
#[test]
fn a_foreign_agent_hook_does_not_replace_the_foreground_session() {
use crate::core::cli_agent::CLIAgent;
let mut st = test_state(true);
let mut sniffer = OscSniffer::new();
let omp = concat!(
"\x1b]777;notify;tty7://cli-agent;",
r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#,
"\x07",
);
apply_signals(&mut st, sniffer.feed(omp.as_bytes()));
assert_eq!(st.agent, Some(CLIAgent::OhMyPi));
let claude = concat!(
"\x1b]777;notify;tty7://cli-agent;",
r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#,
"\x07",
);
apply_signals(&mut st, sniffer.feed(claude.as_bytes()));
assert_eq!(
st.agent_session.as_ref().unwrap().session_id.as_deref(),
Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"),
"a Claude hook on an omp pane must not steal the session id"
);
apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()])));
assert!(
st.agent_session.is_none(),
"switching the foreground agent drops the previous session"
);
}
#[test]
fn opaque_notifications_only_fall_back_when_no_rich_state() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
@@ -6060,6 +6128,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote,
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6107,6 +6176,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6211,6 +6281,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6308,6 +6379,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6345,6 +6417,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6415,6 +6488,7 @@ mod tests {
writer,
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6508,6 +6582,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6589,6 +6664,7 @@ mod tests {
writer,
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6626,6 +6702,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6677,6 +6754,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| Some(PathBuf::from("/Users/alice/dev/tty7"))),
@@ -6708,6 +6786,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -6734,6 +6813,7 @@ mod tests {
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(|| None),
remote: Box::new(|| None),
agent: Box::new(|| None),
cwd: Box::new(|| None),
@@ -7297,4 +7377,70 @@ mod tests {
apply_signals(&mut st, SniffSignals::default());
assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x")));
}
/// A quick launch types the agent's command the moment the shell is up, so
/// the agent's whole first screen can land inside the poll interval of the
/// prompt it was typed at — and then it waits for a key, printing nothing
/// more. Probing only on the interval, the pane never found out an agent
/// was running. A change of foreground process group now probes at once.
#[test]
fn a_new_foreground_group_is_probed_inside_the_poll_interval() {
struct Chunks(std::collections::VecDeque<Vec<u8>>);
impl Read for Chunks {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self.0.pop_front() {
Some(chunk) => {
buf[..chunk.len()].copy_from_slice(&chunk);
Ok(chunk.len())
}
None => Ok(0),
}
}
}
let probes_for = |groups: Vec<i32>| {
let state = Arc::new(Mutex::new(test_state(true)));
let probes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = probes.clone();
let groups = Mutex::new(groups.into_iter());
let handle = DaemonPane::spawn_reader(
state,
Arc::new(AtomicBool::new(false)),
Arc::new(OutputGate::new()),
// Plain output, no prompt marks: only the interval or the
// group can trigger a probe.
Box::new(Chunks(
[b"$ ".to_vec(), b"agent screen".to_vec()]
.into_iter()
.collect(),
)),
null_writer(),
|| false,
ForegroundProbes {
group: Box::new(move || groups.lock().unwrap().next()),
remote: Box::new(|| None),
agent: Box::new(move || {
counted.fetch_add(1, Ordering::SeqCst);
None
}),
cwd: Box::new(|| None),
},
Arc::new(DeathReporter::new(|| {})),
);
handle.join().unwrap();
probes.load(Ordering::SeqCst)
};
assert_eq!(
probes_for(vec![10, 10]),
1,
"same program: the interval rules"
);
assert_eq!(
probes_for(vec![10, 11]),
2,
"a new program is probed at once"
);
}
}
+74 -17
View File
@@ -225,23 +225,15 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
#[cfg(target_os = "macos")]
fn process_table() -> HashMap<u32, Row> {
let mut table = HashMap::new();
let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if bytes <= 0 {
return table;
}
let cap = (bytes as usize / std::mem::size_of::<libc::c_int>()) + 64;
let mut pids = vec![0 as libc::c_int; cap];
let written = unsafe {
libc::proc_listallpids(
pids.as_mut_ptr() as *mut libc::c_void,
(cap * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if written <= 0 {
return table;
}
let n = written as usize / std::mem::size_of::<libc::c_int>();
for &pid in pids.iter().take(n.min(cap)) {
let pids = list_all_pids(|buf| {
let ptr = match buf.is_empty() {
true => std::ptr::null_mut(),
false => buf.as_mut_ptr() as *mut libc::c_void,
};
let bytes = std::mem::size_of_val(buf) as libc::c_int;
unsafe { libc::proc_listallpids(ptr, bytes) }
});
for &pid in &pids {
if pid <= 0 {
continue;
}
@@ -276,6 +268,37 @@ fn process_table() -> HashMap<u32, Row> {
table
}
/// Every pid on the machine, by way of `list`, which has the calling
/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as
/// much of it as it can, and it answers with a *count of pids* — not a byte
/// count, although the size it is given is in bytes. An empty buffer asks
/// only for the count, padded by the kernel for processes born in between.
///
/// Reading that count as bytes was #731. It divided the answer by four twice
/// over, once sizing the buffer and once reading back how much was filled, so
/// only about one pid in sixteen made it into the table — and since the kernel
/// lists its newest processes first, the ones kept were whatever had started
/// most recently. A `go run` server just launched was in; the pane's shell,
/// started long before, was not, and a walk from a root the table has never
/// heard of comes back empty: no processes, no ports, and nothing to say why.
/// A process tree built moments earlier, which is what a quick check of the
/// probe builds, is exactly the case that happened to work.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec<i32> {
let count = list(&mut []);
if count <= 0 {
return Vec::new();
}
let mut pids = vec![0; count as usize + 64];
let written = list(&mut pids);
if written <= 0 {
return Vec::new();
}
pids.truncate(written as usize);
pids.retain(|&pid| pid > 0);
pids
}
#[cfg(target_os = "macos")]
fn cstr_field(buf: &[libc::c_char]) -> String {
let bytes: Vec<u8> = buf
@@ -968,6 +991,40 @@ mod tests {
);
}
/// `proc_listallpids` as libproc implements it: the kernel's newest
/// processes first, a padded estimate when asked with no buffer, and
/// always a count of pids back, never bytes.
fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ {
move |buf: &mut [i32]| {
if buf.is_empty() {
return live.len() as i32 + 20;
}
let n = live.len().min(buf.len());
buf[..n].copy_from_slice(&live[..n]);
n as i32
}
}
/// #731 on macOS: a busy Mac has hundreds of processes, and the pane's
/// shell is an old one. Every pid has to reach the table, or the walk
/// starts from a root it cannot find and the pane shows no processes and
/// no ports at all.
#[test]
fn every_pid_is_listed_not_the_newest_sixteenth() {
// Newest first, the way the kernel lists them: the `go run` server
// just started, and the shell it runs under from an hour ago.
let live: Vec<i32> = (1..=700).rev().collect();
let got = list_all_pids(fake_listallpids(&live));
assert_eq!(got.len(), 700, "the whole machine, not the newest few");
assert!(got.contains(&1), "the oldest process is still there");
}
#[test]
fn a_pid_list_that_cannot_be_read_is_empty() {
assert!(list_all_pids(|_| -1).is_empty());
assert!(list_all_pids(|_| 0).is_empty());
}
#[test]
fn a_pathological_tree_still_stops() {
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh"))].into_iter().collect();
+100
View File
@@ -181,6 +181,10 @@ pub enum WorkspaceOp {
RemoveForward {
forward_id: u64,
},
SetForwardEnabled {
forward_id: u64,
enabled: bool,
},
ListForwards,
TeardownForwards,
SftpList {
@@ -261,6 +265,13 @@ pub struct SshForwardRule {
pub target_port: u16,
#[serde(default)]
pub description: Option<String>,
/// A switched-off rule is registered but binds nothing. Defaults to on,
/// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never
/// sends it and every rule it knows is live, while one that receives it
/// ignores it — the worst case is an older far end opening a rule the
/// user switched off, which is what it always did.
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -420,6 +431,13 @@ pub struct ManagedForward {
#[serde(default)]
pub description: Option<String>,
pub status: ForwardStatus,
/// Whether the forward is switched on. A field rather than a
/// `ForwardStatus` variant for the reason `loop_exit_status` gives: an
/// older peer fails the whole frame on a variant it has never heard of,
/// but skips a field it does not know. A switched-off forward's status is
/// an `Error` saying so, which is what such a peer then draws.
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -551,6 +569,15 @@ pub struct PaneContext {
/// prompt" on a remote pane means nothing: the newest mark is then the
/// near shell's own "I started `ssh`", and it will never be replaced.
pub remote_prompt_seen: bool,
/// Whether the pane's output has switched bracketed paste (mode 2004) on,
/// from the same fold the replay restores modes from. `None` from a daemon
/// built before the field existed — "cannot say", not "off".
///
/// It is here so a client with no terminal of its own — `tty7 send
/// --paste` — can frame a paste the way the GUI does, by asking the pane
/// rather than assuming.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bracketed_paste: Option<bool>,
}
fn default_term() -> String {
@@ -892,6 +919,11 @@ pub enum ClientMsg {
pane_id: u64,
forward_id: u64,
},
SetForwardEnabled {
pane_id: u64,
forward_id: u64,
enabled: bool,
},
ListForwards {
pane_id: u64,
},
@@ -987,6 +1019,7 @@ mod kind {
pub const ADD_FORWARD: u8 = 20;
pub const REMOVE_FORWARD: u8 = 21;
pub const LIST_FORWARDS: u8 = 22;
pub const SET_FORWARD_ENABLED: u8 = 23;
pub const VERSION: u8 = 40;
pub const QUERY_PROCS: u8 = 50;
pub const ON_WORKSPACE: u8 = 52;
@@ -1260,6 +1293,15 @@ impl ClientMsg {
pane_id,
forward_id,
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
} => write_frame(
w,
kind::SET_FORWARD_ENABLED,
&to_json(&(pane_id, forward_id, enabled))?,
),
ClientMsg::QueryProcs { pane_id } => {
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
}
@@ -1390,6 +1432,14 @@ impl ClientMsg {
forward_id,
}
}
kind::SET_FORWARD_ENABLED => {
let (pane_id, forward_id, enabled) = from_json(&payload)?;
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
}
}
kind::LIST_FORWARDS => ClientMsg::ListForwards {
pane_id: from_json(&payload)?,
},
@@ -1758,6 +1808,7 @@ mod tests {
target_host: "10.0.0.5".into(),
target_port: 80,
description: Some("web".into()),
enabled: true,
},
},
ClientMsg::AddForward {
@@ -1769,12 +1820,18 @@ mod tests {
target_host: String::new(),
target_port: 0,
description: None,
enabled: false,
},
},
ClientMsg::RemoveForward {
pane_id: 7,
forward_id: 3,
},
ClientMsg::SetForwardEnabled {
pane_id: 7,
forward_id: 3,
enabled: false,
},
ClientMsg::ListForwards { pane_id: 7 },
ClientMsg::Version,
];
@@ -1934,6 +1991,7 @@ mod tests {
target_port: 80,
description: Some("web".into()),
status: ForwardStatus::Listening,
enabled: true,
},
ManagedForward {
id: 2,
@@ -1945,6 +2003,7 @@ mod tests {
target_port: 3000,
description: None,
status: ForwardStatus::Error("bind refused".into()),
enabled: false,
},
]),
DaemonMsg::Version(DaemonVersion {
@@ -2385,6 +2444,7 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 80,
description: Some("web".into()),
enabled: true,
}],
keepalive_interval_s: Some(30),
keepalive_count_max: Some(3),
@@ -2471,9 +2531,14 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 5432,
description: Some("db".into()),
enabled: true,
},
},
WorkspaceOp::RemoveForward { forward_id: 4 },
WorkspaceOp::SetForwardEnabled {
forward_id: 4,
enabled: true,
},
WorkspaceOp::ListForwards,
WorkspaceOp::TeardownForwards,
WorkspaceOp::SftpList {
@@ -2702,4 +2767,39 @@ mod tests {
"the session daemon must not advertise a dialect it cannot serve"
);
}
/// `enabled` crosses the same peers as `rejected` above and gets the same
/// treatment: a rule or a forward from a peer that predates the switch is
/// a live one, and a peer that predates it reads a frame carrying it.
#[test]
fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() {
let rule: SshForwardRule = serde_json::from_str(
r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080,
"target_host":"srv","target_port":80}"#,
)
.unwrap();
assert!(rule.enabled);
let managed: ManagedForward = serde_json::from_str(
r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1",
"bind_port":8080,"status":"listening"}"#,
)
.unwrap();
assert!(managed.enabled);
#[derive(Deserialize)]
struct LegacyManagedForward {
id: u64,
status: ForwardStatus,
}
let off = serde_json::to_string(&ManagedForward {
enabled: false,
status: ForwardStatus::Error("disabled".into()),
..managed
})
.unwrap();
let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap();
assert_eq!(legacy.id, 1);
assert_eq!(legacy.status, ForwardStatus::Error("disabled".into()));
}
}
+45
View File
@@ -105,6 +105,10 @@ impl crate::host::server::PaneDirectory for Registry {
self.get(pane_id).map(|p| p.procs()).unwrap_or_default()
}
fn hibernate_pane(&self, pane_id: u64) {
hibernate_pane(self, pane_id);
}
fn agent_states(&self) -> Vec<crate::daemon::control::PaneAgentState> {
let panes: Vec<Arc<DaemonPane>> = self.panes.lock().unwrap().values().cloned().collect();
let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect();
@@ -276,6 +280,22 @@ fn kill_pane(registry: &Registry, pane_id: u64) {
crate::daemon::scrollback::forget(pane_id);
}
/// Stop a pane whose tab is going to sleep, keeping its screen.
///
/// [`kill_pane`] with the other half of the bargain: the screen is written one
/// last time, exactly, and left on disk rather than dropped, so the pane that
/// wakes in this one's place opens showing it — the same restore a daemon that
/// died unannounced gets, asked for the same way. The tree still names this
/// pane, which is what keeps the sweeps off the file until then.
fn hibernate_pane(registry: &Registry, pane_id: u64) {
let Some(pane) = registry.remove(pane_id) else {
return;
};
let (segments, title, _) = pane.scrollback_snapshot();
crate::daemon::scrollback::save(pane_id, &segments, title.as_deref());
pane.kill();
}
fn ssh_connection_for(
registry: &Registry,
pane_id: u64,
@@ -1035,6 +1055,31 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
Ok(())
}
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
} => {
let mut w = write_stream;
// Only switching on needs the connection; switching off has to
// work on a pane whose connection is already gone.
let conn = match forward_pane_connection(&registry, pane_id) {
Ok(conn) => Some(conn),
Err(e) if enabled => {
DaemonMsg::Error(e).encode(&mut w)?;
return Ok(());
}
Err(_) => None,
};
match crate::daemon::ssh::SshManager::global()
.set_forward_enabled(pane_id, conn, forward_id, enabled)
{
Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?,
Err(e) => DaemonMsg::Error(e).encode(&mut w)?,
}
Ok(())
}
ClientMsg::QueryProcs { pane_id } => {
let mut w = write_stream;
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
+394 -8
View File
@@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus {
))))
}
/// What a switched-off forward reports. An `Error` for the same reason as
/// `loop_exit_status`, so an older peer reading it draws a stopped forward
/// with a word of explanation; a current one reads `enabled` instead.
fn disabled_status() -> SharedStatus {
Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string())))
}
/// Whether two forwards would want the same listening socket: the same port,
/// on the same side of the connection. Local and dynamic forwards both listen
/// here; a remote forward listens on the far host.
fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool {
let far = |k| k == SshForwardKind::Remote;
a_port != 0 && a_port == b_port && far(a) == far(b)
}
struct ForwardEntry {
id: u64,
kind: SshForwardKind,
bind_host: String,
bind_port: u16,
/// The port the rule asked for, which `bind_port` is not when that was 0
/// and the OS picked one. Switching the forward back on asks again for
/// this, not for whatever it happened to get last time.
requested_port: u16,
target_host: String,
target_port: u16,
description: Option<String>,
status: SharedStatus,
cancel: ForwardCancel,
auto_local: bool,
/// Switched off: the entry, and the rule in it, stay listed, but nothing
/// is bound and `cancel` is `None`.
enabled: bool,
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
@@ -288,6 +310,19 @@ impl ForwardEntry {
target_port: self.target_port,
description: self.description.clone(),
status: self.status.lock().unwrap().clone(),
enabled: self.enabled,
}
}
fn rule(&self) -> SshForwardRule {
SshForwardRule {
kind: self.kind,
bind_host: self.bind_host.clone(),
bind_port: self.requested_port,
target_host: self.target_host.clone(),
target_port: self.target_port,
description: self.description.clone(),
enabled: self.enabled,
}
}
}
@@ -318,6 +353,23 @@ impl SshForwardRegistry {
.await
}
pub async fn set_enabled(
&self,
pane_id: u64,
conn: Option<Arc<SshConnection>>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.set_enabled_owned(
&ForwardOwner::Pane(pane_id),
pane_id,
conn,
forward_id,
enabled,
)
.await
}
pub async fn teardown_pane(&self, pane_id: u64) {
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
}
@@ -347,6 +399,24 @@ impl SshForwardRegistry {
.await
}
pub async fn set_enabled_workspace(
&self,
workspace: WorkspaceId,
view_pane: u64,
conn: Arc<SshConnection>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.set_enabled_owned(
&ForwardOwner::Workspace(workspace),
view_pane,
Some(conn),
forward_id,
enabled,
)
.await
}
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
self.teardown_owned(&ForwardOwner::Workspace(workspace))
.await;
@@ -360,22 +430,24 @@ impl SshForwardRegistry {
rule: &SshForwardRule,
) -> ManagedForward {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (bind_port, status, cancel) = match rule.kind {
SshForwardKind::Local => self.start_local(&conn, rule).await,
SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await,
SshForwardKind::Remote => self.start_remote(&conn, rule).await,
let (bind_port, status, cancel) = if rule.enabled {
self.start(&conn, rule).await
} else {
(rule.bind_port, disabled_status(), ForwardCancel::None)
};
let entry = ForwardEntry {
id,
kind: rule.kind,
bind_host: rule.bind_host.clone(),
bind_port,
requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: false,
enabled: rule.enabled,
};
let managed = entry.to_managed(view_pane);
self.owners
@@ -413,20 +485,145 @@ impl SshForwardRegistry {
})
};
if let Some(entry) = removed {
Self::cancel_entry(entry).await;
Self::cancel(entry.cancel).await;
}
self.list_owned(owner, view_pane)
}
/// Switch one forward off or back on, keeping its entry — and so its
/// place in the list and the rule it was made from — either way.
///
/// Switching on refuses, rather than fails at bind time with whatever the
/// OS says, when another switched-on forward of the same owner already
/// holds the port: the answer then names that forward, which is the one
/// the user has to switch off first. A forward that cannot be started for
/// any other reason stays off, with the reason as the error.
async fn set_enabled_owned(
&self,
owner: &ForwardOwner,
view_pane: u64,
conn: Option<Arc<SshConnection>>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
if !enabled {
let cancel = {
let mut owners = self.owners.lock().unwrap();
let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
entry.enabled = false;
entry.status = disabled_status();
std::mem::replace(&mut entry.cancel, ForwardCancel::None)
};
Self::cancel(cancel).await;
return Ok(self.list_owned(owner, view_pane));
}
let Some(rule) = self.enable_plan(owner, forward_id)? else {
return Ok(self.list_owned(owner, view_pane));
};
let Some(conn) = conn else {
return Err("the SSH connection is gone — reconnect and try again".to_string());
};
let (bound, status, cancel) = self.start(&conn, &rule).await;
if let Some(e) = bind_error(&status) {
Self::cancel(cancel).await;
return Err(e);
}
if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) {
// Removed while it was being started: nobody wants what was bound.
Self::cancel(cancel).await;
}
Ok(self.list_owned(owner, view_pane))
}
fn entry_mut<'a>(
owners: &'a mut HashMap<ForwardOwner, Vec<ForwardEntry>>,
owner: &ForwardOwner,
forward_id: u64,
) -> Result<&'a mut ForwardEntry, String> {
owners
.get_mut(owner)
.and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id))
.ok_or_else(|| format!("no forward {forward_id}"))
}
/// The rule to start for switching `forward_id` on, `None` when it is on
/// already, or why it may not be switched on.
fn enable_plan(
&self,
owner: &ForwardOwner,
forward_id: u64,
) -> Result<Option<SshForwardRule>, String> {
let mut owners = self.owners.lock().unwrap();
let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
if entry.enabled {
return Ok(None);
}
let rule = SshForwardRule {
enabled: true,
..entry.rule()
};
let holder = owners.get(owner).into_iter().flatten().find(|e| {
e.id != forward_id
&& e.enabled
&& same_socket(e.kind, e.bind_port, rule.kind, rule.bind_port)
});
if let Some(h) = holder {
let what = match h.kind {
SshForwardKind::Dynamic => "a SOCKS forward".to_string(),
_ => format!("the forward to {}:{}", h.target_host, h.target_port),
};
return Err(format!(
"port {} is already used by {what}; switch that one off first",
rule.bind_port
));
}
Ok(Some(rule))
}
/// Record a started forward as switched on. Hands `cancel` back when the
/// entry is no longer there to take it.
fn finish_enable(
&self,
owner: &ForwardOwner,
forward_id: u64,
bound: u16,
status: SharedStatus,
cancel: ForwardCancel,
) -> Option<ForwardCancel> {
let mut owners = self.owners.lock().unwrap();
let Ok(entry) = Self::entry_mut(&mut owners, owner, forward_id) else {
return Some(cancel);
};
entry.enabled = true;
entry.bind_port = bound;
entry.status = status;
// Anything already in the slot is a second switch-on that raced this
// one; it is replaced, and has to be stopped rather than leaked.
Some(std::mem::replace(&mut entry.cancel, cancel))
}
async fn start(
&self,
conn: &Arc<SshConnection>,
rule: &SshForwardRule,
) -> (u16, SharedStatus, ForwardCancel) {
match rule.kind {
SshForwardKind::Local => self.start_local(conn, rule).await,
SshForwardKind::Dynamic => self.start_dynamic(conn, rule).await,
SshForwardKind::Remote => self.start_remote(conn, rule).await,
}
}
async fn teardown_owned(&self, owner: &ForwardOwner) {
let entries = self.owners.lock().unwrap().remove(owner);
for entry in entries.into_iter().flatten() {
Self::cancel_entry(entry).await;
Self::cancel(entry.cancel).await;
}
}
async fn cancel_entry(entry: ForwardEntry) {
match entry.cancel {
async fn cancel(cancel: ForwardCancel) {
match cancel {
ForwardCancel::Task(handle) => {
handle.abort();
let _ = handle.await;
@@ -634,6 +831,7 @@ impl SshForwardRegistry {
target_host: remote_host.to_string(),
target_port: remote_port,
description: Some(format!("localhost link → :{remote_port}")),
enabled: true,
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await;
@@ -658,12 +856,14 @@ impl SshForwardRegistry {
kind: SshForwardKind::Local,
bind_host: rule.bind_host.clone(),
bind_port,
requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: true,
enabled: true,
};
self.owners
.lock()
@@ -735,6 +935,20 @@ impl SshManager {
)
}
pub fn set_workspace_forward_enabled(
&self,
workspace: WorkspaceId,
view_pane: u64,
conn: Arc<SshConnection>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.runtime.block_on(
self.forwards
.set_enabled_workspace(workspace, view_pane, conn, forward_id, enabled),
)
}
pub fn list_workspace_forwards(
&self,
workspace: WorkspaceId,
@@ -941,12 +1155,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "h".into(),
target_port: 80,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(task),
auto_local: false,
enabled: true,
}
};
{
@@ -986,12 +1202,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "h".into(),
target_port: 80,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(handle),
auto_local: false,
enabled: true,
}
}
@@ -1047,12 +1265,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "127.0.0.1".into(),
target_port: 3000,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(handle),
auto_local: true,
enabled: true,
};
reg.owners
.lock()
@@ -1143,4 +1363,170 @@ mod tests {
);
assert_eq!(table.lookup("", 0), None);
}
fn push_disabled(
reg: &SshForwardRegistry,
owner: ForwardOwner,
id: u64,
kind: SshForwardKind,
port: u16,
) {
reg.owners
.lock()
.unwrap()
.entry(owner)
.or_default()
.push(ForwardEntry {
id,
kind,
bind_host: "127.0.0.1".into(),
bind_port: port,
requested_port: port,
target_host: "srv-b".into(),
target_port: 80,
description: None,
status: disabled_status(),
cancel: ForwardCancel::None,
auto_local: false,
enabled: false,
});
}
/// Switching a forward off has to release what it bound — that is the
/// point, so the port can be taken by another rule — while leaving the
/// entry, and the rule in it, exactly where it was.
#[tokio::test]
async fn switching_a_forward_off_frees_its_socket_and_keeps_its_rule() {
let reg = SshForwardRegistry::default();
let guard = push_listener(&reg, ForwardOwner::Pane(7), 3).await;
let port = reg.list(7)[0].bind_port;
let list = reg.set_enabled(7, None, 3, false).await.unwrap();
assert_eq!(list.len(), 1, "the forward is still listed");
assert_eq!(list[0].id, 3);
assert!(!list[0].enabled);
assert_eq!(list[0].bind_port, port);
assert_eq!(
(list[0].target_host.as_str(), list[0].target_port),
("127.0.0.1", 3000)
);
assert!(matches!(list[0].status, ForwardStatus::Error(_)));
assert_eq!(Arc::strong_count(&guard), 1, "…and its listener is gone");
TcpListener::bind(("127.0.0.1", port))
.await
.expect("the port is free for someone else");
// Twice is the same as once, and a forward nobody has is an error.
assert_eq!(reg.set_enabled(7, None, 3, false).await.unwrap(), list);
assert!(reg.set_enabled(7, None, 99, false).await.is_err());
reg.teardown_pane(7).await;
assert!(reg.list(7).is_empty());
}
/// Two rules on one local port, pointed at different targets, is the
/// whole use of the switch. Turning the second on while the first still
/// holds the port is refused by name, not left to fail at bind time.
#[tokio::test]
async fn switching_on_a_forward_whose_port_is_held_is_refused_by_name() {
let reg = SshForwardRegistry::default();
push_listener(&reg, ForwardOwner::Pane(7), 0).await;
let port = reg.list(7)[0].bind_port;
push_disabled(&reg, ForwardOwner::Pane(7), 1, SshForwardKind::Local, port);
let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
assert!(err.contains(&port.to_string()), "{err}");
assert!(err.contains("127.0.0.1:3000"), "names the holder: {err}");
assert!(!reg.list(7)[1].enabled, "the refused forward stays off");
// A SOCKS forward listens here too, so it collides the same way.
push_disabled(
&reg,
ForwardOwner::Pane(7),
2,
SshForwardKind::Dynamic,
port,
);
assert!(reg.set_enabled(7, None, 2, true).await.is_err());
// Once the holder is off, the only thing left in the way is the
// missing connection — which is said, and changes nothing.
reg.set_enabled(7, None, 0, false).await.unwrap();
let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
assert!(err.contains("connection"), "{err}");
assert!(reg.list(7).iter().all(|m| !m.enabled));
// Another owner's forwards are not this one's business.
push_disabled(&reg, ForwardOwner::Pane(8), 0, SshForwardKind::Local, port);
assert!(
reg.enable_plan(&ForwardOwner::Pane(8), 0)
.unwrap()
.is_some()
);
}
#[test]
fn only_the_same_port_on_the_same_side_collides() {
use SshForwardKind::{Dynamic, Local, Remote};
assert!(same_socket(Local, 8080, Local, 8080));
assert!(same_socket(Local, 8080, Dynamic, 8080));
assert!(same_socket(Remote, 8080, Remote, 8080));
assert!(!same_socket(Local, 8080, Remote, 8080), "different hosts");
assert!(!same_socket(Local, 8080, Local, 8081));
assert!(
!same_socket(Local, 0, Local, 0),
"0 asks the OS for a fresh one"
);
}
/// Switching back on asks for the port the rule asked for, not the one
/// the OS last picked for it, and records what it got this time.
#[tokio::test]
async fn switching_on_starts_from_the_rule_and_records_the_new_listener() {
let reg = SshForwardRegistry::default();
push_disabled(&reg, ForwardOwner::Pane(7), 0, SshForwardKind::Local, 0);
reg.owners
.lock()
.unwrap()
.get_mut(&ForwardOwner::Pane(7))
.unwrap()[0]
.bind_port = 41000;
let rule = reg
.enable_plan(&ForwardOwner::Pane(7), 0)
.unwrap()
.expect("an off forward has something to start");
assert_eq!(rule.bind_port, 0);
assert!(rule.enabled);
assert_eq!((rule.target_host.as_str(), rule.target_port), ("srv-b", 80));
let guard = Arc::new(());
let held = guard.clone();
let task = tokio::spawn(async move {
let _held = held;
std::future::pending::<()>().await
});
let status: SharedStatus = Arc::new(Mutex::new(ForwardStatus::Listening));
let leftover = reg.finish_enable(
&ForwardOwner::Pane(7),
0,
42000,
status,
ForwardCancel::Task(task),
);
assert!(matches!(leftover, Some(ForwardCancel::None)));
let now = &reg.list(7)[0];
assert!(now.enabled);
assert_eq!(now.bind_port, 42000);
assert_eq!(now.status, ForwardStatus::Listening);
assert!(
reg.enable_plan(&ForwardOwner::Pane(7), 0)
.unwrap()
.is_none(),
"an on forward has nothing to start"
);
reg.teardown_pane(7).await;
assert_eq!(Arc::strong_count(&guard), 1, "the new listener is owned");
}
}
+13
View File
@@ -115,6 +115,19 @@ impl SshManager {
.block_on(self.forwards.remove(pane_id, forward_id))
}
pub fn set_forward_enabled(
&self,
pane_id: u64,
conn: Option<Arc<SshConnection>>,
forward_id: u64,
enabled: bool,
) -> Result<Vec<ManagedForward>, String> {
self.runtime.block_on(
self.forwards
.set_enabled(pane_id, conn, forward_id, enabled),
)
}
pub fn list_forwards(&self, pane_id: u64) -> Vec<ManagedForward> {
self.forwards.list(pane_id)
}
@@ -33,6 +33,13 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
WorkspaceOp::RemoveForward { forward_id } => {
DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id))
}
WorkspaceOp::SetForwardEnabled {
forward_id,
enabled,
} => match mgr.set_workspace_forward_enabled(ws, view, conn, *forward_id, *enabled) {
Ok(list) => DaemonMsg::ForwardList(list),
Err(e) => DaemonMsg::Error(e),
},
WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)),
WorkspaceOp::TeardownForwards => {
mgr.teardown_workspace_forwards(ws);
+185
View File
@@ -34,6 +34,10 @@ pub trait PaneDirectory: Send + Sync {
/// is this process and not the client's own daemon: the client asks over
/// the control link precisely because the processes are here.
fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs;
/// Stop a pane for a tab going to sleep: the processes go, the pane's last
/// screen stays on disk for the wake to open with. Unlike closing a pane,
/// which drops that screen because nobody will ask for it again.
fn hibernate_pane(&self, pane_id: u64);
}
#[derive(Clone, Default)]
@@ -383,6 +387,12 @@ fn handshake<R: Read>(
if services.machine.is_some() {
features.push(feature::MACHINE_TREE.to_string());
}
// A sleeping tab is a mark in the tree *and* stopped panes; a peer that can
// do only half of that would leave a tab asleep with its shells running,
// or its shells stopped with nothing saying the tab is meant to wake.
if services.machine.is_some() && services.panes.is_some() {
features.push(feature::TAB_HIBERNATE.to_string());
}
// The pane daemon's features ride along, same as `protocol_version` above:
// a pane connection answers exactly one message, so a client that wanted
// them from `ClientMsg::Version` would pay a whole routed connection — for
@@ -783,6 +793,34 @@ fn run_request(
.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,
@@ -1722,6 +1760,8 @@ mod aggregate_tests {
}
}
fn hibernate_pane(&self, _pane_id: u64) {}
fn agent_states(&self) -> Vec<PaneAgentState> {
vec![PaneAgentState {
pane_id: 7,
@@ -1902,6 +1942,151 @@ mod aggregate_tests {
"whatever links exist are named; none are blank"
);
}
/// Records which panes were stopped for a sleeping tab, and in what order
/// relative to the tree: each entry carries whether the tree already
/// called the tab asleep when the pane was stopped.
struct SleepRecorder {
store: Arc<MachineStore>,
stopped: std::sync::Mutex<Vec<(u64, bool)>>,
}
impl PaneDirectory for SleepRecorder {
fn pane_count(&self) -> u64 {
0
}
fn panes(&self) -> Vec<PaneInfo> {
Vec::new()
}
fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs {
Default::default()
}
fn agent_states(&self) -> Vec<PaneAgentState> {
Vec::new()
}
fn hibernate_pane(&self, pane_id: u64) {
let asleep = self.store.machine().workspaces.iter().any(|w| {
w.tabs
.iter()
.any(|t| t.hibernated && t.root.contains(pane_id))
});
self.stopped.lock().unwrap().push((pane_id, asleep));
}
}
fn seed(pane: u64) -> machine::PaneSeed {
machine::PaneSeed {
pane,
cwd: Some("/repo".into()),
ssh_spec: None,
agent: None,
shell: None,
}
}
/// Going to sleep stops every pane of the tab — after the tree says the
/// tab is asleep, so nobody reading the tree in between takes the stopped
/// panes for dead ones — and keeps them in the tree for the wake. Waking
/// only clears the mark: the client brings the panes back.
#[test]
fn a_tab_put_to_sleep_stops_its_panes_and_keeps_them_in_the_tree() {
let dir = tempfile::TempDir::new().unwrap();
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
let ws = store.workspace_create(None, None, None).unwrap();
let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap();
store
.pane_split(
ws.id,
7,
machine::Axis::Horizontal,
0.5,
seed(8),
false,
None,
)
.unwrap();
let other = store.tab_create(ws.id, None, seed(9), None, None).unwrap();
let recorder = Arc::new(SleepRecorder {
store: Arc::clone(&store),
stopped: std::sync::Mutex::new(Vec::new()),
});
let client = client_with(Services {
machine: Some(Arc::clone(&store)),
attachments: Arc::new(AttachRegistry::default()),
panes: Some(recorder.clone()),
});
assert!(client.hello().has_feature(feature::TAB_HIBERNATE));
let ReplyOk::Panes(panes) = client
.call(ControlRequest::TabSetHibernated {
workspace: ws.id,
tab: tab.id,
hibernated: true,
})
.unwrap()
else {
panic!("TabSetHibernated must answer with the tab's panes");
};
assert_eq!(panes, vec![7, 8]);
assert_eq!(
*recorder.stopped.lock().unwrap(),
vec![(7, true), (8, true)],
"both panes stopped, each after the tree marked the tab"
);
let machine = store.machine();
let asleep = &machine.workspaces[0].tabs;
assert!(asleep.iter().find(|t| t.id == tab.id).unwrap().hibernated);
assert!(!asleep.iter().find(|t| t.id == other.id).unwrap().hibernated);
for pane in [7, 8] {
assert!(
machine.panes.iter().any(|p| p.id == pane),
"pane {pane}'s record is what the wake restores from"
);
}
recorder.stopped.lock().unwrap().clear();
let ReplyOk::Panes(_) = client
.call(ControlRequest::TabSetHibernated {
workspace: ws.id,
tab: tab.id,
hibernated: false,
})
.unwrap()
else {
panic!("TabSetHibernated must answer with the tab's panes");
};
assert!(recorder.stopped.lock().unwrap().is_empty());
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
}
/// A peer with a tree and no panes could mark a tab asleep and leave its
/// shells running. It does not offer to, and refuses if asked anyway —
/// before the mark, so the tree is not left claiming a sleep that never
/// happened.
#[test]
fn a_peer_serving_no_panes_cannot_put_a_tab_to_sleep() {
let dir = tempfile::TempDir::new().unwrap();
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
let ws = store.workspace_create(None, None, None).unwrap();
let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap();
let client = client_with(Services::with_machine(Arc::clone(&store)));
assert!(client.hello().has_feature(feature::MACHINE_TREE));
assert!(!client.hello().has_feature(feature::TAB_HIBERNATE));
assert!(
client
.call(ControlRequest::TabSetHibernated {
workspace: ws.id,
tab: tab.id,
hibernated: true,
})
.is_err()
);
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
}
}
#[cfg(test)]
+35
View File
@@ -66,6 +66,41 @@ The key is your command's name; the value is one of the slugs above (`claude`,
`droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`,
`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`).
## Quick launch
Every agent tty7 finds on your `PATH` is one palette command away: **Agent:
Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's
**Launch Agent…** row opens the palette on that list, and **New Agent Tab**
(<kbd>⌘ ⇧ A</kbd> on macOS, unbound elsewhere) starts whichever agent you used
last.
A launch always opens a new tab in the current tab's directory and types the
agent's command into its shell, so the agent is detected, tracked and resumed
exactly as if you had typed it — and quitting it leaves you at the prompt. Each
`Agent: …` command is bindable too, as `LaunchAgent:<slug>`.
By default an agent launches as its bare binary (`claude`, `codex`,
`cursor-agent`, …). Give it a command line of its own with `agent_launch`:
```json
{
"agent_launch": {
"claude": "claude --dangerously-skip-permissions",
"codex": "cx --profile work"
}
}
```
A wrapper named there (`cx` above) is recognised as that agent without an
`agent_commands` entry. To keep the flags a running agent was started with,
right-click its pane and choose **Set Current Launch Args as Default**; tty7
writes them into `agent_launch`, minus the session they resumed and any prompt.
In a remote workspace tty7 cannot read the far machine's `PATH`, so it offers
the agents it has already seen running there: start one by hand once and it
joins the list. A wrapper named in your local `agent_launch` is only recognised
over there if the remote `config.json` names it too.
## What you get for free
Just by running an agent in a pane:
+2 -2
View File
@@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
| Command | Effect | JSON |
|---|---|---|
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` |
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…],"hibernated"}]}` |
| `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` |
| `tab new [WORKSPACE] --pane %PANE` | Add a tab around a pane already running | `{"tab","pane"}` |
| `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` |
@@ -357,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":[…]}` |
+6 -1
View File
@@ -31,7 +31,8 @@ section something is in.
Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard.
</Card>
<Card title="Window & Tabs" icon="window-maximize">
Tab position and placement, sidebar auto grouping, and diff previews.
Tab position and placement, sidebar auto grouping, diff previews, and what
SSH tabs are called.
</Card>
<Card title="SSH" icon="server" href="/remote/ssh">
Hosts, connection defaults, security, authentication, proxies, and forwarding.
@@ -69,6 +70,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.
+26 -5
View File
@@ -92,6 +92,7 @@ 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`. |
| `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). |
@@ -112,7 +113,7 @@ 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 the palette 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. |
@@ -177,6 +178,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 +189,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 +230,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 the palette can rank
them. They are written by the app; there is no reason to edit them.
<Note>
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
+3 -2
View File
@@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default.
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
| New Window | <kbd>⌘ N</kbd> | — |
| New Agent Tab | <kbd>⌘ ⇧ A</kbd> | — |
## Panes
@@ -107,10 +108,10 @@ Keybindings**:
| Group | Actions |
|---|---|
| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `ToggleTabSidebar` |
| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` |
| Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` |
| Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` |
| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` |
| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:<slug>` (one per agent, e.g. `LaunchAgent:claude`) |
| Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` |
| Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` |
| Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` |
+2 -1
View File
@@ -55,6 +55,7 @@ Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies)
| | |
|---|---|
| Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) |
| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk |
| Saved SSH hosts | `<config>/servers.json`, mode `0600` on Unix. Addresses, users and key paths — no secrets. |
| SSH passwords and key passphrases | The **OS keychain** — never `config.json` or `servers.json`, never plain text on disk |
| Pane scrollback tails | `<config>/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. |
| Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. |
+18
View File
@@ -67,6 +67,24 @@ need it about as often as you need `ssh -R`.
These live only as long as the session unless you save them into the profile.
## Switching a rule off
Every forward in the Ports section has a switch. Off, the forward lets go of
its port but keeps its whole rule — kind, ports, target, description — so it
can be switched back on without retyping anything. That is how one local port
is pointed at different places: keep one rule per target on the same port, and
switch on the one you want.
Only one of them can hold the port at a time. Switching one on while another
still has its port is refused, and the notice names the forward to switch off
first.
Rules from a profile carry their switch into it, so the next connection opens
the same set. The same switch sits beside each rule under **Settings → SSH →**
a profile **→ Port forwarding**; a rule saved switched off is listed but not
opened. Rules added mid-session are still gone when the session ends, on or
off.
<Frame caption="The Ports section with a local forward and a dynamic (SOCKS) rule">
<img src="/images/ports.webp" alt="The ports section" />
</Frame>
+4 -2
View File
@@ -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
+1 -1
View File
@@ -20,7 +20,7 @@ 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 |
| **Tabs & Panes** | New Tab · Shell: zsh · 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 |
+6
View File
@@ -23,6 +23,12 @@ A new tab always opens in the current pane's directory. Where it lands in the
list is **Settings → Window & Tabs → New tab position** — *After current* by
default, or *At end*.
An SSH tab is named by whatever the remote shell titles itself, like any other.
To keep it on the host instead, set **Settings → Window & Tabs → SSH tab
title** to *Profile name* — the saved host's name, the `~/.ssh/config` alias, or
the address you typed — or *Hostname*. A tab you renamed keeps its name either
way, and a split tab still follows the pane in front.
Right-click a tab for the rest: rename, split right or down, a new worktree tab,
copy the working directory, copy the session id, close · close others · close to
the right, and — when a coding agent is running there — mark unread and fork its
+41 -6
View File
@@ -1,7 +1,7 @@
---
name: tty7
description: >-
Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, run a command in a shell pane you already have and get back its output and exit code, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
---
# Driving tty7 from the command line
@@ -22,7 +22,7 @@ status hooks are installed, and whether `TTY7_CONFIG_DIR` / `TTY7_WS` /
`TTY7_PANE` are set — i.e. whether you are running *inside* a tty7 pane.
Being inside a pane matters for two reasons: the address-taking verbs
(`split`, `send`, `capture`, `procs`, `wait`, `pane close`) default to
(`split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`) default to
`$TTY7_PANE`, and `run --keep` files its pane into `$TTY7_WS`. Outside a tty7
shell you must name a target explicitly, and the error will say so rather than
guessing.
@@ -75,7 +75,7 @@ back in.
Omitting the address inside a tty7 shell means "this pane" / "this workspace".
An explicit address always wins over the environment.
## Running a command: two shapes
## Running a command: three shapes
### Blocking, with a real exit code
@@ -95,6 +95,28 @@ and the JSON object last — the combined stream is *not* parseable as JSON, so
read the last line. And the pane is 120 columns wide with no way to change it,
so output that assumes a wider terminal wraps.
### Blocking, in a pane you already have
```bash
tty7 exec "$PANE" --timeout 900 -- cargo test # prints its output, exits with its code
```
`exec` types the line at the pane's shell prompt — its cwd, its environment,
its history — waits for the prompt to come back, prints what the command
printed (as text, like `capture --plain`; `--raw` keeps the escapes) and exits
with the command's own code. It is how you run the next command in a pane you
set up earlier (a `cd`, an activated venv, an `ssh` session with tty7's shell
integration on the far end) without the send / wait / capture / guess dance.
What it needs, and says when it doesn't have: the pane's shell must have tty7's
shell integration (a pane that has never sent a prompt mark is refused), and
the pane must be sitting at a prompt (a busy one is refused — `wait --until
free` first). The words after `--` become one shell line, so `&&`, pipes and
redirections work if you quote them: `tty7 exec %3 -- 'make && ./run'`. Always
give `--timeout` when nobody is watching: a line the shell never runs — an
unclosed quote waiting on `dquote>` — has no end to wait for. On timeout it
exits 124 and leaves the command running.
### Non-blocking: a pane you talk to over time
This is the one that makes tty7 worth reaching for. Get a pane, send it work,
@@ -135,6 +157,18 @@ for Alt. Repeat it for a sequence; text and keys compose, text first. Typing
`^C` as text does nothing — it arrives as two characters; `--key C-c` is the
real interrupt.
Two things text on the command line cannot do. **Secrets**: `tty7 send %3
"$TOKEN"` puts the token in `ps` for every local user and in your shell
history — pipe it instead, `printf %s "$TOKEN" | tty7 send %3 --stdin --enter`
(or `--from-file PATH`); the bytes go to the pane exactly as read and are never
echoed in `--json`. Echo on the far side is the program's business: `docker
login --password-stdin`, `ssh-add` and `gpg` turn it off themselves.
**Multi-line text**: a newline on a pty *is* Enter, so each line runs as it
arrives. `--paste` sends it the way a paste into the window does — framed in
bracketed paste when the pane has switched that on, so a shell or an agent's
input box takes the whole block as text; `--json` reports `"bracketed"` so you
know which it got.
**A brand-new pane can swallow the Enter.** A shell still working through its
startup files — a prompt framework, `fastfetch`, anything that paints on login —
takes the text you send but loses the carriage return that follows it, and the
@@ -176,8 +210,9 @@ whole ring, which for a pane that was never resized is the same thing.
`--plain` gives you the screen, and a screen is a rectangle: whatever scrolled
past the top of a long build log is gone, and the exit code was never on screen
at all. So when what you want is the *answer* rather than the view, have the
shell write it somewhere clean:
at all. When the pane is a shell sitting at its prompt, `tty7 exec` (above)
hands back both. Otherwise — the pane is busy, or its shell has no tty7
integration — have the shell write the answer somewhere clean:
```bash
tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter
@@ -278,7 +313,7 @@ output on success but never suppresses errors.
The panes on this machine are the user's real work, and some of them are other
coding agents mid-task. Treat anything you did not create as read-only:
- **Never `send` into a pane you didn't open.** Keystrokes into another agent's
- **Never `send` or `exec` into a pane you didn't open.** Keystrokes into another agent's
pane, or into a shell the user is typing in, land in the middle of whatever
is happening there. Check `tty7 agents` before you touch a pane. This goes
double for `--key`: a stray `C-c` kills somebody's work.
+70 -10
View File
@@ -30,7 +30,7 @@ Set inside every tty7 pane, inherited by anything you launch from one.
| Variable | Meaning |
|---|---|
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `tab ls`, `ws tree`. |
| `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. |
@@ -44,13 +44,13 @@ Outside a tty7 shell the address-taking verbs fail with
| 0 | success |
| 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` |
| 2 | usage error (clap) — unknown verb, missing argument, bad type |
| 124 | `tty7 wait` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" |
| 124 | `tty7 wait` or `tty7 exec` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" |
| 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. |
| *other* | only from `tty7 run`, which passes the child's exit code through |
| *other* | only from `tty7 run` and `tty7 exec`, which pass the command's exit code through |
If `run` cannot learn the child's code it prints a note to stderr and exits 1
with `"exit_code_known": false` in the JSON — that is how you tell a real 1
from a stand-in.
If `run` or `exec` cannot learn the command's code it prints a note to stderr
and exits 1 with `"exit_code_known": false` in the JSON — that is how you tell
a real 1 from a stand-in.
## Top-level verbs
@@ -85,6 +85,41 @@ the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo.
JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed
output. The combined stream is not valid JSON; read the last line.
### `tty7 exec [%PANE] [--timeout SECS] [--raw] -- CMD...`
Runs `CMD` in an existing pane's shell and blocks until its prompt comes back:
the words after `--` are joined with spaces and typed at the prompt as one line
followed by Enter, so the shell interprets them — quote `&&`, `|` and `>` to
keep them from your own shell. Prints what the command printed and exits with
its exit code. The pane (default `$TTY7_PANE`) keeps its cwd, environment and
history, which is the difference from `run`: `run` makes a new pane for an
argv, `exec` uses a shell you already have.
The shell integration's OSC 133 marks bound the command: the output is what the
pane printed between the command starting (`C`) and finishing (`D`, which also
carries the exit code). Refused, with nothing typed:
- a pane whose shell has sent no prompt mark — no integration, so nothing would
ever say the command ended; `tty7 doctor`, or fall back to `send` + `wait
--until free`
- a pane that is not at a prompt — something already runs there (your own pane
always is: it is running `tty7 exec`)
- a line holding a control character (newline, tab, ESC), which the line editor
would act on rather than type — join commands with `;` / `&&`
| Flag | |
|---|---|
| `--timeout SECS` | Give up after SECS, exit 124. The command is left running; `send %N --key C-c` stops it. With no timeout it waits for as long as the command runs |
| `--raw` | The output as the pane received it, escapes intact. By default it is replayed through a terminal to text, the way `capture --plain` reads it — which also erases zsh's PROMPT_SP, drawn before the finish mark |
A line the shell never starts — an unclosed quote waiting on a continuation
prompt — never finishes; the timeout message says that is the likely cause.
A line the shell rejects outright (a syntax error) exits 1 with
`"exit_code_known": false` and the shell's complaint as the output. Output past
8 MiB is dropped from the front, with a note on stderr.
JSON: `{"pane","command","exit","exit_code_known","timed_out","pane_exited","output","bytes","elapsed_ms"}`
— `output` is the printed form, `bytes` the raw length.
### `tty7 new [PATH] [--open]`
Creates a workspace plus its first tab and shell, at `PATH` if given. Prints
the workspace id. JSON: `{"id","pane","opened"}`.
@@ -100,7 +135,7 @@ pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the
share kept by the *existing* pane, clamped to 0.05–0.95 — a `--ratio 70`
silently becomes 0.95, not an error. Prints `%NN`. JSON: `{"pane"}`.
### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…`
### `tty7 send [%PANE] [TEXT | --stdin | --from-file PATH] [--paste] [--enter] [--key KEY]…`
Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key
enter` — it appends CR to the text, or presses Enter on its own when there is
none, so `tty7 send %42 --enter` runs whatever pane 42 already has typed. With
@@ -115,7 +150,28 @@ that still doesn't parse (`%3x`) is an address error, never text for your own
pane — while text that merely starts with `%` (`%s/foo/bar/`, `%!sort`) types
as given, as does anything unmarked that is not a plain number (`3x`, `+5`). To
type an address-shaped string, name the pane as well: `tty7 send %42 %3x`.
JSON: `{"pane","sent","enter","keys"}`.
JSON: `{"pane","sent","source","bytes","paste","bracketed","bracketed_paste_mode","enter","keys"}`
— `source` is `argument`, `stdin`, `file` or `none`.
`--stdin` / `--from-file PATH` take the text from there instead of an argument
— for a secret, which as an argument sits in `ps` for every local user and in
your shell history. The bytes go to the pane exactly as read (no escape
interpretation, no newline conversion, not required to be UTF-8), and `sent` is
`null` in the JSON so they are not echoed into a log either. With either, the
one positional is the pane and must be a valid address. Use `printf %s`, not
`echo`, unless you mean the trailing newline — on a pty that newline is Enter.
Whether the far side echoes the secret is up to the program reading it, not
tty7: `docker login --password-stdin`, `ssh-add` and `gpg` turn echo off.
`--paste` sends the text as the GUI sends a paste. When the pane has switched on
bracketed paste (mode 2004 — shells at their prompt and most TUIs do), the text
is wrapped in `ESC[200~ … ESC[201~` with CRLF folded to LF and every ESC byte
stripped, so the text cannot end the paste early; multi-line text then lands as
one block instead of running a line at a time. When the pane has not, the text
goes unframed with each line break sent as Enter — what a paste into such a pane
has always done — and a note goes to stderr. `bracketed` says which was sent;
`bracketed_paste_mode` is what the pane reported (`null` from a server too old
to say). `--enter` after `--paste` submits the pasted block.
`--key` presses a key instead of typing characters — the arrow keys a
permission prompt wants, the `escape` that closes a TUI, the `C-c` that stops a
@@ -196,9 +252,13 @@ The process tree inside the pane, indented by depth, `*` on the foreground
process — then a second table of ports those processes are listening on.
Prints `nothing running in this pane` when both are empty.
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}`,
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}],"context":{…}}`,
where `addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`,
`[::1]`, or a specific interface).
`[::1]`, or a specific interface). `context` is what the pane says about itself:
`local_pty`, `remote` (the host it is pointed at), `at_prompt` (from its shell
integration's marks; absent until the first one), `remote_prompt_seen`, and
`bracketed_paste` (whether the pane has mode 2004 on — what `send --paste`
reads).
Nothing below the depth-0 shell means the foreground command has exited — but
you rarely need to check that by hand, because that is exactly what
+11
View File
@@ -30,12 +30,15 @@ actions!(
RevealLinkUnderPointer,
CopyLinkPathUnderPointer,
MarkTabUnread,
HibernateTab,
ForkAgentSession,
ForkAgentSessionRight,
ForkAgentSessionLeft,
ForkAgentSessionDown,
ForkAgentSessionUp,
CopyAgentSessionId,
NewAgentTab,
SaveAgentLaunchArgs,
SplitRight,
SplitDown,
FocusNextPane,
@@ -125,3 +128,11 @@ actions!(
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,
}
+2 -3
View File
@@ -51,9 +51,8 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String>
}
pub fn submit_bytes(prompt: &str) -> Vec<u8> {
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend(prompt.bytes().filter(|&b| b != 0x1b));
bytes.extend_from_slice(b"\x1b[201~\r");
let mut bytes = tty7_core::core::paste::bracket(prompt.as_bytes());
bytes.push(b'\r');
bytes
}
+18
View File
@@ -161,6 +161,24 @@ impl WorkspaceStore {
store.views.save();
}
/// Note that `agent` ran in workspace `id` (see `WindowView::seen_agents`).
pub fn record_agent_seen(
cx: &mut gpui::App,
id: WorkspaceId,
agent: tty7_core::core::cli_agent::CLIAgent,
) {
let Some(store) = Self::try_store(cx) else {
return;
};
if store
.views
.get_mut(id)
.is_some_and(|view| view.saw_agent(agent.slug()))
{
store.views.save();
}
}
pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId {
host_for(Self::all(cx), id)
}
+1
View File
@@ -883,6 +883,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
bind,
target,
description: String::new(),
enabled: true,
})
}
+8 -4
View File
@@ -48,10 +48,13 @@ fn spawn_config_watcher(cx: &mut App) {
if !tty7_core::host::is_content_change(&event.kind) {
return;
}
let hit = event
.paths
.iter()
.any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p));
// The saved SSH hosts live beside config.json in a file of their own
// (#911), and a hand edit there has to land the same way.
let hit = event.paths.iter().any(|p| {
p.file_name() == watched_file.file_name()
|| p.file_name() == Some(std::ffi::OsStr::new(crate::core::config::SERVERS_FILE))
|| is_theme_file(p)
});
if hit {
let _ = tx.try_send(());
}
@@ -705,6 +708,7 @@ fn main() {
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
crate::ui::prompt::install(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
+362 -28
View File
@@ -884,8 +884,8 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
thread_local! {
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new());
/// Measured ink extents and the font size they were measured at.
static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option<Pixels>>)> =
/// Measured ink bounds and the font size they were measured at.
static INK_BOUNDS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option<Bounds<Pixels>>>)> =
RefCell::new((px(0.), HashMap::new()));
}
@@ -1089,6 +1089,9 @@ fn seg_budget(solo: bool, measured: bool, cells: usize, room: bool, cell_width:
}
/// How much to shrink a segment so its glyph stops inside its budget.
///
/// Never more than 1. A fallback icon is not held to this at all:
/// [`icon_fit`] sizes it, up or down.
fn fit_scale(ink: Pixels, budget: Pixels) -> f32 {
if ink <= budget || ink <= px(0.) {
1.
@@ -1097,22 +1100,157 @@ fn fit_scale(ink: Pixels, budget: Pixels) -> f32 {
}
}
/// Where a segment's ink ends, measured from the left edge of its first cell.
/// Whether the platform's `typographic_bounds` is the glyph's ink.
///
/// CoreText and DirectWrite answer with the ink box. cosmic-text answers with
/// the advance box pinned to the baseline, which says nothing about where an
/// icon's ink sits; sizing and centring on that would misplace it, so the
/// Linux build keeps the shrink-only rule for icons too.
const INK_BOUNDS_ARE_INK: bool = cfg!(any(target_os = "macos", target_os = "windows"));
/// The most an icon is ever enlarged by.
///
/// The cap keeps a glyph that is small by design — a dot, a bullet — from
/// being blown up to a cell-sized blob.
const MAX_ICON_GROWTH: f32 = 2.;
/// Whether a character is an icon: a code point in one of Unicode's Private
/// Use Areas, where Nerd Fonts, Font Awesome, Codicons and Material Design
/// icons all live.
///
/// Nothing in these ranges is text, so sizing one to its cells cannot change
/// how prose reads — the reason the rule is keyed on the code point and not
/// on the face: a CJK or emoji fallback keeps its own metrics.
///
/// The Powerline separators (`U+E0B0`–`U+E0D7`) are not icons. They are
/// shapes meant to abut the cells around them, the solid ones are painted
/// natively, and the thin ones already reach the row's full height, so
/// fitting them to an em and centring them would only pull them away from
/// the segments they join.
fn is_icon(c: char) -> bool {
matches!(
c as u32,
0xE000..=0xE0AF | 0xE0D8..=0xF8FF | 0xF_0000..=0xF_FFFD | 0x10_0000..=0x10_FFFD
)
}
/// Whether the cell after an icon can lend it room, the way kitty lets a
/// Private Use Area glyph followed by a space take two cells.
///
/// Everything [`has_room_after`] lends, and one thing more: a blank that
/// paints the *same* background as the icon's own cell. That is the space
/// after every icon in a coloured prompt segment, and backgrounds are painted
/// before any glyph, so the icon lands on its own colour. Held back to one
/// cell there, a Nerd Font icon is shrunk to about two thirds of the text's
/// height, which is #866.
fn lends_to_icon(row: &[RenderCell], start: usize, cells: usize) -> bool {
if has_room_after(row, start, cells) {
return true;
}
let (Some(icon), Some(next)) = (row.get(start), row.get(start + cells)) else {
return false;
};
is_blank(next)
&& !next.selected
&& icon.draw_bg
&& next.draw_bg
&& next.bg == icon.bg
&& next.match_hit == icon.match_hit
&& next.match_current == icon.match_current
&& !GlyphStyle::of(next).draws_on_blanks()
}
/// How to size an icon a fallback face supplied, and how many cells it spans.
///
/// A fallback face is drawn at the primary's font size on its own metrics, so
/// what an icon looks like beside the text depends on how the two faces'
/// cells compare. Symbols Nerd Font Mono inks a full em per icon, which is
/// 1.6 cells of a 0.6em primary; a face with a narrower cell than the
/// primary's inks less than one. Neither is the icon at the text's size.
///
/// So fit the ink, aspect ratio kept, to the cells it is given and one em of
/// height — the size the icon was drawn at in its own face, and never taller
/// than the text's line. That may shrink or grow it, growth capped at
/// [`MAX_ICON_GROWTH`] and ignored under 2%, which is not worth a reshape.
/// When `lend` says the next cell is free the icon may take it too, but only
/// if that lets it come out bigger: a narrow glyph that already fits one cell
/// keeps to it and stays beside what follows.
fn icon_fit(
ink: Bounds<Pixels>,
cell_width: Pixels,
cells: usize,
lend: bool,
height: Pixels,
) -> (f32, usize) {
let (w, h) = (ink.size.width.as_f32(), ink.size.height.as_f32());
if !(w > 0. && h > 0.) {
return (1., cells);
}
let fit = |cells: usize| {
let scale = (cell_width.as_f32() * cells as f32 / w)
.min(height.as_f32() / h)
.min(MAX_ICON_GROWTH);
if (1. ..=1.02).contains(&scale) {
1.
} else {
scale
}
};
let own = fit(cells);
if lend && fit(cells + 1) > own {
(fit(cells + 1), cells + 1)
} else {
(own, cells)
}
}
/// Where to paint a fitted icon, relative to its cell's top-left corner, so
/// its ink sits centred in `room` — vertically always, and horizontally only
/// when `centre_x`. An icon spilling into a borrowed cell stays where its
/// face put it instead, as it would unfitted, so the gap before the text
/// after it is the one its face drew.
///
/// `ink` is the glyph's bounds at its native size (y up from the baseline);
/// `ascent` and `descent` are the line's as shaped at the fitted size, which
/// is what gpui centres the baseline in the row with.
fn icon_offset(
ink: Bounds<Pixels>,
scale: f32,
room: gpui::Size<Pixels>,
centre_x: bool,
ascent: Pixels,
descent: Pixels,
) -> Point<Pixels> {
let baseline = (room.height - ascent - descent) / 2. + ascent;
let ink_top = baseline - (ink.origin.y + ink.size.height) * scale;
let x = if centre_x {
(room.width - ink.size.width * scale) / 2. - ink.origin.x * scale
} else {
// Nudged only as far as it takes to keep the ink inside the room.
px(0.)
.min(room.width - (ink.origin.x + ink.size.width) * scale)
.max(-ink.origin.x * scale)
};
point(x, (room.height - ink.size.height * scale) / 2. - ink_top)
}
/// The ink bounds of a segment's first glyph, relative to its pen position,
/// y up from the baseline.
///
/// Advance is the wrong yardstick: Apple Color Emoji advances 1.31em but only
/// inks 1.25em, so going by advance shrinks glyphs that would have fit. Ask
/// for the glyph's own bounds instead, cached per (face, char) because the
/// lookup is a font query a screenful of CJK would otherwise repeat on every
/// cell of every frame.
fn ink_extent(
fn ink_bounds(
cx: &App,
shaped: &gpui::ShapedLine,
text: &str,
font_size: Pixels,
) -> Option<Pixels> {
) -> Option<Bounds<Pixels>> {
let font_id = shaped.runs.first()?.font_id;
let c = text.chars().next()?;
INK_EXTENTS.with(|cache| {
INK_BOUNDS.with(|cache| {
let hit = {
let mut cache = cache.borrow_mut();
if cache.0 != font_size {
@@ -1127,13 +1265,12 @@ fn ink_extent(
if let Some(extent) = hit {
return extent;
}
let extent = cx
let bounds = cx
.text_system()
.typographic_bounds(font_id, font_size, c)
.ok()
.map(|bounds| bounds.origin.x + bounds.size.width);
cache.borrow_mut().1.insert((font_id, c), extent);
extent
.ok();
cache.borrow_mut().1.insert((font_id, c), bounds);
bounds
})
}
@@ -1234,7 +1371,7 @@ fn shape_piece(
.shape_line(piece, font_size, run_buf, force_width)
}
/// Whether [`ink_extent`]'s answer speaks for the whole segment.
/// Whether [`ink_bounds`]'s answer speaks for the whole segment.
///
/// It measures the segment's first character in the run's first face. That is
/// all of a one-character segment and only part of anything longer: a
@@ -1284,6 +1421,9 @@ fn paint_glyphs(
build_font(bold_font.unwrap_or(base_font), true, true),
];
// Resolved only once an icon asks, so a pane without one pays nothing.
let mut primary_ids: [Option<gpui::FontId>; 4] = [None; 4];
let run_buf = &mut [TextRun {
len: 0,
font: base_font.clone(),
@@ -1435,17 +1575,40 @@ fn paint_glyphs(
.shape_line(text.clone(), font_size, run_buf, force_width);
// Measured before the budget is set, because how far a solo glyph
// may reach turns on whether its ink is known at all.
let ink = ink_extent(cx, &shaped, &text, font_size);
let bounds = ink_bounds(cx, &shaped, &text, font_size);
let ink = bounds.map(|b| b.origin.x + b.size.width);
let measured = ink_covers_segment(ink, &text);
let budget = seg_budget(
solo,
ink_covers_segment(ink, &text),
measured,
cells,
has_room_after(row_cells, start, cells),
geom.cell_width,
);
if let Some(ink) = ink {
let scale = fit_scale(ink, budget);
if scale < 1. {
let mut origin = point(x, y);
let mut clip_width = budget;
if let (Some(ink), Some(bounds)) = (ink, bounds) {
// Only a lone icon that a fallback face supplied is fitted:
// the primary's own icons were drawn for its cell already.
let icon = INK_BOUNDS_ARE_INK
&& measured
&& text.chars().next().is_some_and(is_icon)
&& shaped.runs.first().is_some_and(|r| {
r.font_id
!= *primary_ids[face_ix].get_or_insert_with(|| {
window.text_system().resolve_font(&faces[face_ix])
})
});
let (scale, room) = if icon {
let lend = lends_to_icon(row_cells, start, cells);
let height = font_size.min(geom.line_height);
let (scale, spans) = icon_fit(bounds, geom.cell_width, cells, lend, height);
let room = size(geom.cell_width * spans as f32, geom.line_height);
(scale, Some((room, spans == cells)))
} else {
(fit_scale(ink, budget), None)
};
if scale != 1. {
shaped = window.text_system().shape_line(
text.clone(),
font_size * scale,
@@ -1453,18 +1616,16 @@ fn paint_glyphs(
force_width,
);
}
if let Some((room, centre_x)) = room {
origin +=
icon_offset(bounds, scale, room, centre_x, shaped.ascent, shaped.descent);
clip_width = room.width;
}
}
let clip = Bounds::new(point(x, y), size(budget, geom.line_height));
let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height));
window.with_content_mask(Some(ContentMask { bounds: clip }), |window| {
_ = shaped.paint(
point(x, y),
geom.line_height,
TextAlign::Left,
None,
window,
cx,
);
_ = shaped.paint(origin, geom.line_height, TextAlign::Left, None, window, cx);
});
}
}
@@ -2675,11 +2836,12 @@ mod tests {
}
#[test]
fn fit_scale_only_shrinks_glyphs_that_overflow_their_budget() {
fn fit_scale_shrinks_what_overflows_and_never_enlarges() {
let budget = px(15.);
assert_eq!(fit_scale(px(19.2), budget), 15. / 19.2);
// Whatever already fits keeps its own size, including a glyph that
// lands exactly on the edge and a run that measured as empty.
// lands exactly on the edge and a run that measured as empty. Making
// a small glyph bigger is `icon_fit`'s rule, not this one's.
assert_eq!(fit_scale(px(15.), budget), 1.);
assert_eq!(fit_scale(px(12.), budget), 1.);
assert_eq!(fit_scale(px(0.), budget), 1.);
@@ -4452,4 +4614,176 @@ mod tests {
Some(&['\u{FE0F}'][..]),
);
}
fn ink(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
Bounds::new(point(px(x), px(y)), size(px(w), px(h)))
}
/// Menlo at 22px: a 13.2px cell in a 30.8px row (line height 1.4).
const CELL: f32 = 13.2;
const EM: f32 = 22.;
fn fit(ink: Bounds<Pixels>, cells: usize, lend: bool) -> (f32, usize) {
icon_fit(ink, px(CELL), cells, lend, px(EM))
}
#[test]
fn only_private_use_code_points_count_as_icons() {
for c in [
'\u{e000}', // first of the BMP Private Use Area
'\u{e0a0}', // Powerline branch
'\u{e0d8}', // first past the Powerline separators
'\u{f179}', // Font Awesome apple
'\u{f8ff}', // last of the BMP Private Use Area
'\u{f0001}', // Material Design icon, Supplementary PUA-A
'\u{10fffd}', // end of Supplementary PUA-B
] {
assert!(is_icon(c), "U+{:04X}", c as u32);
}
// Text a fallback face supplies keeps its own metrics: CJK, emoji,
// Latin, and the drawing characters painted natively anyway.
for c in [
'中',
'あ',
'한',
'\u{1F600}',
'\u{2764}',
'M',
'─',
'█',
'\u{f900}',
] {
assert!(!is_icon(c), "U+{:04X}", c as u32);
}
// Nor do the Powerline separators, which are shapes, not icons.
for c in ['\u{e0b0}', '\u{e0b1}', '\u{e0b3}', '\u{e0d7}'] {
assert!(!is_icon(c), "U+{:04X}", c as u32);
}
}
#[test]
fn a_nerd_font_icon_before_a_space_takes_two_cells_at_full_size() {
// Symbols Nerd Font Mono's apple, as CoreText measures it at 22px: a
// full em tall and 1.4 cells wide. Held to one cell it is shrunk to
// 0.71 of the text's size, which is what #866 saw; with the space
// after it lent, it keeps its whole em.
let apple = ink(1.76, -4.43, 18.48, 22.03);
let (held, cells) = fit(apple, 1, false);
assert_eq!(cells, 1);
assert!((apple.size.width.as_f32() * held - CELL).abs() < 1e-3);
let (lent, cells) = fit(apple, 1, true);
assert_eq!(cells, 2);
assert!((apple.size.height.as_f32() * lent - EM).abs() < 1e-3);
assert!(apple.size.width.as_f32() * lent <= 2. * CELL);
}
#[test]
fn a_narrow_icon_keeps_to_its_own_cell_even_when_the_next_is_free() {
// The branch symbol is an em tall and 0.4em wide: one cell already
// holds it at full height, so borrowing would only push it off
// towards the text after it.
let branch = ink(6.52, -4.4, 8.96, 22.);
assert_eq!(fit(branch, 1, true), (1., 1));
}
#[test]
fn a_small_icon_from_a_narrow_face_grows_to_fill_its_cell() {
// A face with a narrower cell than the primary inks well under it.
let icon = ink(0.5, -1., 8.8, 9.);
let (scale, cells) = fit(icon, 1, false);
assert_eq!(cells, 1);
assert!(scale > 1.);
assert!((icon.size.width.as_f32() * scale - CELL).abs() < 1e-3);
// The same icon two cells wide may use both.
let (two, cells) = fit(icon, 2, false);
assert_eq!(cells, 2);
assert!(two > scale);
}
#[test]
fn a_fitted_icon_is_centred_in_its_room() {
let icon = ink(0.4, -0.6, 5.3, 6.);
let room = size(px(CELL), px(30.8));
let (scale, _) = fit(icon, 1, false);
let (ascent, descent) = (px(20.4 * scale), px(5.2 * scale));
let at = icon_offset(icon, scale, room, true, ascent, descent);
// Spilling into a borrowed cell, it stays where its face put it,
// unless that would take its ink past either edge of the room.
assert_eq!(
icon_offset(icon, scale, room, false, ascent, descent).x,
px(0.)
);
let lent = size(px(2. * CELL), px(30.8));
let overhang = ink(-1., 0., 20., 20.);
assert_eq!(
icon_offset(overhang, 1., lent, false, ascent, descent).x,
px(1.)
);
let pushed = ink(8., 0., 20., 20.);
let x = icon_offset(pushed, 1., lent, false, ascent, descent).x;
assert!((x.as_f32() - (2. * CELL - 28.)).abs() < 1e-4, "{x:?}");
let left = at.x.as_f32() + icon.origin.x.as_f32() * scale;
let right = left + icon.size.width.as_f32() * scale;
assert!((left - (CELL - right)).abs() < 1e-3, "{left}..{right}");
let baseline = at.y + (room.height - ascent - descent) / 2. + ascent;
let top = (baseline - (icon.origin.y + icon.size.height) * scale).as_f32();
let bottom = (baseline - icon.origin.y * scale).as_f32();
assert!((top - (30.8 - bottom)).abs() < 1e-3, "{top}..{bottom}");
}
#[test]
fn icon_fit_leaves_what_is_already_the_right_size_alone() {
// Within 2% of the cell: not worth shaping the glyph again.
assert_eq!(fit(ink(0., 0., 13., 13.), 1, false), (1., 1));
// Nothing measured, nothing to scale by.
assert_eq!(fit(ink(0., 0., 0., 0.), 1, true), (1., 1));
// A dot is small by design and grows no more than the cap.
assert_eq!(fit(ink(1., 2., 2., 2.), 1, false).0, MAX_ICON_GROWTH);
}
fn icon_row(next: RenderCell) -> Vec<RenderCell> {
let icon = RenderCell {
c: '\u{f179}',
..RenderCell::default()
};
vec![icon, next]
}
#[test]
fn a_blank_in_the_same_colour_lends_an_icon_its_cell() {
let blank = RenderCell::default();
assert!(lends_to_icon(&icon_row(blank.clone()), 0, 1));
// Whatever `has_room_after` lends, the end of the row included.
assert!(lends_to_icon(&icon_row(blank.clone())[..1], 0, 1));
// A prompt segment: icon and the space after it on one background.
let bg = Hsla::blue();
let mut row = icon_row(RenderCell {
bg,
draw_bg: true,
..RenderCell::default()
});
row[0].bg = bg;
row[0].draw_bg = true;
assert!(lends_to_icon(&row, 0, 1));
// The next segment's colour, a glyph or a selection do not.
row[1].bg = Hsla::red();
assert!(!lends_to_icon(&row, 0, 1));
let letter = RenderCell {
c: 'x',
..RenderCell::default()
};
assert!(!lends_to_icon(&icon_row(letter), 0, 1));
let selected = RenderCell {
selected: true,
..RenderCell::default()
};
assert!(!lends_to_icon(&icon_row(selected), 0, 1));
// Nor does a coloured blank after an icon on the default background.
let coloured = RenderCell {
bg,
draw_bg: true,
..blank
};
assert!(!lends_to_icon(&icon_row(coloured), 0, 1));
}
}
+33
View File
@@ -571,6 +571,10 @@ pub struct RemoteTerminal {
pub exited: bool,
size: TermSize,
synced_size: bool,
/// This link reattached to a pane that was already running rather than
/// spawning a fresh one, so whatever it reports first — an agent in the
/// foreground included — predates this client.
reattached: bool,
/// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked
/// alongside `size` so a display-scale change still reaches the child even
/// when the grid dimensions are unchanged.
@@ -1087,6 +1091,7 @@ impl RemoteTerminal {
exited: false,
size,
synced_size: false,
reattached: awaiting_replay,
synced_cell: (0, 0),
resize_sent_at: None,
link,
@@ -1878,6 +1883,11 @@ impl RemoteTerminal {
self.remote_context.lock().ok().and_then(|g| g.clone())
}
/// See the field: an attach to a running pane, not a fresh spawn.
pub fn reattached(&self) -> bool {
self.reattached
}
pub fn at_prompt(&self) -> bool {
self.shell_state
.lock()
@@ -2296,6 +2306,29 @@ impl RemoteTerminal {
.ok()
}
/// The list after switching one forward on or off, or why it was not
/// switched — the daemon's own refusal, which names what is in the way.
pub fn set_forward_enabled(
pane_id: u64,
forward_id: u64,
enabled: bool,
) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
ClientMsg::SetForwardEnabled {
pane_id,
forward_id,
enabled,
}
.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::ForwardList(list) => Ok(list),
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
other => Err(anyhow::anyhow!(
"unexpected reply to SetForwardEnabled: {other:?}"
)),
}
}
pub fn list_forwards(pane_id: u64) -> Vec<ManagedForward> {
fn query(pane_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
+182 -22
View File
@@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead};
use crate::core::actions::{
CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown,
ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab,
OpenLinkUnderPointer, RevealLinkUnderPointer, SendBackTab, SendTab, SplitDown, SplitRight,
ToggleMaximizePane,
OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab,
SplitDown, SplitRight, ToggleMaximizePane,
};
use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode};
use crate::core::shell_quote::quote_for_shell;
@@ -140,6 +140,12 @@ pub struct AgentSessionChanged;
impl gpui::EventEmitter<AgentSessionChanged> for TerminalView {}
/// A coding agent started running in this pane: its foreground went from no
/// agent to this one. What quick launch counts as the agent being used.
pub struct AgentDetected(pub crate::core::cli_agent::CLIAgent);
impl gpui::EventEmitter<AgentDetected> for TerminalView {}
/// A file link the user clicked, on its way to whoever can show it. The
/// terminal resolves the path — it is the only thing that knows the pane's
/// directory and host — and the app opens it, because the editor and the file
@@ -419,6 +425,15 @@ pub struct TerminalView {
running_since: Option<std::time::Instant>,
running_title: String,
running_agent: Option<crate::core::cli_agent::CLIAgent>,
/// The foreground agent as of the last poll, so a new one is reported
/// once ([`AgentDetected`]) rather than on every frame it keeps running.
seen_agent: Option<crate::core::cli_agent::CLIAgent>,
/// Whether an agent appearing here is news. A pane this view spawned is
/// armed from the start; one it reattached to — an app restart, a
/// workspace switched back to — may already be running an agent that was
/// counted when it started, so it arms only once its shell is seen back
/// at the prompt with no agent in front.
agent_detection_armed: bool,
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
last_agent_session: (Option<String>, Option<Vec<String>>),
agent_turn_started: Option<std::time::Instant>,
@@ -833,15 +848,7 @@ fn ring_system_bell() -> bool {
}
fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
let text = text.replace("\r\n", "\n");
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend(text.bytes().filter(|&b| b != 0x1b));
bytes.extend_from_slice(b"\x1b[201~");
bytes
} else {
text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
}
tty7_core::core::paste::paste_bytes(text.as_bytes(), bracketed)
}
/// A line the shell can be handed byte for byte, as if it had been typed at its
@@ -1435,6 +1442,7 @@ impl TerminalView {
.map(crate::core::config::gpui_font_features);
let report_mouse = config.mouse_reporting;
let prompt_editor = config.prompt_editor;
let agent_detection_armed = !terminal.reattached();
let mut font = gpui::font(font_family);
font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone()));
if let Some(features) = &font_features {
@@ -1649,6 +1657,8 @@ impl TerminalView {
running_since: None,
running_title: String::new(),
running_agent: None,
seen_agent: None,
agent_detection_armed,
last_agent_status: None,
last_agent_session: (None, None),
agent_turn_started: None,
@@ -1757,6 +1767,35 @@ impl TerminalView {
stated_title(&self.title)
}
/// The title this pane gives its tab: [`Self::stated_title`], unless this
/// is an SSH pane and Settings pins its tab to the host's name instead
/// (#726). The pane's own title is untouched either way — OSC 0/2 keep
/// landing in it, and it is back on the tab the moment the setting is.
///
/// A pane that has ended still says so: the pinned name takes the same
/// suffix the pane's own title would have.
pub(crate) fn tab_title(&self, cx: &App) -> Option<String> {
let pinned = self.ssh_spec.as_deref().and_then(|spec| {
let cfg = cx.try_global::<Config>()?;
crate::ui::ssh_connect::pinned_ssh_title(cfg.ssh_tab_title, spec, &cfg.ssh_profiles)
});
match pinned {
Some(name) if self.terminal.exited => Some(self.ended_title(&name)),
Some(name) => Some(name),
None => self.stated_title().map(str::to_string),
}
}
/// `name` with the suffix that says how this pane ended.
fn ended_title(&self, name: &str) -> String {
let key = if self.workspace().is_some() && !self.terminal.child_exited() {
L10nKey::PaneTitleDisconnected
} else {
L10nKey::PaneTitleProcessExited
};
t_fmt(key, &[("title", name)])
}
/// Sets how opaque the pane wants this terminal painted; the pane leaf
/// calls this every frame while rendering, and the terminal element
/// blends its colours toward the window background during paint (see
@@ -2234,17 +2273,7 @@ impl TerminalView {
self.pending_title = None;
// The pane keeps answering to its own name (an SSH pane's
// host, #438) — only the state suffix is localized (#602).
self.title = if self.workspace().is_some() && !self.terminal.child_exited() {
t_fmt(
L10nKey::PaneTitleDisconnected,
&[("title", &self.default_title)],
)
} else {
t_fmt(
L10nKey::PaneTitleProcessExited,
&[("title", &self.default_title)],
)
};
self.title = self.ended_title(&self.default_title);
if self.terminal.child_exited() {
cx.emit(ChildExited);
}
@@ -3751,6 +3780,8 @@ impl TerminalView {
_ => {}
}
self.poll_agent_detection(at_prompt, cx);
let turn_finished = self.poll_agent_status(notify_allowed, window, cx);
let session = self.terminal.agent_session();
@@ -4011,6 +4042,24 @@ impl TerminalView {
);
}
/// Report an agent that has just started in this pane ([`AgentDetected`]),
/// once per start, and never one that was already running when this view
/// reattached to the pane.
fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context<Self>) {
let agent = self.terminal.foreground_agent();
if agent != self.seen_agent {
self.seen_agent = agent;
if let Some(agent) = agent
&& self.agent_detection_armed
{
cx.emit(AgentDetected(agent));
}
}
if agent.is_none() && at_prompt {
self.agent_detection_armed = true;
}
}
fn poll_agent_status(
&mut self,
notify_allowed: bool,
@@ -7305,6 +7354,12 @@ impl Render for TerminalView {
let fork_ready = can_fork
&& view.remote_context().is_none()
&& view.agent_session().is_some_and(|s| s.session_id.is_some());
// `None` over a pane with no agent; otherwise whether it knows
// the command line it was started with.
let launch_argv_known = view.agent().map(|_| {
view.agent_session()
.is_some_and(|s| s.launch_argv.is_some())
});
let menu = match (can_fork, fork_ready) {
(true, true) => {
@@ -7338,6 +7393,21 @@ impl Render for TerminalView {
(false, _) => menu,
};
// Only over a running agent, and only live when the pane knows
// the command line it was started with — there is nothing to
// save otherwise. Beside Fork when both are there.
let menu = match launch_argv_known {
Some(known) => {
let menu = if can_fork { menu } else { menu.separator() };
menu.menu_with_disabled(
t(L10nKey::AppMenuSaveAgentLaunchArgs),
Box::new(SaveAgentLaunchArgs),
!known,
)
}
None => menu,
};
menu.separator()
.menu(t(L10nKey::AppMenuSplitRight), Box::new(SplitRight))
.menu(t(L10nKey::AppMenuSplitDown), Box::new(SplitDown))
@@ -16208,6 +16278,96 @@ mod gpui_tests {
})
.unwrap();
}
/// Quick launch counts an agent each time one starts. Reattaching to a pane
/// whose agent was already running — every agent tab after an app restart
/// — is not a start, and counting it bumped every agent once per launch of
/// the app. Once that pane's agent exits, the next one is a start again.
#[gpui::test]
fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) {
use crate::core::cli_agent::CLIAgent;
crate::core::config::pin_test_config_dir();
let (window, _root_daemon) = harness(cx);
let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let pane = |reattached: bool, cx: &mut TestAppContext| {
let (pane, daemon) = window
.update(cx, |_, window, cx| {
if reattached {
super::quiet_reattached_test_pane(2, window, cx)
} else {
super::quiet_test_pane(3, window, cx)
}
})
.unwrap();
let seen = detected.clone();
cx.update(|cx| {
cx.subscribe(&pane, move |_, ev: &AgentDetected, _| {
seen.borrow_mut().push(ev.0)
})
.detach()
});
(pane, daemon)
};
let report = |agent: Option<CLIAgent>,
at_prompt: bool,
pane: &Entity<TerminalView>,
daemon: &mut Stream,
cx: &mut TestAppContext| {
DaemonMsg::Agent(agent).encode(daemon).unwrap();
for _ in 0..200 {
if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx));
cx.run_until_parked();
};
let (restored, mut restored_daemon) = pane(true, cx);
report(
Some(CLIAgent::Claude),
false,
&restored,
&mut restored_daemon,
cx,
);
report(
Some(CLIAgent::Claude),
false,
&restored,
&mut restored_daemon,
cx,
);
assert_eq!(
*detected.borrow(),
vec![],
"already running when reattached"
);
report(None, true, &restored, &mut restored_daemon, cx);
report(
Some(CLIAgent::Codex),
false,
&restored,
&mut restored_daemon,
cx,
);
assert_eq!(
*detected.borrow(),
vec![CLIAgent::Codex],
"started after the reattach"
);
let (fresh, mut fresh_daemon) = pane(false, cx);
report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx);
report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx);
assert_eq!(
*detected.borrow(),
vec![CLIAgent::Codex, CLIAgent::Claude],
"a spawned pane counts its first agent, once"
);
}
}
/// The window between the shell reporting a prompt and its line editor
+449
View File
@@ -0,0 +1,449 @@
//! Quick launch for the coding agents this machine can run (#955).
//!
//! Launching an agent never types into a pane that already exists. It opens a
//! new shell — a tab in the active tab's directory, or a split when the entry
//! point was asked for one — and types the agent's command line into *that*
//! shell once it is there. The agent then runs the way a hand-typed one does:
//! the daemon detects it from the pane's foreground, status and resume work as
//! usual, and quitting it leaves the shell behind.
//!
//! Which agents are offered: on this computer, those whose launch program is
//! on `PATH`. A remote workspace's `PATH` is not something the `Host` trait can
//! answer — it has no way to run a command or read the far shell's
//! environment — so there the list is the agents seen running in that
//! workspace before (`WindowView::seen_agents`). Either way the order is
//! frecency, recorded the way saved SSH hosts' is.
use std::collections::HashMap;
use std::sync::LazyLock;
use gpui::{App, Axis, Context, Window};
use gpui_component::WindowExt as _;
use crate::core::cli_agent::{CLIAgent, launch_program, program_on_path};
use crate::core::config::{Config, ProfileUsage, unix_now};
use crate::core::session::WorkspaceStore;
use crate::ui::app::{SpawnAs, SpawnWhere, Tty7App, join_shell_args};
use crate::ui::i18n::{L10nKey, t_fmt};
use crate::ui::pane::PaneSlot;
/// What the New Tab menu's "Launch Agent…" row types into the palette: every
/// quick-launch command is titled `Agent: {name}` (the same word in every
/// language we ship), so this lands on the list with the cursor ready to
/// narrow it — the same seam `PALETTE_SSH_QUERY` is for hosts.
pub(crate) const PALETTE_AGENT_QUERY: &str = "agent";
/// How the keymap spells "launch this agent": `LaunchAgent:claude`.
const LAUNCH_ACTION_PREFIX: &str = "LaunchAgent:";
/// One keymap action name per agent, in [`CLIAgent::ALL`] order.
pub(crate) fn launch_action_names() -> &'static [(CLIAgent, String)] {
static NAMES: LazyLock<Vec<(CLIAgent, String)>> = LazyLock::new(|| {
CLIAgent::ALL
.into_iter()
.map(|agent| (agent, format!("{LAUNCH_ACTION_PREFIX}{}", agent.slug())))
.collect()
});
&NAMES
}
pub(crate) fn launch_action_name(agent: CLIAgent) -> &'static str {
launch_action_names()
.iter()
.find(|(a, _)| *a == agent)
.map(|(_, name)| name.as_str())
.expect("every agent has a launch action")
}
pub(crate) fn agent_for_launch_action(action: &str) -> Option<CLIAgent> {
action
.strip_prefix(LAUNCH_ACTION_PREFIX)
.and_then(CLIAgent::from_slug)
}
/// The agents whose launch program is on `path`, in [`CLIAgent::ALL`] order.
/// An agent with an `agent_launch` override is looked up by the override's
/// program, since that is what the launch will run.
pub(crate) fn installed_on(
path: &std::ffi::OsStr,
overrides: &HashMap<String, String>,
) -> Vec<CLIAgent> {
CLIAgent::ALL
.into_iter()
.filter(|agent| {
launch_program(&agent.launch_command(overrides))
.is_some_and(|program| program_on_path(&program, path))
})
.collect()
}
/// `agents` most-used-first by frecency score; agents with the same score
/// keep the order they came in.
pub(crate) fn by_frecency(
agents: impl IntoIterator<Item = CLIAgent>,
usage: &HashMap<String, ProfileUsage>,
now: u64,
) -> Vec<CLIAgent> {
let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0);
let mut agents: Vec<CLIAgent> = agents.into_iter().collect();
agents.sort_by(|a, b| {
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
agents
}
/// The agent "New Agent Tab" opens: of `offered`, the one launched or seen
/// most recently; the first offered when none has been used yet.
pub(crate) fn most_recent(
offered: &[CLIAgent],
usage: &HashMap<String, ProfileUsage>,
) -> Option<CLIAgent> {
let last_used = |agent: &CLIAgent| usage.get(agent.slug()).map_or(0, |u| u.last_used);
offered
.iter()
.copied()
.filter(|agent| last_used(agent) > 0)
// `max_by_key` keeps the last of equals; reversed, that is the first.
.rev()
.max_by_key(last_used)
.or_else(|| offered.first().copied())
}
/// The `agent_launch` line "Set Current Launch Args as Default" writes for a
/// pane that `agent` is running in with `argv`.
pub(crate) fn launch_line_to_save(agent: CLIAgent, argv: &[String]) -> Option<String> {
agent
.launch_argv_for_default(argv)
.map(|argv| join_shell_args(&argv))
}
/// Make `line` the command `agent` is launched with from now on.
pub(crate) fn remember_launch_line(cfg: &mut Config, agent: CLIAgent, line: String) {
// One entry per agent, whatever case an existing key was written in.
cfg.agent_launch
.retain(|slug, _| CLIAgent::from_slug(slug) != Some(agent));
cfg.agent_launch.insert(agent.slug().to_string(), line);
}
/// Type `command` into the shell `slot` holds — now if it is up, or the
/// moment it lands if it is still connecting.
fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) {
match slot {
PaneSlot::Ready(view) => view.read(cx).run_command_line(&command),
PaneSlot::Connecting(pending) => {
pending.update(cx, |pending, _| pending.spawn.run_on_land = Some(command));
}
}
}
impl Tty7App {
/// The agents this window's quick launch offers, most likely first.
pub(crate) fn offered_agents(&self, cx: &App) -> Vec<CLIAgent> {
let cfg = cx.global::<Config>();
let candidates = match WorkspaceStore::all(cx)
.get(self.workspace)
.filter(|view| view.is_remote())
{
Some(view) => view
.seen_agents
.iter()
.filter_map(|slug| CLIAgent::from_slug(slug))
.collect(),
None => installed_on(
&std::env::var_os("PATH").unwrap_or_default(),
&cfg.agent_launch,
),
};
by_frecency(candidates, &cfg.agent_frecency, unix_now())
}
/// Open a new shell for `agent` and start it there.
pub(crate) fn launch_agent(
&mut self,
agent: CLIAgent,
at: SpawnWhere,
window: &mut Window,
cx: &mut Context<Self>,
) {
let command = agent.launch_command(&cx.global::<Config>().agent_launch);
let slot = match at {
SpawnWhere::NewTab => {
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
});
self.new_tab_slot(cwd, None, window, cx)
}
SpawnWhere::Split => {
self.split_slot(Axis::Horizontal, Some(SpawnAs::Shell(None)), window, cx)
}
};
// Nothing opened (the spawn failed, or this workspace cannot host a
// shell right now), and the reason is already on screen. The command
// goes nowhere rather than into whatever pane was focused before.
let Some(slot) = slot else {
log::warn!("no pane opened for {}; not launching it", agent.slug());
return;
};
run_when_ready(&slot, command, cx);
// Recency only: the run is counted when the pane reports the agent
// actually running, so a launch and its detection are one use, not two.
self.update_config(cx, |cfg| {
cfg.agent_frecency
.entry(agent.slug().to_string())
.or_default()
.last_used = unix_now();
});
}
/// "New Agent Tab": launch the agent used most recently.
pub(crate) fn new_agent_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let offered = self.offered_agents(cx);
match most_recent(&offered, &cx.global::<Config>().agent_frecency) {
Some(agent) => self.launch_agent(agent, SpawnWhere::NewTab, window, cx),
None => {
let remote = WorkspaceStore::remote_ref(cx, self.workspace).is_some();
window.push_notification(
crate::ui::i18n::t(if remote {
L10nKey::AppNoAgentSeenHere
} else {
L10nKey::AppNoAgentOnPath
}),
cx,
);
}
}
}
/// A pane in this window started running `agent`.
pub(crate) fn note_agent_detected(&mut self, agent: CLIAgent, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
let entry = cfg
.agent_frecency
.entry(agent.slug().to_string())
.or_default();
entry.count = entry.count.saturating_add(1);
entry.last_used = unix_now();
});
if WorkspaceStore::remote_ref(cx, self.workspace).is_some() {
WorkspaceStore::record_agent_seen(cx, self.workspace, agent);
}
}
/// "Set Current Launch Args as Default": make the focused pane's agent
/// launch the way this one was started.
pub(crate) fn save_agent_launch_args(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(view) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
let view = view.read(cx);
let Some(agent) = view.agent() else {
window.push_notification(crate::ui::i18n::t(L10nKey::AppPaneNoCodingAgent), cx);
return;
};
let name = agent.display_name();
let Some(line) = view
.agent_session()
.and_then(|s| s.launch_argv)
.and_then(|argv| launch_line_to_save(agent, &argv))
else {
window.push_notification(
t_fmt(L10nKey::AppAgentLaunchArgsUnknown, &[("name", name)]),
cx,
);
return;
};
let saved = line.clone();
self.update_config(cx, |cfg| remember_launch_line(cfg, agent, saved));
window.push_notification(
t_fmt(
L10nKey::AppAgentLaunchSaved,
&[("name", name), ("command", &line)],
),
cx,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(pairs: &[(&str, u32, u64)]) -> HashMap<String, ProfileUsage> {
pairs
.iter()
.map(|(slug, count, last_used)| {
(
slug.to_string(),
ProfileUsage {
count: *count,
last_used: *last_used,
},
)
})
.collect()
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
const NOW: u64 = 1_800_000_000;
const DAY: u64 = 86_400;
#[test]
fn agents_are_offered_most_used_first() {
let used = usage(&[
("codex", 3, NOW - DAY),
("claude", 12, NOW - 2 * DAY),
// Used a lot, but so long ago that it has decayed below Codex.
("gemini", 20, NOW - 200 * DAY),
]);
let offered = by_frecency(
[
CLIAgent::Aider,
CLIAgent::Gemini,
CLIAgent::Codex,
CLIAgent::Claude,
CLIAgent::Amp,
],
&used,
NOW,
);
assert_eq!(
offered,
vec![
CLIAgent::Claude,
CLIAgent::Codex,
CLIAgent::Gemini,
// Never used: they keep the order they were found in.
CLIAgent::Aider,
CLIAgent::Amp,
]
);
}
#[test]
fn new_agent_tab_opens_the_most_recently_used_agent() {
let offered = [CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini];
// Claude is used more, but Codex was the last one run or launched.
let used = usage(&[
("claude", 40, NOW - DAY),
("codex", 1, NOW - 60),
("aider", 5, NOW),
]);
assert_eq!(most_recent(&offered, &used), Some(CLIAgent::Codex));
// Aider was more recent still, but it is not offered here.
// Nothing used yet: the first one offered.
assert_eq!(
most_recent(&offered, &HashMap::new()),
Some(CLIAgent::Claude)
);
assert_eq!(most_recent(&[], &used), None);
// A launch that only stamped recency counts as the most recent.
let launched = usage(&[("claude", 40, NOW - DAY), ("gemini", 0, NOW)]);
assert_eq!(most_recent(&offered, &launched), Some(CLIAgent::Gemini));
}
#[test]
fn saved_launch_args_survive_a_round_trip_through_the_command_line() {
let run = argv(&[
"/opt/homebrew/bin/claude",
"--append-system-prompt",
"be terse, don't guess",
"--add-dir",
"/Users/me/My Projects",
"--model",
"opus",
"--resume",
"abc-123",
]);
let line = launch_line_to_save(CLIAgent::Claude, &run).unwrap();
assert_eq!(
crate::ui::app::split_shell_args(&line).unwrap(),
argv(&[
"claude",
"--append-system-prompt",
"be terse, don't guess",
"--add-dir",
"/Users/me/My Projects",
"--model",
"opus",
])
);
// And the saved line is recognised as the agent it launches.
assert_eq!(
CLIAgent::detect_from_command_with(&line, &HashMap::new()),
Some(CLIAgent::Claude)
);
}
#[test]
fn set_current_launch_args_as_default_writes_the_agent_launch_entry() {
let mut cfg = Config::default();
cfg.agent_launch
.insert("Claude".to_string(), "claude --old".to_string());
cfg.agent_launch
.insert("codex".to_string(), "codex --model o3".to_string());
let line = launch_line_to_save(
CLIAgent::Claude,
&argv(&["claude", "--dangerously-skip-permissions", "--continue"]),
)
.unwrap();
remember_launch_line(&mut cfg, CLIAgent::Claude, line);
let json: serde_json::Value = serde_json::to_value(&cfg.0).unwrap();
assert_eq!(
json["agent_launch"],
serde_json::json!({
"claude": "claude --dangerously-skip-permissions",
"codex": "codex --model o3",
})
);
assert_eq!(
CLIAgent::Claude.launch_command(&cfg.agent_launch),
"claude --dangerously-skip-permissions"
);
}
#[test]
fn every_agent_has_a_bindable_launch_action() {
for agent in CLIAgent::ALL {
let name = launch_action_name(agent);
assert_eq!(agent_for_launch_action(name), Some(agent), "{name}");
}
assert_eq!(agent_for_launch_action("LaunchAgent:nobody"), None);
assert_eq!(agent_for_launch_action("NewAgentTab"), None);
}
#[test]
fn only_agents_whose_launch_program_is_on_path_are_offered() {
let dir = tempfile::TempDir::new().unwrap();
for name in ["codex", "cc"] {
let path = dir.path().join(name);
std::fs::write(&path, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let path = dir.path().as_os_str();
assert_eq!(installed_on(path, &HashMap::new()), vec![CLIAgent::Codex]);
// With an override, the override's program is what has to be there.
let overrides: HashMap<String, String> = [
("claude".to_string(), "cc --fast".to_string()),
("codex".to_string(), "codex-nightly".to_string()),
]
.into_iter()
.collect();
assert_eq!(installed_on(path, &overrides), vec![CLIAgent::Claude]);
}
}
+659 -29
View File
@@ -451,6 +451,25 @@ pub struct Tab {
/// the tab no longer holds are dropped as the next one is written, so a
/// closed pane leaves nothing behind either.
focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>,
/// Set while the tab is asleep (#762): its panes were stopped and `pane`
/// is `Pane::Empty`. What is kept instead is what it takes to bring them
/// back — see [`Asleep`].
pub(crate) asleep: Option<Asleep>,
}
/// A sleeping tab: nothing of it runs, and everything a wake needs is here.
///
/// `layout` is the tab as the restore path reads a tab — pane ids, cwds,
/// shells, agent sessions — so waking is that restore, run on one tab: each
/// pane's id asks the daemon for the screen it stored when the pane was
/// stopped, and an agent with a known session is resumed the way a reboot
/// resumes it. `view` is what the tab is called in the meantime, read off
/// the panes as they went to sleep (or off the tree's records, after a
/// restart), since there is no terminal left to ask.
pub(crate) struct Asleep {
pub(crate) layout: SessionPane,
pub(crate) view: tty7_core::core::tab_view::TabView,
pub(crate) home: Option<std::path::PathBuf>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
@@ -477,6 +496,7 @@ impl Tab {
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
asleep: None,
}
}
@@ -500,9 +520,25 @@ impl Tab {
tree_id: std::cell::Cell::new(tree.id),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
asleep: None,
}
}
pub(crate) fn is_asleep(&self) -> bool {
self.asleep.is_some()
}
/// The layout a sleeping tab will wake into; `None` for a tab that is awake.
pub(crate) fn asleep_layout(&self) -> Option<&SessionPane> {
self.asleep.as_ref().map(|a| &a.layout)
}
/// The agent a sleeping tab was running when it went to sleep, so its row
/// still says what it is while nothing in it can.
pub(crate) fn asleep_agent(&self) -> Option<crate::core::cli_agent::CLIAgent> {
self.asleep.as_ref().and_then(|a| a.view.agent)
}
fn focus_target(&self) -> Option<crate::ui::pane::PaneSlot> {
match self.last_focused {
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
@@ -601,6 +637,14 @@ impl Tab {
Option<std::path::PathBuf>,
) {
let name = self.name.clone();
if let Some(asleep) = &self.asleep {
let view = tty7_core::core::tab_view::TabView {
id: self.tree_id.get(),
name,
..asleep.view.clone()
};
return (view, asleep.home.clone());
}
let Some(leaf) = self.title_leaf(window, cx) else {
return (
tty7_core::core::tab_view::TabView {
@@ -628,7 +672,10 @@ impl Tab {
// name tty7 chose for it deliberately, and `stated_title` hands
// those up as the title the pane is showing.
title: crate::terminal::view::DEFAULT_TITLE.to_string(),
osc_title: leaf.stated_title().map(str::to_string),
// On the title's rung, not a new one above it: a name given to the
// tab still beats an SSH host pinned by Settings (#726), and the
// pinned host beats everything the pane could say about itself.
osc_title: leaf.tab_title(cx),
cwd: leaf.cwd().map(|p| p.display().to_string()),
agent: leaf.agent(),
status: leaf.agent_session().map(|s| s.status),
@@ -1874,6 +1921,7 @@ impl Tty7App {
self.tabs = tabs;
self.active = active;
self.maximized = None;
self.wake_active_if_asleep(window, cx);
self.save_session(cx);
crate::ui::windows::refresh_menu(cx);
self.focus_active(window, cx);
@@ -1921,6 +1969,7 @@ impl Tty7App {
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
asleep: None,
},
);
self.active = insert_at;
@@ -3076,7 +3125,7 @@ impl Tty7App {
cx: &mut Context<Self>,
) {
let fields = self.managed_forward_fields(cx);
let Some(rule) = fields.collect() else {
let Some(mut rule) = fields.collect() else {
// Add is disabled while the fields do not make a rule and the form
// already says what is missing, so there is nothing to do here and
// nothing left to explain.
@@ -3084,6 +3133,10 @@ impl Tty7App {
};
let route = self.forward_route(pane_id, cx);
let previous = self.loopback_panel.mf_editing.clone();
// Editing a switched-off rule edits it; it does not switch it on.
if let Some(old) = &previous {
rule.enabled = old.enabled;
}
// A saved edit is a replace, and the rule being replaced has to come
// out first: the ordinary edit keeps the bind port, and the far side
// really does bind it, so adding first would collide with the very
@@ -3187,10 +3240,14 @@ impl Tty7App {
let Some(list) = route.add(rule) else {
return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string());
};
let broken = added_forward(&before, &list).and_then(|added| match &added.status {
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
ForwardStatus::Listening => None,
});
// A switched-off rule reports an error status because it binds
// nothing, and that is what it was asked to do.
let broken = added_forward(&before, &list)
.filter(|added| added.enabled)
.and_then(|added| match &added.status {
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
ForwardStatus::Listening => None,
});
self.loopback_panel.managed = list;
let Some((id, msg)) = broken else {
return PlaceOutcome::Placed;
@@ -3278,6 +3335,84 @@ impl Tty7App {
cx.notify();
}
/// Switch one forward on or off, and carry the choice into the saved host
/// the rule came from, if it came from one — so the next connection opens
/// the same set of rules instead of the ones that were saved before.
pub(crate) fn set_managed_forward_enabled(
&mut self,
pane_id: u64,
forward_id: u64,
enabled: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let route = self.forward_route(pane_id, cx);
let forward = self
.loopback_panel
.managed
.iter()
.find(|m| m.id == forward_id)
.cloned();
match route.set_enabled(forward_id, enabled) {
Ok(list) => {
self.loopback_panel.managed = list;
if let Some(forward) = forward {
self.remember_forward_enabled(pane_id, &forward, enabled, cx);
}
}
Err(e) => {
window.push_notification(
t_fmt(L10nKey::ForwardSwitchFailed, &[("error", &e.to_string())]),
cx,
);
// The refusal changed nothing, but the panel may be behind
// whatever did change — ask rather than guess.
self.loopback_panel.managed = route.list();
}
}
cx.notify();
}
fn remember_forward_enabled(
&mut self,
pane_id: u64,
forward: &crate::daemon::protocol::ManagedForward,
enabled: bool,
cx: &mut Context<Self>,
) {
let profile_id = self
.tabs
.iter()
.flat_map(|tab| tab.pane.terminals())
.find_map(|leaf| {
let view = leaf.read(cx);
(view.pane_id == pane_id).then(|| view.ssh_spec()?.profile_id.clone())?
})
.and_then(|id| uuid::Uuid::parse_str(&id).ok());
let Some(profile_id) = profile_id else {
return;
};
let saved = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == profile_id)
.and_then(|p| crate::ui::forwards::saved_rule_index(&p.forwards, forward, enabled));
let Some(idx) = saved else {
return;
};
self.update_config(cx, |cfg| {
if let Some(rule) = cfg
.ssh_profiles
.iter_mut()
.find(|p| p.id == profile_id)
.and_then(|p| p.forwards.get_mut(idx))
{
rule.enabled = enabled;
}
});
}
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Whatever the panel would let this pane forward, which is a wider set
// than "a connected native-ssh pane": a pane in a remote workspace
@@ -3484,6 +3619,14 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on);
}
pub(crate) fn set_ssh_tab_title(
&mut self,
mode: crate::core::config::SshTabTitle,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.ssh_tab_title = mode);
}
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) {
let next = match cx.global::<Config>().tab_bar_position {
TabBarPosition::Top => TabBarPosition::Left,
@@ -3797,6 +3940,7 @@ impl Tty7App {
spawn.agent_launch_argv.as_deref(),
cx,
)
.or_else(|| spawn.run_on_land.clone())
})
.flatten();
let view = build_terminal_view(parts, font_size, window, cx);
@@ -3911,8 +4055,21 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.new_tab_slot(cwd, shell, window, cx);
}
/// [`Self::new_tab_with_cwd`], answering with the pane it opened — `None`
/// when nothing opened, so a caller about to type into the new pane has
/// nothing to type into rather than whatever was focused before.
pub(crate) fn new_tab_slot(
&mut self,
cwd: Option<std::path::PathBuf>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<PaneSlot> {
if !self.guard_local_spawn(window, cx) {
return;
return None;
}
let group = self.spawn_group(cwd.as_deref(), cx);
let tab = match new_terminal(
@@ -3934,7 +4091,7 @@ impl Tty7App {
self.startup_error = Some(gpui::SharedString::from(text.clone()));
window.push_notification(text, cx);
cx.notify();
return;
return None;
}
};
// Something opened, so whatever the last failure was is stale.
@@ -3942,13 +4099,14 @@ impl Tty7App {
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
let new_tab = Tab::new(Pane::leaf(tab));
let new_tab = Tab::new(Pane::leaf(tab.clone()));
group.seat(&new_tab);
self.tabs.insert(insert_at, new_tab);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
Some(tab)
}
pub(crate) fn open_native_ssh_tab(
@@ -4032,15 +4190,24 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target) = self
self.split_slot(axis, spawn, window, cx);
}
/// [`Self::split_into`], answering with the pane it placed, or `None`
/// when nothing was placed.
pub(crate) fn split_slot(
&mut self,
axis: Axis,
spawn: Option<SpawnAs>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<PaneSlot> {
let target = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
.and_then(|t| t.pane.focused_or_first(window, cx))?;
if !self.guard_local_spawn(window, cx) {
return;
return None;
}
let cwd = target.read(cx).spawnable_cwd();
let spawn = match spawn {
@@ -4068,7 +4235,7 @@ impl Tty7App {
),
cx,
);
return;
return None;
}
}
}
@@ -4090,22 +4257,23 @@ impl Tty7App {
t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]),
cx,
);
return;
return None;
}
}
}
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab
.pane
.split_leaf(target.entity_id(), axis, false, new.clone())
{
self.maximized = None;
self.focus_leaf(&new, window, cx);
self.save_session(cx);
cx.notify();
}
let tab = self.tabs.get_mut(self.active)?;
if !tab
.pane
.split_leaf(target.entity_id(), axis, false, new.clone())
{
return None;
}
self.maximized = None;
self.focus_leaf(&new, window, cx);
self.save_session(cx);
cx.notify();
Some(new)
}
fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -4721,6 +4889,13 @@ impl Tty7App {
}
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
// A tab is woken by being looked at, the way a browser reloads a
// discarded tab when it is selected: a tab on screen is always a
// running one, so there is no third state to draw. A wake that fails
// leaves the tab asleep and the window where it was.
if self.tabs.get(index).is_some_and(Tab::is_asleep) && !self.wake_tab(index, window, cx) {
return;
}
if index < self.tabs.len() && index != self.active {
self.remember_active_pane(window, cx);
// Zoom rides with its tab (#599): stash the outgoing tab's zoom
@@ -4753,6 +4928,146 @@ impl Tty7App {
}
}
/// Whether tab `index` can be put to sleep right now (#762).
///
/// Not while it is connecting: a pane still on its way has no id the
/// machine knows, so it would be lost rather than stopped. Not with a
/// native SSH pane in a remote window either — that pane runs on this
/// computer, beyond the reach of the machine asked to stop the tab. Not on
/// a machine whose server cannot do it. And not the one tab left awake:
/// the window has to have something on screen, and looking at a tab is
/// what wakes it.
pub(crate) fn can_hibernate_tab(&self, index: usize, cx: &App) -> bool {
let Some(tab) = self.tabs.get(index) else {
return false;
};
if tab.is_asleep() || !crate::ui::tree_sync::can_hibernate_on(cx, self.workspace) {
return false;
}
let remote = WorkspaceStore::all(cx)
.get(self.workspace)
.is_some_and(|w| w.is_remote());
let leaves = tab.pane.leaves();
let all_hibernatable = !leaves.is_empty()
&& leaves.iter().all(|leaf| match leaf {
PaneSlot::Ready(view) => !(remote && view.read(cx).ssh_spec().is_some()),
PaneSlot::Connecting(_) => false,
});
all_hibernatable && self.awake_tab_besides(index).is_some()
}
/// The tab to show instead of `index` when that one goes to sleep: the
/// awake tab used most recently, the nearest one on a tie.
fn awake_tab_besides(&self, index: usize) -> Option<usize> {
(0..self.tabs.len())
.filter(|&i| i != index && !self.tabs[i].is_asleep())
.max_by_key(|&i| {
(
self.tabs[i].last_used.get(),
std::cmp::Reverse(i.abs_diff(index)),
)
})
}
/// Put tab `index` to sleep: stop its panes, keep its place (#762).
///
/// The window lets go of the panes first and the machine stops them after,
/// from the sync this ends with. That order is what keeps a stopped pane
/// from reading as one that died: by the time its shell exits there is no
/// view left here to take the exit as a reason to close the pane, or the
/// tab with it. The machine, for its part, marks the tab before it stops
/// anything, so a restore reading the tree in between never takes the
/// panes for dead ones and spawns them straight back.
pub(crate) fn hibernate_tab(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.can_hibernate_tab(index, cx) {
return;
}
if index == self.active {
let Some(next) = self.awake_tab_besides(index) else {
return;
};
self.activate(next, window, cx);
}
self.put_to_sleep(index, window, cx);
}
/// The part of [`Self::hibernate_tab`] that does not ask whether it may:
/// the tab lets go of its panes and keeps what a wake needs instead.
fn put_to_sleep(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.tabs.get(index).filter(|t| !t.is_asleep()) else {
return;
};
let (mut view, home) = tab.label_view(Some(window), cx);
view.live = false;
view.status = None;
let layout = pane_to_session(&tab.pane, cx);
let tab = &mut self.tabs[index];
let panes = std::mem::replace(&mut tab.pane, Pane::Empty);
tab.zoomed = None;
tab.last_focused = None;
tab.focus_origin.clear();
tab.asleep = Some(Asleep { layout, view, home });
drop(panes);
self.save_session(cx);
cx.notify();
}
/// Bring a sleeping tab back, through the restore every pane takes after a
/// reboot: each pane is spawned in its old one's place, opens on the screen
/// that one left behind, and resumes its agent session when it had one.
/// Returns whether the tab is awake now; a tab none of whose panes could
/// be started stays asleep, and says so.
pub(crate) fn wake_tab(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(asleep) = self.tabs.get_mut(index).and_then(|t| t.asleep.take()) else {
return true;
};
let pane_ws = self.window_workspace(cx);
let layout = redial_native_ssh(&asleep.layout);
// No listing: every pane in here was stopped on purpose, so the attach
// each one tries first fails and falls through to the fresh spawn —
// unless the stop never landed, in which case attaching to the pane
// that is still running is exactly right.
let pane = session_to_pane(
pane_ws.as_ref(),
self.workspace,
&layout,
None,
self.font_size,
window,
cx,
);
let tab = &mut self.tabs[index];
let Some(pane) = pane else {
tab.asleep = Some(asleep);
window.push_notification(t(L10nKey::TabWakeFailed), cx);
return false;
};
tab.pane = pane;
tab.last_focused = None;
self.save_session(cx);
cx.notify();
true
}
/// Wake the tab on screen if it is asleep — for the paths that land on a
/// tab without going through [`Self::activate`]: a neighbour taking over
/// from a closed tab, a window restored onto a sleeping one.
pub(crate) fn wake_active_if_asleep(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.tabs.get(self.active).is_some_and(Tab::is_asleep) {
self.wake_tab(self.active, window, cx);
}
}
/// Whether tab `index` has a pane zoomed over hidden siblings — what the
/// chrome marks so the state is readable without toggling it (#752).
///
@@ -4857,6 +5172,7 @@ impl Tty7App {
} else if index < self.active {
self.active -= 1;
}
self.wake_active_if_asleep(window, cx);
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
@@ -5437,6 +5753,31 @@ impl Tty7App {
},
);
// Every shell the window's machine has, named alike in every language
// so the New Tab menu's "Other Shells…" row can land on exactly these
// by typing one word. Listed in the order that menu uses.
{
let default_shell = self.default_shell_label(cx);
let usage = &cx.global::<Config>().shell_frecency;
let now = crate::core::config::unix_now();
for s in crate::ui::tab_strip::shells_by_frecency(
&self.shells.shells,
&default_shell,
usage,
now,
) {
let mut cmd = Command::new(
t_fmt(L10nKey::AppCmdShellTitle, &[("title", &s.label)]),
CommandKind::OpenShell(s.label.clone()),
)
.in_group(CommandGroup::TabsPanes);
if s.label == default_shell {
cmd = cmd.with_subtitle(t(L10nKey::ShellDefault));
}
commands.push(cmd);
}
}
// Offered only where it would do something. A connection opened from a
// saved host has nothing to save, and a pane that is not an SSH one has
// no connection at all — either would be a row that quietly did nothing
@@ -5495,6 +5836,20 @@ impl Tty7App {
);
}
for agent in self.offered_agents(cx) {
commands.push(
Command::new(
t_fmt(
L10nKey::AppCmdAgentLaunchTitle,
&[("name", agent.display_name())],
),
CommandKind::LaunchAgent(agent),
)
.with_subtitle(agent.launch_command(&cx.global::<Config>().agent_launch))
.in_group(CommandGroup::Agents),
);
}
for (i, tab) in self.tabs.iter().enumerate() {
if i == self.active {
continue;
@@ -5678,7 +6033,15 @@ impl Tty7App {
CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx),
CopyWorkingDirectory => self.copy_active_cwd(window, cx),
MarkTabUnread => self.mark_tab_unread(self.active, cx),
HibernateTab => self.hibernate_tab(self.active, window, cx),
ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx),
NewAgentTab => self.new_agent_tab(window, cx),
// Picked from the palette with ⌥ held, the way a New Tab menu row
// is: a split beside the focused pane instead of a tab.
LaunchAgent(agent) => {
let at = SpawnWhere::from_modifiers(window.modifiers());
self.launch_agent(agent, at, window, cx)
}
CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx),
RenameWorkspace => self.start_workspace_rename(window, cx),
OpenSettings => self.toggle_settings(window, cx),
@@ -5720,6 +6083,11 @@ impl Tty7App {
}
OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx),
ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx),
// ⌥ held as the row is taken splits, as it does on the menu's row.
OpenShell(label) => {
let at = SpawnWhere::from_modifiers(window.modifiers());
self.open_listed_shell(&label, at, window, cx)
}
EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx),
QuickConnect(target) => {
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
@@ -5864,6 +6232,7 @@ impl Tty7App {
L10nKey::SettingsDiffPreviewFromCounts => {
self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx)
}
L10nKey::SettingsSshTabTitle => self.set_ssh_tab_title(defaults.ssh_tab_title, cx),
L10nKey::SettingsNotifyOnCommandFinish => {
self.set_notify_mode(defaults.notify_on_command_finish, cx)
}
@@ -8266,6 +8635,31 @@ impl ForwardRoute {
}
}
pub(crate) fn set_enabled(
&self,
forward_id: u64,
enabled: bool,
) -> anyhow::Result<Vec<crate::daemon::protocol::ManagedForward>> {
let Some(req) =
self.workspace_op(crate::daemon::protocol::WorkspaceOp::SetForwardEnabled {
forward_id,
enabled,
})
else {
return crate::terminal::RemoteTerminal::set_forward_enabled(
self.pane_id,
forward_id,
enabled,
);
};
match crate::terminal::RemoteTerminal::on_workspace(req)? {
crate::daemon::protocol::DaemonMsg::ForwardList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to SetForwardEnabled: {other:?}"
)),
}
}
pub(crate) fn remove(
&self,
forward_id: u64,
@@ -8976,6 +9370,9 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| {
this.mark_tab_unread(this.active, cx)
}))
.on_action(cx.listener(|this, _: &HibernateTab, window, cx| {
this.hibernate_tab(this.active, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| {
this.fork_active_pane_session(ForkPlacement::NewTab, window, cx)
}))
@@ -8994,6 +9391,15 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| {
this.copy_agent_session_id(this.active, window, cx)
}))
.on_action(
cx.listener(|this, _: &NewAgentTab, window, cx| this.new_agent_tab(window, cx)),
)
.on_action(cx.listener(|this, action: &LaunchAgent, window, cx| {
this.launch_agent(action.agent, SpawnWhere::NewTab, window, cx)
}))
.on_action(cx.listener(|this, _: &SaveAgentLaunchArgs, window, cx| {
this.save_agent_launch_args(window, cx)
}))
.on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| {
this.open_settings_section(SettingsSection::Keybindings, window, cx)
}))
@@ -9084,10 +9490,15 @@ fn mru_order(stamps: &[u64], active: usize) -> Vec<usize> {
fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
SessionTab {
name: tab.name.clone(),
pane: pane_to_session(&tab.pane, cx),
pane: match tab.asleep_layout() {
Some(layout) => layout.clone(),
None => pane_to_session(&tab.pane, cx),
},
group: tab.group.get(),
last_auto: tab.auto_group.borrow().clone(),
tree_id: None,
hibernated: false,
asleep_view: None,
}
}
@@ -9262,7 +9673,19 @@ fn tabs_from_session(
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace));
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
let mut dropped = 0usize;
for st in &session.tabs {
let home = crate::ui::path_display::home_for_host(
cx,
workspace.map_or(HostId::LOCAL, |w| w.target.host_id()),
);
for (index, st) in session.tabs.iter().enumerate() {
// Asleep stays asleep: nothing is spawned or attached for it, which is
// the point. The one exception is the tab the window opens onto — a
// tab on screen is awake, so that one is woken here, by the same
// restore every other tab is getting.
if st.hibernated && index != session.active {
tabs.push(asleep_tab(st, home.clone()));
continue;
}
let Some(pane) = session_to_pane(
workspace,
owner,
@@ -9298,12 +9721,97 @@ fn tabs_from_session(
),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
asleep: None,
});
}
let active = session.active.min(tabs.len().saturating_sub(1));
(tabs, active, dropped)
}
/// A tab restored asleep: its place, its name and its group, and nothing
/// running behind them.
fn asleep_tab(st: &SessionTab, home: Option<std::path::PathBuf>) -> Tab {
let mut tab = Tab::new(Pane::Empty);
tab.name = st.name.clone();
tab.group.set(st.group);
*tab.auto_group.borrow_mut() = st.last_auto.clone();
if let Some(id) = st.tree_id {
tab.tree_id.set(id);
}
tab.asleep = Some(Asleep {
layout: st.pane.clone(),
view: st
.asleep_view
.clone()
.unwrap_or_else(|| view_of_layout(tab.tree_id.get(), &st.pane)),
home,
});
tab
}
/// What to call a sleeping tab when the tree had nothing better to say: the
/// first directory in its layout, and the agent in it if there was one.
fn view_of_layout(
id: tty7_core::core::machine::TabId,
layout: &SessionPane,
) -> tty7_core::core::tab_view::TabView {
fn leaves<'a>(pane: &'a SessionPane, out: &mut Vec<&'a SessionPane>) {
match pane {
SessionPane::Leaf { .. } => out.push(pane),
SessionPane::Split { a, b, .. } => {
leaves(a, out);
leaves(b, out);
}
}
}
let mut all = Vec::new();
leaves(layout, &mut all);
let cwd = all.iter().find_map(|leaf| match leaf {
SessionPane::Leaf { cwd, .. } => cwd.as_ref().map(|p| p.display().to_string()),
SessionPane::Split { .. } => None,
});
let agent = all.iter().find_map(|leaf| match leaf {
SessionPane::Leaf { agent, .. } => *agent,
SessionPane::Split { .. } => None,
});
tty7_core::core::tab_view::TabView {
id,
name: None,
title: String::new(),
osc_title: None,
cwd,
agent,
status: None,
live: false,
panes: all.len(),
}
}
/// A sleeping layout as a wake should read it: a native SSH pane dials its
/// host again rather than standing on its old id. That id belongs to a pane
/// whose connection is gone, and the restore reading it would put a local
/// shell in its place.
fn redial_native_ssh(layout: &SessionPane) -> SessionPane {
match layout {
SessionPane::Leaf {
ssh_spec: Some(_), ..
} => {
let mut leaf = layout.clone();
if let SessionPane::Leaf { pane_id, .. } = &mut leaf {
*pane_id = None;
}
leaf
}
SessionPane::Leaf { .. } => layout.clone(),
SessionPane::Split { axis, ratio, a, b } => SessionPane::Split {
axis: *axis,
ratio: *ratio,
a: Box::new(redial_native_ssh(a)),
b: Box::new(redial_native_ssh(b)),
},
}
}
fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool {
!(window_is_remote && leaf_is_native_ssh)
}
@@ -9434,6 +9942,7 @@ pub(crate) fn new_terminal(
agent: None,
agent_session_id: None,
agent_launch_argv: None,
run_on_land: None,
owner,
font_size,
};
@@ -9513,6 +10022,14 @@ fn build_terminal_view(
},
)
.detach();
cx.subscribe_in(
&view,
window,
|app, _view, ev: &crate::terminal::view::AgentDetected, _window, cx| {
app.note_agent_detected(ev.0, cx);
},
)
.detach();
cx.subscribe_in(
&view,
window,
@@ -10643,6 +11160,7 @@ mod tests {
agent: Some(CLIAgent::Claude),
agent_session_id: Some("sid-abc".to_string()),
agent_launch_argv: Some(vec!["claude".to_string()]),
run_on_land: None,
owner: None,
font_size: 14.0,
},
@@ -11096,6 +11614,7 @@ mod ssh_rebuild_gpui_tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
};
app.apply_layout_delta(
&LayoutDelta::TabRestructured { tab, pane: None },
@@ -11900,6 +12419,7 @@ mod managed_forward_gpui_tests {
target_port: 80,
description: None,
status: ForwardStatus::Listening,
enabled: true,
}
}
@@ -12259,6 +12779,7 @@ mod tab_focus_memory_tests {
agent: None,
agent_session_id: None,
agent_launch_argv: None,
run_on_land: None,
owner: None,
font_size: 14.,
},
@@ -12607,3 +13128,112 @@ mod tab_focus_memory_tests {
});
}
}
#[cfg(test)]
mod hibernate_gpui_tests {
use gpui::TestAppContext;
use crate::core::session::SessionPane;
use crate::ui::app::test_window::harness_with_tabs;
use crate::ui::pane::Pane;
fn leaf_pane_id(pane: &SessionPane) -> Option<u64> {
match pane {
SessionPane::Leaf { pane_id, .. } => *pane_id,
SessionPane::Split { .. } => None,
}
}
/// Asleep, a tab holds nothing running and still holds its place: the
/// pane id the wake restores from, the name it had on screen, and a seat in
/// the sync that tells the machine it is asleep rather than gone.
#[gpui::test]
fn a_sleeping_tab_keeps_its_place_its_pane_and_its_name(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
app.update_in(&mut vcx, |app, window, cx| {
let pane_id = app.tabs[1]
.pane
.first_leaf()
.and_then(|slot| slot.terminal().cloned())
.expect("tab 1 has a pane")
.read(cx)
.pane_id;
let (before, _) = app.tabs[1].label_view(Some(window), cx);
app.put_to_sleep(1, window, cx);
assert_eq!(app.tabs.len(), 2, "sleeping is not closing");
let tab = &app.tabs[1];
assert!(tab.is_asleep());
assert!(matches!(tab.pane, Pane::Empty));
assert!(tab.pane.terminals().is_empty(), "nothing is left running");
assert_eq!(tab.asleep_layout().and_then(leaf_pane_id), Some(pane_id));
let (after, _) = tab.label_view(Some(window), cx);
assert_eq!(
format!("{:?}", after.label()),
format!("{:?}", before.label()),
"it is called what it was called while awake"
);
assert!(!after.live);
let (desired, _, held) = crate::ui::tree_sync::desired_tabs(app, cx);
assert!(held.is_empty(), "a sleeping tab is not a tab gone missing");
assert_eq!(desired.len(), 2);
assert!(!desired[0].hibernated);
assert!(desired[1].hibernated);
match &desired[1].root {
crate::ui::tree_sync::DesiredNode::Leaf { pane, .. } => assert_eq!(*pane, pane_id),
other => panic!("expected the one leaf, got {other:?}"),
}
});
}
/// Looking at a tab is what wakes it, so the window has to keep one awake
/// to look at; the tab shown in a sleeping one's place is the awake tab
/// used last.
#[gpui::test]
fn the_last_awake_tab_has_nothing_to_hand_the_window_to(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
app.update_in(&mut vcx, |app, window, cx| {
app.tabs[1].last_used.set(5);
app.tabs[2].last_used.set(9);
assert_eq!(app.awake_tab_besides(0), Some(2), "the one used last");
app.put_to_sleep(2, window, cx);
assert_eq!(app.awake_tab_besides(0), Some(1));
app.put_to_sleep(1, window, cx);
assert_eq!(app.awake_tab_besides(0), None);
assert!(
!app.can_hibernate_tab(0, cx),
"the last awake tab stays awake"
);
assert!(
!app.can_hibernate_tab(1, cx),
"nor is a sleeping tab put to sleep twice"
);
});
}
/// Closing a sleeping tab closes it like any other, and what Reopen Closed
/// Tab gets back is the layout it slept with — not the empty stand-in.
#[gpui::test]
fn a_sleeping_tab_closes_into_what_it_would_have_woken_as(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
app.update_in(&mut vcx, |app, window, cx| {
app.put_to_sleep(1, window, cx);
let layout_id = app.tabs[1].asleep_layout().and_then(leaf_pane_id);
assert!(layout_id.is_some());
app.close_tab(1, window, cx);
assert_eq!(app.tabs.len(), 1);
let closed = app.closed.last().expect("the closed tab is remembered");
assert_eq!(leaf_pane_id(&closed.pane), layout_id);
assert!(!closed.hibernated, "reopening brings it back awake");
});
}
}
+5
View File
@@ -48,6 +48,9 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"),
"icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"),
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
"icons/power.svg" => include_bytes!("../../assets/icons/power.svg"),
"icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"),
"icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"),
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
"icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
@@ -115,6 +118,8 @@ mod tests {
"icons/git-branch.svg",
"icons/git-sync.svg",
"icons/git-commit.svg",
"icons/list-tree.svg",
"icons/list-flat.svg",
] {
assert!(
Assets.load(path).unwrap().is_some(),
+1
View File
@@ -3363,6 +3363,7 @@ mod selection_gpui_tests {
agent: None,
agent_session_id: None,
agent_launch_argv: None,
run_on_land: None,
owner: None,
font_size: 14.0,
},
+1
View File
@@ -569,6 +569,7 @@ mod gpui_tests {
agent: None,
agent_session_id: None,
agent_launch_argv: None,
run_on_land: None,
owner: None,
font_size: 14.0,
},
+106 -5
View File
@@ -3,6 +3,7 @@ use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
use crate::core::ssh_profile::ForwardRule;
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule};
use crate::terminal::view::TerminalView;
use crate::ui::app::{CONTENT_INSET, Tty7App};
@@ -70,6 +71,7 @@ impl ForwardFields {
target_host,
target_port,
description: (!description.is_empty()).then(|| description.to_string()),
enabled: true,
})
}
@@ -93,6 +95,7 @@ impl ForwardFields {
// left over from a trip through the advanced form is not something
// this rule was given.
description: None,
enabled: true,
})
}
@@ -140,9 +143,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule {
target_host: forward.target_host.clone(),
target_port: forward.target_port,
description: forward.description.clone(),
enabled: forward.enabled,
}
}
/// Which of a saved host's rules a live forward was opened from, among
/// those not already switched to `enabled` — the one a switch in the panel
/// has to be written back to.
///
/// Matched on the whole mapping, since a live forward carries no reference to
/// the rule it came from. A saved rule always names its port, so the port the
/// forward bound is the one the rule asked for. A forward added in the panel
/// matches nothing and stays as temporary as it always was.
pub(crate) fn saved_rule_index(
rules: &[ForwardRule],
forward: &ManagedForward,
enabled: bool,
) -> Option<usize> {
rules.iter().position(|r| {
let saved = crate::ui::ssh_connect::map_forward(r);
saved.enabled != enabled
&& saved.kind == forward.kind
&& saved.bind_host == forward.bind_host
&& saved.bind_port == forward.bind_port
&& saved.target_host == forward.target_host
&& saved.target_port == forward.target_port
})
}
impl Tty7App {
pub(crate) fn render_ssh_status_strip(
&self,
@@ -254,18 +282,23 @@ impl Tty7App {
SshForwardKind::Remote => "R",
SshForwardKind::Dynamic => "D",
};
let errored = matches!(forward.status, ForwardStatus::Error(_));
// A switched-off forward has an error status only because it binds
// nothing; it is drawn faded, not red.
let off = !forward.enabled;
let errored = !off && matches!(forward.status, ForwardStatus::Error(_));
let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") {
forward.bind_port.to_string()
} else {
format!("{}:{}", forward.bind_host, forward.bind_port)
};
let mapping = match forward.kind {
SshForwardKind::Dynamic => "SOCKS".to_string(),
_ => format!("→ {}:{}", forward.target_host, forward.target_port),
};
let tail = match &forward.status {
_ if off => mapping,
ForwardStatus::Error(msg) => msg.clone(),
ForwardStatus::Listening => match forward.kind {
SshForwardKind::Dynamic => "SOCKS".to_string(),
_ => format!("→ {}:{}", forward.target_host, forward.target_port),
},
ForwardStatus::Listening => mapping,
};
let pane_id = forward.pane_id;
let forward_id = forward.id;
@@ -295,6 +328,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.gap(px(1.))
.when(off, |col| col.opacity(0.5))
.child(
h_flex()
.items_center()
@@ -326,6 +360,33 @@ impl Tty7App {
)
}),
)
// Always shown, unlike Remove beside it: whether a forward is on is
// state to read at a glance, not an action to go looking for.
.child(
div()
.flex_shrink_0()
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
crate::ui::theme::switch(
("panel-forward-enabled", forward_id as usize),
cx,
)
.checked(forward.enabled)
.xsmall()
.tooltip(if off {
t(L10nKey::ForwardTooltipTurnOn)
} else {
t(L10nKey::ForwardTooltipTurnOff)
})
.on_click(cx.listener(
move |this, on: &bool, window, cx| {
this.set_managed_forward_enabled(
pane_id, forward_id, *on, window, cx,
)
},
)),
),
)
.child(
div()
.flex_shrink_0()
@@ -586,6 +647,7 @@ mod tests {
target_port: 80,
description: Some("the staging box".to_string()),
status: ForwardStatus::Listening,
enabled: true,
}
}
@@ -718,4 +780,43 @@ mod tests {
"nothing was added, so there is nothing to point at"
);
}
#[test]
fn a_switched_off_forward_keeps_its_switch_through_an_edit() {
let mut off = managed(3, 8080);
off.enabled = false;
assert!(!rule_of(&off).enabled);
assert!(rule_of(&managed(3, 8080)).enabled);
}
/// The panel's switch is written back to the saved host only for the rule
/// the forward was opened from, and a rule added in the panel — which no
/// saved rule describes — is left as temporary as it always was.
#[test]
fn a_switch_is_written_back_to_the_saved_rule_it_came_from() {
use crate::core::ssh_profile::{ForwardKind, HostPort};
let saved = |target: &str, enabled: bool| ForwardRule {
kind: ForwardKind::Local,
bind: HostPort::new("127.0.0.1", 8080),
target: HostPort::new(target, 80),
description: "the staging box".to_string(),
enabled,
};
let rules = vec![saved("10.0.0.9", true), saved("10.0.0.5", true)];
let live = managed(3, 8080);
assert_eq!(saved_rule_index(&rules, &live, false), Some(1));
assert_eq!(
saved_rule_index(&rules, &live, true),
None,
"already on: nothing to write"
);
let mut elsewhere = managed(3, 8081);
elsewhere.description = None;
assert_eq!(saved_rule_index(&rules, &elsewhere, false), None);
let mut remote = managed(3, 8080);
remote.kind = SshForwardKind::Remote;
assert_eq!(saved_rule_index(&rules, &remote, false), None);
}
}
+14
View File
@@ -496,6 +496,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("build"));
@@ -508,6 +510,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
@@ -517,6 +521,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
@@ -529,6 +535,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: SessionPane::Split {
axis: crate::core::session::SessionAxis::Horizontal,
ratio: 0.5,
@@ -546,6 +554,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(None),
};
assert_eq!(closed_tab_label(&unnamed), None);
@@ -554,6 +564,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(Some("/")),
};
assert_eq!(closed_tab_label(&root), None);
@@ -566,6 +578,8 @@ mod tests {
tree_id: None,
group: None,
last_auto: None,
hibernated: false,
asleep_view: None,
pane: leaf(None),
};
let label = closed_tab_label(&tab).unwrap();
+41
View File
@@ -620,6 +620,13 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable."
}
L10nKey::SettingsSshTabTitle => "SSH tab title",
L10nKey::SettingsSshTabTitleDesc => {
"What an SSH tab is called. Dynamic follows the title the remote side sets; the other two pin the tab to the host. A tab you renamed keeps its name."
}
L10nKey::SettingsSshTabTitleDynamic => "Dynamic",
L10nKey::SettingsSshTabTitleProfileName => "Profile name",
L10nKey::SettingsSshTabTitleHostname => "Hostname",
L10nKey::DocumentDock => "Dock beside terminal",
L10nKey::DocumentFill => "Fill window",
L10nKey::SettingsNotifications => "Notifications",
@@ -940,6 +947,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"tabs group grouping auto repo repository git ssh host pinned pin ungrouped header sidebar flat folder"
}
L10nKey::SettingsSearchSshTabTitleKeywords => {
"ssh tab title name host hostname profile alias pin fixed osc remote"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"double click word url path select semantic bracket email"
}
@@ -1019,6 +1029,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SshEditProfile => "Edit connection…",
L10nKey::ForwardTooltipAdd => "Add forward",
L10nKey::ForwardTooltipRemove => "Remove",
L10nKey::ForwardTooltipTurnOn => "Turn on",
L10nKey::ForwardTooltipTurnOff => "Turn off — the rule is kept",
L10nKey::ForwardSwitchFailed => "Couldn't switch the forward — {error}",
L10nKey::SettingsFwdEnabled => "Open this rule with the connection",
L10nKey::ForwardLocal => "Local",
L10nKey::ForwardRemote => "Remote",
L10nKey::ForwardDynamic => "Dynamic",
@@ -1214,6 +1228,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::ScmShowMore => "Show more",
L10nKey::ScmShowLess => "Show less",
L10nKey::ScmCommitNotFound => "This commit is not in this repository.",
L10nKey::ScmFilterChanges => "Filter changed files…",
L10nKey::ScmViewAsTree => "View as Tree",
L10nKey::ScmViewAsList => "View as List",
L10nKey::ScmNoMatchingChanges => "No changed files match the filter.",
L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.",
L10nKey::ScmOpenChanges => "Open Changes",
L10nKey::ScmDiscardAllConfirm => {
@@ -1509,9 +1527,15 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory",
L10nKey::CmdCopySessionId => "Copy Session ID",
L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id",
L10nKey::CmdNewAgentTab => "New Agent Tab",
L10nKey::CmdNewAgentTabSubtitle => "open the coding agent you used last in a new tab",
L10nKey::CmdForkSession => "Fork Session",
L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab",
L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread",
L10nKey::CmdHibernateTab => "Hibernate Tab",
L10nKey::CmdHibernateTabSubtitle => {
"stop its processes to free memory; selecting it wakes it"
}
L10nKey::CmdClosePaneTab => "Close Pane / Tab",
L10nKey::CmdCloseWindow => "Close Window",
L10nKey::CmdCloseWindowSubtitle => "shells keep running",
@@ -1670,6 +1694,16 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"No uncommitted changes in {cwd} (or not a git repository)."
}
L10nKey::AppCmdSshProfileTitle => "SSH: {title}",
L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}",
L10nKey::AppNoAgentOnPath => "No coding agent was found on this machine's PATH",
L10nKey::AppNoAgentSeenHere => {
"No coding agent has run in this workspace yet — start one by hand once and it will be offered here"
}
L10nKey::AppAgentLaunchSaved => "{name} will now launch as: {command}",
L10nKey::AppAgentLaunchArgsUnknown => {
"{name} did not report the arguments it was started with"
}
L10nKey::AppCmdShellTitle => "Shell: {title}",
L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}",
L10nKey::AppPlaceholderDescription => "description",
L10nKey::AppPlaceholderSshQuickConnect => "user@host or user@host:port",
@@ -1828,6 +1862,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory",
L10nKey::AppMenuCopySessionId => "Copy Session ID",
L10nKey::AppMenuForkSession => "Fork Session",
L10nKey::AppMenuSaveAgentLaunchArgs => "Set Current Launch Args as Default",
L10nKey::AppMenuClosePaneTab => "Close",
L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs",
L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right",
@@ -1886,6 +1921,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::TabMenuLocalShells => "Local",
L10nKey::TabMenuAddHost => "Add SSH Host…",
L10nKey::TabMenuAllHosts => "All SSH Hosts…",
L10nKey::TabMenuLaunchAgent => "Launch Agent…",
L10nKey::TabMenuOtherShells => "Other Shells…",
L10nKey::TabMenuSplitHint => "Hold {key} to split",
L10nKey::TabUnnamedShell => "Shell {n}",
L10nKey::ShellDefault => "default",
@@ -1912,6 +1949,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsNoneLower => "none",
L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool",
L10nKey::TabContextMarkUnread => "Mark as Unread",
L10nKey::TabContextHibernate => "Hibernate",
L10nKey::TabContextWake => "Wake",
L10nKey::TabTooltipAsleep => "Hibernated — select to wake",
L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started",
}
}
+37
View File
@@ -628,6 +628,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません"
}
L10nKey::SettingsSshTabTitle => "SSH タブのタイトル",
L10nKey::SettingsSshTabTitleDesc => {
"SSH タブに表示する名前。「動的」はリモート側が設定したタイトルに従い、ほかの二つはタブをホストに固定します。名前を変更したタブはその名前のまま"
}
L10nKey::SettingsSshTabTitleDynamic => "動的",
L10nKey::SettingsSshTabTitleProfileName => "プロファイル名",
L10nKey::SettingsSshTabTitleHostname => "ホスト名",
L10nKey::DocumentDock => "ターミナルの隣にドック",
L10nKey::DocumentFill => "ウィンドウ全体",
L10nKey::SettingsNotifications => "通知",
@@ -1002,6 +1009,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"タブ グループ 自動 リポジトリ git ssh ホスト 固定 未分類 サイドバー sidebar auto grouping tabs repo repository pinned pin host ungrouped"
}
L10nKey::SettingsSearchSshTabTitleKeywords => {
"SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email"
}
@@ -1085,6 +1095,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SshEditProfile => "接続を編集…",
L10nKey::ForwardTooltipAdd => "フォワードを追加",
L10nKey::ForwardTooltipRemove => "削除",
L10nKey::ForwardTooltipTurnOn => "オンにする",
L10nKey::ForwardTooltipTurnOff => "オフにする — ルールは残ります",
L10nKey::ForwardSwitchFailed => "フォワードを切り替えられませんでした — {error}",
L10nKey::SettingsFwdEnabled => "接続時にこのルールを開く",
L10nKey::ForwardLocal => "ローカル",
L10nKey::ForwardRemote => "リモート",
L10nKey::ForwardDynamic => "ダイナミック",
@@ -1288,6 +1302,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "続きを表示",
L10nKey::ScmShowLess => "折りたたむ",
L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。",
L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…",
L10nKey::ScmViewAsTree => "ツリーで表示",
L10nKey::ScmViewAsList => "リストで表示",
L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。",
L10nKey::ScmTooManyChanges => {
"変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。"
}
@@ -1573,9 +1591,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー",
L10nKey::CmdCopySessionId => "セッション ID をコピー",
L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID",
L10nKey::CmdNewAgentTab => "新しいエージェントタブ",
L10nKey::CmdNewAgentTabSubtitle => "最後に使ったコーディングエージェントを新しいタブで開く",
L10nKey::CmdForkSession => "セッションをフォーク",
L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク",
L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク",
L10nKey::CmdHibernateTab => "タブを休止",
L10nKey::CmdHibernateTabSubtitle => "プロセスを停止してメモリを解放。選択すると復帰",
L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる",
L10nKey::CmdCloseWindow => "ウィンドウを閉じる",
L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続",
@@ -1742,6 +1764,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"{cwd} に未コミットの変更はありません(または git リポジトリではありません)"
}
L10nKey::AppCmdSshProfileTitle => "SSH: {title}",
L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}",
L10nKey::AppNoAgentOnPath => "このマシンの PATH にコーディングエージェントが見つかりません",
L10nKey::AppNoAgentSeenHere => {
"このワークスペースではまだコーディングエージェントが実行されていません — 一度手動で起動すると、ここに表示されます"
}
L10nKey::AppAgentLaunchSaved => "{name} は今後このコマンドで起動します: {command}",
L10nKey::AppAgentLaunchArgsUnknown => "{name} は起動時の引数を報告していません",
L10nKey::AppCmdShellTitle => "Shell: {title}",
L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}",
L10nKey::AppPlaceholderDescription => "説明",
L10nKey::AppPlaceholderSshQuickConnect => "user@host または user@host:port",
@@ -1904,6 +1934,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー",
L10nKey::AppMenuCopySessionId => "セッション ID をコピー",
L10nKey::AppMenuForkSession => "セッションをフォーク",
L10nKey::AppMenuSaveAgentLaunchArgs => "現在の起動引数をデフォルトに設定",
L10nKey::AppMenuClosePaneTab => "閉じる",
L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる",
L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる",
@@ -1962,6 +1993,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::TabMenuLocalShells => "ローカル",
L10nKey::TabMenuAddHost => "SSH ホストを追加…",
L10nKey::TabMenuAllHosts => "すべての SSH ホスト…",
L10nKey::TabMenuLaunchAgent => "エージェントを起動…",
L10nKey::TabMenuOtherShells => "その他のシェル…",
L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割",
L10nKey::TabUnnamedShell => "シェル {n}",
L10nKey::ShellDefault => "デフォルト",
@@ -1988,6 +2021,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNoneLower => "なし",
L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール",
L10nKey::TabContextMarkUnread => "未読としてマーク",
L10nKey::TabContextHibernate => "休止",
L10nKey::TabContextWake => "復帰",
L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰",
L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません",
})
}
+35
View File
@@ -498,6 +498,11 @@ l10n_keys! {
SettingsSidebarGroupingDesc,
SettingsDiffPreviewFromCounts,
SettingsDiffPreviewFromCountsDesc,
SettingsSshTabTitle,
SettingsSshTabTitleDesc,
SettingsSshTabTitleDynamic,
SettingsSshTabTitleProfileName,
SettingsSshTabTitleHostname,
DocumentDock,
DocumentFill,
SettingsNotifications,
@@ -682,6 +687,7 @@ l10n_keys! {
SettingsSearchScrollbackKeywords,
SettingsSearchShowTrayIconKeywords,
SettingsSearchSidebarGroupingKeywords,
SettingsSearchSshTabTitleKeywords,
SettingsSearchSmartSelectionKeywords,
SettingsSearchStartInKeywords,
SettingsSearchSyncWithSystemKeywords,
@@ -736,6 +742,10 @@ l10n_keys! {
SshEditProfile,
ForwardTooltipAdd,
ForwardTooltipRemove,
ForwardTooltipTurnOn,
ForwardTooltipTurnOff,
ForwardSwitchFailed,
SettingsFwdEnabled,
ForwardLocal,
ForwardRemote,
ForwardDynamic,
@@ -911,6 +921,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
ScmFilterChanges,
ScmViewAsTree,
ScmViewAsList,
ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
@@ -998,6 +1012,7 @@ l10n_keys! {
AppMenuCopyWorkingDirectory,
AppMenuCopySessionId,
AppMenuForkSession,
AppMenuSaveAgentLaunchArgs,
AppMenuClosePaneTab,
AppMenuCloseOtherTabs,
AppMenuCloseTabsRight,
@@ -1057,6 +1072,8 @@ l10n_keys! {
TabMenuLocalShells,
TabMenuAddHost,
TabMenuAllHosts,
TabMenuLaunchAgent,
TabMenuOtherShells,
TabMenuSplitHint,
TabUnnamedShell,
ShellDefault,
@@ -1076,6 +1093,10 @@ l10n_keys! {
TabContextCloseTab,
TabContextCloseTabsBelow,
TabContextMarkUnread,
TabContextHibernate,
TabContextWake,
TabTooltipAsleep,
TabWakeFailed,
RemoteStripDisconnected,
RemoteStripConnecting,
RemoteStripReconnecting,
@@ -1222,9 +1243,13 @@ l10n_keys! {
CmdCopyWorkingDirectory,
CmdCopySessionId,
CmdCopySessionIdSubtitle,
CmdNewAgentTab,
CmdNewAgentTabSubtitle,
CmdForkSession,
CmdForkSessionSubtitle,
CmdMarkTabAsUnread,
CmdHibernateTab,
CmdHibernateTabSubtitle,
CmdClosePaneTab,
CmdCloseWindow,
CmdCloseWindowSubtitle,
@@ -1351,6 +1376,12 @@ l10n_keys! {
AppPaneNoKnownDirectory,
AppNoUncommittedChanges,
AppCmdSshProfileTitle,
AppCmdAgentLaunchTitle,
AppNoAgentOnPath,
AppNoAgentSeenHere,
AppAgentLaunchSaved,
AppAgentLaunchArgsUnknown,
AppCmdShellTitle,
AppCmdSwitchToTab,
AppPlaceholderDescription,
AppPlaceholderSshQuickConnect,
@@ -1611,6 +1642,7 @@ mod tests {
L10nKey::Ok,
// Pure templates: every word in them is a placeholder.
L10nKey::AppCmdSshProfileTitle,
L10nKey::AppCmdShellTitle,
L10nKey::HostOpsError,
L10nKey::SftpTransferProgress,
// Product names.
@@ -1652,6 +1684,9 @@ mod tests {
// translation here would be less clear, not more.
L10nKey::SettingsShell,
L10nKey::CmdGroupAgents,
// The quick-launch rows, which the New Tab menu finds by typing
// this one word into the palette in every locale.
L10nKey::AppCmdAgentLaunchTitle,
];
for &key in KEPT_IN_ENGLISH {
+37
View File
@@ -549,6 +549,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。"
}
L10nKey::SettingsSshTabTitle => "SSH 标签页标题",
L10nKey::SettingsSshTabTitleDesc => {
"SSH 标签页显示的名字。“动态”跟随远端设置的标题;另外两项把标签页固定为主机。手动重命名过的标签页保留其名字。"
}
L10nKey::SettingsSshTabTitleDynamic => "动态",
L10nKey::SettingsSshTabTitleProfileName => "配置名称",
L10nKey::SettingsSshTabTitleHostname => "主机名",
L10nKey::DocumentDock => "停靠在终端旁",
L10nKey::DocumentFill => "铺满窗口",
L10nKey::SettingsNotifications => "通知",
@@ -893,6 +900,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"自动分组 标签页 分组 仓库 git ssh 主机 固定 未分组 侧栏 sidebar auto grouping tabs repo repository pinned pin host ungrouped"
}
L10nKey::SettingsSearchSshTabTitleKeywords => {
"SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin"
}
L10nKey::SettingsSearchSmartSelectionKeywords => {
"智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click"
}
@@ -974,6 +984,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SshEditProfile => "编辑连接…",
L10nKey::ForwardTooltipAdd => "添加转发",
L10nKey::ForwardTooltipRemove => "移除",
L10nKey::ForwardTooltipTurnOn => "开启",
L10nKey::ForwardTooltipTurnOff => "关闭——规则会保留",
L10nKey::ForwardSwitchFailed => "无法切换这条转发——{error}",
L10nKey::SettingsFwdEnabled => "连接时开启这条规则",
L10nKey::ForwardLocal => "本地",
L10nKey::ForwardRemote => "远程",
L10nKey::ForwardDynamic => "动态",
@@ -1154,6 +1168,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "展开",
L10nKey::ScmShowLess => "收起",
L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。",
L10nKey::ScmFilterChanges => "筛选变更文件…",
L10nKey::ScmViewAsTree => "以树形显示",
L10nKey::ScmViewAsList => "以列表显示",
L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。",
L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。",
L10nKey::ScmOpenChanges => "查看改动",
L10nKey::ScmDiscardAllConfirm => {
@@ -1416,9 +1434,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdCopyWorkingDirectory => "复制工作目录",
L10nKey::CmdCopySessionId => "复制会话 ID",
L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID",
L10nKey::CmdNewAgentTab => "新建 Agent 标签页",
L10nKey::CmdNewAgentTabSubtitle => "在新标签页中打开上次使用的编码 agent",
L10nKey::CmdForkSession => "Fork 会话",
L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页",
L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读",
L10nKey::CmdHibernateTab => "休眠标签页",
L10nKey::CmdHibernateTabSubtitle => "停止其中的进程以释放内存,选中即可唤醒",
L10nKey::CmdClosePaneTab => "关闭窗格/标签页",
L10nKey::CmdCloseWindow => "关闭窗口",
L10nKey::CmdCloseWindowSubtitle => "shell 保持运行",
@@ -1571,6 +1593,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。",
L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。",
L10nKey::AppCmdSshProfileTitle => "SSH:{title}",
L10nKey::AppCmdAgentLaunchTitle => "Agent:{name}",
L10nKey::AppNoAgentOnPath => "在本机 PATH 中未找到编码 agent",
L10nKey::AppNoAgentSeenHere => {
"此工作区还没有运行过编码 agent——手动启动一次后,这里就会列出它"
}
L10nKey::AppAgentLaunchSaved => "{name} 以后将以此命令启动:{command}",
L10nKey::AppAgentLaunchArgsUnknown => "{name} 未报告其启动参数",
L10nKey::AppCmdShellTitle => "Shell:{title}",
L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}",
L10nKey::AppPlaceholderDescription => "描述",
L10nKey::AppPlaceholderSshQuickConnect => "user@host 或 user@host:port",
@@ -1723,6 +1753,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录",
L10nKey::AppMenuCopySessionId => "复制会话 ID",
L10nKey::AppMenuForkSession => "Fork 会话",
L10nKey::AppMenuSaveAgentLaunchArgs => "将当前启动参数设为默认",
L10nKey::AppMenuClosePaneTab => "关闭",
L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页",
L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页",
@@ -1781,6 +1812,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::TabMenuLocalShells => "本地",
L10nKey::TabMenuAddHost => "添加 SSH 主机…",
L10nKey::TabMenuAllHosts => "所有 SSH 主机…",
L10nKey::TabMenuLaunchAgent => "启动 Agent…",
L10nKey::TabMenuOtherShells => "其他 Shell…",
L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开",
L10nKey::TabUnnamedShell => "终端 {n}",
L10nKey::ShellDefault => "默认",
@@ -1807,6 +1840,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNoneLower => "无",
L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具",
L10nKey::TabContextMarkUnread => "标记为未读",
L10nKey::TabContextHibernate => "休眠",
L10nKey::TabContextWake => "唤醒",
L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒",
L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动",
})
}
+87
View File
@@ -301,6 +301,24 @@ fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
}
pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
let mut bindings = shipped_bindings();
// One slot per agent, unbound: the palette's `Agent: …` rows are what these
// run, and each takes a key the same way any other action does. Right
// after the other agent actions, so the Keybindings page keeps them together.
let at = bindings
.iter()
.position(|(action, _)| *action == "NewAgentTab")
.map_or(bindings.len(), |i| i + 1);
bindings.splice(
at..at,
crate::ui::agent_launch::launch_action_names()
.iter()
.map(|(_, name)| (name.as_str(), "")),
);
bindings
}
fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
vec![
("NewTab", per_platform("secondary-t", "secondary-shift-t")),
("NewWorkspace", "secondary-shift-n"),
@@ -325,12 +343,18 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("CloseTabsToTheRight", ""),
("CopyWorkingDirectory", ""),
("MarkTabUnread", ""),
("HibernateTab", ""),
("ForkAgentSession", ""),
("ForkAgentSessionRight", ""),
("ForkAgentSessionLeft", ""),
("ForkAgentSessionDown", ""),
("ForkAgentSessionUp", ""),
("CopyAgentSessionId", ""),
// ⌘⇧A for "agent" on macOS, where nothing else in the table or the
// terminal holds it. Off macOS Ctrl+Shift+A is select-all in every
// Linux terminal and in this prompt editor's Ctrl+A family, so it ships
// unbound there rather than on a chord nobody would guess.
("NewAgentTab", per_platform("secondary-shift-a", "")),
("StopWorkspace", ""),
("DeleteWorkspace", ""),
("RenameWorkspace", ""),
@@ -578,6 +602,15 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]),
));
}
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
return Some((
CommandGroup::Agents,
t_fmt(
L10nKey::AppCmdAgentLaunchTitle,
&[("name", agent.display_name())],
),
));
}
if let Some(n) = action.strip_prefix("SelectWorkspace") {
return Some((
CommandGroup::Workspaces,
@@ -614,6 +647,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::TabsPanes,
t(L10nKey::CmdMarkTabAsUnread).to_string(),
),
"HibernateTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdHibernateTab).to_string(),
),
"ReopenClosedTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdReopenClosedTab).to_string(),
@@ -826,6 +863,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Agents,
t(L10nKey::CmdCopySessionId).to_string(),
),
"NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()),
"TogglePalette" => (
CommandGroup::Application,
t(L10nKey::AppMenuCommandPalette).to_string(),
@@ -1343,6 +1381,9 @@ fn action_context(action: &str) -> Option<&'static str> {
}
fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None));
}
Some(match action {
"NewTab" => KeyBinding::new(keystroke, NewTab, None),
"NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None),
@@ -1359,12 +1400,14 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None),
"CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None),
"MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None),
"HibernateTab" => KeyBinding::new(keystroke, HibernateTab, None),
"ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None),
"ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None),
"ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None),
"ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None),
"ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None),
"CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None),
"NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, None),
"SplitRight" => KeyBinding::new(keystroke, SplitRight, None),
"SplitDown" => KeyBinding::new(keystroke, SplitDown, None),
"FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None),
@@ -2315,6 +2358,50 @@ mod tests {
let ks = Keystroke::parse("secondary").unwrap();
assert_eq!(spec_from_keystroke(&ks), None);
}
#[test]
fn new_agent_tab_ships_a_chord_only_where_one_is_free() {
let mut effective: Vec<(String, String)> = default_bindings()
.into_iter()
.map(|(a, k)| (a.to_string(), k.to_string()))
.collect();
let default = effective
.iter()
.find(|(action, _)| action == "NewAgentTab")
.map(|(_, key)| key.clone())
.expect("NewAgentTab has to be listed or it cannot be bound");
if cfg!(target_os = "macos") {
assert_eq!(default, "secondary-shift-a");
assert_eq!(
dispatched(&effective, &default, "Terminal"),
vec![NewAgentTab::name_for_type()],
"{default} must reach NewAgentTab, and nothing else may answer it"
);
} else {
assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS");
}
// Each agent's own launch is a slot of its own, and a key bound to it
// dispatches that agent.
let name = crate::ui::agent_launch::launch_action_name(
tty7_core::core::cli_agent::CLIAgent::Codex,
);
effective
.iter_mut()
.find(|(action, _)| action == name)
.expect("every agent has a keymap slot")
.1 = "ctrl-alt-shift-c".to_string();
let mut keymap = gpui::Keymap::default();
keymap.add_bindings(action_bindings(&effective));
let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()];
let context = [gpui::KeyContext::parse("Terminal").unwrap()];
let hits = keymap.bindings_for_input(&typed, &context).0;
assert_eq!(hits.len(), 1);
assert!(hits[0].action().partial_eq(&LaunchAgent {
agent: tty7_core::core::cli_agent::CLIAgent::Codex,
}));
assert_eq!(action_entry(name).0, CommandGroup::Agents);
}
}
#[cfg(test)]
+9
View File
@@ -49,6 +49,15 @@ impl LocalLink {
link.client.as_ref().filter(|c| c.is_connected()).cloned()
}
/// Whether the local daemon advertised `feature` on its control hello —
/// [`HostLinks::peer_supports`](crate::ui::remote_connect::HostLinks::peer_supports)
/// for this machine. `false` while the link is down, for the same reason.
pub fn supports(cx: &App, feature: &str) -> bool {
cx.try_global::<LocalLink>()
.and_then(|link| link.client.as_ref())
.is_some_and(|c| c.is_connected() && c.hello().has_feature(feature))
}
/// Drops the cached client without waiting for its reader to notice.
///
/// `ControlClient::is_connected` only flips once the reader sees EOF, so
+1
View File
@@ -969,6 +969,7 @@ mod tests {
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
hibernated: false,
};
assert!(apply(
&mut machine,
+2
View File
@@ -1,3 +1,4 @@
pub mod agent_launch;
pub mod app;
pub mod assets;
pub mod code_editor;
@@ -27,6 +28,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
pub mod prompt;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
+26 -2
View File
@@ -35,8 +35,11 @@ pub enum CommandKind {
CloseTabsToTheRight,
CopyWorkingDirectory,
MarkTabUnread,
HibernateTab,
ForkAgentSession,
CopyAgentSessionId,
NewAgentTab,
LaunchAgent(crate::core::cli_agent::CLIAgent),
ResetFontSize,
NextPane,
PrevPane,
@@ -108,6 +111,9 @@ pub enum CommandKind {
ActivateTab(usize),
ConnectSavedProfile(Uuid),
EditSavedProfile(Uuid),
/// Open the shell the window's inventory lists under this label, as the
/// New Tab menu's row for it would.
OpenShell(String),
SaveSshSessionAsHost,
QuickConnect(String),
SaveQuickConnect(String),
@@ -145,8 +151,10 @@ impl CommandKind {
CloseTabsToTheRight => "close-tabs-right",
CopyWorkingDirectory => "copy-cwd",
MarkTabUnread => "mark-tab-unread",
HibernateTab => "hibernate-tab",
ForkAgentSession => "fork-agent-session",
CopyAgentSessionId => "copy-agent-session-id",
NewAgentTab => "new-agent-tab",
ResetFontSize => "reset-font-size",
NextPane => "next-pane",
PrevPane => "prev-pane",
@@ -224,8 +232,10 @@ impl CommandKind {
| ActivateTab(_)
| ConnectSavedProfile(_)
| EditSavedProfile(_)
| OpenShell(_)
| QuickConnect(_)
| SaveQuickConnect(_) => return None,
| SaveQuickConnect(_)
| LaunchAgent(_) => return None,
})
}
@@ -238,6 +248,12 @@ impl CommandKind {
CutText => return inline("secondary-x"),
PasteText => return inline("secondary-v"),
SelectAllText => return inline("secondary-a"),
LaunchAgent(agent) => {
return crate::ui::keymap::effective_key(
crate::ui::agent_launch::launch_action_name(*agent),
cx,
);
}
_ => {}
}
let action = match self {
@@ -257,8 +273,10 @@ impl CommandKind {
CloseTabsToTheRight => "CloseTabsToTheRight",
CopyWorkingDirectory => "CopyWorkingDirectory",
MarkTabUnread => "MarkTabUnread",
HibernateTab => "HibernateTab",
ForkAgentSession => "ForkAgentSession",
CopyAgentSessionId => "CopyAgentSessionId",
NewAgentTab => "NewAgentTab",
ResetFontSize => "ResetFontSize",
NextPane => "FocusNextPane",
PrevPane => "FocusPrevPane",
@@ -340,9 +358,11 @@ impl CommandKind {
| ActivateTab(_)
| ConnectSavedProfile(_)
| EditSavedProfile(_)
| OpenShell(_)
| SaveSshSessionAsHost
| QuickConnect(_)
| SaveQuickConnect(_) => return None,
| SaveQuickConnect(_)
| LaunchAgent(_) => return None,
};
crate::ui::keymap::effective_key(action, cx)
}
@@ -480,6 +500,8 @@ impl Command {
Command::localized(L10nKey::CmdForkSession, ForkAgentSession)
.with_subtitle(t(L10nKey::CmdForkSessionSubtitle)),
Command::localized(L10nKey::CmdMarkTabAsUnread, MarkTabUnread),
Command::localized(L10nKey::CmdHibernateTab, HibernateTab)
.with_subtitle(t(L10nKey::CmdHibernateTabSubtitle)),
Command::localized(L10nKey::CmdClosePaneTab, ClosePane),
Command::localized(L10nKey::CmdCloseOtherTabs, CloseOtherTabs),
Command::localized(L10nKey::CmdCloseTabsToTheRight, CloseTabsToTheRight),
@@ -590,6 +612,8 @@ impl Command {
];
let agents = [
Command::localized(L10nKey::CmdNewAgentTab, NewAgentTab)
.with_subtitle(t(L10nKey::CmdNewAgentTabSubtitle)),
Command::localized(L10nKey::CmdAgentSendSelection, SendSelectionToAgent)
.with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)),
Command::localized(L10nKey::CmdAgentSendGitDiffForReview, SendGitDiffToAgent)
+4
View File
@@ -20,6 +20,10 @@ pub struct PendingSpawn {
pub agent: Option<crate::core::cli_agent::CLIAgent>,
pub agent_session_id: Option<String>,
pub agent_launch_argv: Option<Vec<String>>,
/// A command line to type into the shell once the pane exists, for a pane
/// opened to run something (a quick-launched agent). Typed only into this
/// pane, and only when it is a fresh shell rather than a restored one.
pub run_on_land: Option<String>,
pub owner: Option<crate::core::session::WorkspaceId>,
pub font_size: f32,
}
+180
View File
@@ -0,0 +1,180 @@
//! The confirmation dialog on platforms with no native one.
//!
//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux
//! has neither, so gpui falls back to a renderer of its own — and that one sets
//! the message and the detail as single unbreakable lines inside a fixed-width
//! box that clips its overflow. Any sentence longer than the box is cut off
//! mid-word, which for the quit-and-stop warning is exactly the part that says
//! what will be lost (#920). This is the same dialog laid out as a card that
//! wraps its text, in the app's own surfaces, with Return and Escape answering
//! the way they do on the native dialogs.
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
/// Route every `window.prompt` through [`TextPrompt`] where the platform has
/// no dialog of its own to show.
pub(crate) fn install(cx: &mut App) {
if cfg!(any(target_os = "macos", target_os = "windows")) {
return;
}
cx.set_prompt_builder(build);
}
fn build(
_level: PromptLevel,
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
handle: PromptHandle,
window: &mut Window,
cx: &mut App,
) -> RenderablePromptHandle {
let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx));
handle.with_view(prompt, window, cx)
}
pub(crate) struct TextPrompt {
message: String,
detail: Option<String>,
answers: Vec<PromptButton>,
focus: FocusHandle,
}
impl TextPrompt {
fn new(
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
cx: &mut Context<Self>,
) -> Self {
Self {
message: message.to_string(),
detail: detail.map(str::to_string),
answers: answers.to_vec(),
focus: cx.focus_handle(),
}
}
/// The answer a key picks. Return is answer 0 — `confirm_answers` puts the
/// action there for exactly this — and Escape is the one marked cancel;
/// a prompt with no cancel answer has nothing Escape can safely mean.
fn answer_for_key(&self, key: &str) -> Option<usize> {
match key {
"enter" if !self.answers.is_empty() => Some(0),
"escape" => self.answers.iter().position(PromptButton::is_cancel),
_ => None,
}
}
}
impl EventEmitter<PromptResponse> for TextPrompt {}
impl Focusable for TextPrompt {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl Render for TextPrompt {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let muted = cx.theme().muted_foreground;
// Answer 0 goes rightmost, where the native dialogs put it and where
// `confirm_answers` expects it to land.
let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| {
Button::new(("prompt-answer", ix))
.label(answer.label().clone())
.small()
.when(ix == 0 && !answer.is_cancel(), |b| b.primary())
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}))
});
let card = v_flex()
.occlude()
.w(px(420.))
.max_w(relative(0.9))
.gap_3()
.p_5()
.map(|card| crate::ui::theme::floating_surface(card, cx))
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(self.message.clone()),
)
.children(
self.detail
.clone()
.map(|detail| div().text_sm().text_color(muted).child(detail)),
)
.child(
h_flex()
.pt_2()
.justify_end()
.flex_wrap()
.gap_2()
.children(buttons),
);
div()
.id("text-prompt")
.track_focus(&self.focus)
.size_full()
.cursor_default()
.bg(crate::ui::presets::scrim_fill(cx))
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| {
if let Some(ix) = this.answer_for_key(&ev.keystroke.key) {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}
}))
.flex()
.items_center()
.justify_center()
.child(card)
}
}
#[cfg(test)]
mod tests {
use super::TextPrompt;
use gpui::{AppContext as _, PromptButton, TestAppContext};
fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity<TextPrompt> {
cx.update(|cx| {
cx.new(|cx| {
TextPrompt::new(
"Quit and stop tty7 server?",
Some("a detail long enough to need more than one line"),
answers,
cx,
)
})
})
}
#[gpui::test]
fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel"));
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("enter"), Some(0));
assert_eq!(p.answer_for_key("escape"), Some(1));
assert_eq!(p.answer_for_key("a"), None);
});
}
#[gpui::test]
fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]);
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("escape"), None);
});
}
}
+47 -2
View File
@@ -112,8 +112,11 @@ pub(crate) const ROW_INSET: f32 = 6.;
/// Local forwards only, and only those aimed at the far host's own loopback:
/// a forward to some third machine happens to carry the same number, and
/// pairing it with the port row would claim it leads somewhere it does not.
/// Nor does a switched-off forward: it leads nowhere until it is switched back
/// on, and its own row is where that switch is.
pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool {
m.kind == crate::daemon::protocol::SshForwardKind::Local
m.enabled
&& m.kind == crate::daemon::protocol::SshForwardKind::Local
&& m.target_port == port
&& crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host)
}
@@ -729,6 +732,18 @@ impl Tty7App {
input: &gpui::Entity<gpui_component::input::InputState>,
cx: &mut Context<Self>,
) -> AnyElement {
self.panel_search_with(input, None, cx)
}
/// [`Self::panel_search`] with a tile at its far end, inset the way the
/// branch row's tile is so the two stack in one column.
pub(crate) fn panel_search_with(
&self,
input: &gpui::Entity<gpui_component::input::InputState>,
trailing: Option<AnyElement>,
cx: &mut Context<Self>,
) -> AnyElement {
let has_trailing = trailing.is_some();
h_flex()
.flex_none()
.items_center()
@@ -753,6 +768,8 @@ impl Tty7App {
// still reads as one line of chrome.
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
)
.when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm())))
.children(trailing)
.into_any_element()
}
@@ -1339,8 +1356,26 @@ impl Tty7App {
}),
);
if let Some(f) = forward {
tiles_wide += 1;
tiles_wide += 2;
let forward_id = f.id;
// Switched off, the forward stops speaking for this row and
// comes out as a row of its own with the switch to turn it
// back on.
actions = actions.child(
crate::ui::tab_strip::chrome_tile_sized(
Button::new(("panel-port-switch-off", i))
.icon(Icon::empty().path("icons/power.svg")),
TILE_SIZE_XS,
TILE_GLYPH_XS,
false,
cx,
)
.rounded(px(4.))
.tooltip(t(L10nKey::ForwardTooltipTurnOff))
.on_click(cx.listener(move |this, _, window, cx| {
this.set_managed_forward_enabled(pane_id, forward_id, false, window, cx)
})),
);
actions = actions.child(
self.info_tile(
("panel-port-unforward", i),
@@ -1956,6 +1991,7 @@ mod tests {
target_port,
description: None,
status: ForwardStatus::Listening,
enabled: true,
}
}
@@ -1987,6 +2023,15 @@ mod tests {
);
}
/// A switched-off forward reaches nothing, so it must not take over the
/// port row — its own row is the only place its switch is.
#[test]
fn a_switched_off_forward_is_not_the_way_to_a_port() {
let mut off = forward(SshForwardKind::Local, "localhost", 3000);
off.enabled = false;
assert!(!forwards_port(&off, 3000));
}
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
InfoRow {
label: "changes",
+10
View File
@@ -71,6 +71,16 @@ impl Tty7App {
cx.notify();
}
/// Switch the changed-file list between flat and tree, and remember it.
///
/// Global like `diff_view`: someone who reads changes by directory reads
/// every repository's that way.
pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context<Self>) {
let next = !cx.global::<crate::core::config::Config>().scm_changes_tree;
self.update_config(cx, |cfg| cfg.scm_changes_tree = next);
cx.notify();
}
/// Flip the diff overlay between side-by-side and unified.
///
/// Global rather than per-overlay, matching `diffEditor.renderSideBySide`:
+1
View File
@@ -14,6 +14,7 @@ pub(crate) mod path;
pub(crate) mod state;
#[allow(dead_code)]
pub(crate) mod status;
pub(crate) mod tree;
pub(crate) use actions::ScmIntent;
pub(crate) use state::{GraphState, ScmPanelState};
+275 -4
View File
@@ -39,6 +39,7 @@ use crate::ui::scm::ScmIntent;
use crate::ui::scm::path::split_display_path;
use crate::ui::scm::state::{RepoKey, ScmGroup};
use crate::ui::scm::status::{status_color, status_glyph};
use crate::ui::scm::tree::{TreeRow, normalize_query, path_matches, tree_rows};
/// Shared compact pitch for working-tree and commit-detail file rows.
pub(super) const ROW_H: f32 = 26.;
@@ -52,6 +53,10 @@ pub(super) const ROW_H: f32 = 26.;
/// different numbers.
const BADGE_W: f32 = 14.;
/// One level of the tree view, the file tree's step: the Files panel and this
/// one draw the same directories, and the same depth should read the same.
const TREE_INDENT: f32 = 14.;
/// The key context the message box installs, and the one `ScmCommit` is
/// bound inside. The two are the same string on purpose: a binding whose
/// context nothing attaches is a binding that never fires.
@@ -236,7 +241,12 @@ impl Tty7App {
Some(t(L10nKey::PanelNoChangesHint)),
cx,
),
None => self.scm_groups(&repo, &status, cx),
None => {
// Pinned with the rest, so the filter stays in reach however
// far down the list has been scrolled.
pinned.push(self.scm_filter_row(window, cx));
self.scm_groups(&repo, &status, cx)
}
};
let history = self.render_graph_section(&repo, window, cx);
self.scm_shell_full(title, pinned, body, history)
@@ -1291,24 +1301,63 @@ impl Tty7App {
status: &Arc<WorkingTreeStatus>,
cx: &mut Context<Self>,
) -> AnyElement {
let query = self.scm_filter_query(cx);
let tree = cx.global::<crate::core::config::Config>().scm_changes_tree;
let mut matched = 0;
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.));
for group in ScmGroup::ORDER {
// Filtered before anything else reads it, so the header's count
// and its stage/discard-all act on what is on screen — a
// "discard all" that also reached the files the filter hid would
// be the one surprise this panel cannot take back.
let entries: Vec<&StatusEntry> = status
.entries
.iter()
.filter(|e| in_group(e, group))
.filter(|e| {
query
.as_deref()
.is_none_or(|q| path_matches(e.path.as_str(), q))
})
.collect();
if entries.is_empty() {
continue;
}
matched += entries.len();
let collapsed = self.scm.group_collapsed(group, entries.len());
list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx));
if collapsed {
continue;
}
let shown = entries.len().min(MAX_RENDERED_FILES);
for entry in entries.iter().take(shown) {
list = list.child(self.scm_file_row(repo, group, entry, cx));
if tree {
// A filter opens every directory: a match folded away under
// a closed row is a match the filter failed to show.
let filtering = query.is_some();
let folded = &self.scm.folded_dirs;
let rows = tree_rows(entries[..shown].iter().map(|e| e.path.as_str()), |key| {
!filtering && folded.contains(&(group, key.to_string()))
});
for row in rows {
list = list.child(match row {
TreeRow::Dir {
key,
label,
depth,
files,
collapsed,
} => self.scm_dir_row(
group, key, label, depth, files, collapsed, !filtering, cx,
),
TreeRow::File { index, depth } => {
self.scm_file_row(repo, group, entries[index], Some(depth), cx)
}
});
}
} else {
for entry in entries.iter().take(shown) {
list = list.child(self.scm_file_row(repo, group, entry, None, cx));
}
}
if entries.len() > shown {
list = list.child(self.scm_note(
@@ -1317,6 +1366,9 @@ impl Tty7App {
));
}
}
if query.is_some() && matched == 0 {
list = list.child(self.scm_note(t(L10nKey::ScmNoMatchingChanges).to_string(), cx));
}
if status.truncated {
list = list.child(self.scm_note(
t_fmt(
@@ -1332,6 +1384,142 @@ impl Tty7App {
list.into_any_element()
}
/// The filter over the changed files, with the list/tree switch at its
/// end.
///
/// A standing field, like the Files panel's, rather than one behind a
/// tile like the history's: the list is what this panel is for, and a
/// run that touched sixty files is exactly when nobody wants to go
/// looking for how to narrow it.
fn scm_filter_row(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let input = match self.scm.filter.clone() {
Some(input) => input,
None => {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(L10nKey::ScmFilterChanges))
});
// An `InputState` is its own entity; without this the list
// would never hear about the text typed into it.
self.scm.filter_sub =
Some(
cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| {
if matches!(ev, gpui_component::input::InputEvent::Change) {
cx.notify();
}
}),
);
self.scm.filter = Some(input.clone());
input
}
};
let tree = cx.global::<crate::core::config::Config>().scm_changes_tree;
// The tile shows where it goes, not where you are — the rows under it
// already say which view this is.
let (glyph, tip) = match tree {
true => ("icons/list-flat.svg", t(L10nKey::ScmViewAsList)),
false => ("icons/list-tree.svg", t(L10nKey::ScmViewAsTree)),
};
let toggle = crate::ui::tab_strip::chrome_tile_sized(
Button::new("scm-changes-view").icon(Icon::empty().path(glyph)),
crate::ui::app::TILE_SIZE_SM,
crate::ui::app::TILE_GLYPH_SM,
false,
cx,
)
.rounded_md()
.tooltip(tip)
.on_click(cx.listener(|this, _, _window, cx| this.scm_toggle_changes_tree(cx)))
.into_any_element();
self.panel_search_with(&input, Some(toggle), cx)
}
/// The filter box's text, if it has any.
fn scm_filter_query(&self, cx: &gpui::App) -> Option<String> {
normalize_query(&self.scm.filter.as_ref()?.read(cx).value())
}
/// A directory in the tree view: a chevron in the status letters'
/// column, the (possibly compacted) name, and how many changed files
/// are under it.
#[allow(clippy::too_many_arguments)]
fn scm_dir_row(
&self,
group: ScmGroup,
key: String,
label: String,
depth: usize,
files: usize,
collapsed: bool,
foldable: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let mono = cx.theme().mono_font_family.clone();
let muted = cx.theme().muted_foreground;
let id = SharedString::from(format!("scm-dir-{group:?}-{key}"));
let selector = id.clone();
let toggle = cx.listener({
let key = key.clone();
move |this, _, _window, cx| {
this.scm.toggle_dir(group, key.clone());
cx.notify();
}
});
h_flex()
.id(id)
.debug_selector(move || selector.to_string())
.items_center()
.gap(px(8.))
.min_h(rems(ROW_H / 16.))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
.rounded(px(5.))
.when(foldable, |row| {
row.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.on_click(toggle)
})
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(key.clone()).build(window, cx)
})
.child(
div()
.flex_none()
.w(px(BADGE_W))
.flex()
.justify_center()
.text_color(muted)
.child(
Icon::new(if collapsed {
IconName::ChevronRight
} else {
IconName::ChevronDown
})
.xsmall(),
),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(crate::ui::right_panel::TEXT))
.text_color(gpui::rgb(sf.text_resting))
.child(label),
)
.child(
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono)
.text_color(muted)
.child(files.to_string()),
)
.into_any_element()
}
/// "…and 40 more", and the note about a status git had to truncate.
///
/// Secondary: it is prose about the list rather than a row of it, and the
@@ -1413,11 +1601,15 @@ impl Tty7App {
.into_any_element()
}
/// One changed file. `depth` is its level in the tree view, and `None` in
/// the flat list — where the row carries its directory beside the name,
/// since there is no directory row above it to say it.
fn scm_file_row(
&self,
repo: &RepoKey,
group: ScmGroup,
entry: &StatusEntry,
depth: Option<usize>,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
@@ -1438,8 +1630,10 @@ impl Tty7App {
cx,
);
let selector = id.clone();
h_flex()
.id(id.clone())
.debug_selector(move || selector.to_string())
.group(id)
.relative()
.items_center()
@@ -1448,6 +1642,9 @@ impl Tty7App {
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.when_some(depth, |row, depth| {
row.pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
})
.py(px(3.))
.rounded(px(5.))
.cursor_pointer()
@@ -1503,7 +1700,7 @@ impl Tty7App {
// Cap the secondary directory while the filename fills the rest.
// A rem cap also gives short paths their intrinsic width inside
// the context-menu wrapper's flex layout.
.when(!dir.is_empty(), |this| {
.when(!dir.is_empty() && depth.is_none(), |this| {
this.child(
div()
.flex_none()
@@ -3012,6 +3209,80 @@ mod render_idle_gpui_tests {
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
/// The tree view and the filter, end to end over a real repository: the
/// rows that come out are the ones the pure helpers promise, drawn.
#[gpui::test]
fn the_changes_list_folds_into_a_tree_and_filters_by_path(cx: &mut TestAppContext) {
let _serial = serial();
crate::core::config::pin_test_config_dir();
let root = scratch("tree");
git(&root, &["init", "--quiet"]);
std::fs::create_dir_all(root.join("src/ui")).unwrap();
std::fs::create_dir_all(root.join("docs")).unwrap();
std::fs::write(root.join("src/ui/a.rs"), "a\n").unwrap();
std::fs::write(root.join("src/ui/b.rs"), "b\n").unwrap();
std::fs::write(root.join("docs/readme.md"), "r\n").unwrap();
// Keyed off the root the panel resolved, not off `root`: on Windows
// `scratch` hands back a `\\?\` verbatim path that never compares
// equal to git's spelling of the same directory — see the note on
// `a_settled_source_control_panel_reaches_render_idle`.
let (app, mut vcx, _pane) = scm_panel_on(cx, &root, |app, cx| {
app.scm.repo.as_ref().is_some_and(|r| {
crate::terminal::git_data::status_of(cx, r.host, &r.root)
.is_some_and(|s| s.untracked().count() == 3)
})
});
let drawn = |vcx: &mut VisualTestContext, what: &'static str| {
app.update_in(vcx, |_, _, cx| cx.notify());
vcx.run_until_parked();
vcx.debug_bounds(what).is_some()
};
let a = "scm-row-Untracked-src/ui/a.rs";
let readme = "scm-row-Untracked-docs/readme.md";
let src_ui = "scm-dir-Untracked-src/ui";
// The flat list is the default, and it has no directory rows.
assert!(drawn(&mut vcx, a));
assert!(!drawn(&mut vcx, src_ui));
// `src` holds nothing but `ui`, so the two share one row.
app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
assert!(vcx.update(|_, cx| cx.global::<crate::core::config::Config>().scm_changes_tree));
assert!(drawn(&mut vcx, src_ui));
assert!(!drawn(&mut vcx, "scm-dir-Untracked-src"));
assert!(drawn(&mut vcx, "scm-dir-Untracked-docs"));
assert!(drawn(&mut vcx, a));
// Folding the directory keeps its row and hides what is under it.
app.update(&mut vcx, |app, cx| {
app.scm.toggle_dir(ScmGroup::Untracked, "src/ui".into());
cx.notify();
});
assert!(drawn(&mut vcx, src_ui));
assert!(!drawn(&mut vcx, a));
assert!(drawn(&mut vcx, readme));
// A filter drops what does not match — and opens the folded
// directory the match is in.
app.update_in(&mut vcx, |app, window, cx| {
let input = app.scm.filter.clone().expect("the filter row was drawn");
input.update(cx, |state, cx| state.set_value("UI A", window, cx));
});
assert!(drawn(&mut vcx, a));
assert!(!drawn(&mut vcx, "scm-row-Untracked-src/ui/b.rs"));
assert!(!drawn(&mut vcx, readme));
assert!(!drawn(&mut vcx, "scm-dir-Untracked-docs"));
// The same filter narrows the flat list too.
app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
assert!(drawn(&mut vcx, a));
assert!(!drawn(&mut vcx, readme));
assert!(!drawn(&mut vcx, src_ui));
let _ = std::fs::remove_dir_all(&root);
}
}
/// The action strip is the one place in the panel where a hover reveal sits
+16
View File
@@ -96,6 +96,14 @@ pub(crate) struct ScmPanelState {
/// opening one by hand has to outlast the next file landing in it.
pub(crate) collapsed: HashSet<ScmGroup>,
pub(crate) toggled: HashSet<ScmGroup>,
/// Directories folded shut in the tree view, per group — the same
/// directory can be open under Changes and shut under Staged. Keyed by
/// the full repo-relative path of the (possibly compacted) row.
pub(crate) folded_dirs: HashSet<(ScmGroup, String)>,
/// The filter over the changed files. Created on first render like
/// `commit_input`, with the subscription that turns typing into a repaint.
pub(crate) filter: Option<Entity<InputState>>,
pub(crate) filter_sub: Option<gpui::Subscription>,
/// Working directory → the repository root containing it, or `None` when
/// there is none, with when the answer was given. The root is what every
/// write runs from and what every cache is keyed by, so it is resolved
@@ -165,6 +173,14 @@ impl ScmPanelState {
}
}
/// Fold a tree directory shut, or open it again.
pub(crate) fn toggle_dir(&mut self, group: ScmGroup, key: String) {
let entry = (group, key);
if !self.folded_dirs.remove(&entry) {
self.folded_dirs.insert(entry);
}
}
pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) {
self.toggled.insert(group);
if collapsed {
+298
View File
@@ -0,0 +1,298 @@
//! The changed-file list as a tree, and the filter both views share.
//!
//! Pure, and over plain paths rather than `StatusEntry`: the panel hands in
//! one group's paths and gets back rows that point into that same slice, so
//! the file rows it draws are the ones the flat list would have drawn — same
//! letter, same click, same context menu — only indented under a directory.
//!
//! Everything here comes out of the status the host already sent. A remote
//! repository gets the same tree without a single extra read of its disk.
use std::collections::BTreeMap;
/// One line of the tree view.
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum TreeRow {
/// A directory, possibly several compacted into one: `src/ui/scm` when
/// `src` and `ui` hold nothing but the next directory down. `key` is the
/// full repo-relative path, which is what the fold state is keyed by;
/// `label` is what the row reads, relative to its parent row.
Dir {
key: String,
label: String,
depth: usize,
/// Every file underneath, at any depth — what a folded row still
/// owes the reader.
files: usize,
collapsed: bool,
},
/// A file, by its position in the paths the tree was built from.
File { index: usize, depth: usize },
}
#[derive(Default)]
struct Node<'a> {
dirs: BTreeMap<&'a str, Node<'a>>,
files: Vec<(&'a str, usize)>,
}
impl Node<'_> {
fn file_count(&self) -> usize {
self.files.len() + self.dirs.values().map(Node::file_count).sum::<usize>()
}
}
/// Group `paths` by directory, directories before files at every level and
/// each run in byte order — the order git itself lists paths in.
///
/// A directory whose only content is one more directory is folded into it,
/// the way VS Code's compact folders do: an agent's change three levels down
/// `crates/tty7-core/src` should cost one row to reach, not three.
///
/// `collapsed` is asked once per directory row with its full path. A folded
/// directory still gets its own row; only what is under it is left out.
pub(crate) fn tree_rows<'a>(
paths: impl IntoIterator<Item = &'a str>,
collapsed: impl Fn(&str) -> bool,
) -> Vec<TreeRow> {
let mut root = Node::default();
for (index, path) in paths.into_iter().enumerate() {
// An untracked directory arrives as `dir/`. It is a leaf here, the
// same as it is a single row in the flat list.
let trimmed = path.strip_suffix('/').unwrap_or(path);
let (dir, name) = match trimmed.rsplit_once('/') {
Some((dir, name)) => (Some(dir), name),
None => (None, trimmed),
};
let mut node = &mut root;
for part in dir.into_iter().flat_map(|d| d.split('/')) {
node = node.dirs.entry(part).or_default();
}
node.files.push((name, index));
}
let mut rows = Vec::new();
emit(&root, "", 0, &collapsed, &mut rows);
rows
}
fn emit(
node: &Node<'_>,
prefix: &str,
depth: usize,
collapsed: &impl Fn(&str) -> bool,
rows: &mut Vec<TreeRow>,
) {
for (name, child) in &node.dirs {
let mut label = (*name).to_string();
let mut child = child;
while child.files.is_empty() && child.dirs.len() == 1 {
let (next, grandchild) = child.dirs.iter().next().expect("one entry");
label.push('/');
label.push_str(next);
child = grandchild;
}
let key = match prefix {
"" => label.clone(),
_ => format!("{prefix}/{label}"),
};
let folded = collapsed(&key);
rows.push(TreeRow::Dir {
key: key.clone(),
label,
depth,
files: child.file_count(),
collapsed: folded,
});
if !folded {
emit(child, &key, depth + 1, collapsed, rows);
}
}
let mut files = node.files.clone();
files.sort_by(|a, b| a.0.cmp(b.0));
rows.extend(
files
.into_iter()
.map(|(_, index)| TreeRow::File { index, depth }),
);
}
/// The filter box's text, normalised once: lowercased, trimmed, and `None`
/// when there is nothing left to filter by.
pub(crate) fn normalize_query(text: &str) -> Option<String> {
let q = text.trim().to_lowercase();
(!q.is_empty()).then_some(q)
}
/// Whether a changed path survives the filter.
///
/// Every whitespace-separated word has to appear somewhere in the path, in
/// any order: `scm panel` finds `src/ui/scm/panel.rs`, and so does `panel
/// scm`. `query` is what [`normalize_query`] returned.
pub(crate) fn path_matches(path: &str, query: &str) -> bool {
let hay = path.to_lowercase();
query.split_whitespace().all(|word| hay.contains(word))
}
#[cfg(test)]
mod tests {
use super::*;
fn open(paths: &[&str]) -> Vec<TreeRow> {
tree_rows(paths.iter().copied(), |_| false)
}
fn dir(key: &str, label: &str, depth: usize, files: usize) -> TreeRow {
TreeRow::Dir {
key: key.into(),
label: label.into(),
depth,
files,
collapsed: false,
}
}
fn file(index: usize, depth: usize) -> TreeRow {
TreeRow::File { index, depth }
}
#[test]
fn files_are_grouped_under_their_directories() {
let rows = open(&["src/a.rs", "README.md", "src/b.rs", "docs/x.md"]);
assert_eq!(
rows,
vec![
dir("docs", "docs", 0, 1),
file(3, 1),
dir("src", "src", 0, 2),
file(0, 1),
file(2, 1),
// Files after directories, at the root as everywhere else.
file(1, 0),
]
);
}
#[test]
fn a_directory_holding_only_a_directory_is_compacted() {
let rows = open(&[
"crates/core/src/git/status.rs",
"crates/core/src/git/diff.rs",
"crates/core/Cargo.toml",
]);
assert_eq!(
rows,
vec![
// `crates` holds only `core`, so they share a row; `core`
// holds a file, so the chain stops there.
dir("crates/core", "crates/core", 0, 3),
dir("crates/core/src/git", "src/git", 1, 2),
file(1, 2),
file(0, 2),
file(2, 1),
]
);
}
#[test]
fn a_directory_with_two_children_is_not_compacted() {
let rows = open(&["a/b/x.rs", "a/c/y.rs"]);
assert_eq!(
rows,
vec![
dir("a", "a", 0, 2),
dir("a/b", "b", 1, 1),
file(0, 2),
dir("a/c", "c", 1, 1),
file(1, 2),
]
);
}
#[test]
fn a_folded_directory_keeps_its_row_and_its_count() {
let rows = tree_rows(["src/ui/a.rs", "src/ui/b.rs", "src/main.rs"], |key| {
key == "src/ui"
});
assert_eq!(
rows,
vec![
dir("src", "src", 0, 3),
TreeRow::Dir {
key: "src/ui".into(),
label: "ui".into(),
depth: 1,
files: 2,
collapsed: true,
},
file(2, 1),
]
);
}
#[test]
fn folding_is_asked_about_the_compacted_path() {
let asked = std::cell::RefCell::new(Vec::new());
tree_rows(["a/b/c/x.rs"], |key| {
asked.borrow_mut().push(key.to_string());
false
});
assert_eq!(asked.into_inner(), vec!["a/b/c".to_string()]);
}
#[test]
fn an_untracked_directory_is_a_leaf() {
let rows = open(&["build/", "src/new/"]);
assert_eq!(rows, vec![dir("src", "src", 0, 1), file(1, 1), file(0, 0)]);
}
#[test]
fn every_path_lands_in_exactly_one_file_row() {
let paths = [
"z.rs",
"a/b/c.rs",
"a/b/d.rs",
"a/e.rs",
"f/g/h/i.rs",
"f/j.rs",
"a/b/",
];
let mut seen: Vec<usize> = open(&paths)
.into_iter()
.filter_map(|row| match row {
TreeRow::File { index, .. } => Some(index),
TreeRow::Dir { .. } => None,
})
.collect();
seen.sort_unstable();
assert_eq!(seen, (0..paths.len()).collect::<Vec<_>>());
}
#[test]
fn an_empty_list_is_an_empty_tree() {
assert!(open(&[]).is_empty());
}
#[test]
fn the_filter_needs_every_word_in_any_order_and_any_case() {
let q = normalize_query(" Scm PANEL ").unwrap();
assert!(path_matches("src/ui/scm/panel.rs", &q));
assert!(path_matches("src/ui/Panel/SCM.rs", &q));
assert!(!path_matches("src/ui/scm/state.rs", &q));
let q = normalize_query("panel scm").unwrap();
assert!(path_matches("src/ui/scm/panel.rs", &q));
}
#[test]
fn a_blank_filter_is_no_filter() {
assert_eq!(normalize_query(""), None);
assert_eq!(normalize_query(" "), None);
assert_eq!(normalize_query(" a "), Some("a".into()));
}
#[test]
fn the_filter_reads_non_ascii_paths() {
let q = normalize_query("设计").unwrap();
assert!(path_matches("文档/设计/方案.md", &q));
assert!(!path_matches("文档/实现.md", &q));
}
}
+67 -5
View File
@@ -24,7 +24,7 @@ use uuid::Uuid;
use crate::core::config::{
BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode,
TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop,
SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop,
};
use crate::core::keychain::{
CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents,
@@ -839,6 +839,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsDiffPreviewFromCounts,
keywords: SettingsSearchDiffPreviewFromCountsKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsSshTabTitle,
keywords: SettingsSearchSshTabTitleKeywords,
},
SearchEntry {
section: General,
title: SettingsNotifyOnCommandFinish,
@@ -898,6 +903,7 @@ impl SearchEntry {
L10nKey::SettingsTabBarPosition => "tab_bar_position",
L10nKey::SettingsSidebarGrouping => "sidebar_auto_grouping",
L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview",
L10nKey::SettingsSshTabTitle => "ssh_tab_title",
L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish",
L10nKey::SettingsNotifyThreshold => "notify_threshold_secs",
L10nKey::SettingsTerminalBell => "bell",
@@ -1007,6 +1013,7 @@ impl SearchEntry {
L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc),
L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc),
L10nKey::SettingsDiffPreviewFromCounts => t(L10nKey::SettingsDiffPreviewFromCountsDesc),
L10nKey::SettingsSshTabTitle => t(L10nKey::SettingsSshTabTitleDesc),
L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc),
L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc),
L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc),
@@ -1052,6 +1059,7 @@ impl SearchEntry {
L10nKey::SettingsDiffPreviewFromCounts => {
cfg.sidebar_diff_preview != defaults.sidebar_diff_preview
}
L10nKey::SettingsSshTabTitle => cfg.ssh_tab_title != defaults.ssh_tab_title,
L10nKey::SettingsNotifyOnCommandFinish => {
cfg.notify_on_command_finish != defaults.notify_on_command_finish
}
@@ -1556,6 +1564,7 @@ pub(crate) struct ForwardRuleForm {
pub(crate) target_host: Entity<InputState>,
pub(crate) target_port: Entity<InputState>,
pub(crate) description: Entity<InputState>,
pub(crate) enabled: bool,
}
impl ForwardRuleForm {
@@ -1578,6 +1587,7 @@ impl ForwardRuleForm {
bind,
target,
description: val(&self.description),
enabled: self.enabled,
})
}
@@ -2021,6 +2031,7 @@ fn seed_forward_row(
&rule.description,
t(L10nKey::ForwardDescriptionPlaceholder),
),
enabled: rule.enabled,
}
}
@@ -6229,7 +6240,7 @@ impl Tty7App {
let count = form
.forwards
.iter()
.filter(|r| r.collect(cx).is_some())
.filter(|r| r.enabled && r.collect(cx).is_some())
.count();
let summary = match count {
0 => t(L10nKey::SettingsNoneSummary).to_string(),
@@ -6374,6 +6385,20 @@ impl Tty7App {
.flex_1()
.min_w(px(80.))
.child(Input::new(&row.description).xsmall());
let enabled = div().flex_shrink_0().child(
crate::ui::theme::switch(("ssh-fwd-enabled", idx), cx)
.checked(row.enabled)
.xsmall()
.tooltip(t(L10nKey::SettingsFwdEnabled))
.on_click(cx.listener(move |this, on: &bool, _w, cx| {
if let Some(f) = this.ssh_form_mut()
&& let Some(r) = f.forwards.get_mut(idx)
{
r.enabled = *on;
cx.notify();
}
})),
);
let remove = crate::ui::tab_strip::hit_target(
Button::new(("ssh-fwd-remove", idx))
.icon(Icon::new(IconName::Close))
@@ -6390,6 +6415,7 @@ impl Tty7App {
h_flex()
.gap_2()
.items_center()
.child(enabled)
.child(kind_switch)
.child(description)
.child(remove),
@@ -6398,9 +6424,15 @@ impl Tty7App {
true => mapping(v_flex().gap_1().items_start()),
false => mapping(h_flex().gap_2().items_center()),
}),
false => mapping(h_flex().gap_2().items_center().child(kind_switch))
.child(description)
.child(remove),
false => mapping(
h_flex()
.gap_2()
.items_center()
.child(enabled)
.child(kind_switch),
)
.child(description)
.child(remove),
};
v_flex()
@@ -7662,6 +7694,11 @@ impl Tty7App {
};
let sidebar_diff_preview = cfg.sidebar_diff_preview;
let sidebar_auto_grouping = cfg.sidebar_auto_grouping;
let ssh_tab_title_idx = match cfg.ssh_tab_title {
SshTabTitle::Dynamic => 0,
SshTabTitle::ProfileName => 1,
SshTabTitle::Hostname => 2,
};
let notify_idx = match cfg.notify_on_command_finish {
NotifyMode::Never => 0,
NotifyMode::Unfocused => 1,
@@ -7776,6 +7813,25 @@ impl Tty7App {
)
.into_any_element();
let ssh_tab_title_radio = self.segmented(
"wt-ssh-tab-title",
&[
t(L10nKey::SettingsSshTabTitleDynamic),
t(L10nKey::SettingsSshTabTitleProfileName),
t(L10nKey::SettingsSshTabTitleHostname),
],
ssh_tab_title_idx,
cx,
|this, ix, _w, cx| {
let mode = match ix {
0 => SshTabTitle::Dynamic,
1 => SshTabTitle::ProfileName,
_ => SshTabTitle::Hostname,
};
this.set_ssh_tab_title(mode, cx);
},
);
v_flex()
.when(general, |v| {
v.child(self.section_header(t(L10nKey::SettingsWindow), cx))
@@ -7831,6 +7887,12 @@ impl Tty7App {
sidebar_diff_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsSshTabTitle),
t(L10nKey::SettingsSshTabTitleDesc),
ssh_tab_title_radio,
cx,
))
})
.when(general, |v| {
v.child(self.section_rule(cx))
+123 -1
View File
@@ -461,6 +461,38 @@ fn build_spec_inner(
}
}
/// The name an SSH pane's tab is pinned to under `mode` (#726), or `None` for
/// [`SshTabTitle::Dynamic`], which leaves the tab to the title the pane is
/// showing.
///
/// Built on the same evidence as the name the pane wears before the remote
/// side speaks — [`NativeSshSpec::display_name`], set above — so the pinned
/// name and that one only ever differ where the saved host has been renamed
/// since the pane dialled: the live name wins there, looked up by
/// `profile_id`. An alias from `~/.ssh/config` and a quick connect are not
/// saved hosts, and their `display_name` is the alias and the address typed.
pub(crate) fn pinned_ssh_title(
mode: crate::core::config::SshTabTitle,
spec: &NativeSshSpec,
profiles: &[SshProfile],
) -> Option<String> {
use crate::core::config::SshTabTitle;
let non_blank = |s: &str| Some(s.trim().to_string()).filter(|s| !s.is_empty());
match mode {
SshTabTitle::Dynamic => None,
SshTabTitle::ProfileName => spec
.profile_id
.as_deref()
.and_then(|id| Uuid::parse_str(id).ok())
.and_then(|id| profiles.iter().find(|p| p.id == id))
.and_then(|p| non_blank(&p.name))
.or_else(|| spec.display_name.as_deref().and_then(non_blank))
.or_else(|| non_blank(&spec.host)),
SshTabTitle::Hostname => non_blank(&spec.host),
}
}
/// What a tab's host row opens when it is taken.
///
/// The two halves are not the same form. A saved host is already a record, so
@@ -574,6 +606,7 @@ fn unmap_forward(rule: &SshForwardRule) -> ForwardRule {
bind: HostPort::new(rule.bind_host.clone(), rule.bind_port),
target: HostPort::new(rule.target_host.clone(), rule.target_port),
description: rule.description.clone().unwrap_or_default(),
enabled: rule.enabled,
}
}
@@ -617,7 +650,7 @@ fn map_proxy(profile: &SshProfile) -> SshProxy {
SshProxy::None
}
fn map_forward(rule: &ForwardRule) -> SshForwardRule {
pub(crate) fn map_forward(rule: &ForwardRule) -> SshForwardRule {
SshForwardRule {
kind: match rule.kind {
ForwardKind::Local => SshForwardKind::Local,
@@ -629,6 +662,7 @@ fn map_forward(rule: &ForwardRule) -> SshForwardRule {
target_host: rule.target.host.clone(),
target_port: rule.target.port,
description: (!rule.description.is_empty()).then(|| rule.description.clone()),
enabled: rule.enabled,
}
}
@@ -823,7 +857,16 @@ mod tests {
bind: HostPort::new("localhost", 8080),
target: HostPort::new("127.0.0.1", 80),
description: "web".into(),
enabled: true,
}];
// A switched-off rule survives the trip through a live spec switched
// off, or reopening a host from its connection would turn it back on.
p.forwards.push(ForwardRule {
bind: HostPort::new("localhost", 8080),
target: HostPort::new("10.0.0.7", 80),
enabled: false,
..ForwardRule::default()
});
let spec = build_native_ssh_spec(&p, &[], &store, true);
let back = profile_from_live_spec(&spec);
@@ -1061,4 +1104,83 @@ mod tests {
SshProxy::Http { .. }
));
}
/// #726's three sources under each mode: the saved host's own name, the
/// alias for a `~/.ssh/config` host, and the address for a quick connect;
/// `Hostname` always the address dialled, and `Dynamic` nothing at all.
#[test]
fn a_pinned_ssh_title_comes_from_the_source_the_pane_was_opened_from() {
use crate::core::config::SshTabTitle::{Dynamic, Hostname, ProfileName};
let store = InMemoryCredentialStore::new();
let mut saved = profile("prod-web", "10.0.0.5", "deploy");
let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true);
let profiles = vec![saved.clone()];
assert_eq!(pinned_ssh_title(Dynamic, &spec, &profiles), None);
assert_eq!(
pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
Some("prod-web")
);
assert_eq!(
pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
Some("10.0.0.5")
);
// Renamed after it dialled: the tab follows the saved host.
saved.name = "prod-api".into();
assert_eq!(
pinned_ssh_title(ProfileName, &spec, &[saved.clone()]).as_deref(),
Some("prod-api")
);
// A saved host nobody named reads as its address.
saved.name = " ".into();
let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true);
assert_eq!(
pinned_ssh_title(ProfileName, &spec, &[saved]).as_deref(),
Some("deploy@10.0.0.5")
);
let root = tempfile::tempdir().unwrap();
let ssh = root.path().join(".ssh");
std::fs::create_dir_all(&ssh).unwrap();
std::fs::write(
ssh.join("config"),
"Host bastion\n HostName 192.168.1.9\n User ops\n",
)
.unwrap();
let alias = crate::core::ssh_config::resolve_alias_to_profile_from(
ssh.join("config"),
root.path(),
"bastion",
)
.unwrap();
let spec = native_spec_from_transient_profile(
&alias.profile,
alias.proxy_jump,
&store,
true,
&|_| None,
);
assert_eq!(
pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
Some("bastion")
);
assert_eq!(
pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
Some("192.168.1.9")
);
// What `quick_connect` builds for an address that is no alias.
let mut typed = SshProfile::new("10.0.0.7");
typed.host = "10.0.0.7".into();
typed.user = "root".into();
let spec = build_native_ssh_spec(&typed, &profiles, &store, true);
assert_eq!(
pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
Some("10.0.0.7")
);
assert_eq!(
pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
Some("10.0.0.7")
);
}
}
+16 -1
View File
@@ -547,7 +547,8 @@ impl Tty7App {
let tab = &self.tabs[i];
let is_active = i == active;
let ssh_dot = self.tab_ssh_dot(tab, cx);
let agent = tab.agent(cx);
let asleep = tab.is_asleep();
let agent = tab.agent(cx).or_else(|| tab.asleep_agent());
let agent_status = tab.agent_status(cx);
let agent_unread = tab.agent_unread_count(cx);
let git_cwd = git_click(tab, window, cx);
@@ -562,6 +563,13 @@ impl Tty7App {
} else {
0.
};
// The sleep mark takes the zoom mark's size; a sleeping tab
// has no panes on screen to zoom, so the two never share a row.
let zoom_extra = if asleep {
zoom_extra + row_metrics::ZOOM + row_metrics::GAP
} else {
zoom_extra
};
// Elision is measured against this budget so the label and
// branch never wrap or overflow into CSS truncation.
let label_avail =
@@ -973,6 +981,9 @@ impl Tty7App {
s.text_color(cx.theme().sidebar_foreground)
.hover(|s| s.bg(gpui::rgb(sf.hover)))
})
// Faded as well as marked, the way the strip's chip is: the
// tabs holding nothing should be the quiet ones in the column.
.when(asleep, |s| s.opacity(0.6))
.when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| {
s.opacity(0.75)
})
@@ -1021,6 +1032,9 @@ impl Tty7App {
.when(zoomed, |row| {
row.child(self.zoom_mark(("sidebar-zoom", i), cx))
})
.when(asleep, |row| {
row.child(self.sleep_mark(("sidebar-asleep", i), cx))
})
.child(label_region)
.when(show_badges && badge_pos < 9, |row| {
row.child(
@@ -3991,6 +4005,7 @@ mod fold_tests {
group: None,
last_auto: Some(AutoKey::Repo(PathBuf::from("/w/alpha"))),
root: tty7_core::core::machine::PaneNode::Leaf { pane: 1 },
hibernated: false,
};
app.tabs[i] = crate::ui::app::Tab::from_tree(&tree, pane);
}
+413 -29
View File
@@ -13,9 +13,10 @@ use unicode_segmentation::UnicodeSegmentation as _;
use crate::core::actions::{
CloseActiveTab, CloseOtherTabs, CloseTabsToTheRight, CopyAgentSessionId, CopyWorkingDirectory,
ForkAgentSession, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, SelectWorkspace1,
SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6,
SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette,
ForkAgentSession, HibernateTab, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab,
SelectWorkspace1, SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5,
SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight,
TogglePalette,
};
use crate::core::config::{Config, RightPanelTab};
use crate::core::shells::DetectedShell;
@@ -684,17 +685,27 @@ pub(crate) fn chrome_tile_sized(
/// closes the section is where the rest are.
const MENU_HOSTS: usize = 6;
/// How many shells the New Tab menu names, the default among them.
///
/// The same reasoning as [`MENU_HOSTS`], at a smaller number: a stock macOS box
/// reports nine shells and almost nobody opens more than one or two of them.
/// The default always leads — it is the answer to "what do I get if I just
/// click" — and the rest of the rows go to whatever has actually been opened,
/// by frecency. Everything else is one row away, in the palette.
const MENU_SHELLS: usize = 3;
/// How wide the New Tab menu is allowed to get.
const MENU_W: Pixels = px(360.);
/// How tall, before it starts scrolling.
///
/// Enough for the menu's own full hand — the nine shells a stock macOS box
/// reports, both headings, [`MENU_HOSTS`] hosts and the two rows that close the
/// list, at the 26px a row occupies — so the shape everyone actually sees
/// arrives whole. Past that (a pile of custom shells) it scrolls, and it is
/// capped again against the window in [`NewTabMenu::build`], since a menu taller
/// than what it hangs off is worse than one that scrolls.
/// Both lists are capped — [`MENU_SHELLS`] shells and [`MENU_HOSTS`] hosts —
/// so the menu's full hand is a fixed number of rows: those, both headings, the
/// row closing each section and the modifier hint, at the 26px a row occupies.
/// This leaves room above that for a seam row or two more, so the full hand
/// always arrives whole and never scrolls on its own. It is capped again
/// against the window in [`NewTabMenu::build`], since a menu taller than what
/// it hangs off is worse than one that scrolls.
const MENU_H: Pixels = px(560.);
/// What the row closing the SSH section types into the palette for you.
@@ -715,6 +726,14 @@ const MENU_H: Pixels = px(560.);
/// and opens the unfiltered command list has made the reader ask twice.
const PALETTE_SSH_QUERY: &str = "ssh";
/// What the row closing the Local section types into the palette for you.
///
/// Every shell in the window's inventory is a palette command titled
/// `Shell: {label}` ([`L10nKey::AppCmdShellTitle`], the same word in every
/// language we ship, for the reason [`PALETTE_SSH_QUERY`] gives), so this one
/// word lands on exactly the shells, default first and then by frecency.
const PALETTE_SHELL_QUERY: &str = "shell";
/// How this platform spells the key that turns a New Tab row into a split.
fn split_modifier() -> &'static str {
if cfg!(target_os = "macos") {
@@ -733,8 +752,12 @@ fn split_modifier() -> &'static str {
/// the window sat still is in the list the next time the `+` is pressed.
struct NewTabMenu {
app: gpui::WeakEntity<Tty7App>,
shells: Vec<(SharedString, ShellSpec)>,
/// The shells the menu names, by label — at most [`MENU_SHELLS`].
shells: Vec<SharedString>,
default_shell: SharedString,
/// The inventory holds shells the menu does not name, so the section
/// closes with a row into the palette.
more_shells: bool,
/// Saved host, its display name, and the `user@host:port` beside it —
/// empty when the name already says it.
hosts: Vec<(uuid::Uuid, SharedString, SharedString)>,
@@ -760,21 +783,17 @@ impl NewTabMenu {
// host with a descriptive name and a long `user@host` drags every
// other row out with it and the menu stops looking like chrome.
.max_w(MENU_W)
// Shells are whatever this machine has plus whatever the user
// added by hand, so the row count has no ceiling. Past the height
// Both lists are capped, but a short window is not. Past the height
// of the window an un-scrollable menu simply loses its last rows —
// and the last rows here are the SSH section.
.scrollable(true)
// Only the overflow case should scroll, and the default ceiling is
// too low to tell the two apart: a stock macOS box has nine shells,
// which with both headings, the hosts and the two closing rows
// already runs past `PopupMenu`'s built-in 450px. The menu would
// arrive scrolled on every machine, with `Local` cut off above.
// Our own ceiling rather than `PopupMenu`'s built-in 450px, so the
// one that applies is the one [`MENU_H`] reasons about.
.max_h(ceiling)
.item(PopupMenuItem::label(t(L10nKey::TabMenuLocalShells)));
for (label, spec) in &self.shells {
let spec = spec.clone();
for label in &self.shells {
let app = self.app.clone();
let open = label.clone();
let row = if *label == self.default_shell {
let label = label.clone();
PopupMenuItem::element(move |_window, cx| {
@@ -786,9 +805,7 @@ impl NewTabMenu {
menu = menu.item(row.on_click(move |_, window, cx| {
let at = SpawnWhere::from_modifiers(window.modifiers());
if let Some(app) = app.upgrade() {
app.update(cx, |this, cx| {
this.open_shell(Some(spec.clone()), at, window, cx)
});
app.update(cx, |this, cx| this.open_listed_shell(&open, at, window, cx));
}
}));
}
@@ -805,6 +822,22 @@ impl NewTabMenu {
},
));
}
// The rest of the inventory is in the palette, already filtered to
// it — the same seam the SSH section closes with. Absent when the
// rows above are the whole inventory: a row into a list of nothing
// new would be one more thing to read for no gain.
if self.more_shells {
let app = self.app.clone();
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuOtherShells)).on_click(
move |_, window, cx| {
if let Some(app) = app.upgrade() {
app.update(cx, |this, cx| {
this.open_palette(PALETTE_SHELL_QUERY, window, cx)
});
}
},
));
}
menu = menu
.item(PopupMenuItem::separator())
@@ -850,6 +883,19 @@ impl NewTabMenu {
});
}
}));
// The agents' seam into the palette, the same way the row above is the
// hosts': the menu names none of them, the palette holds them all.
let app = self.app.clone();
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuLaunchAgent)).on_click(
move |_, window, cx| {
if let Some(app) = app.upgrade() {
app.update(cx, |this, cx| {
let query = crate::ui::agent_launch::PALETTE_AGENT_QUERY;
this.open_palette(query, window, cx);
});
}
},
));
// The one place ⌥ is spelled out. Nothing else in the app teaches it,
// and a modifier nobody is told about is a feature nobody has. No rule
@@ -862,6 +908,55 @@ impl NewTabMenu {
}
}
/// Every shell in the inventory, most likely first: the default, then whatever
/// has been opened often and recently, then the rest in the inventory's own
/// order. Shared by the palette and the New Tab menu so the same shell leads
/// both lists.
pub(crate) fn shells_by_frecency<'a>(
shells: &'a [DetectedShell],
default: &str,
usage: &std::collections::HashMap<String, crate::core::config::ProfileUsage>,
now: u64,
) -> Vec<&'a DetectedShell> {
let score = |s: &DetectedShell| usage.get(&s.label).map_or(0.0, |u| u.score(now));
let mut sorted: Vec<&DetectedShell> = shells.iter().collect();
// Stable, so shells nobody has opened keep the order the inventory chose.
sorted.sort_by(|a, b| {
(b.label == default)
.cmp(&(a.label == default))
.then_with(|| {
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
})
});
sorted
}
/// The shells the menu names, off the front of [`shells_by_frecency`]'s list:
/// its head — the default, whenever the inventory has it — and then only
/// shells that have actually been opened, [`MENU_SHELLS`] rows in all.
///
/// A shell nobody has opened is not named just because there is room: a menu
/// that filled its spare rows with `csh` and `tcsh` would be the nine-row list
/// again, only shorter.
fn menu_shells<'a>(
sorted: &[&'a DetectedShell],
default: &str,
usage: &std::collections::HashMap<String, crate::core::config::ProfileUsage>,
now: u64,
) -> Vec<&'a DetectedShell> {
sorted
.iter()
.take(MENU_SHELLS)
.enumerate()
.filter(|(i, s)| {
*i == 0 || s.label == default || usage.get(&s.label).is_some_and(|u| u.score(now) > 0.0)
})
.map(|(_, s)| *s)
.collect()
}
/// The hosts the menu names, in the order they were handed over — frecency,
/// so the ones that fit are the ones actually used.
///
@@ -1485,6 +1580,24 @@ impl Tty7App {
/// Drawn in the tab entry rather than on the pane so it reads from either
/// tab surface, and so it says something about the tabs you are *not*
/// looking at — the zoom outlives a switch away from them.
/// The mark a sleeping tab carries (#762), in the slot the zoom mark uses:
/// leading, beside the other state marks, where the pointer that comes to
/// read it does not cover it.
pub(crate) fn sleep_mark(&self, id: impl Into<gpui::ElementId>, cx: &App) -> gpui::AnyElement {
let tip = SharedString::from(t(L10nKey::TabTooltipAsleep));
div()
.id(id)
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.size(px(16.))
.text_color(cx.theme().muted_foreground)
.child(Icon::new(IconName::Moon).size(px(11.)))
.tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
.into_any_element()
}
pub(crate) fn zoom_mark(&self, id: impl Into<gpui::ElementId>, cx: &App) -> gpui::AnyElement {
let tip = chord_tooltip(t(L10nKey::TabTooltipZoomed), "ToggleMaximizePane", cx);
div()
@@ -1582,19 +1695,50 @@ impl Tty7App {
/// What the menu offers, read off the app as the menu opens — the builder
/// runs on the popup's own entity, so the rows carry a weak handle back.
fn new_tab_menu_rows(&self, app: gpui::WeakEntity<Self>, cx: &App) -> NewTabMenu {
let default_shell = self.default_shell_label(cx);
let usage = &cx.global::<Config>().shell_frecency;
let now = crate::core::config::unix_now();
let sorted = shells_by_frecency(&self.shells.shells, &default_shell, usage, now);
let shells = menu_shells(&sorted, &default_shell, usage, now);
NewTabMenu {
app,
shells: self
.shells
.shells
.iter()
.map(|s| (SharedString::from(s.label.clone()), shell_spec(s)))
more_shells: shells.len() < sorted.len(),
shells: shells
.into_iter()
.map(|s| SharedString::from(s.label.clone()))
.collect(),
default_shell: SharedString::from(self.default_shell_label(cx)),
default_shell: SharedString::from(default_shell),
hosts: menu_hosts(crate::ui::ssh_connect::ssh_profiles_by_frecency(cx)),
}
}
/// Open the inventory's shell by the label it is listed under, wherever
/// `at` says — the New Tab menu's rows and the palette's `Shell:` commands
/// both land here, so both count toward the frecency that orders them.
pub(crate) fn open_listed_shell(
&mut self,
label: &str,
at: SpawnWhere,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(spec) = self
.shells
.shells
.iter()
.find(|s| s.label == label)
.map(shell_spec)
else {
return;
};
self.update_config(cx, |cfg| {
let entry = cfg.shell_frecency.entry(label.to_string()).or_default();
entry.count = entry.count.saturating_add(1);
entry.last_used = crate::core::config::unix_now();
});
self.open_shell(Some(spec), at, window, cx);
}
pub(crate) fn tab_context_menu(
menu: PopupMenu,
index: usize,
@@ -1646,6 +1790,34 @@ impl Tty7App {
);
}
// Sleep and wake (#762). Waking is also what selecting the tab does;
// the item is here for waking one without leaving the tab on screen.
// Hibernate is offered only where the machine can do it, and greyed
// out for a tab it cannot be done to right now — the last one awake,
// or one still connecting — rather than vanishing from under the
// pointer that came looking for it.
if tab.is_some_and(|t| t.is_asleep()) {
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabContextWake)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.wake_tab(index, window, cx));
}
}));
} else if crate::ui::tree_sync::can_hibernate_on(cx, this.workspace) {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::TabContextHibernate))
.action(Box::new(HibernateTab))
.disabled(!this.can_hibernate_tab(index, cx))
.on_click({
let app = app.clone();
move |_, window, cx| {
let _ =
app.update(cx, |this, cx| this.hibernate_tab(index, window, cx));
}
}),
);
}
// Where this tab sits, and where it could be put instead.
//
// Laid out flat rather than behind a "Move to Group ▸" submenu: there
@@ -1945,7 +2117,8 @@ impl Tty7App {
let label = self.tab_label(tab, i, Some(window), cx);
let full_title = self.tab_title_tooltip(tab, i, Some(window), cx);
let ssh_dot = self.tab_ssh_dot(tab, cx);
let agent = tab.agent(cx);
let asleep = tab.is_asleep();
let agent = tab.agent(cx).or_else(|| tab.asleep_agent());
let agent_status = tab.agent_status(cx);
let agent_unread = tab.agent_unread_count(cx);
let zoomed = self.tab_is_zoomed(i);
@@ -2025,6 +2198,9 @@ impl Tty7App {
s.text_color(cx.theme().muted_foreground)
.hover(|s| s.bg(cx.theme().muted))
})
// Faded as well as marked: a row of chips is read at a glance,
// and the tabs holding nothing should be the quiet ones.
.when(asleep, |s| s.opacity(0.6))
.when(dragged, |s| s.opacity(0.75))
.child(
canvas(
@@ -2083,6 +2259,9 @@ impl Tty7App {
.when(zoomed, |chip| {
chip.child(self.zoom_mark(("tab-zoom", i), cx))
})
.when(asleep, |chip| {
chip.child(self.sleep_mark(("tab-asleep", i), cx))
})
.child(label_region)
.when(show_badges && i < 9, |chip| {
chip.child(
@@ -2421,6 +2600,84 @@ mod ssh_host_row_tests {
assert_eq!(spec.port, 2222, "a non-default port is part of the address");
});
}
/// #726 end to end: the setting reaches the name the strip draws, on the
/// title's rung — below a name the user gave the tab, above whatever the
/// remote shell titled itself — and only for an SSH pane.
#[gpui::test]
fn the_ssh_tab_title_setting_pins_only_ssh_tabs_and_only_their_label(cx: &mut TestAppContext) {
use crate::core::config::SshTabTitle;
set_locale("en");
let (app, mut vcx) = harness(cx);
let saved = uuid::Uuid::new_v4();
let set_mode = |cx: &mut gpui::App, mode: SshTabTitle| {
let mut cfg = cx.global::<Config>().clone();
cfg.ssh_tab_title = mode;
cx.set_global(cfg);
};
let (_ends, ssh) = app.update_in(&mut vcx, |app, window, cx| {
let mut cfg = cx.global::<Config>().clone();
let mut profile = SshProfile::new("prod-web");
profile.id = saved;
profile.user = "me".to_string();
profile.host = "build-box".to_string();
cfg.ssh_profiles = vec![profile];
cx.set_global(cfg);
let (local, a) = quiet_test_pane(1, window, cx);
let (ssh, b) = quiet_test_ssh_pane_of(2, Some(saved), window, cx);
// Both have titled themselves over OSC 0/2 the way a shell does.
local.update(cx, |v, _| v.title = "me@laptop:/work/here".into());
ssh.update(cx, |v, _| v.title = "me@build-box:/srv/app".into());
for view in [local, ssh.clone()] {
app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(view))));
}
app.active = 0;
((a, b), ssh)
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| {
let label =
|app: &crate::ui::app::Tty7App, i: usize, window: &gpui::Window, cx: &gpui::App| {
app.tab_label(&app.tabs[i], i, Some(window), cx)
};
let dynamic_local = label(app, 0, window, cx);
let dynamic_ssh = label(app, 1, window, cx);
assert_eq!(dynamic_ssh, "/srv/app", "Dynamic is what tty7 always did");
set_mode(cx, SshTabTitle::ProfileName);
assert_eq!(label(app, 1, window, cx), "prod-web");
assert_eq!(
label(app, 0, window, cx),
dynamic_local,
"a local tab is untouched"
);
assert_eq!(
ssh.read(cx).title,
"me@build-box:/srv/app",
"the remote title is still tracked underneath"
);
set_mode(cx, SshTabTitle::Hostname);
assert_eq!(label(app, 1, window, cx), "build-box");
// A name the user gave the tab outranks the setting.
app.tabs[1].name = Some("mine".into());
assert_eq!(label(app, 1, window, cx), "mine");
app.tabs[1].name = None;
// An ended session keeps saying so under the pinned name.
ssh.update(cx, |v, _| v.terminal.exited = true);
assert_eq!(label(app, 1, window, cx), "build-box — process exited");
ssh.update(cx, |v, _| v.terminal.exited = false);
set_mode(cx, SshTabTitle::Dynamic);
assert_eq!(label(app, 1, window, cx), dynamic_ssh);
});
}
}
#[cfg(test)]
@@ -3276,4 +3533,131 @@ mod tests {
assert_eq!(label_of(&root, 0, Some(home())), "/");
assert_eq!(tooltip_of(&root, 0, Some(home())), None);
}
fn inventory(labels: &[&str]) -> Vec<DetectedShell> {
labels
.iter()
.map(|l| DetectedShell {
label: l.to_string(),
program: format!("/bin/{l}"),
args: Vec::new(),
args_are_tty7_defaults: true,
user_authored: false,
})
.collect()
}
fn used(
entries: &[(&str, u32, u64)],
) -> std::collections::HashMap<String, crate::core::config::ProfileUsage> {
entries
.iter()
.map(|(l, count, last_used)| {
(
l.to_string(),
crate::core::config::ProfileUsage {
count: *count,
last_used: *last_used,
},
)
})
.collect()
}
fn labels(shells: &[&DetectedShell]) -> Vec<String> {
shells.iter().map(|s| s.label.clone()).collect()
}
const STOCK_MAC: &[&str] = &["zsh", "bash", "sh", "csh", "tcsh", "ksh", "dash"];
#[test]
fn the_new_tab_menu_names_the_default_and_what_has_been_opened_by_frecency() {
let now = 100_000_000u64;
let day = 86_400u64;
let shells = inventory(STOCK_MAC);
// `bash` used often and lately; `dash` used more but a year ago;
// `ksh` once, two months back; and the default opened too.
let usage = used(&[
("zsh", 50, now),
("ksh", 1, now - 60 * day),
("bash", 9, now - day),
("dash", 20, now - 365 * day),
]);
let sorted = shells_by_frecency(&shells, "zsh", &usage, now);
assert_eq!(
labels(&sorted),
["zsh", "bash", "dash", "ksh", "sh", "csh", "tcsh"],
"default first, then by frecency, then the inventory's own order"
);
let menu = menu_shells(&sorted, "zsh", &usage, now);
assert_eq!(menu.len(), MENU_SHELLS);
assert_eq!(labels(&menu), ["zsh", "bash", "dash"]);
}
#[test]
fn the_default_shell_leads_the_menu_even_when_it_has_never_been_opened() {
let now = 100_000_000u64;
let shells = inventory(STOCK_MAC);
let usage = used(&[("tcsh", 3, now), ("sh", 7, now), ("ksh", 5, now)]);
let sorted = shells_by_frecency(&shells, "bash", &usage, now);
let menu = menu_shells(&sorted, "bash", &usage, now);
assert_eq!(labels(&menu), ["bash", "sh", "ksh"]);
}
#[test]
fn a_shell_nobody_has_opened_is_not_named_just_because_there_is_room() {
// A fresh install: nothing has been opened, so the menu names the
// default alone and leaves the rest to the palette.
let now = 100_000_000u64;
let shells = inventory(STOCK_MAC);
let none = used(&[]);
let sorted = shells_by_frecency(&shells, "zsh", &none, now);
assert_eq!(labels(&menu_shells(&sorted, "zsh", &none, now)), ["zsh"]);
// A usage record at zero counts as never opened.
let zeroed = used(&[("bash", 0, now)]);
let sorted = shells_by_frecency(&shells, "zsh", &zeroed, now);
assert_eq!(labels(&menu_shells(&sorted, "zsh", &zeroed, now)), ["zsh"]);
// An inventory that does not list the default still offers its head,
// which is the shell a new tab opens with.
let sorted = shells_by_frecency(&shells, "fish", &none, now);
assert_eq!(labels(&menu_shells(&sorted, "fish", &none, now)), ["zsh"]);
}
#[test]
fn other_shells_is_offered_only_when_the_inventory_holds_more_than_the_menu_names() {
let now = 100_000_000u64;
let more = |inv: &[&str], usage: &[(&str, u32, u64)]| {
let shells = inventory(inv);
let usage = used(usage);
let default = inv.first().copied().unwrap_or("");
let sorted = shells_by_frecency(&shells, default, &usage, now);
menu_shells(&sorted, default, &usage, now).len() < sorted.len()
};
assert!(more(STOCK_MAC, &[]), "a stock box names one of seven");
assert!(
!more(&["pwsh"], &[]),
"the default alone is the whole inventory"
);
assert!(
!more(&["zsh", "bash"], &[("bash", 1, now)]),
"every shell is already a row"
);
assert!(
!more(
&["zsh", "bash", "fish"],
&[("bash", 1, now), ("fish", 2, now)]
),
"three shells, all named"
);
assert!(
more(&["zsh", "bash", "fish"], &[("bash", 1, now)]),
"`fish` has never been opened, so it is left to the palette"
);
assert!(
!more(&[], &[]),
"no inventory: the fallback row, nothing more"
);
}
}
+370 -2
View File
@@ -51,6 +51,18 @@ fn classify_tree_link(client: Option<Arc<ControlClient>>) -> TreeLink {
}
}
/// Whether the machine holding `client_ws` can put one of its tabs to sleep.
/// A machine that cannot would take the mark and leave the shells running, or
/// refuse it outright, so a window never offers to on its behalf.
pub(crate) fn can_hibernate_on(cx: &App, client_ws: WorkspaceId) -> bool {
let feature = tty7_core::daemon::control::feature::TAB_HIBERNATE;
let host = WorkspaceStore::host_of(cx, client_ws);
match host.is_local() {
true => crate::ui::local_link::LocalLink::supports(cx, feature),
false => crate::ui::remote_connect::HostLinks::peer_supports(cx, host, feature),
}
}
fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId {
WorkspaceStore::all(cx)
.get(client_ws)
@@ -66,6 +78,9 @@ pub(crate) struct DesiredTab {
pub group: Option<GroupId>,
pub last_auto: Option<AutoKey>,
pub root: DesiredNode,
/// The tab is asleep, and `root` is the layout it will wake into — the
/// same pane ids the machine already holds, none of them running.
pub hibernated: bool,
}
#[derive(Debug, Clone)]
@@ -121,7 +136,15 @@ pub(crate) fn desired_tabs(
let mut active = None;
let mut held = Vec::new();
for (index, tab) in app.tabs.iter().enumerate() {
let Some(root) = desired_node(&tab.pane, remote, cx) else {
// A sleeping tab has nothing on screen to read its layout off, so it
// is read off what it will wake into: the same panes, by the same ids,
// which is what keeps the diff below from taking it for a tab that
// lost them all.
let root = match tab.asleep_layout() {
Some(layout) => desired_node_from_session(layout, remote),
None => desired_node(&tab.pane, remote, cx),
};
let Some(root) = root else {
if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) {
held.push(tab.tree_id.get());
}
@@ -141,11 +164,66 @@ pub(crate) fn desired_tabs(
group: tab.group.get(),
last_auto: tab.auto_group.borrow().clone(),
root,
hibernated: tab.asleep_layout().is_some(),
});
}
(out, active, held)
}
/// [`desired_node`] for a layout that is only a record — a sleeping tab's.
/// A leaf with no id is one the machine never had, and a native SSH leaf in a
/// remote window lives on this client rather than on the window's machine;
/// neither is the machine's to hold, exactly as for a live tab.
fn desired_node_from_session(layout: &SessionPane, remote_window: bool) -> Option<DesiredNode> {
match layout {
SessionPane::Leaf {
cwd,
pane_id,
shell,
ssh_spec,
agent,
agent_session_id,
agent_launch_argv,
} => {
let pane = (*pane_id)?;
if remote_window && ssh_spec.is_some() {
return None;
}
Some(DesiredNode::Leaf {
pane,
seed: PaneSeed {
pane,
cwd: cwd.as_ref().map(|p| p.to_string_lossy().into_owned()),
ssh_spec: ssh_spec.clone(),
agent: agent.map(|agent| AgentFacts {
agent,
session_id: agent_session_id.clone(),
launch_argv: agent_launch_argv.clone(),
status: None,
}),
shell: shell.clone(),
},
})
}
SessionPane::Split { axis, ratio, a, b } => {
let left = desired_node_from_session(a, remote_window);
let right = desired_node_from_session(b, remote_window);
match (left, right) {
(Some(a), Some(b)) => Some(DesiredNode::Split {
axis: match axis {
crate::core::session::SessionAxis::Horizontal => TreeAxis::Horizontal,
crate::core::session::SessionAxis::Vertical => TreeAxis::Vertical,
},
ratio: *ratio,
a: Box::new(a),
b: Box::new(b),
}),
(one, other) => one.or(other),
}
}
}
}
fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool {
match pane {
Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(),
@@ -512,6 +590,13 @@ fn create_tab(
last_auto: want.last_auto.clone(),
});
}
if want.hibernated {
ops.push(ControlRequest::TabSetHibernated {
workspace,
tab: want.id,
hibernated: true,
});
}
mirror.tabs.insert(
index.min(mirror.tabs.len()),
TreeTab {
@@ -520,6 +605,7 @@ fn create_tab(
group: want.group,
last_auto: want.last_auto.clone(),
root,
hibernated: want.hibernated,
},
);
mirror.active = Some(want.id);
@@ -579,6 +665,18 @@ fn reconcile_tab(
last_auto: want.last_auto.clone(),
});
}
// Ahead of anything structural. Going to sleep changes nothing else
// about the tab; waking swaps every pane for its successor, which may
// take a close-and-recreate below — and the mark has to come off the
// tab that exists now, before that one is gone.
if tab.hibernated != want.hibernated {
tab.hibernated = want.hibernated;
ops.push(ControlRequest::TabSetHibernated {
workspace,
tab: want.id,
hibernated: want.hibernated,
});
}
}
let desired_root = want.root.to_pane_node();
@@ -1685,15 +1783,19 @@ pub(crate) fn session_from_tree(
ws: &tty7_core::core::machine::Workspace,
panes: &[PaneRecord],
) -> Session {
let views = tty7_core::core::tab_view::tab_views_of(ws, panes);
let tabs: Vec<SessionTab> = ws
.tabs
.iter()
.map(|tab| SessionTab {
.zip(views)
.map(|(tab, view)| SessionTab {
name: tab.name.clone(),
tree_id: Some(tab.id),
group: tab.group,
last_auto: tab.last_auto.clone(),
pane: session_pane_from_node(&tab.root, panes),
hibernated: tab.hibernated,
asleep_view: tab.hibernated.then_some(view),
})
.collect();
let active = ws
@@ -2761,6 +2863,13 @@ impl Tty7App {
}
true
}
// A tab arriving asleep, or one that someone else put to sleep or
// woke, is not a shape to graft onto this window: building it here
// would spawn what is meant to stay stopped, or leave running what
// was just stopped. Saying the delta did not apply re-pulls the
// tree, and the restore that follows is the one path that already
// knows what a sleeping tab is.
LayoutDelta::TabCreated { tab, .. } if tab.hibernated => false,
LayoutDelta::TabCreated { at, tab } => {
self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx)
}
@@ -2772,6 +2881,7 @@ impl Tty7App {
.and_then(|id| index_of(&self.tabs, id))
.unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1)));
self.maximized = None;
self.wake_active_if_asleep(window, cx);
self.focus_active(window, cx);
}
true
@@ -2819,6 +2929,7 @@ impl Tty7App {
true
}
LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) {
Some(index) if tab.hibernated || self.tabs[index].is_asleep() => false,
Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx),
None => false,
},
@@ -2845,6 +2956,7 @@ impl Tty7App {
}
self.maximized = None;
self.active = index;
self.wake_active_if_asleep(window, cx);
self.focus_active(window, cx);
}
@@ -3578,6 +3690,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
};
assert!(
@@ -4214,6 +4327,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
},
TreeTab {
id: failed,
@@ -4221,6 +4335,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 2 },
hibernated: false,
},
],
active: Some(put_up),
@@ -4307,6 +4422,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 11 },
hibernated: false,
},
TreeTab {
id: theirs.1,
@@ -4314,6 +4430,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 12 },
hibernated: false,
},
],
active: Some(theirs.0),
@@ -4450,6 +4567,7 @@ mod tests {
group: None,
last_auto: None,
root,
hibernated: false,
}
}
@@ -5117,6 +5235,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
};
assert!(apply_to_mirror(
&mut watcher,
@@ -5143,6 +5262,7 @@ mod tests {
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
hibernated: false,
},
pane: None,
},
@@ -5213,6 +5333,7 @@ mod tests {
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
hibernated: false,
}],
active_tab: Some(tab_id),
..Default::default()
@@ -5309,6 +5430,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 7 },
hibernated: false,
}],
active_tab: Some(tab_id),
..Default::default()
@@ -5339,6 +5461,7 @@ mod tests {
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
}],
active_tab: Some(TabId::new()),
..Default::default()
@@ -5368,4 +5491,249 @@ mod tests {
"got {ops:?}"
);
}
fn asleep(id: TabId, root: DesiredNode) -> DesiredTab {
DesiredTab {
hibernated: true,
..tab(id, root)
}
}
/// Going to sleep changes nothing about the tab's shape — the same panes,
/// by the same ids — so the only thing the machine hears is the mark. A
/// tab that lost its panes would have been a close, and the close would
/// have taken every record the wake needs with it.
#[test]
fn a_tab_going_to_sleep_says_so_and_nothing_else() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let root = || split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2));
diff(
ws,
&mut mirror,
&[tab(id, root())],
Some(id),
SyncScope::Full,
&[],
);
let ops = diff(
ws,
&mut mirror,
&[asleep(id, root())],
Some(id),
SyncScope::Full,
&[],
);
assert_eq!(
ops,
vec![ControlRequest::TabSetHibernated {
workspace: ws,
tab: id,
hibernated: true,
}]
);
assert!(mirror.tabs[0].hibernated);
assert!(
diff(
ws,
&mut mirror,
&[asleep(id, root())],
Some(id),
SyncScope::Full,
&[]
)
.is_empty(),
"a tab that stays asleep is not told again"
);
}
/// Waking swaps each stopped pane for its successor. The mark comes off
/// first, while the tab it belongs to is certainly still the one the
/// machine has.
#[test]
fn a_waking_tab_clears_its_mark_before_its_panes_are_replaced() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[asleep(id, leaf(1))],
Some(id),
SyncScope::Full,
&[],
);
let want = vec![tab(id, leaf(9))];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::TabSetHibernated {
workspace: ws,
tab: id,
hibernated: false,
},
ControlRequest::PaneReplace {
workspace: ws,
old: 1,
new: seed(9),
},
]
);
assert!(!mirror.tabs[0].hibernated);
assert_converged(&mirror, &want);
}
/// A window writing a sleeping tab onto a machine that lost it — a store
/// wiped under a remote workspace — writes it back asleep, not as a tab
/// the next restore would spawn.
#[test]
fn a_tab_created_asleep_is_marked_as_it_is_created() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let ops = diff(
ws,
&mut mirror,
&[asleep(id, leaf(4))],
Some(id),
SyncScope::Full,
&[],
);
assert!(
matches!(ops.first(), Some(ControlRequest::TabCreate { .. })),
"{ops:?}"
);
assert!(
ops.contains(&ControlRequest::TabSetHibernated {
workspace: ws,
tab: id,
hibernated: true,
}),
"{ops:?}"
);
assert!(mirror.tabs[0].hibernated);
}
/// The window reads a sleeping tab's layout off the record it kept, and
/// has to hand the machine the very tab the machine already holds: same
/// ids, same seeds, same shape.
#[test]
fn a_sleeping_layout_reads_back_as_the_tree_it_came_from() {
use crate::core::session::SessionAxis;
let leaf_of = |pane: u64, cwd: &str| SessionPane::Leaf {
cwd: Some(std::path::PathBuf::from(cwd)),
pane_id: Some(pane),
shell: None,
ssh_spec: None,
agent: Some(crate::core::cli_agent::CLIAgent::Claude),
agent_session_id: Some(format!("sess-{pane}")),
agent_launch_argv: None,
};
let layout = SessionPane::Split {
axis: SessionAxis::Horizontal,
ratio: 0.3,
a: Box::new(leaf_of(3, "/a")),
b: Box::new(leaf_of(4, "/b")),
};
let node = desired_node_from_session(&layout, false).expect("both leaves have ids");
assert_eq!(
node.to_pane_node(),
PaneNode::Split {
axis: TreeAxis::Horizontal,
ratio: 0.3,
a: Box::new(PaneNode::Leaf { pane: 3 }),
b: Box::new(PaneNode::Leaf { pane: 4 }),
}
);
let seed = node.seed_of(4).expect("pane 4 is a leaf");
assert_eq!(seed.cwd.as_deref(), Some("/b"));
assert_eq!(
seed.agent.as_ref().and_then(|a| a.session_id.as_deref()),
Some("sess-4"),
"the session a wake resumes stays on the record"
);
// A leaf the machine never had is left out, as for a live tab.
let unknown = SessionPane::Split {
axis: SessionAxis::Vertical,
ratio: 0.5,
a: Box::new(leaf_of(3, "/a")),
b: Box::new(SessionPane::Leaf {
cwd: None,
pane_id: None,
shell: None,
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
}),
};
assert_eq!(
desired_node_from_session(&unknown, false).map(|n| n.to_pane_node()),
Some(PaneNode::Leaf { pane: 3 })
);
}
/// A restore reads the mark off the tree: the sleeping tab keeps its pane
/// ids — they are what the wake restores from — and comes with a name read
/// off the tree's records, since nothing in it will be running to say one.
#[test]
fn a_restore_brings_a_sleeping_tab_back_asleep_and_named() {
let (awake, sleeping) = (TabId::new(), TabId::new());
let ws = tty7_core::core::machine::Workspace {
tabs: vec![
TreeTab {
id: awake,
name: None,
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 1 },
hibernated: false,
},
TreeTab {
id: sleeping,
name: None,
group: None,
last_auto: None,
root: PaneNode::Leaf { pane: 2 },
hibernated: true,
},
],
active_tab: Some(awake),
..Default::default()
};
let panes = vec![
PaneRecord {
cwd: Some("/work".into()),
live: true,
..PaneRecord::new(1)
},
PaneRecord {
cwd: Some("/work/api".into()),
osc_title: Some("fixing the switcher".into()),
live: false,
..PaneRecord::new(2)
},
];
let session = session_from_tree(&ws, &panes);
assert!(!session.tabs[0].hibernated);
assert!(session.tabs[0].asleep_view.is_none());
let tab = &session.tabs[1];
assert!(tab.hibernated);
match &tab.pane {
SessionPane::Leaf { pane_id, cwd, .. } => {
assert_eq!(*pane_id, Some(2));
assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api")));
}
_ => panic!("leaf"),
}
let view = tab.asleep_view.as_ref().expect("a sleeping tab is named");
assert_eq!(view.osc_title.as_deref(), Some("fixing the switcher"));
assert!(!view.live);
}
}