diff --git a/README.md b/README.md
index a4c6c7c8..fa649748 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com
| | |
|---|---|
-| **Agent-aware** | per-pane detection (19 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository |
+| **Agent-aware** | per-pane detection (20 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository |
| **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` |
| **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history |
| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME |
@@ -69,12 +69,13 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho
that tells tty7 which session to fork.
-The full support matrix, all nineteen
+The full support matrix, all twenty
| Agent | Detected | Status · resume | Fork |
|---|:-:|:-:|:-:|
| **Claude Code** | ✓ | ✓ | ✓ |
| **Codex** | ✓ | ✓ | ✓ |
+| **TraeCode** | ✓ | ✓ | ✓ |
| **Grok** | ✓ | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 014ba97a..b782628c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -50,7 +50,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
| | |
|---|---|
-| **Agent 感知** | 逐 pane 识别 19 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 侧边栏按仓库分组 |
+| **Agent 感知** | 逐 pane 识别 20 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 侧边栏按仓库分组 |
| **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` 转发命令输出并原样返回退出码 · `split` · `send` · `wait --until free` · `capture` |
| **编辑器级输入** | 从历史推出影子建议 · Tab 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊搜索历史 |
| **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · ⌘ J 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 |
@@ -66,12 +66,13 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
**Fork** 两个条件都要:agent 自己提供 fork 命令,且 hook 已装——tty7 得知道 fork 的是哪个会话。
-19 个 agent 的完整支持矩阵
+20 个 agent 的完整支持矩阵
| Agent | 识别 | 状态 · 重启恢复 | Fork |
|---|:-:|:-:|:-:|
| **Claude Code** | ✓ | ✓ | ✓ |
| **Codex** | ✓ | ✓ | ✓ |
+| **TraeCode** | ✓ | ✓ | ✓ |
| **Grok** | ✓ | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ |
diff --git a/assets/icons/agents/traecli.svg b/assets/icons/agents/traecli.svg
new file mode 100644
index 00000000..d18ff324
--- /dev/null
+++ b/assets/icons/agents/traecli.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs
index 6458c227..63298afb 100644
--- a/crates/tty7-core/src/core/agent_hooks.rs
+++ b/crates/tty7-core/src/core/agent_hooks.rs
@@ -246,6 +246,7 @@ fn ancestor_pids(procs: &[crate::daemon::winproc::Proc]) -> Vec {
pub enum HookAgent {
Claude,
Codex,
+ TraeCode,
Copilot,
OpenCode,
Pi,
@@ -259,9 +260,10 @@ pub enum HookAgent {
}
impl HookAgent {
- pub const ALL: [HookAgent; 12] = [
+ pub const ALL: [HookAgent; 13] = [
HookAgent::Claude,
HookAgent::Codex,
+ HookAgent::TraeCode,
HookAgent::Copilot,
HookAgent::OpenCode,
HookAgent::Pi,
@@ -283,6 +285,7 @@ impl HookAgent {
match agent {
CLIAgent::Claude => Some(HookAgent::Claude),
CLIAgent::Codex => Some(HookAgent::Codex),
+ CLIAgent::TraeCode => Some(HookAgent::TraeCode),
CLIAgent::Copilot => Some(HookAgent::Copilot),
CLIAgent::OpenCode => Some(HookAgent::OpenCode),
CLIAgent::Pi => Some(HookAgent::Pi),
@@ -310,6 +313,7 @@ impl HookAgent {
match self {
HookAgent::Claude => Some(CLAUDE_HOOK_EVENTS),
HookAgent::Codex => Some(CODEX_HOOK_EVENTS),
+ HookAgent::TraeCode => Some(TRAE_CODE_HOOK_EVENTS),
HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS),
HookAgent::Droid => Some(DROID_HOOK_EVENTS),
HookAgent::Qwen => Some(QWEN_HOOK_EVENTS),
@@ -337,6 +341,7 @@ impl HookAgent {
match self {
HookAgent::Claude => "claude",
HookAgent::Codex => "codex",
+ HookAgent::TraeCode => "traecli",
HookAgent::Copilot => "copilot",
HookAgent::OpenCode => "opencode",
HookAgent::Pi => "pi",
@@ -354,6 +359,7 @@ impl HookAgent {
match self {
HookAgent::Claude => "Claude Code",
HookAgent::Codex => "Codex",
+ HookAgent::TraeCode => "TraeCode",
HookAgent::Copilot => "Copilot CLI",
HookAgent::OpenCode => "OpenCode",
HookAgent::Pi => "Pi",
@@ -375,6 +381,7 @@ impl HookAgent {
match self {
HookAgent::Claude => target.claude_settings_path(),
HookAgent::Codex => target.under_home(&[".codex", "hooks.json"]),
+ HookAgent::TraeCode => target.traecli_hooks_path(),
HookAgent::Copilot => target.under_home(&[".copilot", "hooks", OWNED_FILE_STEM_JSON]),
HookAgent::OpenCode => target.under(
&target.xdg_config_dir(),
@@ -487,6 +494,18 @@ impl<'a> HookTarget<'a> {
self.under_home(&[".kimi-code", "config.toml"])
}
+ fn traecli_hooks_path(&self) -> PathBuf {
+ if self.is_local() {
+ if let Some(dir) = std::env::var_os("TRAECLI_HOME").filter(|d| !d.is_empty()) {
+ return PathBuf::from(dir).join("hooks.json");
+ }
+ if let Some(dir) = std::env::var_os("TRAE_HOME").filter(|d| !d.is_empty()) {
+ return PathBuf::from(dir).join("cli").join("hooks.json");
+ }
+ }
+ self.under_home(&[".trae", "cli", "hooks.json"])
+ }
+
fn hook_command(&self, agent: HookAgent, event: &str) -> String {
if let Some(exe) = self.hook_command_exe() {
return format!("{exe} agent-hook {} {event}", agent.slug());
@@ -697,6 +716,15 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[
("Stop", "stop"),
];
+const TRAE_CODE_HOOK_EVENTS: &[(&str, &str)] = &[
+ ("SessionStart", "session-start"),
+ ("UserPromptSubmit", "prompt-submit"),
+ ("PermissionRequest", "permission-request"),
+ ("PostToolUse", "tool-complete"),
+ ("Stop", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
/// Gemini names the turn boundaries after the agent rather than the user, and
/// omitting `matcher` matches everything (`hookPlanner.ts`, `!entry.matcher`),
/// so the bare entries [`hook_map_install`] already writes are enough.
@@ -1106,6 +1134,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option {
HookAgent::Goose => goose_hooks_json(target),
HookAgent::Claude
| HookAgent::Codex
+ | HookAgent::TraeCode
| HookAgent::Gemini
| HookAgent::Droid
| HookAgent::Qwen
@@ -1538,6 +1567,7 @@ mod tests {
let mut events: Vec<&str> = CLAUDE_HOOK_EVENTS
.iter()
.chain(CODEX_HOOK_EVENTS)
+ .chain(TRAE_CODE_HOOK_EVENTS)
.chain(GEMINI_HOOK_EVENTS)
.chain(DROID_HOOK_EVENTS)
.chain(QWEN_HOOK_EVENTS)
@@ -1571,6 +1601,7 @@ mod tests {
(HookAgent::Gemini, "/home/me/.gemini/settings.json"),
(HookAgent::Droid, "/home/me/.factory/settings.json"),
(HookAgent::Qwen, "/home/me/.qwen/settings.json"),
+ (HookAgent::TraeCode, "/home/me/.trae/cli/hooks.json"),
(
HookAgent::Goose,
"/home/me/.agents/plugins/tty7/hooks/hooks.json",
@@ -1862,6 +1893,7 @@ mod tests {
for (agent, expected) in [
(HookAgent::Claude, "/home/me/.claude/settings.json"),
(HookAgent::Codex, "/home/me/.codex/hooks.json"),
+ (HookAgent::TraeCode, "/home/me/.trae/cli/hooks.json"),
(HookAgent::Copilot, "/home/me/.copilot/hooks/tty7.json"),
(
HookAgent::OpenCode,
diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs
index c4c67ea0..712ecf10 100644
--- a/crates/tty7-core/src/core/cli_agent.rs
+++ b/crates/tty7-core/src/core/cli_agent.rs
@@ -23,12 +23,16 @@ pub enum CLIAgent {
Qwen,
OhMyPi,
Kimi,
+ // Keep new variants at the end: daemon messages serialize this enum and
+ // moving an existing discriminant would break mixed-version clients.
+ TraeCode,
}
impl CLIAgent {
- pub const ALL: [CLIAgent; 19] = [
+ pub const ALL: [CLIAgent; 20] = [
CLIAgent::Claude,
CLIAgent::Codex,
+ CLIAgent::TraeCode,
CLIAgent::Gemini,
CLIAgent::Aider,
CLIAgent::Amp,
@@ -52,6 +56,7 @@ impl CLIAgent {
match self {
CLIAgent::Claude => &["claude", "claude-code"],
CLIAgent::Codex => &["codex", "codex-cli"],
+ CLIAgent::TraeCode => &["traecli", "traex"],
CLIAgent::Gemini => &["gemini", "gemini-cli"],
CLIAgent::Aider => &["aider", "aider-chat"],
CLIAgent::Amp => &["amp"],
@@ -85,6 +90,7 @@ impl CLIAgent {
match self {
CLIAgent::Claude => "claude",
CLIAgent::Codex => "codex",
+ CLIAgent::TraeCode => "traecli",
CLIAgent::Gemini => "gemini",
CLIAgent::Aider => "aider",
CLIAgent::Amp => "amp",
@@ -114,6 +120,7 @@ impl CLIAgent {
match self {
CLIAgent::Claude => "Claude Code",
CLIAgent::Codex => "Codex",
+ CLIAgent::TraeCode => "TraeCode",
CLIAgent::Gemini => "Gemini",
CLIAgent::Aider => "Aider",
CLIAgent::Amp => "Amp",
@@ -146,6 +153,7 @@ impl CLIAgent {
match self {
CLIAgent::Claude => Some(format!("claude{flags} --resume {session_id}")),
CLIAgent::Codex => Some(format!("codex resume {session_id}{flags}")),
+ CLIAgent::TraeCode => Some(format!("traecli resume {session_id}{flags}")),
CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")),
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")),
CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")),
@@ -189,6 +197,7 @@ impl CLIAgent {
let flags = self.session_command_flags(session_id, launch_argv)?;
match self {
CLIAgent::Codex => Some(format!("codex fork {session_id}{flags}")),
+ CLIAgent::TraeCode => Some(format!("traecli fork {session_id}{flags}")),
CLIAgent::Claude => Some(format!(
"claude{flags} --resume {session_id} --fork-session"
)),
@@ -213,6 +222,7 @@ impl CLIAgent {
match self {
CLIAgent::Claude
| CLIAgent::Codex
+ | CLIAgent::TraeCode
| CLIAgent::Grok
| CLIAgent::OpenCode
| CLIAgent::OhMyPi
@@ -260,7 +270,9 @@ impl CLIAgent {
let named = argv.iter().position(|t| names_self(t))?;
let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect();
- if self == CLIAgent::Codex && matches!(tail.first(), Some(&"resume") | Some(&"fork")) {
+ if matches!(self, CLIAgent::Codex | CLIAgent::TraeCode)
+ && matches!(tail.first(), Some(&"resume") | Some(&"fork"))
+ {
tail.remove(0);
if tail.first().is_some_and(|t| !t.starts_with('-')) {
tail.remove(0);
@@ -339,6 +351,7 @@ impl CLIAgent {
CLIAgent::Antigravity => &["--conversation", "--continue", "-c"],
CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c", "--fork"],
CLIAgent::Codex => &["--last"],
+ CLIAgent::TraeCode => &["--last", "--resume", "--session-id"],
CLIAgent::Pi => &[
"--session",
"--session-id",
@@ -422,6 +435,9 @@ impl CLIAgent {
match self {
CLIAgent::Claude => 0xD97757,
CLIAgent::Codex => 0x000000,
+ // The brand mark carries its own green foreground on a black
+ // field, so the surrounding tab avatar needs to stay black too.
+ CLIAgent::TraeCode => 0x000000,
CLIAgent::Gemini => 0x4285F4,
CLIAgent::Aider => 0x14B014,
CLIAgent::Amp => 0xF34E3F,
@@ -448,6 +464,7 @@ impl CLIAgent {
match self {
CLIAgent::Claude => "icons/agents/claude.svg",
CLIAgent::Codex => "icons/agents/codex.svg",
+ CLIAgent::TraeCode => "icons/agents/traecli.svg",
CLIAgent::Gemini => "icons/agents/gemini.svg",
CLIAgent::Amp => "icons/agents/amp.svg",
CLIAgent::OpenCode => "icons/agents/opencode.svg",
@@ -771,6 +788,14 @@ mod tests {
CLIAgent::detect_from_argv(&argv(&["/opt/homebrew/bin/codex", "--model", "o3"])),
Some(CLIAgent::Codex)
);
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["/Users/me/.local/bin/traecli"])),
+ Some(CLIAgent::TraeCode)
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["traex"])),
+ Some(CLIAgent::TraeCode)
+ );
assert_eq!(
CLIAgent::detect_from_argv(&argv(&["/usr/local/bin/gemini"])),
Some(CLIAgent::Gemini)
@@ -1192,6 +1217,12 @@ mod tests {
CLIAgent::Codex.resume_command("th_read.9", None).as_deref(),
Some("codex resume th_read.9")
);
+ assert_eq!(
+ CLIAgent::TraeCode
+ .resume_command("019c-123", None)
+ .as_deref(),
+ Some("traecli resume 019c-123")
+ );
assert_eq!(
CLIAgent::Pi
.resume_command("0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17", None)
@@ -1522,6 +1553,10 @@ mod tests {
CLIAgent::Codex.fork_command("abc-123", None).as_deref(),
Some("codex fork abc-123")
);
+ assert_eq!(
+ CLIAgent::TraeCode.fork_command("019c-123", None).as_deref(),
+ Some("traecli fork 019c-123")
+ );
assert_eq!(
CLIAgent::Claude.fork_command("abc-123", None).as_deref(),
Some("claude --resume abc-123 --fork-session")
diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx
index 5888288c..e0e21f5b 100644
--- a/docs/agents/overview.mdx
+++ b/docs/agents/overview.mdx
@@ -1,6 +1,6 @@
---
title: "Coding agents"
-description: "What tty7 does around Claude Code, Codex, and 17 others — without ever wrapping them."
+description: "What tty7 does around Claude Code, Codex, TraeCode, and 17 others — without ever wrapping them."
---
tty7 recognises coding agents running in a pane and builds around them. It does
@@ -15,12 +15,13 @@ need, and what changed.
## Which agents
-Nineteen CLIs are recognised on sight, by the command running in the pane:
+Twenty CLIs are recognised on sight, by the command running in the pane:
| Agent | Command |
|---|---|
| Claude Code | `claude`, `claude-code` |
| Codex | `codex`, `codex-cli` |
+| TraeCode | `traecli`, `traex` |
| Gemini | `gemini`, `gemini-cli` |
| Copilot | `copilot` |
| Cursor | `cursor-agent` |
@@ -58,7 +59,7 @@ If you launch agents through a wrapper script, map its name to an agent in
```
The key is your command's name; the value is one of the slugs above (`claude`,
-`codex`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`,
+`codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`,
`droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`,
`omp`, `kimi`).
diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx
index 61d4fba8..90703839 100644
--- a/docs/agents/sessions.mdx
+++ b/docs/agents/sessions.mdx
@@ -39,6 +39,7 @@ or right-click the **tab or sidebar row** to open the fork in a new tab.
|---|---|
| Claude Code | `claude --resume --fork-session` |
| Codex | `codex fork ` |
+| TraeCode | `traecli fork ` |
| Grok | `grok --resume --fork-session` |
| OpenCode | `opencode --session --fork` |
| Oh My Pi | `omp --fork ` |
diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx
index f4fb2a87..256e7a87 100644
--- a/docs/agents/status.mdx
+++ b/docs/agents/status.mdx
@@ -14,7 +14,7 @@ the agent say which one it is.
| Agent | |
|---|---|
-| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | Hooks available |
+| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | Hooks available |
| Aider · Amp · Cursor · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet |
Installing writes into that agent's own configuration directory. Once installed
diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx
index 71f88c9d..80eb645a 100644
--- a/docs/getting-started/first-launch.mdx
+++ b/docs/getting-started/first-launch.mdx
@@ -54,13 +54,13 @@ leave it off if you type accented characters.
## 4. If you use coding agents, install the hooks
-**Settings → Agents.** tty7 detects 19 coding CLIs by process name on its own —
+**Settings → Agents.** tty7 detects 20 coding CLIs by process name on its own —
you get brand avatars and tab labels for free. The *status dots*, the "needs
your permission" notifications, and `tty7 wait` all need one more thing: a small
hook the agent calls to report what it is doing.
-Click **Install** next to Claude Code, Codex, Copilot CLI, OpenCode, Pi, Grok
-Build, or Oh My Pi. It writes into that agent's own config directory and can be
+Click **Install** next to Claude Code, Codex, TraeCode, Copilot CLI, OpenCode,
+Pi, Grok Build, or Oh My Pi. It writes into that agent's own config directory and can be
removed from the same row.
[More about agents →](/agents/status)
diff --git a/docs/index.mdx b/docs/index.mdx
index d1f0d721..a298a154 100644
--- a/docs/index.mdx
+++ b/docs/index.mdx
@@ -31,7 +31,7 @@ something floods the screen.
syntax highlighting, click-to-place-caret, real multi-line editing.
- 19 coding CLIs are recognised on sight. Per-pane status dots, notifications
+ 20 coding CLIs are recognised on sight. Per-pane status dots, notifications
when one needs you, git context, and session resume after a reboot.
diff --git a/src/terminal/element.rs b/src/terminal/element.rs
index e37ba9a9..158c18c9 100644
--- a/src/terminal/element.rs
+++ b/src/terminal/element.rs
@@ -32,7 +32,7 @@ enum UnderlineKind {
}
#[derive(Clone)]
-struct RenderCell {
+pub(super) struct RenderCell {
c: char,
marks: Option>,
fg: Hsla,
@@ -291,7 +291,7 @@ fn match_tint(cx: &gpui::App) -> u32 {
}
}
-struct PaintColors {
+pub(super) struct PaintColors {
default_fg: Hsla,
default_bg: Hsla,
caret: Hsla,
@@ -401,7 +401,7 @@ fn blend_toward(c: Hsla, dim: f32, under: Rgba) -> Hsla {
}
impl PaintColors {
- fn resolve(theme: &gpui_component::Theme, cx: &gpui::App) -> Self {
+ pub(super) fn resolve(theme: &gpui_component::Theme, cx: &gpui::App) -> Self {
let default_fg = theme.foreground;
let default_bg = theme.background;
let caret = theme.caret;
@@ -837,8 +837,6 @@ fn segment_row(row: &[RenderCell]) -> Vec {
thread_local! {
static CHAR_STRINGS: RefCell> = RefCell::new(HashMap::new());
- static GRID_BUF: RefCell> = const { RefCell::new(Vec::new()) };
-
/// Measured ink extents and the font size they were measured at.
static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>)> =
RefCell::new((px(0.), HashMap::new()));
@@ -1413,7 +1411,7 @@ fn paint_glyphs(
}
#[derive(Clone, Copy)]
-struct GridCursor {
+pub(super) struct GridCursor {
row: usize,
col: usize,
// Where the IME candidate window should anchor: the fake caret drawn by
@@ -1553,7 +1551,8 @@ fn paint_marked(
);
}
-struct GridSnapshot {
+#[derive(Clone)]
+pub(super) struct GridSnapshot {
cursor: Option,
sliver: Option>,
any_selected: bool,
@@ -1567,7 +1566,7 @@ struct GridSnapshot {
}
impl TerminalElement {
- fn build_grid(
+ pub(super) fn build_grid(
&self,
colors: &PaintColors,
buf: &mut Vec,
@@ -1577,9 +1576,8 @@ impl TerminalElement {
cx: &App,
dim: f32,
under: Rgba,
- ) -> GridSnapshot {
- buf.clear();
- buf.resize(rows * cols, RenderCell::default());
+ must_block: bool,
+ ) -> Option {
let mut cursor: Option = None;
let mut sliver: Option> = None;
let mut any_selected = false;
@@ -1591,7 +1589,32 @@ impl TerminalElement {
palette[..16].copy_from_slice(&active.ansi16);
}
let term = self.view.read(cx).terminal.term.clone();
- let term = term.lock();
+ // Rendering does not queue for the grid lock. Holding it is the
+ // pane's own reader, part-way through feeding a batch of output
+ // into the emulator — and one UI thread draws every pane in every
+ // window, so waiting here wires one pane's write speed to the frame
+ // rate of the whole window. That is the same illness as #709, which
+ // was this thread parked in `write(2)` for a stalled link; this is
+ // the read side of it. A frame that cannot have the lock paints the
+ // one before it, and nobody can see a frame of lag.
+ //
+ // `try_lock_unfair` rather than a lease: a painter that queued
+ // would make the reader wait for a frame it is not going to get
+ // anyway. Skipping the queue is safe precisely because it never
+ // waits.
+ let term = match term.try_lock_unfair() {
+ Some(term) => term,
+ // The two frames that have to have it: the first one, with no
+ // previous grid to fall back on, and the one after a resize,
+ // where the previous grid is the wrong shape. Both are rare and
+ // neither is in the steady state.
+ None if must_block => term.lock(),
+ None => return None,
+ };
+ // After the lock, not before: an early return must leave the
+ // previous frame's cells intact for the caller to paint again.
+ buf.clear();
+ buf.resize(rows * cols, RenderCell::default());
let content = term.renderable_content();
display_offset = content.display_offset as i32;
history_size = term.grid().history_size();
@@ -1683,7 +1706,7 @@ impl TerminalElement {
let (any_match, any_current) =
self.flag_search_matches(buf, rows, cols, display_offset, cx);
self.flag_hovered_link(buf, rows, cols, display_offset, cx);
- GridSnapshot {
+ Some(GridSnapshot {
cursor,
sliver,
any_selected,
@@ -1691,7 +1714,7 @@ impl TerminalElement {
any_current,
display_offset,
history_size,
- }
+ })
}
fn flag_hovered_link(
@@ -2035,8 +2058,18 @@ impl Element for TerminalElement {
(colors, 1., Rgba::default())
};
- let mut buf = GRID_BUF.with(|b| std::mem::take(&mut *b.borrow_mut()));
- let snap = self.build_grid(
+ // This pane's previous frame, borrowed for the duration of this one.
+ // `build_grid` overwrites it when it gets the terminal lock, and leaves
+ // it exactly as it is when it does not.
+ let mut buf = self
+ .view
+ .update(cx, |view, _| std::mem::take(&mut view.grid_buf));
+ let previous = self.view.read(cx).grid_snap.clone();
+ // The two frames with nothing to fall back on: the first one this pane
+ // ever paints, and the one after a resize, whose previous grid is the
+ // wrong shape to paint into these bounds. Those wait for the lock.
+ let must_block = previous.is_none() || buf.len() != geom.rows * geom.cols;
+ let built = self.build_grid(
&colors,
&mut buf,
geom.rows,
@@ -2045,7 +2078,17 @@ impl Element for TerminalElement {
cx,
dim,
under,
+ must_block,
);
+ if let Some(snap) = &built {
+ let snap = snap.clone();
+ self.view.update(cx, |view, _| view.grid_snap = Some(snap));
+ }
+ // `must_block` above is exactly the condition under which `build_grid`
+ // is not allowed to come back empty, so one of the two is always here.
+ let Some(snap) = built.or(previous) else {
+ return;
+ };
let cursor = snap.cursor;
let sliver = snap.sliver.as_ref();
@@ -2241,7 +2284,7 @@ impl Element for TerminalElement {
}
});
- GRID_BUF.with(|b| *b.borrow_mut() = buf);
+ self.view.update(cx, |view, _| view.grid_buf = buf);
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index 0933cb06..176533db 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -16,7 +16,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex};
use super::TermSize;
use super::cmd_editor::CmdEditor;
use super::completion::{self, CandidateKind, CompletionSession};
-use super::element::TerminalElement;
+use super::element::{GridSnapshot, RenderCell, TerminalElement};
use super::highlight::{self, TokenKind};
use super::hold::{GapHold, Verdict};
use super::remote::RemoteTerminal;
@@ -288,6 +288,22 @@ pub struct TerminalView {
pub line_height_mul: f32,
pub cell_width: Pixels,
pub(super) line_height: Pixels,
+ /// The grid the last frame painted, and the snapshot that went with it.
+ /// A frame that cannot have the terminal lock repaints this rather than
+ /// waiting on the pane's reader — see [`TerminalElement::build_grid`].
+ /// Owned per pane rather than kept in one shared scratch buffer, because
+ /// what makes it reusable is that it is still the *previous frame of this
+ /// pane* when the next one starts.
+ pub(super) grid_buf: Vec,
+ pub(super) grid_snap: Option,
+ /// Terminal mode and selection as of the last frame that got the lock.
+ /// What the *frame* declares — the keymap context it publishes, whether it
+ /// draws a selection — is read from here, so drawing never queues behind
+ /// the pane's reader for two bits it can be one frame late about.
+ /// Everything with a decision to make (a keystroke asking whether a
+ /// full-screen program owns the screen) still asks the terminal itself.
+ frame_alt_screen: bool,
+ frame_has_selection: bool,
selecting: bool,
drag_scroll: Option,
drag_scroll_epoch: u64,
@@ -1446,6 +1462,10 @@ impl TerminalView {
line_height_mul,
cell_width: px(8.),
line_height: px(17.),
+ grid_buf: Vec::new(),
+ grid_snap: None,
+ frame_alt_screen: false,
+ frame_has_selection: false,
selecting: false,
drag_scroll: None,
drag_scroll_epoch: 0,
@@ -2784,8 +2804,12 @@ impl TerminalView {
self.terminal.term.lock().selection.is_some()
}
+ /// Whether *this frame* draws a selection. The grid half comes from
+ /// [`Self::sync_frame_facts`] rather than the terminal, which is what keeps
+ /// the draw off the lock; a selection that appears while the reader holds
+ /// it is drawn one frame later.
fn any_selection(&self) -> bool {
- self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
+ self.frame_has_selection || (self.input_active() && self.cmd.selected_text().is_some())
}
/// The keymap context this pane declares each frame.
@@ -2797,7 +2821,13 @@ impl TerminalView {
pub(super) fn key_context(&self) -> gpui::KeyContext {
let mut context = gpui::KeyContext::new_with_defaults();
context.add("Terminal");
- if self.on_alt_screen() {
+ // The frame's own answer, not the terminal's. gpui matches keystrokes
+ // against the context the last painted frame published, so this was
+ // already a frame-old reading of the mode even when it locked; the
+ // chord that must not be a frame late (`AlternatePaste`) asks the
+ // terminal again in `alternate_paste`, which is what that comment
+ // below is about.
+ if self.frame_alt_screen {
context.add("alt_screen");
}
context
@@ -5398,7 +5428,12 @@ impl TerminalView {
// paint the grid shifted off the row the thumb just picked.
self.scroll_frac = 0.;
}
- let term = self.terminal.term.lock();
+ // Not worth a wait: the scrollbar is a picture of where the grid is,
+ // and a frame that cannot have the lock keeps the picture it drew last
+ // time rather than parking the whole window to refresh a thumb.
+ let Some(term) = self.terminal.term.try_lock_unfair() else {
+ return;
+ };
let grid = GridScroll {
history: term.grid().history_size(),
display_offset: term.grid().display_offset(),
@@ -5409,6 +5444,23 @@ impl TerminalView {
self.scroll_handle.sync(grid);
}
+ /// Re-read the two things the frame itself declares — the terminal mode its
+ /// keymap context is built from, and whether there is a selection to draw.
+ ///
+ /// One `try_lock` at the top of the frame, not one per reader: every
+ /// caller inside `render` would otherwise take the lock separately, and
+ /// each of those is another chance to sit behind the pane's reader with the
+ /// whole window's frame in hand. Failing to get it leaves the previous
+ /// frame's answers in place, which is the same bargain the grid makes in
+ /// [`TerminalElement::build_grid`].
+ fn sync_frame_facts(&mut self) {
+ let Some(term) = self.terminal.term.try_lock_unfair() else {
+ return;
+ };
+ self.frame_alt_screen = term.mode().contains(TermMode::ALT_SCREEN);
+ self.frame_has_selection = term.selection.is_some();
+ }
+
/// The scrollback bar, laid down the right edge of the grid.
///
/// The track is inset to the rows themselves — [`GRID_PAD_Y`] is padding
@@ -6537,6 +6589,7 @@ impl Drop for TerminalView {
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ self.sync_frame_facts();
self.sync_typeahead_owner();
self.sync_scrollbar();
if self.shell_owns_prompt() {
@@ -13366,6 +13419,74 @@ mod gpui_tests {
.unwrap();
}
+ /// Drawing must never queue for the grid lock.
+ ///
+ /// One UI thread paints every pane in every window, and the thread holding
+ /// this lock is the pane's own reader part-way through feeding a batch of
+ /// output into the emulator. A draw that waited for it would wire one
+ /// pane's write speed to the frame rate of the whole window — the read-side
+ /// twin of #709, which was this same thread parked in `write(2)`.
+ ///
+ /// No second thread and no timing: `try_lock` fails against a lock this
+ /// thread already holds, so "the reader has it" is reproduced exactly, with
+ /// nothing to race. The cost of that trade is what a regression looks like
+ /// — put `lock()` back and this test hangs on the re-entry rather than
+ /// failing, which reads as a CI timeout on exactly this name.
+ #[gpui::test]
+ fn a_frame_that_cannot_have_the_grid_leaves_the_previous_one_alone(cx: &mut TestAppContext) {
+ use super::super::element::PaintColors;
+
+ let (_window, view, _daemon) = rooted_harness(cx);
+ let element = TerminalElement::new(view.clone());
+ let mut buf = Vec::new();
+ let build = |cx: &mut TestAppContext, buf: &mut Vec, must_block: bool| {
+ cx.update(|cx| {
+ let colors = PaintColors::resolve(cx.theme(), cx);
+ element.build_grid(
+ &colors,
+ buf,
+ 24,
+ 80,
+ false,
+ cx,
+ 1.,
+ gpui::Rgba::default(),
+ must_block,
+ )
+ })
+ };
+
+ assert!(
+ build(cx, &mut buf, true).is_some(),
+ "the first frame has no previous grid to stand in for it, so it waits and builds"
+ );
+ assert_eq!(buf.len(), 24 * 80);
+
+ // Shortened so the next call cannot touch the buffer without saying so:
+ // building would `clear` and `resize` it back to a full grid.
+ buf.truncate(3);
+ let term = cx.update(|cx| view.read(cx).terminal.term.clone());
+ let held = term.lock();
+ let refused = build(cx, &mut buf, false);
+ drop(held);
+
+ assert!(
+ refused.is_none(),
+ "a frame that cannot have the lock says so instead of waiting for it"
+ );
+ assert_eq!(
+ buf.len(),
+ 3,
+ "the previous frame's cells have to survive for that frame to be painted again"
+ );
+
+ assert!(
+ build(cx, &mut buf, false).is_some(),
+ "with the lock free, a frame builds without being told to wait"
+ );
+ assert_eq!(buf.len(), 24 * 80);
+ }
+
#[gpui::test]
fn a_highlight_follows_its_text_as_output_scrolls_under_it(cx: &mut TestAppContext) {
let (window, view, _daemon) = rooted_harness(cx);
diff --git a/src/ui/app.rs b/src/ui/app.rs
index f89ca530..7a7e972c 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -763,6 +763,10 @@ pub(crate) struct LoopbackForwardPanelState {
/// Why the last Add or Save did not take, in the far side's own words.
/// Cleared the moment the form is closed or the edit is abandoned.
pub(crate) mf_error: Option,
+ /// Return, on each of the five boxes. Held here for the same reason the
+ /// sftp form holds its own: a live subscription on a box nothing is
+ /// showing would answer Return for a form that is gone.
+ pub(crate) mf_subs: Vec,
}
pub struct Tty7App {
@@ -1422,6 +1426,7 @@ impl Tty7App {
mf_description,
mf_editing: None,
mf_error: None,
+ mf_subs: Vec::new(),
},
sftp_panel,
right_panel: Default::default(),
@@ -2991,6 +2996,49 @@ impl Tty7App {
self.loopback_panel.form_pane_id = Some(pane_id);
self.cancel_managed_forward_edit(window, cx);
self.refresh_managed_forwards(pane_id, cx);
+ self.arm_managed_forward_form(pane_id, window, cx);
+ }
+
+ /// Opens the form focused and listening for Return.
+ ///
+ /// It had neither. Every other form in the app opens with the caret in the
+ /// first field and answers Return — this one opened cold, so adding a rule
+ /// meant clicking into Bind first, and once you were there the only way to
+ /// commit was the mouse again. Escape did nothing either, which is handled
+ /// on the form itself in `forwards.rs`; a key event only reaches it while
+ /// something inside it holds focus, so the focus below is what makes that
+ /// work too.
+ fn arm_managed_forward_form(
+ &mut self,
+ pane_id: u64,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let inputs = [
+ self.loopback_panel.mf_bind_host.clone(),
+ self.loopback_panel.mf_bind_port.clone(),
+ self.loopback_panel.mf_target_host.clone(),
+ self.loopback_panel.mf_target_port.clone(),
+ self.loopback_panel.mf_description.clone(),
+ ];
+ self.loopback_panel.mf_subs = inputs
+ .iter()
+ .map(|input| {
+ cx.subscribe_in(
+ input,
+ window,
+ move |this, _input, ev: &InputEvent, window, cx| {
+ if let InputEvent::PressEnter { .. } = ev {
+ // A no-op when the fields do not make a rule yet:
+ // `add_managed_forward` already guards on that and
+ // the form already says what is missing.
+ this.add_managed_forward(pane_id, window, cx);
+ }
+ },
+ )
+ })
+ .collect();
+ inputs[0].update(cx, |s, cx| s.focus(window, cx));
}
pub(crate) fn close_managed_forward_form(
@@ -2998,8 +3046,14 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context,
) {
- self.loopback_panel.form_pane_id = None;
+ let was_open = self.loopback_panel.form_pane_id.take().is_some();
+ self.loopback_panel.mf_subs.clear();
self.cancel_managed_forward_edit(window, cx);
+ if was_open {
+ // The form held the focus, so taking it down has to hand it back —
+ // otherwise the next keystroke goes nowhere until the user clicks.
+ self.focus_active(window, cx);
+ }
}
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context) {
@@ -7079,31 +7133,11 @@ impl Tty7App {
return None;
}
let notice = self.remote_status(cx)?.input_notice()?;
- let theme = cx.theme();
+ // The pill only. `body_area` anchors it, together with whatever else
+ // is floating down there — see `ui::notice`.
Some(
- div()
- .absolute()
- .left_0()
- .right_0()
- .bottom_4()
- .flex()
- .justify_center()
- .child(
- gpui_component::h_flex()
- .occlude()
- .items_center()
- .gap_2()
- .px_3()
- .py_1p5()
- .rounded_lg()
- .bg(theme.popover)
- .border_1()
- .border_color(theme.warning.opacity(0.4))
- .shadow_md()
- .text_xs()
- .text_color(theme.muted_foreground)
- .child(notice),
- )
+ crate::ui::notice::pill(cx.theme().warning, cx)
+ .child(notice)
.into_any_element(),
)
}
@@ -7320,13 +7354,23 @@ impl Render for Tty7App {
.child(body)
.when_some(self.pane_landing(window, cx), |this, el| this.child(el))
.when_some(tab_landing, |this, el| this.child(el))
- .when_some(ssh_status, |this, el| this.child(el))
.when_some(self.render_remote_workspace_strip(cx), |this, el| {
this.child(el)
})
- .when_some(self.render_remote_input_notice(cx), |this, el| {
- this.child(el)
- });
+ // Both of these used to anchor themselves at `bottom_4` and centre
+ // themselves, as siblings here — so a remote workspace whose ssh
+ // link had also dropped drew them one on top of the other. One
+ // anchor now, and it stacks. The ssh strip goes last because it is
+ // the one carrying buttons.
+ .when_some(
+ crate::ui::notice::anchor(
+ [self.render_remote_input_notice(cx), ssh_status]
+ .into_iter()
+ .flatten()
+ .collect(),
+ ),
+ |this, el| this.child(el),
+ );
// One decision for the whole document surface. Docked, exactly one of
// the two surfaces is drawn — a column has one child, and two `flex_1`
@@ -10470,7 +10514,7 @@ mod zoom_gpui_tests {
// are left holding when the far side does not answer.
#[cfg(test)]
mod managed_forward_gpui_tests {
- use gpui::TestAppContext;
+ use gpui::{Focusable as _, TestAppContext};
use gpui_component::input::InputState;
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
@@ -10490,6 +10534,57 @@ mod managed_forward_gpui_tests {
}
}
+ /// The form had no keyboard contract at all: no Return, no Escape, and it
+ /// opened cold, with the caret still in the terminal behind it. Every
+ /// sibling form in the app has all three.
+ ///
+ /// Escape is a `on_key_down` on the form itself and only fires while
+ /// something inside it holds focus, so the focus below is what makes both
+ /// halves work; the subscriptions are what answer Return. Asserting on
+ /// both together is the point — arming one without the other is the state
+ /// this test exists to catch.
+ #[gpui::test]
+ fn opening_the_forward_form_arms_the_keyboard_and_closing_disarms_it(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
+
+ app.update_in(&mut vcx, |app, window, cx| {
+ assert!(
+ app.loopback_panel.mf_subs.is_empty(),
+ "nothing is listening before the form is up"
+ );
+
+ app.toggle_managed_forward_form(1, window, cx);
+
+ assert_eq!(
+ app.loopback_panel.form_pane_id,
+ Some(1),
+ "the form is up for the pane that asked"
+ );
+ assert_eq!(
+ app.loopback_panel.mf_subs.len(),
+ 5,
+ "Return has to be answered on every box, not just the first"
+ );
+ assert!(
+ app.loopback_panel
+ .mf_bind_host
+ .read(cx)
+ .focus_handle(cx)
+ .is_focused(window),
+ "the form opens with the caret in Bind, so Escape reaches it too"
+ );
+
+ app.close_managed_forward_form(window, cx);
+
+ assert_eq!(app.loopback_panel.form_pane_id, None);
+ assert!(
+ app.loopback_panel.mf_subs.is_empty(),
+ "a live subscription on a box nothing is showing would answer \
+ Return for a form that is gone"
+ );
+ });
+ }
+
#[gpui::test]
fn an_add_that_never_reaches_the_session_leaves_the_panel_as_it_was(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
index 94a7bb1f..801694f7 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -49,6 +49,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
+ "icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
"icons/agents/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"),
"icons/agents/amp.svg" => include_bytes!("../../assets/icons/agents/amp.svg"),
"icons/agents/opencode.svg" => include_bytes!("../../assets/icons/agents/opencode.svg"),
diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs
index a9784dfa..2adb55e7 100644
--- a/src/ui/forwards.rs
+++ b/src/ui/forwards.rs
@@ -148,28 +148,13 @@ impl Tty7App {
.and_then(|id| uuid::Uuid::parse_str(&id).ok());
let theme = cx.theme();
+ let (danger, foreground) = (theme.danger, theme.foreground);
- let bar = h_flex()
- .occlude()
- .items_center()
- .gap_2()
- .px_3()
- .py_1p5()
- .rounded_lg()
- .bg(theme.popover)
- .border_1()
- .border_color(theme.danger.opacity(0.4))
- .shadow_md()
- // Off the right panel's ramp on purpose: this bar floats over the
- // terminal, not inside the panel, and it is sized against the
- // terminal's own text. `app.rs` draws it, `render_panel_info` does
- // not.
- .text_xs()
- .text_color(theme.muted_foreground)
+ let bar = crate::ui::notice::pill(danger, cx)
.child(
div()
.font_weight(FontWeight::MEDIUM)
- .text_color(theme.foreground)
+ .text_color(foreground)
.child(if host.is_empty() {
t(L10nKey::ForwardDisconnected).to_string()
} else {
@@ -186,7 +171,7 @@ impl Tty7App {
.id("ssh-strip-reason")
.max_w(px(360.))
.truncate()
- .text_color(theme.danger)
+ .text_color(danger)
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(full.clone()).build(window, cx)
})
@@ -219,17 +204,10 @@ impl Tty7App {
this.open_ssh_profile_in_settings(id, window, cx)
}))
}));
- Some(
- div()
- .absolute()
- .left_0()
- .right_0()
- .bottom_4()
- .flex()
- .justify_center()
- .child(bar)
- .into_any_element(),
- )
+ // The bar only. `body_area` anchors it, together with whatever else is
+ // floating down there — this used to place itself at `bottom_4` and so
+ // did the remote input notice, on the same container.
+ Some(bar.into_any_element())
}
pub(crate) fn forwards_section(
@@ -456,6 +434,17 @@ impl Tty7App {
.pt(px(6.))
.pb(px(2.))
.gap(px(5.))
+ // Escape backs out of the form, the way it backs out of the sftp
+ // edit box and every sheet the app puts up. Return is answered by
+ // the boxes themselves — see `arm_managed_forward_form` — because
+ // an Input takes Return before it can bubble to here.
+ .on_key_down(
+ cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| {
+ if ev.keystroke.key == "escape" {
+ this.close_managed_forward_form(window, cx);
+ }
+ }),
+ )
.child(self.segmented_on(
sf,
"ssh-managed-forward-kind",
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 483ab2f7..1fa0fba8 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -738,6 +738,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
}
L10nKey::SettingsAgentClaudeCode => "Claude Code",
L10nKey::SettingsAgentCodex => "Codex",
+ L10nKey::SettingsAgentTraeCode => "TraeCode",
L10nKey::SettingsAgentCopilotCli => "Copilot CLI",
L10nKey::SettingsAgentOpencode => "OpenCode",
L10nKey::SettingsAgentPi => "Pi",
@@ -768,6 +769,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"agent integration hooks install uninstall status rich session working waiting tab bar sidebar badge claude"
}
L10nKey::SettingsSearchCodexKeywords => "agent integration hooks install openai codex",
+ L10nKey::SettingsSearchTraeCodeKeywords => {
+ "agent integration hooks install trae code traecli traex"
+ }
L10nKey::SettingsSearchCommandLineToolKeywords => {
"command line tool cli tty7 path shell command install symlink terminal iterm agent script"
}
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 39fdceb1..5de5a95e 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -747,6 +747,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
}
L10nKey::SettingsAgentClaudeCode => "Claude Code",
L10nKey::SettingsAgentCodex => "Codex",
+ L10nKey::SettingsAgentTraeCode => "TraeCode",
L10nKey::SettingsAgentCopilotCli => "Copilot CLI",
L10nKey::SettingsAgentOpencode => "OpenCode",
L10nKey::SettingsAgentPi => "Pi",
@@ -787,6 +788,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchCodexKeywords => {
"エージェント 統合 フック インストール openai codex agent integration hooks install"
}
+ L10nKey::SettingsSearchTraeCodeKeywords => {
+ "エージェント 統合 フック インストール trae code traecli traex agent integration hooks install"
+ }
L10nKey::SettingsSearchCommandLineToolKeywords => {
"cli tty7 パス シェル コマンド インストール シンボリックリンク ターミナル iterm エージェント スクリプト command line tool"
}
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 604547ac..430ca53c 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -570,6 +570,7 @@ l10n_keys! {
SettingsAppHttpProxyInvalid,
SettingsAgentClaudeCode,
SettingsAgentCodex,
+ SettingsAgentTraeCode,
SettingsAgentCopilotCli,
SettingsAgentOpencode,
SettingsAgentPi,
@@ -593,6 +594,7 @@ l10n_keys! {
SettingsSearchBoldFontKeywords,
SettingsSearchClaudeCodeKeywords,
SettingsSearchCodexKeywords,
+ SettingsSearchTraeCodeKeywords,
SettingsSearchCommandLineToolKeywords,
SettingsSearchCommandLineToolTitle,
SettingsSearchCopilotCliKeywords,
@@ -1537,6 +1539,7 @@ mod tests {
// Product names.
L10nKey::SettingsAgentClaudeCode,
L10nKey::SettingsAgentCodex,
+ L10nKey::SettingsAgentTraeCode,
L10nKey::SettingsAgentCopilotCli,
L10nKey::SettingsAgentDroid,
L10nKey::SettingsAgentGemini,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index 728ca710..51625979 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -656,6 +656,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAppHttpProxyInvalid => "不是有效的代理地址,该值未保存。",
L10nKey::SettingsAgentClaudeCode => "Claude Code",
L10nKey::SettingsAgentCodex => "Codex",
+ L10nKey::SettingsAgentTraeCode => "TraeCode",
L10nKey::SettingsAgentCopilotCli => "Copilot CLI",
L10nKey::SettingsAgentOpencode => "OpenCode",
L10nKey::SettingsAgentPi => "Pi",
@@ -694,6 +695,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchCodexKeywords => {
"Codex agent 集成 hook 安装 OpenAI codex agent integration hooks install"
}
+ L10nKey::SettingsSearchTraeCodeKeywords => {
+ "TraeCode traecli traex agent 集成 hook 安装 agent integration hooks install"
+ }
L10nKey::SettingsSearchCommandLineToolKeywords => {
"命令行工具 cli tty7 路径 shell 命令 安装 符号链接 terminal command line tool"
}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 149da06b..496b8c4a 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -18,6 +18,7 @@ pub mod i18n;
pub mod keymap;
pub mod local_link;
pub mod machine_mirror;
+pub mod notice;
pub mod palette;
pub mod pane;
pub mod pane_drag;
diff --git a/src/ui/notice.rs b/src/ui/notice.rs
new file mode 100644
index 00000000..29beaefc
--- /dev/null
+++ b/src/ui/notice.rs
@@ -0,0 +1,82 @@
+//! The pill the app floats over the terminal when something about the
+//! connection needs saying.
+//!
+//! There were two of these — `render_remote_input_notice` in `app.rs` and
+//! `render_ssh_status_strip` in `forwards.rs` — written out a builder call at
+//! a time in two files, identical down to the padding and differing only in
+//! the border colour. Each also placed itself: both were
+//! `absolute().left_0().right_0().bottom_4()`, centred, and both were children
+//! of the same container in `body_area`, with nothing arbitrating between
+//! them. A remote workspace whose ssh link had also dropped drew them on top
+//! of each other.
+//!
+//! So the shell lives here and the notices no longer place themselves. The
+//! anchor is a column: a second notice stacks above the first instead of
+//! landing on it.
+
+use gpui::{AnyElement, App, Div, Hsla, div, prelude::*};
+use gpui_component::{ActiveTheme as _, h_flex, v_flex};
+
+/// Chrome for one floating notice. `accent` is the border, and is the only
+/// thing that says how bad this one is; the rest of the pill is the same
+/// whatever went wrong.
+pub(crate) fn pill(accent: Hsla, cx: &App) -> Div {
+ let theme = cx.theme();
+ h_flex()
+ .occlude()
+ .items_center()
+ .gap_2()
+ .px_3()
+ .py_1p5()
+ .rounded_lg()
+ .bg(theme.popover)
+ .border_1()
+ .border_color(accent.opacity(0.4))
+ .shadow_md()
+ // Off the right panel's ramp on purpose: these float over the
+ // terminal, not inside a panel, and are sized against the terminal's
+ // own text.
+ .text_xs()
+ .text_color(theme.muted_foreground)
+}
+
+/// Anchors whatever notices are up as one bottom-centred column, so two of
+/// them stack rather than collide. `None` when there is nothing to show, which
+/// is what lets the caller keep using `when_some`.
+pub(crate) fn anchor(items: Vec) -> Option {
+ if items.is_empty() {
+ return None;
+ }
+ Some(
+ div()
+ .absolute()
+ .left_0()
+ .right_0()
+ .bottom_4()
+ .child(v_flex().w_full().items_center().gap_2().children(items))
+ .into_any_element(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn nothing_up_means_no_anchor() {
+ assert!(anchor(Vec::new()).is_none());
+ }
+
+ #[test]
+ fn one_notice_still_gets_the_anchor() {
+ assert!(anchor(vec![div().into_any_element()]).is_some());
+ }
+
+ #[test]
+ fn two_notices_share_one_anchor() {
+ // The bug this module exists for: two live notices must come back as a
+ // single stacked element, not as two things each claiming `bottom_4`.
+ let stacked = anchor(vec![div().into_any_element(), div().into_any_element()]);
+ assert!(stacked.is_some());
+ }
+}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 3cfe35c2..6ec891ce 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -601,6 +601,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsAgentCodex,
keywords: SettingsSearchCodexKeywords,
},
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentTraeCode,
+ keywords: SettingsSearchTraeCodeKeywords,
+ },
SearchEntry {
section: Agents,
title: SettingsAgentCopilotCli,
diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs
index d91351fc..660811d1 100644
--- a/src/ui/sftp.rs
+++ b/src/ui/sftp.rs
@@ -394,7 +394,10 @@ impl Tty7App {
self.sftp_panel.open_pane_id = None;
self.sftp_panel.entries.clear();
self.sftp_panel.error = None;
- self.sftp_close_edit();
+ // No `Window` here, and none needed: the browser itself is going away
+ // or being re-pointed at another pane, so focus is settled by whoever
+ // did that, not by the form.
+ let _ = self.sftp_close_edit();
self.sftp_panel.editing_path = None;
self.sftp_panel.editing_path_sub.clear();
self.sftp_panel.jobs.clear();
@@ -431,7 +434,10 @@ impl Tty7App {
self.sftp_panel.open_workspace = self.pane_workspace(pane_id, window, cx);
self.sftp_panel.entries.clear();
self.sftp_panel.error = None;
- self.sftp_close_edit();
+ // No `Window` here, and none needed: the browser itself is going away
+ // or being re-pointed at another pane, so focus is settled by whoever
+ // did that, not by the form.
+ let _ = self.sftp_close_edit();
self.sftp_panel.editing_path = None;
self.sftp_panel.editing_path_sub.clear();
self.sftp_panel.show_history = false;
@@ -705,7 +711,7 @@ impl Tty7App {
);
cx.spawn_in(window, async move |this, cx| {
let Ok(0) = answer.await else { return };
- let _ = this.update(cx, |this, cx| {
+ let _ = this.update_in(cx, |this, window, cx| {
if this.sftp_panel.open_pane_id != Some(pane_id) {
return;
}
@@ -713,7 +719,7 @@ impl Tty7App {
true => SftpOp::RemoveDir { path },
false => SftpOp::RemoveFile { path },
};
- this.sftp_run_op(pane_id, op, cx);
+ this.sftp_run_op(pane_id, op, window, cx);
});
})
.detach();
@@ -759,11 +765,19 @@ impl Tty7App {
.detach();
}
- fn sftp_run_op(&mut self, pane_id: u64, op: SftpOp, cx: &mut Context) {
+ /// Takes a `Window` only so the success arm can hand the focus back: the
+ /// form is still up, still holding the caret, while the far side works.
+ fn sftp_run_op(
+ &mut self,
+ pane_id: u64,
+ op: SftpOp,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
let route = self.sftp_route();
- cx.spawn(async move |this, cx| {
+ cx.spawn_in(window, async move |this, cx| {
let result = cx.background_spawn(async move { route.op(op) }).await;
- let _ = this.update(cx, |this, cx| {
+ let _ = this.update_in(cx, |this, window, cx| {
if this.sftp_panel.open_pane_id != Some(pane_id) {
return;
}
@@ -773,7 +787,7 @@ impl Tty7App {
cx.notify();
}
_ => {
- this.sftp_close_edit();
+ this.sftp_close_edit_in(window, cx);
this.sftp_refresh(cx);
}
}
@@ -798,8 +812,8 @@ impl Tty7App {
let sub = cx.subscribe_in(
&input,
window,
- |this, _input, ev: &InputEvent, _window, cx| match ev {
- InputEvent::PressEnter { .. } => this.sftp_commit_edit(cx),
+ |this, _input, ev: &InputEvent, window, cx| match ev {
+ InputEvent::PressEnter { .. } => this.sftp_commit_edit(window, cx),
// OK is disabled while the box is empty, so the form has to
// redraw as the name is typed.
InputEvent::Change => cx.notify(),
@@ -862,17 +876,36 @@ impl Tty7App {
/// Takes the form down and drops the subscription that was listening to
/// its box. The two travel together — a live subscription on a box nothing
/// is showing would answer Return for a form that is gone.
- fn sftp_close_edit(&mut self) {
+ ///
+ /// Reports whether a form was actually up, because the box owned the focus
+ /// and whoever tore it down has to hand the focus back. It did not, so
+ /// naming a folder and then pressing Escape left the focus on an element
+ /// that no longer existed and the next keystroke went nowhere until you
+ /// clicked. `ssh_prompt` asserts in a comment *and* a test that every
+ /// overlay in the app hands focus back on the way out; these four forms
+ /// were the counterexample.
+ #[must_use]
+ fn sftp_close_edit(&mut self) -> bool {
+ let was_open = self.sftp_panel.editing.is_some();
self.sftp_panel.editing = None;
self.sftp_panel.editing_sub.clear();
+ was_open
}
- pub(crate) fn sftp_cancel_edit(&mut self, cx: &mut Context) {
- self.sftp_close_edit();
+ /// `sftp_close_edit` plus the focus hand-back, for the callers that have a
+ /// `Window` to hand it back with.
+ fn sftp_close_edit_in(&mut self, window: &mut Window, cx: &mut Context) {
+ if self.sftp_close_edit() {
+ self.focus_active(window, cx);
+ }
+ }
+
+ pub(crate) fn sftp_cancel_edit(&mut self, window: &mut Window, cx: &mut Context) {
+ self.sftp_close_edit_in(window, cx);
cx.notify();
}
- pub(crate) fn sftp_commit_edit(&mut self, cx: &mut Context) {
+ pub(crate) fn sftp_commit_edit(&mut self, window: &mut Window, cx: &mut Context) {
let Some(pane_id) = self.sftp_panel.open_pane_id else {
return;
};
@@ -898,7 +931,7 @@ impl Tty7App {
Some(SftpEdit::Rename { original, input }) => {
let name = input.read(cx).value().trim().to_string();
if name.is_empty() || name == *original {
- self.sftp_close_edit();
+ self.sftp_close_edit_in(window, cx);
cx.notify();
return;
}
@@ -924,7 +957,7 @@ impl Tty7App {
None => None,
};
if let Some(op) = op {
- self.sftp_run_op(pane_id, op, cx);
+ self.sftp_run_op(pane_id, op, window, cx);
}
}
@@ -1386,9 +1419,9 @@ impl Tty7App {
.rounded_md()
// Escape backs out of the form, the way it backs out of the
// path editor above it and every sheet the app puts up.
- .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _window, cx| {
+ .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
if ev.keystroke.key == "escape" {
- this.sftp_cancel_edit(cx);
+ this.sftp_cancel_edit(window, cx);
}
}))
.child(
@@ -1408,7 +1441,9 @@ impl Tty7App {
.label(t(L10nKey::Cancel))
.ghost()
.xsmall()
- .on_click(cx.listener(|this, _, _w, cx| this.sftp_cancel_edit(cx))),
+ .on_click(
+ cx.listener(|this, _, w, cx| this.sftp_cancel_edit(w, cx)),
+ ),
)
.child(
Button::new("sftp-edit-ok")
@@ -1416,7 +1451,9 @@ impl Tty7App {
.xsmall()
.primary()
.disabled(!can_commit)
- .on_click(cx.listener(|this, _, _w, cx| this.sftp_commit_edit(cx))),
+ .on_click(
+ cx.listener(|this, _, w, cx| this.sftp_commit_edit(w, cx)),
+ ),
),
),
)
@@ -2198,10 +2235,12 @@ mod tests {
#[cfg(test)]
mod gpui_tests {
+ use super::SftpEdit;
use crate::core::config::{Config, RightPanelTab};
use crate::core::session::Session;
use crate::ui::app::Tty7App;
- use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
+ use gpui::{AppContext, Entity, Focusable as _, TestAppContext, VisualTestContext};
+ use gpui_component::input::InputState;
fn harness(cx: &mut TestAppContext) -> (Entity, VisualTestContext) {
cx.executor().allow_parking();
@@ -2235,6 +2274,47 @@ mod gpui_tests {
})
}
+ /// The edit box owns the focus while the form is up, so taking the form
+ /// down has to hand the focus back.
+ ///
+ /// It did not. Naming a new folder and then pressing Escape left the caret
+ /// on an element that had stopped rendering, and the next keystroke went
+ /// nowhere until you clicked. `ssh_prompt` asserts in a comment *and* a
+ /// test that every overlay in the app hands focus back on the way out;
+ /// these four forms were the counterexample, and `sftp_cancel_edit` could
+ /// not have done it anyway — it took no `Window` at all.
+ #[gpui::test]
+ fn cancelling_the_edit_form_hands_focus_back(cx: &mut TestAppContext) {
+ let (app, mut vcx) = harness(cx);
+
+ let box_focus = app.update_in(&mut vcx, |app, window, cx| {
+ let input = cx.new(|cx| InputState::new(window, cx));
+ input.update(cx, |s, cx| s.focus(window, cx));
+ let handle = input.read(cx).focus_handle(cx);
+ app.sftp_panel.editing = Some(SftpEdit::NewFolder(input));
+ handle
+ });
+ vcx.run_until_parked();
+
+ // Sanity: the box holds focus while the form is up.
+ assert!(
+ app.update_in(&mut vcx, |_, window, _| box_focus.is_focused(window)),
+ "the box should hold focus while the form is up"
+ );
+
+ app.update_in(&mut vcx, |app, window, cx| app.sftp_cancel_edit(window, cx));
+ vcx.run_until_parked();
+
+ assert!(
+ app.update_in(&mut vcx, |app, _, _| app.sftp_panel.editing.is_none()),
+ "the form is down"
+ );
+ assert!(
+ !app.update_in(&mut vcx, |_, window, _| box_focus.is_focused(window)),
+ "the focus the box held must have gone somewhere still on screen"
+ );
+ }
+
#[gpui::test]
fn toggle_sftp_opens_files_then_closes_the_panel(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
diff --git a/src/ui/ssh_prompt.rs b/src/ui/ssh_prompt.rs
index 64fe4966..8ba24001 100644
--- a/src/ui/ssh_prompt.rs
+++ b/src/ui/ssh_prompt.rs
@@ -968,10 +968,19 @@ impl Tty7App {
// Abort stays the emphasized one and now also sits
// where the eye lands last: a changed host key is the
// one prompt where the safe answer wants both.
+ //
+ // Override is the app's one `danger` button, and it is
+ // the site that earns it: the sheet is what a
+ // man-in-the-middle looks like, and this was the only
+ // control in the product that could act on that with
+ // no colour on it at all. It is disabled until the word
+ // is typed, so it greys until armed and then goes red —
+ // the emphasis arrives exactly when the button does.
.child(
Button::new("ssh-hkc-override")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Override))
.small()
+ .danger()
.disabled(!can_override)
.on_click(cx.listener(|this, _, window, cx| {
this.submit_ssh_prompt(window, cx)
diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs
index 9f318889..d57f1b09 100644
--- a/src/ui/switcher.rs
+++ b/src/ui/switcher.rs
@@ -313,6 +313,11 @@ pub(crate) struct Switcher {
/// The modifiers held down when Ctrl+Tab opened the panel. Releasing them
/// commits the highlighted tab, IDEA-style.
hold: Option,
+ /// Where the pointer is: inside the card at all, and inside the tab column
+ /// specifically. Both are set by hover listeners, so they only mean
+ /// anything once the mouse has moved since the panel came up.
+ hover_card: bool,
+ hover_tabs: bool,
left_scroll: gpui::ScrollHandle,
right_scroll: gpui::ScrollHandle,
/// Anchors on the two scrolls, worn by whichever row is selected. Both
@@ -329,6 +334,14 @@ impl Switcher {
fn text(&self, cx: &App) -> String {
self.query.read(cx).value().trim().to_lowercase()
}
+
+ /// The pointer is parked in the card but off the tab column — on a
+ /// workspace row, the search box, a banner. Letting go of Ctrl there is
+ /// not a commit: the user is reaching for the mouse, and closing the panel
+ /// out from under them makes the workspace list unreachable by hand.
+ fn hover_keeps_open(&self) -> bool {
+ self.hover_card && !self.hover_tabs
+ }
}
/// Everything the panel needs for one frame: the groups (one per machine,
@@ -437,9 +450,17 @@ impl Tty7App {
remote_connect::register(cx);
remote_connect::sweep_wsl(cx);
let query = cx.new(|cx| {
- InputState::new(window, cx).placeholder(crate::ui::i18n::t(
- crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines,
- ))
+ InputState::new(window, cx)
+ .placeholder(crate::ui::i18n::t(
+ crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines,
+ ))
+ // On macOS a held Ctrl turns every click into a right click,
+ // and the input answers a right click with Cut/Copy/Paste —
+ // so reaching for this box mid-Ctrl+Tab popped a menu instead
+ // of placing a caret. The rows already dodge this by dropping
+ // their own menus while the gesture is on; this box has no
+ // menu worth keeping either, and Cmd+V still pastes.
+ .context_menu(false)
});
query.update(cx, |state, cx| state.focus(window, cx));
let subs = vec![cx.subscribe_in(
@@ -469,6 +490,8 @@ impl Tty7App {
right_sel: 0,
mru,
hold,
+ hover_card: false,
+ hover_tabs: false,
left_scroll: left_scroll.clone(),
right_scroll: right_scroll.clone(),
left_anchor: gpui::ScrollAnchor::for_handle(left_scroll),
@@ -601,9 +624,21 @@ impl Tty7App {
let Some(hold) = self.switcher.as_ref().and_then(|sw| sw.hold) else {
return;
};
- if !now.modified() || !hold.is_subset_of(now) {
- self.switcher_commit_hold(window, cx);
+ if now.modified() && hold.is_subset_of(now) {
+ return;
}
+ // The pointer is already on the workspace list or the search box, so
+ // the release is the user's hand leaving the keyboard, not a pick.
+ // Drop the hold and leave the panel up for the mouse to finish in.
+ if self
+ .switcher
+ .as_ref()
+ .is_some_and(Switcher::hover_keeps_open)
+ {
+ self.switcher_release_hold(cx);
+ return;
+ }
+ self.switcher_commit_hold(window, cx);
}
/// Called when the modifier that raised the panel comes back up.
@@ -1655,7 +1690,17 @@ impl Tty7App {
this.close_switcher(window, cx)
}),
)
- .child(div().occlude().child(card))
+ .child(
+ div()
+ .id("switcher-card")
+ .occlude()
+ .on_hover(cx.listener(|this, hovered: &bool, _window, _cx| {
+ if let Some(sw) = this.switcher.as_mut() {
+ sw.hover_card = *hovered;
+ }
+ }))
+ .child(card),
+ )
.into_any_element(),
)
}
@@ -1735,8 +1780,14 @@ impl Tty7App {
)
.child(
v_flex()
+ .id("switcher-tab-column")
.flex_1()
.min_w_0()
+ .on_hover(cx.listener(|this, hovered: &bool, _window, _cx| {
+ if let Some(sw) = this.switcher.as_mut() {
+ sw.hover_tabs = *hovered;
+ }
+ }))
.child(crate::ui::scrollbar::with_vertical_scrollbar(
"switcher-tabs-scrollbar",
div()
@@ -4046,6 +4097,127 @@ mod gpui_tests {
});
}
+ /// One of the three places on the card a test wants to put the pointer.
+ #[derive(Clone, Copy)]
+ enum Spot {
+ Workspaces,
+ Tabs,
+ Search,
+ }
+
+ /// Where that part of the card lands on screen. The card is centred and
+ /// its columns are laid out from `CARD_W` / `LEFT_W`, so the geometry is
+ /// worth recomputing here rather than hard-coding pixels that move with
+ /// the window size.
+ fn card_point(vcx: &mut gpui::VisualTestContext, spot: Spot) -> gpui::Point {
+ use gpui::{point, px};
+
+ let viewport = vcx.update(|window, _| window.viewport_size());
+ let card_w = super::CARD_W
+ .min(viewport.width.as_f32() - 2. * super::CARD_MARGIN)
+ .max(320.);
+ let left_w = super::LEFT_W.min(card_w * 0.5);
+ let card_left = (viewport.width.as_f32() - card_w) / 2.;
+ let (dx, dy) = match spot {
+ // The search row is the first thing in the card; both columns
+ // start below it.
+ Spot::Search => (100., 20.),
+ Spot::Workspaces => (20., 60.),
+ // Past the tab column's own header row, onto its first tab.
+ Spot::Tabs => (left_w + 40., 42. + 6. + super::HOST_H + super::ROW_H / 2.),
+ };
+ point(px(card_left + dx), px(super::CARD_TOP + dy))
+ }
+
+ /// Ctrl+Tab, then reach for the mouse: the pointer leaves the tab column
+ /// for the workspace list, and letting go of Ctrl there must not slam the
+ /// panel shut — switching workspaces by hand is exactly what the user is
+ /// in the middle of doing.
+ #[gpui::test]
+ fn releasing_ctrl_over_the_workspace_list_keeps_the_panel_up(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
+ vcx.simulate_modifiers_change(Modifiers::control());
+ app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
+ vcx.run_until_parked();
+
+ let at = card_point(&mut vcx, Spot::Workspaces);
+ vcx.simulate_mouse_move(at, None, Modifiers::control());
+ vcx.simulate_modifiers_change(Modifiers::none());
+
+ app.update(cx, |app, _| {
+ let sw = app
+ .switcher
+ .as_ref()
+ .expect("the panel stays up for the mouse to finish in");
+ assert!(sw.hold.is_none(), "the hold is spent, not re-armed");
+ assert_eq!(app.active, 0, "the release picked nothing");
+ });
+ }
+
+ /// The pointer over the tab column is the ordinary gesture: release still
+ /// commits.
+ #[gpui::test]
+ fn releasing_ctrl_over_the_tab_column_still_commits(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
+ vcx.simulate_modifiers_change(Modifiers::control());
+ app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
+ vcx.run_until_parked();
+
+ let at = card_point(&mut vcx, Spot::Tabs);
+ vcx.simulate_mouse_move(at, None, Modifiers::control());
+ vcx.simulate_modifiers_change(Modifiers::none());
+
+ app.update(cx, |app, _| {
+ assert!(app.switcher.is_none(), "the panel comes down on release");
+ assert_eq!(app.active, 1, "the highlighted tab is now the active one");
+ });
+ }
+
+ /// macOS reports Ctrl+click as a right click, so a tab row picked with
+ /// the mouse mid-gesture arrives on the right button. The row takes that
+ /// press as the pick; nothing between it and the window may swallow it
+ /// first.
+ #[gpui::test]
+ fn ctrl_clicking_a_tab_row_mid_gesture_picks_it(cx: &mut TestAppContext) {
+ let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
+ vcx.simulate_modifiers_change(Modifiers::control());
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.tab_switch(true, window, cx);
+ // Two steps down, so the row the pointer lands on below is not
+ // the one the keyboard had already reached.
+ app.tab_switch(true, window, cx);
+ });
+ vcx.run_until_parked();
+ app.update(cx, |app, _| {
+ assert_eq!(app.switcher.as_ref().expect("up").right_sel, 2);
+ });
+
+ let at = card_point(&mut vcx, Spot::Tabs);
+ vcx.simulate_mouse_move(at, None, Modifiers::control());
+ vcx.simulate_mouse_down(at, gpui::MouseButton::Right, Modifiers::control());
+
+ app.update(cx, |app, _| {
+ let sw = app.switcher.as_ref().expect("the panel stays up");
+ assert_eq!(
+ sw.right_sel, 0,
+ "the row under the pointer took the press, not the keyboard's row 2"
+ );
+ assert!(
+ sw.hold.is_some(),
+ "the gesture is still on until Ctrl is up"
+ );
+ });
+
+ vcx.simulate_modifiers_change(Modifiers::none());
+ app.update(cx, |app, _| {
+ assert!(app.switcher.is_none(), "release commits and closes");
+ assert_eq!(
+ app.active, 0,
+ "the first row of a most-recently-used column is this very tab"
+ );
+ });
+ }
+
#[gpui::test]
fn losing_focus_drops_the_hold_so_the_panel_cannot_hang(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs
index e88716c6..baedc3cf 100644
--- a/src/ui/tab_sidebar.rs
+++ b/src/ui/tab_sidebar.rs
@@ -307,19 +307,16 @@ impl Tty7App {
// rectangle for them, which is what keeps a pane from being
// dropped into a group that is shut.
//
- // The active tab is the one exception: a fold says "I am done
- // with this repo for now", never "hide the tab I am looking at".
- // Without it ⌘T inside a folded group — `spawn_group` seeds the
- // new tab with the group it came from — draws nothing but a
- // header count going up by one, and with the tab bar docked left
- // that row is the tab's only representation on screen.
+ // No exception for the active tab. A fold that leaves one row
+ // hanging under a shut chevron, with the header counting rows
+ // that are not there, reads as a list that failed to load. The
+ // cost is that ⌘T inside a folded group — `spawn_group` seeds
+ // the new tab with the group it came from — puts the new tab
+ // behind the chevron: the pane area shows the fresh shell and the
+ // header count goes up, but the row waits for the group to open.
let row_count = visible_by_section[group_ix].len();
let visible: Vec = match folded {
- true => visible_by_section[group_ix]
- .iter()
- .copied()
- .filter(|&i| i == active)
- .collect(),
+ true => Vec::new(),
false => visible_by_section[group_ix].clone(),
};
let visible_tabs: Vec = visible.clone();
@@ -2240,8 +2237,6 @@ mod fold_tests {
for (i, root) in [(0, &alpha), (1, &alpha), (2, &beta)] {
*app.tabs[i].sidebar_group.borrow_mut() = Some(root.clone());
}
- // Active in the group that stays open: the folded group's own
- // active row has its own test below, and it would mask this one.
app.active = 2;
cx.notify();
});
@@ -2298,10 +2293,8 @@ mod fold_tests {
app.toggle_sidebar_group(Some(&alpha), cx);
});
vcx.run_until_parked();
- // Row 1, not row 0: row 0 is the active tab and a fold never takes
- // that one off the screen, so it says nothing about the fold.
app.update(&mut vcx, |app, _| {
- assert!(!drawn(app, 1), "folded, so the inactive row is not drawn");
+ assert!(!drawn(app, 1), "folded, so the row is not drawn");
});
// Whatever the row is actually showing — the label is derived from the
@@ -2322,11 +2315,13 @@ mod fold_tests {
});
}
- /// A fold means "I am done with this repo for now", never "hide the tab I
- /// am looking at". Without this, ⌘T inside a folded group — the new tab
- /// inherits the group it was spawned from — draws nothing at all.
+ /// A fold hides every row the group has, the active one included. The
+ /// alternative — leaving the active row on screen under a shut chevron,
+ /// with the header counting rows that are not drawn — looks like a list
+ /// that failed to load, which is what folding a group you are working in
+ /// used to produce.
#[gpui::test]
- fn the_active_row_stays_on_screen_inside_a_folded_group(cx: &mut TestAppContext) {
+ fn a_fold_hides_the_active_row_too(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
let alpha = GroupKey::Repo(PathBuf::from("/w/alpha"));
@@ -2340,21 +2335,17 @@ mod fold_tests {
vcx.run_until_parked();
app.update(&mut vcx, |app, _| {
- assert!(drawn(app, 0), "the active row survives its group folding");
- assert!(!drawn(app, 1), "everything else in the group is gone");
+ assert!(!drawn(app, 0), "the active row folds away with the rest");
+ assert!(!drawn(app, 1), "and so does everything else in the group");
});
- // And it follows the active tab, rather than being decided once when
- // the fold happened.
app.update(&mut vcx, |app, cx| {
- app.active = 1;
- cx.notify();
+ app.toggle_sidebar_group(Some(&alpha), cx)
});
vcx.run_until_parked();
app.update(&mut vcx, |app, _| {
- assert!(drawn(app, 1), "the row that is active now is the one drawn");
- assert!(!drawn(app, 0), "and the one that no longer is went away");
+ assert!((0..2).all(|i| drawn(app, i)), "unfolding brings both back");
});
}
}
diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs
index f0f2b0d3..25523d54 100644
--- a/src/ui/tab_strip.rs
+++ b/src/ui/tab_strip.rs
@@ -1302,7 +1302,14 @@ impl Tty7App {
gpui::svg()
.path(agent.icon_path())
.size(px(size * 0.54))
- .text_color(gpui::white()),
+ // SVG assets are rendered as a single-colour mask.
+ // TraeCode's black field comes from the avatar, and
+ // its brand mark uses the official green.
+ .text_color(if agent == crate::core::cli_agent::CLIAgent::TraeCode {
+ gpui::rgb(0x32F08C)
+ } else {
+ gpui::rgb(0xFFFFFF)
+ }),
)
.when_some(dot, |b, dot| b.child(dot))
.tooltip(move |window, cx| {