From 040f4e35a1c5fe789dd4451d3bec5232611fcdd6 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:08:03 +0800
Subject: [PATCH] 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.
---
Cargo.lock | 695 +++++++++++++++++++++++++++++++++++++----
Cargo.toml | 21 ++
README.md | 2 +-
README.zh-CN.md | 2 +-
docs/features.md | 1 +
docs/features.zh-CN.md | 1 +
src/core/config.rs | 7 +
src/core/update.rs | 6 +
src/ui/app.rs | 168 ++++++++++
src/ui/mod.rs | 1 +
src/ui/settings.rs | 17 +
src/ui/tray/icon.rs | 347 ++++++++++++++++++++
src/ui/tray/mod.rs | 404 ++++++++++++++++++++++++
src/ui/tray/native.rs | 158 ++++++++++
src/ui/tray/sni.rs | 168 ++++++++++
15 files changed, 1928 insertions(+), 70 deletions(-)
create mode 100644 src/ui/tray/icon.rs
create mode 100644 src/ui/tray/mod.rs
create mode 100644 src/ui/tray/native.rs
create mode 100644 src/ui/tray/sni.rs
diff --git a/Cargo.lock b/Cargo.lock
index 952b6f7b..119d2e80 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -293,7 +293,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -341,7 +341,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -515,7 +515,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -591,7 +591,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -607,6 +607,29 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
[[package]]
name = "atomic"
version = "0.5.3"
@@ -675,7 +698,7 @@ dependencies = [
"log",
"num-rational",
"num-traits",
- "pastey",
+ "pastey 0.1.1",
"rayon",
"thiserror 2.0.18",
"v_frame",
@@ -796,7 +819,7 @@ dependencies = [
"regex",
"rustc-hash 2.1.2",
"shlex 1.3.0",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -1001,7 +1024,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -1031,6 +1054,31 @@ dependencies = [
"libbz2-rs-sys",
]
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.13.0",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
[[package]]
name = "calloop"
version = "0.14.4"
@@ -1087,7 +1135,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
- "syn",
+ "syn 2.0.118",
"tempfile",
"toml 0.8.23",
]
@@ -1119,6 +1167,16 @@ dependencies = [
"nom 7.1.3",
]
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -1198,7 +1256,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -1597,6 +1655,15 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1744,7 +1811,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -1787,7 +1854,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -1826,7 +1893,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
- "syn",
+ "syn 2.0.118",
"unicode-xid",
]
@@ -1837,7 +1904,7 @@ source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -1919,7 +1986,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -1928,7 +1995,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -1946,6 +2013,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
[[package]]
name = "dunce"
version = "1.0.5"
@@ -2085,7 +2158,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -2097,7 +2170,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -2118,7 +2191,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -2138,7 +2211,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -2275,6 +2348,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset 0.9.1",
+ "rustc_version",
+]
+
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -2425,7 +2508,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -2576,7 +2659,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -2608,6 +2691,64 @@ dependencies = [
"slab",
]
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -2715,6 +2856,38 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -2726,6 +2899,53 @@ dependencies = [
"xml-rs",
]
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.13.0",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.0",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
[[package]]
name = "glob"
version = "0.3.3"
@@ -2801,6 +3021,17 @@ dependencies = [
"gl_generator",
]
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
[[package]]
name = "gpu-allocator"
version = "0.28.0"
@@ -2990,7 +3221,7 @@ source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -3099,7 +3330,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -3231,6 +3462,58 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "h2"
version = "0.4.15"
@@ -3418,7 +3701,7 @@ dependencies = [
"markup5ever",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -3847,7 +4130,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -3979,7 +4262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -4023,6 +4306,17 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.13.0",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "keyring"
version = "4.1.4"
@@ -4051,7 +4345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
- "libloading",
+ "libloading 0.8.9",
"pkg-config",
]
@@ -4081,6 +4375,24 @@ dependencies = [
"libc",
]
+[[package]]
+name = "ksni"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b"
+dependencies = [
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "futures-channel",
+ "futures-lite 2.6.1",
+ "futures-util",
+ "pastey 0.2.3",
+ "serde",
+ "task-local",
+ "zbus",
+]
+
[[package]]
name = "kurbo"
version = "0.11.3"
@@ -4128,6 +4440,30 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading 0.7.4",
+ "once_cell",
+]
+
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
@@ -4171,6 +4507,16 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
[[package]]
name = "libloading"
version = "0.8.9"
@@ -4196,6 +4542,25 @@ dependencies = [
"libc",
]
+[[package]]
+name = "libxdo"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
+dependencies = [
+ "libxdo-sys",
+]
+
+[[package]]
+name = "libxdo-sys"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
+dependencies = [
+ "libc",
+ "x11",
+]
+
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -4588,6 +4953,27 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "muda"
+version = "0.19.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "libxdo",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "png 0.18.1",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "naga"
version = "29.0.3"
@@ -4869,7 +5255,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -5373,6 +5759,31 @@ dependencies = [
"windows-strings 0.5.1",
]
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -5423,6 +5834,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -5563,7 +5980,7 @@ dependencies = [
"phf_shared 0.13.1",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -5607,7 +6024,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -5861,7 +6278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -5891,6 +6308,25 @@ dependencies = [
"wnaf",
]
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8"
+dependencies = [
+ "toml_edit 0.20.7",
+]
+
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -5900,6 +6336,30 @@ dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
]
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
@@ -5919,7 +6379,7 @@ dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -5947,7 +6407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
dependencies = [
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -5977,7 +6437,7 @@ dependencies = [
"convert_case 0.11.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -6382,7 +6842,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -6664,7 +7124,7 @@ dependencies = [
"quote",
"rust-embed-utils",
"shellexpand",
- "syn",
+ "syn 2.0.118",
"walkdir",
]
@@ -6705,7 +7165,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -6985,7 +7445,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7150,7 +7610,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7161,7 +7621,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7208,7 +7668,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7672,7 +8132,7 @@ checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69"
dependencies = [
"proc-macro-error2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7745,7 +8205,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7871,6 +8331,16 @@ dependencies = [
"zeno",
]
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
[[package]]
name = "syn"
version = "2.0.118"
@@ -7899,7 +8369,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -7946,6 +8416,19 @@ dependencies = [
"libc",
]
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.23",
+ "version-compare",
+]
+
[[package]]
name = "taffy"
version = "0.10.1"
@@ -7976,6 +8459,21 @@ dependencies = [
"objc",
]
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "task-local"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626"
+dependencies = [
+ "pin-project-lite",
+]
+
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
@@ -8056,7 +8554,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -8067,7 +8565,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -8207,7 +8705,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -8291,6 +8789,28 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap",
+ "toml_datetime 0.6.11",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
+dependencies = [
+ "indexmap",
+ "toml_datetime 0.6.11",
+ "winnow 0.5.40",
+]
+
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -8385,7 +8905,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -8423,6 +8943,27 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "tray-icon"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "png 0.18.1",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "tree-sitter"
version = "0.26.9"
@@ -8491,6 +9032,7 @@ dependencies = [
"gpui-component-assets",
"gpui_platform",
"keyring",
+ "ksni",
"libc",
"libgssapi",
"log",
@@ -8502,6 +9044,7 @@ dependencies = [
"plist",
"portable-pty",
"reqwest_client",
+ "resvg",
"russh",
"russh-sftp",
"serde",
@@ -8511,6 +9054,7 @@ dependencies = [
"smallvec",
"smol",
"tokio",
+ "tray-icon",
"uuid",
"windows-sys 0.59.0",
"winresource",
@@ -8748,7 +9292,7 @@ source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49
dependencies = [
"perf",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -8817,6 +9361,12 @@ dependencies = [
"sval_serde",
]
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -8948,7 +9498,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"wasm-bindgen-shared",
]
@@ -9235,7 +9785,7 @@ dependencies = [
"js-sys",
"khronos-egl",
"libc",
- "libloading",
+ "libloading 0.8.9",
"log",
"naga",
"ndk-sys",
@@ -9488,7 +10038,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -9499,7 +10049,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -9510,7 +10060,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -9521,7 +10071,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -9532,7 +10082,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -9543,7 +10093,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -9944,6 +10494,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "winnow"
version = "0.7.15"
@@ -10201,7 +10760,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"synstructure",
]
@@ -10258,7 +10817,7 @@ checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"zbus-lockstep",
"zbus_xml",
"zvariant",
@@ -10281,10 +10840,10 @@ version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
- "proc-macro-crate",
+ "proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -10455,7 +11014,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -10475,7 +11034,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"synstructure",
]
@@ -10496,7 +11055,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -10529,7 +11088,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
]
[[package]]
@@ -10625,10 +11184,10 @@ version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
- "proc-macro-crate",
+ "proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.118",
"zvariant_utils",
]
@@ -10641,6 +11200,6 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn",
+ "syn 2.0.118",
"winnow 1.0.3",
]
diff --git a/Cargo.toml b/Cargo.toml
index 8c6d49e0..bbffd1fc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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
diff --git a/README.md b/README.md
index 696866a8..7352b5cd 100644
--- a/README.md
+++ b/README.md
@@ -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 · ⌃ R fuzzy history |
| **Window** | tabs & splits · ⌘ P palette · ⌘ F 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: ⌘ ,
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c823643b..4ef795ea 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -42,7 +42,7 @@
|---|---|
| **输入** | 历史影子建议 · 带说明的 Tab 补全 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊历史搜索 |
| **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · 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)。快捷键:⌘ ,
diff --git a/docs/features.md b/docs/features.md
index 00d8aa85..4ea227af 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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
diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md
index 48851018..358cd44d 100644
--- a/docs/features.zh-CN.md
+++ b/docs/features.zh-CN.md
@@ -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
diff --git a/src/core/config.rs b/src/core/config.rs
index 7c2cddb9..22d7259e 100644
--- a/src/core/config.rs
+++ b/src/core/config.rs
@@ -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,
diff --git a/src/core/update.rs b/src/core/update.rs
index 51fe4c4d..a372115a 100644
--- a/src/core/update.rs
+++ b/src/core/update.rs
@@ -68,7 +68,13 @@ pub fn spawn_check(cx: &mut App) {
if !cx.global::().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
diff --git a/src/ui/app.rs b/src/ui/app.rs
index 711ada02..b69c30e4 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -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::().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,
+ ) {
+ 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) {
+ // 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.update_config(cx, |cfg| cfg.show_tray_icon = on);
+ }
+
// ── Input / Mouse setters ───────────────────────────────────────────────
/// Takes effect on the next keystroke — the terminal reads the flag per
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index ec8f20b0..5f6d9a63 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -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;
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index b813211b..32ecc8a0 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -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(
diff --git a/src/ui/tray/icon.rs b/src/ui/tray/icon.rs
new file mode 100644
index 00000000..754605af
--- /dev/null
+++ b/src/ui/tray/icon.rs
@@ -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,
+ 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 {
+ 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 {
+ 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, 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 = 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]]);
+ }
+ }
+}
diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs
new file mode 100644
index 00000000..2eb278e5
--- /dev/null
+++ b/src/ui/tray/mod.rs
@@ -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,
+ 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,
+ /// `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,
+ },
+}
+
+/// 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 {
+ 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 {
+ 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) {
+ let (tx, rx) = smol::channel::unbounded::();
+
+ // 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 = 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 = 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::().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();
+}
diff --git a/src/ui/tray/native.rs b/src/ui/tray/native.rs
new file mode 100644
index 00000000..7ee81656
--- /dev/null
+++ b/src/ui/tray/native.rs
@@ -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,
+ _cx: &AsyncApp,
+ ) -> Option {
+ // (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 = 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 {
+ 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))
+}
diff --git a/src/ui/tray/sni.rs b/src/ui/tray/sni.rs
new file mode 100644
index 00000000..5eff394d
--- /dev/null
+++ b/src/ui/tray/sni.rs
@@ -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,
+}
+
+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,
+ cx: &AsyncApp,
+ ) -> Option {
+ 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,
+ 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 {
+ 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> {
+ 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 {
+ 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(),
+ }
+}