Merge origin/main into feat/sidebar-custom-groups

main's #806 changed the fold test while this branch changed
toggle_sidebar_group to take Option<&GroupKey>; the textual merge left
one call site on the old signature. Fixed here.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
This commit is contained in:
l0ng-ai
2026-09-08 17:29:10 +08:00
27 changed files with 838 additions and 152 deletions
+3 -2
View File
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · <kbd>⌘ J</kbd> 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.
<details>
<summary>The full support matrix, all nineteen</summary>
<summary>The full support matrix, all twenty</summary>
| Agent | Detected | Status · resume | Fork |
|---|:-:|:-:|:-:|
| **Claude Code** | ✓ | ✓ | ✓ |
| **Codex** | ✓ | ✓ | ✓ |
| **TraeCode** | ✓ | ✓ | ✓ |
| **Grok** | ✓ | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ |
+3 -2
View File
@@ -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 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · <kbd>⌃ R</kbd> 模糊搜索历史 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 命令面板 · <kbd>⌘ F</kbd> 回滚搜索 · <kbd>⌘ J</kbd> 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 |
@@ -66,12 +66,13 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
**Fork** 两个条件都要:agent 自己提供 fork 命令,且 hook 已装——tty7 得知道 fork 的是哪个会话。
<details>
<summary>19 个 agent 的完整支持矩阵</summary>
<summary>20 个 agent 的完整支持矩阵</summary>
| Agent | 识别 | 状态 · 重启恢复 | Fork |
|---|:-:|:-:|:-:|
| **Claude Code** | ✓ | ✓ | ✓ |
| **Codex** | ✓ | ✓ | ✓ |
| **TraeCode** | ✓ | ✓ | ✓ |
| **Grok** | ✓ | ✓ | ✓ |
| **OpenCode** | ✓ | ✓ | ✓ |
| **Oh My Pi** | ✓ | ✓ | ✓ |
+4
View File
@@ -0,0 +1,4 @@
<!-- TRAE brand mark, normalized from the user-provided trae-color.svg. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"/>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+33 -1
View File
@@ -246,6 +246,7 @@ fn ancestor_pids(procs: &[crate::daemon::winproc::Proc]) -> Vec<u32> {
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<String> {
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,
+37 -2
View File
@@ -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")
+4 -3
View File
@@ -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`).
+1
View File
@@ -39,6 +39,7 @@ or right-click the **tab or sidebar row** to open the fork in a new tab.
|---|---|
| Claude Code | `claude --resume <id> --fork-session` |
| Codex | `codex fork <id>` |
| TraeCode | `traecli fork <id>` |
| Grok | `grok --resume <id> --fork-session` |
| OpenCode | `opencode --session <id> --fork` |
| Oh My Pi | `omp --fork <id>` |
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -31,7 +31,7 @@ something floods the screen.
syntax highlighting, click-to-place-caret, real multi-line editing.
</Card>
<Card title="Agent-aware" icon="robot" href="/agents/overview">
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.
</Card>
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
+60 -17
View File
@@ -32,7 +32,7 @@ enum UnderlineKind {
}
#[derive(Clone)]
struct RenderCell {
pub(super) struct RenderCell {
c: char,
marks: Option<Box<[char]>>,
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<RowSeg> {
thread_local! {
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new());
static GRID_BUF: RefCell<Vec<RenderCell>> = 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<Pixels>>)> =
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<GridCursor>,
sliver: Option<Vec<RenderCell>>,
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<RenderCell>,
@@ -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<GridSnapshot> {
let mut cursor: Option<GridCursor> = None;
let mut sliver: Option<Vec<RenderCell>> = 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);
+125 -4
View File
@@ -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<RenderCell>,
pub(super) grid_snap: Option<GridSnapshot>,
/// 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<DragScroll>,
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<Self>) -> 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<RenderCell>, 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);
+125 -30
View File
@@ -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<String>,
/// 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<Subscription>,
}
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<Self>,
) {
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>,
) {
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<Self>) {
@@ -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);
+1
View File
@@ -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"),
+19 -30
View File
@@ -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",
+4
View File
@@ -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"
}
+4
View File
@@ -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"
}
+3
View File
@@ -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,
+4
View File
@@ -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"
}
+1
View File
@@ -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;
+82
View File
@@ -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<AnyElement>) -> Option<AnyElement> {
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());
}
}
+5
View File
@@ -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,
+101 -21
View File
@@ -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<Self>) {
/// 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<Self>,
) {
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>) {
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<Self>) {
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>) {
self.sftp_close_edit_in(window, cx);
cx.notify();
}
pub(crate) fn sftp_commit_edit(&mut self, cx: &mut Context<Self>) {
pub(crate) fn sftp_commit_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Tty7App>, 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);
+9
View File
@@ -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)
+178 -6
View File
@@ -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<gpui::Modifiers>,
/// 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<gpui::Pixels> {
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);
+19 -28
View File
@@ -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<usize> = 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<usize> = 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");
});
}
}
+8 -1
View File
@@ -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| {