packages feed

taffybar-7.2.6: src/System/Taffybar/Widget/AnthropicUsage.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Widgets for displaying Claude Code subscription usage.
module System.Taffybar.Widget.AnthropicUsage
  ( AnthropicUsageDisplayMode (..),
    AnthropicUsageDisplayModeState,
    AnthropicUsageLabelConfig (..),
    AnthropicUsageStackConfig (..),
    AnthropicUsageWindowSelector (..),
    defaultAnthropicUsageLabelConfig,
    defaultAnthropicUsageStackConfig,
    anthropicUsageLabelNew,
    anthropicUsageLabelNewWith,
    anthropicUsageFiveHourWindowLabelNew,
    anthropicUsageWeeklyWindowLabelNew,
    anthropicUsageSectionNewWith,
    anthropicUsageStackNew,
    anthropicUsageStackNewWith,
    anthropicUsageNew,
    anthropicUsageNewWith,
    formatAnthropicUsageWindowLabel,
    formatAnthropicUsageSummaryLabel,
  )
where

import Control.Concurrent (ThreadId, forkIO, killThread)
import Control.Concurrent.MVar (MVar, newMVar, readMVar, swapMVar)
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TChan (TChan, dupTChan, newBroadcastTChanIO, readTChan, writeTChan)
import Control.Monad (forM_, forever, void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Reader (ask, runReaderT)
import Data.Maybe (catMaybes)
import qualified Data.Text as T
import Data.Time.Clock
import qualified GI.GLib as GLib
import qualified GI.Gtk as Gtk
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.Information.AnthropicUsage
import System.Taffybar.Util (postGUIASync)
import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)
import Text.Printf (printf)

data AnthropicUsageDisplayMode
  = AnthropicUsageDisplayUsed
  | AnthropicUsageDisplayRemaining
  deriving (Eq, Show)

newtype AnthropicUsageDisplayModeState
  = AnthropicUsageDisplayModeState
      (MVar AnthropicUsageDisplayMode, TChan AnthropicUsageDisplayMode)

data AnthropicUsageLabelParts = AnthropicUsageLabelParts
  { anthropicUsageLabelWidget :: Gtk.Widget,
    anthropicUsageLabel :: Gtk.Label,
    anthropicUsageLabelDisplayState :: AnthropicUsageDisplayModeState,
    anthropicUsageLabelSnapshotVar :: MVar AnthropicUsageSnapshot,
    anthropicUsageLabelUsageChan :: TChan AnthropicUsageSnapshot,
    anthropicUsageLabelRefreshNow :: IO ()
  }

data AnthropicUsageLabelConfig = AnthropicUsageLabelConfig
  { anthropicUsageLabelInfoConfig :: AnthropicUsageConfig,
    anthropicUsageLabelDefaultDisplayMode :: AnthropicUsageDisplayMode,
    anthropicUsageLabelFallbackText :: T.Text,
    anthropicUsageLabelClass :: T.Text,
    anthropicUsageLabelFormatter :: AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text,
    anthropicUsageLabelUnavailableFormatter :: T.Text -> T.Text,
    anthropicUsageLabelTooltipFormatter :: AnthropicUsageDisplayMode -> AnthropicUsageSnapshot -> Maybe T.Text
  }

data AnthropicUsageWindowSelector
  = AnthropicUsageFiveHourWindow
  | AnthropicUsageWeeklyWindow
  | -- | Per-model weekly limit (currently Fable). Absent for plans without one,
    -- in which case the label renders empty.
    AnthropicUsageScopedWeeklyWindow
  deriving (Eq, Show)

data AnthropicUsageStackConfig = AnthropicUsageStackConfig
  { anthropicUsageStackInfoConfig :: AnthropicUsageConfig,
    anthropicUsageStackDefaultDisplayMode :: AnthropicUsageDisplayMode,
    anthropicUsageStackFallbackText :: T.Text
  }

