From 176a39e4557bc00f08c8258213ee713efbd5c11a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:53:51 +0800 Subject: [PATCH 1/5] fix(ui): stack the floating notices; give the forms back keyboard and focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things a user can walk into today, all of them an interaction the app starts and does not finish. The remote input notice and the ssh status strip were 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 `absolute` at `bottom_4`, centred, and both 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. The shell moves to `ui::notice`, the notices stop placing themselves, and the anchor is a column, so a second one stacks. The managed port-forward form had no keyboard contract at all: zero `on_key_down`, zero input subscriptions. No Return, no Escape, and it opened cold, so adding a rule meant clicking into Bind first and committing with the mouse. Every sibling form in the app has all three. The four sftp edit forms took the focus into a box they owned and dropped the box without handing the focus back — `sftp_cancel_edit` took no `Window` at all, so it could not have. Naming a folder and 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 were the counterexample. Fixed on all three paths that take the form down: cancel, a rename to the name it already had, and a successful op coming back from the far side. `Override` on the changed-host-key sheet — the one control in the product that can accept a key that no longer matches, which is what a man-in-the-middle looks like — carried no colour at all. `.danger()` had zero call sites across the whole tree. It is disabled until "yes" is typed, so it greys until armed and then goes red: the emphasis arrives exactly when the button does. Button order and `.primary()` are deliberately left alone; `.primary()` means "the recommended action" on both host-key sheets, and on this one that is Abort. --- src/ui/app.rs | 155 ++++++++++++++++++++++++++++++++++--------- src/ui/forwards.rs | 49 ++++++-------- src/ui/mod.rs | 1 + src/ui/notice.rs | 82 +++++++++++++++++++++++ src/ui/sftp.rs | 122 ++++++++++++++++++++++++++++------ src/ui/ssh_prompt.rs | 9 +++ 6 files changed, 337 insertions(+), 81 deletions(-) create mode 100644 src/ui/notice.rs diff --git a/src/ui/app.rs b/src/ui/app.rs index 6a76284b..95ae67b6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -749,6 +749,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 { @@ -1401,6 +1405,7 @@ impl Tty7App { mf_description, mf_editing: None, mf_error: None, + mf_subs: Vec::new(), }, sftp_panel, right_panel: Default::default(), @@ -2968,6 +2973,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( @@ -2975,8 +3023,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) { @@ -7056,31 +7110,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(), ) } @@ -7291,13 +7325,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` @@ -10441,7 +10485,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}; @@ -10461,6 +10505,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/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/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/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) From d69d8710d7b5d4b280464571b9e0574ef701642a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:33:29 +0800 Subject: [PATCH 2/5] perf(terminal): never queue the frame behind the grid lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One UI thread paints every pane in every window, and the thread holding the grid lock is the pane's own reader part-way through feeding a batch of output into the emulator. Waiting for it wired 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)` for a stalled link. `build_grid` now takes the lock with `try_lock_unfair` and returns `None` when it cannot have it, so that frame paints the one before it; nobody can see a frame of lag. Unfair rather than queued on purpose: a painter that queued would make the reader wait for a frame it is not going to get anyway, and skipping the queue is safe precisely because it never waits. Two frames still block, because neither has anything to fall back on — the first frame a pane ever paints, and the frame after a resize, whose previous grid is the wrong shape. The cells therefore have to survive a refused frame, so the shared `GRID_BUF` thread_local is gone and each pane owns its own `grid_buf` and `grid_snap` on the view. What made the buffer reusable was always that it is the previous frame *of this pane*; one shared scratch buffer could not be that once a frame could decline to rebuild it. The `buf.clear()`/`resize` moves below the lock for the same reason. `key_context` and `any_selection` read frame-cached copies of the terminal mode and the selection flag, refreshed by one `try_lock` at the top of `render` rather than one per caller. gpui matches keystrokes against the context the last painted frame published, so the mode was already a frame-old reading even when it locked. Anything with a real decision to make still asks the terminal itself — `alternate_paste` is the chord that cannot be a frame late. `sync_scrollbar` stops blocking too: the scrollbar is a picture of where the grid is, not worth parking a window to refresh a thumb. Guarded by `a_frame_that_cannot_have_the_grid_leaves_the_previous_one_alone`, which reproduces "the reader has it" with no second thread and no timing — `try_lock` fails against a lock this thread already holds. --- src/terminal/element.rs | 77 ++++++++++++++++++------ src/terminal/view.rs | 129 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 185 insertions(+), 21 deletions(-) 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 fe246279..e74f7720 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, @@ -2775,8 +2795,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. @@ -2788,7 +2812,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 @@ -5389,7 +5419,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(), @@ -5400,6 +5435,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 @@ -6528,6 +6580,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() { @@ -13357,6 +13410,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); From 177eff5d7756469cbc2dfbda9339021240690b69 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:33:41 +0800 Subject: [PATCH 3/5] fix(switcher): let the pointer finish a Ctrl+Tab gesture the keyboard started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Tab raises the panel and holds it up until Ctrl comes back up, which commits whatever is highlighted. That is right while the gesture is a keyboard one, and wrong the moment the user reaches for the mouse: letting go of Ctrl over the workspace list slammed the panel shut and picked a tab, so switching workspaces by hand — the thing the pointer was on its way to do — was unreachable. The panel now tracks where the pointer is, on the card at all and on the tab column specifically. A release with the pointer parked on the card but off the tab column drops the hold and leaves the panel up for the mouse to finish in; over the tab column it still commits, because that is the ordinary gesture. Both flags come from hover listeners, so they mean nothing until the mouse has actually moved since the panel came up, which is exactly the distinction wanted. Two macOS consequences of holding Ctrl, fixed with it. A held Ctrl turns every click into a right click, so reaching for the search box mid-gesture popped Cut/Copy/Paste instead of placing a caret — the rows already dodged this by dropping their own menus while the gesture is on, and the box has no menu worth keeping either (Cmd+V still pastes). And a tab row picked with the mouse arrives on the right button, so nothing between the row and the window may swallow that press first. Guarded by three tests that put the pointer on a computed point of the card rather than a hard-coded pixel: release over the workspace list keeps the panel up and picks nothing, release over the tab column still commits, and a Ctrl+click on a tab row mid-gesture picks the row under the pointer rather than the one the keyboard had reached. --- src/ui/switcher.rs | 184 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 6 deletions(-) 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); From ca9bb747e485e5731c8f6a98a76617904824b3a0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:56:57 +0800 Subject: [PATCH 4/5] fix(ui): let a folded sidebar group hide its active row too (#806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fold left the active tab's row on screen, so folding the group you are working in drew a shut chevron with one row hanging under it and a header counting rows that were not there — it reads as a list that failed to load, not as a group you closed. The exception existed to keep Cmd-T inside a folded group visible, since `spawn_group` seeds a new tab with the group it came from. That cost is taken instead: the pane area shows the fresh shell and the header count goes up, and the row waits for the group to be opened. Auto-unfolding on spawn was the other option and is worse — it only fires when the repo probe already hit the cache, so a cold tab parks in Scratch and moves into its group later without passing through it, and a magic that works half the time is harder to read than none. Claude-Session: https://claude.ai/code/session_01XD6R419Hy1CV1CeVZSRBf7 --- src/ui/tab_sidebar.rs | 47 +++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 173b0c19..9b8921b1 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -284,19 +284,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(); @@ -1582,8 +1579,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(); }); @@ -1640,10 +1635,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 @@ -1664,11 +1657,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 = PathBuf::from("/w/alpha"); @@ -1682,21 +1677,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"); }); } } From fc94022ed04b7dbba1d4687e2278ab116a18bf75 Mon Sep 17 00:00:00 2001 From: ayamir <61657399+ayamir@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:57:04 +0800 Subject: [PATCH 5/5] feat(agent): add TraeCode CLI support (#807) * feat(agent): add TraeCode CLI support * fix(settings): index TraeCode agent hooks --- README.md | 5 +-- README.zh-CN.md | 5 +-- assets/icons/agents/traecli.svg | 4 +++ crates/tty7-core/src/core/agent_hooks.rs | 34 ++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 39 ++++++++++++++++++++++-- docs/agents/overview.mdx | 7 +++-- docs/agents/sessions.mdx | 1 + docs/agents/status.mdx | 2 +- docs/getting-started/first-launch.mdx | 6 ++-- docs/index.mdx | 2 +- src/ui/assets.rs | 1 + src/ui/i18n/en.rs | 4 +++ src/ui/i18n/ja.rs | 4 +++ src/ui/i18n/mod.rs | 3 ++ src/ui/i18n/zh.rs | 4 +++ src/ui/settings.rs | 5 +++ src/ui/tab_strip.rs | 9 +++++- 17 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 assets/icons/agents/traecli.svg 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/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/i18n/en.rs b/src/ui/i18n/en.rs index fea7d64f..b9af2001 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 50a29245..03ddc490 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 c7bd1d96..25425c07 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, @@ -1532,6 +1534,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 9ea57025..c83ea1ca 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/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/tab_strip.rs b/src/ui/tab_strip.rs index 160b7f5c..82b90fff 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1301,7 +1301,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| {