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.CPUMonitor (cpuMonitorNew)
import System.Taffybar.Widget.Generic.Graph (GraphConfig (..), GraphDirection (..), GraphStyle (..), defaultGraphConfig) import System.Taffybar.Widget.Generic.Graph (GraphConfig (..), GraphDirection (..), GraphStyle (..), defaultGraphConfig)
import qualified System.Taffybar.Widget.NetworkManager as NetworkManager 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.SNIMenu (withNmAppletMenu)
import System.Taffybar.Widget.SNITray import System.Taffybar.Widget.SNITray
( CollapsibleSNITrayParams (..), ( CollapsibleSNITrayParams (..),
@@ -60,6 +54,7 @@ import System.Taffybar.Widget.Util
) )
import qualified System.Taffybar.Widget.Wlsunset as Wlsunset import qualified System.Taffybar.Widget.Wlsunset as Wlsunset
import qualified System.Taffybar.Widget.Workspaces as Workspaces import qualified System.Taffybar.Widget.Workspaces as Workspaces
import TaffybarConfig.AIUsage (aiUsageWidget)
import TaffybarConfig.Host (laptopHosts) import TaffybarConfig.Host (laptopHosts)
import TaffybarConfig.WidgetUtil import TaffybarConfig.WidgetUtil
( decorateWithClassAndBox, ( decorateWithClassAndBox,
@@ -375,16 +370,6 @@ usageSectionWidget klass iconFile tooltip stackBuilder =
section <- buildIconLabelBox iconWidget stack section <- buildIconLabelBox iconWidget stack
widgetSetClassGI section "usage-section" 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 :: Int
sniPriorityVisibilityThresholdDefault = 0 sniPriorityVisibilityThresholdDefault = 0
@@ -445,7 +430,7 @@ endWidgetsForHost hostName =
let baseEndWidgets = let baseEndWidgets =
[ sniTrayWidget, [ sniTrayWidget,
audioWidget, audioWidget,
openAIUsageWidget, aiUsageWidget,
cpuWidget, cpuWidget,
ramSwapWidget, ramSwapWidget,
diskUsageWidget, diskUsageWidget,
@@ -458,7 +443,7 @@ endWidgetsForHost hostName =
sniTrayWidget, sniTrayWidget,
asusDiskUsageWidget, asusDiskUsageWidget,
audioBacklightWidget, audioBacklightWidget,
openAIUsageWidget, aiUsageWidget,
cpuWidget, cpuWidget,
ramSwapWidget, ramSwapWidget,
sunLockWidget, sunLockWidget,

View File

@@ -13,7 +13,8 @@ cabal-version: >=1.10
executable taffybar executable taffybar
hs-source-dirs: . hs-source-dirs: .
main-is: taffybar.hs main-is: taffybar.hs
other-modules: TaffybarConfig.Config other-modules: TaffybarConfig.AIUsage
, TaffybarConfig.Config
, TaffybarConfig.ChromeFavicons , TaffybarConfig.ChromeFavicons
, TaffybarConfig.Host , TaffybarConfig.Host
, TaffybarConfig.Widgets , TaffybarConfig.Widgets
@@ -27,6 +28,7 @@ executable taffybar
, containers , containers
, directory , directory
, filepath , filepath
, fsnotify
, gi-gdk3 , gi-gdk3
, gi-gtk3 , gi-gtk3
, gi-gdkpixbuf , gi-gdkpixbuf