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| {