Merge branch 'main' into worktree-code-panel

# Conflicts:
#	src/core/config.rs
#	src/ui/mod.rs
#	src/ui/tab_sidebar.rs
#	src/ui/tab_strip.rs
This commit is contained in:
l0ng-ai
2026-07-24 15:15:08 +08:00
59 changed files with 7628 additions and 780 deletions
+47 -25
View File
@@ -33,6 +33,14 @@ const MAX_STDIN: u64 = 64 * 1024;
/// the controlling terminal. Always exits quietly — a hook that fails must
/// never break the agent's own flow (agents surface nonzero exits).
pub fn run_agent_hook(agent: &str, event: &str) {
// Shed our own console before doing anything else. Debug builds are
// console-subsystem (so `println!` logging works while developing the GUI),
// so every hook process Claude Code spawns gets its *own* console window —
// a rash of terminal windows that flash open and vanish as each end-of-turn
// hook fires. We never use this console for I/O (stdin is piped and we write
// to the *agent's* console via AttachConsole), so freeing it now tears the
// window down before it can paint. No-op in release (GUI subsystem) and Unix.
detach_console();
// Not inside tty7 (or a remote shell): stay silent, so globally-installed
// hooks don't leak escape sequences into other terminals.
if std::env::var_os(TTY7_ENV_MARKER).is_none() {
@@ -54,6 +62,26 @@ pub fn run_agent_hook(agent: &str, event: &str) {
write_to_controlling_tty(&build_hook_sequence(agent, event, &input));
}
/// Detach from — and, when we're the only process attached, destroy — the
/// calling process's console. On Windows debug builds each `tty7 agent-hook …`
/// process owns a throwaway console whose window would otherwise flash on
/// screen; freeing it before the window paints removes the flash. The emitter
/// re-attaches to the agent's console via `AttachConsole` when it writes, so
/// this doesn't cost us the output path. No-op where there's no console to free.
#[cfg(not(unix))]
fn detach_console() {
use windows_sys::Win32::System::Console::FreeConsole;
// SAFETY: FreeConsole takes no arguments; it simply returns 0 when the
// process has no attached console (release/GUI builds) and is otherwise a
// clean detach.
unsafe {
FreeConsole();
}
}
#[cfg(unix)]
fn detach_console() {}
/// The sentinel event one hook invocation maps onto, or `None` to stay silent.
/// Most hooks pass their event through; the exception is Copilot's single
/// `notification` hook, which fires for *every* notification type — only
@@ -82,7 +110,7 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
"agent": agent,
"event": event,
});
for key in ["session_id", "message"] {
for key in ["session_id", "message", "cwd"] {
if let Some(v) = payload
.get(key)
.and_then(|v| v.as_str())
@@ -404,7 +432,9 @@ pub fn hooks_state(agent: HookAgent) -> HooksState {
/// Install (or rewrite in place) one agent's tty7 hooks. Idempotent: existing
/// tty7 entries/files are replaced, never duplicated, and anything
/// user-authored is left untouched. Returns a short human-readable summary.
/// user-authored is left untouched. Returns a terse summary meant for the
/// settings row's note line — the row already shows the agent and target
/// path, so the summary never repeats them.
pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
let path = agent
.target_path()
@@ -412,10 +442,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
match agent {
HookAgent::Claude => {
hook_map_install(&path, agent, CLAUDE_HOOK_EVENTS)?;
Ok(format!(
"Claude Code hooks installed in {} — restart running claude sessions to pick them up",
path.display()
))
Ok("Installed".to_string())
}
HookAgent::Codex => {
hook_map_install(&path, agent, CODEX_HOOK_EVENTS)?;
@@ -423,11 +450,10 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
// Best-effort: the file install above is complete and correct
// either way, so a missing codex binary downgrades to advice
// instead of failing the install.
let summary = format!("Codex hooks installed in {}", path.display());
Ok(match enable_codex_hooks_feature() {
Ok(()) => summary,
Ok(()) => "Installed".to_string(),
Err(e) => format!(
"{summary} — couldn't run `codex features enable hooks` ({e}); run it once manually"
"Installed, but couldn't run `codex features enable hooks` ({e}) — run it once manually"
),
})
}
@@ -435,11 +461,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
let content = owned_file_content(agent)
.ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?;
owned_file_install(&path, &content, &agent.marker())?;
Ok(format!(
"{} integration installed at {}",
agent.display_name(),
path.display()
))
Ok("Installed".to_string())
}
}
}
@@ -477,7 +499,11 @@ pub fn refresh_hooks_at_launch() -> usize {
match install_hooks(agent) {
Ok(summary) => {
refreshed += 1;
log::info!("refreshed stale agent hooks: {summary}");
log::info!(
"refreshed stale {} hooks at {}: {summary}",
agent.display_name(),
agent.target_display()
);
}
Err(e) => log::warn!(
"could not refresh stale {} hooks: {e}",
@@ -705,11 +731,7 @@ fn hook_map_uninstall(path: &Path, agent: HookAgent) -> anyhow::Result<String> {
return Ok("No tty7 hooks found; nothing to remove".to_string());
}
crate::core::config::write_atomic(path, serde_json::to_string_pretty(&root)?.as_bytes())?;
Ok(format!(
"{} hooks removed from {}",
agent.display_name(),
path.display()
))
Ok("Removed".to_string())
}
/// The tty7 hook command inside one matcher entry
@@ -740,10 +762,9 @@ fn enable_codex_hooks_feature() -> Result<(), String> {
.chain(home_dir().map(|h| h.join(".local/bin/codex")))
.find(|p| p.exists());
let program = candidates.unwrap_or_else(|| PathBuf::from("codex"));
match std::process::Command::new(&program)
.args(["features", "enable", "hooks"])
.output()
{
let mut cmd = std::process::Command::new(&program);
cmd.args(["features", "enable", "hooks"]);
match crate::core::proc::hide_console(&mut cmd).output() {
Ok(out) if out.status.success() => Ok(()),
Ok(out) => Err(format!(
"codex exited with {}: {}",
@@ -828,7 +849,7 @@ fn owned_file_uninstall(path: &Path, marker: &str) -> anyhow::Result<String> {
{
let _ = std::fs::remove_dir(parent);
}
Ok(format!("Removed {}", path.display()))
Ok("Removed".to_string())
}
/// Copilot hook file (`~/.copilot/hooks/tty7.json`): Copilot auto-loads every
@@ -957,6 +978,7 @@ mod tests {
assert_eq!(ev.kind, AgentEventKind::Notification);
assert_eq!(ev.session_id.as_deref(), Some("abc-123"));
assert!(ev.message.as_deref().unwrap().contains("permission"));
assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w")));
// Garbage stdin still yields a well-formed bare event.
let seq = build_hook_sequence("claude", "stop", "not json at all");
+433 -17
View File
@@ -161,7 +161,18 @@ impl CLIAgent {
/// native session id, or `None` for agents without a known resume flag.
/// The id is what the agent reported in its `session-start` event (see
/// [`AgentEvent`]); commands mirror cmux's per-agent resume table.
pub fn resume_command(self, session_id: &str) -> Option<String> {
///
/// `launch_argv` is the argv the agent was originally launched with, when
/// the daemon observed one. Its flags (`--dangerously-skip-permissions`,
/// `--model …`) are carried onto the resume command so the restored
/// session runs in the same mode the user picked — verbatim only when the
/// whole tail passes the conservative shell-safety gate; otherwise the
/// bare table command still resumes, just without the flags.
pub fn resume_command(
self,
session_id: &str,
launch_argv: Option<&[String]>,
) -> Option<String> {
// Ids come from the agent's own events, but they still land on a shell
// command line — refuse anything that isn't a plain token so a
// malicious/corrupt id can't smuggle shell syntax.
@@ -172,25 +183,143 @@ impl CLIAgent {
{
return None;
}
// The user's launch flags, pre-joined with a leading space so they
// splice into the format strings below; empty when none survive.
let flags = launch_argv
.and_then(|argv| self.replay_flags(argv))
.map(|flags| {
flags.iter().fold(String::new(), |mut s, f| {
s.push(' ');
s.push_str(f);
s
})
})
.unwrap_or_default();
match self {
CLIAgent::Claude => Some(format!("claude --resume {session_id}")),
CLIAgent::Codex => Some(format!("codex resume {session_id}")),
CLIAgent::Gemini => Some(format!("gemini --resume {session_id}")),
CLIAgent::OpenCode => Some(format!("opencode --session {session_id}")),
CLIAgent::Amp => Some(format!("amp threads continue {session_id}")),
CLIAgent::Cursor => Some(format!("cursor-agent --resume {session_id}")),
CLIAgent::Claude => Some(format!("claude{flags} --resume {session_id}")),
// Codex resumes via a subcommand that accepts the interactive
// options after the positional id (`codex resume [OPTIONS]
// [SESSION_ID]`).
CLIAgent::Codex => Some(format!("codex resume {session_id}{flags}")),
CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")),
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")),
// Amp's global options (`--dangerously-allow-all`, …) are accepted
// by the `threads continue` subcommand (verified: unknown options
// are a parse error, globals pass).
CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")),
CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")),
// Copilot CLI: `copilot --resume <sessionId>` (`-r` shorthand) —
// the one hooks-covered agent that was missing from this table.
CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")),
_ => None,
}
}
/// The launch-flag tail of `argv` worth replaying on a resume command, or
/// `None` to resume bare. Deliberately conservative: anything ambiguous
/// falls back to no flags rather than a corrupted command line.
///
/// - The tail is everything after the token that names this agent (the
/// launcher itself, or the script path in an interpreter-wrapped argv);
/// leading `VAR=value` env assignments are skipped first so they can't
/// mis-anchor (`CLAUDE_CONFIG_DIR=/opt/claude claude …`). No naming
/// token at all (custom wrapper rules) → no flags.
/// - Stale session-targeting flags (`--resume old-id`, `--continue`, a
/// re-launched `codex resume <id>`) are stripped — the new id must win.
/// - Every surviving token must be a plain shell-safe word, the first must
/// be a `-` flag, and no two bare words may run consecutively — a bare
/// word is only acceptable as the value directly behind a flag; anything
/// else is a positional prompt that must not re-submit itself into the
/// resumed session. Any violation drops the whole tail.
fn replay_flags(self, argv: &[String]) -> Option<Vec<String>> {
let names_self = |token: &str| {
token.split(['/', '\\']).any(|seg| {
CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self)
})
};
let argv = &argv[argv.iter().take_while(|t| is_env_assignment(t)).count()..];
let named = argv.iter().position(|t| names_self(t))?;
let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect();
// A relaunched `codex resume <old-id>`: drop the subcommand and its id
// so they don't replay as a positional prompt.
if self == CLIAgent::Codex && tail.first() == Some(&"resume") {
tail.remove(0);
if tail.first().is_some_and(|t| !t.starts_with('-')) {
tail.remove(0);
}
}
// Session-targeting flags whose old value must not survive; each is
// stripped together with one following non-flag value token (harmless
// for the value-less ones — anything trailing them is positional).
let stale: &[&str] = match self {
CLIAgent::Claude => &[
"--resume",
"-r",
"--continue",
"-c",
"--session-id",
"--from-pr",
],
CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"],
CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"],
CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c"],
// `--last` targets "the most recent session" and would contradict
// the explicit id we inject.
CLIAgent::Codex => &["--last"],
_ => &[],
};
let mut i = 0;
while i < tail.len() {
let t = tail[i];
if stale.contains(&t)
|| stale
.iter()
.any(|f| f.len() > 2 && t.starts_with(&format!("{f}=")))
{
tail.remove(i);
if i < tail.len() && !tail[i].starts_with('-') {
tail.remove(i);
}
} else {
i += 1;
}
}
// The safety gate: plain tokens only, and a flag-shaped tail — every
// bare word must sit directly behind a `-` flag (its value slot); the
// first token being bare, or two bare words in a row, is a positional
// prompt and drops the whole tail. (A single bare word behind a
// boolean flag is indistinguishable from a flag value and slips
// through — the residual ambiguity of not knowing each flag's arity.)
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())
}
/// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility
/// on both light and dark themes rather than exact brand black/white — a pure
/// black or white dot vanishes against one theme, so vendors whose mark is
/// monochrome (Codex/OpenAI, Cursor) get a recognizable mid-tone hue instead.
/// on both light and dark themes rather than exact brand black/white. A pure
/// black or white dot vanishes against one theme, so monochrome vendors get
/// a recognizable mid-tone hue instead; Codex keeps its black field.
pub fn accent_rgb(self) -> u32 {
match self {
CLIAgent::Claude => 0xD97757, // Claude terracotta
CLIAgent::Codex => 0x10A37F, // OpenAI green (black mark reads as this)
CLIAgent::Codex => 0x000000, // Codex black field
CLIAgent::Gemini => 0x4285F4, // Google blue
CLIAgent::Aider => 0x14B8A6, // teal
CLIAgent::Amp => 0xF34E3F, // Amp red
@@ -479,12 +608,39 @@ pub struct AgentSessionState {
/// key its own `--resume` flag takes — persisted for restore.
#[serde(default)]
pub session_id: Option<String>,
/// The argv the agent was launched with, as the daemon observed it (the
/// foreground process-table poll on Unix, the shell integration's typed
/// `133;C` capture on Windows). Persisted alongside the session id so
/// restore can carry the user's launch flags
/// (`--dangerously-skip-permissions`, `--model …`) onto the resume
/// command — see [`CLIAgent::resume_command`]. Not touched by
/// [`apply_event`](Self::apply_event); the daemon stamps it from the
/// identity-detection side.
#[serde(default)]
pub launch_argv: Option<Vec<String>>,
/// Whether this state came from the rich sentinel channel (hooks
/// installed) rather than the opaque OSC 9/777 fallback. Rich state drives
/// turn-level notifications; fallback state only paints the dot (the
/// agent's own notification text was already toasted by the client).
#[serde(default)]
pub rich: bool,
/// The agent's working directory as its hook payloads report it — the
/// agent's own claim, which tracks internal chdirs the PTY can't show
/// (Claude Code's EnterWorktree moves the session without any shell `cd`).
/// Cleared on `session-end` so a finished session can't pin consumers to
/// a stale path; while absent, consumers fall back to the pane's proc cwd.
#[serde(default)]
pub cwd: Option<std::path::PathBuf>,
/// Tool completions seen in this session, counted only so consumers can
/// spot *that* the agent did something — a turn's edits land tool by tool,
/// and the status alone can't say so (`ToolComplete` is a no-op transition
/// during normal work, by design). The sidebar's git probe watches this to
/// refresh mid-turn instead of waiting for `stop`; see
/// [`TerminalView::refresh_git_status`](crate::terminal::view::TerminalView).
/// Monotonic within a session and never reset — consumers compare against
/// the value they last saw, so only the *change* means anything.
#[serde(default)]
pub activity: u64,
}
impl AgentStatus {
@@ -512,6 +668,9 @@ impl AgentSessionState {
if let Some(id) = &ev.session_id {
self.session_id = Some(id.clone());
}
if let Some(cwd) = &ev.cwd {
self.cwd = Some(cwd.clone());
}
match ev.kind {
AgentEventKind::SessionStart => {
self.status = AgentStatus::Idle;
@@ -549,6 +708,10 @@ impl AgentSessionState {
// stream of completions during normal work is a no-op and can
// never overwrite Done between turns.
AgentEventKind::ToolComplete => {
// The count moves even when the status doesn't: a tool call is
// the one signal that the working tree may have just changed
// under a turn that won't end for minutes.
self.activity = self.activity.wrapping_add(1);
if self.status == AgentStatus::Waiting {
self.status = AgentStatus::Working;
self.message = None;
@@ -560,9 +723,12 @@ impl AgentSessionState {
}
// The agent session ended but its id stays: Claude & friends can
// resume an *ended* session, which is exactly what restore does.
// Its cwd claim does NOT stay: with no agent running, the pane's
// real (proc-observed) directory is the truth again.
AgentEventKind::SessionEnd => {
self.status = AgentStatus::Idle;
self.message = None;
self.cwd = None;
}
}
}
@@ -596,6 +762,9 @@ pub struct AgentEvent {
pub kind: AgentEventKind,
pub session_id: Option<String>,
pub message: Option<String>,
/// The agent's working directory at the moment the hook fired, when the
/// payload carries one (Claude Code sends it on every hook event).
pub cwd: Option<std::path::PathBuf>,
}
/// Parse a complete OSC payload (identifier included, e.g.
@@ -621,6 +790,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
session_id: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
cwd: Option<String>,
}
let w: Wire = serde_json::from_slice(json).ok()?;
@@ -631,6 +802,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
kind,
session_id: nonempty(w.session_id),
message: nonempty(w.message),
cwd: nonempty(w.cwd).map(std::path::PathBuf::from),
})
}
@@ -744,6 +916,11 @@ mod tests {
}
}
#[test]
fn codex_avatar_uses_its_black_brand_field() {
assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000);
}
#[test]
fn detects_newer_agents_by_command() {
for (cmd, agent) in [
@@ -880,6 +1057,7 @@ mod tests {
kind,
session_id: id.map(String::from),
message: msg.map(String::from),
cwd: None,
};
s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1")));
@@ -939,22 +1117,260 @@ mod tests {
assert_eq!(s.session_id.as_deref(), Some("sid-1"));
}
/// Tool completions are deliberately a *status* no-op during normal work
/// (the assertions above), which leaves consumers watching the status with
/// no way to tell that an agent mid-turn just wrote a file. `activity` is
/// what makes them observable: it moves on every completion, in every
/// status, and never rewinds — the sidebar's git probe compares it against
/// the value it last saw.
#[test]
fn tool_completions_count_even_when_the_status_holds_still() {
let ev = |kind| AgentEvent {
agent: Some(CLIAgent::Claude),
kind,
session_id: None,
message: None,
cwd: None,
};
let mut s = AgentSessionState::default();
s.apply_event(&ev(AgentEventKind::PromptSubmit));
assert_eq!(s.activity, 0, "a turn starting is not tool activity");
for n in 1..=3 {
s.apply_event(&ev(AgentEventKind::ToolComplete));
assert_eq!(s.status, AgentStatus::Working, "the status holds still…");
assert_eq!(s.activity, n, "…while the counter is what moves");
}
// A straggler after the turn ended still counts: it may well have
// written a file, and it must not be mistaken for "nothing happened".
s.apply_event(&ev(AgentEventKind::Stop));
s.apply_event(&ev(AgentEventKind::ToolComplete));
assert_eq!(
s.status,
AgentStatus::Done,
"and still doesn't resurrect the turn"
);
assert_eq!(s.activity, 4);
// Session end resets plenty of state but not this — a rewind to 0 would
// read to a delta-comparing consumer as one more tool call.
s.apply_event(&ev(AgentEventKind::SessionEnd));
assert_eq!(s.activity, 4);
}
/// The agent's cwd claim: any event carrying one sets it, later events
/// without one leave it alone (mid-turn events keep the worktree path
/// alive), and session end drops it — an exited agent must not pin the
/// pane's git line to a directory nothing runs in anymore.
#[test]
fn session_state_tracks_and_releases_the_agent_cwd() {
use std::path::PathBuf;
let ev = |kind, cwd: Option<&str>| AgentEvent {
agent: Some(CLIAgent::Claude),
kind,
session_id: None,
message: None,
cwd: cwd.map(PathBuf::from),
};
let mut s = AgentSessionState::default();
s.apply_event(&ev(AgentEventKind::SessionStart, Some("/repo")));
assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo")));
// EnterWorktree lands as a tool-complete carrying the new directory.
s.apply_event(&ev(
AgentEventKind::ToolComplete,
Some("/repo/.claude/worktrees/fix-x"),
));
assert_eq!(
s.cwd.as_deref(),
Some(std::path::Path::new("/repo/.claude/worktrees/fix-x"))
);
// An event without a cwd (another agent's sparser payload) keeps it.
s.apply_event(&ev(AgentEventKind::Stop, None));
assert_eq!(
s.cwd.as_deref(),
Some(std::path::Path::new("/repo/.claude/worktrees/fix-x"))
);
s.apply_event(&ev(AgentEventKind::SessionEnd, None));
assert_eq!(s.cwd, None, "session end releases the cwd claim");
}
#[test]
fn resume_commands_are_shell_safe() {
assert_eq!(
CLIAgent::Claude.resume_command("abc-123").as_deref(),
CLIAgent::Claude.resume_command("abc-123", None).as_deref(),
Some("claude --resume abc-123")
);
assert_eq!(
CLIAgent::Codex.resume_command("th_read.9").as_deref(),
CLIAgent::Codex.resume_command("th_read.9", None).as_deref(),
Some("codex resume th_read.9")
);
// No resume flag known → None.
assert_eq!(CLIAgent::Aider.resume_command("abc"), None);
assert_eq!(CLIAgent::Aider.resume_command("abc", None), None);
// An id carrying shell syntax is refused outright.
assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /"), None);
assert_eq!(CLIAgent::Claude.resume_command("$(boom)"), None);
assert_eq!(CLIAgent::Claude.resume_command(""), None);
assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None);
assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None);
assert_eq!(CLIAgent::Claude.resume_command("", None), None);
}
#[test]
fn resume_carries_launch_flags() {
let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::<Vec<_>>();
// The headline case: the user's mode flags survive the restart.
assert_eq!(
CLIAgent::Claude
.resume_command(
"abc-123",
Some(&argv(&["claude", "--dangerously-skip-permissions"]))
)
.as_deref(),
Some("claude --dangerously-skip-permissions --resume abc-123")
);
// Value-taking flags ride along whole.
assert_eq!(
CLIAgent::Claude
.resume_command("abc", Some(&argv(&["claude", "--model", "opus"])))
.as_deref(),
Some("claude --model opus --resume abc")
);
// Interpreter-wrapped launch: flags start after the token naming the
// agent, and the table's launcher name is what replays.
assert_eq!(
CLIAgent::Claude
.resume_command(
"abc",
Some(&argv(&[
"node",
"/x/node_modules/@anthropic-ai/claude-code/cli.js",
"--dangerously-skip-permissions",
]))
)
.as_deref(),
Some("claude --dangerously-skip-permissions --resume abc")
);
// A stale session-targeting flag is stripped — the new id must win.
assert_eq!(
CLIAgent::Claude
.resume_command(
"new-id",
Some(&argv(&["claude", "--resume", "old-id", "--model", "opus"]))
)
.as_deref(),
Some("claude --model opus --resume new-id")
);
// Codex resumes via its subcommand, flags after the positional id; a
// relaunched `codex resume <old>` sheds the old subcommand + id.
assert_eq!(
CLIAgent::Codex
.resume_command("id-1", Some(&argv(&["codex", "--yolo"])))
.as_deref(),
Some("codex resume id-1 --yolo")
);
assert_eq!(
CLIAgent::Codex
.resume_command("id-2", Some(&argv(&["codex", "resume", "id-1", "--yolo"])))
.as_deref(),
Some("codex resume id-2 --yolo")
);
// Anything shell-unsafe or positional-shaped drops the WHOLE tail —
// resume still works, just bare.
assert_eq!(
CLIAgent::Claude
.resume_command(
"abc",
Some(&argv(&["claude", "--allowedTools", "Bash(git:*)"]))
)
.as_deref(),
Some("claude --resume abc")
);
assert_eq!(
CLIAgent::Claude
.resume_command("abc", Some(&argv(&["claude", "fix-the-bug"])))
.as_deref(),
Some("claude --resume abc")
);
// A leading env assignment doesn't mis-anchor the flag tail.
assert_eq!(
CLIAgent::Claude
.resume_command(
"abc",
Some(&argv(&[
"CLAUDE_CONFIG_DIR=/opt/claude",
"claude",
"--dangerously-skip-permissions",
]))
)
.as_deref(),
Some("claude --dangerously-skip-permissions --resume abc")
);
// Two consecutive bare words = a positional prompt, not a flag value —
// it must not re-submit itself into the resumed session.
assert_eq!(
CLIAgent::Claude
.resume_command(
"abc",
Some(&argv(&["claude", "--model", "opus", "review", "this"]))
)
.as_deref(),
Some("claude --resume abc")
);
// Codex `--last` targets "most recent" and would contradict the
// explicit id → stripped.
assert_eq!(
CLIAgent::Codex
.resume_command(
"id-3",
Some(&argv(&["codex", "resume", "--last", "--yolo"]))
)
.as_deref(),
Some("codex resume id-3 --yolo")
);
// No token names the agent (custom wrapper rule) → bare.
assert_eq!(
CLIAgent::Claude
.resume_command(
"abc",
Some(&argv(&["cc", "--dangerously-skip-permissions"]))
)
.as_deref(),
Some("claude --resume abc")
);
// Amp: global mode flags ride after the `threads continue` positional.
assert_eq!(
CLIAgent::Amp
.resume_command("t-1", Some(&argv(&["amp", "--dangerously-allow-all"])))
.as_deref(),
Some("amp threads continue t-1 --dangerously-allow-all")
);
// A relaunch via `amp threads continue …` is subcommand-shaped, not
// flag-shaped → bare (the gate rejects the leading bare word).
assert_eq!(
CLIAgent::Amp
.resume_command("t-2", Some(&argv(&["amp", "threads", "continue", "t-1"])))
.as_deref(),
Some("amp threads continue t-2")
);
// Copilot resumes by flag, stale session targeting stripped.
assert_eq!(
CLIAgent::Copilot
.resume_command(
"s-9",
Some(&argv(&["copilot", "--resume", "s-1", "--allow-all-tools"]))
)
.as_deref(),
Some("copilot --allow-all-tools --resume s-9")
);
assert_eq!(
CLIAgent::Copilot.resume_command("s-9", None).as_deref(),
Some("copilot --resume s-9")
);
}
#[test]
+91
View File
@@ -42,6 +42,18 @@ pub struct Config {
/// `~/.config/tty7/themes/*`); unknown ids fall back to the default theme. The
/// native chrome is forced to match the theme's light/dark brightness.
pub theme_preset: String,
/// Follow the OS light/dark appearance. When `true` the active theme is
/// resolved from `theme_preset_light` / `theme_preset_dark` by the current
/// system appearance (switching live when the OS mode flips) and
/// `theme_preset` is ignored; the native chrome follows the OS instead of
/// being pinned to the theme.
pub theme_follow_system: bool,
/// Theme id used while `theme_follow_system` is on and the OS is in light
/// mode. Same registry/fallback rules as `theme_preset`.
pub theme_preset_light: String,
/// Theme id used while `theme_follow_system` is on and the OS is in dark
/// mode. Same registry/fallback rules as `theme_preset`.
pub theme_preset_dark: String,
/// Global window-opacity override, 0.2–1.0. `None` (the default) follows the
/// active theme's own `opacity`; when set it applies to every theme, so a
/// chosen translucency survives theme switches.
@@ -75,6 +87,14 @@ pub struct Config {
/// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on
/// hover, and open them on ⌘/Ctrl-click. On by default.
pub link_url: bool,
/// Optional command template run when ⌘/Ctrl-clicking a detected file-path
/// link, instead of tty7's built-in "open in the default app" behavior. The
/// template is tokenized on whitespace and the placeholders `{path}`,
/// `{line}`, and `{column}` are substituted per argument; an argument that
/// contains a placeholder with no value (e.g. `{line}` on a link that has no
/// line number) is dropped. `None` (the default) keeps the built-in open.
/// Example: `"herdr edit {path} --line {line}"`.
pub link_file_command: Option<String>,
/// When a pane is in a detected SSH session, Command-clicking loopback URLs
/// opens them through a temporary local SSH port-forward. Off by default
/// because it starts background `ssh` processes.
@@ -152,6 +172,13 @@ pub struct Config {
/// visual flash (the current behavior).
#[serde(default, deserialize_with = "de_lenient")]
pub bell: BellMode,
/// Tab at the prompt opens tty7's own completion menu (commands, paths,
/// per-command signatures). On by default. When off — or whenever the
/// engine has nothing to offer — the prompt line is handed to the shell
/// and Tab goes to the PTY, so the shell's native completion (compsys,
/// fzf-tab, …) answers instead.
#[serde(default = "default_true")]
pub tab_completion: bool,
// ── Appearance ──────────────────────────────────────────────────────────
/// The shape drawn for the terminal cursor.
@@ -189,6 +216,19 @@ pub struct Config {
/// the clipboard is never overwritten by a stray selection unless opted
/// into.
pub copy_on_select: bool,
/// Double-click smart selection: expand the selection to the whole URL,
/// email address, file path, or matching bracket pair under the cursor
/// when the plain word sits inside one. On by default; off restores the
/// bare word-boundary double-click.
#[serde(default = "default_true")]
pub smart_select: bool,
/// Characters (besides whitespace) that end a double-click word
/// selection, in both the terminal grid and the prompt's command editor.
/// The default mirrors alacritty's semantic escape set — note `/ . - _`
/// are *not* separators, so paths select as one word. JSON-only (no GUI
/// widget yet).
#[serde(default = "default_word_separators")]
pub word_separators: String,
/// Window state at launch: normal / maximized / fullscreen.
#[serde(default, deserialize_with = "de_lenient")]
pub startup_mode: StartupMode,
@@ -450,6 +490,11 @@ impl Default for Config {
// The default theme id (mirrors `ui::presets::DEFAULT_ID`; core can't
// depend on ui). Unknown ids fall back to it anyway.
theme_preset: "light".to_string(),
theme_follow_system: false,
// The built-in light/dark pair; each side is user-swappable in
// Settings once "sync with system" is on.
theme_preset_light: "light".to_string(),
theme_preset_dark: "dark".to_string(),
window_opacity: None,
window_blur: None,
keybindings: HashMap::new(),
@@ -464,6 +509,7 @@ impl Default for Config {
// out: URL detection on, cursor blinking, 10k scrollback, new tabs
// after the active one, notify only while unfocused.
link_url: true,
link_file_command: None,
ssh_loopback_forward: false,
cursor_blink: true,
scrollback_limit: 10_000,
@@ -487,6 +533,7 @@ impl Default for Config {
// Visual flash preserves the pre-config behavior (the bell always
// flashed); opting into None/Audible is a deliberate change.
bell: BellMode::Visual,
tab_completion: true,
cursor_style: CursorStyle::Block,
// Input/mouse defaults preserve today's behavior: Option composes
// characters as macOS ships it (opt into Option-as-Meta); GPUI
@@ -500,6 +547,8 @@ impl Default for Config {
mouse_reporting: true,
clipboard_trim_trailing_spaces: false,
copy_on_select: false,
smart_select: true,
word_separators: default_word_separators(),
startup_mode: StartupMode::Normal,
remember_window_size: true,
working_directory: WorkingDirectory::default(),
@@ -584,6 +633,14 @@ impl Config {
self.right_panel_width = default_right_panel_width();
}
self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
// An empty or whitespace-only file-open command means "no override"; the
// settings text field yields `""` when cleared, so fold it back to `None`
// rather than trying to run an empty command.
if let Some(command) = &self.link_file_command
&& command.trim().is_empty()
{
self.link_file_command = None;
}
}
/// Write the current config back to disk, creating the parent directory if
@@ -772,6 +829,13 @@ fn default_true() -> bool {
true
}
/// Serde default for [`Config::word_separators`]: alacritty's stock semantic
/// escape set, the boundary characters double-click word selection used
/// before this was configurable.
fn default_word_separators() -> String {
",│`|:\"' ()[]{}<>\t".to_string()
}
/// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a
/// command had to cross before this was configurable.
fn default_notify_threshold_secs() -> u64 {
@@ -882,6 +946,27 @@ mod tests {
assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4);
}
#[test]
fn theme_follow_system_defaults_and_round_trips() {
// Old configs (no follow-system keys) must land on off + the built-in
// light/dark pair, so nothing changes until the user opts in.
let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap();
assert!(!cfg.theme_follow_system);
assert_eq!(cfg.theme_preset_light, "light");
assert_eq!(cfg.theme_preset_dark, "dark");
assert_eq!(cfg.theme_preset, "dracula");
let mut cfg = Config::default();
cfg.theme_follow_system = true;
cfg.theme_preset_light = "one_light".to_string();
cfg.theme_preset_dark = "dracula".to_string();
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert!(back.theme_follow_system);
assert_eq!(back.theme_preset_light, "one_light");
assert_eq!(back.theme_preset_dark, "dracula");
}
#[test]
fn font_features_are_optional_and_parse_as_gpui_features() {
let cfg: Config =
@@ -1080,6 +1165,7 @@ mod tests {
let cfg = Config::default();
assert!(cfg.restore_session);
assert!(cfg.mouse_reporting);
assert!(cfg.tab_completion);
assert_eq!(cfg.notify_threshold_secs, 10);
assert_eq!(cfg.bell, BellMode::Visual);
@@ -1088,9 +1174,14 @@ mod tests {
let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
assert!(cfg.restore_session);
assert!(cfg.mouse_reporting);
assert!(cfg.tab_completion);
assert_eq!(cfg.notify_threshold_secs, 10);
assert_eq!(cfg.bell, BellMode::Visual);
// The opt-out round-trips.
let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap();
assert!(!cfg.tab_completion);
// Valid values round-trip; a bad bell string falls back without failing
// the whole parse.
let cfg: Config = serde_json::from_str(
+1
View File
@@ -18,6 +18,7 @@ pub mod config;
#[allow(dead_code)]
pub mod keychain;
pub mod osc;
pub mod proc;
pub mod session;
pub mod shells;
pub mod ssh_config;
+40
View File
@@ -0,0 +1,40 @@
//! One place for the Windows subprocess flag every helper shell-out needs.
//!
//! tty7 is a GUI process with no console of its own, so launching a console
//! subsystem program (`git.exe`, `wsl.exe`, …) makes Windows allocate a fresh
//! console for it — a black window that pops up and vanishes. That is invisible
//! on a one-off invocation and very visible on the git-status probe, which runs
//! four `git` calls every time a pane's cwd changes or a command ends.
//!
//! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are
//! unaffected, so output capture keeps working. Every non-PTY `Command` in the
//! app should go through [`hide_console`] (or [`hide_console_tokio`] for the
//! async flavor) before it runs. PTY children are not in scope — the daemon
//! owns those and passes its own flags (see [`crate::daemon::spawn`]).
use std::process::Command;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Suppress the console window Windows would otherwise allocate for a console
/// subsystem child. No-op on Unix, so callers stay `cfg`-free.
pub fn hide_console(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// [`hide_console`] for `tokio::process::Command`. Separate because tokio's
/// builder is a distinct type with its own `creation_flags`, not a `Deref` to
/// the std one.
pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command {
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
+25
View File
@@ -54,6 +54,12 @@ pub enum SessionPane {
agent: Option<crate::core::cli_agent::CLIAgent>,
#[serde(default)]
agent_session_id: Option<String>,
/// The argv the agent was launched with, as the daemon observed it —
/// lets the resume command carry the user's launch flags
/// (`--dangerously-skip-permissions`, …) instead of resuming bare.
/// `None` for old sessions or when nothing was captured.
#[serde(default)]
agent_launch_argv: Option<Vec<String>>,
},
/// A split of two subtrees along `axis`, with `a` taking `ratio` of space.
Split {
@@ -192,6 +198,7 @@ mod tests {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
},
},
SessionTab {
@@ -206,6 +213,7 @@ mod tests {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
}),
b: Box::new(SessionPane::Leaf {
cwd: Some(PathBuf::from("/tmp")),
@@ -213,6 +221,7 @@ mod tests {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
}),
},
},
@@ -244,6 +253,10 @@ mod tests {
ssh_spec: None,
agent: Some(crate::core::cli_agent::CLIAgent::Claude),
agent_session_id: Some("abc-123".into()),
agent_launch_argv: Some(vec![
"claude".into(),
"--dangerously-skip-permissions".into(),
]),
};
let back: SessionPane =
serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap();
@@ -251,10 +264,20 @@ mod tests {
SessionPane::Leaf {
agent,
agent_session_id,
agent_launch_argv,
..
} => {
assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude));
assert_eq!(agent_session_id.as_deref(), Some("abc-123"));
assert_eq!(
agent_launch_argv.as_deref(),
Some(
&[
"claude".to_string(),
"--dangerously-skip-permissions".to_string()
][..]
)
);
}
_ => panic!("expected leaf"),
}
@@ -266,6 +289,7 @@ mod tests {
SessionPane::Leaf {
agent: None,
agent_session_id: None,
agent_launch_argv: None,
..
}
));
@@ -305,6 +329,7 @@ mod tests {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
},
}],
};
+26 -10
View File
@@ -278,7 +278,11 @@ fn detect_windows() -> Vec<DetectedShell> {
out.push(DetectedShell {
label: "Git Bash".into(),
program: bash.to_string_lossy().into_owned(),
// Interactive login shell — matches Git Bash's own launcher.
// Interactive login shell — matches Git Bash's own launcher. These
// are tty7's args, not the user's, so shell integration may replace
// them with its own spelling of the same thing (see
// `protocol::ShellSpec::args_are_tty7_defaults`); they stand as the
// fallback for when integration doesn't apply or fails to set up.
args: vec!["-i".into(), "-l".into()],
});
}
@@ -296,6 +300,22 @@ fn detect_windows() -> Vec<DetectedShell> {
out
}
/// Git Bash's `bash.exe`, if Git for Windows is installed. Exposed only to
/// tests, so `daemon::shell_integration`'s live-PTY check can spawn the same
/// binary the dropdown does (and skip itself when there is none).
#[cfg(all(windows, test))]
pub fn git_bash_path() -> Option<PathBuf> {
find_git_bash()
}
/// Installed WSL distributions. Exposed only to tests, for the same reason as
/// [`git_bash_path`]: the live-PTY check needs a real distro to launch into,
/// and skips itself when there is none.
#[cfg(all(windows, test))]
pub fn wsl_distros() -> Vec<String> {
list_wsl_distros()
}
/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64,
/// x86, and the per-user installer's home).
#[cfg(windows)]
@@ -319,17 +339,13 @@ fn find_git_bash() -> Option<PathBuf> {
}
/// Installed WSL distribution names via `wsl.exe -l -q`, or empty when WSL is
/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window
/// (we're a GUI process).
/// absent. [`hide_console`](crate::core::proc::hide_console) keeps the probe
/// from flashing a console window (we're a GUI process).
#[cfg(windows)]
fn list_wsl_distros() -> Vec<String> {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let Ok(output) = std::process::Command::new("wsl.exe")
.args(["-l", "-q"])
.creation_flags(CREATE_NO_WINDOW)
.output()
else {
let mut cmd = std::process::Command::new("wsl.exe");
cmd.args(["-l", "-q"]);
let Ok(output) = crate::core::proc::hide_console(&mut cmd).output() else {
return Vec::new();
};
if !output.status.success() {
+31
View File
@@ -74,6 +74,12 @@ pub struct SshProfile {
pub warn_on_close: Option<bool>,
/// Suppress the server login banner.
pub skip_banner: bool,
/// Bootstrap tty7's shell integration into the remote shell (prompt marks,
/// exit codes, cwd — what the inline line editor runs on). On by default;
/// a remote we can't integrate declines itself, so this is the escape hatch
/// for one we *can* but shouldn't.
#[serde(default = "default_true")]
pub shell_integration: bool,
/// Commands sent automatically right after the shell opens.
pub login_scripts: Vec<String>,
/// Request X11 forwarding.
@@ -110,6 +116,7 @@ impl Default for SshProfile {
connect_timeout_s: None,
warn_on_close: None,
skip_banner: false,
shell_integration: true,
login_scripts: Vec::new(),
x11: false,
algorithms: Algorithms::default(),
@@ -434,6 +441,22 @@ pub fn expand_tilde(path: &str) -> String {
mod tests {
use super::*;
/// Profiles written before the shell-integration switch existed must load
/// with it *on*: a plain `#[serde(default)]` would give `false` and quietly
/// opt every existing profile out of the feature it never knew about.
#[test]
fn profiles_saved_before_the_switch_existed_default_to_integrated() {
let profile: SshProfile =
serde_json::from_str(r#"{"name":"prod","host":"h","user":"u"}"#).unwrap();
assert!(profile.shell_integration);
// …and a profile that explicitly opted out stays opted out.
let off: SshProfile = serde_json::from_str(
r#"{"name":"prod","host":"h","user":"u","shell_integration":false}"#,
)
.unwrap();
assert!(!off.shell_integration);
}
#[test]
fn quick_connect_parses_basic_forms() {
let q = parse_quick_connect("deploy@10.0.0.5").unwrap();
@@ -679,3 +702,11 @@ fn new_id() -> Uuid {
fn default_port() -> u16 {
22
}
/// Serde default for [`SshProfile::shell_integration`]. Named rather than
/// `#[serde(default)]` because the default is `true`, and because profiles
/// written before the field existed must deserialize as opted *in* — the
/// integration is the behavior we want everywhere it works.
fn default_true() -> bool {
true
}
+3 -4
View File
@@ -62,10 +62,9 @@ pub fn is_inside_repo(cwd: &Path) -> bool {
/// Run `git -C <dir> <args>`, returning trimmed stdout on success and trimmed
/// stderr as the error otherwise.
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
let mut cmd = std::process::Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = crate::core::proc::hide_console(&mut cmd)
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if out.status.success() {
+389 -46
View File
@@ -102,6 +102,33 @@ fn default_shell_name(cmd: &CommandBuilder) -> String {
cmd.get_shell()
}
/// The shell a spawn resolved to, plus who authored its args.
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
struct ChosenShell {
program: String,
args: Vec<String>,
/// True when `args` are tty7's own defaults from shell discovery rather
/// than the user's, so shell integration may replace them. See
/// [`ShellSpec::args_are_tty7_defaults`].
args_are_tty7_defaults: bool,
}
/// Whether the chosen shell carries args tty7 must not second-guess, which is
/// what makes `shell_integration::setup` decline bash and PowerShell (their
/// injections replace argv rather than extend it). Two things have to hold for
/// the args to be off-limits:
///
/// - there are some — an empty `args: []` (just picking the program) leaves
/// nothing for bash's `--rcfile … -i` to conflict with; and
/// - the *user* wrote them. The new-tab dropdown's args are tty7's own
/// (`core::shells::detect_shells`), so integration is free to express the
/// same intent its own way: Git Bash's `-i -l` means "interactive login
/// shell", exactly what `setup_bash` rebuilds out of `--rcfile … -i` plus a
/// replayed login-file chain. See [`ShellSpec::args_are_tty7_defaults`].
fn has_custom_args(chosen: Option<&ChosenShell>) -> bool {
chosen.is_some_and(|c| !c.args.is_empty() && !c.args_are_tty7_defaults)
}
/// Which shell a spawn launches, by precedence: the explicit per-spawn override
/// (the new-tab dropdown) > the configured `shell` in `config.json` > `None`,
/// meaning the platform default (`default_prog()`). Kept as a function so the
@@ -109,8 +136,21 @@ fn default_shell_name(cmd: &CommandBuilder) -> String {
fn choose_shell(
spawn_override: Option<ShellSpec>,
configured: Option<(String, Vec<String>)>,
) -> Option<(String, Vec<String>)> {
spawn_override.map(|s| (s.program, s.args)).or(configured)
) -> Option<ChosenShell> {
spawn_override
.map(|s| ChosenShell {
program: s.program,
args: s.args,
args_are_tty7_defaults: s.args_are_tty7_defaults,
})
.or_else(|| {
// Straight from `config.json` — the user wrote these.
configured.map(|(program, args)| ChosenShell {
program,
args,
args_are_tty7_defaults: false,
})
})
}
fn apply_shell_integration(
@@ -123,7 +163,7 @@ fn apply_shell_integration(
// sentinel builder. Integrations that need argv (fish `-C`, bash `--rcfile`,
// PowerShell flags) must use an explicit command builder first. Env-only zsh
// integration keeps the default login-shell path.
if integration.force_non_login || (cmd.is_default_prog() && !integration.args.is_empty()) {
if integration.replaces_argv || (cmd.is_default_prog() && !integration.args.is_empty()) {
*cmd = CommandBuilder::new(resolved_program);
}
cmd.args(&integration.args);
@@ -144,26 +184,70 @@ fn build_spawn_config(
shell: Option<ShellSpec>,
) -> anyhow::Result<SpawnConfig> {
let initial_cwd = initial_working_directory(cwd);
let (cmd, integration_dir) = build_shell_command(shell, &initial_cwd)?;
// Resolved here rather than inside `build_shell_command` because the WSL tag
// must be read off the shell we *actually* launch. `shell` is only the
// per-spawn override; `config.json` supplies the program when it is `None`,
// and a `wsl.exe` configured there is just as much a WSL pane as one picked
// from the dropdown.
let configured = choose_shell(shell, crate::core::config::shell_command());
let remote = wsl_remote_context(configured.as_ref());
let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd)?;
Ok(SpawnConfig {
cmd,
initial_cwd,
integration_dir,
remote: None,
remote,
})
}
/// Tag a `wsl.exe` pane as living in another filesystem namespace, from the
/// resolved shell rather than the process table — `wsl.exe` is exactly what tty7
/// launched, so there is nothing to detect.
///
/// This is what makes `TerminalView::local_cwd` decline the distro's cwd, and
/// so what keeps the local git probe, path completion, link resolution and cwd
/// inheritance away from a path that means nothing on this side (and that
/// Windows would read as drive-relative). It is set whether or not shell
/// integration succeeded: an unintegrated WSL pane reports no cwd today, but if
/// it ever does the gate must already be in place.
///
/// Takes the post-[`choose_shell`] program, not the per-spawn override: a
/// `wsl.exe` written into `config.json` reaches the same integration and so must
/// reach the same tag.
fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> {
if !cfg!(windows) {
return None;
}
let chosen = shell?;
let base = std::path::Path::new(&chosen.program)
.file_name()?
.to_str()?
.to_ascii_lowercase();
if base.strip_suffix(".exe").unwrap_or(&base) != "wsl" {
return None;
}
Some(RemoteContext {
kind: RemoteKind::Wsl,
argv: Vec::new(),
// The distro, when the args name one; otherwise `wsl.exe` picks the
// default and we have no name for it without another probe. Shared with
// the integration so the two can't disagree about which distro an argv
// names — they are handed the very same args.
target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(),
})
}
/// Build the argv for a spawn from an already-resolved shell (see
/// [`choose_shell`]); `None` means the platform default (the login shell on
/// Unix, PowerShell on Windows).
fn build_shell_command(
shell: Option<ShellSpec>,
configured: Option<ChosenShell>,
initial_cwd: &Option<PathBuf>,
) -> anyhow::Result<(CommandBuilder, Option<PathBuf>)> {
// Build the shell command; `None` means the platform default (the login
// shell on Unix, PowerShell on Windows).
let configured = choose_shell(shell, crate::core::config::shell_command());
let mut cmd = match &configured {
Some((program, args)) => {
let mut c = CommandBuilder::new(program);
c.args(args);
Some(chosen) => {
let mut c = CommandBuilder::new(&chosen.program);
c.args(&chosen.args);
c
}
None => default_prog(),
@@ -174,20 +258,20 @@ fn build_shell_command(
// it's whatever `default_prog()` resolved (passwd/`$SHELL` on Unix,
// `powershell.exe` on Windows — see `default_shell_name`).
let resolved_program = match &configured {
Some((program, _)) => program.clone(),
Some(chosen) => chosen.program.clone(),
None => default_shell_name(&cmd),
};
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell
// — see `daemon::shell_integration`). Best effort — `None` (an unsupported
// shell, or a bash/PowerShell with unpreservable custom args) means we launch
// bare. A configured shell only counts as having "custom args" to preserve
// when it actually specifies any — an empty `args: []` (just picking the
// program) leaves nothing for bash's `--rcfile -i` to conflict with.
let has_custom_args = configured
.as_ref()
.is_some_and(|(_, args)| !args.is_empty());
let integration = shell_integration::setup(Some(&resolved_program), has_custom_args);
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell,
// and through `wsl.exe` into a distro — see `daemon::shell_integration`).
// Best effort — `None` (an unsupported shell, or one with unpreservable
// custom args) means we launch bare. The args go in because the WSL path
// reads the distro out of them.
let integration = shell_integration::setup(
Some(&resolved_program),
configured.as_ref().map_or(&[][..], |c| c.args.as_slice()),
has_custom_args(configured.as_ref()),
);
if let Some(integration) = &integration {
apply_shell_integration(&mut cmd, &resolved_program, integration);
}
@@ -212,7 +296,17 @@ fn initial_working_directory(cwd: Option<PathBuf>) -> Option<PathBuf> {
// client didn't pass an explicit cwd (tab-inherit / session restore still
// win). Inherit -> `forced` is `None`, so we keep the fallback as before.
let forced = crate::core::config::working_directory_base();
cwd.or(forced).or(fallback)
// Whatever wins must actually be a directory *here*. A client cwd is only
// as good as the OSC 7 that produced it, and a shell that reports a path
// this machine cannot resolve — a remote namespace, or an msys path like
// `/c/Users/x` that Windows reads as drive-relative — would otherwise turn
// a new tab or split into a hard spawn failure ("The directory name is
// invalid") instead of quietly falling back. Cheap to check, and it bounds
// the whole class rather than one shell's spelling at a time.
[cwd, forced, fallback]
.into_iter()
.flatten()
.find(|d| d.is_dir())
}
fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<PathBuf>) {
@@ -374,6 +468,11 @@ struct PaneState {
/// the foreground `argv` (same process-table poll as `remote`). `None` when
/// no known agent runs — see [`crate::core::cli_agent`].
agent: Option<crate::core::cli_agent::CLIAgent>,
/// The argv the detected agent was launched with, held here until a rich
/// session exists to stamp it into (the sentinel events that create the
/// session can land before the first argv poll, and vice versa). Cleared
/// with the chip. See [`stamp_launch_argv`].
agent_argv: Option<Vec<String>>,
/// The rich agent-session status (idle/working/waiting/done + native
/// session id), folded from the sentinel OSC events the agent's hooks emit
/// (with an opaque OSC 9/777 fallback). Cleared when the agent exits.
@@ -409,8 +508,9 @@ struct ForegroundProbes {
/// process group) — "no opinion", never applied, so it can't wipe an agent
/// identified another way (sentinel events, the Windows `133;C;<cmd>`
/// mark). `Some(answer)` is a real poll result; its inner `None` ("polled,
/// no agent") clears the chip.
agent: Box<dyn Fn() -> Option<Option<crate::core::cli_agent::CLIAgent>> + Send>,
/// no agent") clears the chip. A detected agent travels with the `argv` it
/// was identified from, kept for flag carry-over on session resume.
agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>,
}
/// The local-PTY backend: the same handles `DaemonPane` has always owned.
@@ -575,6 +675,7 @@ impl DaemonPane {
remote: spawn.remote.clone(),
agent: None,
agent_session: None,
agent_argv: None,
alive: true,
}));
let shutting_down = Arc::new(AtomicBool::new(false));
@@ -683,6 +784,7 @@ impl DaemonPane {
// agent detection never runs for it.
agent: None,
agent_session: None,
agent_argv: None,
alive: true,
}));
let shutting_down = Arc::new(AtomicBool::new(false));
@@ -890,15 +992,23 @@ impl DaemonPane {
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
}
let remote = if poll_now {
// A native-SSH pane already carries its own remote
// context; process-table detection must not clobber
// it (this pane *is* SSH). Only a plain PTY pane gets
// foreground `ssh` detection.
// A pane tty7 itself spawned as remote (native SSH,
// or WSL) already carries its own context from the
// spawn spec; process-table detection must not
// clobber it. Only `Ssh` — the kind this very probe
// produces — may be replaced, so a pane that has
// since left a foreground `ssh` clears correctly.
//
// Testing `!= Ssh` rather than `== NativeSsh` is
// load-bearing for WSL: `wsl.exe` is not `ssh`, so
// the probe returns `None` and would blank the
// context on the very next poll — twice a second,
// each time also clearing the pane's cwd.
let managed = {
let st = state.lock().unwrap();
st.remote
.as_ref()
.is_some_and(|remote| remote.kind == RemoteKind::NativeSsh)
.is_some_and(|remote| remote.kind != RemoteKind::Ssh)
};
(!managed).then(&foreground_remote)
} else {
@@ -1648,7 +1758,8 @@ 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_commands_cached())
.map(|agent| (agent, Vec::new())),
);
}
st.shell = shell.clone();
@@ -1686,6 +1797,12 @@ fn agent_from_shell_mark(
shell: &ShellState,
custom: &std::collections::HashMap<String, String>,
) -> Option<crate::core::cli_agent::CLIAgent> {
// Identity only — deliberately NOT a launch-argv source for resume flag
// carry-over. The `133;C` capture is terminal *output* (any program can
// print the OSC and forge it), and forged flags would ride an
// auto-executed resume command on the next restore; the Unix argv comes
// from the process table and has no such problem. Windows sessions
// therefore resume bare.
shell
.command
.as_deref()
@@ -1739,6 +1856,16 @@ fn apply_agent_signals(
sess.message = Some(body);
}
// A session the events just created starts without the launch argv the
// identity poll captured — seed it so resume gets the flags no matter
// which side observed the pane first. Updates keep flowing through
// [`stamp_launch_argv`].
if let (Some(sess), Some(argv)) = (&mut st.agent_session, &st.agent_argv)
&& sess.launch_argv.is_none()
{
sess.launch_argv = Some(argv.clone());
}
if st.agent_session != before
&& let Some(sub) = &st.subscriber
{
@@ -1750,14 +1877,35 @@ fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) {
if st.remote == remote {
return;
}
// The cwd belonged to whichever side we are leaving, and it does not
// survive the crossing: a remote path is meaningless locally, and a local
// one is meaningless on the remote. Drop it so the pane reports no cwd
// until the new shell's OSC 7 lands, rather than attributing the old
// namespace's directory to the new one — otherwise a local shell without
// shell integration keeps serving the remote's last path to the local
// `git` probe for the rest of its life.
// `DaemonMsg::Cwd` carries a bare path with no "cleared" form, so the
// client mirrors this on its own when it sees the `RemoteContext` below.
st.cwd = None;
if let Some(sub) = &st.subscriber {
let _ = sub.send(DaemonMsg::RemoteContext(remote.clone()));
}
st.remote = remote;
}
fn apply_agent(st: &mut PaneState, agent: Option<crate::core::cli_agent::CLIAgent>) {
fn apply_agent(
st: &mut PaneState,
detected: Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>,
) {
let (agent, argv) = match detected {
Some((agent, argv)) => (Some(agent), Some(argv)),
None => (None, None),
};
if st.agent == agent {
// Same chip, but the observed argv can still be news: the sentinel
// events may have branded the pane before the first argv poll, or the
// user relaunched the agent with different flags.
stamp_launch_argv(st, argv);
return;
}
// The agent leaving the foreground ends its session: clear the rich state
@@ -1770,10 +1918,38 @@ fn apply_agent(st: &mut PaneState, agent: Option<crate::core::cli_agent::CLIAgen
let _ = sub.send(DaemonMsg::AgentStatus(None));
}
}
if agent.is_none() {
st.agent_argv = None;
}
if let Some(sub) = &st.subscriber {
let _ = sub.send(DaemonMsg::Agent(agent));
}
st.agent = agent;
stamp_launch_argv(st, argv);
}
/// Record the detected agent's launch argv and mirror it into the rich session
/// state (pushing the change to the client) when one exists — resume-after-
/// restart reads it from there. `None` (a poll that saw no argv) and an empty
/// argv (Windows mark detection, identity without a trustworthy argv) never
/// wipe a captured value; the chip clearing in [`apply_agent`] does that.
fn stamp_launch_argv(st: &mut PaneState, argv: Option<Vec<String>>) {
let Some(argv) = argv else { return };
if argv.is_empty() {
return;
}
if st.agent_argv.as_ref() == Some(&argv) {
return;
}
st.agent_argv = Some(argv.clone());
if let Some(sess) = &mut st.agent_session
&& sess.launch_argv.as_ref() != Some(&argv)
{
sess.launch_argv = Some(argv);
if let Some(sub) = &st.subscriber {
let _ = sub.send(DaemonMsg::AgentStatus(st.agent_session.clone()));
}
}
}
/// Whether a foreground command — not the shell itself — currently owns the
@@ -1840,14 +2016,16 @@ fn foreground_remote_context(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Opti
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn foreground_agent(
master: &Mutex<Box<dyn MasterPty + Send>>,
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
) -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> {
let detect = || {
let pid = master.lock().ok().and_then(|m| m.process_group_leader())?;
let argv = crate::daemon::remote::foreground_argv(pid)?;
crate::core::cli_agent::CLIAgent::detect_from_argv_with(
let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with(
&argv,
crate::core::config::agent_commands_cached(),
)
)?;
// The argv rides along: resume-after-restart replays its flags.
Some((agent, argv))
};
Some(detect())
}
@@ -1859,7 +2037,7 @@ fn foreground_agent(
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn foreground_agent(
_master: &Mutex<Box<dyn MasterPty + Send>>,
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
) -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> {
None
}
@@ -2027,7 +2205,9 @@ fn strip_uri_drive_slash(path: &str) -> &str {
}
/// Parse an OSC 7 `file://HOST/PATH` (or bare absolute path) payload.
fn parse_osc7(payload: &[u8]) -> Option<PathBuf> {
/// `pub(crate)` so the shell-integration tests can round-trip what the snippets
/// actually emit through the parser that consumes it.
pub(crate) fn parse_osc7(payload: &[u8]) -> Option<PathBuf> {
let rest = payload.strip_prefix(b"7;")?;
let path_bytes: &[u8] = if let Some(after) = rest.strip_prefix(b"file://") {
let idx = after.iter().position(|&c| c == b'/')?;
@@ -2116,6 +2296,48 @@ fn proc_name(pid: i32) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
/// A cwd the client reports is only as trustworthy as the OSC 7 behind it.
/// Passing one this machine cannot resolve straight to `cmd.cwd()` turns a
/// new tab or split into a hard spawn failure, so anything that isn't a
/// real directory here must fall through to the next candidate instead.
#[test]
fn initial_working_directory_skips_paths_that_are_not_directories() {
let real = std::env::temp_dir();
assert!(real.is_dir(), "temp dir should exist");
// A usable client cwd still wins outright.
assert_eq!(
initial_working_directory(Some(real.clone())),
Some(real.clone())
);
// A remote-namespace path, and the msys shape Windows reads as
// drive-relative: neither resolves here, so neither may be used.
for bogus in [
"/home/someone/definitely-not-here",
"/c/Users/definitely-not-here",
] {
let got = initial_working_directory(Some(PathBuf::from(bogus)));
assert_ne!(
got.as_deref(),
Some(Path::new(bogus)),
"{bogus} is not a directory here and must not be handed to spawn"
);
// Whatever we fall back to must itself be usable.
if let Some(d) = got {
assert!(d.is_dir(), "fallback {d:?} must be a real directory");
}
}
// A file is not a directory either.
let file = real.join("tty7-iwd-probe");
std::fs::write(&file, b"x").expect("write probe file");
let got = initial_working_directory(Some(file.clone()));
assert_ne!(got.as_deref(), Some(file.as_path()));
let _ = std::fs::remove_file(&file);
}
/// End-to-end check of the *live* agent-detection chain this feature rides
/// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding
@@ -2163,11 +2385,17 @@ mod tests {
let _ = child.kill();
let _ = child.wait();
let (agent, argv) = detected.expect("agent detected from live PTY child");
assert_eq!(
detected,
Some(crate::core::cli_agent::CLIAgent::Codex),
agent,
crate::core::cli_agent::CLIAgent::Codex,
"a live PTY child with argv[0]=codex must be detected as Codex"
);
assert_eq!(
argv.first().map(String::as_str),
Some("codex"),
"the observed argv rides along with the detection"
);
}
/// Spawn shell precedence: explicit override > configured > platform
@@ -2177,27 +2405,138 @@ mod tests {
let over = ShellSpec {
program: "fish".into(),
args: vec!["-l".into()],
args_are_tty7_defaults: true,
};
let cfg = ("zsh".to_string(), vec!["-i".to_string()]);
// Override wins even when a shell is configured.
// Override wins even when a shell is configured, carrying its
// arg-ownership flag through.
assert_eq!(
choose_shell(Some(over.clone()), Some(cfg.clone())),
Some(("fish".to_string(), vec!["-l".to_string()]))
Some(ChosenShell {
program: "fish".to_string(),
args: vec!["-l".to_string()],
args_are_tty7_defaults: true,
})
);
// No override → the configured shell, whose args are the user's and so
// are never tty7 defaults.
assert_eq!(
choose_shell(None, Some(cfg.clone())),
Some(ChosenShell {
program: "zsh".to_string(),
args: vec!["-i".to_string()],
args_are_tty7_defaults: false,
})
);
// No override → the configured shell.
assert_eq!(choose_shell(None, Some(cfg.clone())), Some(cfg));
// Neither → platform default.
assert_eq!(choose_shell(None, None), None);
}
/// Only *user*-authored args block integration. Locks the contract stated
/// on [`has_custom_args`] — in particular that the Git Bash dropdown row's
/// `-i -l` does not, which is what lets it get shell integration at all.
#[test]
fn only_user_authored_args_block_shell_integration() {
let chosen = |args: Vec<&str>, tty7: bool| ChosenShell {
program: r"C:\Program Files\Git\bin\bash.exe".to_string(),
args: args.into_iter().map(str::to_string).collect(),
args_are_tty7_defaults: tty7,
};
// The Git Bash dropdown row: tty7 wrote `-i -l`, so `setup_bash` may
// replace them.
assert!(!has_custom_args(Some(&chosen(vec!["-i", "-l"], true))));
// The same args from the user's config.json are theirs to keep.
assert!(has_custom_args(Some(&chosen(vec!["-i", "-l"], false))));
// No args at all: nothing to preserve either way.
assert!(!has_custom_args(Some(&chosen(vec![], false))));
assert!(!has_custom_args(Some(&chosen(vec![], true))));
// Platform default — no configured shell at all.
assert!(!has_custom_args(None));
}
/// A WSL pane must be tagged as living in another filesystem namespace, so
/// `TerminalView::local_cwd` declines the distro's cwd and the local git
/// probe / completion / link resolution / cwd inheritance never see a path
/// that means nothing here — and that Windows would read as drive-relative
/// (`/home/me` -> `C:\home\me`) rather than reject.
#[cfg(windows)]
#[test]
fn wsl_panes_are_tagged_as_a_foreign_filesystem() {
let spec = |program: &str, args: Vec<&str>| ChosenShell {
program: program.to_string(),
args: args.into_iter().map(str::to_string).collect(),
args_are_tty7_defaults: true,
};
// The dropdown's WSL row.
let ctx = wsl_remote_context(Some(&spec(
"wsl.exe",
vec!["--distribution", "Ubuntu-24.04", "--cd", "~"],
)))
.expect("wsl.exe must be tagged");
assert_eq!(ctx.kind, RemoteKind::Wsl);
// The distro rides along as the target so the UI has a name for it.
assert_eq!(ctx.target, "Ubuntu-24.04");
// Nothing reads `argv` for this kind; it is not an ssh invocation.
assert!(ctx.argv.is_empty());
// Short flag, and no flag at all (wsl.exe then picks the default
// distro — still a WSL pane, just one we have no name for).
assert_eq!(
wsl_remote_context(Some(&spec("wsl.exe", vec!["-d", "Debian"])))
.expect("short flag")
.target,
"Debian"
);
assert_eq!(
wsl_remote_context(Some(&spec("wsl.exe", vec![])))
.expect("default distro is still WSL")
.target,
""
);
// The `--distribution=NAME` spelling too — the tag reads the distro with
// the integration's own parser, so the two cannot disagree about an argv
// they are both handed.
assert_eq!(
wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"])))
.expect("joined flag")
.target,
"Arch"
);
// Case- and suffix-insensitive, like every other Windows program name.
assert!(wsl_remote_context(Some(&spec(r"C:\Windows\System32\WSL.EXE", vec![]))).is_some());
// Regression: the tag is read off the *resolved* shell, not the
// per-spawn override. A `wsl.exe` written into `config.json` reaches
// `setup_wsl` with no override in play (empty args, so nothing custom to
// preserve), so the distro starts reporting its own cwd — and an
// untagged pane would hand `/home/me/proj` straight to the local git
// probe, which Windows resolves drive-relative to `C:\home\me\proj`.
let from_config = choose_shell(None, Some(("wsl.exe".to_string(), Vec::new())));
assert_eq!(
wsl_remote_context(from_config.as_ref()).map(|c| c.kind),
Some(RemoteKind::Wsl),
"a configured wsl.exe is as much a WSL pane as a dropdown one"
);
// Everything else is a local pane and must not be tagged — tagging it
// would silently disable its git status, completion and cwd inheritance.
assert!(wsl_remote_context(Some(&spec("powershell.exe", vec![]))).is_none());
assert!(
wsl_remote_context(Some(&spec(r"C:\Program Files\Git\bin\bash.exe", vec![]))).is_none()
);
assert!(wsl_remote_context(None).is_none());
}
#[test]
fn arg_based_integration_rebuilds_default_shell_builder() {
let mut cmd = CommandBuilder::new_default_prog();
let injection = shell_integration::Injection {
env: std::collections::HashMap::new(),
args: vec!["-C".to_string(), "echo ready".to_string()],
force_non_login: false,
replaces_argv: false,
dir: None,
};
@@ -2220,7 +2559,7 @@ mod tests {
let injection = shell_integration::Injection {
env,
args: Vec::new(),
force_non_login: false,
replaces_argv: false,
dir: None,
};
@@ -2897,6 +3236,7 @@ mod tests {
remote: None,
agent: None,
agent_session: None,
agent_argv: None,
alive,
}
}
@@ -2993,7 +3333,10 @@ mod tests {
status: AgentStatus::Working,
message: None,
session_id: Some("sid".into()),
launch_argv: None,
rich: true,
cwd: None,
activity: 0,
});
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
assert_eq!(
+67 -3
View File
@@ -76,6 +76,18 @@ pub struct ShellSpec {
pub program: String,
#[serde(default)]
pub args: Vec<String>,
/// True when `args` were authored by tty7's own shell discovery
/// (`core::shells`) rather than by the user, and so may be replaced by
/// shell integration — Git Bash's `-i -l` is tty7's way of saying "an
/// interactive login shell", which `setup_bash`'s `--rcfile … -i` plus its
/// replayed login-file chain expresses differently but equivalently.
/// User-configured args get no such liberty; see
/// `daemon::shell_integration::setup`'s `has_custom_args`.
///
/// Defaults to `false` on the wire so an older client's frame — which can
/// only carry user-configured args — keeps them untouched.
#[serde(default)]
pub args_are_tty7_defaults: bool,
}
/// Whether a short `ssh` option flag consumes the following argument as its
@@ -120,14 +132,21 @@ pub struct PaneInfo {
pub alive: bool,
}
/// A foreground remote session the daemon can prove from the local process table.
/// A pane whose filesystem is not the host's — either a remote session, or a
/// local one behind a boundary the host's own tools can't follow (WSL).
///
/// The common consequence, whatever the kind, is that the pane's cwd names a
/// path in *that* namespace: see `TerminalView::local_cwd`, which is what keeps
/// a local `git` / `read_dir` / spawn away from it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteContext {
pub kind: RemoteKind,
/// Original foreground argv. Kept so follow-up operations can preserve ssh
/// config flags such as `-F`, `-p`, and `-J` rather than guessing.
/// config flags such as `-F`, `-p`, and `-J` rather than guessing. Empty
/// for kinds that aren't detected from a foreground process.
pub argv: Vec<String>,
/// The destination token (`host`, `user@host`, or ssh config alias).
/// The destination token: `host`, `user@host`, or ssh config alias for the
/// ssh kinds; the distro name for [`RemoteKind::Wsl`].
pub target: String,
}
@@ -142,6 +161,15 @@ pub enum RemoteKind {
/// (`daemon::ssh`). Forwarding / SFTP reach the connection through the
/// in-memory registry.
NativeSsh,
/// A `wsl.exe` pane: not remote in the network sense, but its shell lives
/// inside a distro with its own filesystem namespace, so a cwd it reports
/// (`/home/me/proj`) means nothing to the Windows-side host — and on
/// Windows is *drive-relative* rather than invalid, so it silently resolves
/// to `C:\home\me\proj`. Set at spawn time from the `ShellSpec`, not
/// detected from the process table. Nothing SSH-specific applies to it:
/// callers that mean "an SSH pane" must test the kind, not merely that a
/// `RemoteContext` is present.
Wsl,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -542,6 +570,19 @@ pub struct NativeSshSpec {
pub verify_host_keys: bool,
#[serde(default)]
pub skip_banner: bool,
/// Bootstrap tty7's shell integration (OSC 133 prompt marks + cwd
/// reporting) into the remote shell — what powers the inline line editor,
/// exit-code marks and cwd tracking for this pane. See
/// [`crate::daemon::shell_integration::remote`].
///
/// On by default, and a remote we can't integrate declines itself (the
/// probe answers "unknown shell" and the session starts bare), so this is
/// for the case the probe can't detect: a remote where the bootstrap *would*
/// work but the user would rather it didn't — a bash host where the
/// login-shell → `--rcfile` swap upsets something, or simply a host they
/// want left exactly as stock ssh leaves it.
#[serde(default = "default_true")]
pub shell_integration: bool,
/// Lines sent verbatim (each + `\n`) to the shell channel after it starts,
/// sequentially, with no expect-logic.
#[serde(default)]
@@ -598,6 +639,7 @@ impl std::fmt::Debug for NativeSshSpec {
.field("term", &self.term)
.field("verify_host_keys", &self.verify_host_keys)
.field("skip_banner", &self.skip_banner)
.field("shell_integration", &self.shell_integration)
.field("login_script", &self.login_script)
.field("display_name", &self.display_name)
.field("profile_id", &self.profile_id)
@@ -1454,6 +1496,7 @@ mod tests {
shell: Some(ShellSpec {
program: "wsl.exe".into(),
args: vec!["--distribution".into(), "Ubuntu".into()],
args_are_tty7_defaults: true,
}),
},
ClientMsg::Attach {
@@ -1589,6 +1632,13 @@ mod tests {
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
target: "dev".into(),
})),
// A WSL pane's context rides the same wire; `kind` is serialized
// kebab-case, so this pins the encoding of the new variant.
DaemonMsg::RemoteContext(Some(RemoteContext {
kind: RemoteKind::Wsl,
argv: Vec::new(),
target: "Ubuntu-24.04".into(),
})),
DaemonMsg::RemoteContext(None),
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)),
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)),
@@ -1597,7 +1647,13 @@ mod tests {
status: crate::core::cli_agent::AgentStatus::Waiting,
message: Some("Claude needs your permission to use Bash".into()),
session_id: Some("abc-123".into()),
launch_argv: Some(vec![
"claude".into(),
"--dangerously-skip-permissions".into(),
]),
rich: true,
cwd: Some("/repo/.claude/worktrees/fix-x".into()),
activity: 12,
})),
DaemonMsg::AgentStatus(None),
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
@@ -1748,6 +1804,8 @@ mod tests {
let shell = ShellSpec {
program: "fish".to_string(),
args: vec!["-l".to_string()],
// Set so the round-trip covers the flag, not just program + args.
args_are_tty7_defaults: true,
};
let msg = ClientMsg::Spawn {
cwd: Some(PathBuf::from("/work")),
@@ -1933,6 +1991,7 @@ mod tests {
term: "xterm-256color".into(),
verify_host_keys: true,
skip_banner: false,
shell_integration: true,
login_script: vec![],
display_name: None,
profile_id: None,
@@ -1956,6 +2015,7 @@ mod tests {
term: "xterm-256color".into(),
verify_host_keys: true,
skip_banner: false,
shell_integration: true,
login_script: vec!["tmux attach".into()],
display_name: Some("prod-web".into()),
profile_id: Some("uuid-1".into()),
@@ -1979,6 +2039,10 @@ mod tests {
.unwrap();
assert_eq!(spec.term, "xterm-256color"); // defaulted
assert!(spec.verify_host_keys); // defaulted true
// A spec persisted before shell integration existed must come back
// opted *in* — `#[serde(default)]` on a bool would silently turn it off
// for every reconnect to a pane saved by an older build.
assert!(spec.shell_integration);
assert_eq!(spec.password, None);
assert!(spec.jump.is_none());
}
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -528,6 +528,10 @@ mod tests {
/// match — this is what keeps a stale pidfile with a recycled pid from
/// getting an innocent process killed. Driven with a real `sleep` child:
/// alive, path readable, basename `sleep` ≠ the test binary's.
///
/// `spawn` returns after the fork, possibly before the child has exec'd —
/// until then its executable path still reads as *this* test binary — so
/// the path assertions poll until the exec is visible.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn reap_guard_rejects_a_live_process_of_another_executable() {
@@ -538,11 +542,19 @@ mod tests {
let pid = child.id() as libc::pid_t;
assert!(process_alive(pid), "the sleep child is alive and ours");
assert_eq!(
process_path(pid).and_then(|p| p.file_name().map(|n| n.to_os_string())),
Some("sleep".into()),
"process_path resolves an arbitrary pid, not just our parent"
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let basename = process_path(pid).and_then(|p| p.file_name().map(|n| n.to_os_string()));
if basename == Some("sleep".into()) {
break;
}
assert!(
Instant::now() < deadline,
"process_path resolves an arbitrary pid, not just our parent \
(still {basename:?} after 5s)"
);
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!process_matches_own_exe(pid),
"sleep must not match the test binary; matching here would mean the reap could kill it"
+4
View File
@@ -173,6 +173,10 @@ fn spawn_proxy_command(
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true);
// The daemon is detached and has no console to lend this child, so without
// the flag a `ProxyCommand` (`ssh -W`, `connect.exe`, `cloudflared`) gets a
// console of its own that stays up for the whole session.
crate::core::proc::hide_console_tokio(&mut cmd);
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("spawn ProxyCommand failed: {e}"))?;
+116 -5
View File
@@ -35,12 +35,13 @@ use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration;
use russh::Pty;
use russh::{ChannelMsg, Pty};
use crate::daemon::protocol::{
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, NativeSshSpec,
SshForwardRule, SshPhase, WinSize,
};
use crate::daemon::shell_integration::remote;
use forward::RemoteForwardTable;
use handler::ClientHandler;
@@ -84,6 +85,11 @@ pub struct SshManager {
/// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback),
/// driven on this manager's runtime.
forwards: SshForwardRegistry,
/// Memoized remote shell-integration probes, keyed like connections. A
/// present `None` means "probed, nothing to inject" — cached just as firmly
/// as a hit so an unintegrable host isn't re-probed on every new tab. See
/// [`SshManager::remote_bootstrap`].
probes: Mutex<HashMap<ConnectionKey, Option<(remote::RemoteShell, String)>>>,
}
impl SshManager {
@@ -101,6 +107,7 @@ impl SshManager {
runtime,
conns: Mutex::new(HashMap::new()),
forwards: SshForwardRegistry::default(),
probes: Mutex::new(HashMap::new()),
}
})
}
@@ -316,10 +323,29 @@ impl SshManager {
let _ = channel.agent_forward(false).await;
}
channel
.request_shell(true)
.await
.map_err(|e| format!("shell request failed: {e}"))?;
// Shell integration (OSC 133 + cwd reporting) for the remote shell. When
// the remote is one we know how to bootstrap, the shell is started by an
// `exec` request carrying a setup script that ends in `exec <shell>`,
// rather than by a bare `shell` request; see `shell_integration::remote`.
// Anything unrecognized — or a probe that couldn't be run — falls through
// to the plain shell request, which is exactly what every session did
// before this existed.
// Opting out short-circuits the probe too, not just the bootstrap: a
// profile with the switch off should cost nothing and touch nothing.
let bootstrap = match spec.shell_integration {
true => self.remote_bootstrap(&conn).await,
false => None,
};
match bootstrap {
Some(script) => channel
.exec(true, script)
.await
.map_err(|e| format!("shell request failed: {e}"))?,
None => channel
.request_shell(true)
.await
.map_err(|e| format!("shell request failed: {e}"))?,
}
// Login script: each line verbatim + newline, in order, no expect-logic.
for line in &spec.login_script {
@@ -341,6 +367,41 @@ impl SshManager {
self.conns.lock().unwrap().remove(key);
}
/// The shell-integration bootstrap script for `conn`'s next shell, or `None`
/// to start that shell bare.
///
/// Deciding costs one `exec` round-trip against the remote (see
/// [`probe_remote_shell`]), so the answer is memoized on the connection key —
/// the same identity connections are reused under. Opening a second tab to a
/// host therefore pays nothing, and a *reconnect* to a host probed earlier
/// pays nothing either: which shell a login lands in doesn't change between
/// connections, so the cache deliberately outlives them.
///
/// Two panes racing to a not-yet-probed host may both probe. That is a
/// duplicated round-trip on a cold connection, not a correctness problem —
/// the probe has no side effects and both arrive at the same answer — so it
/// isn't worth serializing every spawn behind a per-key lock the way
/// connection establishment is.
async fn remote_bootstrap(&self, conn: &Arc<SshConnection>) -> Option<String> {
let key = conn.key().clone();
let cached = { self.probes.lock().unwrap().get(&key).cloned() };
let probed = match cached {
Some(hit) => hit,
None => {
let probed = probe_remote_shell(conn).await;
match &probed {
Some((shell, path)) => {
log::debug!("ssh {key:?}: remote shell {shell:?} at {path}")
}
None => log::debug!("ssh {key:?}: no remote shell integration"),
}
self.probes.lock().unwrap().insert(key, probed.clone());
probed
}
};
probed.map(|(shell, path)| remote::bootstrap_command(shell, &path))
}
/// Establish (or reuse) the connection for `spec`, recursing through the jump
/// chain. Boxed because it is `async`-recursive. The returned `bool` is `true`
/// when an existing connection was reused (no fresh authentication) — the
@@ -443,6 +504,54 @@ impl SshManager {
}
}
/// How long to wait for the shell probe before giving up on integrating a
/// remote. Generous, because the probe runs under the remote's login shell and
/// therefore behind whatever its `.zshenv` does; short enough that a host which
/// never answers costs a pause, not a hang. Expiring is not an error — the
/// session continues with a plain shell.
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Cap on probe output, in case the remote's startup files are chatty. Far more
/// than the two lines we asked for; a remote that exceeds it has already told us
/// everything [`remote::parse_probe`] could use.
const PROBE_OUTPUT_LIMIT: usize = 8 * 1024;
/// Ask the remote which login shell it would start, on a throwaway channel.
///
/// This is a non-PTY `exec`, so it runs and exits without touching the session
/// the user is about to get; nothing here can break that session, and every
/// failure path returns `None`, meaning "start the shell bare".
///
/// stderr is folded in with stdout because the marker-based parse tolerates
/// noise, and a remote whose startup files complain on stderr would otherwise
/// have its (perfectly good) answer thrown away.
async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell, String)> {
let mut channel = conn.open_session_channel().await.ok()?;
channel.exec(true, remote::PROBE_COMMAND).await.ok()?;
let mut out: Vec<u8> = Vec::new();
let collect = async {
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => {
out.extend_from_slice(&data);
if out.len() >= PROBE_OUTPUT_LIMIT {
break;
}
}
ChannelMsg::Eof | ChannelMsg::Close => break,
_ => {}
}
}
};
// A timeout doesn't discard what did arrive: the answer is on the second
// line, so a remote that printed it and then stalled before closing the
// channel is still perfectly readable.
let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await;
remote::parse_probe(&String::from_utf8_lossy(&out))
}
/// A conservative set of PTY modes for the shell channel — an interactive TTY
/// with canonical input, echo, and signal handling on, and standard baud codes.
/// The remote line discipline uses these as its starting point.
@@ -487,6 +596,7 @@ mod tests {
term: "xterm-256color".into(),
verify_host_keys: true,
skip_banner: false,
shell_integration: true,
login_script: vec![],
display_name: None,
profile_id: None,
@@ -523,6 +633,7 @@ mod tests {
runtime,
conns: Mutex::new(HashMap::new()),
forwards: SshForwardRegistry::default(),
probes: Mutex::new(HashMap::new()),
};
let key = ConnectionKey::from_spec(&base_spec());
mgr.conns
+35
View File
@@ -246,6 +246,39 @@ fn enrich_path_from_login_shell() {
unsafe { std::env::set_var("PATH", merged) };
}
/// A bare (non-bundled) binary — `cargo dev` / `cargo run` — has no
/// `Info.plist` pointing the Dock at `tty7.icns`, so macOS shows the generic
/// executable icon. Feed the Dock the bundled logo at runtime in that case;
/// launches from the real `.app` keep the `.icns` and skip this.
#[cfg(target_os = "macos")]
fn set_dock_icon_for_bare_binary() {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSData;
let bundled = std::env::current_exe().is_ok_and(|p| {
p.components()
.any(|c| c.as_os_str().to_string_lossy().ends_with(".app"))
});
if bundled {
return;
}
// gpui's `run` closure executes on the main thread; bail defensively
// rather than panic if that ever stops holding.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
static ICON_PNG: &[u8] = include_bytes!("../assets/app-icon.png");
let data = NSData::with_bytes(ICON_PNG);
if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
// SAFETY: passing a valid NSImage on the main thread; AppKit copies the
// reference, no ownership transferred.
unsafe {
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&image));
}
}
}
fn main() {
// Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter
// Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
@@ -323,6 +356,8 @@ fn main() {
gpui_component::init(cx);
register_bundled_fonts(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
// Load user config once and stash it as a global for views to read.
cx.set_global(Config::load());
// Build the theme registry (built-ins + user theme files) before the
+96 -18
View File
@@ -222,24 +222,76 @@ impl CmdEditor {
self.cursor = self.chars.len();
}
/// Bounds `(start, end)` of the word (run of non-whitespace) containing char
/// index `idx`. On whitespace this collapses to `(idx, idx)`.
pub fn word_bounds(&self, idx: usize) -> (usize, usize) {
/// Bounds `(start, end)` of the word containing char index `idx`: the run
/// of chars that are neither whitespace nor in `separators` (the
/// configured word-separator set, shared with the grid's semantic
/// selection). A separator char is its own one-char word, matching the
/// grid; on whitespace the run collapses and the leftward walk snaps to
/// the previous word's start.
///
/// `smart` mirrors `Config::smart_select`: with it off this is exactly
/// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt
/// editor and the grid alike.
pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
let idx = idx.min(self.chars.len());
if !smart {
return self.plain_word_bounds(idx, separators, smart);
}
// A bracket or quote selects through its match, same as the grid.
// Checked before CJK segmentation so full-width `()`/`“”` pair
// instead of being segmented as lone punctuation tokens. Only for
// the double-click itself — drags use `plain_word_bounds` so the
// selection doesn't lurch when the pointer crosses a quote.
if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) {
return (s, e + 1);
}
// CJK prose has no separators between words: segment it with the
// platform dictionary instead of selecting the whole unbroken run.
if let Some(&c) = self.chars.get(idx)
&& super::smart_select::is_cjk(c)
{
let text: String = self.chars.iter().collect();
if let Some((s, e)) = super::smart_select::cjk_word_range(&text, idx) {
return (s, e + 1);
}
}
self.plain_word_bounds(idx, separators, smart)
}
/// [`Self::word_bounds`] without the pair/segmentation smarts: the plain
/// separator-walk word. Used for word-granular drags, where pair matching
/// would make the selection jump around as the pointer crosses a quote.
/// `smart` still governs the mixed-script narrowing, so a drag matches
/// what the double-click that started it selected.
fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
let idx = idx.min(self.chars.len());
if let Some(&c) = self.chars.get(idx)
&& !c.is_whitespace()
&& separators.contains(c)
{
return (idx, idx + 1);
}
let boundary = |c: char| c.is_whitespace() || separators.contains(c);
let mut s = idx;
while s > 0 && !self.chars[s - 1].is_whitespace() {
while s > 0 && !boundary(self.chars[s - 1]) {
s -= 1;
}
let mut e = idx;
while e < self.chars.len() && !self.chars[e].is_whitespace() {
while e < self.chars.len() && !boundary(self.chars[e]) {
e += 1;
}
// Mixed-script runs (a Latin word glued to CJK text) shrink to the
// clicked char's script class — same correction as the grid's.
if smart && idx < e {
let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1);
return (ns, ne + 1);
}
(s, e)
}
/// Select the word (run of non-whitespace) containing char index `idx`.
pub fn select_word_at(&mut self, idx: usize) {
let (s, e) = self.word_bounds(idx);
/// Select the word containing char index `idx` (see [`Self::word_bounds`]).
pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) {
let (s, e) = self.word_bounds(idx, separators, smart);
self.anchor = Some(s);
self.cursor = e;
}
@@ -249,8 +301,15 @@ impl CmdEditor {
/// selection grows by whole words: dragging past the anchor word selects
/// forward to the far edge of the word under `idx`, dragging before it selects
/// backward to that word's near edge. The cursor sits at the moving edge.
pub fn extend_word_to(&mut self, anchor_start: usize, anchor_end: usize, idx: usize) {
let (ws, we) = self.word_bounds(idx.min(self.chars.len()));
pub fn extend_word_to(
&mut self,
anchor_start: usize,
anchor_end: usize,
idx: usize,
separators: &str,
smart: bool,
) {
let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators, smart);
if we >= anchor_end {
self.anchor = Some(anchor_start);
self.cursor = we;
@@ -573,15 +632,34 @@ mod tests {
assert_eq!(e.selection(), None);
}
/// The default word-separator set (mirrors `Config::word_separators`).
const SEPS: &str = ",│`|:\"' ()[]{}<>\t";
#[test]
fn select_word_and_all() {
let mut e = ed("git push origin", 6);
e.select_word_at(6); // cursor on "push"
e.select_word_at(6, SEPS, true); // cursor on "push"
assert_eq!(e.selected_text().as_deref(), Some("push"));
e.select_all();
assert_eq!(e.selection(), Some((0, 15)));
}
#[test]
fn select_word_stops_at_separators() {
// Quotes and commas bound a word; a separator char is its own word.
let mut e = ed("echo 'a,b'", 0);
e.select_word_at(6, SEPS, true); // on "a"
assert_eq!(e.selected_text().as_deref(), Some("a"));
e.select_word_at(7, SEPS, true); // on the comma itself
assert_eq!(e.selected_text().as_deref(), Some(","));
e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close
assert_eq!(e.selected_text().as_deref(), Some("'a,b'"));
// `/ . - _ =` are not separators: a path stays one word.
let mut e = ed("cat ./a-b/c_d.txt", 0);
e.select_word_at(8, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt"));
}
#[test]
fn extend_to_keeps_anchor() {
let mut e = ed("abcdef", 2);
@@ -595,20 +673,20 @@ mod tests {
fn extend_word_to_grows_by_whole_words_both_directions() {
// Double-click "push" (chars 4..8), then drag over later/earlier words.
let mut e = ed("git push origin main", 4);
e.select_word_at(6);
e.select_word_at(6, SEPS, true);
let (s, a) = e.selection().unwrap(); // (4, 8) == "push"
assert_eq!((s, a), (4, 8));
// Drag forward into "origin": selection reaches that word's far edge.
e.extend_word_to(s, a, 10);
e.extend_word_to(s, a, 10, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("push origin"));
// Drag on into "main": grows to its end.
e.extend_word_to(s, a, 18);
e.extend_word_to(s, a, 18, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("push origin main"));
// Drag backward before the anchor word into "git": anchor flips to the
// word's far edge, selection covers "git push".
e.extend_word_to(s, a, 1);
e.extend_word_to(s, a, 1, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("git push"));
}
@@ -673,15 +751,15 @@ mod tests {
// that word — the same left-scan that makes a double-click at the end
// of the line select the last word.
let mut e = ed("ab cd", 0);
e.select_word_at(2); // the space between the words
e.select_word_at(2, SEPS, true); // the space between the words
assert_eq!(e.selected_text().as_deref(), Some("ab"));
// Index at/past the end selects the trailing word, clamped.
e.select_word_at(99);
e.select_word_at(99, SEPS, true);
assert_eq!(e.selected_text().as_deref(), Some("cd"));
// On a gap wider than one cell there is no adjacent word to the left of
// the clicked cell: the empty range collapses to no selection.
let mut e = ed("ab cd", 0);
e.select_word_at(3); // second space: both neighbours are whitespace
e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace
assert_eq!(e.selection(), None);
}
+202 -27
View File
@@ -118,10 +118,38 @@ const BUILTINS: &[&str] = &[
/// (or `$PATH` entries) can't blow up the UI or the cycle.
const MAX_CANDIDATES: usize = 400;
/// Commands whose arguments are directories, never files. They have no Fig
/// signature (shell builtins), so the generic path fallback handles them —
/// which must not offer files (`cd tar` completing to `tar.exe` is never
/// right).
const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"];
/// The command name the cursor's word is an argument of: the first token of
/// the current simple command (after the last shell separator), reduced to its
/// basename so `/bin/rmdir` matches like `rmdir`. `None` when there is no
/// command token before the word.
fn current_command(chars: &[char], word_start: usize) -> Option<String> {
let prefix: String = chars[..word_start].iter().collect();
let seg_start = prefix
.rfind(['|', '&', ';', '\n', '('])
.map(|i| i + 1)
.unwrap_or(0);
let cmd = prefix[seg_start..].split_whitespace().next()?;
let base = cmd
.rfind(std::path::is_separator)
.map(|i| &cmd[i + 1..])
.unwrap_or(cmd);
(!base.is_empty()).then(|| base.to_string())
}
/// Compute completions for `line` at char position `cursor`, resolving relative
/// paths against `cwd`: command names in command position, filesystem paths
/// elsewhere. Returns `None` when there's nothing to offer.
pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
///
/// `cwd` is `None` when the pane has no directory on *this* machine — a remote
/// pane. Command completion still runs; everything that would touch the local
/// filesystem is skipped rather than answered from the wrong machine.
pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> {
let chars: Vec<char> = line.chars().collect();
let cursor = cursor.min(chars.len());
@@ -141,9 +169,31 @@ pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
// back to filesystem paths. A signature slot that declares suggestions
// or generators owns the position: it returns `Some` (possibly with no
// sync candidates but pending scripts) rather than ceding to paths.
//
// A missing `cwd` means a remote pane, and it disables only the parts
// that read *this* machine: paths and generators (see
// [`complete_signature`]). The rest of a signature is static text —
// `git push`, `--verbose` — and is just as true on the remote, so it is
// still offered. Withholding it too would make every Tab in a remote
// pane a no-match, and a no-match hands the line to the shell, which
// costs the user the inline editor for that prompt.
match complete_signature(&chars, word_start, &word, cwd) {
Some(sig) => (sig.cands, sig.pending),
None => (complete_path(&word, cwd), Vec::new()),
None => match cwd {
// No signature and no local filesystem to fall back on. Offering
// this machine's names would insert them into a remote command
// line where they do not exist; returning nothing instead lets
// the caller hand the Tab to the remote's own completion, which
// can actually see that filesystem.
None => (Vec::new(), Vec::new()),
// No signature: generic paths, narrowed to directories when
// the command only takes those (`cd`, `pushd`, …).
Some(cwd) => {
let dirs_only = current_command(&chars, word_start)
.is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str()));
(complete_path(&word, cwd, dirs_only), Vec::new())
}
},
}
};
let candidates: Vec<Candidate> = word_cands
@@ -233,7 +283,9 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) {
/// Filesystem path completion. Splits `word` into the directory part (kept
/// verbatim in each candidate so the typed path prefix is preserved) and the
/// final-segment prefix to match in that directory. Ordered by closeness.
fn complete_path(word: &str, cwd: &Path) -> Vec<WordCand> {
/// `dirs_only` drops file entries — for commands / argument slots that only
/// accept directories.
fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
// Split on the last path separator. `is_separator` is `/` on Unix and both
// `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly
// under the (future) Windows line editor; separators are ASCII so the byte
@@ -257,7 +309,15 @@ fn complete_path(word: &str, cwd: &Path) -> Vec<WordCand> {
if !name.starts_with(prefix) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
// Follow symlinks when classifying: a symlink to a directory must count
// as one (it both takes the trailing `/` and survives a dirs-only
// filter — `cd` into a linked dir is routine).
let is_dir = entry
.file_type()
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && entry.path().is_dir()));
if dirs_only && !is_dir {
continue;
}
let kind = if is_dir {
CandidateKind::Dir
} else {
@@ -297,11 +357,19 @@ struct SigResult {
/// value, suggestion, or generator slot (so a bare argument still lists files).
/// A slot with generators returns `Some` even with zero sync candidates — its
/// results are still inbound, and falling back to paths there is exactly #51.
///
/// `cwd` is `None` for a remote pane, which suppresses the two things that would
/// answer with *this* machine's state: path completion, and generators. The
/// generator exclusion matters more than it looks — a generator is a local
/// `/bin/sh -c` (see [`super::generator`]), so `git checkout <Tab>` against a
/// remote would offer the branches of whatever repo the *local* cwd happens to
/// sit in. Wrong filenames are obvious when they fail; wrong branch names look
/// plausible and land in a real command.
fn complete_signature(
chars: &[char],
word_start: usize,
word: &str,
cwd: &Path,
cwd: Option<&Path>,
) -> Option<SigResult> {
// Only the current simple command matters: start after the last shell
// separator so `foo | git <tab>` completes `git`, not `foo`.
@@ -345,10 +413,15 @@ fn complete_signature(
if let Some(arg) = pending_value {
let mut out = Vec::new();
push_arg_suggestions(&mut out, arg, word);
if arg.wants_paths() {
out.extend(complete_path(word, cwd));
if let Some(cwd) = cwd {
if arg.wants_paths() {
out.extend(complete_path(word, cwd, arg.wants_dirs_only()));
}
}
let pending = collect_generators(arg);
let pending = match cwd {
Some(_) => collect_generators(arg),
None => Vec::new(),
};
// A slot that declares suggestions or generators owns the position even
// when nothing matches yet; only a truly featureless value slot cedes to
// path completion.
@@ -382,10 +455,14 @@ fn complete_signature(
let mut claims_slot = false;
if let Some(arg) = node.args().first() {
push_arg_suggestions(&mut out, arg, word);
if arg.wants_paths() {
out.extend(complete_path(word, cwd));
if let Some(cwd) = cwd {
if arg.wants_paths() {
out.extend(complete_path(word, cwd, arg.wants_dirs_only()));
}
}
if cwd.is_some() {
pending = collect_generators(arg);
}
pending = collect_generators(arg);
// Suggestions/generators mean this positional owns the slot: don't cede
// to paths just because the sync list came back empty.
claims_slot = !arg.suggestions.is_empty() || !pending.is_empty();
@@ -688,7 +765,7 @@ mod tests {
/// The candidate texts `complete` returns for `line` with the cursor at the
/// end, or an empty vec when it offers nothing.
fn texts(line: &str) -> Vec<String> {
complete(line, line.chars().count(), Path::new("/"))
complete(line, line.chars().count(), Some(Path::new("/")))
.map(|c| c.candidates.into_iter().map(|c| c.text).collect())
.unwrap_or_default()
}
@@ -699,7 +776,7 @@ mod tests {
assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}");
assert!(t.iter().any(|s| s == "status"));
// Descriptions ride along for the menu's second column.
let c = complete("git ", 4, Path::new("/")).unwrap();
let c = complete("git ", 4, Some(Path::new("/"))).unwrap();
let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap();
assert_eq!(commit.kind, CandidateKind::Value);
assert!(commit.description.is_some());
@@ -714,7 +791,7 @@ mod tests {
#[test]
fn signature_offers_flags_for_the_active_subcommand() {
let c = complete("git commit --", 13, Path::new("/")).unwrap();
let c = complete("git commit --", 13, Some(Path::new("/"))).unwrap();
let msg = c.candidates.iter().find(|c| c.text == "--message").unwrap();
assert_eq!(msg.kind, CandidateKind::Flag);
assert_eq!(
@@ -741,7 +818,8 @@ mod tests {
// position — it returns the generator scripts and no path candidates.
let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]);
let line = "git checkout ";
let c = complete(line, line.chars().count(), &dir).expect("generator slot is a completion");
let c = complete(line, line.chars().count(), Some(dir.as_path()))
.expect("generator slot is a completion");
assert!(
!c.pending.is_empty(),
"the branch/tag generators ride along as pending scripts"
@@ -767,7 +845,7 @@ mod tests {
fn generator_script_tokens_join_with_single_spaces() {
// The converter word-split original string scripts; joining restores a
// single `/bin/sh -c` command.
let c = complete("git checkout ", 13, Path::new("/")).unwrap();
let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
let branch = c
.pending
.iter()
@@ -821,11 +899,39 @@ mod tests {
assert_eq!(s.selected().unwrap().text, "branch-b");
}
#[test]
fn dir_only_commands_complete_only_directories() {
// `cd tar` must offer `target/`, never `tar.gz` (#136) — same for the
// other dir-only builtins, and for absolute spellings by basename.
let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]);
let only_dirs = |line: &str| {
complete(line, line.chars().count(), Some(dir.as_path()))
.map(|c| c.candidates.into_iter().map(|c| c.text).collect::<Vec<_>>())
.unwrap_or_default()
};
assert_eq!(only_dirs("cd tar"), vec!["target"]);
assert_eq!(only_dirs("pushd tar"), vec!["target"]);
assert_eq!(only_dirs("/bin/rmdir tar"), vec!["target"]);
// Only the current simple command counts: `cd` after a pipe governs.
assert_eq!(only_dirs("foo | cd tar"), vec!["target"]);
// A bare argument slot narrows too.
assert_eq!(only_dirs("cd "), vec!["target"]);
// A generic command keeps offering files alongside directories.
let both = only_dirs("frobnicate tar");
assert!(both.contains(&"tar.gz".to_string()), "{both:?}");
assert!(both.contains(&"target".to_string()), "{both:?}");
}
#[test]
fn unknown_command_falls_back_to_paths() {
// A command with no signature still path-completes (no panic, no menu here).
let dir = temp_tree("fallback", &[("readme.md", false)]);
let c = complete("frobnicate read", "frobnicate read".chars().count(), &dir).unwrap();
let c = complete(
"frobnicate read",
"frobnicate read".chars().count(),
Some(dir.as_path()),
)
.unwrap();
assert_eq!(c.candidates[0].text, "readme.md");
}
@@ -925,7 +1031,7 @@ mod tests {
#[test]
fn command_position_offers_builtins_with_word_range() {
let c = complete("ech", 3, Path::new("/")).unwrap();
let c = complete("ech", 3, Some(Path::new("/"))).unwrap();
let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap();
assert_eq!(echo.kind, CandidateKind::Command);
assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech"
@@ -938,7 +1044,7 @@ mod tests {
&[("apple.txt", false), ("apply.sh", false), ("assets", true)],
);
let line = "cat a";
let c = complete(line, line.chars().count(), &dir).unwrap();
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
// Closeness order: assets(6) < apply.sh(8) < apple.txt(9).
assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]);
@@ -952,7 +1058,7 @@ mod tests {
let dir = temp_tree("nested", &[("sub", true)]);
std::fs::write(dir.join("sub/file.rs"), b"").unwrap();
let line = "cat sub/f";
let c = complete(line, line.chars().count(), &dir).unwrap();
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
assert_eq!(c.candidates[0].text, "sub/file.rs");
assert_eq!(c.candidates[0].start, 4);
}
@@ -960,9 +1066,9 @@ mod tests {
#[test]
fn hidden_files_only_with_dot_prefix() {
let dir = temp_tree("hidden", &[(".secret", false), ("visible", false)]);
let c = complete("ls v", 4, &dir).unwrap();
let c = complete("ls v", 4, Some(dir.as_path())).unwrap();
assert!(c.candidates.iter().all(|c| !c.text.starts_with('.')));
let c = complete("ls .", 4, &dir).unwrap();
let c = complete("ls .", 4, Some(dir.as_path())).unwrap();
assert!(c.candidates.iter().any(|c| c.text == ".secret"));
}
@@ -978,18 +1084,87 @@ mod tests {
],
);
let line = "cat x";
let c = complete(line, line.chars().count(), &dir).unwrap();
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]);
}
/// A remote pane has no local cwd. Path candidates must come back empty
/// rather than from tty7's own directory — inserting a local filename into
/// a remote command line names a file that isn't there. Command completion
/// is unaffected: it reads `$PATH`, not the cwd.
#[test]
fn a_remote_pane_completes_commands_but_never_local_paths() {
let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]);
// With a local cwd the file is offered...
let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths");
assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here")));
// ...and with none it is not, from the same line.
assert!(complete("cat only", 8, None).is_none());
// Nor does a bare argument position dump anything.
assert!(complete("cat ", 4, None).is_none());
// Command position still works — that source never touches the cwd.
let c = complete("ech", 3, None).expect("command completion needs no cwd");
assert!(c.candidates.iter().any(|c| c.text == "echo"));
}
/// The static half of a signature — subcommands, flags — describes the
/// *command*, not the machine, so it survives the loss of a local cwd. This
/// is what keeps Tab useful in a remote pane: a position with no candidates
/// hands the line to the shell (`handoff_tab_to_shell`), which costs the
/// user the inline editor until the next prompt, so answering "nothing" for
/// every `git <Tab>` was a real regression once remote panes gained an
/// editor at all.
#[test]
fn a_remote_pane_still_gets_a_signatures_static_candidates() {
let c = complete("git ", 4, None).expect("subcommands need no filesystem");
assert!(c.candidates.iter().any(|c| c.text == "commit"));
assert!(c.candidates.iter().any(|c| c.text == "push"));
// Prefix filtering works the same as it does locally.
let c = complete("git ch", 6, None).expect("subcommands need no filesystem");
assert!(c.candidates.iter().any(|c| c.text == "checkout"));
assert!(!c.candidates.iter().any(|c| c.text == "commit"));
// Flags too.
let c = complete("git commit --", 13, None).expect("flags need no filesystem");
assert!(c.candidates.iter().any(|c| c.text == "--message"));
}
/// Generators are local `/bin/sh -c` child processes, so against a remote
/// they would answer with this machine's state — `git checkout <Tab>`
/// offering the branches of whatever repo tty7's own cwd sits in. Unlike a
/// wrong filename, a wrong branch name is plausible enough to be accepted.
#[test]
fn a_remote_pane_never_runs_a_generator() {
// Locally this slot is generator-owned (the branch list).
let local = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
assert!(
!local.pending.is_empty(),
"expected the local branch generator to still be declared"
);
// Remotely the same slot may keep its static candidates, but must not
// schedule a single script.
if let Some(remote) = complete("git checkout ", 13, None) {
assert!(
remote.pending.is_empty(),
"a remote pane scheduled local generators: {:?}",
remote.pending
);
}
}
#[test]
fn no_candidates_returns_none() {
let dir = temp_tree("empty", &[("zzz", false)]);
assert!(complete("cat q", 5, &dir).is_none());
assert!(complete("cat q", 5, Some(dir.as_path())).is_none());
// A blank line offers nothing (no dump of every command on bare Tab).
assert!(complete("", 0, &dir).is_none());
assert!(complete(" ", 3, &dir).is_none());
assert!(complete("", 0, Some(dir.as_path())).is_none());
assert!(complete(" ", 3, Some(dir.as_path())).is_none());
}
#[test]
@@ -997,7 +1172,7 @@ mod tests {
// Caret sits right after "ap" with more text following; the candidate
// replaces only `word_start..cursor`, leaving the tail untouched.
let dir = temp_tree("midline", &[("apple.txt", false)]);
let c = complete("cat ap x.log", 6, &dir).unwrap();
let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap();
let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap();
assert_eq!((apple.start, apple.end), (4, 6));
// Applying it splices over just that range.
+1 -1
View File
@@ -1209,7 +1209,7 @@ impl TerminalElement {
return;
}
if button == MouseButton::Left {
v.on_select_start(col, row, left, clicks, cx);
v.on_select_start(col, row, left, clicks, mods.shift, cx);
}
});
});
+200 -32
View File
@@ -20,6 +20,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// A repo's git snapshot: the branch it's on and how much the working tree has
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
@@ -62,39 +63,46 @@ pub fn probe(cwd: &Path) -> Option<RepoSnapshot> {
if !cwd.exists() {
return None;
}
// Doubles as the "is this a git repo" gate: fails outside a work tree.
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
// One `rev-parse` answers every path question at once: the work-tree root
// (which doubles as the "is this a git repo" gate — it fails outside a
// work tree) plus the git-dir/common-dir pair that tells a linked worktree
// from a main checkout. Asking separately cost two process spawns per
// probe, which mattered once probes stopped being rare: they now also fire
// on window activation and on an agent's tool calls, across every pane.
let paths = git(
cwd,
&[
"rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-dir",
"--git-common-dir",
],
)?;
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
let root = PathBuf::from(lines.next()?);
// A git old enough to reject `--path-format` fails the whole invocation
// above, so reaching here means the two dirs are present — but degrade to
// "main checkout" rather than trusting that, same as the old code did.
let home = repo_home(&root, lines.next(), lines.next());
let branch = branch_name(cwd)?;
Some(RepoSnapshot {
home: repo_home(cwd, &root),
home,
root,
branch,
counts: diff_numstat(cwd),
})
}
/// The repository "home" every checkout of one repo shares: for a linked
/// worktree (its git dir differs from the common git dir) the main work
/// tree's root — the parent of `<main>/.git`; for the main checkout itself,
/// a submodule, or any failure to tell, the work-tree root unchanged. A bare
/// common dir (no trailing `.git` component, the bare-repo-plus-worktrees
/// layout) anchors on the bare directory itself — still one shared key.
fn repo_home(cwd: &Path, root: &Path) -> PathBuf {
let both = git(
cwd,
&[
"rev-parse",
"--path-format=absolute",
"--git-dir",
"--git-common-dir",
],
);
let Some(both) = both else {
return root.to_path_buf();
};
let mut lines = both.lines();
let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
/// The repository "home" every checkout of one repo shares, from the work-tree
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
/// (its git dir differs from the common git dir) the main work tree's root —
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
/// the bare directory itself — still one shared key.
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
return root.to_path_buf();
};
if git_dir == common {
@@ -133,6 +141,9 @@ pub struct GitStatusCache {
/// In-flight cwds re-triggered meanwhile — reprobed once their flight
/// lands, so the newest trigger's state is never skipped.
dirty: HashSet<PathBuf>,
/// When each cwd's last probe *landed*, for the throttle that opportunistic
/// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)).
last_probe: HashMap<PathBuf, Instant>,
}
impl gpui::Global for GitStatusCache {}
@@ -177,12 +188,75 @@ impl GitStatusCache {
}
}
/// Claim an *opportunistic* probe for `cwd`: one triggered by a cheap,
/// frequent signal — the window regaining focus, an agent finishing a tool
/// call — rather than by a rare edge like a command ending.
///
/// Unlike [`begin_probe`](Self::begin_probe) this declines instead of
/// queueing: a probe already in flight, or one against a repo probed less
/// than `min_interval` ago, drops the trigger entirely (no dirty mark, no
/// rerun). That's the whole point of the two entry points — the rare edges
/// must never be missed, while these signals repeat on their own, so a
/// count that's a second stale beats a `git` storm across every pane of a
/// repo the moment the user alt-tabs back.
///
/// The throttle counts per *repo*, not per cwd (see
/// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock
/// rather than waiting for the landing: without that, a dozen panes
/// scattered over one repo's subdirectories would all claim in the same
/// instant — each of them passing a throttle no probe had answered yet —
/// and produce a dozen identical full-repo diffs.
pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool {
if self.in_flight.contains(cwd) {
return false;
}
let key = self.throttle_key(cwd).to_path_buf();
if self
.last_probe
.get(&key)
.is_some_and(|at| at.elapsed() < min_interval)
{
return false;
}
self.last_probe.insert(key, Instant::now());
self.in_flight.insert(cwd.to_path_buf());
true
}
/// What the opportunistic throttle counts against: the work-tree root once
/// some probe has answered for `cwd`, and `cwd` itself before that.
///
/// The counts a probe produces are repo-wide — `git diff --numstat HEAD`
/// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src`
/// and `repo/docs` are three ways of asking one question, and want one
/// shared clock rather than one each. In-flight dedup stays keyed by cwd:
/// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe)
/// has to be able to release exactly what was claimed.
///
/// Before any probe has landed the root is simply unknown, so the first
/// sweep over a repo still costs one probe per distinct cwd; every sweep
/// after that collapses to one.
fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path {
match self.roots.get(cwd) {
Some(Some(root)) => root,
_ => cwd,
}
}
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
/// live repo keeps the root's previous counts (a transient `git` error is
/// not "the tree went clean"). Returns whether the cwd was re-triggered
/// while this probe flew — the caller should start one more probe.
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
self.in_flight.remove(cwd);
// Re-stamp on landing so the gap is measured from fresh counts, and
// under the root this probe just resolved — which is how a cwd first
// learns to share its repo's clock (at claim time it had none).
let key = match &snapshot {
Some(snap) => snap.root.clone(),
None => self.throttle_key(cwd).to_path_buf(),
};
self.last_probe.insert(key, Instant::now());
match snapshot {
Some(snap) => {
let (added, removed) = snap.counts.unwrap_or_else(|| {
@@ -251,18 +325,19 @@ fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
/// Run `git -C <cwd> <args>` and return stdout on success, `None` on a
/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read
/// truly read-only; stdin is nulled so a misconfigured git can't block on a
/// prompt. Shared with [`git_diff`](crate::terminal::git_diff) so every git
/// read in the app goes through the same lock-free, prompt-proof invocation.
/// prompt; `hide_console` keeps this GUI process from flashing a console window
/// on Windows for every probe. Shared with [`git_diff`](crate::terminal::git_diff)
/// so every git read in the app goes through the same lock-free, prompt-proof
/// invocation.
pub(crate) fn git(cwd: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
.stderr(Stdio::null());
let out = crate::core::proc::hide_console(&mut cmd).output().ok()?;
if !out.status.success() {
return None;
}
@@ -421,4 +496,97 @@ mod tests {
assert_eq!(cache.status_for(main).unwrap().branch, "main");
assert_eq!(cache.status_for(wt).unwrap().branch, "feat/x");
}
/// The four shapes `repo_home` has to tell apart, straight from the
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
#[test]
fn repo_home_resolves_worktree_layouts() {
let root = Path::new("/repo/.wt/feat");
// A main checkout: the two dirs agree, so the work tree is its own home.
assert_eq!(
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
PathBuf::from("/repo")
);
// A linked worktree: the common dir is the main checkout's `.git`, so
// the home is that `.git`'s parent — the main work tree.
assert_eq!(
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
PathBuf::from("/repo")
);
// A bare repo with worktrees hanging off it: no `.git` component to
// strip, so the bare dir itself is the shared key.
assert_eq!(
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
PathBuf::from("/bare.git")
);
// A git too old (or too odd) to answer both: degrade to the work tree
// rather than guessing a grouping key.
assert_eq!(
repo_home(root, Some("/repo/.git"), None),
root.to_path_buf()
);
assert_eq!(repo_home(root, None, None), root.to_path_buf());
}
/// The opportunistic path declines where the edge path queues: an in-flight
/// probe drops the trigger (and leaves nothing dirty, so no rerun), and a
/// probe that just landed rate-limits the next one.
#[test]
fn throttled_probes_decline_instead_of_queueing() {
let mut cache = GitStatusCache::default();
let cwd = Path::new("/repo");
let gap = Duration::from_secs(60);
assert!(cache.begin_probe_throttled(cwd, gap));
// In flight: declined, and unlike `begin_probe` it doesn't mark dirty —
// the landing reports "nothing pending" rather than asking for a rerun.
assert!(!cache.begin_probe_throttled(cwd, gap));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
// Landed just now: still inside the gap, so the next trigger is dropped.
assert!(!cache.begin_probe_throttled(cwd, gap));
// …but a zero gap always lets one through, and edge triggers never
// consult the throttle at all.
assert!(cache.begin_probe_throttled(cwd, Duration::ZERO));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
assert!(cache.begin_probe(cwd));
}
/// The throttle is per repo, not per cwd: panes sitting in different
/// subdirectories ask one question (the counts are repo-wide), so once the
/// cache knows where they live, a window activation costs one probe for
/// the repo rather than one per pane.
#[test]
fn throttle_collapses_subdirectories_of_one_repo() {
let mut cache = GitStatusCache::default();
let (top, src, docs) = (
Path::new("/repo"),
Path::new("/repo/src"),
Path::new("/repo/docs"),
);
let gap = Duration::from_secs(60);
// Nothing known yet, so each cwd is its own key and each gets a probe.
for cwd in [top, src, docs] {
assert!(cache.begin_probe_throttled(cwd, gap));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1))))));
}
// Now all three resolve to `/repo`, so the next sweep collapses: the
// first pane to ask spends the probe and the rest ride on it.
assert!(!cache.begin_probe_throttled(top, gap));
assert!(!cache.begin_probe_throttled(src, gap));
// …and the claim itself is what stops the stampede — with the clock
// wound back far enough to let one through, the *others* still decline
// while it is in flight, even though nothing has landed yet.
assert!(cache.begin_probe_throttled(docs, Duration::ZERO));
assert!(!cache.begin_probe_throttled(top, gap));
assert!(!cache.begin_probe_throttled(src, gap));
// A pane elsewhere is untouched by any of it.
let other = Path::new("/other");
assert!(cache.begin_probe_throttled(other, gap));
}
}
+153 -2
View File
@@ -105,6 +105,51 @@ pub(super) fn reshape_option_keystroke(
}
}
/// True when a keystroke is ordinary text that macOS should deliver through the
/// input context (`insertText:` → `replace_text_in_range` → `commit_text`)
/// rather than the raw `key_char` path.
///
/// gpui derives `key_char` by running the event's *virtual keycode* back through
/// the current layout (`chars_for_modified_key` in its macOS backend); it never
/// reads the event's Unicode payload. That is fine for a physical keyboard, where
/// the keycode is the truth, but wrong for any event whose text lives only in the
/// payload — notably remote-control apps, which synthesize keystrokes as
/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`.
/// Keycode 0 is `a`, so every remotely typed character arrived as `a`.
///
/// gpui already diverts printable keys to the input context, but only while a
/// composing input source is active (`is_ime_input_source_active`), so the bug
/// appeared and vanished depending on which input method was selected — and the
/// plain ABC layout, the macOS default, always lost the text. Declining the key
/// here instead makes the IME the single delivery path for text on macOS: gpui
/// falls through to `handleEvent:`, and the Unicode payload survives.
///
/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below,
/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy.
///
/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as
/// `CSI <code>;<mods>[;<text>]u`, and the IME path terminates in
/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that
/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get
/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`]
/// declines unmodified text keys there, so the IME route is equivalent.
///
/// Compiled under `test` on every platform so the routing rule is covered by
/// CI everywhere, not just on the macOS runner.
#[cfg(any(target_os = "macos", test))]
pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool {
if kitty.report_all_keys {
return false;
}
let m = &ks.modifiers;
if m.control || m.platform || m.function || m.alt {
return false;
}
ks.key_char
.as_deref()
.is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}'))
}
/// Translate a GPUI keystroke into the bytes a PTY expects.
///
/// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try
@@ -522,7 +567,26 @@ impl InputHandler for TerminalInputHandler {
None
}
fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool {
fn apple_press_and_hold_enabled(&mut self) -> bool {
// A terminal wants auto-repeat, not the accent palette: holding `j` in
// vim scrolls, it does not offer `ĵ`. This used to be moot because
// `on_key_down` consumed printable keys before gpui consulted it; now
// that text defers to the IME (see `defer_to_ime`), gpui reaches its
// held-key branch, and answering `false` there makes it repeat the
// character instead of handing the key to press-and-hold.
false
}
fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool {
// REPORT_ALL_KEYS_AS_ESC wants every key as `CSI <code>;<mods>[;<text>]u`,
// which only `keystroke_to_bytes` produces — the IME path commits raw
// UTF-8. Keep printable keys on the dispatch path so they get encoded,
// matching the same gate in `on_key_down`. CJK composition and "escape
// every key" are mutually exclusive by construction; an app that asks
// for the latter gets it.
if self.view.read(cx).kitty_flags().report_all_keys {
return false;
}
// While a multi-key keybinding is mid-sequence — e.g. the tmux preset's
// `ctrl-b` prefix is held pending — the next key belongs to the keymap,
// not the IME. macOS otherwise diverts printable keys straight to the IME
@@ -554,9 +618,29 @@ impl InputHandler for TerminalInputHandler {
#[cfg(test)]
mod tests {
use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes};
use super::{
KittyFlags, defer_to_ime, keystroke_to_bytes, reshape_option_keystroke, tab_bytes,
};
use gpui::{Keystroke, Modifiers};
/// Kitty full mode: every key escaped, with the produced text attached.
fn full_mode() -> KittyFlags {
KittyFlags {
disambiguate: true,
report_all_keys: true,
report_text: true,
}
}
/// Level 1 only — the mode a shell leaves on after a TUI exits.
fn disambiguate_only() -> KittyFlags {
KittyFlags {
disambiguate: true,
report_all_keys: false,
report_text: false,
}
}
/// The legacy call shape used by the pre-existing tests: encode with the Kitty
/// protocol off, exercising exactly the byte output shells see by default.
fn legacy(ks: &Keystroke) -> Option<Vec<u8>> {
@@ -571,6 +655,73 @@ mod tests {
}
}
#[test]
fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() {
let plain = Modifiers::default();
let a = ks(plain, "a", Some("a"));
// Default and disambiguate-only: text belongs to the IME, which is the
// only path that carries a synthesized event's real Unicode payload.
assert!(defer_to_ime(&a, KittyFlags::default()));
assert!(defer_to_ime(&a, disambiguate_only()));
// Full mode: the IME commits raw UTF-8, so deferring would drop the
// `CSI 97;1;97u` the app negotiated for. Stay on the encoder path.
assert!(!defer_to_ime(&a, full_mode()));
assert_eq!(
keystroke_to_bytes(&a, full_mode()),
Some(b"\x1b[97;1;97u".to_vec()),
);
// Space is text too, and follows the same rule.
let space = ks(plain, "space", Some(" "));
assert!(defer_to_ime(&space, KittyFlags::default()));
assert!(!defer_to_ime(&space, full_mode()));
}
#[test]
fn shifted_text_follows_the_same_ime_rule() {
let shift = Modifiers {
shift: true,
..Default::default()
};
let upper = ks(shift, "a", Some("A"));
assert!(defer_to_ime(&upper, KittyFlags::default()));
assert!(!defer_to_ime(&upper, full_mode()));
}
#[test]
fn non_text_keys_never_defer_to_the_ime() {
let plain = Modifiers::default();
// No `key_char` at all — arrows, F-keys, backspace, escape.
assert!(!defer_to_ime(
&ks(plain, "left", None),
KittyFlags::default()
));
assert!(!defer_to_ime(
&ks(plain, "backspace", None),
KittyFlags::default()
));
// Control chars are filtered even when a `key_char` is present.
assert!(!defer_to_ime(
&ks(plain, "enter", Some("\n")),
KittyFlags::default()
));
assert!(!defer_to_ime(
&ks(plain, "tab", Some("\t")),
KittyFlags::default()
));
// Chords belong to the encoders, not the IME.
let ctrl = Modifiers {
control: true,
..Default::default()
};
assert!(!defer_to_ime(
&ks(ctrl, "c", Some("c")),
KittyFlags::default()
));
}
#[test]
fn keystroke_to_bytes_maps_control_letters() {
let ctrl = Modifiers {
+1
View File
@@ -35,6 +35,7 @@ mod reverse_search;
pub mod search;
mod signature;
mod size;
mod smart_select;
mod typeahead;
pub mod view;
+32
View File
@@ -100,6 +100,14 @@ struct ShellState {
/// back) from the stale pre-submit state — even when 1 Hz polling misses
/// the intermediate not-at-prompt window of a fast command.
seq: u64,
/// Monotonic count of *entered-prompt edges*: bumped only when a report
/// flips `at_prompt` false → true. Unlike `seq` it ignores same-prompt
/// redraws — prompt frameworks re-emit the PS1-embedded `133;B` on every
/// `reset-prompt` / completion-list reprint, and each re-emission is
/// another `Prompt` frame. The Tab handoff keys its release off this
/// (see `TerminalView::editor_handoff`): only a command actually running
/// (`133;C` → not-at-prompt) starts a new cycle.
cycle: u64,
}
/// The shared handles the reader thread writes into as daemon frames arrive;
@@ -670,6 +678,8 @@ impl RemoteTerminal {
at_prompt,
last_exit,
seq: guard.seq + 1,
cycle: guard.cycle
+ u64::from(at_prompt && !guard.at_prompt),
};
}
// The shell just reported a fresh prompt, so at
@@ -702,6 +712,17 @@ impl RemoteTerminal {
}
DaemonMsg::RemoteContext(ctx) => {
flush_batch!();
// Crossing the local/remote boundary invalidates
// the cwd: it names a directory in the namespace
// we just left. Drop it so the pane reports none
// until the new shell's OSC 7 lands — otherwise
// an `exit` from `ssh` leaves the remote's last
// path in place, and a local shell without shell
// integration never overwrites it, so the local
// `git` probe keeps running against it.
if let Ok(mut guard) = cwd.lock() {
*guard = None;
}
if let Ok(mut guard) = remote.lock() {
*guard = ctx;
}
@@ -933,6 +954,13 @@ impl RemoteTerminal {
self.shell_state.lock().map(|s| s.seq).unwrap_or(0)
}
/// Monotonic count of entered-prompt edges — see [`ShellState::cycle`].
/// Stable across same-prompt redraws (which bump `seq` but not this);
/// only leaving the prompt for a command and coming back advances it.
pub fn prompt_cycle(&self) -> u64 {
self.shell_state.lock().map(|s| s.cycle).unwrap_or(0)
}
/// Exit code of the most recently completed foreground command, as sniffed
/// from OSC 133;D daemon-side. `None` before any command has finished.
pub fn last_exit_code(&self) -> Option<i32> {
@@ -1587,6 +1615,7 @@ fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Confi
Config {
scrolling_history: user_config.scrollback_limit,
default_cursor_style: alacritty_cursor_style(user_config.cursor_style),
semantic_escape_chars: user_config.word_separators.clone(),
..Config::default()
}
}
@@ -2404,7 +2433,10 @@ mod tests {
status: AgentStatus::Waiting,
message: Some("Claude needs your permission".into()),
session_id: Some("sid-1".into()),
launch_argv: None,
rich: true,
cwd: None,
activity: 0,
}))
.encode(&mut daemon_side)
.unwrap();
+7
View File
@@ -125,6 +125,13 @@ impl Arg {
.iter()
.any(|t| t == "filepaths" || t == "folders")
}
/// Whether this arg's filesystem completion is directories only — a Fig
/// `folders` template with no `filepaths` alongside it.
pub fn wants_dirs_only(&self) -> bool {
self.template.iter().any(|t| t == "folders")
&& !self.template.iter().any(|t| t == "filepaths")
}
}
/// A static value suggestion for an argument.
File diff suppressed because it is too large Load Diff
+672 -71
View File
File diff suppressed because it is too large Load Diff
+363 -52
View File
@@ -10,7 +10,7 @@ use gpui_component::input::{InputEvent, InputState};
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
use gpui_component::slider::{SliderEvent, SliderState};
use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _};
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
@@ -383,6 +383,10 @@ pub struct Tty7App {
/// the same repo shows the just-refreshed branch/diff line, not a stale
/// per-row copy. Never read.
_git_status_watch: Subscription,
/// Keeps the window-appearance observer alive: while
/// `Config::theme_follow_system` is on, an OS light/dark flip re-resolves
/// the theme slot and repaints. Never read.
_appearance_watch: Subscription,
/// `Some` while the command palette overlay is open; `None` when closed.
/// The view owns its search input, filtered list and keyboard handling and
/// emits a `PaletteEvent`; we build the catalog and run the chosen command.
@@ -446,6 +450,12 @@ pub struct Tty7App {
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
/// row into view.
pub(crate) sidebar_scroll: gpui::ScrollHandle,
/// `Some` while a tab / group is being dragged to a new position, in either
/// the strip or the rail: the frozen geometry the live preview reflow is
/// computed against (see [`crate::ui::reorder`]). Shared by `Rc` because the
/// `on_drag` that opens it only gets `&mut App`, not the entity. Cleared on
/// the first frame after gpui ends the drag.
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
/// Filter box in the sidebar's top control bar ("Search tabs…"); its text
/// narrows the visible rows by fuzzy-ish substring match on the tab label.
pub(crate) sidebar_search: Entity<InputState>,
@@ -627,13 +637,40 @@ impl Tty7App {
// so treat it like a release. Dismissing on *both* flips also keeps a
// reveal scheduled just before the switch from popping the badges up
// in a window the user already left.
let activation_watch = cx.observe_window_activation(window, |this, _window, cx| {
let activation_watch = cx.observe_window_activation(window, |this, window, cx| {
this.dismiss_mod_hint(cx);
// The panes' link-modifier tracking loses the release the same
// way, and a stale "⌘ held" is worse than missing badges: a
// plain unmodified click would open links. Treat the flip as a
// release; holding ⌘ again re-arms it via `on_modifiers_changed`.
this.set_link_modifier(false, cx);
// Coming back is the only cue we get that the working tree may
// have moved while the user was elsewhere: an edit in another
// editor, a `git` command in another app, an agent in another
// window. None of those reach a pane's poll loop, so without this
// the sidebar's `+N −N` would keep showing pre-alt-tab numbers
// until the user happened to run a command in the pane.
if window.is_window_active() {
this.refresh_git_status_all(cx);
}
});
// Follow OS light/dark flips live: while "sync with system" is on, an
// appearance change re-resolves the theme slot and repaints. While it's
// off the appearance only ever changes because `apply_theme` pinned it
// to the theme — skip, or the pin would re-trigger a redundant apply.
let this = cx.weak_entity();
let appearance_watch = window.observe_window_appearance(move |window, cx| {
if !cx.global::<Config>().theme_follow_system {
return;
}
apply_theme(Some(window), cx);
let _ = this.update(cx, |this, cx| {
// The editor targets the on-screen theme, and with no global
// override the opacity slider follows it — keep both in step.
this.rebuild_theme_editor(window, cx);
this.sync_window_opacity_slider(window, cx);
cx.notify();
});
});
// Paint the configured color theme (defaults to a light one) and build
// the menu bar.
@@ -685,6 +722,7 @@ impl Tty7App {
_keystroke_watch: keystroke_watch,
_activation_watch: activation_watch,
_git_status_watch: git_status_watch,
_appearance_watch: appearance_watch,
palette: None,
palette_sub: None,
closed: Vec::new(),
@@ -716,6 +754,7 @@ impl Tty7App {
right_panel_width: Rc::new(Cell::new(right_panel_width)),
right_panel_dragging: Rc::new(Cell::new(false)),
sidebar_scroll: gpui::ScrollHandle::new(),
reorder: Rc::new(RefCell::new(None)),
sidebar_search,
_sidebar_search_sub: sidebar_search_sub,
file_search,
@@ -789,11 +828,20 @@ impl Tty7App {
return true;
}
// From the home page (zero tabs) there are no running sessions to
// reassure about — prompting would be pure friction. Close directly.
// reassure about — prompting would be pure friction. Close directly,
// but still quit with the window: closing our only window without
// quitting leaves a windowless process sitting in the Dock that no
// longer responds to being clicked (#147). Deferred onto the next
// tick so the close itself completes first, same as the confirmed
// path below.
if weak_app
.upgrade()
.is_some_and(|app| app.read(cx).tabs.is_empty())
{
cx.spawn(async move |cx| {
let _ = cx.update(|cx| cx.quit());
})
.detach();
return true;
}
let answer = window.prompt(
@@ -826,7 +874,7 @@ impl Tty7App {
/// Snapshot the current tabs/active index into a `Session` and persist it.
/// Called after every structural change; the write is a small synchronous
/// JSON dump and any error is swallowed inside `Session::save`.
fn save_session(&self, cx: &App) {
pub(crate) fn save_session(&self, cx: &App) {
let tabs: Vec<SessionTab> = self
.tabs
.iter()
@@ -1195,8 +1243,90 @@ impl Tty7App {
/// Switch the active color theme by id, repaint, and persist the choice so
/// it survives a restart. The theme carries its own dark/light brightness.
/// While the system is being followed, the choice lands in the slot for the
/// *current* OS appearance (the theme visibly on screen changes either way).
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
cx.global_mut::<Config>().theme_preset = id.to_string();
let dark_now = crate::ui::theme::system_dark(cx);
let cfg = cx.global_mut::<Config>();
if !cfg.theme_follow_system {
cfg.theme_preset = id.to_string();
} else if dark_now {
cfg.theme_preset_dark = id.to_string();
} else {
cfg.theme_preset_light = id.to_string();
}
self.after_theme_change(window, cx);
}
/// Set the theme for one follow-system slot explicitly (the Light / Dark
/// cards in Settings). Only visibly changes anything when that slot is the
/// one currently on screen; either way the choice is persisted.
pub(crate) fn set_slot_preset(
&mut self,
dark_slot: bool,
id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cfg = cx.global_mut::<Config>();
if dark_slot {
cfg.theme_preset_dark = id.to_string();
} else {
cfg.theme_preset_light = id.to_string();
}
self.after_theme_change(window, cx);
}
/// Turn "sync with system appearance" on/off (the Appearance switch).
/// Turning it off never visibly changes the theme: whatever is on screen
/// is adopted as the manual choice. Turning it on seeds the slot matching
/// the manual theme's own brightness with that theme — so the look only
/// changes when the OS is currently in the *other* mode, where switching
/// to that mode's slot is exactly what the feature promises.
pub(crate) fn set_theme_follow_system(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if on {
let manual = cx.global::<Config>().theme_preset.clone();
let manual_dark = crate::ui::presets::by_id(cx, &manual).dark;
let cfg = cx.global_mut::<Config>();
cfg.theme_follow_system = true;
if manual_dark {
cfg.theme_preset_dark = manual;
} else {
cfg.theme_preset_light = manual;
}
} else {
// Resolve while following is still on (the pin is released, so
// this reads the real OS appearance).
let effective = crate::ui::theme::effective_preset_id(cx);
let cfg = cx.global_mut::<Config>();
cfg.theme_follow_system = false;
cfg.theme_preset = effective;
}
self.after_theme_change(window, cx);
// Re-aim an open picker panel at a slot that exists in the new mode —
// after the apply above, so `system_dark` reads the unpinned OS value.
let slot = if on {
if crate::ui::theme::system_dark(cx) {
crate::ui::settings::ThemeSlot::Dark
} else {
crate::ui::settings::ThemeSlot::Light
}
} else {
crate::ui::settings::ThemeSlot::Manual
};
if let Some(s) = self.active_settings_mut() {
s.theme_panel_slot = slot;
}
}
/// The shared tail of every theme-selection change: repaint, persist, and
/// keep the dependent Settings widgets in step.
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
apply_theme(Some(window), cx);
set_menus(cx);
cx.global::<Config>().save();
@@ -1208,10 +1338,21 @@ impl Tty7App {
cx.notify();
}
/// Show/hide the theme picker panel beside the Appearance page.
pub(crate) fn toggle_theme_panel(&mut self, cx: &mut Context<Self>) {
/// Show/hide the theme picker panel beside the Appearance page. Clicking
/// the card whose slot the open panel already targets closes it; clicking
/// another card re-aims the open panel at that slot.
pub(crate) fn toggle_theme_panel(
&mut self,
slot: crate::ui::settings::ThemeSlot,
cx: &mut Context<Self>,
) {
if let Some(s) = self.active_settings_mut() {
s.theme_panel_open = !s.theme_panel_open;
if s.theme_panel_open && s.theme_panel_slot == slot {
s.theme_panel_open = false;
} else {
s.theme_panel_open = true;
s.theme_panel_slot = slot;
}
cx.notify();
}
}
@@ -1237,7 +1378,7 @@ impl Tty7App {
/// and open the color editor on it. This is the entry point for customizing a
/// read-only built-in (or an imported iTerm scheme).
pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let id = cx.global::<Config>().theme_preset.clone();
let id = crate::ui::theme::effective_preset_id(cx);
let theme = crate::ui::presets::by_id(cx, &id);
match crate::ui::presets::fork_to_file(&theme) {
Ok(new_id) => {
@@ -1258,7 +1399,7 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = cx.global::<Config>().theme_preset.clone();
let id = crate::ui::theme::effective_preset_id(cx);
let mut theme = crate::ui::presets::by_id(cx, &id);
if !theme.editable() {
return;
@@ -1300,7 +1441,7 @@ impl Tty7App {
/// set, else the active theme's own value, else fully opaque.
pub(crate) fn effective_window_opacity(cx: &App) -> f32 {
let config = cx.global::<Config>();
let theme = crate::ui::presets::by_id(cx, &config.theme_preset);
let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
config.window_opacity.or(theme.opacity).unwrap_or(1.0)
}
@@ -1427,7 +1568,7 @@ impl Tty7App {
if self.settings.is_none() {
return;
}
let id = cx.global::<Config>().theme_preset.clone();
let id = crate::ui::theme::effective_preset_id(cx);
let theme = crate::ui::presets::by_id(cx, &id);
if !theme.editable() {
if let Some(s) = self.settings.as_mut() {
@@ -2016,6 +2157,14 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.copy_on_select = on);
}
pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.smart_select = on);
}
pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.tab_completion = on);
}
pub(crate) fn set_startup_mode(
&mut self,
mode: crate::core::config::StartupMode,
@@ -2074,6 +2223,24 @@ impl Tty7App {
window.focus(&handle, cx);
}
/// Ask every pane in the window to re-probe its git status. Called when the
/// window regains focus: the sidebar shows a git line for *every* tab, not
/// just the active one, so refreshing only the focused pane would leave the
/// rest of the list stale — which is exactly the list the user is scanning
/// right after switching back.
///
/// Panes sharing a cwd fold into one probe in the shared cache, and the
/// throttle there counts per repo rather than per cwd, so once the cache
/// knows where each pane lives the cost of a window with many panes is
/// bounded by the number of distinct repos — not by the number of
/// subdirectories they happen to sit in, which would be the same full-repo
/// `git diff` asked several times over.
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) {
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));
}
}
/// Where a freshly opened tab should be inserted, per `new_tab_position`:
/// right after the active tab, or appended at the end. Clamped to the tab
/// count so the zero-tab home state (active 0, no tabs) inserts at 0.
@@ -2097,11 +2264,14 @@ impl Tty7App {
cx: &mut Context<Self>,
) {
// Inherit the cwd of the active tab's focused terminal so the new tab
// opens in the same directory the user is currently working in.
// opens in the same directory the user is currently working in. Local
// cwds only: the new tab is a local shell, and an inherited cwd wins
// over every fallback in `pane::initial_working_directory`, so a remote
// path would be handed straight to the spawn as a working directory.
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).cwd())
.and_then(|leaf| leaf.read(cx).local_cwd())
});
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
// Leaving the current tab for the new one; snapshot its focused pane
@@ -2196,8 +2366,10 @@ impl Tty7App {
};
// The new pane inherits the cwd — and the shell, when the pane being
// split was opened with an explicit pick (a WSL/fish tab splits into
// more WSL/fish, not back to the default).
let cwd = target.read(cx).cwd();
// more WSL/fish, not back to the default). Local cwds only: the
// native-SSH branch below has the daemon discard it regardless, and the
// local branch would otherwise spawn against a remote path.
let cwd = target.read(cx).local_cwd();
// Splitting a native-SSH pane opens another SSH pane on the same
// connection rather than dropping back to a local shell. Re-resolve the
// persisted (secret-free) spec from its saved profile so keychain
@@ -2501,7 +2673,7 @@ impl Tty7App {
// Capture the tab's cwd *before* its panes are killed (the daemon can't
// report it afterwards): if it sat in a tty7-managed worktree, the
// cleanup offer below needs it.
let worktree_cwd = self.tab_cwd(index, window, cx);
let worktree_cwd = self.tab_local_cwd(index, window, cx);
// Snapshot the tab (layout + each pane's current cwd + name) onto the
// recently-closed stack so Cmd+Shift+T can bring it back.
let snapshot = tab_to_session(&self.tabs[index], cx);
@@ -2548,11 +2720,14 @@ impl Tty7App {
let Some(cwd) = cwd else { return };
// Every leaf of every surviving tab, not just focused panes — a shell
// tucked away in a split occupies the worktree all the same.
// Local paths only: this list is what stops a worktree being removed
// out from under a live shell, and a remote cwd can neither occupy a
// local worktree nor be compared against one meaningfully.
let open_cwds: Vec<std::path::PathBuf> = self
.tabs
.iter()
.flat_map(|tab| tab.pane.leaves())
.filter_map(|leaf| leaf.read(cx).cwd())
.filter_map(|leaf| leaf.read(cx).local_cwd())
.collect();
cx.spawn(async move |this, cx| {
let Some(wt) = cx
@@ -2646,6 +2821,30 @@ impl Tty7App {
}
}
/// "Mark as Unread" (tab context menu): re-flag every finished (`Done`)
/// agent turn in the tab as unread, so the avatar's green dot swells back
/// into its count badge until the user next looks at those panes. The
/// active tab's focus target is told the dismissed menu is about to hand
/// focus back to it, so that focus-in doesn't immediately re-read the mark
/// (see `TerminalView::mark_agent_result_unread`).
pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context<Self>) {
use crate::core::cli_agent::AgentStatus;
let Some(tab) = self.tabs.get(index) else {
return;
};
let refocus = (index == self.active).then(|| tab.focus_target()).flatten();
for leaf in tab.pane.leaves() {
let refocus_incoming = refocus.as_ref() == Some(&leaf);
leaf.update(cx, |view, cx| {
if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) {
view.mark_agent_result_unread(refocus_incoming);
cx.notify();
}
});
}
cx.notify();
}
/// The cwd of the tab's label-driving terminal (focused leaf, else first) —
/// what the tab context menu's "Copy Working Directory" copies and "New
/// Worktree Tab" derives the repo from.
@@ -2662,6 +2861,18 @@ impl Tty7App {
.and_then(|leaf| leaf.read(cx).cwd())
}
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
/// for the worktree operations, which shell out to a local `git`. "Copy
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
/// pane's remote path is exactly what the user wants there.
fn tab_local_cwd(&self, index: usize, window: &Window, cx: &App) -> Option<std::path::PathBuf> {
self.tabs
.get(index)?
.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
}
/// "New Worktree Tab": probe the repository containing the tab's cwd for
/// defaults (a fresh generated name, the current branch as start point) on
/// the background executor, then open the confirmation sheet
@@ -2673,7 +2884,7 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(cwd) = self.tab_cwd(index, window, cx) else {
let Some(cwd) = self.tab_local_cwd(index, window, cx) else {
window.push_notification("This tab has no working directory yet", cx);
return;
};
@@ -2713,33 +2924,25 @@ impl Tty7App {
cx.notify();
}
/// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop).
/// Keeps the same tab active across the move and re-persists the session.
pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
/// Rearrange the whole tab vector into `order` (old indices in their new
/// order) — the single path by which a drag-reorder lands, used by the
/// sidebar where a single visual move can imply a larger permutation
/// (relocating a tab inside its group without disturbing the group order,
/// or moving an entire group). Keeps the same tab active and re-persists.
pub(crate) fn apply_tab_order(&mut self, order: &[usize], cx: &mut Context<Self>) {
if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) {
return;
}
// Reordering shifts indices; a pending rename keyed on a fixed index would
// commit onto the wrong tab. Drop it.
// Reordering shifts indices: a rename pending on a fixed one would
// commit onto the wrong tab.
self.renaming = None;
let was_active = self.active;
let tab = self.tabs.remove(from);
self.tabs.insert(to, tab);
// Re-derive the active index so the same logical tab stays selected:
// removal shifts indices after `from` left, insertion shifts indices at
// or after `to` right.
self.active = if was_active == from {
to
} else {
let mut a = was_active;
if from < a {
a -= 1;
}
if to <= a {
a += 1;
}
a
};
let mut slots: Vec<Option<Tab>> = std::mem::take(&mut self.tabs)
.into_iter()
.map(Some)
.collect();
self.tabs = order.iter().filter_map(|&i| slots[i].take()).collect();
self.active = order.iter().position(|&i| i == was_active).unwrap_or(0);
self.save_session(cx);
cx.notify();
}
@@ -3050,7 +3253,10 @@ impl Tty7App {
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.and_then(|view| view.read(cx).cwd());
// Local `git` shell-out, so a remote pane's cwd is not usable —
// it reports "no known directory" rather than silently diffing
// whatever the path collides with locally.
.and_then(|view| view.read(cx).local_cwd());
let Some(cwd) = cwd else {
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
return;
@@ -3059,9 +3265,9 @@ impl Tty7App {
// which is what a review pass wants. Both invocations are quick; the
// prompt builder caps runaway diffs.
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&cwd)
let mut cmd = std::process::Command::new("git");
cmd.args(args).current_dir(&cwd);
crate::core::proc::hide_console(&mut cmd)
.output()
.ok()
.filter(|o| o.status.success())
@@ -3098,6 +3304,7 @@ impl Tty7App {
self.build_font_selects(&mut subs, window, cx);
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
// Live filter for the theme picker panel; each keystroke re-renders the
@@ -3133,10 +3340,12 @@ impl Tty7App {
shell_program_input,
shell_args_input,
wd_path_input,
link_file_command_input,
scroll_slider,
window_opacity_slider,
theme_editor: None,
theme_panel_open: false,
theme_panel_slot: crate::ui::settings::ThemeSlot::Manual,
theme_search,
recording: None,
rebinding_note: None,
@@ -3318,6 +3527,59 @@ impl Tty7App {
(shell_program_input, shell_args_input, wd_path_input)
}
/// File-open command template input (Links section), committing on Enter/blur.
fn build_link_file_command_input(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<InputState> {
let value = cx
.global::<Config>()
.link_file_command
.clone()
.unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("open in default app")
.default_value(value)
});
subs.push(
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_link_file_command(cx);
}
}),
);
input
}
/// Persist the file-open command template from the Links settings input. An
/// empty value clears the override (falls back to the built-in open).
fn commit_link_file_command(&mut self, cx: &mut Context<Self>) {
let Some(command) = self.active_settings().map(|s| {
s.link_file_command_input
.read(cx)
.value()
.trim()
.to_string()
}) else {
return;
};
let command = if command.is_empty() {
None
} else {
Some(command)
};
let cfg = cx.global_mut::<Config>();
if cfg.link_file_command == command {
return; // no change — avoid a redundant disk write on every Blur
}
cfg.link_file_command = command;
cfg.save();
cx.notify();
}
/// Window-opacity slider for the Appearance page (20%–100%). Emits `Change`
/// continuously as the user drags; each tick sets the global override and
/// repaints, so the translucency is live under the thumb.
@@ -3718,8 +3980,14 @@ impl Tty7App {
}
};
Some(rgb)
} else if v.remote_context().is_some() {
// A foreground `ssh` typed into a shell: a plain neutral dot.
} else if v
.remote_context()
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
{
// A foreground `ssh` typed into a shell: a plain neutral dot. The
// kind check matters: a WSL pane also carries a `RemoteContext` (so
// its cwd is treated as foreign — see `local_cwd`), but it is not an
// SSH session and this dot means "SSH".
Some(0x9CA3AF)
} else {
None
@@ -3777,6 +4045,11 @@ impl Tty7App {
cx.notify();
}
/// The focused pane when it is an SSH session of either kind.
///
/// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too,
/// so that its cwd is treated as foreign (see `TerminalView::local_cwd`),
/// and it must not reach anything SSH-shaped from here.
pub(crate) fn active_ssh_pane(
&self,
window: &Window,
@@ -3788,7 +4061,8 @@ impl Tty7App {
.pane
.focused_or_first(window, cx)?;
let pane = pane.read(cx);
Some((pane.pane_id, pane.remote_context()?))
let remote = pane.remote_context()?;
(remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote))
}
/// The focused pane when it is a *connected native* SSH session — the gate for
@@ -4160,6 +4434,32 @@ impl Tty7App {
impl Render for Tty7App {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// A live drag-reorder commits when the drag *ends*, which in gpui means
// the mouse was released (nothing else clears an active drag): the first
// frame without one retires the preview and applies the order it was
// last showing. Deliberately not an `on_drop` handler — those only fire
// when the pointer is over that particular element at release, so a
// release a hair outside the rail or the strip would silently lose the
// move. What you were looking at is what you get, wherever you let go.
// One place at the root covers every surface that can start a drag.
if cx.has_active_drag() {
// Still dragging: forget last frame's answer so only what this
// frame actually draws can be committed (see `clear_pending`).
crate::ui::reorder::clear_pending(&self.reorder);
} else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) {
self.apply_tab_order(&order, cx);
}
// While a tab or group is in hand, the cursor is a closed hand for the
// whole window. It has to be set on the *drag* rather than styled on
// the element: gpui overrides every hovered element's cursor with the
// active drag's for the duration, and that override is `None` — a plain
// arrow — unless something fills it in. Set once per drag (it forces a
// refresh, so re-setting it every frame would spin).
if self.reorder.borrow().is_some()
&& cx.active_drag_cursor_style() != Some(gpui::CursorStyle::ClosedHand)
{
cx.set_active_drag_cursor_style(gpui::CursorStyle::ClosedHand, window);
}
// Vertical-tab mode: the sidebar owns the tab list, so the title-bar strip
// drops its chips (keeping only "+"/"⋯"). Gated on having tabs — the
// zero-tab home page keeps the full-width horizontal layout, so an empty
@@ -4627,7 +4927,13 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
Pane::Leaf(view) => {
let view = view.read(cx);
SessionPane::Leaf {
cwd: view.cwd(),
// Local cwd only. A restored pane whose daemon pane is gone
// respawns on the *default local shell* (a shell pick isn't
// persisted), so a remote cwd would come back paired with a
// local shell that cannot chdir into it. Native-SSH panes
// reconnect from `ssh_spec` and the daemon discards the cwd
// for them anyway (`server::SpawnNativeSsh`).
cwd: view.local_cwd(),
pane_id: Some(view.pane_id),
// Persist the secret-free native-SSH spec so a *dead* pane can be
// reconnected on restore (FR-E4/C2); `None` for local panes. A
@@ -4635,9 +4941,12 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
ssh_spec: view.ssh_spec(),
// The running agent + its native session id (when its hooks
// reported one), so a pane the daemon loses can resume the
// agent conversation instead of just reopening a shell.
// agent conversation instead of just reopening a shell. The
// observed launch argv rides along so the resume command keeps
// the user's flags (`--dangerously-skip-permissions`, …).
agent: view.agent(),
agent_session_id: view.agent_session().and_then(|s| s.session_id),
agent_launch_argv: view.agent_session().and_then(|s| s.launch_argv),
}
}
Pane::Split {
@@ -4659,6 +4968,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
},
}
}
@@ -4730,6 +5040,7 @@ fn session_to_pane(
ssh_spec,
agent,
agent_session_id,
agent_launch_argv,
} => {
// Only restore the pane id when the daemon confirms it's still live;
// a stale id (daemon restarted, pane killed) falls back to a spawn.
@@ -4761,7 +5072,7 @@ fn session_to_pane(
if restore.is_none()
&& cx.global::<Config>().restore_agent_sessions
&& let (Some(agent), Some(id)) = (agent, agent_session_id)
&& let Some(cmd) = agent.resume_command(id)
&& let Some(cmd) = agent.resume_command(id, agent_launch_argv.as_deref())
{
view.read(cx).run_command_line(&cmd);
}
+1
View File
@@ -187,6 +187,7 @@ mod tests {
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
}
}
+1
View File
@@ -19,6 +19,7 @@ pub mod palette;
pub mod pane;
pub mod perf;
pub mod presets;
pub mod reorder;
pub mod right_panel;
pub mod settings;
pub mod sftp;
+1 -2
View File
@@ -21,7 +21,6 @@ use gpui_component::{
use uuid::Uuid;
use crate::core::config::Config;
use crate::core::ssh_profile::parse_quick_connect;
/// What a command actually does. Most variants map to an existing `Tty7App`
@@ -272,7 +271,7 @@ impl Command {
/// preset. The active theme is marked with a check so the list doubles as a
/// "which theme am I on?" indicator.
pub fn theme_commands(cx: &App) -> Vec<Command> {
let active = cx.global::<Config>().theme_preset.clone();
let active = crate::ui::theme::effective_preset_id(cx);
crate::ui::presets::all(cx)
.into_iter()
.enumerate()
+560
View File
@@ -0,0 +1,560 @@
//! Live drag-to-reorder — the "the list gets out of your way while you drag"
//! behaviour behind the tab strip's chips, the sidebar's rows and the sidebar's
//! group headers.
//!
//! Nothing floats above the window: the item you're dragging stays in the
//! list, dimmed, and travels by the list rearranging around it. gpui's drag
//! system is used only for what it's good at here — knowing a drag is live and
//! redrawing every mouse move — while its floating preview renders nothing.
//! What it doesn't offer at all is any way to know *where* the cursor is
//! mid-drag from inside a drop target: only a `drag_over` style and a final
//! `on_drop`, which is the "one tile swaps with another on release" model this
//! module replaces. Here the surface reads [`Window::mouse_position`] every
//! frame, asks [`Reorder`] where the dragged slot belongs *right now*, and
//! renders the list in that order, so what you see is already the result.
//!
//! The shape of it:
//!
//! | Step | Who | What |
//! |---|---|---|
//! | Measure | every slot, every frame | its own bounds into a per-frame cell |
//! | Freeze | `on_drag` | that cell becomes [`Reorder::rects`] for the whole drag |
//! | Preview | the surface, per frame | [`Reorder::target`] → [`Reorder::order`] |
//! | Track | the held slot | [`Reorder::held_offset`] → follows the cursor |
//! | Slide | each displaced slot | [`Reorder::flip_offset`] → animate to zero |
//! | Record | the surface, per frame | [`set_pending`] — the order a release would give |
//! | Commit | the root, on drag end | [`take_pending`] → `Tty7App::apply_tab_order` |
//!
//! **Geometry is frozen at drag start on purpose.** The preview reflow moves
//! the very slots the hit-testing reads, so measuring live would let the list
//! feed back into its own input and oscillate under a still cursor. Freezing
//! also means a mid-drag scroll isn't tracked — a deliberate trade for a rail
//! whose rows are a few dozen pixels tall.
use gpui::{Axis, Bounds, Pixels, Point, px};
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
/// The app-wide slot for the one drag that can be live at a time. Shared by
/// `Rc` because the `on_drag` that opens it only gets `&mut App`.
pub(crate) type ReorderState = Rc<RefCell<Option<Reorder>>>;
/// Everything a surface needs to draw one frame of a live reorder.
pub(crate) struct Preview {
/// Slot indices in the order to render them.
pub(crate) order: Vec<usize>,
/// The slot the held item currently occupies — where a release right now
/// would land it.
pub(crate) target: usize,
/// The slot being dragged. It stays in the list like any other — the drag
/// is drawn by the list rearranging, not by a card floating over it — and
/// only wears a "picked up" dimming so you can see which one you have.
pub(crate) from: usize,
/// Bumped whenever the preview order changes; part of each slot's
/// animation id so a slide restarts rather than resuming.
pub(crate) generation: usize,
/// Per slot (indexed by its *frozen* index), the offset to start this
/// frame at so it slides into place. Zero for everything the last change
/// didn't touch, and unused for [`Self::from`] — the held item doesn't
/// slide, it tracks.
pub(crate) offsets: Vec<Pixels>,
/// Where to draw the held item relative to the slot it's laid out in, so
/// it follows the cursor continuously instead of hopping slot to slot.
pub(crate) held: Pixels,
}
/// This frame's preview of `surface`, if that's where the live drag started
/// and its frozen geometry still describes a list of `len` slots.
pub(crate) fn preview(
state: &ReorderState,
surface: &Surface,
len: usize,
pointer: Point<Pixels>,
) -> Option<Preview> {
let state = state.borrow();
let r = state.as_ref().filter(|r| r.covers(surface, len))?;
let target = r.target(pointer);
let (generation, prev) = r.begin_frame(target);
Some(Preview {
order: r.order(target),
target,
from: r.from,
generation,
offsets: (0..len)
.map(|slot| r.flip_offset(slot, prev, target))
.collect(),
held: r.held_offset(pointer, target),
})
}
/// Record the tab order the current preview implies, so releasing the mouse
/// applies exactly what the user was looking at.
///
/// The surface computes this every frame while it draws the preview, rather
/// than a drop handler working it out on release, because a drop handler only
/// fires when the pointer is over *that element* at release — release a hair
/// above the rail (easy when dragging a row upward) and the move would be
/// silently lost, the list snapping back. The drag ending is the commit, and
/// where the cursor happens to be at that moment doesn't enter into it.
pub(crate) fn set_pending(state: &ReorderState, surface: &Surface, order: Vec<usize>) {
if let Some(r) = state.borrow().as_ref().filter(|r| r.surface == *surface) {
*r.pending.borrow_mut() = Some(order);
}
}
/// Forget the order recorded by the previous frame, at the start of every
/// frame a drag is live — the surfaces re-record it as they draw.
///
/// Without this the recording is a high-water mark rather than a snapshot: drag
/// a row down and back to its own slot and the surface stops recording (the
/// move is a no-op), so a stale "moved" order would survive to be committed by
/// a release the user made after visibly putting the row back. Same for a frame
/// where the drag's frozen geometry no longer matches the list ([`Reorder::covers`]
/// — a tab closed, or a git probe moved one to another group): nothing is drawn
/// and so nothing may commit.
pub(crate) fn clear_pending(state: &ReorderState) {
if let Some(r) = state.borrow().as_ref() {
r.pending.borrow_mut().take();
}
}
/// Take the recorded order out of a finished drag — see [`set_pending`].
pub(crate) fn take_pending(state: &ReorderState) -> Option<Vec<usize>> {
state.borrow_mut().take()?.pending.into_inner()
}
/// Which of the app's reorderable lists a drag belongs to. The sidebar rail
/// holds two at once — the rows inside a group, and the group blocks
/// themselves — so a surface asking "is this drag mine?" needs more than
/// "am I the sidebar". Rows carry their group's repo root (`None` = Scratch)
/// because a row drag must never reflow a sibling group: a tab's group comes
/// from its cwd, not from where it sits in the list.
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum Surface {
/// The horizontal title-bar strip. Display order is plain tab order.
Strip,
/// The rows of one sidebar group.
SidebarRows(Option<PathBuf>),
/// The sidebar's group blocks (header + its rows), dragged by the header.
SidebarGroups,
}
/// One live drag-reorder. Created when gpui starts a drag, read by the surface
/// on every frame until the drop, then dropped.
pub(crate) struct Reorder {
/// The list this drag belongs to; a surface ignores state that isn't its own.
pub(crate) surface: Surface,
/// Index of the dragged slot in the frozen order.
pub(crate) from: usize,
/// Every slot's bounds in display order, as measured on the last frame
/// before the drag started (see the module docs on why they're frozen).
rects: Vec<Bounds<Pixels>>,
/// The axis the list runs along — vertical for the rail, horizontal for the strip.
axis: Axis,
/// The list's gap between slots, so a displaced slot's shift matches what
/// the layout will actually do.
gap: Pixels,
/// Where inside the dragged slot the pointer grabbed it, so the slot's
/// position is derived from the cursor exactly as gpui's floating preview is.
grab: Point<Pixels>,
/// The target the previous frame drew, and a counter bumped whenever it
/// changes. The slide-in animation keys its element id off the counter, so
/// a slot that has just been displaced restarts its slide instead of
/// resuming a finished one.
prev: Cell<usize>,
generation: Cell<usize>,
/// The tab order releasing right now would produce, refreshed every frame
/// by the surface drawing the preview. See [`set_pending`].
pending: RefCell<Option<Vec<usize>>>,
}
impl Reorder {
pub(crate) fn new(
surface: Surface,
from: usize,
rects: Vec<Bounds<Pixels>>,
axis: Axis,
gap: Pixels,
grab: Point<Pixels>,
) -> Self {
Self {
surface,
from,
rects,
axis,
gap,
grab,
prev: Cell::new(from),
generation: Cell::new(0),
pending: RefCell::new(None),
}
}
/// True when this state belongs to `surface` and its frozen geometry still
/// describes a list of `len` slots — a tab closing mid-drag, or the git
/// probe moving a tab to another group, invalidates it rather than letting
/// stale indices reorder the wrong thing.
pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool {
self.surface == *surface && self.rects.len() == len && self.from < len
}
/// The scalar component along the list's axis.
fn along(&self, p: Point<Pixels>) -> Pixels {
match self.axis {
Axis::Vertical => p.y,
Axis::Horizontal => p.x,
}
}
/// A slot's extent along the list's axis.
fn extent(&self, b: &Bounds<Pixels>) -> Pixels {
match self.axis {
Axis::Vertical => b.size.height,
Axis::Horizontal => b.size.width,
}
}
/// How far a slot moves when the dragged one passes it: the dragged slot's
/// extent plus the gap it also takes with it.
fn shift(&self) -> Pixels {
self.extent(&self.rects[self.from]) + self.gap
}
/// Where the dragged slot belongs for a cursor at `pointer`: how many of
/// the other slots would sit before it.
///
/// A neighbour yields once the held item covers half of it — its *trailing*
/// edge past that neighbour's centre going forward, its *leading* edge past
/// it going back. Edges rather than the held item's own centre, because the
/// two are only equivalent when everything is the same size: in the rail a
/// three-row group block is twice a one-row block, and a centre-to-centre
/// test would demand the tall block's middle reach the short one's middle —
/// pointer travel that runs off the top of the list, which is exactly the
/// "big group won't move up" case. Half-overlap asks the same of both
/// directions and of any pair of sizes.
///
/// The comparison is against the *frozen* centres, never the reflowed ones,
/// so the reflow can't move the number it's being compared to and the
/// crossing can't chase itself under a still cursor.
pub(crate) fn target(&self, pointer: Point<Pixels>) -> usize {
let leading = self.free_origin(pointer);
let trailing = leading + self.extent(&self.rects[self.from]);
self.rects
.iter()
.enumerate()
.filter(|(i, _)| *i != self.from)
.filter(|(i, r)| {
let centre = self.along(r.origin) + self.extent(r) / 2.;
if *i < self.from {
// Still above the held item: it hasn't reached back this far.
leading >= centre
} else {
trailing > centre
}
})
.count()
}
/// Where the dragged slot's leading edge is, following the pointer without
/// limit: the cursor less where inside the slot it was grabbed, so the item
/// sits under the cursor exactly where you picked it up.
fn free_origin(&self, pointer: Point<Pixels>) -> Pixels {
self.along(pointer) - self.along(self.grab)
}
/// [`Self::free_origin`] confined to the list's own span, so dragging far
/// past either end parks the item against that end instead of sending it
/// off across the window. Only the *drawing* is clamped — [`Self::target`]
/// reads the free position, so pushing past the last slot still selects it.
fn held_origin(&self, pointer: Point<Pixels>) -> Pixels {
let first = self.along(self.rects[0].origin);
let last = self.rects.last().expect("non-empty");
let end = self.along(last.origin) + self.extent(last);
self.free_origin(pointer)
.clamp(first, end - self.extent(&self.rects[self.from]))
}
/// The offset to draw the dragged slot at so it tracks the pointer: the
/// distance from where the list has *laid it out* this frame (its slot
/// under `target`) to where the cursor is actually holding it.
///
/// This is what makes the drag feel attached rather than stepwise — the
/// held item moves pixel-for-pixel with the mouse, and the reflow of the
/// others is the only thing that snaps.
pub(crate) fn held_offset(&self, pointer: Point<Pixels>, target: usize) -> Pixels {
let home = self.along(self.rects[self.from].origin);
self.held_origin(pointer) - (home + self.displacement(self.from, target))
}
/// Slot indices in preview order: the dragged one lifted out of `from` and
/// dropped back in at `target`.
pub(crate) fn order(&self, target: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..self.rects.len()).collect();
let dragged = order.remove(self.from);
order.insert(target.min(order.len()), dragged);
order
}
/// Open a frame previewing `target`: returns the animation generation to
/// key slide-ins on, and the target the previous frame drew — which
/// [`Self::flip_offset`] measures the slide from. Call once per frame,
/// before laying the slots out.
pub(crate) fn begin_frame(&self, target: usize) -> (usize, usize) {
let prev = self.prev.get();
if prev != target {
self.generation.set(self.generation.get() + 1);
self.prev.set(target);
}
(self.generation.get(), prev)
}
/// Where the slot frozen at index `slot` sits under a given preview,
/// relative to its resting place.
///
/// The displaced slots each close up by one dragged-slot pitch, in the
/// direction the drag came from. The dragged slot itself moves the other
/// way by everything it has jumped over — it stays in the list rather than
/// floating above it, so it has a position to be displaced to like anyone
/// else, and the two sides always add up to a swap.
fn displacement(&self, slot: usize, target: usize) -> Pixels {
if slot == self.from {
// Sum the pitches of the slots crossed, since rows differ in height.
let crossed = if target > self.from {
self.from + 1..=target
} else {
target..=self.from.saturating_sub(1)
};
let span: Pixels = crossed
.filter(|&i| i != self.from && i < self.rects.len())
.map(|i| self.extent(&self.rects[i]) + self.gap)
.fold(px(0.), |a, b| a + b);
if target > self.from { span } else { -span }
} else if self.from < slot && slot <= target {
-self.shift()
} else if target <= slot && slot < self.from {
self.shift()
} else {
px(0.)
}
}
/// The offset a slot should *start* this frame at so it slides into its new
/// place instead of teleporting: where the last frame drew it, minus where
/// this frame puts it. Zero for every slot the new target didn't disturb —
/// which is all but one on a typical frame, so the list only animates the
/// row you just crossed.
pub(crate) fn flip_offset(&self, slot: usize, prev: usize, target: usize) -> Pixels {
self.displacement(slot, prev) - self.displacement(slot, target)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{point, size};
/// A vertical list of `n` slots, each `h` tall with a `gap` between them,
/// starting at y = 0 — the sidebar's shape.
fn column(n: usize, h: f32, gap: f32, from: usize) -> Reorder {
let rects = (0..n)
.map(|i| Bounds {
origin: point(px(0.), px(i as f32 * (h + gap))),
size: size(px(200.), px(h)),
})
.collect();
Reorder::new(
Surface::Strip,
from,
rects,
Axis::Vertical,
px(gap),
// Grabbed dead centre of the slot.
point(px(100.), px(h / 2.)),
)
}
/// The dragged slot claims a new index the moment its centre reaches where
/// the neighbour would sit without it, and holds its own index until then.
#[test]
fn target_follows_the_pointer_across_neighbours() {
// 4 rows of 30px + 2px gaps: centres at 15, 47, 79, 111.
let r = column(4, 30., 2., 0);
// Dragging row 0, held by its centre. Row 1 yields once row 0's bottom
// edge covers half of it — pointer 32, i.e. bottom edge at 47.
assert_eq!(r.target(point(px(100.), px(32.))), 0);
assert_eq!(r.target(point(px(100.), px(34.))), 1);
// Then row 2's centre (79) at pointer 64, row 3's (111) at 96.
assert_eq!(r.target(point(px(100.), px(66.))), 2);
assert_eq!(r.target(point(px(100.), px(200.))), 3);
}
/// Dragging upward is the mirror image, and the order it previews is the
/// dragged slot lifted out and re-inserted.
#[test]
fn order_lifts_the_dragged_slot_into_the_target() {
let r = column(4, 30., 2., 3);
assert_eq!(r.target(point(px(100.), px(2.))), 0);
assert_eq!(r.order(0), vec![3, 0, 1, 2]);
assert_eq!(r.order(1), vec![0, 3, 1, 2]);
assert_eq!(r.order(3), vec![0, 1, 2, 3]);
}
/// Only the slot the drag just crossed gets a slide offset, and it's the
/// full row pitch (row height + gap) in the direction it came from.
#[test]
fn flip_offset_animates_only_the_slot_just_crossed() {
let r = column(4, 30., 2., 0);
// Preview moved from "row 0 stays" to "row 0 sits after row 1":
// row 1 closed up by one pitch, so it starts one pitch lower.
assert_eq!(r.flip_offset(1, 0, 1), px(32.));
// Rows the crossing didn't touch don't move at all.
assert_eq!(r.flip_offset(2, 0, 1), px(0.));
assert_eq!(r.flip_offset(3, 0, 1), px(0.));
// The dragged slot slides too, now that it rides in the list rather
// than floating over it: three rows crossed, three pitches to travel.
assert_eq!(r.flip_offset(0, 0, 3), px(-96.));
assert_eq!(r.flip_offset(0, 3, 0), px(96.));
// Backing out again slides row 1 the other way.
assert_eq!(r.flip_offset(1, 1, 0), px(-32.));
}
/// A tall block and a short one swap in *both* directions, with the same
/// half-overlap threshold. The regression this pins: under a
/// centre-to-centre test a three-row group could never move above a
/// one-row group, because reaching its centre meant dragging the pointer
/// off the top of the list.
#[test]
fn unequal_sizes_swap_in_both_directions() {
// A 60px block at y=0 and a 140px block at y=62 (2px gap).
let rects = vec![
Bounds {
origin: point(px(0.), px(0.)),
size: size(px(200.), px(60.)),
},
Bounds {
origin: point(px(0.), px(62.)),
size: size(px(200.), px(140.)),
},
];
let grab = point(px(100.), px(10.));
let tall = Reorder::new(
Surface::SidebarGroups,
1,
rects.clone(),
Axis::Vertical,
px(2.),
grab,
);
// Dragging the tall block up: it takes the top slot once its leading
// edge passes the short block's centre (30) — pointer 40, i.e. ~30px
// of travel from rest, all of it well inside the list.
assert_eq!(tall.target(point(px(100.), px(41.))), 1);
assert_eq!(tall.target(point(px(100.), px(39.))), 0);
// Held at rest, it keeps its own slot.
assert_eq!(tall.target(point(px(100.), px(72.))), 1);
// And the short block still goes down past the tall one, at the same
// half-overlap rule: trailing edge (pointer + 50) past centre 132.
let short = Reorder::new(
Surface::SidebarGroups,
0,
rects,
Axis::Vertical,
px(2.),
grab,
);
assert_eq!(short.target(point(px(100.), px(80.))), 0);
assert_eq!(short.target(point(px(100.), px(84.))), 1);
}
/// The held item tracks the pointer pixel for pixel, measured from
/// whichever slot the list has currently laid it out in — so it sits under
/// the cursor both before and after a crossing re-slots it.
#[test]
fn held_offset_tracks_the_pointer_across_a_crossing() {
// 4 rows of 30px + 2px gaps (pitch 32), grabbed dead centre of row 0.
let r = column(4, 30., 2., 0);
// Nudged 10px down, still target 0: the row is 10px off its home slot.
assert_eq!(r.held_offset(point(px(100.), px(25.)), 0), px(10.));
// Just past row 1's centre the target flips to 1, and the row is now
// laid out one pitch lower — so the same pointer reads 32px less.
assert_eq!(r.held_offset(point(px(100.), px(48.)), 1), px(1.));
// Dragging far past the end parks it against the last slot instead of
// running off: row 3 starts at 96, so that's the furthest it goes.
assert_eq!(r.held_offset(point(px(100.), px(900.)), 3), px(0.));
}
/// Only what the last drawn frame recorded may commit. The regression this
/// pins: dragging an item away and then back to its own slot stops the
/// surface recording (the move became a no-op), so without the per-frame
/// clear the earlier "moved" order would survive and be applied on release,
/// moving an item the user had visibly put back.
#[test]
fn pending_only_survives_the_frame_that_recorded_it() {
let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0))));
let mine = Surface::Strip;
// A frame that previews a move records it.
clear_pending(&state);
set_pending(&state, &mine, vec![1, 0, 2]);
// Another surface's recording is ignored — one drag, one owner.
set_pending(&state, &Surface::SidebarGroups, vec![2, 1, 0]);
// The next frame draws the item back in its own slot and records
// nothing; the earlier order must not outlive it.
clear_pending(&state);
assert_eq!(take_pending(&state), None);
// Taking also retires the drag.
assert!(state.borrow().is_none());
// And the ordinary path: recorded, then released.
*state.borrow_mut() = Some(column(3, 30., 2., 0));
clear_pending(&state);
set_pending(&state, &mine, vec![1, 0, 2]);
assert_eq!(take_pending(&state), Some(vec![1, 0, 2]));
}
/// The generation only advances when the preview actually changes, so a
/// jittering cursor inside one slot doesn't restart the slide every frame.
#[test]
fn begin_frame_bumps_the_generation_only_on_change() {
let r = column(3, 30., 2., 0);
assert_eq!(r.begin_frame(0), (0, 0));
assert_eq!(r.begin_frame(1), (1, 0));
assert_eq!(r.begin_frame(1), (1, 1));
assert_eq!(r.begin_frame(2), (2, 1));
}
/// A horizontal list measures along x — the strip's chips, which are wider
/// than they are tall and vary in width.
#[test]
fn horizontal_lists_measure_along_x() {
let widths = [100., 160., 120.];
let mut x = 0.;
let rects = widths
.iter()
.map(|w| {
let b = Bounds {
origin: point(px(x), px(0.)),
size: size(px(*w), px(30.)),
};
x += w + 6.;
b
})
.collect();
let r = Reorder::new(
Surface::Strip,
0,
rects,
Axis::Horizontal,
px(6.),
point(px(50.), px(15.)),
);
// Chip 1's centre is at x=186; chip 0 takes its slot once its trailing
// edge (pointer + 50) covers half of it.
assert_eq!(r.target(point(px(135.), px(15.))), 0);
assert_eq!(r.target(point(px(137.), px(15.))), 1);
assert_eq!(r.order(2), vec![1, 2, 0]);
}
}
+196 -35
View File
@@ -94,7 +94,7 @@ fn settings_search_entries() -> &'static [SearchEntry] {
SearchEntry {
section: Appearance,
title: "Theme",
keywords: "appearance color colours scheme dark light palette background foreground accent",
keywords: "appearance color colours scheme dark light palette background foreground accent sync system os auto follow",
},
SearchEntry {
section: Appearance,
@@ -177,6 +177,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: "Forward SSH loopback links",
keywords: "ssh remote port tunnel localhost forward",
},
SearchEntry {
section: Terminal,
title: "Smart selection",
keywords: "double click word url path select semantic",
},
SearchEntry {
section: Terminal,
title: "Copy on select",
@@ -355,17 +360,23 @@ pub(crate) struct SettingsState {
pub(crate) shell_args_input: Entity<InputState>,
/// Custom working-directory path (used when the strategy is `Custom`).
pub(crate) wd_path_input: Entity<InputState>,
/// Command template run when ⌘-clicking a file link (Links section). Empty
/// clears the override, restoring the built-in "open in default app".
pub(crate) link_file_command_input: Entity<InputState>,
/// Mouse-scroll multiplier slider (Terminal section).
pub(crate) scroll_slider: Entity<SliderState>,
/// Global window-opacity slider (Appearance's Window section). Shows the
/// effective value; dragging sets the config override.
pub(crate) window_opacity_slider: Entity<SliderState>,
/// The color editor for the active editable theme, or `None` when the active
/// theme is read-only (a built-in / import) or the system is being followed.
/// The color editor for the effective (on-screen) theme, or `None` when
/// that theme is read-only (a built-in / import).
pub(crate) theme_editor: Option<ThemeEditor>,
/// Whether the theme picker panel is open beside the content pane
/// (Appearance section only). Toggled from the "Current theme" card.
/// (Appearance section only). Toggled from the theme card(s).
pub(crate) theme_panel_open: bool,
/// Which theme choice the open picker panel writes to (see [`ThemeSlot`]).
/// Set by the card that opened the panel.
pub(crate) theme_panel_slot: ThemeSlot,
/// Live filter for the theme picker panel's list.
pub(crate) theme_search: Entity<InputState>,
/// `Some` while a Keybindings row is capturing a new shortcut: the action
@@ -403,6 +414,16 @@ pub(crate) struct SettingsState {
pub(crate) _subs: Vec<Subscription>,
}
/// The theme choice a picker card / the picker panel targets. `Manual` is the
/// single `Config::theme_preset` (sync-with-system off); `Light` / `Dark` are
/// the two follow-system slots (`Config::theme_preset_light` / `_dark`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThemeSlot {
Manual,
Light,
Dark,
}
/// The SSH section's right-pane selection (see [`SettingsState::ssh_detail`]).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshDetail {
@@ -465,6 +486,7 @@ pub(crate) struct SshProfileForm {
agent_forward: bool,
x11: bool,
skip_banner: bool,
shell_integration: bool,
verify_host_keys: Option<bool>,
warn_on_close: Option<bool>,
@@ -1191,7 +1213,7 @@ impl Tty7App {
"Pick a color theme. Each one sets its own light or dark look.",
cx,
))
.child(self.render_current_theme(cx))
.child(self.render_theme_selection(cx))
// Custom-theme management (duplicate / edit colors / open folder) is
// *about* themes, so it lives with the picker rather than stranded at
// the foot of the page after Cursor.
@@ -1266,7 +1288,7 @@ impl Tty7App {
};
let config = cx.global::<Config>();
let overridden = config.window_opacity.is_some() || config.window_blur.is_some();
let theme = presets::by_id(cx, &config.theme_preset.clone());
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
let opacity = Tty7App::effective_window_opacity(cx);
let blur = cx.global::<Config>().window_blur.unwrap_or(theme.blur);
@@ -1349,7 +1371,7 @@ impl Tty7App {
// The theme's current image, for the filename label and the
// opacity readout (the slider owns its own thumb position).
let theme = presets::by_id(cx, &cx.global::<Config>().theme_preset.clone());
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
let image = theme.image.clone();
let image_name = image.as_ref().map(|i| {
i.path
@@ -1936,6 +1958,7 @@ impl Tty7App {
agent_forward: profile.agent_forward,
x11: profile.x11,
skip_banner: profile.skip_banner,
shell_integration: profile.shell_integration,
verify_host_keys: profile.verify_host_keys,
warn_on_close: profile.warn_on_close,
_subs: subs,
@@ -1989,6 +2012,7 @@ impl Tty7App {
connect_timeout_s: val(&form.connect_timeout).parse().ok(),
warn_on_close: form.warn_on_close,
skip_banner: form.skip_banner,
shell_integration: form.shell_integration,
login_scripts: split_lines(&form.login_scripts.read(cx).value()),
x11: form.x11,
algorithms: Algorithms {
@@ -2555,6 +2579,22 @@ impl Tty7App {
cx,
),
)
.child(
self.settings_row(
"Shell integration",
"Let the remote shell report prompts, exit codes and directory.",
Switch::new("ssh-form-shell-integration")
.checked(form.shell_integration)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.shell_integration = *on;
cx.notify();
}
}))
.into_any_element(),
cx,
),
)
.child(text_row(
self,
"Login scripts",
@@ -2750,6 +2790,8 @@ impl Tty7App {
let clip_trim = cfg.clipboard_trim_trailing_spaces;
let copy_on_select = cfg.copy_on_select;
let mouse_reporting = cfg.mouse_reporting;
let smart_select = cfg.smart_select;
let tab_completion = cfg.tab_completion;
let bell = cfg.bell;
// Map the persisted threshold onto its preset radio index (nearest slot
// for any off-preset value a hand-edit might leave).
@@ -2775,6 +2817,10 @@ impl Tty7App {
Some(s) => s.scroll_slider.clone(),
None => return div().into_any_element(),
};
let link_file_command_input = match self.active_settings() {
Some(s) => s.link_file_command_input.clone(),
None => return div().into_any_element(),
};
let link_switch = Switch::new("term-link-url")
.checked(link_url)
@@ -2784,6 +2830,10 @@ impl Tty7App {
.checked(ssh_loopback_forward)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx)))
.into_any_element();
let link_file_command_control = div()
.w(px(300.))
.child(Input::new(&link_file_command_input).small())
.into_any_element();
let scrollback_radio = self.segmented(
"term-scrollback",
&["1,000", "10,000", "100,000"],
@@ -2835,6 +2885,14 @@ impl Tty7App {
.checked(mouse_reporting)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
.into_any_element();
let smart_select_switch = Switch::new("term-smart-select")
.checked(smart_select)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
.into_any_element();
let tab_completion_switch = Switch::new("term-tab-completion")
.checked(tab_completion)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx)))
.into_any_element();
let bell_idx = match bell {
BellMode::None => 0,
BellMode::Visual => 1,
@@ -2935,11 +2993,22 @@ impl Tty7App {
mouse_report_switch,
cx,
))
.when_some(option_alt_row, |v, row| {
v.child(self.section_rule(cx))
.child(self.section_header("Keyboard", cx))
.child(row)
})
.child(self.settings_row(
"Smart selection",
"Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
smart_select_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Keyboard", cx))
.child(self.settings_row(
"Tab completion",
"Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \
shell's own completion instead.",
tab_completion_switch,
cx,
))
.when_some(option_alt_row, |v, row| v.child(row))
.child(self.section_rule(cx))
.child(self.section_header("Links", cx))
.child(self.settings_row(
@@ -2954,6 +3023,14 @@ impl Tty7App {
ssh_loopback_switch,
cx,
))
.child(self.settings_row(
"Open files with",
"Command run when ⌘-clicking a file link, instead of the default app. \
Use {path}, {line}, {column}; a flag whose value is absent is dropped \
(e.g. herdr edit {path} --line={line}). Empty uses the default app.",
link_file_command_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Clipboard", cx))
.child(self.settings_row(
@@ -3018,7 +3095,7 @@ impl Tty7App {
let (dot_color, status_text) = match state {
HooksState::NotInstalled => (muted_fg, "Not installed"),
HooksState::Installed => (success, "Installed"),
HooksState::Outdated => (warning, "Outdated — installed by another tty7 version"),
HooksState::Outdated => (warning, "Outdated"),
};
// The primary action reads as what it will *do* from this state.
let primary_label = match state {
@@ -3031,8 +3108,11 @@ impl Tty7App {
.filter(|(for_agent, _)| *for_agent == agent)
.map(|(_, text)| text.clone());
// items_end: the whole stack shares the row's right edge, so
// status, buttons, and note line up across every agent row.
let control = v_flex()
.gap_2()
.items_end()
.child(
h_flex()
.gap_2()
@@ -3062,8 +3142,18 @@ impl Tty7App {
)
}),
)
// Width-capped so a long note (error text) wraps instead of
// inflating the shrink-proof control column and crushing the
// label to zero width.
.when_some(row_note, |col, text| {
col.child(div().text_xs().text_color(muted_fg).child(text))
col.child(
div()
.max_w_80()
.text_xs()
.text_right()
.text_color(muted_fg)
.child(text),
)
})
.into_any_element();
@@ -3277,11 +3367,39 @@ impl Tty7App {
)
}
/// The compact "Current theme" card on the Appearance page: a preview of the
/// active theme beside its kind (built-in vs custom) and light/dark mode,
/// its name, and its six chromatic ANSI swatches; the whole row a click
/// target that opens the picker panel on the right.
fn render_current_theme(&self, cx: &mut Context<Self>) -> AnyElement {
/// The theme choice block on the Appearance page: the "Sync with system"
/// switch, then either the single manual-theme card or — while following
/// the OS — one card per light/dark slot.
fn render_theme_selection(&self, cx: &mut Context<Self>) -> AnyElement {
let follow = cx.global::<Config>().theme_follow_system;
let follow_switch = Switch::new("theme-follow-system")
.checked(follow)
.on_click(cx.listener(|this, on: &bool, window, cx| {
this.set_theme_follow_system(*on, window, cx)
}))
.into_any_element();
let root = v_flex().child(self.settings_row(
"Sync with system",
"Follow the OS appearance with separate light and dark themes.",
follow_switch,
cx,
));
if follow {
root.child(self.render_theme_card(ThemeSlot::Light, cx))
.child(self.render_theme_card(ThemeSlot::Dark, cx))
.into_any_element()
} else {
root.child(self.render_theme_card(ThemeSlot::Manual, cx))
.into_any_element()
}
}
/// One compact theme card: a preview of the slot's theme beside its caption
/// (kind + light/dark mode for the manual card, the slot's role for the
/// follow-system cards), its name, and its six chromatic ANSI swatches; the
/// whole row a click target that opens the picker panel on the right,
/// aimed at this slot.
fn render_theme_card(&self, slot: ThemeSlot, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let border = theme.border;
let foreground = theme.foreground;
@@ -3289,16 +3407,36 @@ impl Tty7App {
let hover_bg = theme.secondary.opacity(0.5);
let surface = theme.secondary.opacity(0.28);
let active_id = cx.global::<Config>().theme_preset.clone();
let config = cx.global::<Config>();
let (card_id, active_id) = match slot {
ThemeSlot::Manual => ("theme-card-manual", config.theme_preset.clone()),
ThemeSlot::Light => ("theme-card-light", config.theme_preset_light.clone()),
ThemeSlot::Dark => ("theme-card-dark", config.theme_preset_dark.clone()),
};
let active = presets::by_id(cx, &active_id);
let name = active.name.clone();
let mode = if active.dark { "Dark" } else { "Light" };
// A user file (duplicated or dropped in the themes folder) vs a built-in.
let kind = if active.path.is_some() {
"Custom"
} else {
"Built-in"
};
let caption = match slot {
ThemeSlot::Manual => {
let mode = if active.dark { "Dark" } else { "Light" };
format!("{kind} · {mode}")
}
// The slot cards are captioned by their role; the one matching the
// current OS appearance is the theme actually on screen.
ThemeSlot::Light if !crate::ui::theme::system_dark(cx) => {
format!("Light mode · {kind} · Active")
}
ThemeSlot::Light => format!("Light mode · {kind}"),
ThemeSlot::Dark if crate::ui::theme::system_dark(cx) => {
format!("Dark mode · {kind} · Active")
}
ThemeSlot::Dark => format!("Dark mode · {kind}"),
};
// The six chromatic ANSI slots (red…cyan) as tiny swatches — the part of
// a theme the mini preview's few bars can't show, and what actually
// distinguishes two same-background themes at a glance.
@@ -3311,15 +3449,17 @@ impl Tty7App {
.bg(rgb(to_u32(active.ansi16[i])))
}));
let preview = self.theme_preview(&active);
let open = self.active_settings().is_some_and(|s| s.theme_panel_open);
let open = self
.active_settings()
.is_some_and(|s| s.theme_panel_open && s.theme_panel_slot == slot);
div()
.id("current-theme")
.id(card_id)
.mt_1()
.mb_2()
.w_full()
.cursor_pointer()
.on_click(cx.listener(|this, _, _w, cx| this.toggle_theme_panel(cx)))
.on_click(cx.listener(move |this, _, _w, cx| this.toggle_theme_panel(slot, cx)))
.child(
h_flex()
.items_center()
@@ -3338,12 +3478,7 @@ impl Tty7App {
.child(
v_flex()
.gap_0p5()
.child(
div()
.text_xs()
.text_color(muted_fg)
.child(format!("{kind} · {mode}")),
)
.child(div().text_xs().text_color(muted_fg).child(caption))
.child(
div()
.text_sm()
@@ -3379,14 +3514,34 @@ impl Tty7App {
// as its own surface rather than an extension of the page.
let bg = theme.sidebar;
let active_id = cx.global::<Config>().theme_preset.clone();
let (search, query) = match self.active_settings() {
let (search, query, slot) = match self.active_settings() {
Some(s) => (
s.theme_search.clone(),
s.theme_search.read(cx).value().trim().to_lowercase(),
s.theme_panel_slot,
),
None => return div().into_any_element(),
};
let config = cx.global::<Config>();
// Guard against a slot that no longer exists in the current mode (the
// sync switch flipped while the panel was open re-aims it, but stale
// state must still render something sensible).
let slot = match (config.theme_follow_system, slot) {
(false, _) => ThemeSlot::Manual,
(true, ThemeSlot::Manual) => {
if crate::ui::theme::system_dark(cx) {
ThemeSlot::Dark
} else {
ThemeSlot::Light
}
}
(true, s) => s,
};
let active_id = match slot {
ThemeSlot::Manual => config.theme_preset.clone(),
ThemeSlot::Light => config.theme_preset_light.clone(),
ThemeSlot::Dark => config.theme_preset_dark.clone(),
};
let header = h_flex()
.items_center()
@@ -3414,7 +3569,11 @@ impl Tty7App {
.pb_3()
.text_xs()
.text_color(muted_fg)
.child("Change your current theme.");
.child(match slot {
ThemeSlot::Manual => "Change your current theme.",
ThemeSlot::Light => "Choose the theme for light mode.",
ThemeSlot::Dark => "Choose the theme for dark mode.",
});
// Plain text input, the same shape the Shell section uses — our own
// field, not a bespoke pill. The Input fills its parent, but a percent
@@ -3483,8 +3642,10 @@ impl Tty7App {
s.child(Icon::new(IconName::Check).small().text_color(foreground))
}),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.set_preset(&click_id, window, cx)
.on_click(cx.listener(move |this, _, window, cx| match slot {
ThemeSlot::Manual => this.set_preset(&click_id, window, cx),
ThemeSlot::Light => this.set_slot_preset(false, &click_id, window, cx),
ThemeSlot::Dark => this.set_slot_preset(true, &click_id, window, cx),
})),
);
}
+1
View File
@@ -299,6 +299,7 @@ fn build_spec_inner(
term: "xterm-256color".to_string(),
verify_host_keys: profile.verify_host_keys.unwrap_or(global_verify_host_keys),
skip_banner: profile.skip_banner,
shell_integration: profile.shell_integration,
login_script: profile.login_scripts.clone(),
display_name: (!profile.name.is_empty()).then(|| profile.name.clone()),
profile_id: Some(profile.id.to_string()),
+690 -166
View File
@@ -5,29 +5,35 @@
//!
//! Split out of `app.rs` as an `impl Tty7App` block, exactly like `tab_strip`.
//! It shares the model wholesale: the same `self.tabs`/`self.active` state, the
//! same `tab_label`, the same `activate`/`close_tab`/`move_tab`/`start_rename`
//! operations, the same `DragTab` payload, and the same theme tokens the chips
//! use — so the vertical list stays pixel-consistent with the strip and adds no
//! new state or business logic, only a new set of click targets in a new shape.
//! same `tab_label`, the same `activate`/`close_tab`/`start_rename` operations,
//! the same `DragTab` payload and reorder machinery, and the same theme tokens
//! the chips use — so the vertical list stays pixel-consistent with the strip
//! and adds no new state or business logic, only a new set of click targets in
//! a new shape.
use gpui::{
AnyElement, Bounds, Context, FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, SharedString, Window, canvas, div, prelude::*, px,
Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window,
WindowControlArea, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient,
prelude::*, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::menu::ContextMenuExt as _;
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use std::cell::Cell;
use gpui_component::menu::{ContextMenu, ContextMenuExt as _};
use gpui_component::{
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::core::config::{Config, SidebarGrouping};
use crate::terminal::git_status::GitStatusCache;
use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App};
use crate::ui::hints::tab_badge_label;
use crate::ui::tab_strip::DragTab;
use crate::ui::reorder::{self, Reorder, Surface};
use crate::ui::tab_strip::{DragTab, REORDER_SLIDE_MS};
/// Minimum sidebar width, and the maximum as a fraction of the window width, so
/// a resize drag can't collapse the rail or let it swallow the terminal.
@@ -39,6 +45,27 @@ const MAX_SIDEBAR_WIDTH_RATIO: f32 = 0.5;
/// drag. Centered (half overhangs the body) so it clears the row close buttons.
const RESIZE_HANDLE_WIDTH: f32 = 8.;
/// Gap between rows in the rail, and between the group blocks — the distance a
/// row or block travels on top of its own height when a drag passes it.
const ROW_GAP: f32 = 2.;
/// Marks a live drag as a *group* drag — the sidebar counterpart to
/// [`DragTab`], and like it a stateless marker that renders nothing: the block
/// being dragged never leaves the rail, so there is no card floating over the
/// window. Its type is what tells the rail's drop handlers "this is a group,
/// not a tab". Scratch never starts one: it's pinned last by
/// [`sidebar_sections`], so it has no slot to move to.
#[derive(Clone)]
pub(crate) struct DragGroup;
impl Render for DragGroup {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
// gpui always paints *something* at the cursor for an active drag;
// an empty, zero-sized element is how this drag paints nothing.
div()
}
}
impl Tty7App {
/// The vertical tab sidebar rendered down the left edge of the body in
/// `tab_bar_position: left` mode. Only reached when at least one tab is open
@@ -92,24 +119,105 @@ impl Tty7App {
let keys: Rc<Vec<Option<PathBuf>>> = Rc::new(self.sidebar_group_keys(cx));
let sections = sidebar_sections(&keys);
// Each row's position in display order — the digit its ⌘N badge
// shows. Advanced for every tab, filtered-out ones included, so the
// digits (and what ⌘N targets) don't shift while the search box
// narrows the list.
let mut visual_pos = 0usize;
for (group_name, idxs) in sections {
// Rows first: the search filter may empty a group, in which case
// its header is skipped too (and the header's count reflects the
// *visible* rows while a filter narrows the list).
let mut rows: Vec<AnyElement> = Vec::new();
for i in idxs {
// This row's display-order digit; claimed before the search
// filter can skip the row (see `visual_pos` above).
let badge_pos = visual_pos;
visual_pos += 1;
// Each row's position in display order — the digit its ⌘N badge shows.
// Claimed for every tab, filtered-out ones included, and read off this
// map rather than counted as rows are emitted, so neither the search
// box nor a drag's live reflow can renumber the shortcuts under you.
let badge_pos: Vec<usize> = {
let mut pos = vec![0usize; self.tabs.len()];
for (n, i) in sections.iter().flat_map(|s| s.tabs.iter()).enumerate() {
pos[*i] = n;
}
pos
};
// Which tabs each section actually lists, and their labels. Settled
// before anything is laid out, because both drag surfaces are keyed to
// what is *visible*: a group the search box has emptied isn't rendered,
// so it must not claim a slot in the group geometry either — a phantom
// zero-sized slot would sit at the origin and swallow every crossing.
// (Matching is on the visible label; a row keeps its real tab index, so
// activate/close/reorder still hit the right tab when the list is
// narrowed.)
let visible_by_section: Vec<Vec<(usize, String)>> = sections
.iter()
.map(|s| {
s.tabs
.iter()
.map(|&i| (i, self.tab_label(&self.tabs[i], i, Some(window), cx)))
.filter(|(_, label)| query.is_empty() || label.to_lowercase().contains(&query))
.collect()
})
.collect();
// ── Live drag-reorder, part 1: the group blocks ───────────────────────
// Repo groups can be dragged by their header to reorder the whole
// block; Scratch can't (it's pinned last, so it has neither a slot to
// move to nor one to give up), so the draggable slots are exactly the
// rendered repo groups. See [`crate::ui::reorder`] for the machinery.
let pointer = window.mouse_position();
let rendered = |ix: &usize| !visible_by_section[*ix].is_empty();
let repo_slots: Vec<usize> = (0..sections.len())
.filter(|&ix| sections[ix].key.is_some())
.filter(rendered)
.collect();
let repo_groups = repo_slots.len();
let group_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
Rc::new(RefCell::new(vec![Bounds::default(); repo_groups]));
let group_preview =
reorder::preview(&self.reorder, &Surface::SidebarGroups, repo_groups, pointer);
let repo_roots: Vec<PathBuf> = repo_slots
.iter()
.filter_map(|&ix| sections[ix].key.clone())
.collect();
let slot_display: Vec<usize> = match &group_preview {
Some(p) => {
// Same as the rows below: record what releasing right now would
// produce, so the commit doesn't depend on where the cursor is.
if let (Some(from), Some(to)) = (repo_roots.get(p.from), repo_roots.get(p.target))
&& let Some(order) = regrouped_order(&keys, from, to)
{
reorder::set_pending(&self.reorder, &Surface::SidebarGroups, order);
}
p.order.clone()
}
None => (0..repo_groups).collect(),
};
// The blocks to lay out, as `(drag slot, section)`. Repo groups lead in
// the previewed slot order; Scratch trails them with no slot of its own.
let mut blocks: Vec<(Option<usize>, usize)> = slot_display
.into_iter()
.map(|slot| (Some(slot), repo_slots[slot]))
.collect();
blocks.extend(
(0..sections.len())
.filter(|&ix| sections[ix].key.is_none())
.filter(rendered)
.map(|ix| (None, ix)),
);
for (group_slot, group_ix) in blocks {
let section = &sections[group_ix];
let group_key = section.key.clone();
// Kept as concrete elements, not `AnyElement`s: a live drag
// restyles them (the slide-in offset), which can only be applied
// once every row of the group has been built.
let mut rows: Vec<ContextMenu<Stateful<Div>>> = Vec::new();
let visible = visible_by_section[group_ix].clone();
let visible_tabs: Vec<usize> = visible.iter().map(|(i, _)| *i).collect();
let row_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
Rc::new(RefCell::new(vec![Bounds::default(); visible.len()]));
// ── Live drag-reorder, part 2: the rows of this group ─────────────
let row_preview = reorder::preview(
&self.reorder,
&Surface::SidebarRows(group_key.clone()),
visible.len(),
pointer,
);
for (slot, (i, label)) in visible.into_iter().enumerate() {
let badge_pos = badge_pos[i];
let tab = &self.tabs[i];
let is_active = i == active;
let label = self.tab_label(tab, i, Some(window), cx);
// No status/cwd text under the title: the avatar's status dot
// already carries working/waiting/done, and the group header + the
// trailing branch tag carry the location — a "Working…" or cwd
@@ -192,14 +300,6 @@ impl Tty7App {
}
line
});
// Filter by the search box; matching is on the visible label. The row
// keeps its real index `i`, so activate/close/move still hit the right
// tab even when the list is narrowed.
if !query.is_empty() && !label.to_lowercase().contains(&query) {
continue;
}
let drag_label: SharedString = label.clone().into();
// Inline rename input for this tab, if it's the one being renamed —
// the same `self.renaming` branch the strip uses, so a context-menu
// rename works identically in either layout.
@@ -238,26 +338,12 @@ impl Tty7App {
)
// Branch + diff line, when the pane sits in a git repo.
.children(git_line)
// Click activates. (Renaming lives in the context menu,
// matching the strip — no double-click rename.)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
cx.stop_propagation();
this.activate(i, window, cx);
}),
)
// Drag the row by its label to reorder it (shared `DragTab`).
.on_drag(
DragTab {
index: i,
label: drag_label.clone(),
},
|drag, _, _, cx| {
cx.stop_propagation();
cx.new(|_| drag.clone())
},
)
// No mouse handler of its own: activation *and* the
// reorder drag both live on the row, and a child that
// swallowed the press would take the label — the
// largest part of the row — out of both. (Only the
// diff counts inside the branch line stop the press,
// deliberately: they're their own click target.)
.into_any_element(),
};
@@ -266,6 +352,38 @@ impl Tty7App {
// A per-row group so this row's close affordance reveals on its own
// hover without touching siblings (same trick as the chip).
.group(SharedString::from(format!("tab-row-{i}")))
// A row is first of all a switch target, so the hover
// cursor says "click me"; picking it up swaps in the
// closed hand (see `Tty7App::render`).
.cursor_pointer()
// Drag anywhere on the row to reorder it (shared `DragTab`).
// On the row, not on its label: the drag's frame of
// reference is where the *row* was grabbed, which is what
// the frozen geometry below measures — hang it off the
// label and the held row rides a few pixels off the cursor,
// skewing every crossing by that much. `slot` is the row's
// position among the *visible* rows of its group; a drag
// never leaves the group, so that's the whole world it
// needs. The builder runs once, when gpui promotes the
// press into a drag, freezing the geometry as of the last
// painted frame.
.on_drag(DragTab, {
let state = self.reorder.clone();
let slots = row_slots.clone();
let group_key = group_key.clone();
move |_drag, grab, _window, cx| {
cx.stop_propagation();
*state.borrow_mut() = Some(Reorder::new(
Surface::SidebarRows(group_key.clone()),
slot,
slots.borrow().clone(),
Axis::Vertical,
px(ROW_GAP),
grab,
));
cx.new(|_| DragTab)
}
})
.w_full()
// Size to content with a small, uniform vertical padding: a
// one-line shell tab is a short row, a two-line git tab
@@ -292,20 +410,31 @@ impl Tty7App {
s.text_color(cx.theme().sidebar_foreground)
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.5)))
})
// Drop target: dropping a dragged row here moves it to this
// slot — but only within the same group; a cross-group drop is
// a no-op, since a tab's group comes from its cwd's repo, not
// from where it sits in the list. (With grouping off all keys
// are `None`, so the check never blocks anything.)
.drag_over::<DragTab>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.2)))
.on_drop(cx.listener({
let keys = keys.clone();
move |this, drag: &DragTab, _window, cx| {
if keys.get(drag.index) == keys.get(i) {
this.move_tab(drag.index, i, cx);
}
}
}))
// Held: a light dimming so the row under your cursor reads
// as picked up. Not a lift — it stays in the rail's plane.
.when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| {
s.opacity(0.75)
})
// Measures this row into its group's slot table — the
// geometry a drag starting on a later frame freezes.
// Absolute and empty, so it costs the layout nothing.
.child(
canvas(
{
let slots = row_slots.clone();
move |bounds, _window, _cx| {
if let Some(s) = slots.borrow_mut().get_mut(slot) {
*s = bounds;
}
}
},
|_, _, _, _| {},
)
// `inset_0`, not `size_full` — see the strip's copy of
// this canvas for why the distinction matters.
.absolute()
.inset_0(),
)
// A click anywhere on the row (padding, gaps) activates it; the
// label and close children stop propagation for their own actions.
.on_mouse_down(
@@ -318,101 +447,287 @@ impl Tty7App {
// Leading avatar: agent brand mark, SSH status, or shell glyph.
.child(self.tab_avatar(agent, agent_status, agent_unread, ssh_dot, 22., cx))
.child(label_region)
// Trailing slot: while the shortcut hints are armed it shows the
// row's ⌘N switch digit; otherwise the close affordance —
// opacity-0-until-hover on every row, active or not, so a column
// of tabs reads clean. Space is reserved either way. The digit
// Trailing ⌘N badge: while the shortcut hints are armed the
// row shows its switch digit in an in-flow 20px slot (an
// all-rows-at-once modal reflow, same as the strip). The digit
// is the row's *display* position (`activate_visual` speaks the
// same order), so under grouping the rail still reads 1…9 top
// to bottom instead of scattering the tab-vector indices.
.child(if show_badges && badge_pos < 9 {
.when(show_badges && badge_pos < 9, |row| {
// Bare digit, no keycap box — matches the chip badge exactly.
div()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.size(px(20.))
.text_xs()
.font_weight(FontWeight::MEDIUM)
.text_color(if is_active {
cx.theme().sidebar_accent_foreground
} else {
cx.theme().muted_foreground
})
.child(tab_badge_label(badge_pos))
.into_any_element()
} else {
div()
.flex_shrink_0()
.opacity(0.)
.group_hover(SharedString::from(format!("tab-row-{i}")), |s| {
s.opacity(1.)
})
.child(
Button::new(("sidebar-close", i))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
this.close_tab(i, window, cx);
})),
)
.into_any_element()
row.child(
div()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.size(px(20.))
.text_xs()
.font_weight(FontWeight::MEDIUM)
.text_color(if is_active {
cx.theme().sidebar_accent_foreground
} else {
cx.theme().muted_foreground
})
.child(tab_badge_label(badge_pos)),
)
})
// Close affordance: out of flow, so the label runs the full
// rail width instead of always reserving a slot for a button
// that's invisible until hover (same Safari-style float as
// the strip's chips). On hover the ✕ sits over the *title
// line's* right end — pinned to the row top, not centered:
// on a two-line row a centered ✕ would straddle both lines
// and cover the branch line's `+n −n` counts, which are a
// click target of their own (the diff-overlay toggle). A
// solid backing in the row's hover fill plus a short
// gradient run-in fades covered title text out instead of
// hard-cutting mid-glyph. Nothing reflows on hover.
.when(!(show_badges && badge_pos < 9), |row| {
// The row fills are composited over the rail (the accent
// carries alpha; the inactive hover is a half-strength
// wash), so flatten them against `sidebar` to get the
// opaque colour the float must match.
let backing = if is_active {
cx.theme().sidebar.blend(cx.theme().sidebar_accent)
} else {
cx.theme()
.sidebar
.blend(cx.theme().sidebar_accent.opacity(0.5))
};
let mut fade_from = backing;
fade_from.a = 0.;
row.child(
h_flex()
.absolute()
// `py_1` row padding: the 20px button covers the
// title line exactly.
.top(px(4.))
.right(px(6.))
.opacity(0.)
.group_hover(SharedString::from(format!("tab-row-{i}")), |s| {
s.opacity(1.)
})
.child(div().w(px(10.)).h(px(20.)).bg(linear_gradient(
90.,
linear_color_stop(fade_from, 0.),
linear_color_stop(backing, 1.),
)))
.child(
div().bg(backing).child(
Button::new(("sidebar-close", i))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
this.close_tab(i, window, cx);
})),
),
),
)
});
// Per-tab right-click menu, shared with the strip's chips;
// `below_wording` flips the trailing close to "Close Tabs Below"
// to match the vertical layout.
let menu_app = cx.entity().downgrade();
rows.push(
row.context_menu(move |menu, window, cx| {
Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx)
})
.into_any_element(),
);
rows.push(row.context_menu(move |menu, window, cx| {
Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx)
}));
}
if rows.is_empty() {
continue;
}
let row_display: Vec<usize> = match &row_preview {
Some(p) => {
// Record the tab order releasing right now would produce, so
// letting go applies exactly what's on screen no matter where
// the cursor ended up (see `reorder::set_pending`).
if let Some(order) =
reordered_rows(&keys, &group_key, &visible_tabs, p.from, p.target)
{
reorder::set_pending(
&self.reorder,
&Surface::SidebarRows(group_key.clone()),
order,
);
}
p.order.clone()
}
None => (0..rows.len()).collect(),
};
let row_count = rows.len();
let mut rows: Vec<Option<ContextMenu<Stateful<Div>>>> =
rows.into_iter().map(Some).collect();
let rows: Vec<AnyElement> = row_display
.into_iter()
.map(|slot| match &row_preview {
// The row in hand: drawn wherever the cursor is holding it,
// pixel for pixel, with no animation in the way. `deferred`
// keeps its slot in the layout but paints it after its
// siblings, so it passes *over* the rows it's crossing
// instead of being clipped behind them.
Some(p) if p.from == slot => deferred(
rows[slot]
.take()
.expect("each slot emitted once")
.relative()
.top(p.held),
)
.into_any_element(),
// Slide into place rather than teleporting. `offset` is
// zero for every row the last crossing left alone, so one
// row moves at a time instead of the group re-animating.
Some(p) => {
let offset = p.offsets[slot].as_f32();
rows[slot]
.take()
.expect("each slot emitted once")
.with_animation(
(
SharedString::from(format!("row-slide-{}", p.generation)),
slot,
),
Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS))
.with_easing(ease_out_quint()),
move |el, delta| el.top(px(offset * (1. - delta))),
)
.into_any_element()
}
None => rows[slot]
.take()
.expect("each slot emitted once")
.into_any_element(),
})
.collect();
// Group header: the repo's directory name (or "Scratch"), small
// and muted so it labels without competing with the rows, plus the
// visible-row count. Not a click target — rows do the activating.
if let Some(name) = group_name {
list = list.child(
h_flex()
.w_full()
.items_center()
.gap_1p5()
.pl_2()
.pr_2()
.pt_1p5()
.pb_0p5()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
// Count sits right next to the name (not pushed to the
// rail's right edge): the name shrinks and truncates if
// long, the count trails it as a quiet tally.
.child(
div()
.flex_shrink(1.)
.min_w_0()
.truncate()
.font_weight(FontWeight::SEMIBOLD)
.child(name.to_uppercase()),
// visible-row count. Not a click target — rows do the activating —
// but it *is* the whole group's drag handle: drag one project name
// and the block moves, tabs and all, with the other groups sliding
// around it exactly as rows do inside one. Scratch (`group_key ==
// None`) sits out: it's pinned last, so it has nowhere to go.
let header = section.name.clone().map(|name| {
let label: SharedString = name.to_uppercase().into();
h_flex()
.id(("sidebar-group", group_ix))
.w_full()
.items_center()
.gap_1p5()
.pl_2()
.pr_1p5()
.pt_1p5()
.pb_0p5()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.when_some(group_slot, |header, slot| {
// Unlike a row, a header does nothing on click — its
// only affordance is the drag, so the open hand is the
// honest hover cursor (it closes once you pick it up).
header.cursor_grab().on_drag(DragGroup, {
let state = self.reorder.clone();
let slots = group_slots.clone();
move |_drag, grab, _window, cx| {
cx.stop_propagation();
*state.borrow_mut() = Some(Reorder::new(
Surface::SidebarGroups,
slot,
slots.borrow().clone(),
Axis::Vertical,
px(ROW_GAP),
// The header is the handle, but the *block*
// is what moves. The header leads the block,
// so the grab point inside the header is
// also the grab point inside the block —
// it passes through unchanged.
grab,
));
cx.new(|_| DragGroup)
}
})
})
// Count sits right next to the name (not pushed to the
// rail's right edge): the name shrinks and truncates if
// long, the count trails it as a quiet tally.
.child(
div()
.flex_shrink(1.)
.min_w_0()
.truncate()
.font_weight(FontWeight::SEMIBOLD)
.child(label),
)
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground.opacity(0.7))
.child(row_count.to_string()),
)
});
// One block per group — header plus its rows — so a header drag can
// move the whole thing as a unit and measure it as one slot.
let block = v_flex()
.w_full()
.gap(px(ROW_GAP))
// Held: the block you're dragging dims, exactly as a held row
// does — nothing lifts off the rail.
.when(
group_preview
.as_ref()
.is_some_and(|p| Some(p.from) == group_slot),
|b| b.opacity(0.75),
)
.children(header)
.children(rows)
// Measures the block for the group-drag geometry. Only the
// rendered repo groups hold a slot — Scratch is pinned last and
// never moves, and a group the search box emptied isn't here at
// all — so `group_slot` indexes that list, not `sections`.
.when_some(group_slot, |block, slot| {
block.child(
canvas(
{
let slots = group_slots.clone();
move |bounds, _window, _cx| {
if let Some(s) = slots.borrow_mut().get_mut(slot) {
*s = bounds;
}
}
},
|_, _, _, _| {},
)
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground.opacity(0.7))
.child(rows.len().to_string()),
),
);
}
for row in rows {
list = list.child(row);
}
.absolute()
.inset_0(),
)
});
list = list.child(match (&group_preview, group_slot) {
// The block in hand tracks the cursor, painted over the ones it
// crosses (same treatment a held row gets inside a group).
(Some(p), Some(slot)) if p.from == slot => {
deferred(block.relative().top(p.held)).into_any_element()
}
// Everything else slides; a slotless block (Scratch) never
// moves, so it falls through to the plain block below.
(Some(p), Some(slot)) => {
let offset = p.offsets[slot].as_f32();
block
.with_animation(
(
SharedString::from(format!("group-slide-{}", p.generation)),
slot,
),
Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS))
.with_easing(ease_out_quint()),
move |el, delta| el.top(px(offset * (1. - delta))),
)
.into_any_element()
}
_ => block.into_any_element(),
});
}
// The rail's own controls — new tab, and collapse — live in the top zone
@@ -592,7 +907,37 @@ impl Tty7App {
// so the rail reads as one panel from the very top edge. The
// rail's controls ride its right end, on the title bar's own
// center line — same row as the "⋯" across the window.
.child(controls)
//
// The real `TitleBar` — which carries the window's drag region
// — only spans the *right* column in this layout, so this strip
// would be dead space you can't grab the window by. Make the
// controls' own row act like the title bar it sits level with:
// drag to move, double-click to zoom. Driven exactly like
// `TitleBar` does it (and the settings overlay's stand-in
// strip): a press arms a flag and the first *move* starts the
// window move, so a plain click — and a double-click — still
// lands intact, while the buttons on the right keep taking
// their own clicks.
.child({
let should_move = Rc::new(Cell::new(false));
controls
.id("sidebar-titlebar-drag")
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(true)
})
.on_mouse_up(MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(false)
})
.on_mouse_move(move |_, window, _| {
if should_move.replace(false) {
window.start_window_move();
}
})
.on_double_click(|_, window, _| window.titlebar_double_click())
})
.child(top_bar)
.child(list),
)
@@ -643,7 +988,7 @@ impl Tty7App {
let keys = self.sidebar_group_keys(cx);
sidebar_sections(&keys)
.into_iter()
.flat_map(|(_, idxs)| idxs)
.flat_map(|s| s.tabs)
.collect()
}
@@ -662,12 +1007,25 @@ impl Tty7App {
}
}
/// Partition per-tab group keys into the sidebar's sections: `(header,
/// indices)` with groups in first-appearance order (a new repo appends, the
/// existing ones never reshuffle) and the Scratch group pinned last. A `None`
/// header means "render flat, no headers" — used when no tab is in any repo,
/// where a lone Scratch header over everything would be noise.
fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<(Option<String>, Vec<usize>)> {
/// One block of the sidebar: a header and the tabs under it.
#[derive(Debug, PartialEq)]
struct Section {
/// The repo root this group is keyed on, `None` for Scratch. Sections with
/// a key are the draggable ones (Scratch is pinned last), and it doubles as
/// the group's identity in a drag.
key: Option<PathBuf>,
/// The header text, or `None` for "render flat, no header".
name: Option<String>,
/// The group's tabs, in tab order.
tabs: Vec<usize>,
}
/// Partition per-tab group keys into the sidebar's sections: groups in
/// first-appearance order (a new repo appends, the existing ones never
/// reshuffle) with the Scratch group pinned last. A nameless single section
/// means "render flat, no headers" — used when no tab is in any repo, where a
/// lone Scratch header over everything would be noise.
fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<Section> {
let mut group_order: Vec<&PathBuf> = Vec::new();
for k in keys.iter().flatten() {
if !group_order.iter().any(|g| *g == k) {
@@ -675,26 +1033,109 @@ fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<(Option<String>, Vec<usize>
}
}
if group_order.is_empty() {
return vec![(None, (0..keys.len()).collect())];
return vec![Section {
key: None,
name: None,
tabs: (0..keys.len()).collect(),
}];
}
let names = group_names(&group_order);
let mut sections: Vec<(Option<String>, Vec<usize>)> = group_order
let mut sections: Vec<Section> = group_order
.iter()
.zip(names)
.map(|(root, name)| {
let idxs = (0..keys.len())
.map(|(root, name)| Section {
key: Some((*root).clone()),
name: Some(name),
tabs: (0..keys.len())
.filter(|&i| keys[i].as_ref() == Some(*root))
.collect();
(Some(name), idxs)
.collect(),
})
.collect();
let scratch: Vec<usize> = (0..keys.len()).filter(|&i| keys[i].is_none()).collect();
if !scratch.is_empty() {
sections.push((Some("Scratch".into()), scratch));
sections.push(Section {
key: None,
name: Some("Scratch".into()),
tabs: scratch,
});
}
sections
}
/// The tab permutation for a row dropped at a new place inside its own group:
/// `visible` are the group's rows as the rail currently lists them (the search
/// box may be hiding others), and the row at `from` lands where the row at `to`
/// is now.
///
/// Like [`regrouped_order`] this returns a whole-vector permutation rather than
/// a single move, because "third row in this group" only means something once
/// the vector is laid out the way the rail draws it: groups in their existing
/// order, each one contiguous, Scratch last. Rows the filter is hiding keep
/// their place in the group, and no other group is disturbed.
fn reordered_rows(
keys: &[Option<PathBuf>],
group: &Option<PathBuf>,
visible: &[usize],
from: usize,
to: usize,
) -> Option<Vec<usize>> {
let (&moved, &anchor) = (visible.get(from)?, visible.get(to)?);
if moved == anchor {
return None;
}
let mut members: Vec<usize> = (0..keys.len()).filter(|&i| keys[i] == *group).collect();
members.retain(|&i| i != moved);
// Land it on the far side of the row it was dropped onto, so dragging down
// ends up below that row and dragging up above it.
let at = members.iter().position(|&i| i == anchor)? + usize::from(to > from);
members.insert(at, moved);
let mut out: Vec<usize> = Vec::with_capacity(keys.len());
for g in sidebar_sections(keys).iter().map(|s| &s.key) {
if g == group {
out.extend_from_slice(&members);
} else {
out.extend((0..keys.len()).filter(|&i| keys[i] == *g));
}
}
Some(out)
}
/// The tab permutation that moves the group rooted at `from` into `to`'s slot,
/// as old indices in their new order — or `None` when the move is a no-op (same
/// group, or either root no longer has any tab).
///
/// Groups are ordered by first appearance in the tab vector, so the move is
/// "reorder the group list, then lay the tabs back out group by group". Each
/// group therefore comes out *contiguous*, with Scratch last, matching exactly
/// what the sidebar renders — which also settles the old caveat that a tab drag
/// inside an interleaved group could shuffle other groups' headers: after any
/// header drag the vector is compacted and interleaving is gone. Relative order
/// within a group is preserved.
fn regrouped_order(keys: &[Option<PathBuf>], from: &Path, to: &Path) -> Option<Vec<usize>> {
if from == to {
return None;
}
let mut order: Vec<&PathBuf> = Vec::new();
for k in keys.iter().flatten() {
if !order.iter().any(|g| *g == k) {
order.push(k);
}
}
let fi = order.iter().position(|g| g.as_path() == from)?;
let ti = order.iter().position(|g| g.as_path() == to)?;
let moved = order.remove(fi);
order.insert(ti, moved);
let mut out: Vec<usize> = Vec::with_capacity(keys.len());
for g in &order {
out.extend((0..keys.len()).filter(|&i| keys[i].as_ref() == Some(*g)));
}
// Scratch tabs trail the repo groups, which is where the sidebar draws them.
out.extend((0..keys.len()).filter(|&i| keys[i].is_none()));
Some(out)
}
/// Display names for the group roots: each root's directory name, extended
/// upward by parent components only while it collides with another root's
/// (`app` stays `app` on its own; two checkouts both named `app` become
@@ -764,17 +1205,100 @@ mod tests {
Some(p("/w/beta")),
];
let sections = sidebar_sections(&keys);
let shape: Vec<(Option<PathBuf>, Option<String>, Vec<usize>)> = sections
.into_iter()
.map(|s| (s.key, s.name, s.tabs))
.collect();
assert_eq!(
sections,
shape,
vec![
(Some("beta".into()), vec![0, 3]),
(Some("alpha".into()), vec![2]),
(Some("Scratch".into()), vec![1]),
(Some(p("/w/beta")), Some("beta".into()), vec![0, 3]),
(Some(p("/w/alpha")), Some("alpha".into()), vec![2]),
(None, Some("Scratch".into()), vec![1]),
]
);
// No tab in any repo: one headerless section over everything.
let flat = sidebar_sections(&[None, None]);
assert_eq!(flat, vec![(None, vec![0, 1])]);
assert_eq!(flat.len(), 1);
assert_eq!(flat[0].name, None);
assert_eq!(flat[0].tabs, vec![0, 1]);
}
/// A row dropped inside its group lands on the far side of the row it was
/// dropped onto, leaves every other group alone, and comes out with the
/// groups laid out contiguously the way the rail draws them.
#[test]
fn reordered_rows_moves_within_the_group_only() {
// alpha owns 0 and 2, interleaved with beta's 1; scratch is 3.
let keys = vec![
Some(p("/w/alpha")),
Some(p("/w/beta")),
Some(p("/w/alpha")),
None,
];
let alpha = Some(p("/w/alpha"));
// alpha's first row dragged onto its second: alpha reads [2, 0], and
// the vector comes out grouped — alpha, beta, scratch.
assert_eq!(
reordered_rows(&keys, &alpha, &[0, 2], 0, 1),
Some(vec![2, 0, 1, 3])
);
// Back the other way.
assert_eq!(
reordered_rows(&keys, &alpha, &[0, 2], 1, 0),
Some(vec![2, 0, 1, 3])
);
// Dropping a row on itself changes nothing.
assert_eq!(reordered_rows(&keys, &alpha, &[0, 2], 1, 1), None);
}
/// Rows the search box is hiding aren't dragged along: the visible rows
/// reorder among themselves and the hidden one keeps its place in the group.
#[test]
fn reordered_rows_leaves_filtered_out_rows_alone() {
let keys = vec![Some(p("/w/a")), Some(p("/w/a")), Some(p("/w/a"))];
let a = Some(p("/w/a"));
// Only rows 0 and 2 are listed; dragging 0 past 2 puts it after row 2,
// and row 1 stays between… where it was relative to the others.
assert_eq!(
reordered_rows(&keys, &a, &[0, 2], 0, 1),
Some(vec![1, 2, 0])
);
}
/// A header drag moves the whole group into the target's slot and lays
/// every group out contiguously, Scratch last, keeping intra-group order.
#[test]
fn regrouped_order_moves_the_group_into_the_target_slot() {
// Groups by first appearance: alpha (0, 3), beta (2), gamma (4).
let keys = vec![
Some(p("/w/alpha")),
None,
Some(p("/w/beta")),
Some(p("/w/alpha")),
Some(p("/w/gamma")),
];
// gamma dropped on alpha → gamma, alpha, beta, then Scratch.
assert_eq!(
regrouped_order(&keys, &p("/w/gamma"), &p("/w/alpha")),
Some(vec![4, 0, 3, 2, 1])
);
// alpha dropped on gamma (a move down) → beta, gamma, alpha.
assert_eq!(
regrouped_order(&keys, &p("/w/alpha"), &p("/w/gamma")),
Some(vec![2, 4, 0, 3, 1])
);
}
/// Dropping a group on itself, or naming a root no tab lives in, is a
/// no-op rather than a re-shuffle.
#[test]
fn regrouped_order_ignores_self_and_unknown_roots() {
let keys = vec![Some(p("/w/alpha")), Some(p("/w/beta"))];
assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/alpha")), None);
assert_eq!(regrouped_order(&keys, &p("/w/gone"), &p("/w/beta")), None);
assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/gone")), None);
}
/// Same-named roots grow a parent prefix until distinct; unrelated names
+253 -112
View File
@@ -5,19 +5,30 @@
//! orchestration rather than chrome rendering.
use gpui::{
AnyElement, App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window,
div, prelude::*, px,
Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton,
MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint,
linear_color_stop, linear_gradient, prelude::*, px,
};
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex};
use std::cell::RefCell;
use std::rc::Rc;
use crate::core::actions::{OpenSettings, TogglePalette};
use crate::core::config::{Config, RightPanelTab};
use crate::daemon::protocol::ShellSpec;
use crate::ui::app::{Tab, Tty7App};
use crate::ui::hints::tab_badge_label;
use crate::ui::reorder::{self, Reorder, Surface};
/// How long a slot takes to slide out of the way of a dragged tab, and the
/// gap between chips it has to travel. Short and hard-decelerating: long
/// enough to read as motion, short enough that a fast drag across the strip
/// never queues up a backlog of sliding tabs.
pub(crate) const REORDER_SLIDE_MS: u64 = 140;
const CHIP_GAP: f32 = 6.;
/// How many trailing path components a deep tab label keeps, mirroring
/// ghostty's zsh integration title `%(4~|…/%3~|%~)`: a path deeper than this
@@ -121,28 +132,21 @@ fn short_title(raw: &str) -> String {
label
}
/// Drag payload for reordering tabs. Carries the source index and a label so the
/// drag preview can show the tab being moved. `pub(crate)` so the vertical
/// [`tab_sidebar`](crate::ui::tab_sidebar) reuses the same payload (and could one
/// day support strip ↔ sidebar cross-drops via the shared `move_tab`).
/// Marks a live drag as a *tab* drag: its type is what the rail's and strip's
/// drop handlers match on, and its presence is what keeps gpui redrawing while
/// the pointer moves. It deliberately carries no state and renders nothing —
/// the tab being dragged never leaves the list, so there is no card floating
/// over the window; the reorder is drawn entirely by the list itself (see
/// [`crate::ui::reorder`]). `pub(crate)` so the vertical
/// [`tab_sidebar`](crate::ui::tab_sidebar) shares the same payload.
#[derive(Clone)]
pub(crate) struct DragTab {
pub(crate) index: usize,
pub(crate) label: SharedString,
}
pub(crate) struct DragTab;
impl Render for DragTab {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
// gpui always paints *something* at the cursor for an active drag;
// an empty, zero-sized element is how this drag paints nothing.
div()
.px_3()
.py_1()
.rounded_lg()
.bg(cx.theme().secondary)
.border_1()
.border_color(cx.theme().border)
.text_sm()
.text_color(cx.theme().foreground)
.child(self.label.clone())
}
}
@@ -463,6 +467,9 @@ impl Tty7App {
let spec = ShellSpec {
program: shell.program.clone(),
args: shell.args.clone(),
// Every arg on a dropdown row was written by
// `core::shells::detect_shells`, not the user.
args_are_tty7_defaults: true,
};
let open = app.clone();
let item = if shell.label == default_name {
@@ -540,6 +547,27 @@ impl Tty7App {
}
}));
// Mark as Unread — re-arm the avatar's green Done badge so a result
// you want to revisit nags again. Only agent tabs get the entry, and
// only a settled (`Done`) tab has a finished turn to mark; a busier
// status (working/waiting) owns the dot anyway, so the entry disables
// rather than promising a badge that can't show yet.
let tab = this.tabs.get(index);
if tab.is_some_and(|t| t.agent(cx).is_some()) {
let done = tab.and_then(|t| t.agent_status(cx))
== Some(crate::core::cli_agent::AgentStatus::Done);
menu = menu.item(
PopupMenuItem::new("Mark as Unread")
.disabled(!done)
.on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.mark_tab_unread(index, cx));
}
}),
);
}
// Worktree: an isolated checkout of this tab's repo on a fresh branch,
// opened as a new tab — parallel-agent fuel. Only offered when the
// tab's cwd actually sits in a git repository (a filesystem-only
@@ -675,17 +703,47 @@ impl Tty7App {
// `min_w`) and truncates their labels rather than pushing the "+" away.
let mut chips = h_flex()
.items_center()
.gap_1p5()
.gap(px(CHIP_GAP))
.min_w_0()
.max_w(chips_avail)
.overflow_hidden();
for (i, tab) in self.tabs.iter().enumerate() {
// ── Live drag-reorder ─────────────────────────────────────────────────
// While a chip is being dragged the strip renders in the order a drop
// would produce (see [`crate::ui::reorder`]): the dragged chip travels
// along the row itself — nothing floats over the window — and every
// chip it passes slides over to meet it. `slots` collects this frame's
// chip geometry: the reference a drag starting on a later frame freezes.
let slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
Rc::new(RefCell::new(vec![Bounds::default(); self.tabs.len()]));
let preview = reorder::preview(
&self.reorder,
&Surface::Strip,
self.tabs.len(),
window.mouse_position(),
);
// Display order: plain tab order, or the previewed one mid-drag. In the
// strip a slot *is* a tab index, so the previewed order doubles as the
// tab permutation a release would commit — recorded every frame so
// letting go applies exactly what's on screen (see `reorder`).
let display: Vec<usize> = match &preview {
Some(p) => {
reorder::set_pending(&self.reorder, &Surface::Strip, p.order.clone());
p.order.clone()
}
None => (0..self.tabs.len()).collect(),
};
for i in display {
// In sidebar mode the vertical rail carries the tab list; the strip
// keeps only its "+"/"⋯" chrome, so skip the chip row entirely.
if !show_chips {
break;
}
// The chip you're holding: still a chip in the row, just dimmed so
// it reads as picked up while it slides between slots.
let dragged = preview.as_ref().is_some_and(|p| p.from == i);
let tab = &self.tabs[i];
let is_active = i == active;
let label = self.tab_label(tab, i, Some(window), cx);
// SSH status dot (PRD FR-E2): coloured by the pane's connection phase.
@@ -703,9 +761,6 @@ impl Tty7App {
.as_ref()
.filter(|r| r.index == i)
.map(|r| r.input.clone());
// Clean label (no pane-count suffix) for the rename prefill / drag preview.
let drag_label: SharedString = label.clone().into();
// Either the editable input (while renaming) or the clickable,
// draggable label.
let label_region = match rename_input {
@@ -731,42 +786,38 @@ impl Tty7App {
// from the type, not from colour alone.
.when(is_active, |d| d.font_weight(FontWeight::MEDIUM))
.child(label)
// Single click activates; double click zooms the window,
// same as the rest of the titlebar. (Renaming lives in the
// context menu.)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
// Swallow the event — on Windows the chip's `occlude()`
// means it would never reach the TitleBar anyway, so we
// forward the double-click zoom explicitly instead.
// Caveat: gpui only implements `titlebar_double_click`
// on macOS; on Windows/Linux it's a no-op, so the chip
// doesn't zoom there until upstream adds support.
cx.stop_propagation();
if ev.click_count >= 2 {
window.titlebar_double_click();
} else {
this.activate(i, window, cx);
}
}),
)
// Drag the tab by its label to reorder it.
.on_drag(
DragTab {
index: i,
label: drag_label.clone(),
},
|drag, _, _, cx| {
cx.stop_propagation();
cx.new(|_| drag.clone())
},
)
// No mouse handler of its own: click and drag both live on
// the chip, and a child that swallowed the press would take
// the label — most of the chip — out of both.
.into_any_element(),
};
let chip = h_flex()
.id(("tab-chip", i))
// Drag anywhere on the chip to reorder it. On the chip, not on
// its label: the drag's frame of reference is where the *chip*
// was grabbed, which is what the frozen geometry below
// measures — hang it off the label and the held chip rides
// offset from the cursor, skewing every crossing by that much.
// The builder runs once, when gpui promotes the press into a
// drag, freezing the strip's geometry as of the last painted
// frame.
.on_drag(DragTab, {
let state = self.reorder.clone();
let slots = slots.clone();
move |_drag, grab, _window, cx| {
cx.stop_propagation();
*state.borrow_mut() = Some(Reorder::new(
Surface::Strip,
i,
slots.borrow().clone(),
Axis::Horizontal,
px(CHIP_GAP),
grab,
));
cx.new(|_| DragTab)
}
})
// The strip lives inside gpui-component's `TitleBar`, which marks
// its whole area as `WindowControlArea::Drag`. On Windows that maps
// to `HTCAPTION`, so unless an element on top registers a
@@ -779,6 +830,10 @@ impl Tty7App {
// A group so this chip's close affordance can reveal on hover
// (progressive disclosure) without affecting sibling tabs.
.group(SharedString::from(format!("tab-chip-{i}")))
// Same as the sidebar rows: a chip is a switch target first, so
// hover says "click me" and the drag swaps in the closed hand
// (see `Tty7App::render`).
.cursor_pointer()
.items_center()
.justify_between()
.gap_1p5()
@@ -809,20 +864,53 @@ impl Tty7App {
s.text_color(cx.theme().muted_foreground)
.hover(|s| s.bg(cx.theme().muted))
})
// Drop target: dropping a dragged tab here moves it to this slot.
.drag_over::<DragTab>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.2)))
.on_drop(cx.listener(move |this, drag: &DragTab, _window, cx| {
this.move_tab(drag.index, i, cx);
}))
// A click anywhere on the chip activates the tab. Clicks on the
// label or close button are handled by those children (which stop
// propagation), so this fires for the rest — icon, padding, the
// bare chip — making the whole tab a switch target, not just text.
// Held: a light dimming so the chip under your cursor reads as
// picked up. Not a lift — it stays in the row's own plane.
.when(dragged, |s| s.opacity(0.75))
// Measures this chip into the frame's slot table. Absolute and
// empty, so it costs the layout nothing.
.child(
canvas(
{
let slots = slots.clone();
move |bounds, _window, _cx| {
if let Some(slot) = slots.borrow_mut().get_mut(i) {
*slot = bounds;
}
}
},
|_, _, _, _| {},
)
// `inset_0`, not `size_full`: an absolutely-positioned
// child with no insets is laid out at its parent's
// *content* box, so a measuring canvas inside a padded
// element would report an origin shifted right by the
// left padding — and the held chip would ride that far
// off the cursor. Pinning all four insets to 0 anchors it
// to the padding box, which is the chip itself.
.absolute()
.inset_0(),
)
// A click anywhere on the chip activates the tab; a double click
// zooms the window, as the rest of the title bar does. Both live
// here rather than on the label so the whole chip — label, icon,
// padding — is one switch target and one drag handle. (The close
// button and the rename input stop the press for their own use.)
//
// The event is swallowed: on Windows the chip's `occlude()` means
// it would never reach the TitleBar anyway, so the zoom is
// forwarded explicitly. Caveat: gpui only implements
// `titlebar_double_click` on macOS; elsewhere it's a no-op until
// upstream adds support.
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
cx.stop_propagation();
this.activate(i, window, cx);
if ev.click_count >= 2 {
window.titlebar_double_click();
} else {
this.activate(i, window, cx);
}
}),
)
// Leading SSH status dot when this tab hosts an SSH session.
@@ -851,58 +939,110 @@ impl Tty7App {
})
// Clickable / editable label region.
.child(label_region)
// Trailing slot: normally the close affordance — kept out of the
// way (opacity 0) on every chip, active or not, and fades in on
// chip hover, so a row of tabs reads clean instead of
// three-icons-per-chip busy. Space is reserved either way, so
// nothing shifts on hover. While the shortcut hints are armed,
// the same slot shows the tab's ⌘N badge instead.
.child(if show_badges && i < 9 {
// Trailing ⌘N badge: while the shortcut hints are armed the
// badge takes an in-flow 20px slot (the strip reflows once as
// the hints arm/disarm — a deliberate, all-chips-at-once modal
// moment). It can't float like the close button below: badges
// also show on unhovered inactive chips, which are transparent
// over the window background (possibly a gradient or image), so
// there's no solid colour to back an overlay with.
.when(show_badges && i < 9, |chip| {
// Bare digit, no keycap box — the hint blends into the chip
// rather than reading as another button. Sized to the exact
// 20px square of the close button it stands in for, so the
// swap can never change the chip's width (an ellipsized
// label would otherwise reflow and the strip would jitter).
div()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.size(px(20.))
.text_xs()
.font_weight(FontWeight::MEDIUM)
.text_color(if is_active {
cx.theme().foreground
} else {
cx.theme().muted_foreground
})
.child(tab_badge_label(i))
.into_any_element()
} else {
div()
.flex_shrink_0()
.opacity(0.)
.group_hover(SharedString::from(format!("tab-chip-{i}")), |s| {
s.opacity(1.)
})
.child(
Button::new(("tab-close", i))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
this.close_tab(i, window, cx);
})),
)
.into_any_element()
// rather than reading as another button.
chip.child(
div()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.size(px(20.))
.text_xs()
.font_weight(FontWeight::MEDIUM)
.text_color(if is_active {
cx.theme().foreground
} else {
cx.theme().muted_foreground
})
.child(tab_badge_label(i)),
)
})
// Close affordance: out of flow, so the label runs the full
// chip width instead of always reserving a 20px slot for a
// button that's invisible until hover. On hover the ✕ floats
// over the label's right edge (Safari-style) on a solid backing
// in the chip's current fill — `secondary` on the active chip,
// `muted` on an inactive one, whose hover fill is exactly what
// the ✕'s visibility implies — with a short gradient run-in so
// covered text fades out instead of hard-cutting mid-glyph.
// Nothing reflows on hover.
.when(!(show_badges && i < 9), |chip| {
let backing = if is_active {
cx.theme().secondary
} else {
cx.theme().muted
};
let mut fade_from = backing;
fade_from.a = 0.;
chip.child(
h_flex()
.absolute()
.top(px(5.))
.right(px(6.))
.opacity(0.)
.group_hover(SharedString::from(format!("tab-chip-{i}")), |s| {
s.opacity(1.)
})
.child(div().w(px(10.)).h(px(20.)).bg(linear_gradient(
90.,
linear_color_stop(fade_from, 0.),
linear_color_stop(backing, 1.),
)))
.child(
div().bg(backing).child(
Button::new(("tab-close", i))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
this.close_tab(i, window, cx);
})),
),
),
)
});
// Per-tab right-click menu (rename / worktree / split / copy cwd /
// close group) — the same builder the sidebar rows use.
let menu_app = cx.entity().downgrade();
chips = chips.child(chip.context_menu(move |menu, window, cx| {
let chip = chip.context_menu(move |menu, window, cx| {
Self::tab_context_menu(menu, i, false, &menu_app, window, cx)
}));
});
chips = chips.child(match &preview {
// The chip in hand: drawn wherever the cursor is holding it,
// pixel for pixel, with no animation in the way. `deferred`
// keeps its slot in the layout but paints it after its
// siblings, so it passes *over* the chips it's crossing
// instead of being clipped behind them.
Some(p) if p.from == i => deferred(chip.relative().left(p.held)).into_any_element(),
// A chip the drag just crossed starts the frame where it used
// to be and eases to its new slot. `offset` is zero for every
// chip the last crossing left alone, so this is one moving
// chip at a time, not the whole row re-animating every frame.
Some(p) => {
let offset = p.offsets[i].as_f32();
chip.with_animation(
(
SharedString::from(format!("chip-slide-{}", p.generation)),
i,
),
Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS))
.with_easing(ease_out_quint()),
move |el, delta| el.left(px(offset * (1. - delta))),
)
.into_any_element()
}
None => chip.into_any_element(),
});
}
// "+" new-tab button — click opens the shell picker. The default shell
@@ -993,6 +1133,7 @@ impl Tty7App {
// Only `chips` is width-capped and `overflow_hidden`, so neither button is
// pushed off-screen no matter how many tabs are open.
h_flex()
.id("tab-strip")
.items_center()
.gap_1p5()
// Chip mode: viewport-derived width (see `strip_w`) so the right edge —
+61 -21
View File
@@ -87,12 +87,37 @@ pub(crate) fn window_background(bg: &presets::ActiveBackground) -> Background {
}
}
/// Whether the OS is currently in dark mode, as gpui reports it. Only
/// meaningful while the native appearance isn't pinned (see
/// [`sync_native_appearance`]) — a pinned appearance reports the pin, not the
/// OS setting, which is why callers gate on `Config::theme_follow_system`.
pub(crate) fn system_dark(cx: &App) -> bool {
matches!(
cx.window_appearance(),
gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark
)
}
/// The id of the theme that should be on screen right now: the light/dark
/// slot matching the OS appearance while `Config::theme_follow_system` is on,
/// otherwise the manual `Config::theme_preset`.
pub(crate) fn effective_preset_id(cx: &App) -> String {
let config = cx.global::<Config>();
if !config.theme_follow_system {
config.theme_preset.clone()
} else if system_dark(cx) {
config.theme_preset_dark.clone()
} else {
config.theme_preset_light.clone()
}
}
/// The background appearance the window should be *created* with: Blurred when
/// the effective theme wants blur, otherwise Transparent — never Opaque, so the
/// opacity slider works live (see the comment in [`apply_theme`]).
pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance {
let config = cx.global::<Config>();
let theme = presets::by_id(cx, &config.theme_preset);
let theme = presets::by_id(cx, &effective_preset_id(cx));
if config.window_blur.unwrap_or(theme.blur) {
WindowBackgroundAppearance::Blurred
} else {
@@ -100,15 +125,23 @@ pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance {
}
}
/// Paint gpui-component's `Theme` from the active color theme (selected by
/// `Config::theme_preset`). The theme's inferred `dark` brightness picks the
/// Paint gpui-component's `Theme` from the active color theme (resolved by
/// [`effective_preset_id`]). The theme's inferred `dark` brightness picks the
/// component `ThemeMode`; every shell surface is then derived from the theme's
/// background/foreground (see `Theme::neutrals`). Also publishes the
/// terminal-facing palette as the `ActivePalette` global so the renderer matches,
/// and applies the theme's window opacity/blur.
pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
let follow = cx.global::<Config>().theme_follow_system;
// While following the OS the native pin must be released *before* the
// theme is resolved: `effective_preset_id` reads the system appearance
// through `effectiveAppearance`, which keeps reporting the pinned value
// until the pin is cleared.
if follow {
sync_native_appearance(None);
}
let theme = presets::by_id(cx, &effective_preset_id(cx));
let config = cx.global::<Config>();
let theme = presets::by_id(cx, &config.theme_preset.clone());
let mode = if theme.dark {
ThemeMode::Dark
} else {
@@ -120,8 +153,12 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
let opacity = config.window_opacity.or(theme.opacity).filter(|o| *o < 1.0);
let blur = config.window_blur.unwrap_or(theme.blur);
// Force the native macOS chrome (traffic lights, system menus, scrollbars)
// into the theme's own light/dark mode regardless of the OS setting.
sync_native_appearance(theme.dark);
// into the theme's own light/dark mode regardless of the OS setting —
// only while *not* following the OS, where the chrome should track the
// system (and pinning would blind `system_dark` to OS flips).
if !follow {
sync_native_appearance(Some(theme.dark));
}
let m = theme.neutrals();
let active = theme.active_palette();
@@ -278,8 +315,9 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) {
cx.set_cursor_hide_mode(mode);
}
/// Force the macOS app appearance to match the active theme's light/dark mode
/// instead of following the OS `Appearance` setting.
/// Pin the macOS app appearance to the active theme's light/dark mode
/// (`Some(dark)`), or release the pin so it follows the OS `Appearance`
/// setting again (`None`, used while `Config::theme_follow_system` is on).
///
/// macOS draws the native traffic-light buttons according to the window's
/// effective appearance. With a dark tty7 theme on a light-mode macOS, the
@@ -289,7 +327,7 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) {
/// no setter, so we pin `NSApplication.appearance` ourselves via AppKit. This
/// also keeps system menus, context menus and scrollbars in the right mode.
#[cfg(target_os = "macos")]
fn sync_native_appearance(dark: bool) {
fn sync_native_appearance(dark: Option<bool>) {
use objc2::MainThreadMarker;
use objc2_app_kit::{
NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication,
@@ -300,18 +338,20 @@ fn sync_native_appearance(dark: bool) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
// SAFETY: reading the framework-provided appearance-name statics.
let name = unsafe {
if dark {
NSAppearanceNameDarkAqua
} else {
NSAppearanceNameAqua
}
};
if let Some(appearance) = NSAppearance::appearanceNamed(name) {
NSApplication::sharedApplication(mtm).setAppearance(Some(&appearance));
}
let appearance = dark.and_then(|dark| {
// SAFETY: reading the framework-provided appearance-name statics.
let name = unsafe {
if dark {
NSAppearanceNameDarkAqua
} else {
NSAppearanceNameAqua
}
};
NSAppearance::appearanceNamed(name)
});
// `None` here means "inherit from the system" — the AppKit way to unpin.
NSApplication::sharedApplication(mtm).setAppearance(appearance.as_deref());
}
#[cfg(not(target_os = "macos"))]
fn sync_native_appearance(_dark: bool) {}
fn sync_native_appearance(_dark: Option<bool>) {}
+39 -16
View File
@@ -1,12 +1,12 @@
//! Tray bitmap rendering: the bundled SVGs rasterized with `resvg` (gpui's
//! own SVG path only yields a tinted alpha mask, so the tray draws its own).
//!
//! Two states per platform:
//! Per platform:
//! - macOS: the outline terminal glyph (`tray.svg`) as a *template* image —
//! the system recolors its alpha for light/dark menu bars. Attention swaps
//! to a non-template variant: the glyph recolored to a mid-grey that reads
//! on both bar appearances, plus an amber badge (template images can't
//! carry color, so attention opts out of templating).
//! the system recolors its alpha for light/dark menu bars, permanently.
//! Attention never touches the bitmap (template images can't carry color,
//! and leaving template mode made the glyph illegible); the tooltip and
//! menu carry agent status instead (see `native.rs`).
//! - Windows / Linux: the colored app icon (`app-icon.svg`); attention
//! punches a transparent ring into the corner and fills an amber badge, so
//! the badge separates from the orange tile behind it.
@@ -27,9 +27,9 @@ const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/tray.svg");
#[cfg(not(target_os = "macos"))]
const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/app-icon.svg");
/// Physical pixel size. macOS forces the NSImage to 18 pt in the status bar
/// regardless of pixel size (see tray-icon's macOS backend), so 36 px renders
/// crisp on retina. Windows tray slots are 16–32 px; 32 downsamples cleanly.
/// Physical pixel size. On macOS the bitmap is 36 px (retina-crisp at 18 pt);
/// native.rs then overrides the NSImage to 22 pt so the glyph fills the menu
/// bar. Windows tray slots are 16–32 px; 32 downsamples cleanly.
#[cfg(target_os = "macos")]
const SIZE: u32 = 36;
#[cfg(not(target_os = "macos"))]
@@ -37,22 +37,32 @@ const SIZE: u32 = 32;
/// The `Waiting` amber, same hue as the in-window status dot
/// (`AgentStatus::dot_rgb`).
#[cfg(not(target_os = "macos"))]
const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B);
/// Render the tray icon. `attention` = some agent is blocked on the user.
/// `None` only on a malformed bundled SVG, i.e. never in practice — callers
/// treat it as "no icon change".
/// Render the tray icon: the template outline glyph, always — attention
/// never touches the bitmap, so the icon stays a template image the system
/// keeps legible on any bar. `None` only on a malformed bundled SVG, i.e.
/// never in practice — callers treat it as "no icon change".
#[cfg(target_os = "macos")]
pub(super) fn render() -> Option<RgbaImage> {
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut());
Some(to_rgba(&pixmap))
}
/// Render the tray icon. `attention` = some agent is blocked on the user —
/// stamps the amber badge into the colored app icon. `None` only on a
/// malformed bundled SVG, i.e. never in practice — callers treat it as "no
/// icon change".
#[cfg(not(target_os = "macos"))]
pub(super) fn render(attention: bool) -> Option<RgbaImage> {
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut());
if attention {
// macOS attention leaves template mode (color needs real RGB), so the
// glyph must carry its own color: a mid-grey legible on both light
// and dark menu bars. Colored platforms keep the icon's own colors.
#[cfg(target_os = "macos")]
recolor(&mut pixmap, (0x8E, 0x8E, 0x93));
badge(&mut pixmap);
}
@@ -202,6 +212,7 @@ fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) {
/// Stamp the amber attention badge in the top-right corner: first clear a
/// slightly larger disc so the badge is ringed by transparency (separating it
/// from whatever the glyph or a colored tile puts behind it), then fill.
#[cfg(not(target_os = "macos"))]
fn badge(pixmap: &mut tiny_skia::Pixmap) {
let s = SIZE as f32;
let (cx, cy) = (s * 0.78, s * 0.22);
@@ -301,8 +312,20 @@ mod tests {
assert_eq!((px[1], px[2]), (0, 0));
}
/// The template glyph renders at the declared size with visible coverage.
#[cfg(target_os = "macos")]
#[test]
fn render_produces_template_glyph() {
let img = render().unwrap();
assert_eq!((img.width, img.height), (SIZE, SIZE));
assert_eq!(img.data.len(), (SIZE * SIZE * 4) as usize);
let covered = img.data.chunks_exact(4).filter(|p| p[3] > 0).count();
assert!(covered > 0, "icon rendered fully transparent");
}
/// Both tray states render at the declared size with visible coverage,
/// and the attention badge actually changes the bitmap.
#[cfg(not(target_os = "macos"))]
#[test]
fn render_produces_both_states() {
let normal = render(false).unwrap();
+12 -6
View File
@@ -1,10 +1,12 @@
//! System tray / menu bar status item.
//!
//! The tray is the app's face outside the window: the icon flips to an
//! attention state the moment any pane's coding agent blocks on the user
//! (amber `Waiting`), and the menu lists every agent pane — click one to
//! reveal it — plus window/notification/quit controls. Menu labels are
//! English, matching the native app menus (`ui::theme::set_menus`).
//! The tray is the app's face outside the window: on Windows/Linux the icon
//! flips to an amber-badged attention state the moment any pane's coding
//! agent blocks on the user (`Waiting`); on macOS the icon is a template
//! image that stays calm in every state (legible on any bar) — agent status
//! lives in the tooltip and menu instead. The menu lists every agent pane —
//! click one to reveal it — plus window/notification/quit controls. Menu
//! labels are English, matching the native app menus (`ui::theme::set_menus`).
//!
//! Platform split (see Cargo.toml for the why):
//! - macOS / Windows: tauri's `tray-icon` (NSStatusItem / Shell_NotifyIcon),
@@ -90,7 +92,10 @@ pub(crate) struct TraySnapshot {
}
impl TraySnapshot {
/// Whether any agent is blocked on the user — drives the attention icon.
/// Whether any agent is blocked on the user — drives the attention badge
/// on Windows/Linux (the macOS icon stays calm; the tooltip and menu
/// carry agent status there).
#[cfg_attr(target_os = "macos", allow(dead_code))]
pub(crate) fn attention(&self) -> bool {
self.agents.iter().any(|a| a.status == AgentStatus::Waiting)
}
@@ -266,6 +271,7 @@ mod tests {
fn attention_follows_waiting_and_tooltip_counts() {
assert!(snapshot_with_agent(AgentStatus::Waiting).attention());
assert!(!snapshot_with_agent(AgentStatus::Working).attention());
assert!(!snapshot_with_agent(AgentStatus::Done).attention());
assert_eq!(
snapshot_with_agent(AgentStatus::Waiting).tooltip(),
"tty7 — 1 waiting"
+43 -14
View File
@@ -14,8 +14,10 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub(super) struct Backend {
tray: TrayIcon,
/// The icon state currently shown, so a snapshot diff that doesn't flip
/// attention skips the bitmap rebuild.
/// Whether the amber badge is currently stamped, so a snapshot diff that
/// doesn't flip attention skips the bitmap rebuild. macOS tracks nothing:
/// its template glyph never changes (see `icon.rs`).
#[cfg(not(target_os = "macos"))]
attention: bool,
}
@@ -37,12 +39,15 @@ impl Backend {
}
}));
#[cfg(target_os = "macos")]
let img = icon::render()?;
#[cfg(not(target_os = "macos"))]
let img = icon::render(false)?;
let icon = Icon::from_rgba(img.data, img.width, img.height).ok()?;
let tray = TrayIconBuilder::new()
.with_icon(icon)
// The calm glyph is a template on macOS (system recolors it for
// the bar); a no-op on Windows.
// The glyph is a template on macOS (system recolors it for the
// bar, in both states); a no-op on Windows.
.with_icon_as_template(true)
.with_tooltip("tty7")
.with_menu(Box::new(build_menu(&TraySnapshot::default())))
@@ -57,26 +62,50 @@ impl Backend {
return None;
}
};
// tray-icon hardcodes the NSImage height to 18 pt; override to a
// larger size so the glyph fills more of the menu bar. The bitmap
// itself is already rendered at `icon::SIZE` px (retina-ready).
#[cfg(target_os = "macos")]
if let Some(status_item) = tray.ns_status_item() {
if let Some(mtm) = objc2::MainThreadMarker::new() {
if let Some(button) = status_item.button(mtm) {
if let Some(nsimage) = button.image() {
// 22 pt matches the macOS menu bar height; the glyph
// scales proportionally from its 96×96 viewBox.
let target_h: f64 = 22.0;
let aspect = nsimage.size().width / nsimage.size().height;
nsimage.setSize(objc2_foundation::NSSize::new(target_h * aspect, target_h));
}
}
}
}
Some(Self {
tray,
#[cfg(not(target_os = "macos"))]
attention: false,
})
}
/// Push a changed snapshot into the native item: menu always (it's what
/// changed), icon only across an attention flip.
/// changed); on Windows, the badge only across an attention flip. The
/// macOS icon is a template image in every state — the system recolors it
/// for the bar, and it never carries an attention mark (status lives in
/// the tooltip and menu).
pub(super) fn update(&mut self, snap: &TraySnapshot) {
self.tray.set_menu(Some(Box::new(build_menu(snap))));
let _ = self.tray.set_tooltip(Some(snap.tooltip()));
let attention = snap.attention();
if attention != self.attention {
self.attention = attention;
if let Some(img) = icon::render(attention)
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
{
// Attention leaves template mode: the amber badge needs
// its real color (macOS; the flag is a no-op on Windows).
let _ = self.tray.set_icon_with_as_template(Some(icon), !attention);
// Windows: flip the amber corner badge on the colored icon.
#[cfg(not(target_os = "macos"))]
{
let attention = snap.attention();
if attention != self.attention {
self.attention = attention;
if let Some(img) = icon::render(attention)
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
{
let _ = self.tray.set_icon(Some(icon));
}
}
}
}