From de3896cbdf6e1a737efb87861adc478305764122 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:43:06 +0800 Subject: [PATCH] feat(right-panel): redesign the detail panel and chrome icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the detail panel's four tabs and the window chrome to read as a deliberate, commercial-grade surface while keeping the terminal's calm. - Chrome icons: filled-block panel toggles; Outline gets a list glyph, Changes a git-branch, Info a redrawn mark; glyphs go to 18px on the full foreground instead of the faint 15px secondary tone. - Panel body: labelled Session / Processes / Ports bands, mono values, pid/port pills, status dots on Outline, neutral M/U badges on Changes, live counts in the headers, and roomier rows. Hue only ever lands on the git add / remove / fail semantics, everything else stays neutral grey. - Fixes: the corner "…" no longer jumps a pixel when the panel opens (the top strip mirrors the TitleBar's hidden bottom border); that strip now drags the window and double-click zooms, like the rail's; and the process list stops flickering — the poll guard now spans the whole 2s cycle instead of being restarted by every repaint between ticks. --- assets/icons/info.svg | 1 + assets/icons/list.svg | 1 + assets/icons/panel-left.svg | 1 + assets/icons/panel-right.svg | 1 + src/ui/assets.rs | 13 ++ src/ui/file_tree.rs | 6 +- src/ui/right_panel.rs | 387 +++++++++++++++++++++++++---------- src/ui/tab_sidebar.rs | 12 +- src/ui/tab_strip.rs | 69 ++++--- 9 files changed, 355 insertions(+), 136 deletions(-) create mode 100644 assets/icons/info.svg create mode 100644 assets/icons/list.svg create mode 100644 assets/icons/panel-left.svg create mode 100644 assets/icons/panel-right.svg diff --git a/assets/icons/info.svg b/assets/icons/info.svg new file mode 100644 index 00000000..ec30e25e --- /dev/null +++ b/assets/icons/info.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/list.svg b/assets/icons/list.svg new file mode 100644 index 00000000..981d0c33 --- /dev/null +++ b/assets/icons/list.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg new file mode 100644 index 00000000..2d61872f --- /dev/null +++ b/assets/icons/panel-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg new file mode 100644 index 00000000..a975c9ec --- /dev/null +++ b/assets/icons/panel-right.svg @@ -0,0 +1 @@ + diff --git a/src/ui/assets.rs b/src/ui/assets.rs index ef3f0f1c..af8c1cd9 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -44,6 +44,19 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { // Lucide's `git-branch`, for the sidebar row's branch line (gpui-component // doesn't bundle a git glyph). "icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"), + // Filled-panel dock glyphs for the window chrome: an outlined frame with + // one inset solid block marking which dock is open (the "commercial-app" + // look). gpui-component only ships the hollow line `panel-left/right`, so + // tty7 carries its own filled variants for the title bar controls. + "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), + "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), + // Lucide's `list`, for the detail panel's Outline tab: the recorded + // commands are a plain list, not a terminal (`square-terminal` read as + // "this is a shell", which every pane already is). + "icons/list.svg" => include_bytes!("../../assets/icons/list.svg"), + // A refined `info` glyph for the Info tab: a solid dot over a round-capped + // stem in a slightly inset circle, cleaner than the stock hairline `i`. + "icons/info.svg" => include_bytes!("../../assets/icons/info.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/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"), diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 96926c32..47f92fa5 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -945,7 +945,7 @@ impl Tty7App { .gap_1() .pl(px(6.0 + row.depth as f32 * INDENT)) .pr_1() - .py_0p5() + .py_1() .rounded(cx.theme().radius) .cursor_pointer() // Soft inset-pill highlight on the content surface. @@ -953,8 +953,10 @@ impl Tty7App { .when(!selected, |d| { d.hover(|s| s.bg(cx.theme().accent.opacity(0.5))) }) + // Folders take the full foreground, files the muted tone — a neutral + // weight difference, no hue, so the tree keeps the terminal's calm. .child(Icon::new(icon).xsmall().text_color(if is_dir { - cx.theme().primary + cx.theme().foreground } else { muted })) diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 7762fd91..fdb1b63d 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -13,11 +13,15 @@ //! sidebar row does, Changes probes the same `git_diff` the diff overlay does, and //! Files renders the same rows as the code panel's tree. -use gpui::{AnyElement, Context, Window, div, prelude::*, px}; +use gpui::{AnyElement, Context, MouseButton, Window, WindowControlArea, div, prelude::*, px}; use gpui_component::button::Button; use gpui_component::input::Input; -use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, +}; +use std::cell::Cell; use std::path::PathBuf; +use std::rc::Rc; use crate::core::config::{Config, RightPanelTab}; use crate::daemon::protocol::PaneProcs; @@ -51,10 +55,17 @@ pub(crate) struct RightPanelState { pub(crate) procs_pane: Option, /// Last completed process/port query for `procs_pane`. pub(crate) procs: Option, - /// A query is in flight. Also the poll loop's own guard: the loop reschedules - /// itself from the completion handler, so this being set means "a tick is - /// already on the way" and a re-render must not start a second chain. + /// A poll cycle is live — a query is in flight *or* the inter-tick timer is + /// waiting between ticks. The render path checks this before starting the + /// loop, so a re-render never starts a second chain. It must stay set across + /// the timer too: clearing it the instant a query returned let every repaint + /// in the 2s gap kick off another query, collapsing the interval into a tight + /// query→notify→repaint→query loop that made the list flicker. pub(crate) procs_loading: bool, + /// Bumped on every pane switch to retire the in-flight poll loop: a tick whose + /// generation no longer matches drops its result and stops rescheduling, so the + /// freshly started loop for the new pane is the only one left running. + pub(crate) procs_gen: u64, } /// How often the Info tab re-queries processes and ports while it's open. Fast @@ -134,10 +145,43 @@ impl Tty7App { // bolted under the title bar: its surface runs the full height of // the window, and the tab row sits *on* it rather than on the // terminal's bar above a seam. - .child( + .child({ + // The top zone sits level with the real `TitleBar`, but the + // bar only spans the terminal column — so, exactly like the + // rail's top strip (`tab_sidebar`), make this one act like the + // title bar it aligns with: drag to move, double-click to zoom. + // A press arms a flag and the first *move* starts the window + // move, so a plain click on a tab — and a double-click — still + // lands intact; the tabs and corner chrome take their own. + let should_move = Rc::new(Cell::new(false)); h_flex() + .id("right-panel-titlebar-drag") .flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) + // gpui-component's `TitleBar` centres its content inside a + // `border_b_1` box — border-box shrinks the content height + // by that 1px, nudging its centred glyphs up half a pixel. + // The corner chrome (⋯, panel toggle) lives in *both* the + // title bar and here, so mirror that hidden border to keep + // its centre line identical; without it the glyphs jump + // down a physical pixel the moment the panel opens. + .border_b_1() + .border_color(cx.theme().transparent) + .window_control_area(WindowControlArea::Drag) + .on_mouse_down(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(true) + }) + .on_mouse_up(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(false) + }) + .on_mouse_move(move |_, window, _| { + if should_move.replace(false) { + window.start_window_move(); + } + }) + .on_double_click(|_, window, _| window.titlebar_double_click()) .items_center() .gap(px(2.)) .pl(px(CONTENT_INSET - crate::ui::app::TILE_PAD)) @@ -145,8 +189,8 @@ impl Tty7App { .child(div().flex_1()) // The panel is what reaches the window's right edge while // it's open, so it carries the corner chrome. - .child(self.window_chrome(window, cx)), - ) + .child(self.window_chrome(window, cx)) + }) .child(body) .child(handle) .into_any_element(), @@ -252,15 +296,20 @@ impl Tty7App { /// what the icon-only tab row can't. `trailing` carries a tab's own controls /// where it has any, so they sit on the label's line rather than earning a /// second header row. + /// A tab's header: the name in a weightier small-caps than the old faint + /// label, plus an optional live count trailing it (files, commands, changed + /// files) so the header states scale at a glance, and an optional control on + /// the right. The count is the quiet mono tally the sidebar group headers use. fn panel_title( &self, text: &str, + count: Option, trailing: Option, cx: &mut Context, ) -> AnyElement { h_flex() .flex_none() - .h(px(28.)) + .h(px(32.)) .items_center() .justify_between() .pl(px(CONTENT_INSET)) @@ -272,10 +321,25 @@ impl Tty7App { CONTENT_INSET })) .child( - div() - .text_size(px(10.)) - .text_color(cx.theme().muted_foreground) - .child(text.to_uppercase()), + h_flex() + .items_baseline() + .gap(px(7.)) + .child( + div() + .text_size(px(11.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().secondary_foreground) + .child(text.to_uppercase()), + ) + .when_some(count, |this, c| { + this.child( + div() + .text_size(px(11.)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(c), + ) + }), ) .when_some(trailing, |this, t| this.child(t)) .into_any_element() @@ -366,7 +430,7 @@ impl Tty7App { /// row comes from an accessor the sidebar already uses, so the panel can /// never disagree with the row that spawned it. fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let title = self.panel_title("Info", None, cx); + let title = self.panel_title("Info", None, None, cx); let mut rows: Vec<(&'static str, String)> = Vec::new(); // Held aside from `rows` because they're not key/value lines: the actions // hang off the cwd, and the two lists get their own sub-headers below. @@ -416,25 +480,31 @@ impl Tty7App { // ticking while this tab is the one being looked at. self.sync_procs(pane_id, cx); - let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(5.)); + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(3.)); for (k, v) in rows { list = list.child( h_flex() .items_baseline() - .gap(px(8.)) - .text_size(px(11.5)) + .gap(px(9.)) + .py(px(1.)) + .text_size(px(12.)) .child( div() .flex_none() - .w(px(52.)) + .w(px(46.)) .text_color(cx.theme().muted_foreground) .child(k), ) .child( + // The value is the datum — a path, a branch, a host, a + // count — so it takes the mono face, set apart from the + // sans key beside it. div() .flex_1() .min_w_0() .truncate() + .font_family(mono.clone()) .text_color(cx.theme().foreground) .child(v), ), @@ -442,6 +512,10 @@ impl Tty7App { } let inner = v_flex() + // Three labelled bands — Session / Processes / Ports — instead of one + // flat column, so the pane's facts, what it's running, and what it's + // listening on read as distinct groups. + .child(self.panel_subtitle("Session", false, cx)) .child(list) .when_some(cwd_for_actions, |this, cwd| { this.child(self.cwd_actions(cwd, cx)) @@ -504,14 +578,20 @@ impl Tty7App { .into_any_element() } - /// A small caps divider inside a tab's body, for the sub-lists that hang off - /// the Info tab. Lighter than [`panel_title`], which is the tab's own header. - fn panel_subtitle(&self, text: &str, cx: &mut Context) -> AnyElement { + /// A small-caps band label inside a tab's body, for the sub-lists that hang + /// off the Info tab. Lighter than [`panel_title`], which is the tab's own + /// header. `divider` draws a hairline above it, so the second and third bands + /// separate from the one before; the first band passes `false`. + fn panel_subtitle(&self, text: &str, divider: bool, cx: &mut Context) -> AnyElement { div() + .when(divider, |d| { + d.mt(px(6.)).border_t_1().border_color(cx.theme().border) + }) .px(px(CONTENT_INSET)) - .pt(px(12.)) - .pb(px(3.)) - .text_size(px(10.)) + .pt(px(if divider { 12. } else { 10. })) + .pb(px(4.)) + .text_size(px(10.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) .child(text.to_uppercase()) .into_any_element() @@ -525,13 +605,13 @@ impl Tty7App { if procs.len() < 2 { return None; } - let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.)); + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET)).py(px(1.)).gap(px(2.)); for p in procs { list = list.child( h_flex() - .items_baseline() - .gap(px(6.)) - .text_size(px(11.5)) + .items_center() + .gap(px(8.)) .child( div() .flex_1() @@ -540,6 +620,8 @@ impl Tty7App { // Indent by depth so the tree reads without drawing // connector glyphs into a 260px column. .pl(px(f32::from(p.depth) * 10.)) + .text_size(px(12.)) + .font_family(mono.clone()) .text_color(if p.foreground { cx.theme().foreground } else { @@ -547,18 +629,17 @@ impl Tty7App { }) .child(p.name.clone()), ) - .child( - div() - .flex_none() - .text_size(px(10.5)) - .text_color(cx.theme().muted_foreground) - .child(p.pid.to_string()), - ), + .child(info_chip( + &p.pid.to_string(), + cx.theme().accent, + cx.theme().muted_foreground, + &mono, + )), ); } Some( v_flex() - .child(self.panel_subtitle("Processes", cx)) + .child(self.panel_subtitle("Processes", true, cx)) .child(list) .into_any_element(), ) @@ -571,25 +652,26 @@ impl Tty7App { if ports.is_empty() { return None; } - let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.)); + let mono = cx.theme().mono_font_family.clone(); + let mut list = v_flex().px(px(CONTENT_INSET)).py(px(1.)).gap(px(2.)); for p in ports { list = list.child( h_flex() - .items_baseline() + .items_center() .gap(px(8.)) - .text_size(px(11.5)) - .child( - div() - .flex_none() - .w(px(52.)) - .text_color(cx.theme().foreground) - .child(p.port.to_string()), - ) + .child(info_chip( + &p.port.to_string(), + cx.theme().accent, + cx.theme().foreground, + &mono, + )) .child( div() .flex_1() .min_w_0() .truncate() + .text_size(px(12.)) + .font_family(mono.clone()) .text_color(cx.theme().muted_foreground) .child(p.name.clone()), ), @@ -597,7 +679,7 @@ impl Tty7App { } Some( v_flex() - .child(self.panel_subtitle("Ports", cx)) + .child(self.panel_subtitle("Ports", true, cx)) .child(list) .into_any_element(), ) @@ -618,17 +700,24 @@ impl Tty7App { // Drop the previous pane's answer rather than showing it under the new // pane's heading until the first tick lands. self.right_panel.procs = None; + // Retire the old pane's loop and free the guard so the new pane's loop + // can start below; the retired tick bows out on the generation check. + self.right_panel.procs_gen += 1; + self.right_panel.procs_loading = false; } if !self.right_panel.procs_loading { - self.spawn_procs_query(pane_id, cx); + self.right_panel.procs_loading = true; + let generation = self.right_panel.procs_gen; + self.spawn_procs_query(pane_id, generation, cx); } } /// One query, then reschedule — the poll loop. It reschedules only while the /// panel is open on Info, so the loop is self-terminating: close the panel or /// switch tabs and the next completion simply doesn't queue another. - fn spawn_procs_query(&mut self, pane_id: u64, cx: &mut Context) { - self.right_panel.procs_loading = true; + fn spawn_procs_query(&mut self, pane_id: u64, generation: u64, cx: &mut Context) { + // `procs_loading` is set by the caller (`sync_procs`) and deliberately + // stays set across the whole cycle, including the timer wait below. cx.spawn(async move |this, cx| { let procs = cx .background_executor() @@ -636,16 +725,21 @@ impl Tty7App { .await; let keep_polling = this .update(cx, |app, cx| { - app.right_panel.procs_loading = false; - // A pane switch while we flew makes this answer stale; drop it - // and let the new pane's own query land. - if app.right_panel.procs_pane != Some(pane_id) { + // A pane switch while we flew bumped the generation: drop this + // answer and leave the guard to whoever owns the new one. + if app.right_panel.procs_gen != generation { return false; } app.right_panel.procs = Some(procs); cx.notify(); let cfg = cx.global::(); - cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info + let wanted = + cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; + if !wanted { + // Loop ends here; release the guard so reopening restarts it. + app.right_panel.procs_loading = false; + } + wanted }) .unwrap_or(false); if !keep_polling { @@ -654,11 +748,16 @@ impl Tty7App { cx.background_executor().timer(PROCS_POLL).await; let _ = this.update(cx, |app, cx| { // Re-check rather than trusting the pre-sleep decision: two seconds - // is plenty of time to close the panel. + // is plenty of time to switch panes or close the panel. + if app.right_panel.procs_gen != generation { + return; + } let cfg = cx.global::(); let wanted = cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; - if wanted && app.right_panel.procs_pane == Some(pane_id) { - app.spawn_procs_query(pane_id, cx); + if wanted { + app.spawn_procs_query(pane_id, generation, cx); + } else { + app.right_panel.procs_loading = false; } }); }) @@ -674,41 +773,59 @@ impl Tty7App { /// Newest first because that's the end you came from: you scrolled past the /// thing you want, and the list should start where your attention is. fn render_panel_outline(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let title = self.panel_title("Outline", None, cx); let Some(leaf) = self .tabs .get(self.active) .and_then(|t| t.detail_pane(window, cx)) else { + let title = self.panel_title("Outline", None, None, cx); return self.panel_scroll(self.panel_empty("No active session.", cx), title); }; - let marks = leaf.read(cx).command_marks(); - if marks.is_empty() { + // Count first (a cheap getter) so the borrow ends before `panel_title` + // needs `&mut cx`; the list re-borrows the marks below. + let count = leaf.read(cx).command_marks().len(); + if count == 0 { // Two very different causes, one honest sentence: nothing has run // yet, or this shell never reported OSC 133 (no integration, a bare // `sh`, a nested PTY that eats the marks). + let title = self.panel_title("Outline", None, None, cx); return self.panel_scroll( self.panel_empty("No commands recorded for this pane.", cx), title, ); } + let title = self.panel_title("Outline", Some(count.to_string()), None, cx); + let mono = cx.theme().mono_font_family.clone(); let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); + let marks = leaf.read(cx).command_marks(); for mark in marks.iter().rev() { let row = mark.row; let leaf = leaf.clone(); - // A command that failed is the one you're most often looking for, so - // it gets the only color in the list. let failed = mark.exit.is_some_and(|c| c != 0); + let running = !mark.done; + // A leading status marker reads as a shape first: a hollow ring for a + // clean finish, a filled dot while it runs, and — the only tinted one + // — a danger dot for a nonzero exit. The failure is what you scan for. + let dot = { + let d = div().flex_none().size(px(7.)).rounded_full(); + if failed { + d.bg(cx.theme().danger) + } else if running { + d.bg(cx.theme().muted_foreground) + } else { + d.border_1() + .border_color(cx.theme().muted_foreground.opacity(0.55)) + } + }; list = list.child( h_flex() .id(gpui::SharedString::from(format!("panel-mark-{row}"))) - .items_baseline() - .gap(px(6.)) + .items_center() + .gap(px(8.)) .px(px(4.)) - .py(px(2.)) - .rounded(px(4.)) - .text_size(px(11.5)) + .py(px(3.)) + .rounded(px(5.)) .cursor_pointer() .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55))) .on_click(cx.listener(move |_this, _, _window, cx| { @@ -716,18 +833,21 @@ impl Tty7App { view.scroll_to_mark(row, cx); }); })) + .child(dot) .child( div() .flex_1() .min_w_0() .truncate() + // Commands are code: the mono face sets them apart from + // the sans labels and lines the list up like a log. + .text_size(px(12.)) + .font_family(mono.clone()) .text_color(if failed { cx.theme().danger } else { cx.theme().foreground }) - // Commands wrap in the shell but must not here: one - // row per command is what makes the list scannable. .child(one_line(&mark.text)), ) // Only nonzero exits earn a badge. Annotating every success @@ -738,20 +858,10 @@ impl Tty7App { div() .flex_none() .text_size(px(10.5)) + .font_family(mono.clone()) .text_color(cx.theme().danger) .child(code.to_string()), ) - }) - // A command still running is worth marking: it's why the - // pane is busy. - .when(!mark.done, |this| { - this.child( - div() - .flex_none() - .text_size(px(10.5)) - .text_color(cx.theme().muted_foreground) - .child("…"), - ) }), ); } @@ -764,7 +874,6 @@ impl Tty7App { /// diff overlay's hunk cards, which need far more than 260px to be readable. /// Clicking a row opens the full overlay on that repo. fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let title = self.panel_title("Changes", None, cx); let cwd = self .tabs .get(self.active) @@ -777,6 +886,7 @@ impl Tty7App { }); let Some(cwd) = cwd else { + let title = self.panel_title("Changes", None, None, cx); return self.panel_scroll(self.panel_empty("No working directory.", cx), title); }; // Probe on first paint for this cwd, and whenever the pane moves to a @@ -788,6 +898,18 @@ impl Tty7App { self.spawn_right_panel_diff(cwd.clone(), cx); } + // Count of changed files for the header tally — computed before the title + // so the diff borrow ends before `panel_title` takes `&mut cx`. + let count = match &self.right_panel.diff { + Some(Some(snap)) => { + let n = snap.files.len() + snap.untracked.len(); + (n > 0).then(|| n.to_string()) + } + _ => None, + }; + let title = self.panel_title("Changes", count, None, cx); + let mono = cx.theme().mono_font_family.clone(); + let inner = match &self.right_panel.diff { None => self.panel_empty("Loading…", cx), Some(None) => self.panel_empty("Not a git work tree.", cx), @@ -811,12 +933,11 @@ impl Tty7App { list = list.child( h_flex() .id(gpui::SharedString::from(format!("panel-change-{path}"))) - .items_baseline() + .items_center() .gap(px(8.)) .px(px(4.)) - .py(px(2.)) - .rounded(px(4.)) - .text_size(px(11.5)) + .py(px(3.)) + .rounded(px(5.)) .cursor_pointer() .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55))) .when(selected, |s| s.bg(cx.theme().sidebar_accent)) @@ -835,36 +956,62 @@ impl Tty7App { ); }) }) + // A neutral status letter, kind by glyph not by hue — + // tracked edits are `M`; untracked get `U` below. + .child(git_badge("M", cx.theme().muted_foreground, &mono)) .child( div() .flex_1() .min_w_0() .truncate() + .text_size(px(12.)) + .font_family(mono.clone()) .text_color(cx.theme().foreground) .child(path), ) - .child( - div() - .flex_none() - .text_color(cx.theme().success) - .child(format!("+{added}")), - ) - .child( - div() - .flex_none() - .text_color(cx.theme().danger) - .child(format!("−{removed}")), - ), + // +N / −M keep the terminal-git greens and reds, the + // one place hue earns its keep; a zero side is dropped + // rather than shown as `+0`. + .when(added > 0, |this| { + this.child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().success) + .child(format!("+{added}")), + ) + }) + .when(removed > 0, |this| { + this.child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().danger) + .child(format!("−{removed}")), + ) + }), ); } if !untracked.is_empty() { list = list.child( - div() - .pt(px(4.)) + h_flex() + .items_center() + .gap(px(8.)) .px(px(4.)) - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(format!("{} untracked", untracked.len())), + .py(px(3.)) + .child(git_badge( + "U", + cx.theme().muted_foreground.opacity(0.75), + &mono, + )) + .child( + div() + .text_size(px(11.5)) + .text_color(cx.theme().muted_foreground) + .child(format!("{} untracked", untracked.len())), + ), ); } list.into_any_element() @@ -913,7 +1060,7 @@ impl Tty7App { /// views of one tree rather than two trees. fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let controls = self.files_controls(cx); - let title = self.panel_title("Files", Some(controls), cx); + let title = self.panel_title("Files", None, Some(controls), cx); let search = self.files_search(cx); let rows = self.render_file_tree_rows(window, cx); v_flex() @@ -926,6 +1073,38 @@ impl Tty7App { } } +/// A small status letter (`M`/`U`/…) for a change row. The *kind* is told by the +/// glyph in the mono face, not by colour, so the list stays monochrome; callers +/// pass a muted tone and reserve real hue for the `+N −M` counts beside it. +fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { + div() + .flex_none() + .w(px(14.)) + .text_center() + .text_size(px(10.5)) + .font_family(mono.clone()) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(color) + .child(letter.to_string()) + .into_any_element() +} + +/// A pid / port pill: a mono number on the soft-grey capsule the rest of the +/// chrome uses, so a numeric datum reads as a tag rather than loose text. +fn info_chip(text: &str, bg: gpui::Hsla, fg: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { + div() + .flex_none() + .px(px(5.)) + .py(px(1.5)) + .rounded(px(4.)) + .bg(bg) + .text_size(px(10.5)) + .font_family(mono.clone()) + .text_color(fg) + .child(text.to_string()) + .into_any_element() +} + /// The one-word status the Info row shows next to the agent's name. fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str { use crate::core::cli_agent::AgentStatus::*; diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 0cea4dfd..e17b82cd 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -748,11 +748,11 @@ impl Tty7App { .child( self.attach_new_tab_menu( Button::new("sidebar-add") - .icon(Icon::new(IconName::Plus).size(px(15.))) + .icon(Icon::new(IconName::Plus).size(px(18.))) .ghost() .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg(), cx, ), @@ -760,13 +760,13 @@ impl Tty7App { .child( crate::ui::tab_strip::chrome_tile( Button::new("sidebar-collapse") - .icon(Icon::new(IconName::PanelLeft).size(px(15.))), + .icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip("Hide Sidebar") .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 1a5925ba..37930a88 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -164,7 +164,10 @@ pub(crate) fn chrome_tile_variant(cx: &gpui::App) -> ButtonCustomVariant { let accent = cx.theme().sidebar_accent; ButtonCustomVariant::new(cx) .color(cx.theme().transparent) - .foreground(cx.theme().secondary_foreground) + // Full `foreground`, not the softer `secondary_foreground`: the chrome + // glyphs read as deliberate controls rather than faint hints — the + // "commercial-app" weight, paired with the filled dock icons below. + .foreground(cx.theme().foreground) .hover(accent.opacity(0.55)) .active(accent) } @@ -213,13 +216,13 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-right-panel") - .icon(Icon::new(IconName::PanelRight).size(px(15.))), + .icon(Icon::empty().path("icons/panel-right.svg").size(px(18.))), panel_open, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip("Detail Panel") .on_click(cx.listener(|this, _, _window, cx| { @@ -231,13 +234,13 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-menu") - .icon(Icon::new(IconName::Ellipsis).size(px(15.))), + .icon(Icon::new(IconName::Ellipsis).size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .dropdown_menu_with_anchor( gpui::Anchor::TopRight, @@ -257,10 +260,29 @@ impl Tty7App { pub(crate) fn right_panel_tabs(&self, cx: &mut Context) -> Vec { let active_tab = cx.global::().right_panel_tab; [ - (RightPanelTab::Info, IconName::Info, "Info"), - (RightPanelTab::Outline, IconName::SquareTerminal, "Outline"), - (RightPanelTab::Changes, IconName::Replace, "Changes"), - (RightPanelTab::Files, IconName::FolderClosed, "Files"), + ( + RightPanelTab::Info, + Icon::empty().path("icons/info.svg"), + "Info", + ), + ( + RightPanelTab::Outline, + Icon::empty().path("icons/list.svg"), + "Outline", + ), + // git-branch (from tty7's own assets) instead of the abstract + // `Replace` glyph: Changes is a working-tree diff, and the branch + // mark reads as version control at a glance — matching the mockup. + ( + RightPanelTab::Changes, + Icon::empty().path("icons/git-branch.svg"), + "Changes", + ), + ( + RightPanelTab::Files, + Icon::new(IconName::FolderClosed), + "Files", + ), ] .into_iter() .map(|(tab, icon, label)| { @@ -269,14 +291,13 @@ impl Tty7App { .flex_shrink_0() .child( chrome_tile( - Button::new(("right-panel-tab", tab as usize)) - .icon(Icon::new(icon).size(px(15.))), + Button::new(("right-panel-tab", tab as usize)).icon(icon.size(px(18.))), active_tab == tab, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip(label) .on_click(cx.listener(move |this, _, _window, cx| { @@ -1061,13 +1082,13 @@ impl Tty7App { self.attach_new_tab_menu( chrome_tile( Button::new("tab-add") - .icon(Icon::new(IconName::Plus).size(px(15.))), + .icon(Icon::new(IconName::Plus).size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg(), cx, ), @@ -1091,13 +1112,13 @@ impl Tty7App { self.attach_new_tab_menu( chrome_tile( Button::new("titlebar-add-collapsed") - .icon(Icon::new(IconName::Plus).size(px(15.))), + .icon(Icon::new(IconName::Plus).size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg(), cx, ), @@ -1107,13 +1128,13 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-expand-sidebar") - .icon(Icon::new(IconName::PanelLeft).size(px(15.))), + .icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))), false, cx, ) .xsmall() - .w(px(30.)) - .h(px(30.)) + .w(px(32.)) + .h(px(32.)) .rounded_lg() .tooltip("Show Sidebar") .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),