# Claudible Statusline Script (PowerShell 5.1+) # Displays context, balance, notifications, and ads in Claude Code statusline # Configuration is auto-populated by install script # Force UTF-8 output encoding for proper Unicode/Vietnamese support [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 # Configuration (set by installer) $ENDPOINT_URL = "https://claudible.io" $API_KEY = 'YOUR_KEY' # ANSI escape character (compatible with PS 5.1+) $ESC = [char]27 # Cache settings $CACHE_DIR = Join-Path $env:TEMP "claudible-statusline" $CACHE_FILE = Join-Path $CACHE_DIR "billing_cache.json" $MODELS_CACHE_FILE = Join-Path $CACHE_DIR "models_cache.json" $CACHE_TTL = 30 # seconds $MODELS_CACHE_TTL = 86400 # 24 hours for models/pricing $GIT_CACHE_TTL = 300 # 5 minutes for git branch # Ensure cache directory exists if (-not (Test-Path $CACHE_DIR)) { New-Item -ItemType Directory -Path $CACHE_DIR -Force | Out-Null } # Read JSON input from stdin $InputJson = "" try { if ([Console]::IsInputRedirected) { $InputJson = [Console]::In.ReadToEnd() } } catch { $InputJson = "" } # Parse context from input JSON function Parse-Context { param([string]$Json) $script:CWD = "" $script:GIT_BRANCH = "" $script:GIT_NUM_FILES = 0 $script:MODEL = "" $script:MODEL_ID = "" $script:INPUT_TOKENS = 0 $script:CONVERSATION_TOKENS = 0 $script:OUTPUT_TOKENS = 0 $script:MAX_TOKENS = 200000 $script:LINES_ADDED = 0 $script:LINES_REMOVED = 0 if ([string]::IsNullOrEmpty($Json)) { $script:CWD = (Get-Location).Path return } try { $data = ConvertFrom-Json $Json if ($data.cwd) { $script:CWD = $data.cwd } if ($data.gitNumStagedOrUnstagedFilesChanged) { $script:GIT_NUM_FILES = $data.gitNumStagedOrUnstagedFilesChanged } # Model is object with display_name and id if ($data.model -and $data.model.display_name) { $script:MODEL = $data.model.display_name } if ($data.model -and $data.model.id) { $script:MODEL_ID = $data.model.id } # Context tokens from current_usage $inputTokens = 0 $cacheCreation = 0 $cacheRead = 0 $outputTokens = 0 if ($data.context_window -and $data.context_window.current_usage) { $usage = $data.context_window.current_usage if ($usage.input_tokens) { $inputTokens = $usage.input_tokens } if ($usage.cache_creation_input_tokens) { $cacheCreation = $usage.cache_creation_input_tokens } if ($usage.cache_read_input_tokens) { $cacheRead = $usage.cache_read_input_tokens } if ($usage.output_tokens) { $outputTokens = $usage.output_tokens } } $script:CONVERSATION_TOKENS = $inputTokens + $cacheCreation + $cacheRead $script:INPUT_TOKENS = $inputTokens $script:OUTPUT_TOKENS = $outputTokens # Max tokens from context_window_size if ($data.context_window -and $data.context_window.context_window_size) { $script:MAX_TOKENS = $data.context_window.context_window_size } # Lines changed from cost object if ($data.cost) { if ($data.cost.total_lines_added) { $script:LINES_ADDED = [int]$data.cost.total_lines_added } if ($data.cost.total_lines_removed) { $script:LINES_REMOVED = [int]$data.cost.total_lines_removed } } } catch { $script:CWD = (Get-Location).Path } if ([string]::IsNullOrEmpty($script:CWD)) { $script:CWD = (Get-Location).Path } # Git branch - Claude Code doesn't send it, get ourselves (cached) try { if (-not [string]::IsNullOrEmpty($script:CWD) -and (Test-Path $script:CWD) -and (Get-Command git -ErrorAction SilentlyContinue)) { $prevLocation = Get-Location Set-Location $script:CWD $isGitRepo = git rev-parse --is-inside-work-tree 2>$null Set-Location $prevLocation if ($isGitRepo -eq "true") { $cwdHash = [System.BitConverter]::ToString( [System.Security.Cryptography.MD5]::Create().ComputeHash( [System.Text.Encoding]::UTF8.GetBytes($script:CWD) ) ).Replace("-", "").ToLower() $gitCacheFile = Join-Path $CACHE_DIR "git_branch_$cwdHash" if (Test-Path $gitCacheFile) { $cacheAge = ((Get-Date) - (Get-Item $gitCacheFile).LastWriteTime).TotalSeconds if ($cacheAge -lt $GIT_CACHE_TTL) { $script:GIT_BRANCH = (Get-Content $gitCacheFile -Raw -ErrorAction SilentlyContinue).Trim() } } if ([string]::IsNullOrEmpty($script:GIT_BRANCH)) { Set-Location $script:CWD $script:GIT_BRANCH = git branch --show-current 2>$null Set-Location $prevLocation if (-not [string]::IsNullOrEmpty($script:GIT_BRANCH)) { [System.IO.File]::WriteAllText($gitCacheFile, $script:GIT_BRANCH) } } } } } catch { $script:GIT_BRANCH = "" } } # Format model name for display function Format-Model { param([string]$Model) if ([string]::IsNullOrEmpty($Model)) { return "unknown" } # Match "Claude " or model id "claude--" if ($Model -match '(?i)(opus|sonnet|haiku)') { $tier = (Get-Culture).TextInfo.ToTitleCase($Matches[1].ToLower()) if ($Model -match '(\d+\.\d+)') { return "${tier}-$($Matches[1])" } if ($Model -match '(\d+)-(\d+)') { return "${tier}-$($Matches[1]).$($Matches[2])" } return $tier } # Fallback $shortName = $Model -replace "^claude-", "" -replace "-\d+$", "" if ([string]::IsNullOrEmpty($shortName)) { return "unknown" } return $shortName.Substring(0, [Math]::Min(12, $shortName.Length)) } # Shorten path for display function Shorten-Path { param([string]$Path) $home = $env:USERPROFILE if (-not [string]::IsNullOrEmpty($home)) { $result = $Path -replace [regex]::Escape($home), "~" } else { $result = $Path } # Normalize to forward slashes for display $result = $result -replace '\\', '/' if ($result.Length -gt 40) { $parts = $result -split '/' if ($parts.Count -ge 2) { $result = $parts[-2] + "/" + $parts[-1] } } return $result } # Get models/pricing data with caching function Get-ModelsPricing { # Check cache if (Test-Path $MODELS_CACHE_FILE) { $cacheAge = ((Get-Date) - (Get-Item $MODELS_CACHE_FILE).LastWriteTime).TotalSeconds if ($cacheAge -lt $MODELS_CACHE_TTL) { return Get-Content $MODELS_CACHE_FILE -Raw -Encoding UTF8 } } # Fetch fresh data if (-not [string]::IsNullOrEmpty($ENDPOINT_URL) -and $ENDPOINT_URL -ne "__" + "ENDPOINT_URL__" -and -not [string]::IsNullOrEmpty($API_KEY) -and $API_KEY -ne "__" + "API_KEY__") { try { $response = Invoke-RestMethod -Uri "$ENDPOINT_URL/v1/models" -Method Get ` -Headers @{ "x-api-key" = $API_KEY } -TimeoutSec 5 -ErrorAction Stop $jsonResponse = $response | ConvertTo-Json -Depth 10 [System.IO.File]::WriteAllText($MODELS_CACHE_FILE, $jsonResponse, [System.Text.Encoding]::UTF8) return $jsonResponse } catch { # Fetch failed } } # Return cached data if available if (Test-Path $MODELS_CACHE_FILE) { return Get-Content $MODELS_CACHE_FILE -Raw -Encoding UTF8 } return $null } # Get pricing for a specific model function Get-ModelPricing { param( [string]$ModelId, [string]$ModelsJson ) $inputPrice = 3 # Default fallback (Sonnet pricing) $outputPrice = 15 if ([string]::IsNullOrEmpty($ModelId) -or [string]::IsNullOrEmpty($ModelsJson)) { return @{ Input = $inputPrice; Output = $outputPrice } } try { $modelsData = ConvertFrom-Json $ModelsJson $found = $false if ($modelsData.data) { foreach ($model in $modelsData.data) { if ($model.id -eq $ModelId -and $model.pricing) { if ($model.pricing.input) { $inputPrice = $model.pricing.input } if ($model.pricing.output) { $outputPrice = $model.pricing.output } $found = $true break } } } if (-not $found) { if ($ModelId -match "opus") { $inputPrice = 5 $outputPrice = 25 } elseif ($ModelId -match "haiku") { $inputPrice = 1 $outputPrice = 5 } } } catch { # Parse failed, use defaults } return @{ Input = $inputPrice; Output = $outputPrice } } # Get billing data with caching function Get-Billing { # Check cache if (Test-Path $CACHE_FILE) { $cacheAge = ((Get-Date) - (Get-Item $CACHE_FILE).LastWriteTime).TotalSeconds if ($cacheAge -lt $CACHE_TTL) { return Get-Content $CACHE_FILE -Raw -Encoding UTF8 } } # Fetch fresh data if (-not [string]::IsNullOrEmpty($ENDPOINT_URL) -and $ENDPOINT_URL -ne "__" + "ENDPOINT_URL__" -and -not [string]::IsNullOrEmpty($API_KEY) -and $API_KEY -ne "__" + "API_KEY__") { try { $body = @{ key = $API_KEY } | ConvertTo-Json $response = Invoke-RestMethod -Uri "$ENDPOINT_URL/dashboard/lookup" -Method Post ` -ContentType "application/json; charset=utf-8" -Body $body -TimeoutSec 5 -ErrorAction Stop $jsonResponse = $response | ConvertTo-Json -Depth 10 [System.IO.File]::WriteAllText($CACHE_FILE, $jsonResponse, [System.Text.Encoding]::UTF8) return $jsonResponse } catch { # Fetch failed } } # Return cached data if available if (Test-Path $CACHE_FILE) { return Get-Content $CACHE_FILE -Raw -Encoding UTF8 } return $null } # Parse billing response function Parse-Billing { param([string]$Json) $script:BALANCE = 0 $script:DAILY_QUOTA = 0 $script:NOTIFICATION = "" $script:ADS = @() if ([string]::IsNullOrEmpty($Json)) { return } try { $data = ConvertFrom-Json $Json if ($data.balance) { $script:BALANCE = $data.balance } if ($data.dailyQuota) { $script:DAILY_QUOTA = $data.dailyQuota } if ($data.notification) { $script:NOTIFICATION = $data.notification } if ($data.ads) { $script:ADS = $data.ads } } catch { # Parse failed } } # Create progress bar function New-ProgressBar { param( [int]$Current, [int]$Max, [int]$Width = 5 ) if ($Max -eq 0) { return ([string][char]0x25AF) * $Width # ▯ } $pct = [Math]::Floor($Current * 100 / $Max) $filled = [Math]::Floor($pct * $Width / 100) $empty = $Width - $filled # Anthropic Claude color for filled $filledStr = "$ESC[32m" + (([string][char]0x25AE) * $filled) + "$ESC[0m" # ▮ green $emptyStr = "$ESC[90m" + (([string][char]0x25AF) * $empty) + "$ESC[0m" # ▯ dim return $filledStr + $emptyStr } # Format number with K suffix function Format-Tokens { param([int]$Num) if ($Num -ge 1000) { return [Math]::Floor($Num / 1000).ToString() + "k" } return $Num.ToString() } # Helper function for emoji # Windows Terminal and VS Code support emoji, legacy console gets ASCII fallback function Get-Icon { param([string]$Name) $supportsEmoji = $env:WT_SESSION -or $env:TERM_PROGRAM -eq "vscode" if ($supportsEmoji) { switch ($Name) { "folder" { return [char]::ConvertFromUtf32(0x1F4C2) } # open folder "branch" { return [char]::ConvertFromUtf32(0x1F33F) } # herb "robot" { return [char]::ConvertFromUtf32(0x1F916) } # robot "chart" { return [char]::ConvertFromUtf32(0x1F4CA) } # bar chart "tokens" { return [char]::ConvertFromUtf32(0x1F4D1) } # bookmark tabs "lines" { return [char]::ConvertFromUtf32(0x1F4DD) } # memo "money" { return [char]::ConvertFromUtf32(0x1F4B0) } # money bag "bell" { return [char]::ConvertFromUtf32(0x1F514) } # bell "leaf" { return [char]::ConvertFromUtf32(0x1F340) } # four-leaf clover "mega" { return [char]::ConvertFromUtf32(0x1F4E2) } # loudspeaker default { return "" } } } else { # ASCII fallback for legacy Windows console switch ($Name) { "folder" { return "[D]" } "branch" { return "[B]" } "robot" { return "[M]" } "chart" { return "[C]" } "tokens" { return "[T]" } "lines" { return "[L]" } "money" { return "`$" } "bell" { return "[!]" } "leaf" { return "c" } "mega" { return "[*]" } default { return "" } } } } # Main output function Main { Parse-Context $InputJson # ANSI colors (using $ESC for PS 5.1 compatibility) $W = "$ESC[97m" # White (60%) $G = "$ESC[32m" # Green (30%) $R = "$ESC[31m" # Red $DM = "$ESC[90m" # Dim (10%) $D = "$ESC[0m" # Reset # Build line 1: folder + git branch + lines diff $line1 = "" # Working directory (cyan) $shortCwd = Shorten-Path $CWD $line1 += (Get-Icon "folder") + " ${W}${shortCwd}${D}" # Git info (white) if (-not [string]::IsNullOrEmpty($GIT_BRANCH)) { $line1 += " " + (Get-Icon "branch") + " ${W}${GIT_BRANCH}${D}" if ($GIT_NUM_FILES -gt 0) { $line1 += " ${W}($GIT_NUM_FILES)${D}" } } # Lines diff (green/red - keep distinct) if ($LINES_ADDED -gt 0 -or $LINES_REMOVED -gt 0) { $line1 += " " + (Get-Icon "lines") + " ${G}+${LINES_ADDED}${D} ${R}-${LINES_REMOVED}${D}" } # Build line 2: context + model + cost + balance $line2 = "" # Context usage $ctxPct = 0 if ($MAX_TOKENS -gt 0) { $ctxPct = [Math]::Floor($CONVERSATION_TOKENS * 100 / $MAX_TOKENS) } $ctxBar = New-ProgressBar -Current $CONVERSATION_TOKENS -Max $MAX_TOKENS $ctxCurrent = Format-Tokens $CONVERSATION_TOKENS $ctxMax = Format-Tokens $MAX_TOKENS $line2 += (Get-Icon "chart") + " ${W}${ctxCurrent}/${ctxMax}${D} ${DM}|${D} $ctxBar ${W}${ctxPct}%${D} ${DM}|${D}" # Model (white) $modelDisplay = Format-Model $MODEL $line2 += " " + (Get-Icon "robot") + " ${W}${modelDisplay}${D}" # Cost (green, shamrock) $modelsData = Get-ModelsPricing $pricing = Get-ModelPricing -ModelId $MODEL_ID -ModelsJson $modelsData $inputPrice = $pricing.Input $outputPrice = $pricing.Output $estCost = 0.0000 if ($CONVERSATION_TOKENS -gt 0) { $estCost = ($CONVERSATION_TOKENS * $inputPrice / 1000000) + ($OUTPUT_TOKENS * $outputPrice / 1000000) } $estCostFmt = "{0:F4}" -f $estCost $line2 += " ${DM}|${D} " + (Get-Icon "leaf") + " ${W}${estCostFmt}${D}" # Balance (green $, last) $billingData = Get-Billing if (-not [string]::IsNullOrEmpty($billingData)) { Parse-Billing $billingData if ($BALANCE -gt 0) { $balInt = [Math]::Floor($BALANCE) $quotaInt = [Math]::Floor($DAILY_QUOTA) if ($DAILY_QUOTA -gt 0 -and $BALANCE -le $DAILY_QUOTA) { $used = $DAILY_QUOTA - $BALANCE $usedFmt = "{0:F1}" -f $used $line2 += " ${DM}|${D} ${G}`$${D} ${W}${balInt}/${quotaInt} (-${usedFmt})${D}" } else { $line2 += " ${DM}|${D} ${G}`$${D} ${W}${balInt}${D}" } } } # Notification and ads (if present) if ((-not [string]::IsNullOrEmpty($NOTIFICATION)) -or ($ADS -and $ADS.Count -gt 0)) { $maxLen = [Math]::Max($line1.Length, $line2.Length) Write-Output $line1 Write-Output $line2 # Separator Write-Output ([string][char]0x2500 * $maxLen) # ─ if (-not [string]::IsNullOrEmpty($NOTIFICATION)) { Write-Output ((Get-Icon "bell") + " $NOTIFICATION") } if ($ADS -and $ADS.Count -gt 0) { foreach ($ad in $ADS) { Write-Output ((Get-Icon "mega") + " $ad") } } } else { Write-Output $line1 Write-Output $line2 } } Main