defaultAnthropicUsageLabelConfig :: AnthropicUsageLabelConfig
defaultAnthropicUsageLabelConfig =
  AnthropicUsageLabelConfig
    { anthropicUsageLabelInfoConfig = defaultAnthropicUsageConfig,
      anthropicUsageLabelDefaultDisplayMode = AnthropicUsageDisplayUsed,
      anthropicUsageLabelFallbackText = "Claude n/a",
      anthropicUsageLabelClass = "anthropic-usage-label",
      anthropicUsageLabelFormatter = formatAnthropicUsageSummaryLabel,
      anthropicUsageLabelUnavailableFormatter = const "Claude n/a",
      anthropicUsageLabelTooltipFormatter = formatAnthropicUsageTooltip
    }

defaultAnthropicUsageStackConfig :: AnthropicUsageStackConfig
defaultAnthropicUsageStackConfig =
  AnthropicUsageStackConfig
    { anthropicUsageStackInfoConfig = defaultAnthropicUsageConfig,
      anthropicUsageStackDefaultDisplayMode = AnthropicUsageDisplayUsed,
      anthropicUsageStackFallbackText = "n/a"
    }

anthropicUsageNew :: TaffyIO Gtk.Widget
anthropicUsageNew = anthropicUsageLabelNew

anthropicUsageNewWith :: AnthropicUsageLabelConfig -> TaffyIO Gtk.Widget
anthropicUsageNewWith = anthropicUsageLabelNewWith

anthropicUsageLabelNew :: TaffyIO Gtk.Widget
anthropicUsageLabelNew = anthropicUsageLabelNewWith defaultAnthropicUsageLabelConfig

anthropicUsageFiveHourWindowLabelNew :: TaffyIO Gtk.Widget
anthropicUsageFiveHourWindowLabelNew =
  anthropicUsageLabelNewWith $
    windowLabelConfig AnthropicUsageFiveHourWindow defaultAnthropicUsageStackConfig

anthropicUsageWeeklyWindowLabelNew :: TaffyIO Gtk.Widget
anthropicUsageWeeklyWindowLabelNew =
  anthropicUsageLabelNewWith $
    windowLabelConfig AnthropicUsageWeeklyWindow defaultAnthropicUsageStackConfig

anthropicUsageStackNew :: TaffyIO Gtk.Widget
anthropicUsageStackNew = anthropicUsageStackNewWith defaultAnthropicUsageStackConfig

anthropicUsageStackNewWith :: AnthropicUsageStackConfig -> TaffyIO Gtk.Widget
anthropicUsageStackNewWith config = do
  (stack, fiveHourParts) <- anthropicUsageStackPartsNewWith config
  liftIO $
    wrapAnthropicUsageMenu
      "anthropic-usage"
      stack
      (windowLabelConfig AnthropicUsageFiveHourWindow config)
      fiveHourParts

anthropicUsageSectionNewWith :: Gtk.Widget -> AnthropicUsageStackConfig -> TaffyIO Gtk.Widget
anthropicUsageSectionNewWith iconWidget config = do
  (stack, fiveHourParts) <- anthropicUsageStackPartsNewWith config
  liftIO $ do
    section <- buildIconLabelBox iconWidget stack
    _ <- widgetSetClassGI section "usage-section"
    wrapAnthropicUsageMenu
      "anthropic-usage"
      section
      (windowLabelConfig AnthropicUsageFiveHourWindow config)
      fiveHourParts

