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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:06:21 -07:00
parent 1ec67b7892
commit 5eaaa39527
3 changed files with 122 additions and 19 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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