mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
Generated
+627
-68
File diff suppressed because it is too large
Load Diff
+21
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 16–32 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]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user