anthropicUsageStackPartsNewWith :: AnthropicUsageStackConfig -> TaffyIO (Gtk.Widget, AnthropicUsageLabelParts)
anthropicUsageStackPartsNewWith config = do
  fiveHourParts <- anthropicUsageLabelPartsNewWith $ windowLabelConfig AnthropicUsageFiveHourWindow config
  weeklyParts <- anthropicUsageLabelPartsNewWith $ windowLabelConfig AnthropicUsageWeeklyWindow config
  liftIO $ do
    box <- Gtk.boxNew Gtk.OrientationVertical 0
    _ <- widgetSetClassGI box "anthropic-usage-stack"
    Gtk.boxPackStart box (anthropicUsageLabelWidget fiveHourParts) False False 0
    Gtk.boxPackStart box (anthropicUsageLabelWidget weeklyParts) False False 0
    Gtk.widgetShowAll box
    widget <- Gtk.toWidget box
    return (widget, fiveHourParts)

windowLabelConfig :: AnthropicUsageWindowSelector -> AnthropicUsageStackConfig -> AnthropicUsageLabelConfig
windowLabelConfig selector stackConfig =
  defaultAnthropicUsageLabelConfig
    { anthropicUsageLabelInfoConfig = anthropicUsageStackInfoConfig stackConfig,
      anthropicUsageLabelDefaultDisplayMode = anthropicUsageStackDefaultDisplayMode stackConfig,
      anthropicUsageLabelFallbackText = anthropicUsageStackFallbackText stackConfig,
      anthropicUsageLabelClass = "anthropic-usage-window-label",
      anthropicUsageLabelFormatter = formatAnthropicUsageWindowLabel selector,
      anthropicUsageLabelUnavailableFormatter = const (anthropicUsageStackFallbackText stackConfig)
    }

anthropicUsageLabelNewWith :: AnthropicUsageLabelConfig -> TaffyIO Gtk.Widget
anthropicUsageLabelNewWith config = do
  parts <- anthropicUsageLabelPartsNewWith config
  liftIO $ wrapAnthropicUsageMenu "anthropic-usage" (anthropicUsageLabelWidget parts) config parts

anthropicUsageLabelPartsNewWith :: AnthropicUsageLabelConfig -> TaffyIO AnthropicUsageLabelParts
anthropicUsageLabelPartsNewWith config = do
  let infoConfig = anthropicUsageLabelInfoConfig config
  usageChan <- getAnthropicUsageChan infoConfig
  initialSnapshot <- getAnthropicUsageState infoConfig
  displayState <- getAnthropicUsageDisplayModeState (anthropicUsageLabelDefaultDisplayMode config)
  ctx <- ask

  liftIO $ do
    label <- Gtk.labelNew (Just (anthropicUsageLabelFallbackText config))
    snapshotVar <- newMVar initialSnapshot
    let refreshNow = runReaderT (forceAnthropicUsageRefresh infoConfig) ctx
    -- Visibility is driven by 'updateLabelFromState': a formatter returning
    -- empty text (e.g. the scoped per-model window when the plan has none)
    -- hides the label entirely instead of leaving an empty row, and
    -- no-show-all keeps ancestor 'widgetShowAll' calls from re-showing it.
    Gtk.widgetSetNoShowAll label True
    _ <- widgetSetClassGI label (anthropicUsageLabelClass config)
    updateLabelFromState config label displayState initialSnapshot

    void $ Gtk.onWidgetRealize label $ do
      -- The usage listener only runs while the label is realized (e.g. the
      -- visible page of a GtkStack), so broadcasts that arrived while the
      -- widget was hidden were missed. Re-sync from the shared state before
      -- listening so the label does not show a stale snapshot.
      currentSnapshot <- runReaderT (getAnthropicUsageState infoConfig) ctx
      void $ swapMVar snapshotVar currentSnapshot
      updateLabelFromState config label displayState currentSnapshot
      usageThread <- forkUsageListener config label displayState snapshotVar usageChan
      modeThread <- forkDisplayModeListener config label displayState snapshotVar
      void $ Gtk.onWidgetUnrealize label $ do
        killThread usageThread
        killThread modeThread

    Gtk.widgetShowAll label
    widget <- Gtk.toWidget label
    return $
      AnthropicUsageLabelParts
        { anthropicUsageLabelWidget = widget,
          anthropicUsageLabel = label,
          anthropicUsageLabelDisplayState = displayState,
          anthropicUsageLabelSnapshotVar = snapshotVar,
          anthropicUsageLabelUsageChan = usageChan,
          anthropicUsageLabelRefreshNow = refreshNow
        }

