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))),