From c98488ca390a4867aae28af0350a1ebe28a06c71 Mon Sep 17 00:00:00 2001 From: handlecusion Date: Wed, 3 Jun 2026 14:35:39 +0900 Subject: [PATCH 1/3] Add global shortcut and focus-returning popover hide Register a global Ctrl+Cmd+T shortcut in Rust to toggle the popover from anywhere. Route every explicit dismiss (Ctrl+Cmd+T, tray-click toggle, and the frontend's hide_popover command for Cmd+W / Esc) through a shared helper that calls NSApp hide, so keyboard focus returns to the previously frontmost app instead of stranding the accessory app with no key window. The blur-hide path is left untouched since focus has already moved to whatever stole it. --- src-tauri/Cargo.lock | 69 ++++++++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 30 +++++++++++++++++++ src-tauri/src/tray.rs | 35 +++++++++++++++++++++- 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5ab86f4..bc4fd98 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1213,6 +1213,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1338,6 +1348,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2890,7 +2918,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4025,6 +4053,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -4312,6 +4355,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-process", "tauri-plugin-updater", "tokio", @@ -5657,6 +5701,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -5667,6 +5728,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a72b29a..16857aa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ env_logger = "0.11" tauri-plugin-updater = "2" tauri-plugin-dialog = "2" tauri-plugin-process = "2" +tauri-plugin-global-shortcut = "2" # Bundled SQLite so we don't depend on the system libsqlite3; needed for # the Hermes Agent SQLite tail in usage_tail.rs (no schema migrations — # read-only queries only). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3cb0e5f..0ffa2c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,14 @@ fn quit_app(app: tauri::AppHandle) { app.exit(0); } +/// Hide the popover from the frontend (⌘W / Esc) and return focus to the +/// previously-frontmost app. Routed through the same helper as the tray-click +/// and Ctrl+Cmd+T toggle so every explicit dismiss behaves identically. +#[tauri::command] +fn hide_popover(app: tauri::AppHandle) { + tray::hide_popover(&app); +} + #[tauri::command] fn push_dialog_shield(state: tauri::State<'_, Arc>) { state.push_suppress_blur_hide(); @@ -299,6 +307,12 @@ pub fn run() { let state = AppState::new(); let state_clone = state.clone(); + // Ctrl+Cmd+T toggles the popover from anywhere — registered in setup, fired + // by the plugin handler below. Shortcut is Copy, so the same value is reused + // for both the handler match and the setup-time registration. + use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; + let toggle_shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SUPER), Code::KeyT); + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, @@ -307,11 +321,21 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(move |app, shortcut, event| { + if shortcut == &toggle_shortcut && event.state() == ShortcutState::Pressed { + tray::toggle_popover(app); + } + }) + .build(), + ) .manage(state.clone()) .invoke_handler(tauri::generate_handler![ get_graph, refresh_graph, quit_app, + hide_popover, push_dialog_shield, pop_dialog_shield, set_animate_tray, @@ -331,6 +355,12 @@ pub fn run() { } let handle = app.handle().clone(); tray::setup(&handle)?; + { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + if let Err(e) = app.global_shortcut().register(toggle_shortcut) { + log::warn!("global shortcut register failed: {}", e); + } + } #[cfg(target_os = "macos")] if let Err(e) = native_tray::init() { log::warn!( diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 068ea79..ee3295e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -81,7 +81,7 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { if let Some(w) = app.get_webview_window("main") { let visible = w.is_visible().unwrap_or(false); if visible { - let _ = w.hide(); + hide_popover(app); } else { let _ = position_window_under_tray(tray, &w); let _ = w.show(); @@ -95,6 +95,39 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { Ok(()) } +/// Hide the popover and hand keyboard focus back to the app that was in front +/// before it opened. Plain `w.hide()` (orderOut) leaves Tokcat the active +/// accessory app with no window, so focus lands nowhere; `app.hide()` (NSApp +/// hide) deactivates Tokcat and reactivates the previously-frontmost app. The +/// `w.hide()` runs first so the toggle's `is_visible()` check is reliable +/// regardless of how NSApp hide reports window visibility. Used by every +/// explicit dismiss (Ctrl+Cmd+T, tray-click toggle, ⌘W, Esc) but not the +/// blur-hide, where focus has already moved to whatever stole it. +pub fn hide_popover(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.hide(); + } + #[cfg(target_os = "macos")] + let _ = app.hide(); +} + +/// Show the popover under the tray if hidden, hide it if visible. Mirrors the +/// left-click tray toggle so the global shortcut (Ctrl+Cmd+T) behaves the same. +pub fn toggle_popover(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + if w.is_visible().unwrap_or(false) { + hide_popover(app); + } else { + if let Some(tray) = app.tray_by_id("main-tray") { + let _ = position_window_under_tray(&tray, &w); + } + let _ = w.show(); + let _ = w.set_focus(); + let _ = app.emit("popover-shown", ()); + } + } +} + fn show_popover(app: &AppHandle) { if let Some(w) = app.get_webview_window("main") { if let Some(tray) = app.tray_by_id("main-tray") { From b4813f7f9424f004b96418e254dc87e582575936 Mon Sep 17 00:00:00 2001 From: handlecusion Date: Wed, 3 Jun 2026 14:35:39 +0900 Subject: [PATCH 2/3] Add in-popover keyboard shortcuts and their UI affordances Cmd-only, cmd-exclusive shortcuts for settings, refresh, direct/prev/next tab switching, the 2D/3D toggle, and update checks. Plus three affordances: a translucent hint pin on each control while Cmd is held, a spinning refresh icon while a manual refresh is in flight, and Cmd+W (like Esc) closing an open settings/about modal before it falls through to hiding the popover. --- src/App.tsx | 122 ++++++++++++++++++++++++++++- src/components/DashboardTabs.tsx | 11 ++- src/components/HeaderBar.tsx | 8 +- src/components/UsageBarGraph2D.tsx | 3 + src/styles.css | 59 ++++++++++++++ 5 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bb17dfb..341de41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,12 @@ export default function App() { const [aboutOpen, setAboutOpen] = useState(false) const [appVersion, setAppVersion] = useState('') + // True while the Cmd key is held — drives the translucent shortcut-hint pins + // overlaid on each actionable control (⌘R, ⌘,, ⌘1…, ⌘G). + const [cmdHeld, setCmdHeld] = useState(false) + // True while a manual refresh (button / ⌘R / tray) is in flight — spins the + // header refresh icon so the fetch is visible even when it returns instantly. + const [refreshing, setRefreshing] = useState(false) useEffect(() => { saveSettings(settings) @@ -150,16 +156,25 @@ export default function App() { return () => window.clearInterval(id) }, []) - // Manual refresh from tray menu — bypasses cache. + // Manual refresh from the header button, ⌘R, or the tray menu — bypasses + // cache. Drives `refreshing` for the whole fetch so the header icon spins; + // refresh_graph holds a ~450ms floor, so the spin is always visible. useEffect(() => { if (refreshTick === 0) return - if (!isTauri()) return + setRefreshing(true) + if (!isTauri()) { + const id = window.setTimeout(() => setRefreshing(false), 600) + return () => window.clearTimeout(id) + } + let done = false ;(async () => { try { const { invoke } = await import('@tauri-apps/api/core') await invoke('refresh_graph', { year }) } catch {} + if (!done) setRefreshing(false) })() + return () => { done = true } }, [refreshTick, year]) const allYears = useMemo(() => { @@ -185,6 +200,103 @@ export default function App() { if (!dashboardClients.includes(activeTab)) setActiveTab('overview') }, [activeTab, dashboardClients]) + // Internal keyboard shortcuts. The global Ctrl+Cmd+T popover toggle is + // registered natively in Rust; everything here is cmd-only and cmd-exclusive + // (ctrl/alt/shift held → ignored) so it never collides with that global key. + useEffect(() => { + const tabs = ['overview', ...dashboardClients] + + async function hidePopover() { + if (!isTauri()) return + try { + const { invoke } = await import('@tauri-apps/api/core') + await invoke('hide_popover') + } catch {} + } + + const onKeyDown = (e: KeyboardEvent) => { + // Esc closes the top-most modal first, else hides the popover. Skipped + // when a form control is focused so Esc can dismiss its native dropdown. + if (e.key === 'Escape') { + const tag = document.activeElement?.tagName + if (tag === 'SELECT' || tag === 'INPUT' || tag === 'TEXTAREA') return + if (settingsOpen) { setSettingsOpen(false); e.preventDefault(); return } + if (aboutOpen) { setAboutOpen(false); e.preventDefault(); return } + void hidePopover() + e.preventDefault() + return + } + + if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return + const k = e.key.toLowerCase() + + if (k >= '1' && k <= '9') { + const idx = Number(k) - 1 + if (idx < tabs.length) { setActiveTab(tabs[idx]); e.preventDefault() } + return + } + + switch (k) { + case ',': + setSettingsOpen(true); e.preventDefault(); break + case 'r': + setRefreshTick(t => t + 1); e.preventDefault(); break + case 'w': + // Mirror Esc: close the top-most modal first, only hide the popover + // when nothing is open over it. + if (settingsOpen) setSettingsOpen(false) + else if (aboutOpen) setAboutOpen(false) + else void hidePopover() + e.preventDefault(); break + case 'q': + if (isTauri()) { + ;(async () => { + try { + const { invoke } = await import('@tauri-apps/api/core') + await invoke('quit_app') + } catch {} + })() + } + e.preventDefault(); break + case 'g': + setUsageView(v => (v === '2d' ? '3d' : '2d')); e.preventDefault(); break + case 'u': + if (isTauri()) void checkForUpdatesInteractive() + e.preventDefault(); break + case '[': { + const i = tabs.indexOf(activeTab) + setActiveTab(tabs[(i - 1 + tabs.length) % tabs.length]); e.preventDefault(); break + } + case ']': { + const i = tabs.indexOf(activeTab) + setActiveTab(tabs[(i + 1) % tabs.length]); e.preventDefault(); break + } + } + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [dashboardClients, activeTab, settingsOpen, aboutOpen]) + + // Track the Cmd key for the shortcut-hint overlay. keyup can be missed if the + // app loses focus mid-hold (e.g. Cmd+Tab), so blur/visibilitychange force the + // pins off — otherwise they'd stay stuck after switching away. + useEffect(() => { + const onDown = (e: KeyboardEvent) => { if (e.key === 'Meta') setCmdHeld(true) } + const onUp = (e: KeyboardEvent) => { if (e.key === 'Meta') setCmdHeld(false) } + const reset = () => setCmdHeld(false) + window.addEventListener('keydown', onDown) + window.addEventListener('keyup', onUp) + window.addEventListener('blur', reset) + document.addEventListener('visibilitychange', reset) + return () => { + window.removeEventListener('keydown', onDown) + window.removeEventListener('keyup', onUp) + window.removeEventListener('blur', reset) + document.removeEventListener('visibilitychange', reset) + } + }, []) + const overviewClientSet = useMemo(() => new Set(presentClients), [presentClients]) const activeClientIds = useMemo( () => (activeTab === 'overview' ? presentClients : [activeTab]), @@ -357,8 +469,10 @@ export default function App() { onThemeChange={(t) => setTheme(t as ThemeName)} onRefresh={() => setRefreshTick(t => t + 1)} onOpenSettings={() => setSettingsOpen(true)} + kbdHints={cmdHeld} + refreshing={refreshing} /> - + {activeTab === 'overview' ? (
diff --git a/src/components/DashboardTabs.tsx b/src/components/DashboardTabs.tsx index 83ed0a2..d8b7f2d 100644 --- a/src/components/DashboardTabs.tsx +++ b/src/components/DashboardTabs.tsx @@ -5,6 +5,7 @@ interface Props { clients: string[] active: string onChange: (tab: string) => void + kbdHints?: boolean } function ClientMark({ id }: { id: string }) { @@ -26,7 +27,10 @@ function ClientMark({ id }: { id: string }) { ) } -export function DashboardTabs({ clients, active, onChange }: Props) { +export function DashboardTabs({ clients, active, onChange, kbdHints }: Props) { + // ⌘1 = Overview, ⌘2… = clients, matching the keyboard handler in App. Only + // the first nine tabs get a hint since ⌘0 is unbound. + const hint = (idx: number) => (kbdHints && idx < 9 ? `⌘${idx + 1}` : null) return (
- {clients.map(id => { + {clients.map((id, i) => { const style = getClientStyle(id) const isActive = active === id + const h = hint(i + 1) return ( ) })} diff --git a/src/components/HeaderBar.tsx b/src/components/HeaderBar.tsx index db1724c..505953f 100644 --- a/src/components/HeaderBar.tsx +++ b/src/components/HeaderBar.tsx @@ -11,9 +11,11 @@ interface Props { onThemeChange: (t: string) => void onRefresh?: () => void onOpenSettings?: () => void + kbdHints?: boolean + refreshing?: boolean } -export function HeaderBar({ totalTokens, year, years, onYearChange, theme, onThemeChange, onRefresh, onOpenSettings }: Props) { +export function HeaderBar({ totalTokens, year, years, onYearChange, theme, onThemeChange, onRefresh, onOpenSettings, kbdHints, refreshing }: Props) { return (
@@ -36,12 +38,13 @@ export function HeaderBar({ totalTokens, year, years, onYearChange, theme, onThe {onRefresh && ( )} {onOpenSettings && ( @@ -50,6 +53,7 @@ export function HeaderBar({ totalTokens, year, years, onYearChange, theme, onThe + {kbdHints && } )}
diff --git a/src/components/UsageBarGraph2D.tsx b/src/components/UsageBarGraph2D.tsx index f72e89a..3ee59fb 100644 --- a/src/components/UsageBarGraph2D.tsx +++ b/src/components/UsageBarGraph2D.tsx @@ -21,6 +21,7 @@ interface Props { accent: string /** When provided, the card leads with these token-usage totals (shown in both 2D and 3D). */ stats?: Stats + kbdHints?: boolean } interface Segment { @@ -95,6 +96,7 @@ export function UsageBarGraph2D({ graphDark, accent, stats, + kbdHints, }: Props) { const [hover, setHover] = useState(null) const headSubtitle = stats && view === '3d' ? 'Full year' : subtitle @@ -160,6 +162,7 @@ export function UsageBarGraph2D({
+ {kbdHints && }