wrapAnthropicUsageMenu ::
  T.Text ->
  Gtk.Widget ->
  AnthropicUsageLabelConfig ->
  AnthropicUsageLabelParts ->
  IO Gtk.Widget
wrapAnthropicUsageMenu klass child config parts = do
  ebox <- Gtk.eventBoxNew
  _ <- widgetSetClassGI ebox klass
  Gtk.containerAdd ebox child
  void $
    Gtk.onWidgetButtonPressEvent ebox $ \_event -> do
      showUsageMenu
        ebox
        config
        (anthropicUsageLabel parts)
        (anthropicUsageLabelDisplayState parts)
        (anthropicUsageLabelSnapshotVar parts)
        (anthropicUsageLabelUsageChan parts)
        (anthropicUsageLabelRefreshNow parts)
      return True
  Gtk.widgetShowAll ebox
  Gtk.toWidget ebox

getAnthropicUsageDisplayModeState :: AnthropicUsageDisplayMode -> TaffyIO AnthropicUsageDisplayModeState
getAnthropicUsageDisplayModeState defaultMode =
  getStateDefault $ liftIO $ do
    modeVar <- newMVar defaultMode
    modeChan <- newBroadcastTChanIO
    return $ AnthropicUsageDisplayModeState (modeVar, modeChan)

forkUsageListener ::
  AnthropicUsageLabelConfig ->
  Gtk.Label ->
  AnthropicUsageDisplayModeState ->
  MVar AnthropicUsageSnapshot ->
  TChan AnthropicUsageSnapshot ->
  IO ThreadId
forkUsageListener config label displayState snapshotVar usageChan = do
  ourUsageChan <- atomically $ dupTChan usageChan
  forkIO $
    forever $ do
      snapshot <- atomically $ readTChan ourUsageChan
      void $ swapMVar snapshotVar snapshot
      updateLabelFromState config label displayState snapshot

forkDisplayModeListener ::
  AnthropicUsageLabelConfig ->
  Gtk.Label ->
  AnthropicUsageDisplayModeState ->
  MVar AnthropicUsageSnapshot ->
  IO ThreadId
forkDisplayModeListener config label displayState@(AnthropicUsageDisplayModeState (_, modeChan)) snapshotVar = do
  ourModeChan <- atomically $ dupTChan modeChan
  forkIO $
    forever $ do
      _ <- atomically $ readTChan ourModeChan
      snapshot <- readMVar snapshotVar
      updateLabelFromState config label displayState snapshot

updateLabelFromState ::
  AnthropicUsageLabelConfig ->
  Gtk.Label ->
  AnthropicUsageDisplayModeState ->
  AnthropicUsageSnapshot ->
  IO ()
updateLabelFromState config label displayState snapshot = do
  displayMode <- readDisplayMode displayState
  let labelText = formatSnapshotLabel config displayMode snapshot
      tooltipText = anthropicUsageLabelTooltipFormatter config displayMode snapshot
  postGUIASync $ do
    Gtk.labelSetText label labelText
    Gtk.widgetSetTooltipText label tooltipText
    Gtk.widgetSetVisible label (not (T.null labelText))

formatSnapshotLabel :: AnthropicUsageLabelConfig -> AnthropicUsageDisplayMode -> AnthropicUsageSnapshot -> T.Text
formatSnapshotLabel config mode snapshot =
  case snapshot of
    AnthropicUsageAvailable info -> anthropicUsageLabelFormatter config mode info
    AnthropicUsageUnavailable message -> anthropicUsageLabelUnavailableFormatter config message

