From 5eaaa3952716be0a9aba89ebceb4e5edecc77204 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 10 Jun 2026 23:06:21 -0700 Subject: [PATCH] taffybar: add AIUsage stack widget that switches on scratchpad state Reads $XDG_STATE_HOME/hypr/ai-scratchpad (written by the Super+Alt+C toggle) to show either the OpenAI or Anthropic usage section. Uses a Gtk.Stack for instant switching and fsnotify to watch the state file for live updates without polling. Co-Authored-By: Claude Sonnet 4.6 --- .../config/taffybar/TaffybarConfig/AIUsage.hs | 116 ++++++++++++++++++ .../config/taffybar/TaffybarConfig/Widgets.hs | 21 +--- .../config/taffybar/imalison-taffybar.cabal | 4 +- 3 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 dotfiles/config/taffybar/TaffybarConfig/AIUsage.hs diff --git a/dotfiles/config/taffybar/TaffybarConfig/AIUsage.hs b/dotfiles/config/taffybar/TaffybarConfig/AIUsage.hs new file mode 100644 index 00000000..4bdca789 --- /dev/null +++ b/dotfiles/config/taffybar/TaffybarConfig/AIUsage.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | A usage widget that follows the Hyprland AI scratchpad selection. +-- +-- The Hyprland config (scratchpads.lua) lets SUPER+ALT+C toggle whichever AI +-- app is currently selected via rofi_ai_scratchpad.sh, which records the +-- choice in $XDG_STATE_HOME/hypr/ai-scratchpad. This widget reads the same +-- state file and shows the matching provider's usage section (OpenAI for +-- "codex", Anthropic for "claude"), switching live when the file changes. +module TaffybarConfig.AIUsage + ( aiUsageWidget, + ) +where + +import Control.Exception (IOException, try) +import Control.Monad (void) +import Control.Monad.IO.Class (liftIO) +import Data.Text (Text) +import qualified Data.Text as T +import qualified GI.Gtk as Gtk +import qualified System.FSNotify as FSNotify +import System.Directory (createDirectoryIfMissing, getHomeDirectory) +import System.Environment (lookupEnv) +import System.FilePath (takeFileName, ()) +import System.Taffybar.Context (TaffyIO) +import System.Taffybar.Util (postGUIASync) +import System.Taffybar.Widget.AnthropicUsage + ( AnthropicUsageDisplayMode (AnthropicUsageDisplayRemaining), + AnthropicUsageStackConfig (..), + anthropicUsageSectionNewWith, + defaultAnthropicUsageStackConfig, + ) +import System.Taffybar.Widget.OpenAIUsage + ( OpenAIUsageDisplayMode (OpenAIUsageDisplayRemaining), + OpenAIUsageStackConfig (..), + defaultOpenAIUsageStackConfig, + openAIUsageSectionNewWith, + ) +import TaffybarConfig.WidgetUtil (decorateWithClassAndBox, usageLogoWidget) + +codexChild, claudeChild :: Text +codexChild = "codex" +claudeChild = "claude" + +aiScratchpadStateDir :: IO FilePath +aiScratchpadStateDir = do + stateHome <- lookupEnv "XDG_STATE_HOME" + base <- case stateHome of + Just dir | not (null dir) -> pure dir + _ -> ( ".local/state") <$> getHomeDirectory + pure (base "hypr") + +aiScratchpadStateFile :: FilePath +aiScratchpadStateFile = "ai-scratchpad" + +-- | Read the currently selected AI scratchpad, defaulting to codex like the +-- Hyprland side does. +readActiveAIScratchpad :: IO Text +readActiveAIScratchpad = do + dir <- aiScratchpadStateDir + result <- try (readFile (dir aiScratchpadStateFile)) :: IO (Either IOException String) + pure $ case result of + Right contents + | T.strip (T.pack contents) == claudeChild -> claudeChild + _ -> codexChild + +openAIUsageSection :: TaffyIO Gtk.Widget +openAIUsageSection = do + iconWidget <- liftIO $ usageLogoWidget "openai-symbol.svg" "OpenAI usage" + openAIUsageSectionNewWith + iconWidget + defaultOpenAIUsageStackConfig + { openAIUsageStackDefaultDisplayMode = OpenAIUsageDisplayRemaining + } + +anthropicUsageSection :: TaffyIO Gtk.Widget +anthropicUsageSection = do + iconWidget <- liftIO $ usageLogoWidget "claude-symbol.svg" "Claude usage" + anthropicUsageSectionNewWith + iconWidget + defaultAnthropicUsageStackConfig + { anthropicUsageStackDefaultDisplayMode = AnthropicUsageDisplayRemaining + } + +-- | Show usage for whichever AI app the Hyprland AI scratchpad currently +-- targets, switching live when the selection changes. +aiUsageWidget :: TaffyIO Gtk.Widget +aiUsageWidget = do + openAIWidget <- openAIUsageSection + anthropicWidget <- anthropicUsageSection + stackWidget <- liftIO $ do + stack <- Gtk.stackNew + Gtk.stackAddNamed stack openAIWidget codexChild + Gtk.stackAddNamed stack anthropicWidget claudeChild + readActiveAIScratchpad >>= Gtk.stackSetVisibleChildName stack + + let syncVisibleChild = + readActiveAIScratchpad + >>= \name -> postGUIASync (Gtk.stackSetVisibleChildName stack name) + + void $ Gtk.onWidgetRealize stack $ do + stateDir <- aiScratchpadStateDir + createDirectoryIfMissing True stateDir + manager <- FSNotify.startManager + void $ + FSNotify.watchDir + manager + stateDir + ((== aiScratchpadStateFile) . takeFileName . FSNotify.eventPath) + (const syncVisibleChild) + syncVisibleChild + void $ Gtk.onWidgetUnrealize stack $ FSNotify.stopManager manager + + Gtk.widgetShowAll stack + Gtk.toWidget stack + decorateWithClassAndBox "ai-usage" stackWidget diff --git a/dotfiles/config/taffybar/TaffybarConfig/Widgets.hs b/dotfiles/config/taffybar/TaffybarConfig/Widgets.hs index 7feafa48..50e70d4d 100644 --- a/dotfiles/config/taffybar/TaffybarConfig/Widgets.hs +++ b/dotfiles/config/taffybar/TaffybarConfig/Widgets.hs @@ -33,12 +33,6 @@ import qualified System.Taffybar.Widget.Audio as Audio import System.Taffybar.Widget.CPUMonitor (cpuMonitorNew) import System.Taffybar.Widget.Generic.Graph (GraphConfig (..), GraphDirection (..), GraphStyle (..), defaultGraphConfig) import qualified System.Taffybar.Widget.NetworkManager as NetworkManager -import System.Taffybar.Widget.OpenAIUsage - ( OpenAIUsageDisplayMode (OpenAIUsageDisplayRemaining), - OpenAIUsageStackConfig (..), - defaultOpenAIUsageStackConfig, - openAIUsageSectionNewWith, - ) import System.Taffybar.Widget.SNIMenu (withNmAppletMenu) import System.Taffybar.Widget.SNITray ( CollapsibleSNITrayParams (..), @@ -60,6 +54,7 @@ import System.Taffybar.Widget.Util ) import qualified System.Taffybar.Widget.Wlsunset as Wlsunset import qualified System.Taffybar.Widget.Workspaces as Workspaces +import TaffybarConfig.AIUsage (aiUsageWidget) import TaffybarConfig.Host (laptopHosts) import TaffybarConfig.WidgetUtil ( decorateWithClassAndBox, @@ -375,16 +370,6 @@ usageSectionWidget klass iconFile tooltip stackBuilder = section <- buildIconLabelBox iconWidget stack widgetSetClassGI section "usage-section" -openAIUsageWidget :: TaffyIO Gtk.Widget -openAIUsageWidget = do - iconWidget <- liftIO $ usageLogoWidget "openai-symbol.svg" "OpenAI usage" - decorateWithClassAndBoxM "openai-usage" $ - openAIUsageSectionNewWith - iconWidget - defaultOpenAIUsageStackConfig - { openAIUsageStackDefaultDisplayMode = OpenAIUsageDisplayRemaining - } - sniPriorityVisibilityThresholdDefault :: Int sniPriorityVisibilityThresholdDefault = 0 @@ -445,7 +430,7 @@ endWidgetsForHost hostName = let baseEndWidgets = [ sniTrayWidget, audioWidget, - openAIUsageWidget, + aiUsageWidget, cpuWidget, ramSwapWidget, diskUsageWidget, @@ -458,7 +443,7 @@ endWidgetsForHost hostName = sniTrayWidget, asusDiskUsageWidget, audioBacklightWidget, - openAIUsageWidget, + aiUsageWidget, cpuWidget, ramSwapWidget, sunLockWidget, diff --git a/dotfiles/config/taffybar/imalison-taffybar.cabal b/dotfiles/config/taffybar/imalison-taffybar.cabal index cedc2ed9..18f5de9e 100644 --- a/dotfiles/config/taffybar/imalison-taffybar.cabal +++ b/dotfiles/config/taffybar/imalison-taffybar.cabal @@ -13,7 +13,8 @@ cabal-version: >=1.10 executable taffybar hs-source-dirs: . main-is: taffybar.hs - other-modules: TaffybarConfig.Config + other-modules: TaffybarConfig.AIUsage + , TaffybarConfig.Config , TaffybarConfig.ChromeFavicons , TaffybarConfig.Host , TaffybarConfig.Widgets @@ -27,6 +28,7 @@ executable taffybar , containers , directory , filepath + , fsnotify , gi-gdk3 , gi-gtk3 , gi-gdkpixbuf