feat(tray): system tray icon with agent status menu

A cross-platform tray / menu bar status item: the icon flips to an
attention state when any coding agent blocks on input, and its menu
lists agent panes (brand avatar + status dot, click to reveal),
switches the notification policy, forces an update check, and offers
Quit and Stop Daemon alongside the session-keeping plain quit.

macOS/Windows use tray-icon (muda menus, main-thread NSStatusItem);
Linux deliberately uses ksni (pure-Rust SNI over zbus) instead of
tray-icon's GTK+libappindicator backend so the AppImage stays lean,
with a slow-backoff retry for SNI hosts that appear after login.
Bitmaps are rasterized at runtime with resvg (already in the tree).

Gated by show_tray_icon (default on) with a Settings toggle; the 1s
foreground poll re-reads it, so toggles and config.json hot-reloads
apply live.
This commit is contained in:
l0ng-ai
2026-07-16 14:08:03 +08:00
parent 0b72b752d7
commit 040f4e35a1
15 changed files with 1928 additions and 70 deletions
Generated
+627 -68
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -113,6 +113,21 @@ tokio = { version = "1", features = [
# Already in the tree transitively (vte et al.), so this pins no new code.
memchr = "2"
# System tray / menu bar status item (`ui::tray`). Rasterizes the bundled SVG
# logo into the tray bitmap at runtime — gpui's own SVG path only yields a
# tinted alpha mask, not raw RGBA. Pinned to the exact version already in the
# tree via gpui (0.45.x), so this adds no new native code; default features off
# drops the text/font machinery our icon SVGs don't use.
resvg = { version = "0.45", default-features = false }
# The tray icon itself, macOS + Windows: tauri's `tray-icon` (NSStatusItem /
# Shell_NotifyIcon via objc2 / windows-sys, both already in the tree). Linux is
# deliberately NOT on this crate — its Linux backend needs GTK + libappindicator,
# which tty7's AppImage doesn't bundle and the x11/wayland gpui backends don't
# pull. Linux instead uses `ksni` below.
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
tray-icon = "0.24"
# `setsid` (daemon detach) and the macOS foreground-process proc queries are the
# only libc users left, both Unix-only — so the dep is Unix-only too.
[target.'cfg(unix)'.dependencies]
@@ -155,6 +170,12 @@ objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "
# itself via `cfg`, so no feature is needed for them.
[target.'cfg(target_os = "linux")'.dependencies]
gpui_platform = { workspace = true, features = ["x11", "wayland"] }
# Linux tray (`ui::tray`): pure-Rust StatusNotifierItem over DBus (zbus — already
# in the tree). `blocking` + `async-io` gives a synchronous handle without
# requiring the app to own a tokio runtime; ksni runs its own service thread. On
# desktops without an SNI host (bare GNOME without the AppIndicator extension)
# spawning fails and the tray is silently absent — the app is unaffected.
ksni = { version = "0.3.6", default-features = false, features = ["blocking", "async-io"] }
# gpui's `test-support` unlocks `#[gpui::test]` + `TestAppContext`, the headless
# App/Window harness the view/event tests run on. Dev-only: the feature merges
+1 -1
View File
@@ -42,7 +42,7 @@ Native builds for each platform on [**Releases**](https://github.com/l0ng-ai/tty
|---|---|
| **Input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · eight themes · IME |
| **Coding agents** | per-pane agent detection (~17 CLIs): status dot, notifications, branch + diff, resume after reboot |
| **Coding agents** | per-pane agent detection (~17 CLIs): status dot, notifications, branch + diff, resume after reboot, tray icon that signals "needs your input" |
| **SSH** | native russh stack: profiles with keychain secrets, SFTP panel, port forwarding, jump hosts |
Details for every row: [docs/features.md](docs/features.md). Keybindings: <kbd>⌘ ,</kbd>
+1 -1
View File
@@ -42,7 +42,7 @@
|---|---|
| **输入** | 历史影子建议 · 带说明的 Tab 补全 · 语法高亮 · 多行编辑 · 点击定位光标 · <kbd>⌃ R</kbd> 模糊历史搜索 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 命令面板 · <kbd>⌘ F</kbd> 回滚搜索 · 8 套主题 · 输入法 |
| **Coding agent** | 按 pane 识别约 17 个 CLI agent:状态点、通知、分支 + diff、重启后续上会话 |
| **Coding agent** | 按 pane 识别约 17 个 CLI agent:状态点、通知、分支 + diff、重启后续上会话、托盘图标提醒"等你输入" |
| **SSH** | 原生 russh 栈:profile 凭据进 keychain、SFTP 面板、端口转发、跳板机 |
每一行的细节见 [docs/features.zh-CN.md](docs/features.zh-CN.md)。快捷键:<kbd>⌘ ,</kbd>
+1
View File
@@ -31,6 +31,7 @@ it never wraps or replaces the agent.
- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N M`), refreshed on `cd` and when a command finishes
- **Session resume** — panes lost to a reboot re-launch their agent conversation (`claude --resume …`) on restore (`restore_agent_sessions`, on by default)
- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt
- **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Daemon* alongside the plain session-keeping quit (`show_tray_icon`, on by default)
## SSH
+1
View File
@@ -30,6 +30,7 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N M`),`cd` 或命令跑完时自动刷新
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话(`claude --resume …``restore_agent_sessions`,默认开启)
- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent
- **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*`show_tray_icon`,默认开启)
## SSH
+7
View File
@@ -112,6 +112,12 @@ pub struct Config {
/// on quit — it's just ignored at startup.
#[serde(default = "default_true")]
pub restore_session: bool,
/// Show the system tray / menu bar status item: the icon flips to an
/// attention state when a coding agent needs input, and its menu lists the
/// agent panes. On by default; the tray's poll loop re-reads this every
/// second, so toggling it (Settings or a `config.json` edit) applies live.
#[serde(default = "default_true")]
pub show_tray_icon: bool,
/// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief
/// visual flash (the current behavior).
#[serde(default, deserialize_with = "de_lenient")]
@@ -425,6 +431,7 @@ impl Default for Config {
check_for_updates: true,
notify_threshold_secs: default_notify_threshold_secs(),
restore_session: true,
show_tray_icon: true,
// Visual flash preserves the pre-config behavior (the bell always
// flashed); opting into None/Audible is a deliberate change.
bell: BellMode::Visual,
+6
View File
@@ -68,7 +68,13 @@ pub fn spawn_check(cx: &mut App) {
if !cx.global::<Config>().check_for_updates {
return;
}
spawn_check_forced(cx);
}
/// The same check, ignoring the `check_for_updates` toggle — for explicit
/// user-initiated checks (the tray's "Check for Updates…"), where "I asked"
/// overrides "don't ask on my behalf at startup".
pub fn spawn_check_forced(cx: &mut App) {
cx.spawn(async move |cx| {
let current = env!("CARGO_PKG_VERSION");
// Race the fetch against a timer so a stalled connection can't leave the
+168
View File
@@ -594,6 +594,12 @@ impl Tty7App {
ssh_close_confirm: None,
window_bounds: window.window_bounds().get_bounds(),
};
// Bring the system tray up (icon + agent menu + poll loop). Skipped in
// tests: the headless harness has no native status bar to register
// with, and the poll task would just spin against the mocked clock.
if !cfg!(test) {
crate::ui::tray::init(cx);
}
// Discover this machine's shells for the "+" dropdown off the UI thread
// (the WSL probe on Windows spawns a process, and /etc/shells hits the
// filesystem). Until it lands the dropdown offers just the default entry.
@@ -735,6 +741,162 @@ impl Tty7App {
cx.notify();
}
// ── System tray (`ui::tray`) ────────────────────────────────────────────
/// Snapshot every agent pane for the tray menu: brand name, status, and a
/// "where" line (cwd directory name + git branch). Most urgent first, so
/// the pane that needs the user tops the menu. Called by the tray's poll
/// loop once a second; the walk is a handful of entity reads.
pub(crate) fn tray_snapshot(&self, cx: &App) -> crate::ui::tray::TraySnapshot {
use crate::core::cli_agent::AgentStatus;
let urgency = |s: AgentStatus| match s {
AgentStatus::Waiting => 3,
AgentStatus::Working => 2,
AgentStatus::Done => 1,
AgentStatus::Idle => 0,
};
let mut agents = Vec::new();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let view = leaf.read(cx);
let Some(agent) = view.agent() else { continue };
let status = view
.agent_session()
.map(|s| s.status)
.unwrap_or(AgentStatus::Idle);
let dir = view
.cwd()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()));
let branch = view.git_status(cx).map(|g| g.branch);
let detail = match (dir, branch) {
(Some(dir), Some(branch)) => format!("{dir} @ {branch}"),
(Some(dir), None) => dir,
// No cwd yet (pane still spawning) — the agent name alone
// still identifies the row.
(None, _) => String::new(),
};
agents.push(crate::ui::tray::AgentRow {
leaf_id: leaf.entity_id().as_u64(),
agent,
status,
detail,
});
}
}
agents.sort_by_key(|a| std::cmp::Reverse(urgency(a.status)));
crate::ui::tray::TraySnapshot {
agents,
notify_mode: cx.global::<Config>().notify_on_command_finish,
}
}
/// Apply a tray menu click. Runs on the foreground executor with the
/// window in hand (see `tray::init`'s action pump).
pub(crate) fn handle_tray_action(
&mut self,
action: crate::ui::tray::TrayAction,
window: &mut Window,
cx: &mut Context<Self>,
) {
use crate::ui::tray::TrayAction;
// Tray clicks arrive while another app is frontmost — that's the
// tray's whole premise. `activate_window` alone only orders our
// window front within the app (macOS: `makeKeyAndOrderFront:`); the
// *application* must also be activated or the reveal — and any
// window-modal prompt we show next — stays buried behind the app the
// user clicked from.
fn surface_window(window: &mut Window, cx: &mut App) {
cx.activate(true);
window.activate_window();
}
match action {
TrayAction::ShowWindow => surface_window(window, cx),
TrayAction::RevealPane { leaf_id } => {
// Resolve the leaf against the *live* tree — the menu the user
// clicked may predate a tab close; a vanished pane is a no-op
// (the window still comes forward).
let tab_ix = self.tabs.iter().position(|t| {
t.pane
.leaves()
.iter()
.any(|l| l.entity_id().as_u64() == leaf_id)
});
if let Some(ix) = tab_ix {
self.activate(ix, window, cx);
// The reveal must actually show the pane: a sibling leaf
// maximized in this tab would otherwise keep the target
// off-screen while we hand it keyboard focus (every other
// focus-moving path clears this too).
self.maximized = None;
if let Some(leaf) = self.tabs[ix]
.pane
.leaves()
.into_iter()
.find(|l| l.entity_id().as_u64() == leaf_id)
{
self.tabs[ix].last_focused = Some(leaf.entity_id());
self.focus_leaf(&leaf, window, cx);
}
cx.notify();
}
surface_window(window, cx);
}
TrayAction::SetNotifyMode(mode) => self.set_notify_mode(mode, cx),
TrayAction::OpenSettings => {
surface_window(window, cx);
if self.settings.is_none() {
self.toggle_settings(window, cx);
}
}
TrayAction::CheckForUpdates => {
surface_window(window, cx);
// Forced: a manual "check now" should work even when the
// startup check is disabled. The result lands in the About
// panel we open next (via the `UpdateStatus` global).
crate::core::update::spawn_check_forced(cx);
self.open_settings_section(SettingsSection::About, window, cx);
}
// Same as ⌘Q: sessions keep running in the daemon.
TrayAction::Quit => cx.quit(),
TrayAction::QuitStopSessions => self.quit_stop_sessions(window, cx),
}
}
/// Tray "Quit and Stop Daemon": confirm, shut the daemon down (which
/// hangs up every shell — the whole point of picking this over plain
/// quit), then quit. The stop runs off the UI thread; like
/// `--stop-daemon` it can take a beat while children get their grace
/// period.
fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// The prompt is window-modal and the click came from the tray with
// another app frontmost — activate the app AND the window, or the
// user never sees the question.
cx.activate(true);
window.activate_window();
let answer = window.prompt(
PromptLevel::Warning,
"Quit and Stop Daemon?",
Some(
"This quits tty7 and stops the background daemon — anything \
still running in your sessions is terminated. Your tabs and \
layout are kept and reopen with fresh shells next launch. \
(Plain Quit keeps sessions running.)",
),
&["Cancel", "Quit and Stop"],
cx,
);
cx.spawn(async move |_this, cx| {
// Index 1 == "Shut Down"; Cancel or a dismissed prompt do nothing.
if !matches!(answer.await, Ok(1)) {
return;
}
cx.background_spawn(async { crate::daemon::spawn::stop() })
.await;
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
/// Restart the persistent background daemon: shut the running one down (which
/// stops every live shell) and bring a fresh one up, then rebuild the tabs
/// from the just-saved session so the layout returns with fresh shells.
@@ -1458,6 +1620,12 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.restore_session = on);
}
/// Toggle the system tray icon. The tray's poll loop re-reads the flag
/// every second, so the icon appears/disappears without a restart.
pub(crate) fn set_show_tray_icon(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.show_tray_icon = on);
}
// ── Input / Mouse setters ───────────────────────────────────────────────
/// Takes effect on the next keystroke — the terminal reads the flag per
+1
View File
@@ -23,4 +23,5 @@ pub mod ssh_prompt;
pub mod tab_sidebar;
pub mod tab_strip;
pub mod theme;
pub mod tray;
pub mod worktree_prompt;
+17
View File
@@ -250,6 +250,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: "Remember window size",
keywords: "window size position bounds geometry launch startup remember",
},
SearchEntry {
section: WindowTabs,
title: "Show tray icon",
keywords: "tray menu bar status item agent attention system icon",
},
SearchEntry {
section: WindowTabs,
title: "New tab position",
@@ -2891,6 +2896,7 @@ impl Tty7App {
};
let restore_session = cfg.restore_session;
let remember_window_size = cfg.remember_window_size;
let show_tray_icon = cfg.show_tray_icon;
let tab_bar_idx = match cfg.tab_bar_position {
TabBarPosition::Top => 0,
TabBarPosition::Left => 1,
@@ -2904,6 +2910,10 @@ impl Tty7App {
.checked(remember_window_size)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx)))
.into_any_element();
let tray_switch = Switch::new("wt-tray-icon")
.checked(show_tray_icon)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx)))
.into_any_element();
let startup_radio = self.segmented(
"wt-startup",
&["Normal", "Maximized", "Fullscreen"],
@@ -2967,6 +2977,13 @@ impl Tty7App {
restore_switch,
cx,
))
.child(self.settings_row(
"Show tray icon",
"Keep a status item in the system tray / menu bar: it signals when a \
coding agent needs your input, and its menu jumps to agent panes.",
tray_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Tabs", cx))
.child(self.settings_row(
+347
View File
@@ -0,0 +1,347 @@
//! Tray bitmap rendering: the bundled SVGs rasterized with `resvg` (gpui's
//! own SVG path only yields a tinted alpha mask, so the tray draws its own).
//!
//! Two states per platform:
//! - macOS: the outline terminal glyph (`logo.svg`) as a *template* image —
//! the system recolors its alpha for light/dark menu bars. Attention swaps
//! to a non-template variant: the glyph recolored to a mid-grey that reads
//! on both bar appearances, plus an amber badge (template images can't
//! carry color, so attention opts out of templating).
//! - Windows / Linux: the colored app icon (`app-icon.svg`); attention
//! punches a transparent ring into the corner and fills an amber badge, so
//! the badge separates from the orange tile behind it.
use resvg::tiny_skia;
use resvg::usvg;
/// Straight (unpremultiplied) RGBA, the format both `tray_icon::Icon` and
/// (after a byte shuffle) `ksni::Icon` want.
pub(super) struct RgbaImage {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
}
#[cfg(target_os = "macos")]
const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/logo.svg");
#[cfg(not(target_os = "macos"))]
const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/app-icon.svg");
/// Physical pixel size. macOS forces the NSImage to 18 pt in the status bar
/// regardless of pixel size (see tray-icon's macOS backend), so 36 px renders
/// crisp on retina. Windows tray slots are 1632 px; 32 downsamples cleanly.
#[cfg(target_os = "macos")]
const SIZE: u32 = 36;
#[cfg(not(target_os = "macos"))]
const SIZE: u32 = 32;
/// The `Waiting` amber, same hue as the in-window status dot
/// (`AgentStatus::dot_rgb`).
const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B);
/// Render the tray icon. `attention` = some agent is blocked on the user.
/// `None` only on a malformed bundled SVG, i.e. never in practice — callers
/// treat it as "no icon change".
pub(super) fn render(attention: bool) -> Option<RgbaImage> {
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
let scale = SIZE as f32 / tree.size().width().max(tree.size().height());
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
if attention {
// macOS attention leaves template mode (color needs real RGB), so the
// glyph must carry its own color: a mid-grey legible on both light
// and dark menu bars. Colored platforms keep the icon's own colors.
#[cfg(target_os = "macos")]
recolor(&mut pixmap, (0x8E, 0x8E, 0x93));
badge(&mut pixmap);
}
Some(to_rgba(&pixmap))
}
/// The tray-menu row avatar for an agent pane: the tab avatar's visual
/// language translated to a menu icon — brand-colored disc, the brand mark as
/// a white silhouette (geometry only, same as the tab renders it), and the
/// status dot in the bottom-right corner. `None` only if the brand SVG fails
/// to resolve/parse, which the caller treats as "text-only row".
pub(super) fn agent_avatar(
agent: crate::core::cli_agent::CLIAgent,
status: crate::core::cli_agent::AgentStatus,
) -> Option<tiny_skia::Pixmap> {
use gpui::AssetSource as _;
// 16 pt at 2× — the size native menus render item icons at.
const SIZE: u32 = 32;
let s = SIZE as f32;
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
// Brand-colored disc.
let accent = agent.accent_rgb();
let mut paint = tiny_skia::Paint {
anti_alias: true,
..Default::default()
};
paint.set_color_rgba8(
(accent >> 16) as u8,
(accent >> 8) as u8,
accent as u8,
0xFF,
);
let mut pb = tiny_skia::PathBuilder::new();
pb.push_circle(s / 2.0, s / 2.0, s / 2.0);
let disc = pb.finish()?;
pixmap.fill_path(
&disc,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
None,
);
// Brand mark as a white silhouette, centered at ~60% of the disc — the
// same "tinted alpha mask" treatment the tab avatar gets from gpui. The
// SVG resolves through the app's asset source, so the generic `bot`
// fallback for unbranded agents comes along for free.
let svg = crate::ui::assets::Assets
.load(agent.icon_path())
.ok()
.flatten()?;
let tree = usvg::Tree::from_data(&svg, &usvg::Options::default()).ok()?;
let glyph_size = (s * 0.60).round() as u32;
let mut glyph = tiny_skia::Pixmap::new(glyph_size, glyph_size)?;
let scale = glyph_size as f32 / tree.size().width().max(tree.size().height());
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut glyph.as_mut(),
);
recolor(&mut glyph, (0xFF, 0xFF, 0xFF));
let offset = ((SIZE - glyph_size) / 2) as i32;
pixmap.draw_pixmap(
offset,
offset,
glyph.as_ref(),
&tiny_skia::PixmapPaint::default(),
tiny_skia::Transform::identity(),
None,
);
// Status dot, bottom-right, ringed by transparency so it reads against
// the disc — the same composition as the tab avatar's dot. Idle has none.
if let Some(rgb) = status.dot_rgb() {
let (cx, cy, r) = (s * 0.80, s * 0.80, s * 0.17);
let circle = |radius: f32| {
let mut pb = tiny_skia::PathBuilder::new();
pb.push_circle(cx, cy, radius);
pb.finish()
};
if let Some(ring) = circle(r * 1.45) {
paint.blend_mode = tiny_skia::BlendMode::Clear;
pixmap.fill_path(
&ring,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
None,
);
}
if let Some(dot) = circle(r) {
paint.blend_mode = tiny_skia::BlendMode::SourceOver;
paint.set_color_rgba8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 0xFF);
pixmap.fill_path(
&dot,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
None,
);
}
}
Some(pixmap)
}
/// Un-premultiply a tiny-skia pixmap into straight RGBA (what
/// `tray_icon::Icon`/`muda::Icon` want).
pub(super) fn to_rgba(pixmap: &tiny_skia::Pixmap) -> RgbaImage {
let mut data = Vec::with_capacity(pixmap.data().len());
for p in pixmap.pixels() {
let c = p.demultiply();
data.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
RgbaImage {
data,
width: pixmap.width(),
height: pixmap.height(),
}
}
/// Repaint every covered pixel to `rgb`, keeping coverage (alpha) intact —
/// turns the glyph into a flat single-color mark.
fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) {
for p in pixmap.pixels_mut() {
let a = p.alpha();
let mul = |c: u8| ((c as u16 * a as u16) / 255) as u8;
// from_rgba only rejects components > alpha; mul() guarantees not.
if let Some(np) =
tiny_skia::PremultipliedColorU8::from_rgba(mul(rgb.0), mul(rgb.1), mul(rgb.2), a)
{
*p = np;
}
}
}
/// Stamp the amber attention badge in the top-right corner: first clear a
/// slightly larger disc so the badge is ringed by transparency (separating it
/// from whatever the glyph or a colored tile puts behind it), then fill.
fn badge(pixmap: &mut tiny_skia::Pixmap) {
let s = SIZE as f32;
let (cx, cy) = (s * 0.78, s * 0.22);
let r = s * 0.20;
let circle = |radius: f32| {
let mut pb = tiny_skia::PathBuilder::new();
pb.push_circle(cx, cy, radius);
pb.finish()
};
let mut paint = tiny_skia::Paint {
anti_alias: true,
..Default::default()
};
if let Some(ring) = circle(r * 1.35) {
paint.blend_mode = tiny_skia::BlendMode::Clear;
pixmap.fill_path(
&ring,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
None,
);
}
if let Some(dot) = circle(r) {
paint.blend_mode = tiny_skia::BlendMode::SourceOver;
paint.set_color_rgba8(AMBER.0, AMBER.1, AMBER.2, 0xFF);
pixmap.fill_path(
&dot,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
None,
);
}
}
/// The same bitmap in `ksni::Icon`'s wire format: ARGB32, network byte order.
#[cfg(target_os = "linux")]
pub(super) fn render_argb(attention: bool) -> Option<(Vec<u8>, u32)> {
let img = render(attention)?;
let mut argb = Vec::with_capacity(img.data.len());
for px in img.data.chunks_exact(4) {
argb.extend_from_slice(&[px[3], px[0], px[1], px[2]]);
}
Some((argb, img.width))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::cli_agent::{AgentStatus, CLIAgent};
/// `recolor` must keep coverage (alpha) and produce premultiplied
/// components that `PremultipliedColorU8` accepts (component ≤ alpha) —
/// the invariant the `from_rgba` in its body relies on.
#[test]
fn recolor_keeps_alpha_and_flattens_color() {
let mut pm = tiny_skia::Pixmap::new(2, 2).unwrap();
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(10, 200, 30, 128);
pm.fill_rect(
tiny_skia::Rect::from_xywh(0.0, 0.0, 2.0, 2.0).unwrap(),
&paint,
tiny_skia::Transform::identity(),
None,
);
let alphas: Vec<u8> = pm.pixels().iter().map(|p| p.alpha()).collect();
recolor(&mut pm, (0xFF, 0xFF, 0xFF));
for (p, a) in pm.pixels().iter().zip(alphas) {
assert_eq!(p.alpha(), a);
// White at coverage a premultiplies to ~a on every channel.
assert!(p.red().abs_diff(a) <= 1, "red {} vs alpha {a}", p.red());
assert_eq!(p.red(), p.green());
assert_eq!(p.green(), p.blue());
}
}
/// `to_rgba` un-premultiplies: a half-covered red pixel comes back as
/// full red with the original alpha.
#[test]
fn to_rgba_demultiplies() {
let mut pm = tiny_skia::Pixmap::new(1, 1).unwrap();
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(255, 0, 0, 128);
pm.fill_rect(
tiny_skia::Rect::from_xywh(0.0, 0.0, 1.0, 1.0).unwrap(),
&paint,
tiny_skia::Transform::identity(),
None,
);
let img = to_rgba(&pm);
assert_eq!((img.width, img.height), (1, 1));
let px = &img.data[0..4];
assert_eq!(px[3], 128);
assert!(px[0] >= 253, "red demultiplied back to ~255, got {}", px[0]);
assert_eq!((px[1], px[2]), (0, 0));
}
/// Both tray states render at the declared size with visible coverage,
/// and the attention badge actually changes the bitmap.
#[test]
fn render_produces_both_states() {
let normal = render(false).unwrap();
let attention = render(true).unwrap();
for img in [&normal, &attention] {
assert_eq!((img.width, img.height), (SIZE, SIZE));
assert_eq!(img.data.len(), (SIZE * SIZE * 4) as usize);
let covered = img.data.chunks_exact(4).filter(|p| p[3] > 0).count();
assert!(covered > 0, "icon rendered fully transparent");
}
assert_ne!(normal.data, attention.data);
}
/// The avatar renders for a branded agent, an unbranded (bot-fallback)
/// agent, and with/without the status dot.
#[test]
fn agent_avatar_renders_brand_and_fallback() {
for agent in [CLIAgent::Claude, CLIAgent::Qwen] {
let idle = agent_avatar(agent, AgentStatus::Idle).unwrap();
let waiting = agent_avatar(agent, AgentStatus::Waiting).unwrap();
assert_eq!((idle.width(), idle.height()), (32, 32));
// The disc leaves the very corners transparent…
assert_eq!(idle.pixel(0, 0).unwrap().alpha(), 0);
// …and the center is covered (disc + glyph).
assert!(idle.pixel(16, 16).unwrap().alpha() > 0);
// The status dot changes the bottom-right corner.
assert_ne!(idle.data(), waiting.data());
}
}
/// ksni wants ARGB32 in network byte order — verify the shuffle against
/// the RGBA source.
#[cfg(target_os = "linux")]
#[test]
fn render_argb_reorders_bytes() {
let rgba = render(false).unwrap();
let (argb, size) = render_argb(false).unwrap();
assert_eq!(size, rgba.width);
assert_eq!(argb.len(), rgba.data.len());
for (a4, r4) in argb.chunks_exact(4).zip(rgba.data.chunks_exact(4)) {
assert_eq!(a4, [r4[3], r4[0], r4[1], r4[2]]);
}
}
}
+404
View File
@@ -0,0 +1,404 @@
//! System tray / menu bar status item.
//!
//! The tray is the app's face outside the window: the icon flips to an
//! attention state the moment any pane's coding agent blocks on the user
//! (amber `Waiting`), and the menu lists every agent pane — click one to
//! reveal it — plus window/notification/quit controls. Menu labels are
//! English, matching the native app menus (`ui::theme::set_menus`).
//!
//! Platform split (see Cargo.toml for the why):
//! - macOS / Windows: tauri's `tray-icon` (NSStatusItem / Shell_NotifyIcon),
//! in [`native`]. Both are driven by the main-thread event loop gpui
//! already pumps, so the backend lives on the foreground executor.
//! - Linux: `ksni` (StatusNotifierItem over DBus, pure Rust), in [`sni`] —
//! `tray-icon`'s Linux backend would drag in GTK + libappindicator, which
//! the AppImage doesn't bundle. On desktops without an SNI host the spawn
//! fails and the app simply runs without a tray.
//!
//! Data flow mirrors the rest of the UI (which polls rather than observes —
//! see `TerminalView::poll_foreground`): a foreground task snapshots the
//! agent panes once a second, diffs against the last snapshot, and only
//! touches the native tray when something changed. Menu clicks come back on
//! a channel and are applied to the app on the foreground executor.
mod icon;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod native;
#[cfg(target_os = "linux")]
mod sni;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use native::Backend;
#[cfg(target_os = "linux")]
use sni::Backend;
use crate::core::cli_agent::AgentStatus;
use crate::core::config::{Config, NotifyMode};
use crate::ui::app::Tty7App;
use gpui::Context;
/// How often the poll loop re-snapshots the app. Agent status itself is
/// polled into the views on a 300 ms timer; 1 s here keeps the tray a hair
/// behind the in-window dots at negligible cost.
const POLL: std::time::Duration = std::time::Duration::from_millis(1000);
/// A menu click, decoded from the platform menu item id and applied to the
/// app by [`Tty7App::handle_tray_action`] on the foreground executor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrayAction {
/// Bring the window to the front.
ShowWindow,
/// Reveal the pane hosting this agent: switch to its tab, focus the
/// leaf, and activate the window. The id is the leaf's gpui entity id —
/// resolved against the live tab tree at click time, so a row that
/// outlived its pane (menu open across a close) degrades to a no-op.
RevealPane { leaf_id: u64 },
/// Set the notification policy (the same knob as Settings → Terminal).
SetNotifyMode(NotifyMode),
OpenSettings,
/// Force an update check (even with the startup check disabled) and open
/// Settings → About, where the result lands.
CheckForUpdates,
/// Plain quit — identical to ⌘Q: the daemon and every session survive.
Quit,
/// Quit *and* shut the daemon down, ending every running session.
/// Confirmed with a prompt before anything happens.
QuitStopSessions,
}
/// One agent pane, as shown in the tray menu.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct AgentRow {
/// The hosting leaf's entity id (`EntityId::as_u64`), the reveal key.
pub leaf_id: u64,
/// Which agent — names the row and picks the brand avatar.
pub agent: crate::core::cli_agent::CLIAgent,
pub status: AgentStatus,
/// Where it's working: the cwd's directory name, plus the git branch
/// when known — e.g. "tty7 @ main".
pub detail: String,
}
/// Everything the tray renders, diffed once a second against the app.
#[derive(Clone, Default, PartialEq, Eq)]
pub(crate) struct TraySnapshot {
/// Agent panes, most urgent first (waiting > working > done > idle).
pub agents: Vec<AgentRow>,
pub notify_mode: NotifyMode,
}
impl TraySnapshot {
/// Whether any agent is blocked on the user — drives the attention icon.
pub(crate) fn attention(&self) -> bool {
self.agents
.iter()
.any(|a| a.status == AgentStatus::Waiting)
}
/// Hover text: a one-line census of the agent panes ("tty7 — 1 waiting,
/// 2 working"), or just "tty7" when none are running.
pub(crate) fn tooltip(&self) -> String {
let count = |s: AgentStatus| self.agents.iter().filter(|a| a.status == s).count();
let mut parts = Vec::new();
for (n, word) in [
(count(AgentStatus::Waiting), "waiting"),
(count(AgentStatus::Working), "working"),
(count(AgentStatus::Done), "done"),
] {
if n > 0 {
parts.push(format!("{n} {word}"));
}
}
if parts.is_empty() {
"tty7".to_string()
} else {
format!("tty7 — {}", parts.join(", "))
}
}
}
/// The platform-independent menu shape; each backend translates it 1:1 into
/// its native menu type, so the layout and labels live in exactly one place
/// ([`menu_spec`]).
pub(crate) enum SpecItem {
Item {
id: String,
label: String,
/// `Some(_)` renders a checkable item (the notification radio).
checked: Option<bool>,
/// `Some(_)` renders the agent's brand avatar (colored disc + white
/// mark + status dot — the tab avatar's menu translation) next to the
/// label. The backends rasterize it via [`icon::agent_avatar`].
avatar: Option<(crate::core::cli_agent::CLIAgent, AgentStatus)>,
},
Separator,
Submenu {
label: String,
items: Vec<SpecItem>,
},
}
/// Build the menu for a snapshot. Layout: reveal/window on top, then the
/// live agent panes, then notification policy + settings, then the two quit
/// flavors — plain quit keeps sessions (like ⌘Q), the second one stops them.
pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> {
let item = |id: &str, label: String| SpecItem::Item {
id: id.to_string(),
label,
checked: None,
avatar: None,
};
let mut items = vec![
item("show", "Show tty7".into()),
SpecItem::Separator,
];
for a in &snap.agents {
// The avatar (brand disc + status dot) carries the who/state visually,
// exactly like the tab chip; the textual suffix repeats the state for
// scanability in a text-first menu.
let state = match a.status {
AgentStatus::Waiting => " — needs input",
AgentStatus::Working => " — working",
AgentStatus::Done => " — done",
AgentStatus::Idle => "",
};
items.push(SpecItem::Item {
id: format!("agent:{}", a.leaf_id),
label: format!("{} · {}{state}", a.agent.display_name(), a.detail),
checked: None,
avatar: Some((a.agent, a.status)),
});
}
if !snap.agents.is_empty() {
items.push(SpecItem::Separator);
}
let notify = |id: &str, label: &str, mode: NotifyMode| SpecItem::Item {
id: id.to_string(),
label: label.to_string(),
checked: Some(snap.notify_mode == mode),
avatar: None,
};
items.push(SpecItem::Submenu {
label: "Notifications".into(),
items: vec![
notify("notify:always", "Always", NotifyMode::Always),
notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused),
notify("notify:never", "Never", NotifyMode::Never),
],
});
items.push(item("settings", "Settings…".into()));
items.push(item("updates", "Check for Updates…".into()));
items.push(SpecItem::Separator);
items.push(item("quit", "Quit tty7".into()));
// Plain quit leaves the daemon (and every session) running; this one
// stops the daemon too. "Daemon" is already in the product vocabulary —
// the app menu ships "Restart Daemon…" — and the confirm prompt spells
// out the consequences.
items.push(item("quit-stop", "Quit and Stop Daemon…".into()));
items
}
/// Decode a clicked menu item id back into an action. Ids are assigned in
/// [`menu_spec`]; unknown ids (never expected) decode to `None` and the
/// click is dropped.
pub(crate) fn action_from_id(id: &str) -> Option<TrayAction> {
match id {
"show" => Some(TrayAction::ShowWindow),
"settings" => Some(TrayAction::OpenSettings),
"updates" => Some(TrayAction::CheckForUpdates),
"quit" => Some(TrayAction::Quit),
"quit-stop" => Some(TrayAction::QuitStopSessions),
"notify:always" => Some(TrayAction::SetNotifyMode(NotifyMode::Always)),
"notify:unfocused" => Some(TrayAction::SetNotifyMode(NotifyMode::Unfocused)),
"notify:never" => Some(TrayAction::SetNotifyMode(NotifyMode::Never)),
_ => {
let leaf_id = id.strip_prefix("agent:")?.parse().ok()?;
Some(TrayAction::RevealPane { leaf_id })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snapshot_with_agent(status: AgentStatus) -> TraySnapshot {
TraySnapshot {
agents: vec![AgentRow {
leaf_id: 42,
agent: crate::core::cli_agent::CLIAgent::Claude,
status,
detail: "tty7 @ main".into(),
}],
notify_mode: NotifyMode::Unfocused,
}
}
/// Every id the menu builder mints must decode back to an action —
/// otherwise a click on that item silently does nothing.
#[test]
fn every_menu_id_decodes_to_an_action() {
fn check(items: &[SpecItem]) {
for item in items {
match item {
SpecItem::Item { id, label, .. } => assert!(
action_from_id(id).is_some(),
"menu item {label:?} has undecodable id {id:?}"
),
SpecItem::Separator => {}
SpecItem::Submenu { items, .. } => check(items),
}
}
}
check(&menu_spec(&snapshot_with_agent(AgentStatus::Waiting)));
check(&menu_spec(&TraySnapshot::default()));
}
#[test]
fn agent_rows_decode_to_reveal_with_their_leaf_id() {
assert_eq!(
action_from_id("agent:42"),
Some(TrayAction::RevealPane { leaf_id: 42 })
);
// Garbage after the prefix is dropped, not a panic or a mis-decode.
assert_eq!(action_from_id("agent:nope"), None);
assert_eq!(action_from_id("bogus"), None);
}
#[test]
fn attention_follows_waiting_and_tooltip_counts() {
assert!(snapshot_with_agent(AgentStatus::Waiting).attention());
assert!(!snapshot_with_agent(AgentStatus::Working).attention());
assert_eq!(
snapshot_with_agent(AgentStatus::Waiting).tooltip(),
"tty7 — 1 waiting"
);
assert_eq!(TraySnapshot::default().tooltip(), "tty7");
}
/// The empty snapshot renders no agent section (no dangling separator),
/// and the notification radio reflects the snapshot's mode.
#[test]
fn menu_spec_shape() {
let empty = menu_spec(&TraySnapshot::default());
let labels: Vec<_> = empty
.iter()
.filter_map(|i| match i {
SpecItem::Item { label, .. } => Some(label.as_str()),
SpecItem::Submenu { label, .. } => Some(label.as_str()),
SpecItem::Separator => None,
})
.collect();
assert_eq!(
labels,
[
"Show tty7",
"Notifications",
"Settings…",
"Check for Updates…",
"Quit tty7",
"Quit and Stop Daemon…"
]
);
// No two separators in a row when the agent section is absent.
assert!(!empty.windows(2).any(|w| matches!(
w,
[SpecItem::Separator, SpecItem::Separator]
)));
let with_agent = menu_spec(&snapshot_with_agent(AgentStatus::Waiting));
assert!(with_agent.iter().any(|i| matches!(
i,
SpecItem::Item { id, avatar: Some(_), .. } if id == "agent:42"
)));
}
}
/// Wire the tray up: one task pumps menu clicks into the app, another polls
/// the app into the tray. Called once from `Tty7App::with_session`; both
/// tasks end (dropping the tray icon) when the app entity drops.
///
/// `show_tray_icon` is re-read every tick, so the Settings toggle and a
/// `config.json` hot-reload both take effect within a second — the backend
/// is dropped (icon removed) when off and re-created when back on.
pub(crate) fn init(cx: &mut Context<Tty7App>) {
let (tx, rx) = smol::channel::unbounded::<TrayAction>();
// Menu clicks → the app. The platform handler feeds `tx` from wherever
// the OS delivers menu events; this task is the only place they touch
// gpui state, with a real window + context in hand.
cx.spawn(async move |this, cx| {
while let Ok(action) = rx.recv().await {
let alive = this.update_in(cx, |app, window, cx| {
app.handle_tray_action(action, window, cx)
});
if alive.is_err() {
break;
}
}
})
.detach();
// App → tray poll loop. Owns the backend; dropping it removes the icon.
// The backend types are !Send on macOS (NSStatusItem), which is fine on
// the foreground executor — exactly where tray-icon requires them.
cx.spawn(async move |this, cx| {
let mut backend: Option<Backend> = None;
// Last snapshot actually pushed; `None` forces a push after
// (re)creation so a fresh icon never shows a stale menu.
let mut shown: Option<TraySnapshot> = None;
// Creation can fail transiently — on Linux the SNI host may simply
// not be on the bus *yet* (tty7 autostarting at login races the
// desktop shell / AppIndicator extension) — so a failed create is
// retried on a slow backoff before giving up for this enable-cycle:
// one attempt every RETRY_EVERY ticks, MAX_ATTEMPTS total. Toggling
// the setting off and on re-arms.
const MAX_ATTEMPTS: u32 = 10;
const RETRY_EVERY: u32 = 30; // ticks ≈ seconds
let mut attempts = 0u32;
let mut cooldown = 0u32;
loop {
cx.background_executor().timer(POLL).await;
let Ok((enabled, snap)) = this.update(cx, |app, cx| {
(
cx.global::<Config>().show_tray_icon,
app.tray_snapshot(cx),
)
}) else {
break; // app gone — backend drops with the task
};
if !enabled {
backend = None;
shown = None;
attempts = 0;
cooldown = 0;
continue;
}
if backend.is_none() && attempts < MAX_ATTEMPTS {
if cooldown > 0 {
cooldown -= 1;
continue;
}
attempts += 1;
backend = Backend::create(tx.clone(), cx).await;
if backend.is_none() {
cooldown = RETRY_EVERY;
if attempts == MAX_ATTEMPTS {
log::warn!(
"tray icon unavailable after {MAX_ATTEMPTS} attempts; \
running without one"
);
}
}
shown = None;
}
if let Some(backend) = backend.as_mut()
&& shown.as_ref() != Some(&snap)
{
backend.update(&snap);
shown = Some(snap);
}
}
})
.detach();
}
+158
View File
@@ -0,0 +1,158 @@
//! macOS / Windows tray backend on tauri's `tray-icon`.
//!
//! Lifecycle rules the poll loop in `mod.rs` already honors: the tray must be
//! created on a thread that pumps native events — gpui's main thread — and
//! `TrayIcon` is `!Send` there, so the backend lives inside the foreground
//! poll task and dropping it removes the status item.
use super::{action_from_id, icon, SpecItem, TrayAction, TraySnapshot};
use gpui::AsyncApp;
use tray_icon::menu::{
CheckMenuItem, IconMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem,
Submenu,
};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub(super) struct Backend {
tray: TrayIcon,
/// The icon state currently shown, so a snapshot diff that doesn't flip
/// attention skips the bitmap rebuild.
attention: bool,
}
impl Backend {
/// Build the status item with the calm icon and an initial (empty-state)
/// menu; the first `update` follows immediately. `None` (creation
/// failure) is terminal for this enable-cycle — see `gave_up` in the
/// poll loop.
pub(super) async fn create(
tx: smol::channel::Sender<TrayAction>,
_cx: &AsyncApp,
) -> Option<Self> {
// (Re-)install the process-global menu-event hook. Menu events are
// delivered by the native event loop the app already pumps; decoding
// and the actual work happen on the channel's gpui side.
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
if let Some(action) = action_from_id(&event.id().0) {
let _ = tx.try_send(action);
}
}));
let img = icon::render(false)?;
let icon = Icon::from_rgba(img.data, img.width, img.height).ok()?;
let tray = TrayIconBuilder::new()
.with_icon(icon)
// The calm glyph is a template on macOS (system recolors it for
// the bar); a no-op on Windows.
.with_icon_as_template(true)
.with_tooltip("tty7")
.with_menu(Box::new(build_menu(&TraySnapshot::default())))
// Windows defaults to menu-on-right-click only; a status item
// whose left click does nothing reads as broken.
.with_menu_on_left_click(true)
.build();
let tray = match tray {
Ok(tray) => tray,
Err(e) => {
log::warn!("failed to create tray icon: {e}");
return None;
}
};
Some(Self {
tray,
attention: false,
})
}
/// Push a changed snapshot into the native item: menu always (it's what
/// changed), icon only across an attention flip.
pub(super) fn update(&mut self, snap: &TraySnapshot) {
self.tray.set_menu(Some(Box::new(build_menu(snap))));
let _ = self.tray.set_tooltip(Some(snap.tooltip()));
let attention = snap.attention();
if attention != self.attention {
self.attention = attention;
if let Some(img) = icon::render(attention)
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
{
// Attention leaves template mode: the amber badge needs
// its real color (macOS; the flag is a no-op on Windows).
let _ = self.tray.set_icon_with_as_template(Some(icon), !attention);
}
}
}
}
/// Translate the shared menu spec into a muda menu.
fn build_menu(snap: &TraySnapshot) -> Menu {
let menu = Menu::new();
for item in super::menu_spec(snap) {
append(&menu, &item);
}
menu
}
/// Append one spec item to a muda container (top-level menu or submenu —
/// both expose `append(&dyn IsMenuItem)` behind small wrappers).
fn append(menu: &Menu, item: &SpecItem) {
let appended = match item {
SpecItem::Item { .. } => menu.append(leaf_item(item).as_ref()),
SpecItem::Separator => menu.append(&PredefinedMenuItem::separator()),
SpecItem::Submenu { label, items } => {
let sub = Submenu::new(label, true);
for child in items {
let child: Box<dyn IsMenuItem> = match child {
SpecItem::Item { .. } => leaf_item(child),
_ => Box::new(PredefinedMenuItem::separator()),
};
if let Err(e) = sub.append(child.as_ref()) {
log::warn!("tray submenu item failed to append: {e}");
}
}
menu.append(&sub)
}
};
if let Err(e) = appended {
log::warn!("tray menu item failed to append: {e}");
}
}
/// Build the muda item for a `SpecItem::Item`: checkable → `CheckMenuItem`,
/// avatar-bearing (agent rows) → `IconMenuItem` with the rasterized brand
/// avatar, plain → `MenuItem`. A failed avatar render degrades to text-only.
fn leaf_item(item: &SpecItem) -> Box<dyn IsMenuItem> {
let SpecItem::Item {
id,
label,
checked,
avatar,
} = item
else {
return Box::new(PredefinedMenuItem::separator());
};
if let Some(checked) = checked {
return Box::new(CheckMenuItem::with_id(
id.clone(),
label,
true,
*checked,
None,
));
}
if let Some((agent, status)) = avatar {
let rendered = icon::agent_avatar(*agent, *status).map(|pm| {
let img = icon::to_rgba(&pm);
tray_icon::menu::Icon::from_rgba(img.data, img.width, img.height)
});
if let Some(Ok(avatar_icon)) = rendered {
return Box::new(IconMenuItem::with_id(
id.clone(),
label,
true,
Some(avatar_icon),
None,
));
}
}
Box::new(MenuItem::with_id(id.clone(), label, true, None))
}
+168
View File
@@ -0,0 +1,168 @@
//! Linux tray backend: `ksni`, a pure-Rust StatusNotifierItem over DBus.
//!
//! ksni owns a service thread and re-queries the [`ksni::Tray`] impl for
//! icon/menu/status whenever we call `Handle::update`, so the backend just
//! swaps the stored snapshot in. Menu item activation runs on ksni's thread;
//! actions cross back to gpui over the same channel the other platforms use.
//!
//! On desktops without an SNI host (bare GNOME without the AppIndicator
//! extension) the spawn fails; the poll loop logs once and the app runs
//! without a tray.
use super::{action_from_id, icon, SpecItem, TrayAction, TraySnapshot};
use gpui::AsyncApp;
pub(super) struct Backend {
handle: ksni::blocking::Handle<SniTray>,
}
impl Backend {
/// Spawn the SNI service. Registration is a DBus round-trip, so it runs
/// on the background executor rather than stalling the foreground poll
/// loop; the returned handle is `Send` and lives with the poll task.
pub(super) async fn create(
tx: smol::channel::Sender<TrayAction>,
cx: &AsyncApp,
) -> Option<Self> {
cx.background_spawn(async move {
use ksni::blocking::TrayMethods as _;
let tray = SniTray {
tx,
snap: TraySnapshot::default(),
};
match tray.spawn() {
Ok(handle) => Some(Backend { handle }),
Err(e) => {
log::warn!("failed to register StatusNotifierItem: {e}");
None
}
}
})
.await
}
pub(super) fn update(&mut self, snap: &TraySnapshot) {
let snap = snap.clone();
// `update` re-reads menu/icon/status from the Tray impl and pushes
// the changed properties over DBus. Returns None once the service is
// gone (host died) — nothing to do about it here.
self.handle.update(move |tray| tray.snap = snap);
}
}
impl Drop for Backend {
fn drop(&mut self) {
// Dropping the handle alone leaves the service thread (and the icon)
// alive; ask it to unregister. The awaiter is intentionally not
// waited on — teardown can finish on ksni's thread.
self.handle.shutdown();
}
}
struct SniTray {
tx: smol::channel::Sender<TrayAction>,
snap: TraySnapshot,
}
impl SniTray {
/// A menu item that sends the action decoded from `id` — the same id
/// space `action_from_id` serves on the other platforms.
fn send(&self, id: &str) {
if let Some(action) = action_from_id(id) {
let _ = self.tx.try_send(action);
}
}
}
impl ksni::Tray for SniTray {
fn id(&self) -> String {
"tty7".into()
}
fn title(&self) -> String {
"tty7".into()
}
fn status(&self) -> ksni::Status {
if self.snap.attention() {
// Hosts surface this — e.g. KDE moves the item out of the
// overflow and may highlight it.
ksni::Status::NeedsAttention
} else {
ksni::Status::Active
}
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
let Some((data, size)) = icon::render_argb(self.snap.attention()) else {
return Vec::new();
};
vec![ksni::Icon {
width: size as i32,
height: size as i32,
data,
}]
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
title: self.snap.tooltip(),
..Default::default()
}
}
/// Plain left click on the item (not the menu): reveal the window.
fn activate(&mut self, _x: i32, _y: i32) {
let _ = self.tx.try_send(TrayAction::ShowWindow);
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
super::menu_spec(&self.snap)
.into_iter()
.map(translate)
.collect()
}
}
/// Translate one shared spec item into ksni's menu tree.
fn translate(item: SpecItem) -> ksni::MenuItem<SniTray> {
match item {
SpecItem::Item {
id,
label,
checked: None,
avatar,
} => ksni::menu::StandardItem {
label,
// Agent rows carry the brand avatar (disc + white mark + status
// dot) as PNG bytes — dbusmenu's icon-data. A failed render just
// leaves the row text-only.
icon_data: avatar
.and_then(|(agent, status)| icon::agent_avatar(agent, status))
.and_then(|pm| pm.encode_png().ok())
.unwrap_or_default(),
activate: Box::new(move |tray: &mut SniTray| tray.send(&id)),
..Default::default()
}
.into(),
SpecItem::Item {
id,
label,
checked: Some(checked),
..
} => ksni::menu::CheckmarkItem {
label,
checked,
activate: Box::new(move |tray: &mut SniTray| tray.send(&id)),
..Default::default()
}
.into(),
SpecItem::Separator => ksni::MenuItem::Separator,
SpecItem::Submenu { label, items } => ksni::menu::SubMenu {
label,
submenu: items.into_iter().map(translate).collect(),
..Default::default()
}
.into(),
}
}