readDisplayMode :: AnthropicUsageDisplayModeState -> IO AnthropicUsageDisplayMode
readDisplayMode (AnthropicUsageDisplayModeState (modeVar, _)) = readMVar modeVar

setDisplayMode :: AnthropicUsageDisplayModeState -> AnthropicUsageDisplayMode -> IO ()
setDisplayMode (AnthropicUsageDisplayModeState (modeVar, modeChan)) mode = do
  void $ swapMVar modeVar mode
  atomically $ writeTChan modeChan mode

toggleDisplayMode :: AnthropicUsageDisplayMode -> AnthropicUsageDisplayMode
toggleDisplayMode AnthropicUsageDisplayUsed = AnthropicUsageDisplayRemaining
toggleDisplayMode AnthropicUsageDisplayRemaining = AnthropicUsageDisplayUsed

formatAnthropicUsageSummaryLabel :: AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text
formatAnthropicUsageSummaryLabel displayMode info =
  let windows =
        T.intercalate
          " "
          [ formatWindowLabel displayMode (anthropicUsageFiveHourWindow info),
            formatWeeklyWithScopedLabel displayMode info
          ]
      unavailable = anthropicUsageHasAvailableSubscription info == Just False
   in (if unavailable then "Claude ! " else "Claude ") <> windows

formatAnthropicUsageWindowLabel :: AnthropicUsageWindowSelector -> AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text
formatAnthropicUsageWindowLabel AnthropicUsageWeeklyWindow displayMode info =
  formatWeeklyWithScopedLabel displayMode info
formatAnthropicUsageWindowLabel selector displayMode info =
  maybe "" (formatWindowLabel displayMode) (selectedWindow selector info)

-- | The weekly label with the per-model weekly limit folded in when present,
-- e.g. @7d 65%·F45%r@, so the scoped window does not need its own row.
formatWeeklyWithScopedLabel :: AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text
formatWeeklyWithScopedLabel displayMode info =
  let weekly = anthropicUsageWeeklyWindow info
      scopedPart window =
        "·"
          <> T.toUpper (T.take 1 (anthropicUsageWindowName window))
          <> formatWindowValue displayMode window
   in anthropicUsageWindowName weekly
        <> " "
        <> formatWindowValue displayMode weekly
        <> maybe "" scopedPart (anthropicUsageScopedWeeklyWindow info)
        <> displayModeIndicator displayMode

formatAnthropicUsageTooltip :: AnthropicUsageDisplayMode -> AnthropicUsageSnapshot -> Maybe T.Text
formatAnthropicUsageTooltip displayMode snapshot =
  Just $
    case snapshot of
      AnthropicUsageUnavailable message -> "Unable to read Claude usage: " <> message
      AnthropicUsageAvailable info ->
        T.intercalate "\n" $
          catMaybes
            [ ("Plan: " <>) <$> anthropicUsageSubscriptionType info,
              ("Tier: " <>) <$> anthropicUsageRateLimitTier info,
              ("Organization: " <>) <$> anthropicUsageOrganizationName info,
              Just $ "Display: " <> displayModeText displayMode,
              Just $ "5h: " <> formatWindow displayMode (anthropicUsageGeneratedAt info) (anthropicUsageFiveHourWindow info),
              Just $ "7d: " <> formatWindow displayMode (anthropicUsageGeneratedAt info) (anthropicUsageWeeklyWindow info),
              ( \window ->
                  anthropicUsageWindowName window
                    <> ": "
                    <> formatWindow displayMode (anthropicUsageGeneratedAt info) window
              )
                <$> anthropicUsageScopedWeeklyWindow info,
              formatSubscriptionStatus info,
              ("Extra usage disabled: " <>) <$> anthropicUsageExtraUsageDisabledReason info
            ]

showUsageMenu ::
  Gtk.EventBox ->
  AnthropicUsageLabelConfig ->
  Gtk.Label ->
  AnthropicUsageDisplayModeState ->
  MVar AnthropicUsageSnapshot ->
  TChan AnthropicUsageSnapshot ->
  IO () ->
  IO ()
