diff --git a/CHANGELOG.md b/CHANGELOG.md index 2828a55c..7505083f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 touch one it didn't, so a hand-written skill that happens to share the directory name survives. (#248) +- **Windows taskbar buttons show a live status dot per window** — + Settings → Window & Tabs gains **Taskbar status dot** (on by default), + which stamps a small colored disc on each window's taskbar button using + the same palette as the in-window agent dots: blue while a shell command + or coding agent is running, amber when an agent needs your input, and + green when work finishes while the window is in the background (cleared + the moment the window is activated). The poll runs once a second, so the + toggle and a `config.json` hot-reload both apply within a second, and + turning it off clears any badge already stamped. This is a Windows-only + feature by nature (`ITaskbarList3::SetOverlayIcon`); macOS's Dock badge + and Linux's tray badge are the natural equivalents there. (#199) + ### Fixed - **Scoop shims work again when tty7 is launched from a hardened Windows diff --git a/Cargo.lock b/Cargo.lock index d1bde944..31ec37c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9675,6 +9675,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "plist", + "raw-window-handle", "regex", "reqwest_client", "resvg", diff --git a/Cargo.toml b/Cargo.toml index a4f88659..56b3885f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,7 +166,12 @@ windows = { version = "0.58", features = [ "Win32_System_Variant", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", + "Win32_UI_WindowsAndMessaging", ] } +# Unwraps gpui's `HasWindowHandle` into the raw Win32 HWND that the taskbar +# overlay (`ui::taskbar`) targets. Same version gpui pins — already in the +# tree, no new native code. +raw-window-handle = "0.6" # Embeds `assets/favicon.ico` into the `.exe` so Windows shows the tty7 logo in # the taskbar / window / Explorer (macOS gets its icon from the `.app` bundle via diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 82dc685d..43468176 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -177,6 +177,14 @@ pub struct Config { pub restore_session: bool, #[serde(default = "default_true")] pub show_tray_icon: bool, + /// Windows: stamp a status dot on each window's taskbar button — amber + /// when an agent needs input, blue while a command or agent works, green + /// when work finished unfocused (cleared on activation). Colors match the + /// in-window status dots. On by default; the overlay poll re-reads this + /// every second, so the toggle and a `config.json` hot-reload apply live. + /// Ignored off Windows (a macOS Dock badge would be its own setting). + #[serde(default = "default_true")] + pub taskbar_status_icon: bool, #[serde(default = "default_true")] pub confirm_window_close: bool, #[serde(default, deserialize_with = "de_lenient")] @@ -418,6 +426,7 @@ impl Default for Config { notify_threshold_secs: default_notify_threshold_secs(), restore_session: true, show_tray_icon: true, + taskbar_status_icon: true, confirm_window_close: true, bell: BellMode::Visual, tab_completion: true, diff --git a/docs/features.md b/docs/features.md index 215cecf6..6baae17e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -65,6 +65,7 @@ it never wraps or replaces the agent. - **Copy Session ID** — put the agent's native session id on the clipboard, beside *Copy Working Directory*, for pasting into `codex resume`, a bug report, or another tool - **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) +- **Taskbar status dot (Windows)** — each window's taskbar button carries a colored badge: blue while a shell command or agent is working, amber when an agent needs your input, and green when something finishes while the window is unfocused (cleared on activation). Uses the same colors as the in-window status dots so the meaning never drifts. Toggle in Settings → Window & Tabs, or with `taskbar_status_icon` in `config.json` (on by default; Windows only) - **`tty7 wait`** — the CLI's orchestration primitive: block until a pane's agent needs input or finishes its turn (`tty7 wait %3 --until waiting,done --changed --timeout 600`, exit 124 on timeout), so one agent can sleep until its peer blocks on a permission prompt instead of screen-scraping — then `tty7 capture %3 --plain` to read the result. The agent status is a level, not an event, so `--changed` ignores the state the pane was already in when the wait began; without it, the JSON's `stale` flag says whether the answer might belong to the previous turn - **Orchestration skill** — a switch (Settings → Agents) that installs a Claude Code skill (`~/.claude/skills/tty7-orchestration`) teaching a *primary* agent the delegation loop — spawn a worker pane, send it a bounded task, `wait` on it, capture the result. A skill rather than a global instruction on purpose: only its one-line description rides in context until explicitly invoked, and worker agents never inherit orchestration authority - **`tty7` on PATH** — the CLI ships inside every installer and is put on PATH at launch, so a script or a coding agent can drive tty7 from any terminal. Inside a tty7 pane it works regardless, since panes inherit the app's environment. On Unix it is a symlink into whichever of `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers; on Windows the install directory is appended to your user PATH, and the uninstaller takes it back out. A `tty7` you installed yourself is left alone, never replaced. Off via Settings → Agents or `install_cli_on_path: false` in `config.json` diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 564ae3f7..6551936c 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -62,6 +62,7 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包 - **复制 Session ID** —— 把 agent 的原生 session id 复制到剪贴板,就在 *Copy Working Directory* 旁边,方便粘进 `codex resume`、bug 报告或别的工具 - **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent - **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*(`show_tray_icon`,默认开启) +- **任务栏状态点(Windows)** —— 每个窗口的任务栏按钮都会带一个彩色角标:命令或 agent 运行中为蓝色,agent 等你输入为琥珀色,窗口未聚焦时工作完成为绿色(切回窗口即清除)。颜色与窗口内状态点完全一致,语义不会漂移。可在「设置 → 窗口与标签」开关,或在 `config.json` 里用 `taskbar_status_icon` 控制(默认开启;仅 Windows) - **`tty7 wait`** —— CLI 的编排原语:阻塞到某个 pane 的 agent 等待输入或完成一轮(`tty7 wait %3 --until waiting,done --changed --timeout 600`,超时退出码 124),让一个 agent 睡到同伴卡在权限确认的那一刻,而不是抓屏猜——然后 `tty7 capture %3 --plain` 收结果。agent 状态是电平不是边沿,所以 `--changed` 会忽略 wait 开始时 pane 本来就处在的那个状态;不加它的话,JSON 里的 `stale` 标记会告诉你这个答案是不是上一轮留下的 - **Orchestration skill** —— 一个开关(设置 → Agents),安装一个 Claude Code skill(`~/.claude/skills/tty7-orchestration`),教 *primary* agent 完整的委派循环——开 worker pane、发一个边界清晰的任务、`wait` 等待、收结果。特意做成 skill 而非全局指令:平时只有一行描述占上下文,显式调用才加载全文,worker agent 也不会继承编排权限 - **`tty7` 上 PATH** —— CLI 随每个安装包一起发布,启动时自动放到 PATH 上,脚本和 coding agent 在任何终端里都能驱动 tty7。tty7 自己的 pane 里则一定可用,因为 pane 继承 app 的环境。Unix 上是往 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/bin`、`~/.cargo/bin` 中你 PATH 已经覆盖的那个目录里放一个软链;Windows 上是把安装目录追加到用户 PATH,卸载时再摘掉。你自己装的 `tty7` 一律保持原样,不会被覆盖。关掉:设置 → Agents,或 `config.json` 里 `install_cli_on_path: false` diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index c983135e..22c6663a 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -940,6 +940,19 @@ impl RemoteTerminal { .unwrap_or(false) } + /// Whether the shell is off its prompt running a command. Not simply + /// `!at_prompt()`: both answers are gated on `active`, so a pane whose + /// shell integration never reported (plain cmd.exe, an ssh without + /// OSC 133) reads as neither at the prompt *nor* busy, rather than + /// permanently busy. Feeds the Windows taskbar overlay. + #[cfg_attr(not(windows), allow(dead_code))] + pub fn shell_busy(&self) -> bool { + self.shell_state + .lock() + .map(|s| s.active && !s.at_prompt) + .unwrap_or(false) + } + pub fn prompt_seq(&self) -> u64 { self.shell_state.lock().map(|s| s.seq).unwrap_or(0) } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 791d3f38..d9e66f06 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1039,6 +1039,14 @@ impl TerminalView { self.terminal.agent_session() } + /// Whether the shell is off its prompt running a command (see + /// [`RemoteTerminal::shell_busy`] for why integration-less panes answer + /// `false` rather than "busy forever"). Feeds the Windows taskbar overlay. + #[cfg_attr(not(windows), allow(dead_code))] + pub fn shell_busy(&self) -> bool { + self.terminal.shell_busy() + } + pub fn agent_result_unread(&self) -> bool { self.agent_result_unread } diff --git a/src/ui/app.rs b/src/ui/app.rs index ef5863bc..bd99a0fc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -717,6 +717,11 @@ impl Tty7App { }; if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 { crate::ui::tray::init(cx); + // The taskbar overlay poll is app-wide for the same reason the + // tray's is: one task snapshots every window (each window gets + // its own badge, but nobody wants N poll loops). + #[cfg(windows)] + crate::ui::taskbar::init(cx); } app.refresh_shells(cx); cx.on_app_quit(|app, cx| { @@ -1045,6 +1050,31 @@ impl Tty7App { agents } + /// This window's aggregated signals for the taskbar overlay + /// (`ui::taskbar`): whether any agent pane is blocked on the user, and + /// whether any pane is still working (an agent mid-turn or a shell off + /// its prompt). Same walk as [`agent_rows`](Self::agent_rows), minus the + /// per-row detail the tray menu needs. + #[cfg(windows)] + pub(crate) fn taskbar_signals(&self, cx: &App) -> (bool, bool) { + use crate::core::cli_agent::AgentStatus; + let (mut attention, mut busy) = (false, false); + for tab in &self.tabs { + for leaf in tab.pane.terminals() { + let view = leaf.read(cx); + match view.agent_session().map(|s| s.status) { + Some(AgentStatus::Waiting) => attention = true, + Some(AgentStatus::Working) => busy = true, + _ => {} + } + if view.shell_busy() { + busy = true; + } + } + } + (attention, busy) + } + pub(crate) fn handle_tray_action( &mut self, action: crate::ui::tray::TrayAction, @@ -2047,6 +2077,13 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.show_tray_icon = on); } + /// Toggle the Windows taskbar status dot. Same live-apply story as the + /// tray: the overlay poll re-reads the flag every second, and turning it + /// off clears any badge already stamped. + pub(crate) fn set_taskbar_status_icon(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.taskbar_status_icon = on); + } + pub(crate) fn set_confirm_window_close(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.confirm_window_close = on); } diff --git a/src/ui/i18n.rs b/src/ui/i18n.rs index 3b36492a..a1c369dc 100644 --- a/src/ui/i18n.rs +++ b/src/ui/i18n.rs @@ -317,6 +317,8 @@ pub enum L10nKey { SettingsConfirmLastWindowCloseDesc, SettingsShowTrayIcon, SettingsShowTrayIconDesc, + SettingsTaskbarStatusIcon, + SettingsTaskbarStatusIconDesc, SettingsTabs, SettingsNewTabPosition, SettingsNewTabPositionDesc, @@ -438,6 +440,7 @@ pub enum L10nKey { SettingsSearchScrollSpeedKeywords, SettingsSearchScrollbackKeywords, SettingsSearchShowTrayIconKeywords, + SettingsSearchTaskbarStatusIconKeywords, SettingsSearchSidebarGroupingKeywords, SettingsSearchSmartSelectionKeywords, SettingsSearchStartInKeywords, @@ -1586,6 +1589,13 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { "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.", "在系统托盘/菜单栏保留状态项:当编码 agent 需要输入时发出提示,其菜单可跳转到该 agent 的窗格。", ), + L10nKey::SettingsTaskbarStatusIcon => ("Taskbar status dot", "任务栏状态点"), + L10nKey::SettingsTaskbarStatusIconDesc => ( + "Badge the taskbar icon with each window's status: blue while a \ + command or agent is working, green when one finishes in the \ + background, amber when an agent needs your input.", + "给每个窗口的任务栏图标加状态角标:蓝色表示命令或 agent 正在运行,绿色表示有任务在后台完成,琥珀色表示 agent 等你输入。", + ), L10nKey::SettingsTabs => ("Tabs", "标签页"), L10nKey::SettingsNewTabPosition => ("New tab position", "新标签页位置"), L10nKey::SettingsNewTabPositionDesc => ( @@ -1931,6 +1941,10 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { "tray menu bar status item agent attention system icon", "显示托盘图标 托盘 菜单栏 状态 图标 show tray icon menu bar status", ), + L10nKey::SettingsSearchTaskbarStatusIconKeywords => ( + "taskbar badge overlay status dot busy done agent attention windows", + "任务栏 状态点 角标 覆盖图标 taskbar badge status dot overlay", + ), L10nKey::SettingsSearchSidebarGroupingKeywords => ( "tabs group repo repository git scratch header sidebar flat", "侧栏分组 标签页 分组 仓库 git 侧栏 sidebar grouping tabs repo repository", @@ -3235,6 +3249,8 @@ mod tests { L10nKey::SettingsConfirmLastWindowCloseDesc, L10nKey::SettingsShowTrayIcon, L10nKey::SettingsShowTrayIconDesc, + L10nKey::SettingsTaskbarStatusIcon, + L10nKey::SettingsTaskbarStatusIconDesc, L10nKey::SettingsTabs, L10nKey::SettingsNewTabPosition, L10nKey::SettingsNewTabPositionDesc, @@ -3354,6 +3370,7 @@ mod tests { L10nKey::SettingsSearchScrollSpeedKeywords, L10nKey::SettingsSearchScrollbackKeywords, L10nKey::SettingsSearchShowTrayIconKeywords, + L10nKey::SettingsSearchTaskbarStatusIconKeywords, L10nKey::SettingsSearchSidebarGroupingKeywords, L10nKey::SettingsSearchSmartSelectionKeywords, L10nKey::SettingsSearchStartInKeywords, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7be1962c..075d160a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -32,6 +32,10 @@ pub mod ssh_prompt; pub mod switcher; pub mod tab_sidebar; pub mod tab_strip; +// Taskbar overlay badges are a Windows concept (`ITaskbarList3`); macOS's +// Dock badge would be this module's sibling, not its port. +#[cfg(windows)] +pub mod taskbar; pub mod theme; pub mod tray; pub mod tree_sync; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 683b5d4a..da1dcd50 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -387,6 +387,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsShowTrayIcon, keywords: SettingsSearchShowTrayIconKeywords, }, + SearchEntry { + section: WindowTabs, + title: SettingsTaskbarStatusIcon, + keywords: SettingsSearchTaskbarStatusIconKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsNewTabPosition, @@ -3932,6 +3937,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 taskbar_status_icon = cfg.taskbar_status_icon; let confirm_window_close = cfg.confirm_window_close; let tab_bar_idx = match cfg.tab_bar_position { TabBarPosition::Top => 0, @@ -4003,6 +4009,10 @@ impl Tty7App { .checked(show_tray_icon) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx))) .into_any_element(); + let taskbar_switch = crate::ui::theme::switch("wt-taskbar-status", cx) + .checked(taskbar_status_icon) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_taskbar_status_icon(*on, cx))) + .into_any_element(); let startup_radio = self.segmented( "wt-startup", &[ @@ -4100,6 +4110,17 @@ impl Tty7App { tray_switch, cx, )) + // Windows only: `SetOverlayIcon` is a taskbar concept. The row is + // simply absent elsewhere rather than shown disabled — a switch + // that can never do anything is noise, not a setting. + .children(cfg!(windows).then(|| { + self.settings_row( + t(L10nKey::SettingsTaskbarStatusIcon), + t(L10nKey::SettingsTaskbarStatusIconDesc), + taskbar_switch, + cx, + ) + })) .child(self.section_rule(cx)) .child(self.section_header(t(L10nKey::SettingsTabs), cx)) .child(self.settings_row( diff --git a/src/ui/taskbar.rs b/src/ui/taskbar.rs new file mode 100644 index 00000000..e7c2af6f --- /dev/null +++ b/src/ui/taskbar.rs @@ -0,0 +1,384 @@ +//! Windows taskbar overlay badge: a status dot stamped on each window's +//! taskbar button, so a glance at the taskbar answers "is anything running / +//! finished / stuck waiting on me" while tty7 is in the background. +//! +//! Three states, sharing the in-window dot palette (`AgentStatus::dot_rgb`) +//! so a color never means two things: +//! - **amber** — a coding agent in this window is blocked on the user +//! (same signal as the tray's attention badge); +//! - **blue** — a shell command or agent is still working; +//! - **green** — work finished while the window was unfocused; cleared the +//! moment the window is activated (once you've seen it, it's done its job). +//! +//! Data flow copies `ui::tray`: a foreground task polls once a second, +//! aggregates each window's panes into an [`Overlay`], diffs against what the +//! taskbar currently shows, and only calls into COM on a change. The config +//! flag is re-read every tick, so the Settings toggle and a `config.json` +//! hot-reload both apply within a second (off clears any stamped badge). +//! +//! Windows-only by nature — `SetOverlayIcon` is a taskbar concept. macOS has +//! the Dock badge as a natural follow-up; on Linux there is no portable +//! equivalent and the tray badge already covers attention there. + +use std::collections::HashMap; + +use gpui::App; + +use crate::core::cli_agent::AgentStatus; +use crate::core::config::Config; +use crate::ui::i18n::{L10nKey, t}; + +/// Same cadence as the tray poll: agent status reaches the views on a 300 ms +/// timer, so 1 s here keeps the taskbar a hair behind the in-window dots at +/// negligible cost. +const POLL: std::time::Duration = std::time::Duration::from_millis(1000); + +/// What the badge shows, least to most urgent. Attention outranks Busy +/// outranks Done, matching the tray's `urgency` ordering — when panes +/// disagree, the one that needs the user wins the single overlay slot. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Overlay { + #[default] + None, + /// Work finished while the window was unfocused (green). + Done, + /// A shell command or agent is still working (blue). + Busy, + /// An agent is blocked on the user (amber). + Attention, +} + +impl Overlay { + /// Dot color, borrowed from the in-window status dots so the taskbar + /// never invents a fourth meaning for a color. + fn rgb(self) -> Option { + match self { + Overlay::None => None, + Overlay::Done => AgentStatus::Done.dot_rgb(), + Overlay::Busy => AgentStatus::Working.dot_rgb(), + Overlay::Attention => AgentStatus::Waiting.dot_rgb(), + } + } + + /// Screen-reader text for the overlay (`SetOverlayIcon`'s accessibility + /// description). Localized like every other user-visible string, and + /// borrowed from the panel and tray so the badge reads the same as the + /// status it mirrors. + fn description(self) -> &'static str { + match self { + Overlay::None => "", + Overlay::Done => t(L10nKey::PanelAgentDone), + Overlay::Busy => t(L10nKey::PanelAgentWorking), + Overlay::Attention => t(L10nKey::TrayAgentNeedsInput), + } + } +} + +/// Pick the overlay for one window from its aggregated signals. +fn overlay_for(attention: bool, busy: bool, done_pending: bool) -> Overlay { + if attention { + Overlay::Attention + } else if busy { + Overlay::Busy + } else if done_pending { + Overlay::Done + } else { + Overlay::None + } +} + +/// Per-window memory for the "finished while unfocused" state: `Done` is an +/// *edge* (busy stopped while the user was elsewhere), not a level, so +/// someone has to remember the edge until the user looks. +#[derive(Default)] +struct Track { + was_busy: bool, + done_pending: bool, +} + +impl Track { + /// Advance one poll tick. Activation clears a pending Done (seen = done); + /// a busy→idle transition while unfocused sets it. Ordering matters: an + /// active window never *accumulates* a Done, even on the same tick the + /// command finishes — the user is looking right at the result. + fn advance(&mut self, busy: bool, active: bool) { + if active { + self.done_pending = false; + } else if self.was_busy && !busy { + self.done_pending = true; + } + self.was_busy = busy; + } +} + +/// Wire the overlay up: one app-wide poll task, same lifetime story as +/// `tray::init` (called once, for the first window; lives for the app). +pub(crate) fn init(cx: &mut App) { + cx.spawn(async move |cx| { + // COM object + what each taskbar button currently shows. Both live + // here, on the foreground executor: `ITaskbarList3` is !Send and the + // taskbar wants to be called from the UI thread anyway (gpui already + // ran `OleInitialize` on it). + let mut taskbar = Taskbar::default(); + let mut tracks: HashMap = HashMap::new(); + let mut shown: HashMap = HashMap::new(); + loop { + cx.background_executor().timer(POLL).await; + let wanted = cx.update(|cx| snapshot(&mut tracks, cx)); + for (hwnd, overlay) in &wanted { + // Only remember a badge the taskbar actually took. A failed + // stamp (Explorer restarting takes every overlay with it) + // leaves the cache alone, so the next tick sees the same diff + // and tries again instead of believing a badge it never drew. + if shown.get(hwnd).copied().unwrap_or_default() != *overlay + && taskbar.stamp(*hwnd, *overlay) + { + shown.insert(*hwnd, *overlay); + } + } + // Windows that closed take their taskbar button (and overlay) + // with them — just forget them. + shown.retain(|hwnd, _| wanted.iter().any(|(h, _)| h == hwnd)); + } + }) + .detach(); +} + +/// One poll tick: every open window's HWND and the overlay it should show. +/// With the setting off, every window maps to `Overlay::None` — going through +/// the normal diff is what *clears* badges stamped before the toggle flipped. +fn snapshot(tracks: &mut HashMap, cx: &mut App) -> Vec<(isize, Overlay)> { + use crate::ui::windows::WindowRegistry; + + let enabled = cx.global::().taskbar_status_icon; + let mut wanted = Vec::new(); + for (workspace, weak) in WindowRegistry::open_windows(cx) { + let Some(app) = weak.upgrade() else { continue }; + let Some(handle) = WindowRegistry::window_for(cx, workspace) else { + continue; + }; + let Ok((hwnd, active)) = handle.update(cx, |_, window, _| { + (win32_hwnd(window), window.is_window_active()) + }) else { + continue; + }; + let Some(hwnd) = hwnd else { continue }; + let (attention, busy) = app.read(cx).taskbar_signals(cx); + let track = tracks.entry(hwnd).or_default(); + track.advance(busy, active); + let overlay = if enabled { + overlay_for(attention, busy, track.done_pending) + } else { + Overlay::None + }; + wanted.push((hwnd, overlay)); + } + tracks.retain(|hwnd, _| wanted.iter().any(|(h, _)| h == hwnd)); + wanted +} + +/// The window's Win32 handle, unwrapped from gpui's `HasWindowHandle`. `None` +/// never actually happens on the Windows backend, but degrading to "no badge" +/// beats unwrapping someone else's platform invariant. +fn win32_hwnd(window: &gpui::Window) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + // Fully qualified: gpui's inherent `Window::window_handle` (the + // `AnyWindowHandle`) shadows the `HasWindowHandle` trait method. + match HasWindowHandle::window_handle(window).ok()?.as_raw() { + RawWindowHandle::Win32(h) => Some(h.hwnd.get()), + _ => None, + } +} + +/// The COM half: owns the lazily-created `ITaskbarList3` and turns an +/// [`Overlay`] into a `SetOverlayIcon` call. +#[derive(Default)] +struct Taskbar { + list: Option, + /// Create-failure backoff, same shape as `tray::init`'s: Explorer may not + /// be up yet when the first window opens, and it can restart later, so a + /// failure is a cooldown rather than a life sentence. + attempts: u32, + cooldown: u32, +} + +/// Give up on the overlay after this many failed creations, and wait this +/// many stamps between tries. Both copied from `ui::tray`. +const MAX_ATTEMPTS: u32 = 10; +const RETRY_EVERY: u32 = 30; + +impl Taskbar { + /// Stamp one window's badge. Returns whether the taskbar took it, so the + /// caller knows not to cache a badge that was never drawn. + fn stamp(&mut self, hwnd: isize, overlay: Overlay) -> bool { + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, HICON}; + use windows::core::PCWSTR; + + // Cloned (COM refcount) rather than borrowed: a failed call has to + // drop `self.list` below, which the borrow would forbid. + let Some(list) = self.list().cloned() else { + return false; + }; + let hwnd = HWND(hwnd as *mut core::ffi::c_void); + // Null icon + null description == clear the overlay slot. + let icon = overlay.rgb().and_then(dot_icon); + // Keep the wide string alive across the call. + let desc_buf: Vec = overlay.description().encode_utf16().chain([0]).collect(); + let desc = if icon.is_some() { + PCWSTR(desc_buf.as_ptr()) + } else { + PCWSTR::null() + }; + let stamped = unsafe { + let stamped = list.SetOverlayIcon(hwnd, icon.unwrap_or_default(), desc); + // The taskbar copies the icon during the call, so ours is free to + // go immediately (per the SetOverlayIcon contract) — holding it + // longer would leak one GDI handle per status change. Unconditional + // on the result: a rejected icon is still ours to destroy. + if let Some(icon) = icon + && icon != HICON::default() + { + let _ = DestroyIcon(icon); + } + stamped + }; + if let Err(e) = stamped { + // Either a dead HWND (window closed between snapshot and stamp — + // its button is gone anyway) or an interface outlived by an + // Explorer restart. Both are cheapest to treat the same way: drop + // the interface and let the next tick re-create and re-stamp. + log::debug!("taskbar overlay stamp failed: {e}"); + self.list = None; + return false; + } + // A dot the taskbar accepted means the interface is live; forgive the + // creation failures that came before it. + self.attempts = 0; + true + } + + fn list(&mut self) -> Option<&windows::Win32::UI::Shell::ITaskbarList3> { + use windows::Win32::System::Com::{CLSCTX_ALL, CoCreateInstance}; + use windows::Win32::UI::Shell::{ITaskbarList3, TaskbarList}; + + if self.list.is_none() { + if self.attempts >= MAX_ATTEMPTS { + return None; + } + if self.cooldown > 0 { + self.cooldown -= 1; + return None; + } + self.attempts += 1; + // gpui's Windows platform already ran `OleInitialize` on this + // thread, so plain creation is all that's needed. + let created: Result = + unsafe { CoCreateInstance(&TaskbarList, None, CLSCTX_ALL) }; + match created.and_then(|list| unsafe { list.HrInit() }.map(|()| list)) { + Ok(list) => self.list = Some(list), + Err(e) => { + // Explorer not running (custom shells) is the realistic + // cause; the app is fine without a badge. + self.cooldown = RETRY_EVERY; + if self.attempts == MAX_ATTEMPTS { + log::warn!( + "taskbar overlay unavailable after {MAX_ATTEMPTS} attempts: {e}" + ); + } + } + } + } + self.list.as_ref() + } +} + +/// Render the status dot as a 32×32 `HICON`: a solid antialiased disc on a +/// transparent field. Drawn by hand — a circle needs no SVG pipeline, and the +/// alpha channel of a 32-bit icon carries the antialiasing. +/// +/// `SetOverlayIcon` asks for 16×16 *at 96 dpi*, so at 150%/200% scaling the +/// shell wants 24/32 — rendering at 32 and letting it downscale beats handing +/// it 16 to blow up, the same reason `tray::icon` renders at 32 off macOS. +fn dot_icon(rgb: u32) -> Option { + use windows::Win32::UI::WindowsAndMessaging::CreateIcon; + + const S: usize = 32; + const CENTER: f32 = S as f32 / 2.0 - 0.5; + // Leaves a pixel of breathing room at 96 dpi so the dot never touches the + // icon's edge once the shell has scaled it. + const RADIUS: f32 = S as f32 * 7.0 / 16.0; + let (r, g, b) = ((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8); + // BGRA, straight (non-premultiplied) alpha — the layout `CreateIcon` + // expects for 32-bit XOR bits. + let mut xor = vec![0u8; S * S * 4]; + for y in 0..S { + for x in 0..S { + let dx = x as f32 - CENTER; + let dy = y as f32 - CENTER; + // Solid to `RADIUS`, with alpha ramping linearly over the last + // pixel of distance to antialias the rim. + let a = (RADIUS - (dx * dx + dy * dy).sqrt()).clamp(0.0, 1.0); + if a > 0.0 { + let i = (y * S + x) * 4; + xor[i] = b; + xor[i + 1] = g; + xor[i + 2] = r; + xor[i + 3] = (a * 255.0) as u8; + } + } + } + // The AND mask is vestigial for 32-bit icons (alpha wins), but CreateIcon + // still demands one bit per pixel. + let and = [0u8; S * S / 8]; + unsafe { CreateIcon(None, S as i32, S as i32, 1, 32, and.as_ptr(), xor.as_ptr()) }.ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_priority_is_attention_busy_done() { + assert_eq!(overlay_for(true, true, true), Overlay::Attention); + assert_eq!(overlay_for(false, true, true), Overlay::Busy); + assert_eq!(overlay_for(false, false, true), Overlay::Done); + assert_eq!(overlay_for(false, false, false), Overlay::None); + } + + #[test] + fn done_arms_only_on_a_busy_edge_while_unfocused() { + let mut t = Track::default(); + // Command runs and finishes with the window focused: never Done. + t.advance(true, true); + t.advance(false, true); + assert!(!t.done_pending); + // Runs focused, finishes unfocused: Done. + t.advance(true, true); + t.advance(true, false); + t.advance(false, false); + assert!(t.done_pending); + // Sticks while unfocused, even as new work starts and stops… + t.advance(true, false); + assert!(t.done_pending); + // …and clears the moment the window is activated. + t.advance(false, true); + assert!(!t.done_pending); + } + + #[test] + fn idle_unfocused_window_never_arms_done() { + let mut t = Track::default(); + t.advance(false, false); + t.advance(false, false); + assert!(!t.done_pending); + } + + #[test] + fn overlay_colors_track_the_shared_dot_palette() { + assert_eq!(Overlay::Busy.rgb(), AgentStatus::Working.dot_rgb()); + assert_eq!(Overlay::Attention.rgb(), AgentStatus::Waiting.dot_rgb()); + assert_eq!(Overlay::Done.rgb(), AgentStatus::Done.dot_rgb()); + assert_eq!(Overlay::None.rgb(), None); + } +}