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(), + } +}