showUsageMenu anchor config label displayState snapshotVar usageChan refreshNow = do
  currentEvent <- Gtk.getCurrentEvent
  menu <- Gtk.menuNew
  Gtk.menuAttachToWidget menu anchor Nothing
  populateUsageMenu menu config label displayState snapshotVar refreshNow

  ourUsageChan <- atomically $ dupTChan usageChan
  menuThread <-
    forkIO $
      forever $ do
        snapshot <- atomically $ readTChan ourUsageChan
        void $ swapMVar snapshotVar snapshot
        postGUIASync $ populateUsageMenu menu config label displayState snapshotVar refreshNow

  void $
    Gtk.onWidgetHide menu $
      void $
        GLib.idleAdd GLib.PRIORITY_LOW $ do
          killThread menuThread
          Gtk.widgetDestroy menu
          return False

  Gtk.widgetShowAll menu
  Gtk.menuPopupAtPointer menu currentEvent
  void $ forkIO refreshNow

populateUsageMenu ::
  Gtk.Menu ->
  AnthropicUsageLabelConfig ->
  Gtk.Label ->
  AnthropicUsageDisplayModeState ->
  MVar AnthropicUsageSnapshot ->
  IO () ->
  IO ()
populateUsageMenu menu config label displayState snapshotVar refreshNow = do
  children <- Gtk.containerGetChildren menu
  forM_ children $ \item -> do
    Gtk.containerRemove menu item
    Gtk.widgetDestroy item
  snapshot <- readMVar snapshotVar
  displayMode <- readDisplayMode displayState
  appendInfoItem menu "Claude Code usage"
  appendSnapshotMenuItems menu displayMode snapshot

  appendSeparator menu
  toggleItem <-
    Gtk.menuItemNewWithLabel $
      "Display: "
        <> displayModeText displayMode
        <> " (toggle)"
  void $
    Gtk.onMenuItemActivate toggleItem $ do
      setDisplayMode displayState (toggleDisplayMode displayMode)
      readMVar snapshotVar >>= updateLabelFromState config label displayState
      populateUsageMenu menu config label displayState snapshotVar refreshNow
  Gtk.menuShellAppend menu toggleItem

  refreshItem <- Gtk.menuItemNewWithLabel ("Refresh" :: T.Text)
  void $ Gtk.onMenuItemActivate refreshItem $ void $ forkIO refreshNow
  Gtk.menuShellAppend menu refreshItem

  Gtk.widgetShowAll menu

appendInfoItem :: Gtk.Menu -> T.Text -> IO ()
appendInfoItem menu text = do
  item <- Gtk.menuItemNewWithLabel text
  Gtk.widgetSetSensitive item False
  Gtk.menuShellAppend menu item

appendSeparator :: Gtk.Menu -> IO ()
appendSeparator menu = do
  sep <- Gtk.separatorMenuItemNew
  Gtk.menuShellAppend menu sep

appendSnapshotMenuItems :: Gtk.Menu -> AnthropicUsageDisplayMode -> AnthropicUsageSnapshot -> IO ()
appendSnapshotMenuItems menu _ (AnthropicUsageUnavailable message) =
  appendMenuSection menu "Status" ["Unavailable: " <> message]
appendSnapshotMenuItems menu displayMode (AnthropicUsageAvailable info) = do
  appendMenuSection
    menu
    "Account"
    ( catMaybes
        [ ("Plan: " <>) <$> anthropicUsageSubscriptionType info,
          ("Tier: " <>) <$> anthropicUsageRateLimitTier info,
          ("Organization: " <>) <$> anthropicUsageOrganizationName info,
          formatSubscriptionStatus info,
          Just $ "Display: " <> displayModeText displayMode
        ]
    )
  appendMenuSection
    menu
    "5h Window"
    (formatWindowMenuLines displayMode (anthropicUsageGeneratedAt info) (anthropicUsageFiveHourWindow info))
  appendMenuSection
    menu
    "7d Window"
    (formatWindowMenuLines displayMode (anthropicUsageGeneratedAt info) (anthropicUsageWeeklyWindow info))
  forM_ (anthropicUsageScopedWeeklyWindow info) $ \window ->
    appendMenuSection
      menu
      (anthropicUsageWindowName window <> " Window")
      (formatWindowMenuLines displayMode (anthropicUsageGeneratedAt info) window)
  appendMenuSection
    menu
    "Extra Usage"
    (catMaybes [("Disabled: " <>) <$> anthropicUsageExtraUsageDisabledReason info])

appendMenuSection :: Gtk.Menu -> T.Text -> [T.Text] -> IO ()
appendMenuSection _ _ [] = return ()
appendMenuSection menu title items = do
  appendSeparator menu
  appendInfoItem menu title
  forM_ items (appendInfoItem menu)

formatWindowMenuLines :: AnthropicUsageDisplayMode -> UTCTime -> AnthropicUsageWindow -> [T.Text]
formatWindowMenuLines displayMode generatedAt window =
  [ "Tokens: " <> formatTotalTokens (anthropicUsageWindowTotals window),
    "Requests: " <> T.pack (show $ anthropicUsageRequestCount $ anthropicUsageWindowTotals window),
    "Input: " <> formatCount (anthropicUsageInputTokens totals),
    "Cache write: " <> formatCount (anthropicUsageCacheCreationTokens totals),
    "Cache read: " <> formatCount (anthropicUsageCacheReadTokens totals),
    "Output: " <> formatCount (anthropicUsageOutputTokens totals),
    "Window: " <> formatDuration (round $ diffUTCTime (anthropicUsageWindowEnd window) (anthropicUsageWindowStart window)),
    "Resets in: " <> formatDuration (max 0 $ round $ diffUTCTime (anthropicUsageWindowEnd window) generatedAt),
    "Display: " <> formatWindowValue displayMode window
  ]
    <> catMaybes
      [ ("Budget: " <>) . formatCount <$> anthropicUsageWindowBudgetTokens window,
        ("Estimated cost: " <>) . formatCurrency <$> anthropicUsageEstimatedCostUSD totals
      ]
  where
    totals = anthropicUsageWindowTotals window

formatSubscriptionStatus :: AnthropicUsageInfo -> Maybe T.Text
formatSubscriptionStatus info =
  case anthropicUsageHasAvailableSubscription info of
    Nothing -> Nothing
    Just True -> Just "Subscription: available"
    Just False -> Just "Subscription: unavailable"

formatWindow :: AnthropicUsageDisplayMode -> UTCTime -> AnthropicUsageWindow -> T.Text
formatWindow displayMode generatedAt window =
  anthropicUsageWindowName window
    <> " "
    <> formatWindowValue displayMode window
    <> ", "
    <> T.pack (show $ anthropicUsageRequestCount $ anthropicUsageWindowTotals window)
    <> " req"
    <> ", resets in "
    <> formatDuration (max 0 $ round $ diffUTCTime (anthropicUsageWindowEnd window) generatedAt)

formatWindowLabel :: AnthropicUsageDisplayMode -> AnthropicUsageWindow -> T.Text
formatWindowLabel displayMode window =
  anthropicUsageWindowName window
    <> " "
    <> formatWindowValueWithIndicator displayMode window

formatWindowValue :: AnthropicUsageDisplayMode -> AnthropicUsageWindow -> T.Text
formatWindowValue displayMode window =
  case anthropicUsageWindowUtilizationPercent window of
    Just utilization -> T.pack $ printf "%d%%" $ utilizationPercent displayMode utilization
    Nothing ->
      case anthropicUsageWindowBudgetTokens window of
        Nothing -> formatCount $ displayTokens displayMode window
        Just budget -> T.pack $ printf "%d%%" $ displayPercent displayMode budget window

utilizationPercent :: AnthropicUsageDisplayMode -> Double -> Int
utilizationPercent AnthropicUsageDisplayUsed utilization = round utilization
utilizationPercent AnthropicUsageDisplayRemaining utilization = max 0 (100 - round utilization)

formatWindowValueWithIndicator :: AnthropicUsageDisplayMode -> AnthropicUsageWindow -> T.Text
formatWindowValueWithIndicator displayMode window =
  formatWindowValue displayMode window <> displayModeIndicator displayMode

displayTokens :: AnthropicUsageDisplayMode -> AnthropicUsageWindow -> Int
displayTokens AnthropicUsageDisplayUsed = windowTotalTokens
displayTokens AnthropicUsageDisplayRemaining =
  \window ->
    case anthropicUsageWindowBudgetTokens window of
      Nothing -> windowTotalTokens window
      Just budget -> max 0 $ budget - windowTotalTokens window

displayPercent :: AnthropicUsageDisplayMode -> Int -> AnthropicUsageWindow -> Int
displayPercent displayMode budget window
  | budget <= 0 = 0
  | otherwise =
      let usedPercent = round $ (fromIntegral (windowTotalTokens window) / fromIntegral budget :: Double) * 100
       in case displayMode of
            AnthropicUsageDisplayUsed -> usedPercent
            AnthropicUsageDisplayRemaining -> max 0 (100 - usedPercent)

windowTotalTokens :: AnthropicUsageWindow -> Int
windowTotalTokens window =
  let totals = anthropicUsageWindowTotals window
   in anthropicUsageInputTokens totals
        + anthropicUsageCacheCreationTokens totals
        + anthropicUsageCacheReadTokens totals
        + anthropicUsageOutputTokens totals

formatTotalTokens :: AnthropicUsageTotals -> T.Text
formatTotalTokens totals =
  formatCount $
    anthropicUsageInputTokens totals
      + anthropicUsageCacheCreationTokens totals
      + anthropicUsageCacheReadTokens totals
      + anthropicUsageOutputTokens totals

displayModeText :: AnthropicUsageDisplayMode -> T.Text
displayModeText AnthropicUsageDisplayUsed = "used"
displayModeText AnthropicUsageDisplayRemaining = "remaining"

displayModeIndicator :: AnthropicUsageDisplayMode -> T.Text
displayModeIndicator AnthropicUsageDisplayUsed = "u"
displayModeIndicator AnthropicUsageDisplayRemaining = "r"

selectedWindow :: AnthropicUsageWindowSelector -> AnthropicUsageInfo -> Maybe AnthropicUsageWindow
selectedWindow AnthropicUsageFiveHourWindow = Just . anthropicUsageFiveHourWindow
selectedWindow AnthropicUsageWeeklyWindow = Just . anthropicUsageWeeklyWindow
selectedWindow AnthropicUsageScopedWeeklyWindow = anthropicUsageScopedWeeklyWindow

formatDuration :: Int -> T.Text
formatDuration seconds
  | seconds < 60 = T.pack $ printf "%ds" seconds
  | seconds < 3600 = T.pack $ printf "%dm" (seconds `div` 60)
  | seconds < 86400 = T.pack $ printf "%dh" (seconds `div` 3600)
  | otherwise = T.pack $ printf "%dd" (seconds `div` 86400)

formatCount :: Int -> T.Text
formatCount count
  | count >= 1_000_000 = T.pack $ printf "%.1fM" (fromIntegral count / 1_000_000 :: Double)
  | count >= 10_000 = T.pack $ printf "%dk" (count `div` 1_000)
  | count >= 1_000 = T.pack $ printf "%.1fk" (fromIntegral count / 1_000 :: Double)
  | otherwise = T.pack $ show count

formatCurrency :: Double -> T.Text
formatCurrency amount = T.pack $ printf "$%.2f" amount