Compare commits

..

1 Commits

Author SHA1 Message Date
thomas 2b1edf3806 add sub-bar usage widget extension with opencode-go support 2026-03-12 12:30:09 +00:00
121 changed files with 15031 additions and 3067 deletions
+3 -3
View File
@@ -224,8 +224,8 @@
"networkPreference": "wifi", "networkPreference": "wifi",
"iconTheme": "System Default", "iconTheme": "System Default",
"cursorSettings": { "cursorSettings": {
"theme": "Adwaita", "theme": "Qogir",
"size": 24, "size": 32,
"niri": { "niri": {
"hideWhenTyping": false, "hideWhenTyping": false,
"hideAfterInactiveMs": 0 "hideAfterInactiveMs": 0
@@ -377,7 +377,7 @@
"osdPosition": 5, "osdPosition": 5,
"osdVolumeEnabled": true, "osdVolumeEnabled": true,
"osdMediaVolumeEnabled": true, "osdMediaVolumeEnabled": true,
"osdMediaPlaybackEnabled": false, "osdMediaPlaybackEnabled": true,
"osdBrightnessEnabled": true, "osdBrightnessEnabled": true,
"osdIdleInhibitorEnabled": true, "osdIdleInhibitorEnabled": true,
"osdMicMuteEnabled": true, "osdMicMuteEnabled": true,
-14
View File
@@ -27,14 +27,6 @@ end
status is-interactive; and begin status is-interactive; and begin
# On macOS SSH sessions, normalize TERM if remote terminfo is missing
# (eg. TERM=alacritty from Linux host), otherwise tools like jj/less warn
if test (uname) = Darwin; and set -q SSH_TTY
if not infocmp "$TERM" >/dev/null 2>&1
set -gx TERM xterm-256color
end
end
# Abbreviations # Abbreviations
abbr -a tx 'tmux' abbr -a tx 'tmux'
abbr -a txa 'tmux attach' abbr -a txa 'tmux attach'
@@ -42,8 +34,6 @@ status is-interactive; and begin
abbr -a txk 'tmux kill-session -t' abbr -a txk 'tmux kill-session -t'
abbr -a txks 'tmux kill-server' abbr -a txks 'tmux kill-server'
abbr -a zj 'zellij'
# Aliases # Aliases
alias che chezmoi alias che chezmoi
alias gb 'git branch' alias gb 'git branch'
@@ -83,10 +73,6 @@ status is-interactive; and begin
if test "$TERM" != dumb if test "$TERM" != dumb
fzf --fish | source fzf --fish | source
bind --erase \ct
bind -M insert --erase \ct
bind \cf fzf-file-widget
bind -M insert \cf fzf-file-widget
end end
# add completions generated by Home Manager to $fish_complete_path # add completions generated by Home Manager to $fish_complete_path
+1 -10
View File
@@ -1,17 +1,8 @@
function y function y
set tmp (mktemp -t "yazi-cwd.XXXXXX") set tmp (mktemp -t "yazi-cwd.XXXXXX")
yazi $argv --cwd-file="$tmp"
# In Zellij + Alacritty, force Yazi away from graphical adapters and into Chafa.
# This gives text/blocks previews instead of broken/blank image panes.
if set -q ZELLIJ
env -u DISPLAY -u WAYLAND_DISPLAY -u SWAYSOCK -u HYPRLAND_INSTANCE_SIGNATURE -u WAYFIRE_SOCKET TERM=xterm-256color yazi $argv --cwd-file="$tmp"
else
yazi $argv --cwd-file="$tmp"
end
if set cwd (command cat -- "$tmp"); and [ -n "$cwd" ]; and [ "$cwd" != "$PWD" ] if set cwd (command cat -- "$tmp"); and [ -n "$cwd" ]; and [ "$cwd" != "$PWD" ]
builtin cd -- "$cwd" builtin cd -- "$cwd"
end end
rm -f -- "$tmp" rm -f -- "$tmp"
end end
-15
View File
@@ -1,15 +0,0 @@
function yazi --wraps yazi --description "Yazi wrapper that forces Chafa inside Zellij"
if set -q ZELLIJ
begin
set -e DISPLAY
set -e WAYLAND_DISPLAY
set -e SWAYSOCK
set -e HYPRLAND_INSTANCE_SIGNATURE
set -e WAYFIRE_SOCKET
set -lx TERM xterm-256color
command yazi $argv
end
else
command yazi $argv
end
end
-18
View File
@@ -1,18 +0,0 @@
---@class SigilConfig
---@field target table<string, string|boolean>
---@field ignore? string[]
---@type SigilConfig
local config = {
target = {
linux = "~/.config/gsf",
default = "~/.config/gsf",
},
ignore = {
-- "**/.DS_Store",
-- "**/*.tmp",
-- "cache/**",
},
}
return config
-17
View File
@@ -1,17 +0,0 @@
{
"mode": 0,
"sens_mult": 1.5,
"yx_ratio": 1.0,
"input_dpi": 400.0,
"angle_rotation": 0.0,
"accel": 2.0,
"offset_linear": 3.5,
"output_cap": 30.0,
"decay_rate": 0.1,
"offset_natural": 0.0,
"limit": 2.0,
"gamma": 1.0,
"smooth": 0.5,
"motivity": 1.5,
"sync_speed": 5.0
}
+6 -10
View File
@@ -3,12 +3,11 @@
[templates.ghostty] [templates.ghostty]
input_path = '~/.config/matugen/templates/ghostty-theme' input_path = '~/.config/matugen/templates/ghostty-theme'
output_path = '~/.config/ghostty/themes/matugen' output_path = '~/.config/ghostty/themes/matugen'
post_hook = "pkill -SIGUSR2 ghostty || true && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/ghostty/themes/matugen ~/.config/ghostty/themes/matugen --remote-cmd 'pkill -SIGUSR2 ghostty || true' >/dev/null 2>&1 &" post_hook = 'pkill -SIGUSR2 ghostty'
[templates.kitty] [templates.kitty]
input_path = '~/.config/matugen/templates/kitty-colors.conf' input_path = '~/.config/matugen/templates/kitty-colors.conf'
output_path = '~/.config/kitty/colors.conf' output_path = '~/.config/kitty/colors.conf'
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/kitty/colors.conf ~/.config/kitty/colors.conf >/dev/null 2>&1 &"
[templates.foot] [templates.foot]
input_path = '~/.config/matugen/templates/foot-theme' input_path = '~/.config/matugen/templates/foot-theme'
@@ -25,12 +24,10 @@ output_path = '~/.config/gtk-4.0/colors.css'
[templates.fish-prompt] [templates.fish-prompt]
input_path = '~/.config/matugen/templates/fish-prompt-colors.fish' input_path = '~/.config/matugen/templates/fish-prompt-colors.fish'
output_path = '~/.config/fish/conf.d/prompt-colors.fish' output_path = '~/.config/fish/conf.d/prompt-colors.fish'
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/fish/conf.d/prompt-colors.fish ~/.config/fish/conf.d/prompt-colors.fish >/dev/null 2>&1 &"
[templates.yazi] [templates.yazi]
input_path = '~/.config/matugen/templates/yazi-theme.toml' input_path = '~/.config/matugen/templates/yazi-theme.toml'
output_path = '~/.config/yazi/theme.toml' output_path = '~/.config/yazi/theme.toml'
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/yazi/theme.toml ~/.config/yazi/theme.toml >/dev/null 2>&1 &"
[templates.qt5ct] [templates.qt5ct]
input_path = '~/.config/matugen/templates/qtct-colors.conf' input_path = '~/.config/matugen/templates/qtct-colors.conf'
@@ -47,29 +44,28 @@ output_path = '~/.config/niri/colors.kdl'
[templates.tmux] [templates.tmux]
input_path = '~/.config/matugen/templates/tmux-colors.conf' input_path = '~/.config/matugen/templates/tmux-colors.conf'
output_path = '~/.config/tmux/colors.conf' output_path = '~/.config/tmux/colors.conf'
post_hook = "tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/tmux/colors.conf ~/.config/tmux/colors.conf --remote-cmd 'export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\" && tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true' >/dev/null 2>&1 &" post_hook = 'tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true && nohup ~/.config/matugen/scripts/sync-tmux-mac.sh >/dev/null 2>&1 &'
[templates.zellij] [templates.zellij]
input_path = '~/.config/matugen/templates/zellij-colors.kdl' input_path = '~/.config/matugen/templates/zellij-colors.kdl'
output_path = '~/.config/zellij/themes/matugen.kdl' output_path = '~/.config/zellij/themes/matugen.kdl'
post_hook = "touch ~/.config/zellij/config.kdl && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/zellij/themes/matugen.kdl ~/.config/zellij/themes/matugen.kdl --remote-cmd 'touch ~/.config/zellij/config.kdl' >/dev/null 2>&1 &" post_hook = 'touch ~/.config/zellij/config.kdl && nohup ~/.config/matugen/scripts/sync-zellij-mac.sh >/dev/null 2>&1 &'
[templates.jjui] [templates.jjui]
input_path = '~/.config/matugen/templates/jjui-theme.toml' input_path = '~/.config/matugen/templates/jjui-theme.toml'
output_path = '~/.config/jjui/themes/matugen.toml' output_path = '~/.config/jjui/themes/matugen.toml'
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/jjui/themes/matugen.toml ~/.config/jjui/themes/matugen.toml >/dev/null 2>&1 &"
[templates.nvim] [templates.nvim]
input_path = '~/.config/matugen/templates/neovim.lua' input_path = '~/.config/matugen/templates/neovim.lua'
output_path = '~/.config/nvim/lua/plugins/dankcolors.lua' output_path = '~/.config/nvim/lua/plugins/dankcolors.lua'
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/nvim/lua/plugins/dankcolors.lua ~/.config/nvim/lua/plugins/dankcolors.lua >/dev/null 2>&1 &" post_hook = 'nohup ~/.config/matugen/scripts/sync-nvim-mac.sh >/dev/null 2>&1 &'
[templates.pi] [templates.pi]
input_path = '~/.config/matugen/templates/pi-theme.json' input_path = '~/.config/matugen/templates/pi-theme.json'
output_path = '~/.pi/agent/themes/matugen.json.tmp' output_path = '~/.pi/agent/themes/matugen.json.tmp'
post_hook = "cat ~/.pi/agent/themes/matugen.json.tmp > ~/.pi/agent/themes/matugen.json && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.pi/agent/themes/matugen.json ~/.pi/agent/themes/matugen.json >/dev/null 2>&1 &" post_hook = 'cat ~/.pi/agent/themes/matugen.json.tmp > ~/.pi/agent/themes/matugen.json && nohup ~/.config/matugen/scripts/sync-pi-mac.sh >/dev/null 2>&1 &'
[templates.wallpaper] [templates.wallpaper]
input_path = '~/.config/matugen/templates/wallpaper-path.txt' input_path = '~/.config/matugen/templates/wallpaper-path.txt'
output_path = '~/.cache/matugen-last-image' output_path = '~/.cache/matugen-last-image'
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh wallpaper ~/.cache/matugen-last-image >/dev/null 2>&1 &" post_hook = 'nohup ~/.config/matugen/scripts/sync-wallpaper-mac.sh >/dev/null 2>&1 &'
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env sh
set -eu
host="${MATUGEN_SYNC_HOST:-mac-attio}"
log_file="$HOME/.cache/matugen-sync-mac.log"
mkdir -p "$HOME/.cache"
usage() {
echo "usage:" >&2
echo " sync-mac.sh file <source_path> <remote_path> [--remote-cmd <command>]" >&2
echo " sync-mac.sh wallpaper <wallpaper_path_file>" >&2
exit 1
}
sync_file() {
source_path="$1"
remote_path="$2"
remote_cmd="${3-}"
# If caller passes a local absolute path, mirror it under remote $HOME.
case "$remote_path" in
"$HOME")
remote_path="~"
;;
"$HOME"/*)
remote_path="~/${remote_path#"$HOME"/}"
;;
esac
remote_dir="$(dirname "$remote_path")"
remote_tmp="${remote_path}.tmp"
ssh "$host" "mkdir -p $remote_dir"
scp "$source_path" "$host:$remote_tmp"
ssh "$host" "mv $remote_tmp $remote_path"
if [ -n "$remote_cmd" ]; then
ssh "$host" "$remote_cmd"
fi
}
sync_wallpaper() {
wallpaper_path_file="$1"
[ -f "$wallpaper_path_file" ] || exit 0
wallpaper_path="$(cat "$wallpaper_path_file")"
[ -n "$wallpaper_path" ] || exit 0
[ -f "$wallpaper_path" ] || exit 0
base_name="$(basename "$wallpaper_path")"
local_cache_dir="$HOME/.cache/matugen-wallpapers"
local_copy="$local_cache_dir/$base_name"
mkdir -p "$local_cache_dir"
cp -f "$wallpaper_path" "$local_copy"
ssh "$host" "mkdir -p ~/.cache/matugen-wallpapers"
scp "$local_copy" "$host:~/.cache/matugen-wallpapers/$base_name"
ssh "$host" "osascript -e 'tell application \"System Events\" to tell every desktop to set picture to POSIX file \"~/.cache/matugen-wallpapers/$base_name\"'"
}
mode="${1-}"
[ -n "$mode" ] || usage
shift
{
echo "[$(date '+%Y-%m-%d %H:%M:%S')] mode=$mode"
case "$mode" in
file)
[ "$#" -ge 2 ] || usage
source_path="$1"
remote_path="$2"
shift 2
remote_cmd=""
if [ "${1-}" = "--remote-cmd" ]; then
[ "$#" -eq 2 ] || usage
remote_cmd="$2"
elif [ "$#" -ne 0 ]; then
usage
fi
sync_file "$source_path" "$remote_path" "$remote_cmd"
;;
wallpaper)
[ "$#" -eq 1 ] || usage
sync_wallpaper "$1"
;;
*)
usage
;;
esac
} >>"$log_file" 2>&1
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
log_file="$HOME/.cache/matugen-sync-nvim.log"
mkdir -p "$HOME/.cache"
{
ssh mac-attio "mkdir -p ~/.config/nvim/lua/plugins"
scp "$HOME/.config/nvim/lua/plugins/dankcolors.lua" \
mac-attio:~/.config/nvim/lua/plugins/
} >>"$log_file" 2>&1
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
log_file="$HOME/.cache/matugen-sync-pi.log"
mkdir -p "$HOME/.cache"
{
ssh mac-attio "mkdir -p ~/.pi/agent/themes"
scp "$HOME/.pi/agent/themes/matugen.json" \
mac-attio:~/.pi/agent/themes/matugen.json.tmp
ssh mac-attio "mv ~/.pi/agent/themes/matugen.json.tmp ~/.pi/agent/themes/matugen.json"
} >>"$log_file" 2>&1
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
log_file="$HOME/.cache/matugen-sync-tmux.log"
mkdir -p "$HOME/.cache"
{
ssh mac-attio "mkdir -p ~/.config/tmux"
scp "$HOME/.config/tmux/colors.conf" \
mac-attio:~/.config/tmux/
ssh mac-attio 'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true'
} >>"$log_file" 2>&1
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
log_file="$HOME/.cache/matugen-sync-wallpaper.log"
mkdir -p "$HOME/.cache"
{
wallpaper_path="$(cat "$HOME/.cache/matugen-last-image")"
if [ -n "$wallpaper_path" ]; then
base_name="$(basename "$wallpaper_path")"
dest_path="$HOME/.cache/matugen-wallpapers/$base_name"
mkdir -p "$HOME/.cache/matugen-wallpapers"
cp -f "$wallpaper_path" "$dest_path"
ssh mac-attio "mkdir -p ~/.cache/matugen-wallpapers"
scp "$dest_path" "mac-attio:~/.cache/matugen-wallpapers/$base_name"
ssh mac-attio "osascript -e 'tell application \"System Events\" to tell every desktop to set picture to POSIX file \"~/.cache/matugen-wallpapers/$base_name\"'"
fi
} >>"$log_file" 2>&1
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
log_file="$HOME/.cache/matugen-sync-zellij.log"
mkdir -p "$HOME/.cache"
{
ssh mac-attio "mkdir -p ~/.config/zellij/themes"
scp "$HOME/.config/zellij/themes/matugen.kdl" \
mac-attio:~/.config/zellij/themes/
ssh mac-attio "touch ~/.config/zellij/config.kdl"
} >>"$log_file" 2>&1
+1 -1
View File
@@ -238,7 +238,7 @@ layer-rule {
} }
window-rule { window-rule {
match app-id="steam" title=r#"notificationtoasts_\d+_desktop$"# match app-id="steam" title=r#"^notificationtoasts_\d+_desktop$"#
default-floating-position x=10 y=10 relative-to="bottom-right" default-floating-position x=10 y=10 relative-to="bottom-right"
} }
+1 -3
View File
@@ -119,9 +119,7 @@ return {
end, "[T]oggle Inlay [H]ints") end, "[T]oggle Inlay [H]ints")
end end
-- Svelte-specific: keep treesitter highlighting in control -- Svelte-specific: notify on TS/JS file changes
-- (semantic tokens can make svelte buffers look oddly colored)
-- and notify on TS/JS file changes
-- https://github.com/sveltejs/language-tools/issues/2008#issuecomment-2351976230 -- https://github.com/sveltejs/language-tools/issues/2008#issuecomment-2351976230
if client and client.name == "svelte" then if client and client.name == "svelte" then
vim.api.nvim_create_autocmd("BufWritePost", { vim.api.nvim_create_autocmd("BufWritePost", {
+21 -23
View File
@@ -51,29 +51,27 @@ return {
-- end, -- end,
-- desc = "Git Blame Line", -- desc = "Git Blame Line",
-- }, -- },
-- {
-- Commented out LazyGit in favor of separated jj "<leader>gf",
-- { function()
-- "<leader>gf", Snacks.lazygit.log_file()
-- function() end,
-- Snacks.lazygit.log_file() desc = "Lazygit Current File History",
-- end, },
-- desc = "Lazygit Current File History", {
-- }, "<leader>lg",
-- { function()
-- "<leader>lg", Snacks.lazygit()
-- function() end,
-- Snacks.lazygit() desc = "Lazygit",
-- end, },
-- desc = "Lazygit", {
-- }, "<leader>gl",
-- { function()
-- "<leader>gl", Snacks.lazygit.log()
-- function() end,
-- Snacks.lazygit.log() desc = "Lazygit Log (cwd)",
-- end, },
-- desc = "Lazygit Log (cwd)",
-- },
{ {
"<leader>dn", "<leader>dn",
function() function()
+3 -73
View File
@@ -4,13 +4,12 @@ return {
lazy = false, lazy = false,
build = ":TSUpdate", build = ":TSUpdate",
config = function() config = function()
local ts = require("nvim-treesitter") require("nvim-treesitter").setup({
ts.setup({
install_dir = vim.fn.stdpath("data") .. "/site", install_dir = vim.fn.stdpath("data") .. "/site",
}) })
-- Install parsers (async, no-op if already installed) -- Install parsers (async, no-op if already installed)
ts.install({ require("nvim-treesitter").install({
"vimdoc", "vimdoc",
"javascript", "javascript",
"typescript", "typescript",
@@ -29,75 +28,6 @@ return {
"nix", "nix",
}) })
vim.api.nvim_create_autocmd("FileType", { -- Enable treesitter highlighting - indentexpr set automatically per filetype
pattern = {
"svelte",
"javascript",
"typescript",
"tsx",
"html",
"css",
"lua",
"nix",
},
callback = function()
pcall(vim.treesitter.start)
end,
})
-- Workaround: some svelte parser/query combos don't inject JS for <script>
-- blocks without an explicit lang attribute, which leaves raw_text as @none.
vim.treesitter.query.set("svelte", "injections", [=[
; inherits: html_tags
((style_element
(start_tag
(attribute
(attribute_name) @_attr
(quoted_attribute_value
(attribute_value) @_lang)))
(raw_text) @injection.content)
(#eq? @_attr "lang")
(#any-of? @_lang "scss" "postcss" "less")
(#set! injection.language "scss"))
; fallback for plain <script>...</script>
((script_element
(raw_text) @injection.content)
(#set! injection.language "javascript"))
((script_element
(start_tag
(attribute
(attribute_name) @_attr
(quoted_attribute_value
(attribute_value) @_lang)))
(raw_text) @injection.content)
(#eq? @_attr "lang")
(#any-of? @_lang "ts" "typescript")
(#set! injection.language "typescript"))
((script_element
(start_tag
(attribute
(attribute_name) @_attr
(quoted_attribute_value
(attribute_value) @_lang)))
(raw_text) @injection.content)
(#eq? @_attr "lang")
(#any-of? @_lang "js" "javascript")
(#set! injection.language "javascript"))
((element
(start_tag
(attribute
(attribute_name) @_attr
(quoted_attribute_value
(attribute_value) @injection.language)))
(text) @injection.content)
(#eq? @_attr "lang")
(#eq? @injection.language "pug"))
]=])
end, end,
} }
-5
View File
@@ -89,11 +89,6 @@ vim.o.confirm = true
-- vim.o.winborder = "rounded" -- vim.o.winborder = "rounded"
-- Clipboard: keep default y/p behavior; over SSH, route + register through OSC52
if vim.env.SSH_TTY then
vim.g.clipboard = "osc52"
end
-- Highlight text on yank -- Highlight text on yank
vim.api.nvim_create_autocmd("TextYankPost", { vim.api.nvim_create_autocmd("TextYankPost", {
callback = function() callback = function()
-13
View File
@@ -1,13 +0,0 @@
{
"activePack": "glados",
"volume": 1,
"muted": false,
"enabledCategories": {
"session.start": true,
"task.acknowledge": true,
"task.complete": true,
"task.error": false,
"input.required": true,
"resource.limit": true
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"activePack": "solid_snake",
"volume": 1.5,
"muted": false,
"enabledCategories": {
"session.start": true,
"task.acknowledge": true,
"task.complete": true,
"task.error": false,
"input.required": true,
"resource.limit": true
}
}
@@ -21,6 +21,11 @@
"file": "sounds/IKnowYoureThere.mp3", "file": "sounds/IKnowYoureThere.mp3",
"label": "I know you're there. I can feel you here.", "label": "I know you're there. I can feel you here.",
"sha256": "df3780607b7a480fd3968c8aae5e0a397ea956008a5c7a47fecb887a05d61622" "sha256": "df3780607b7a480fd3968c8aae5e0a397ea956008a5c7a47fecb887a05d61622"
},
{
"file": "sounds/HelloImbecile.mp3",
"label": "Hello, imbecile!",
"sha256": "dd10461e79bb4b1319f436cef5f0541f18a9505638824a6e765b9f2824a3380f"
} }
] ]
}, },
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"lastChangelogVersion": "0.63.1", "lastChangelogVersion": "0.57.1",
"defaultProvider": "openai-codex", "defaultProvider": "anthropic",
"defaultModel": "gpt-5.3-codex", "defaultModel": "claude-opus-4-6",
"defaultThinkingLevel": "high", "defaultThinkingLevel": "medium",
"theme": "matugen", "theme": "matugen",
"lsp": { "lsp": {
"hookMode": "edit_write" "hookMode": "edit_write"
@@ -1,232 +0,0 @@
---
name: attio-frontend-rules
description: Styling conventions and component guidelines for the Attio frontend codebase. Covers styled-components patterns, transient props, data attributes, spacing, color tokens, and design system usage. Use when modifying frontend UI code in the Attio monorepo.
---
# Attio Frontend Rules
Guidelines and conventions for working on the Attio frontend codebase. Use whenever modifying the frontend.
---
## Styling
### General
- We use `styled-components` to style our components within our TS code.
- We use the `$` prefix to indicate props that are consumed in Styled Components. This prevents them from being passed to the DOM as attributes.
### Explore available components
Before writing custom components or reaching for custom CSS, check if there are existing components that fit what you're trying to do. Try asking AI agents to find existing components or have a look at available ones in storybook.
Run `yarn workspace @attio/design start-storybook` to open the storybook in a new tab in your browser.
### Re-styling existing components
Most reusable components offer props which affect their styling.
For example, `Layout.Stack` exposes various props to adjust padding (`p`, `px`, `py`, …), margins (`m`, `mx`, …), width, height, flex layout properties, etc.
Components like `Button` or `Typography` expose a `variant` prop to select between multiple colour variants commonly used in the codebase.
**Always prefer using these props over creating custom styling.** When you need to change the styling of a reusable component just for one particular part of the UI, use styled-components:
```jsx
import {styled} from "styled-components"
const Container = styled(Layout.Stack)`
border: 1px solid ${({theme}) => theme.tokens.stroke.primary};
border-radius: ${({theme}) => theme.borderRadii["12"]};
background-color: ${({theme}) => theme.tokens.surface.secondary};
overflow: hidden;
`
```
When implementing reusable components, add a `className` prop and assign it to the topmost component in the DOM subtree. This enables re-styling via `styled`.
```jsx
export function Stack({..., className}: {..., className: string | undefined}) {
return (
<div className={className}>
...
</div>
)
}
```
If the same re-styling is applied multiple times, it should become its own reusable component (or component variant).
### Layout.Stack defaults
`Layout.Stack` defaults `align` to `"center"` (i.e. `align-items: center`). **Always explicitly set `align="flex-start"`** when you need left/top alignment — don't assume it will be the default.
```tsx
// Good — explicit alignment
<Layout.Stack direction="column" align="flex-start">
<Typography.Body.Standard.Component>Title</Typography.Body.Standard.Component>
<Typography.Caption.Standard.Component>Description</Typography.Caption.Standard.Component>
</Layout.Stack>
// Bad — text will be centered, not left-aligned
<Layout.Stack direction="column">
<Typography.Body.Standard.Component>Title</Typography.Body.Standard.Component>
<Typography.Caption.Standard.Component>Description</Typography.Caption.Standard.Component>
</Layout.Stack>
```
Other useful `Layout.Stack` props: `direction`, `justify`, `gap`, `flex`, `shrink`, `minWidth`, `width`, `height`, and all spacing props (`p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`, `m`, `mx`, `my`, etc.). **Always prefer these props over writing custom styled divs with `display: flex`.**
### Avoid layout assumptions
Components should not generally include external layout styles such as `width`, `z-index`, `margin` or `flex`. These properties should instead be set by the parent component using a `styled(MyComponent)` override.
### Transient props
Use [`$transient` props](https://styled-components.com/docs/api#transient-props) for anything consumed only inside a component's style. This prevents these props being passed as attributes to the underlying DOM node and generating noisy runtime errors.
```tsx
// Good
const GoodContainer = styled.div<{$isReady: boolean}>`
background: ${p => p.$isReady ? "green" : "red"};
`
// Bad
const BadContainer = styled.div<{isReady: boolean}>`
background: ${p => p.isReady ? "green" : "red"};
`
```
### Data attributes vs transient props
While transient props offer flexibility, consider using data attributes for variant styling. This improves CSS readability with many variants/selectors and offers marginal performance improvements.
**Rule of thumb:**
- **Transient props** — CSS that needs to be interpolated at runtime.
- **Data attributes** — styling variants that can be statically defined.
```jsx
// Good: transient props for runtime interpolation
const Container = styled.div<{$size: Size}>`
font-size: ${({ $size }) => getFontSize($size)}px;
`
// Good: data attributes for variant definitions
const Container = styled.div`
&[data-variant="subtle"] {
background-color: light-blue;
}
&[data-variant="outline"] {
background-color: transparent;
}
`
// Data attributes can be typed
enum Variants {
SUBTLE = "subtle",
OUTLINE = "outline"
}
const Container = styled.div`
&[data-variant=${Variants.SUBTLE}] {
background-color: light-blue;
}
&[data-variant=${Variants.OUTLINE}] {
background-color: transparent;
}
`
```
### High cardinality props
Any prop which changes between many values at runtime should **not** be passed to a styled component. This avoids the overhead of styled-components generating a new class for each value. Use a traditional inline `style` prop instead.
```tsx
interface Props {
isReady: boolean
widthPx: number // Varies with a continuous distribution
}
// Good
const GoodContainer = styled.div<{$isReady: boolean}>`
background: ${p => p.$isReady ? "green" : "red"};
`
export function GoodComponent({isReady, widthPx}: Props) {
return <GoodContainer $isReady={isReady} style={{width: `${widthPx}px`}} />
}
// Bad
const BadComponent = styled.div<{$isReady: boolean, $widthPx: number}>`
background: ${p => p.$isReady ? "green" : "red"};
width: ${p => p.$widthPx}px;
`
```
### Spacing (padding, margins, gaps)
Many reusable components expose props for padding (`p`, `px`, `py`, `pr`, `pl`, `pt`, `pb`), margin (`m`, `mx`, `my`, …) and gap (`gap`). Prefer setting spacing through these props. They are limited to a specific subset of values (e.g. `"4px"`, `"8px"`) matching the design system — don't use other values for spacing 99% of the time.
When creating new components or overriding styles, use the `Spacing` constant from `@attio/picasso`:
```tsx
import {styled} from "styled-components"
import {Form, Spacing} from "@attio/picasso"
const StyledFormHelpText = styled(Form.HelpText)`
${Spacing.px("16px")}
${Spacing.pt("16px")}
${Spacing.pb("0px")}
`
```
Use predefined values for border radii from theme:
```jsx
export const Link = styled.a`
border-radius: ${({theme}) => theme.borderRadii[6]};
`
```
### Color tokens
We have fixed tokens for colours. **Never define raw hex values or use string names for colours.**
Many components expose a `variant` prop to select predefined colour variants:
```tsx
<Typography.Caption.Standard.Component variant="secondary">
Some text...
</Typography.Caption.Standard.Component>
```
When creating new components or adjusting styling, always use tokens from the theme. This is critical because we support light and dark mode — tokens ensure the correct colour is selected for both.
In styled-components:
```tsx
export const CheckoutPreviewContainer = styled(Layout.Stack)`
${Spacing.p("20px")}
border-left: 1px solid ${({theme}) => theme.tokens.stroke.primary};
border-bottom-right-radius: ${({theme}) => theme.borderRadii["16"]};
background: ${({theme}) => theme.tokens.surface.secondary};
`
```
In React components, access colours through the `useTheme` hook. Modify colours using utilities like `opacify`:
```jsx
import Color from "color"
import {opacify} from "polished"
const theme = useTheme()
const backdropActiveColor = theme.alphas.bgOverlay
const backdropColor = opacify(1)(backdropActiveColor)
const activeOpacity = Color(backdropActiveColor).alpha()
```
### Turn on scrollbars
Develop with scrollbars set to "Always" in macOS System Settings. This ensures you spot unexpected overflow issues that users with this setting (or Windows users) will see.
+1 -28
View File
@@ -4,15 +4,9 @@ When the user provides a screenshot path (e.g., `/tmp/pi-clipboard-xxx.png`), **
--- ---
# Version control
**Prefer jj (Jujutsu) over git.** If a project has a colocated jj repo (`.jj` directory), use `jj` commands for all version control operations — rebasing, branching, log, etc. Only fall back to git when jj doesn't support something or the project isn't set up for it.
After pushing changes, always run `jj new` to start a fresh working copy commit.
# Git commits and PRs # Git commits and PRs
Before writing any commits or PR titles, check recent history with `jj log` (or `git log --oneline -20` if jj is unavailable) to match my style. Before writing any commits or PR titles, check recent git history with `git log --oneline -20` to match my style.
My commit style: My commit style:
@@ -67,27 +61,6 @@ npx mcporter emit-ts <server> --mode types
Config location: `~/.mcporter/mcporter.json` (or `config/mcporter.json` in project) Config location: `~/.mcporter/mcporter.json` (or `config/mcporter.json` in project)
### configuring servers quickly
```bash
# add a server to project-local config
npx mcporter config add <name> <url>
# example
npx mcporter config add svelte https://mcp.svelte.dev/mcp
# verify server + tool schemas
npx mcporter list
npx mcporter list svelte --schema
# inspect exact merged config source
npx mcporter config list --json
# if OAuth server, complete auth
npx mcporter auth <name>
```
If `mcporter list` shows no servers, first run `npx mcporter config add ...` (or import from editor config with `npx mcporter config import cursor --copy`).
## Commonly Used MCP Servers ## Commonly Used MCP Servers
Only use these servers and read about them when applicable. Only use these servers and read about them when applicable.
-1
View File
@@ -1 +0,0 @@
{}
+4 -4
View File
@@ -9,8 +9,8 @@
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'. * The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
*/ */
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
import type { TUI, KeybindingsManager, Component } from "@mariozechner/pi-tui"; import type { TUI, Theme, KeybindingsManager, Component } from "@mariozechner/pi-tui";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
export default function editSessionExtension(pi: ExtensionAPI) { export default function editSessionExtension(pi: ExtensionAPI) {
@@ -59,7 +59,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
ctx.ui.notify(`Editor exited with code ${result.status}`, "warning"); ctx.ui.notify(`Editor exited with code ${result.status}`, "warning");
} }
done(undefined); done();
// Return dummy component // Return dummy component
return createDummyComponent(); return createDummyComponent();
@@ -69,7 +69,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
await ctx.ui.custom<void>(factory); await ctx.ui.custom<void>(factory);
// Signal that we're about to reload the session (so confirm-destructive skips) // Signal that we're about to reload the session (so confirm-destructive skips)
pi.events.emit("edit-session:reload", undefined); pi.events.emit("edit-session:reload");
// Reload the session by switching to the same file (forces re-read from disk) // Reload the session by switching to the same file (forces re-read from disk)
ctx.ui.notify("Reloading session...", "info"); ctx.ui.notify("Reloading session...", "info");
+2 -3
View File
@@ -80,8 +80,7 @@ export default function (pi: ExtensionAPI) {
loader.onAbort = () => done(null); loader.onAbort = () => done(null);
const doGenerate = async () => { const doGenerate = async () => {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!); const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
if (!auth.ok) throw new Error(auth.error);
const userMessage: Message = { const userMessage: Message = {
role: "user", role: "user",
@@ -97,7 +96,7 @@ export default function (pi: ExtensionAPI) {
const response = await complete( const response = await complete(
ctx.model!, ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal }, { apiKey, signal: loader.signal },
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
@@ -1,535 +0,0 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { spawn } from "node:child_process";
import { basename, dirname, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@mariozechner/pi-coding-agent";
const HOOK_TIMEOUT_MS = 10 * 60 * 1000;
type HookEventName = "PostToolUse" | "PostToolUseFailure";
type ResolvedCommandHook = {
eventName: HookEventName;
matcher?: RegExp;
matcherText?: string;
command: string;
source: string;
};
type HookState = {
projectDir: string;
hooks: ResolvedCommandHook[];
};
type CommandRunResult = {
code: number;
stdout: string;
stderr: string;
elapsedMs: number;
timedOut: boolean;
};
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
return value as Record<string, unknown>;
}
function walkUpDirectories(startDir: string, stopDir?: string): string[] {
const directories: string[] = [];
const hasStopDir = stopDir !== undefined;
let current = resolve(startDir);
let parent = dirname(current);
let reachedStopDir = hasStopDir && current === stopDir;
let reachedFilesystemRoot = parent === current;
directories.push(current);
while (!reachedStopDir && !reachedFilesystemRoot) {
current = parent;
parent = dirname(current);
reachedStopDir = hasStopDir && current === stopDir;
reachedFilesystemRoot = parent === current;
directories.push(current);
}
return directories;
}
function findNearestGitRoot(startDir: string): string | undefined {
for (const directory of walkUpDirectories(startDir)) {
if (existsSync(join(directory, ".git"))) {
return directory;
}
}
return undefined;
}
function hasHooksConfig(directory: string): boolean {
const claudeSettingsPath = join(directory, ".claude", "settings.json");
const ruleSyncHooksPath = join(directory, ".rulesync", "hooks.json");
const piHooksPath = join(directory, ".pi", "hooks.json");
return isFile(claudeSettingsPath) || isFile(ruleSyncHooksPath) || isFile(piHooksPath);
}
function findProjectDir(cwd: string): string {
const gitRoot = findNearestGitRoot(cwd);
for (const directory of walkUpDirectories(cwd, gitRoot)) {
if (hasHooksConfig(directory)) {
return directory;
}
}
return gitRoot ?? resolve(cwd);
}
function readJsonFile(path: string): unknown | undefined {
if (!isFile(path)) {
return undefined;
}
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch {
return undefined;
}
}
function resolveHookCommand(command: string, projectDir: string): string {
return command.replace(/\$CLAUDE_PROJECT_DIR\b/g, projectDir);
}
function compileMatcher(matcherText: string | undefined): RegExp | undefined {
if (matcherText === undefined) {
return undefined;
}
try {
return new RegExp(matcherText);
} catch {
return undefined;
}
}
function createHook(
eventName: HookEventName,
matcherText: string | undefined,
command: string,
source: string,
projectDir: string,
): ResolvedCommandHook | undefined {
const matcher = compileMatcher(matcherText);
if (matcherText !== undefined && matcher === undefined) {
return undefined;
}
return {
eventName,
matcher,
matcherText,
command: resolveHookCommand(command, projectDir),
source,
};
}
function getHookEntries(
hooksRecord: Record<string, unknown>,
eventName: HookEventName,
): unknown[] {
const keys =
eventName === "PostToolUse"
? ["PostToolUse", "postToolUse"]
: ["PostToolUseFailure", "postToolUseFailure"];
for (const key of keys) {
const value = hooksRecord[key];
if (Array.isArray(value)) {
return value;
}
}
return [];
}
function parseClaudeSettingsHooks(
config: unknown,
source: string,
projectDir: string,
): ResolvedCommandHook[] {
const root = asRecord(config);
const hooksRoot = root ? asRecord(root.hooks) : undefined;
if (!hooksRoot) {
return [];
}
const hooks: ResolvedCommandHook[] = [];
const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"];
for (const eventName of events) {
const entries = getHookEntries(hooksRoot, eventName);
for (const entry of entries) {
const entryRecord = asRecord(entry);
if (!entryRecord || !Array.isArray(entryRecord.hooks)) {
continue;
}
const matcherText =
typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined;
for (const nestedHook of entryRecord.hooks) {
const nestedHookRecord = asRecord(nestedHook);
if (!nestedHookRecord) {
continue;
}
if (nestedHookRecord.type !== "command") {
continue;
}
if (typeof nestedHookRecord.command !== "string") {
continue;
}
const hook = createHook(
eventName,
matcherText,
nestedHookRecord.command,
source,
projectDir,
);
if (hook) {
hooks.push(hook);
}
}
}
}
return hooks;
}
function parseSimpleHooksFile(
config: unknown,
source: string,
projectDir: string,
): ResolvedCommandHook[] {
const root = asRecord(config);
const hooksRoot = root ? asRecord(root.hooks) : undefined;
if (!hooksRoot) {
return [];
}
const hooks: ResolvedCommandHook[] = [];
const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"];
for (const eventName of events) {
const entries = getHookEntries(hooksRoot, eventName);
for (const entry of entries) {
const entryRecord = asRecord(entry);
if (!entryRecord || typeof entryRecord.command !== "string") {
continue;
}
const matcherText =
typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined;
const hook = createHook(
eventName,
matcherText,
entryRecord.command,
source,
projectDir,
);
if (hook) {
hooks.push(hook);
}
}
}
return hooks;
}
function loadHooks(cwd: string): HookState {
const projectDir = findProjectDir(cwd);
const claudeSettingsPath = join(projectDir, ".claude", "settings.json");
const ruleSyncHooksPath = join(projectDir, ".rulesync", "hooks.json");
const piHooksPath = join(projectDir, ".pi", "hooks.json");
const hooks: ResolvedCommandHook[] = [];
const claudeSettings = readJsonFile(claudeSettingsPath);
if (claudeSettings !== undefined) {
hooks.push(...parseClaudeSettingsHooks(claudeSettings, claudeSettingsPath, projectDir));
}
const ruleSyncHooks = readJsonFile(ruleSyncHooksPath);
if (ruleSyncHooks !== undefined) {
hooks.push(...parseSimpleHooksFile(ruleSyncHooks, ruleSyncHooksPath, projectDir));
}
const piHooks = readJsonFile(piHooksPath);
if (piHooks !== undefined) {
hooks.push(...parseSimpleHooksFile(piHooks, piHooksPath, projectDir));
}
return {
projectDir,
hooks,
};
}
function toClaudeToolName(toolName: string): string {
if (toolName === "ls") {
return "LS";
}
if (toolName.length === 0) {
return toolName;
}
return toolName[0].toUpperCase() + toolName.slice(1);
}
function matchesHook(hook: ResolvedCommandHook, toolName: string): boolean {
if (!hook.matcher) {
return true;
}
const claudeToolName = toClaudeToolName(toolName);
hook.matcher.lastIndex = 0;
if (hook.matcher.test(toolName)) {
return true;
}
hook.matcher.lastIndex = 0;
return hook.matcher.test(claudeToolName);
}
function extractTextContent(content: unknown): string {
if (!Array.isArray(content)) {
return "";
}
const parts: string[] = [];
for (const item of content) {
if (!item || typeof item !== "object") {
continue;
}
const itemRecord = item as Record<string, unknown>;
if (itemRecord.type === "text" && typeof itemRecord.text === "string") {
parts.push(itemRecord.text);
}
}
return parts.join("\n");
}
function normalizeToolInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...input };
const pathValue = typeof input.path === "string" ? input.path : undefined;
if (pathValue !== undefined) {
normalized.file_path = pathValue;
normalized.filePath = pathValue;
}
return normalized;
}
function buildToolResponse(
event: ToolResultEvent,
normalizedInput: Record<string, unknown>,
): Record<string, unknown> {
const response: Record<string, unknown> = {
is_error: event.isError,
isError: event.isError,
content: event.content,
text: extractTextContent(event.content),
details: event.details ?? null,
};
const filePath =
typeof normalizedInput.file_path === "string" ? normalizedInput.file_path : undefined;
if (filePath !== undefined) {
response.file_path = filePath;
response.filePath = filePath;
}
return response;
}
function buildHookPayload(
event: ToolResultEvent,
eventName: HookEventName,
ctx: ExtensionContext,
projectDir: string,
): Record<string, unknown> {
const normalizedInput = normalizeToolInput(event.input);
const sessionId = ctx.sessionManager.getSessionFile() ?? "ephemeral";
return {
session_id: sessionId,
cwd: ctx.cwd,
claude_project_dir: projectDir,
hook_event_name: eventName,
tool_name: toClaudeToolName(event.toolName),
tool_call_id: event.toolCallId,
tool_input: normalizedInput,
tool_response: buildToolResponse(event, normalizedInput),
};
}
function runCommandHook(
command: string,
cwd: string,
payload: Record<string, unknown>,
): Promise<CommandRunResult> {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn("bash", ["-lc", command], {
cwd,
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
let resolved = false;
const finish = (code: number) => {
if (resolved) {
return;
}
resolved = true;
resolve({
code,
stdout,
stderr,
elapsedMs: Date.now() - startedAt,
timedOut,
});
};
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
const killTimer = setTimeout(() => {
child.kill("SIGKILL");
}, 1000);
(killTimer as NodeJS.Timeout & { unref?: () => void }).unref?.();
}, HOOK_TIMEOUT_MS);
(timeout as NodeJS.Timeout & { unref?: () => void }).unref?.();
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error) => {
clearTimeout(timeout);
stderr += `${error.message}\n`;
finish(-1);
});
child.on("close", (code) => {
clearTimeout(timeout);
finish(code ?? -1);
});
try {
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
stderr += `${message}\n`;
}
});
}
function hookEventNameForResult(event: ToolResultEvent): HookEventName {
return event.isError ? "PostToolUseFailure" : "PostToolUse";
}
function formatDuration(elapsedMs: number): string {
if (elapsedMs < 1000) {
return `${elapsedMs}ms`;
}
return `${(elapsedMs / 1000).toFixed(1)}s`;
}
function hookName(command: string): string {
const shPathMatch = command.match(/[^\s|;&]+\.sh\b/);
if (shPathMatch) {
return basename(shPathMatch[0]);
}
const firstToken = command.trim().split(/\s+/)[0] ?? "hook";
return basename(firstToken);
}
export default function(pi: ExtensionAPI) {
let state: HookState = {
projectDir: process.cwd(),
hooks: [],
};
const refreshHooks = (cwd: string) => {
state = loadHooks(cwd);
};
pi.on("session_start", (_event, ctx) => {
refreshHooks(ctx.cwd);
});
pi.on("session_switch", (_event, ctx) => {
refreshHooks(ctx.cwd);
});
pi.on("tool_result", async (event, ctx) => {
if (state.hooks.length === 0) {
return;
}
const eventName = hookEventNameForResult(event);
const matchingHooks = state.hooks.filter(
(hook) => hook.eventName === eventName && matchesHook(hook, event.toolName),
);
if (matchingHooks.length === 0) {
return;
}
const payload = buildHookPayload(event, eventName, ctx, state.projectDir);
const executedCommands = new Set<string>();
for (const hook of matchingHooks) {
if (executedCommands.has(hook.command)) {
continue;
}
executedCommands.add(hook.command);
const result = await runCommandHook(hook.command, state.projectDir, payload);
const name = hookName(hook.command);
const duration = formatDuration(result.elapsedMs);
if (result.code === 0) {
ctx.ui.notify(`󰛢 Hook \`${name}\` executed, took ${duration}`, "info");
continue;
}
const matcherLabel = hook.matcherText ?? "*";
const errorLine =
result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
ctx.ui.notify(
`󰛢 Hook \`${name}\` failed after ${duration} (${matcherLabel}) from ${hook.source}: ${errorLine}`,
"warning",
);
}
});
}
+3 -4
View File
@@ -13,11 +13,10 @@
"vscode-languageserver-protocol": "^3.17.5" "vscode-languageserver-protocol": "^3.17.5"
}, },
"devDependencies": { "devDependencies": {
"@mariozechner/pi-ai": "^0.63.1", "@mariozechner/pi-ai": "^0.56.3",
"@mariozechner/pi-coding-agent": "^0.63.1", "@mariozechner/pi-coding-agent": "^0.56.3",
"@mariozechner/pi-tui": "^0.63.1", "@mariozechner/pi-tui": "^0.56.3",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/turndown": "^5.0.6",
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },
"pi": {}, "pi": {},
@@ -211,12 +211,12 @@ function updateWidget(ctx: ExtensionContext): void {
(resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""), (resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
); );
ctx.ui.setWidget("web-activity", lines); ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0));
} }
function formatEntryLine( function formatEntryLine(
entry: ActivityEntry, entry: ActivityEntry,
theme: ExtensionContext["ui"]["theme"], theme: { fg: (color: string, text: string) => string },
): string { ): string {
const typeStr = entry.type === "api" ? "API" : "GET"; const typeStr = entry.type === "api" ? "API" : "GET";
const target = const target =
@@ -550,7 +550,7 @@ export default function (pi: ExtensionAPI) {
} else { } else {
widgetUnsubscribe?.(); widgetUnsubscribe?.();
widgetUnsubscribe = null; widgetUnsubscribe = null;
ctx.ui.setWidget("web-activity", undefined); ctx.ui.setWidget("web-activity", null);
} }
}, },
}); });
@@ -598,7 +598,7 @@ export default function (pi: ExtensionAPI) {
})), })),
}), }),
async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<any> { async execute(_toolCallId, params, signal, onUpdate, ctx) {
const queryList = params.queries ?? (params.query ? [params.query] : []); const queryList = params.queries ?? (params.query ? [params.query] : []);
const isMultiQuery = queryList.length > 1; const isMultiQuery = queryList.length > 1;
const shouldCurate = params.curate !== false && ctx?.hasUI !== false; const shouldCurate = params.curate !== false && ctx?.hasUI !== false;
@@ -613,10 +613,7 @@ export default function (pi: ExtensionAPI) {
if (shouldCurate) { if (shouldCurate) {
closeCurator(); closeCurator();
let resolvePromise!: (value: unknown) => void; const { promise, resolve: resolvePromise } = Promise.withResolvers<unknown>();
const promise = new Promise<unknown>((resolve) => {
resolvePromise = resolve;
});
const includeContent = params.includeContent ?? false; const includeContent = params.includeContent ?? false;
const searchResults = new Map<number, QueryResultData>(); const searchResults = new Map<number, QueryResultData>();
const allUrls: string[] = []; const allUrls: string[] = [];
@@ -640,7 +637,7 @@ export default function (pi: ExtensionAPI) {
queryList, queryList,
includeContent, includeContent,
numResults: params.numResults, numResults: params.numResults,
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined, recencyFilter: params.recencyFilter,
domainFilter: params.domainFilter, domainFilter: params.domainFilter,
availableProviders, availableProviders,
defaultProvider, defaultProvider,
@@ -687,7 +684,7 @@ export default function (pi: ExtensionAPI) {
const { answer, results } = await search(queryList[qi], { const { answer, results } = await search(queryList[qi], {
provider: defaultProvider as SearchProvider | undefined, provider: defaultProvider as SearchProvider | undefined,
numResults: params.numResults, numResults: params.numResults,
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined, recencyFilter: params.recencyFilter,
domainFilter: params.domainFilter, domainFilter: params.domainFilter,
signal, signal,
}); });
@@ -757,7 +754,7 @@ export default function (pi: ExtensionAPI) {
text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`; text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`;
} }
return { return {
content: [{ type: "text" as const, text }], content: [{ type: "text", text }],
details: { details: {
phase: "curate-window", phase: "curate-window",
searchCount: searchResults.size, searchCount: searchResults.size,
@@ -827,7 +824,7 @@ export default function (pi: ExtensionAPI) {
const { answer, results } = await search(query, { const { answer, results } = await search(query, {
provider: resolvedProvider as SearchProvider | undefined, provider: resolvedProvider as SearchProvider | undefined,
numResults: params.numResults, numResults: params.numResults,
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined, recencyFilter: params.recencyFilter,
domainFilter: params.domainFilter, domainFilter: params.domainFilter,
signal, signal,
}); });
@@ -1120,10 +1117,7 @@ export default function (pi: ExtensionAPI) {
`Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`; `Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
} }
const content: Array< const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [];
| { type: "image"; data: string; mimeType: string }
| { type: "text"; text: string }
> = [];
if (result.frames?.length) { if (result.frames?.length) {
for (const frame of result.frames) { for (const frame of result.frames) {
content.push({ type: "image", data: frame.data, mimeType: frame.mimeType }); content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
@@ -1296,7 +1290,7 @@ export default function (pi: ExtensionAPI) {
urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })), urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
}), }),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx): Promise<any> { async execute(_toolCallId, params) {
const data = getResult(params.responseId); const data = getResult(params.responseId);
if (!data) { if (!data) {
return { return {
@@ -1483,7 +1477,7 @@ export default function (pi: ExtensionAPI) {
pi.sendMessage({ pi.sendMessage({
customType: "web-search-results", customType: "web-search-results",
content: [{ type: "text", text }], content: [{ type: "text", text }],
display: true, display: "tool",
details: { queryCount: results.length, totalResults: urls.length }, details: { queryCount: results.length, totalResults: urls.length },
}, { triggerTurn: true, deliverAs: "followUp" }); }, { triggerTurn: true, deliverAs: "followUp" });
} }
@@ -42,10 +42,9 @@ export async function extractPDFToMarkdown(
const pdf = await getDocumentProxy(new Uint8Array(buffer)); const pdf = await getDocumentProxy(new Uint8Array(buffer));
const metadata = await pdf.getMetadata(); const metadata = await pdf.getMetadata();
const info = (metadata.info ?? {}) as Record<string, unknown>;
// Extract title from metadata or URL // Extract title from metadata or URL
const metaTitle = typeof info.Title === "string" ? info.Title : undefined; const metaTitle = metadata.info?.Title as string | undefined;
const urlTitle = extractTitleFromURL(url); const urlTitle = extractTitleFromURL(url);
const title = metaTitle?.trim() || urlTitle; const title = metaTitle?.trim() || urlTitle;
@@ -80,9 +79,8 @@ export async function extractPDFToMarkdown(
lines.push(""); lines.push("");
lines.push(`> Source: ${url}`); lines.push(`> Source: ${url}`);
lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`); lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`);
const author = typeof info.Author === "string" ? info.Author : undefined; if (metadata.info?.Author) {
if (author) { lines.push(`> Author: ${metadata.info.Author}`);
lines.push(`> Author: ${author}`);
} }
lines.push(""); lines.push("");
lines.push("---"); lines.push("---");
@@ -245,8 +245,8 @@ export async function condenseSearchResults(
const model = ctx.modelRegistry.find(provider, modelId); const model = ctx.modelRegistry.find(provider, modelId);
if (!model) return null; if (!model) return null;
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!auth.ok) return null; if (!apiKey) return null;
const queryData = [...results.entries()] const queryData = [...results.entries()]
.sort((a, b) => a[0] - b[0]) .sort((a, b) => a[0] - b[0])
@@ -281,8 +281,7 @@ export async function condenseSearchResults(
: timeoutSignal; : timeoutSignal;
const response = await complete(model, aiContext, { const response = await complete(model, aiContext, {
apiKey: auth.apiKey, apiKey,
headers: auth.headers,
signal: combinedSignal, signal: combinedSignal,
max_tokens: MAX_TOKENS, max_tokens: MAX_TOKENS,
} as any); } as any);
+22 -31
View File
@@ -34,20 +34,17 @@ importers:
version: 3.17.5 version: 3.17.5
devDependencies: devDependencies:
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: ^0.63.1 specifier: ^0.56.3
version: 0.63.1(ws@8.19.0)(zod@4.3.6) version: 0.56.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: ^0.63.1 specifier: ^0.56.3
version: 0.63.1(ws@8.19.0)(zod@4.3.6) version: 0.56.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': '@mariozechner/pi-tui':
specifier: ^0.63.1 specifier: ^0.56.3
version: 0.63.1 version: 0.56.3
'@types/node': '@types/node':
specifier: ^25.3.3 specifier: ^25.3.3
version: 25.3.3 version: 25.3.3
'@types/turndown':
specifier: ^5.0.6
version: 5.0.6
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
@@ -292,22 +289,22 @@ packages:
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
hasBin: true hasBin: true
'@mariozechner/pi-agent-core@0.63.1': '@mariozechner/pi-agent-core@0.56.3':
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==} resolution: {integrity: sha512-TsI1zENf3wqqKPaERnj486Q4i6Y/y6lAZipLNcfDYUDxDrLwNfQ9EW9xukkbJfTZ8zjG3VZ2pBZe3C7wM51dVQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.63.1': '@mariozechner/pi-ai@0.56.3':
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==} resolution: {integrity: sha512-l4J+cVyVeBLAlGOY/osGDvsbTz0DySCQmR171G6SdbPvIeLGhIi6siZ+zHwq91GJYjv/wtu/08M08ag2mGZKeA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.63.1': '@mariozechner/pi-coding-agent@0.56.3':
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==} resolution: {integrity: sha512-yHgnadye+TT/4NWKBirZUjw/LWdNWTa7M4HJdX2RxRbwuj4q7RZ0Aqy+lQbOHEPDQYhxK3kZb9hjiAbbGficZQ==}
engines: {node: '>=20.6.0'} engines: {node: '>=20.6.0'}
hasBin: true hasBin: true
'@mariozechner/pi-tui@0.63.1': '@mariozechner/pi-tui@0.56.3':
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==} resolution: {integrity: sha512-eZ1P9QRKHp78hwx+lITr/mujZqe+eCwL/bOS9vXXkFP070RW4VYum0j7TJ4BrFEH/nNkXRS1tYCXYU05une1bA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.14.1': '@mistralai/mistralai@1.14.1':
@@ -571,9 +568,6 @@ packages:
'@types/retry@0.12.0': '@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -1728,9 +1722,9 @@ snapshots:
std-env: 3.10.0 std-env: 3.10.0
yoctocolors: 2.1.2 yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.63.1(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-agent-core@0.56.3(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.63.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.56.3(ws@8.19.0)(zod@4.3.6)
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
- aws-crt - aws-crt
@@ -1740,7 +1734,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.63.1(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-ai@0.56.3(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1002.0 '@aws-sdk/client-bedrock-runtime': 3.1002.0
@@ -1764,14 +1758,13 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-coding-agent@0.63.1(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-coding-agent@0.56.3(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/jiti': 2.6.5 '@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.63.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-agent-core': 0.56.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.63.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.56.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.63.1 '@mariozechner/pi-tui': 0.56.3
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
ajv: 8.18.0
chalk: 5.6.2 chalk: 5.6.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
diff: 8.0.3 diff: 8.0.3
@@ -1797,7 +1790,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-tui@0.63.1': '@mariozechner/pi-tui@0.56.3':
dependencies: dependencies:
'@types/mime-types': 2.1.4 '@types/mime-types': 2.1.4
chalk: 5.6.2 chalk: 5.6.2
@@ -2173,8 +2166,6 @@ snapshots:
'@types/retry@0.12.0': {} '@types/retry@0.12.0': {}
'@types/turndown@5.0.6': {}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 25.3.3 '@types/node': 25.3.3
+249 -250
View File
@@ -14,9 +14,9 @@ import { complete, type Message } from "@mariozechner/pi-ai";
import { getModel } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai";
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
import { import {
BorderedLoader, BorderedLoader,
convertToLlm, convertToLlm,
serializeConversation, serializeConversation,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
@@ -43,307 +43,306 @@ Output ONLY the session name, nothing else.`;
const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5"); const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5");
// Number of messages before auto-naming kicks in // Number of messages before auto-naming kicks in
const AUTO_NAME_THRESHOLD = 1; const AUTO_NAME_THRESHOLD = 2;
// Debug log file // Debug log file
const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log"); const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log");
function log(message: string) { function log(message: string) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const entry = `[${timestamp}] ${message}\n`; const entry = `[${timestamp}] ${message}\n`;
try { try {
fs.appendFileSync(LOG_FILE, entry); fs.appendFileSync(LOG_FILE, entry);
} catch (e) { } catch (e) {
// ignore write errors // ignore write errors
} }
} }
export default function(pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
// Track if we've already attempted auto-naming for this session // Track if we've already attempted auto-naming for this session
let autoNamedAttempted = false; let autoNamedAttempted = false;
// Listen for agent_end to auto-name sessions (non-blocking) // Listen for agent_end to auto-name sessions (non-blocking)
pi.on("agent_end", (_event, ctx) => { pi.on("agent_end", (_event, ctx) => {
log("=== agent_end triggered ==="); log("=== agent_end triggered ===");
log(`hasUI: ${ctx.hasUI}`); log(`hasUI: ${ctx.hasUI}`);
log(`current session name: ${pi.getSessionName()}`); log(`current session name: ${pi.getSessionName()}`);
log(`autoNamedAttempted: ${autoNamedAttempted}`); log(`autoNamedAttempted: ${autoNamedAttempted}`);
// Skip if already has a name or already attempted // Skip if already has a name or already attempted
if (pi.getSessionName() || autoNamedAttempted) { if (pi.getSessionName() || autoNamedAttempted) {
log("Skipping: already has name or already attempted"); log("Skipping: already has name or already attempted");
return; return;
} }
// Count user messages in the branch // Count user messages in the branch
const branch = ctx.sessionManager.getBranch(); const branch = ctx.sessionManager.getBranch();
const userMessages = branch.filter( const userMessages = branch.filter(
(entry): entry is SessionEntry & { type: "message" } => (entry): entry is SessionEntry & { type: "message" } =>
entry.type === "message" && entry.message.role === "user", entry.type === "message" && entry.message.role === "user",
); );
log(`Total entries in branch: ${branch.length}`); log(`Total entries in branch: ${branch.length}`);
log(`User messages: ${userMessages.length}`); log(`User messages: ${userMessages.length}`);
log(`Threshold: ${AUTO_NAME_THRESHOLD}`); log(`Threshold: ${AUTO_NAME_THRESHOLD}`);
// Only auto-name after threshold is reached // Only auto-name after threshold is reached
if (userMessages.length < AUTO_NAME_THRESHOLD) { if (userMessages.length < AUTO_NAME_THRESHOLD) {
log("Skipping: below threshold"); log("Skipping: below threshold");
return; return;
} }
// Mark as attempted so we don't try again // Mark as attempted so we don't try again
autoNamedAttempted = true; autoNamedAttempted = true;
log("Threshold reached, attempting auto-name"); log("Threshold reached, attempting auto-name");
// Only auto-name in interactive mode // Only auto-name in interactive mode
if (!ctx.hasUI) { if (!ctx.hasUI) {
log("Skipping: no UI (non-interactive mode)"); log("Skipping: no UI (non-interactive mode)");
return; return;
} }
// Gather conversation context // Gather conversation context
const messages = branch const messages = branch
.filter( .filter(
(entry): entry is SessionEntry & { type: "message" } => (entry): entry is SessionEntry & { type: "message" } =>
entry.type === "message", entry.type === "message",
) )
.map((entry) => entry.message); .map((entry) => entry.message);
log(`Total messages to analyze: ${messages.length}`); log(`Total messages to analyze: ${messages.length}`);
if (messages.length === 0) { if (messages.length === 0) {
log("No messages found, aborting"); log("No messages found, aborting");
return; return;
} }
// Convert to LLM format and serialize // Convert to LLM format and serialize
const llmMessages = convertToLlm(messages); const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages); const conversationText = serializeConversation(llmMessages);
log(`Conversation text length: ${conversationText.length}`); log(`Conversation text length: ${conversationText.length}`);
// Truncate if too long (keep costs low) // Truncate if too long (keep costs low)
const maxChars = 4000; const maxChars = 4000;
const truncatedText = const truncatedText =
conversationText.length > maxChars conversationText.length > maxChars
? conversationText.slice(0, maxChars) + "\n..." ? conversationText.slice(0, maxChars) + "\n..."
: conversationText; : conversationText;
log(`Truncated text length: ${truncatedText.length}`); log(`Truncated text length: ${truncatedText.length}`);
log("Starting background auto-name..."); log("Starting background auto-name...");
// Fire-and-forget: run auto-naming in background without blocking // Fire-and-forget: run auto-naming in background without blocking
const doAutoName = async () => { const doAutoName = async () => {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL); const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
log(`Got API key: ${auth.ok ? "yes" : "no"}`); log(`Got API key: ${apiKey ? "yes" : "no"}`);
if (!auth.ok) { if (!apiKey) {
log(`No API key available, aborting: ${auth.error}`); log("No API key available, aborting");
return; return;
} }
const userMessage: Message = { const userMessage: Message = {
role: "user", role: "user",
content: [ content: [
{ {
type: "text", type: "text",
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`, text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
}, },
], ],
timestamp: Date.now(), timestamp: Date.now(),
}; };
const response = await complete( const response = await complete(
AUTO_NAME_MODEL, AUTO_NAME_MODEL,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey: auth.apiKey, headers: auth.headers }, { apiKey },
); );
log(`Response received, stopReason: ${response.stopReason}`); log(`Response received, stopReason: ${response.stopReason}`);
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
log("Request was aborted"); log("Request was aborted");
return; return;
} }
const name = response.content const name = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text") .filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text.trim()) .map((c) => c.text.trim())
.join(" ") .join(" ")
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes .replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
log(`Generated name: "${name}"`); log(`Generated name: "${name}"`);
// Clean up the generated name // Clean up the generated name
const cleanName = name const cleanName = name
.replace(/\n/g, " ") .replace(/\n/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() .trim()
.slice(0, 50); // Max 50 chars .slice(0, 50); // Max 50 chars
log(`Cleaned name: "${cleanName}"`); log(`Cleaned name: "${cleanName}"`);
if (cleanName) { if (cleanName) {
pi.setSessionName(cleanName); pi.setSessionName(cleanName);
ctx.ui.notify(`Auto-named: ${cleanName}`, "info"); ctx.ui.notify(`Auto-named: ${cleanName}`, "info");
log(`Successfully set session name to: ${cleanName}`); log(`Successfully set session name to: ${cleanName}`);
} else { } else {
log("Cleaned name was empty, not setting"); log("Cleaned name was empty, not setting");
} }
}; };
// Run in background without awaiting - don't block the agent // Run in background without awaiting - don't block the agent
doAutoName().catch((err) => { doAutoName().catch((err) => {
log(`ERROR: ${err}`); log(`ERROR: ${err}`);
console.error("Auto-naming failed:", err); console.error("Auto-naming failed:", err);
}); });
}); });
// Reset flag on new session // Reset flag on new session
pi.on("session_start", () => { pi.on("session_start", () => {
log("=== session_start ==="); log("=== session_start ===");
autoNamedAttempted = false; autoNamedAttempted = false;
log("Reset autoNamedAttempted to false"); log("Reset autoNamedAttempted to false");
}); });
pi.on("session_switch", () => { pi.on("session_switch", () => {
log("=== session_switch ==="); log("=== session_switch ===");
autoNamedAttempted = false; autoNamedAttempted = false;
log("Reset autoNamedAttempted to false"); log("Reset autoNamedAttempted to false");
}); });
// Manual command for setting/getting session name // Manual command for setting/getting session name
pi.registerCommand("session-name", { pi.registerCommand("session-name", {
description: description:
"Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)", "Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)",
handler: async (args, ctx) => { handler: async (args, ctx) => {
const trimmedArgs = args.trim(); const trimmedArgs = args.trim();
// Show current name if no args // Show current name if no args
if (!trimmedArgs) { if (!trimmedArgs) {
const current = pi.getSessionName(); const current = pi.getSessionName();
ctx.ui.notify( ctx.ui.notify(
current ? `Session: ${current}` : "No session name set", current ? `Session: ${current}` : "No session name set",
"info", "info",
); );
return; return;
} }
// Auto-generate name using AI // Auto-generate name using AI
if (trimmedArgs === "--auto" || trimmedArgs === "-a") { if (trimmedArgs === "--auto" || trimmedArgs === "-a") {
if (!ctx.hasUI) { if (!ctx.hasUI) {
ctx.ui.notify("Auto-naming requires interactive mode", "error"); ctx.ui.notify("Auto-naming requires interactive mode", "error");
return; return;
} }
// Gather conversation context // Gather conversation context
const branch = ctx.sessionManager.getBranch(); const branch = ctx.sessionManager.getBranch();
const messages = branch const messages = branch
.filter( .filter(
(entry): entry is SessionEntry & { type: "message" } => (entry): entry is SessionEntry & { type: "message" } =>
entry.type === "message", entry.type === "message",
) )
.map((entry) => entry.message); .map((entry) => entry.message);
if (messages.length === 0) { if (messages.length === 0) {
ctx.ui.notify("No conversation to analyze", "error"); ctx.ui.notify("No conversation to analyze", "error");
return; return;
} }
// Convert to LLM format and serialize // Convert to LLM format and serialize
const llmMessages = convertToLlm(messages); const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages); const conversationText = serializeConversation(llmMessages);
// Truncate if too long (keep costs low) // Truncate if too long (keep costs low)
const maxChars = 4000; const maxChars = 4000;
const truncatedText = const truncatedText =
conversationText.length > maxChars conversationText.length > maxChars
? conversationText.slice(0, maxChars) + "\n..." ? conversationText.slice(0, maxChars) + "\n..."
: conversationText; : conversationText;
// Generate name with loader UI // Generate name with loader UI
const result = await ctx.ui.custom<string | null>( const result = await ctx.ui.custom<string | null>(
(tui, theme, _kb, done) => { (tui, theme, _kb, done) => {
const loader = new BorderedLoader( const loader = new BorderedLoader(
tui, tui,
theme, theme,
"Generating session name...", "Generating session name...",
); );
loader.onAbort = () => done(null); loader.onAbort = () => done(null);
const doGenerate = async () => { const doGenerate = async () => {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL); const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
if (!auth.ok) throw new Error(auth.error);
const userMessage: Message = { const userMessage: Message = {
role: "user", role: "user",
content: [ content: [
{ {
type: "text", type: "text",
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`, text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
}, },
], ],
timestamp: Date.now(), timestamp: Date.now(),
}; };
const response = await complete( const response = await complete(
AUTO_NAME_MODEL, AUTO_NAME_MODEL,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal }, { apiKey, signal: loader.signal },
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
return null; return null;
} }
const name = response.content const name = response.content
.filter( .filter(
(c): c is { type: "text"; text: string } => c.type === "text", (c): c is { type: "text"; text: string } => c.type === "text",
) )
.map((c) => c.text.trim()) .map((c) => c.text.trim())
.join(" ") .join(" ")
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes .replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
return name; return name;
}; };
doGenerate() doGenerate()
.then(done) .then(done)
.catch((err) => { .catch((err) => {
console.error("Auto-naming failed:", err); console.error("Auto-naming failed:", err);
done(null); done(null);
}); });
return loader; return loader;
}, },
); );
if (result === null) { if (result === null) {
ctx.ui.notify("Auto-naming cancelled", "info"); ctx.ui.notify("Auto-naming cancelled", "info");
return; return;
} }
// Clean up the generated name // Clean up the generated name
const cleanName = result const cleanName = result
.replace(/\n/g, " ") .replace(/\n/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() .trim()
.slice(0, 50); // Max 50 chars .slice(0, 50); // Max 50 chars
if (!cleanName) { if (!cleanName) {
ctx.ui.notify("Failed to generate name", "error"); ctx.ui.notify("Failed to generate name", "error");
return; return;
} }
pi.setSessionName(cleanName); pi.setSessionName(cleanName);
ctx.ui.notify(`Session auto-named: ${cleanName}`, "info"); ctx.ui.notify(`Session auto-named: ${cleanName}`, "info");
return; return;
} }
// Manual naming // Manual naming
pi.setSessionName(trimmedArgs); pi.setSessionName(trimmedArgs);
ctx.ui.notify(`Session named: ${trimmedArgs}`, "info"); ctx.ui.notify(`Session named: ${trimmedArgs}`, "info");
}, },
}); });
} }
@@ -1,60 +0,0 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function walkUpDirectories(startDir: string, stopDir?: string): string[] {
const directories: string[] = [];
const hasStopDir = stopDir !== undefined;
let current = resolve(startDir);
let parent = dirname(current);
let reachedStopDir = hasStopDir && current === stopDir;
let reachedFilesystemRoot = parent === current;
directories.push(current);
while (!reachedStopDir && !reachedFilesystemRoot) {
current = parent;
parent = dirname(current);
reachedStopDir = hasStopDir && current === stopDir;
reachedFilesystemRoot = parent === current;
directories.push(current);
}
return directories;
}
function findNearestGitRoot(startDir: string): string | undefined {
for (const directory of walkUpDirectories(startDir)) {
if (existsSync(join(directory, ".git"))) {
return directory;
}
}
return undefined;
}
function findClaudeSkillDirs(cwd: string): string[] {
const gitRoot = findNearestGitRoot(cwd);
return walkUpDirectories(cwd, gitRoot)
.map((directory) => join(directory, ".claude", "skills"))
.filter(isDirectory);
}
export default function(pi: ExtensionAPI) {
pi.on("resources_discover", (event) => {
const skillPaths = findClaudeSkillDirs(event.cwd);
if (skillPaths.length === 0) {
return;
}
return { skillPaths };
});
}
-707
View File
@@ -1,707 +0,0 @@
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import * as fs from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
type ProviderName = "anthropic" | "codex" | "gemini" | "opencode-go";
interface RateWindow {
label: string;
usedPercent: number;
resetDescription?: string;
resetAt?: string;
}
interface UsageSnapshot {
provider: ProviderName;
displayName: string;
windows: RateWindow[];
error?: string;
fetchedAt: number;
lastSuccessAt?: number;
fromCache?: boolean;
}
interface ProviderCache {
usage?: UsageSnapshot;
lastSuccessAt?: number;
}
interface CodexRateWindow {
reset_at?: number;
limit_window_seconds?: number;
used_percent?: number;
}
interface CodexRateLimit {
primary_window?: CodexRateWindow;
secondary_window?: CodexRateWindow;
}
interface PiAuthShape {
anthropic?: { access?: string };
"google-gemini-cli"?: { access?: string };
"openai-codex"?: { access?: string; accountId?: string };
}
const CACHE_TTL_MS = 5 * 60 * 1000;
const ANTHROPIC_CACHE_TTL_MS = 30 * 60 * 1000;
const REFRESH_MS = 5 * 60 * 1000;
const PROVIDER_ORDER: ProviderName[] = ["anthropic", "codex", "gemini", "opencode-go"];
const SHORTCUT_TOGGLE = "ctrl+alt+b";
const SHORTCUT_BAR_STYLE = "ctrl+alt+t";
const showToggleState = async (ctx: ExtensionContext, next: boolean, refresh: () => Promise<void>) => {
if (!next) {
ctx.ui.setWidget("sub-bar-local", undefined);
ctx.ui.notify("sub bar hidden", "info");
return;
}
ctx.ui.notify("sub bar shown", "info");
await refresh();
};
const OPENCODE_CONFIG_FILE = join(homedir(), ".config", "opencode", "opencode-go-usage.json");
const PI_AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
function readJsonFile<T>(path: string): T | undefined {
try {
if (!fs.existsSync(path)) return undefined;
const content = fs.readFileSync(path, "utf-8");
return JSON.parse(content) as T;
} catch {
return undefined;
}
}
function clampPercent(value: number | undefined): number {
if (typeof value !== "number" || Number.isNaN(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function formatDuration(seconds?: number): string | undefined {
if (typeof seconds !== "number" || seconds <= 0) return undefined;
if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
return h > 0 ? `${d}d ${h}h` : `${d}d`;
}
function formatResetDate(resetIso?: string): string | undefined {
if (!resetIso) return undefined;
const resetDate = new Date(resetIso);
if (Number.isNaN(resetDate.getTime())) return undefined;
const diffSec = Math.max(0, Math.floor((resetDate.getTime() - Date.now()) / 1000));
return formatDuration(diffSec);
}
function getErrorAge(lastSuccessAt?: number): string | undefined {
if (!lastSuccessAt) return undefined;
const diffSec = Math.floor((Date.now() - lastSuccessAt) / 1000);
return formatDuration(diffSec);
}
function getPiAuth(): PiAuthShape {
return readJsonFile<PiAuthShape>(PI_AUTH_FILE) ?? {};
}
function loadAnthropicToken(): string | undefined {
const env = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (env) return env;
return getPiAuth().anthropic?.access;
}
function loadGeminiToken(): string | undefined {
const env = (
process.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN ||
process.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN ||
process.env.GEMINI_OAUTH_TOKEN
)?.trim();
if (env) return env;
return getPiAuth()["google-gemini-cli"]?.access;
}
function loadCodexCredentials(): { accessToken?: string; accountId?: string } {
const envToken = (
process.env.OPENAI_CODEX_OAUTH_TOKEN ||
process.env.OPENAI_CODEX_ACCESS_TOKEN ||
process.env.CODEX_OAUTH_TOKEN ||
process.env.CODEX_ACCESS_TOKEN
)?.trim();
const envAccountId = (process.env.OPENAI_CODEX_ACCOUNT_ID || process.env.CHATGPT_ACCOUNT_ID)?.trim();
if (envToken) {
return { accessToken: envToken, accountId: envAccountId || undefined };
}
const auth = getPiAuth()["openai-codex"];
if (!auth?.access) return {};
return { accessToken: auth.access, accountId: auth.accountId };
}
function loadOpenCodeGoCredentials(): { workspaceId?: string; authCookie?: string } {
const envWorkspaceId = process.env.OPENCODE_GO_WORKSPACE_ID?.trim();
const envAuthCookie = process.env.OPENCODE_GO_AUTH_COOKIE?.trim();
if (envWorkspaceId && envAuthCookie) {
return { workspaceId: envWorkspaceId, authCookie: envAuthCookie };
}
const config = readJsonFile<{ workspaceId?: string; authCookie?: string }>(OPENCODE_CONFIG_FILE);
return {
workspaceId: config?.workspaceId?.trim(),
authCookie: config?.authCookie?.trim(),
};
}
function modelToProvider(modelProvider?: string): ProviderName | undefined {
const id = (modelProvider ?? "").toLowerCase();
if (id.includes("opencode-go")) return "opencode-go";
if (id.includes("anthropic")) return "anthropic";
if (id.includes("gemini") || id.includes("google")) return "gemini";
if (id.includes("codex") || id.includes("openai")) return "codex";
return undefined;
}
function codexWindowLabel(window: CodexRateWindow | undefined, fallback: string): string {
if (!window || typeof window.limit_window_seconds !== "number" || window.limit_window_seconds <= 0) {
return fallback;
}
return formatDuration(window.limit_window_seconds) ?? fallback;
}
function pushCodexWindow(windows: RateWindow[], fallbackLabel: string, window?: CodexRateWindow): void {
if (!window) return;
const resetIso = typeof window.reset_at === "number" ? new Date(window.reset_at * 1000).toISOString() : undefined;
windows.push({
label: codexWindowLabel(window, fallbackLabel),
usedPercent: clampPercent(window.used_percent),
resetAt: resetIso,
});
}
function barForPercent(theme: Theme, usedPercent: number, width = 8, style: "thin" | "thick" = "thick"): string {
const safeWidth = Math.max(1, width);
const filled = Math.round((clampPercent(usedPercent) / 100) * safeWidth);
const empty = Math.max(0, safeWidth - filled);
const color = usedPercent >= 85 ? "error" : usedPercent >= 60 ? "warning" : "success";
const filledChar = style === "thin" ? "─" : "█";
const emptyChar = style === "thin" ? "─" : "░";
return `${theme.fg(color, filledChar.repeat(filled))}${theme.fg("dim", emptyChar.repeat(empty))}`;
}
function padToWidth(text: string, width: number): string {
const w = visibleWidth(text);
if (w >= width) return truncateToWidth(text, width);
return text + " ".repeat(width - w);
}
function buildColumnWidths(totalWidth: number, columns: number): number[] {
if (columns <= 0) return [];
const base = Math.max(1, Math.floor(totalWidth / columns));
let remainder = Math.max(0, totalWidth - base * columns);
const widths: number[] = [];
for (let i = 0; i < columns; i++) {
const extra = remainder > 0 ? 1 : 0;
widths.push(base + extra);
if (remainder > 0) remainder--;
}
return widths;
}
function formatUsageTwoLines(
theme: Theme,
usage: UsageSnapshot,
width: number,
statusNote?: string,
barStyle: "thin" | "thick" = "thick",
): { top: string; bottom?: string } {
const provider = theme.bold(theme.fg("accent", usage.displayName));
if (usage.error && usage.windows.length === 0) {
const age = getErrorAge(usage.lastSuccessAt);
const stale = age ? ` (stale ${age})` : "";
return { top: truncateToWidth(`${provider} ${theme.fg("warning", `${usage.error}${stale}`)}`, width) };
}
if (usage.windows.length === 0) {
return { top: truncateToWidth(provider, width) };
}
const prefix = `${provider} ${theme.fg("dim", "•")} `;
const prefixWidth = Math.min(visibleWidth(prefix), Math.max(0, width - 1));
const contentWidth = Math.max(1, width - prefixWidth);
const gap = " ";
const gapWidth = visibleWidth(gap);
const windowsCount = usage.windows.length;
const totalGapWidth = Math.max(0, (windowsCount - 1) * gapWidth);
const columnsArea = Math.max(1, contentWidth - totalGapWidth);
const colWidths = buildColumnWidths(columnsArea, windowsCount);
const topCols = usage.windows.map((window, index) => {
const colWidth = colWidths[index] ?? 1;
const pct = clampPercent(window.usedPercent);
const resetRaw = formatResetDate(window.resetAt) ?? window.resetDescription ?? "";
const left = `${window.label} ${pct}%`;
const right = resetRaw;
if (!right) return padToWidth(theme.fg("text", left), colWidth);
const leftWidth = visibleWidth(left);
const rightWidth = visibleWidth(right);
if (leftWidth + 1 + rightWidth <= colWidth) {
const spaces = " ".repeat(colWidth - leftWidth - rightWidth);
return `${theme.fg("text", left)}${spaces}${theme.fg("dim", right)}`;
}
const compact = `${left} ${right}`;
return padToWidth(theme.fg("text", truncateToWidth(compact, colWidth)), colWidth);
});
const bottomCols = usage.windows.map((window, index) => {
const colWidth = colWidths[index] ?? 1;
const pct = clampPercent(window.usedPercent);
return barForPercent(theme, pct, colWidth, barStyle);
});
let top = prefix + topCols.join(gap);
if (statusNote) {
const note = theme.fg("warning", `${statusNote}`);
top = truncateToWidth(top + note, width);
}
const bottomPrefix = " ".repeat(prefixWidth);
const bottom = bottomPrefix + bottomCols.join(gap);
return {
top: padToWidth(top, width),
bottom: padToWidth(bottom, width),
};
}
async function fetchAnthropicUsage(): Promise<UsageSnapshot> {
const token = loadAnthropicToken();
if (!token) throw new Error("missing anthropic oauth token");
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
headers: {
Authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
},
});
if (!response.ok) throw new Error(`anthropic http ${response.status}`);
const data = (await response.json()) as {
five_hour?: { utilization?: number; resets_at?: string };
seven_day?: { utilization?: number; resets_at?: string };
extra_usage?: { utilization?: number; used_credits?: number; monthly_limit?: number; is_enabled?: boolean };
};
const windows: RateWindow[] = [];
if (data.five_hour?.utilization !== undefined) {
windows.push({ label: "5h", usedPercent: clampPercent(data.five_hour.utilization), resetAt: data.five_hour.resets_at });
}
if (data.seven_day?.utilization !== undefined) {
windows.push({ label: "Week", usedPercent: clampPercent(data.seven_day.utilization), resetAt: data.seven_day.resets_at });
}
// hide Anthropic extra_usage window for now (it is confusing/noisy for premium users)
if (windows.length === 0) throw new Error("no anthropic usage windows");
const now = Date.now();
return {
provider: "anthropic",
displayName: "Claude Plan",
windows,
fetchedAt: now,
lastSuccessAt: now,
};
}
async function fetchCodexUsage(): Promise<UsageSnapshot> {
const { accessToken, accountId } = loadCodexCredentials();
if (!accessToken) throw new Error("missing codex oauth token");
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers });
if (!response.ok) throw new Error(`codex http ${response.status}`);
const data = (await response.json()) as {
rate_limit?: CodexRateLimit;
};
const windows: RateWindow[] = [];
pushCodexWindow(windows, "3h", data.rate_limit?.primary_window);
pushCodexWindow(windows, "Day", data.rate_limit?.secondary_window);
if (windows.length === 0) throw new Error("no codex usage windows");
const now = Date.now();
return {
provider: "codex",
displayName: "Codex Plan",
windows,
fetchedAt: now,
lastSuccessAt: now,
};
}
async function fetchGeminiUsage(): Promise<UsageSnapshot> {
const token = loadGeminiToken();
if (!token) throw new Error("missing gemini oauth token");
const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: "{}",
});
if (!response.ok) throw new Error(`gemini http ${response.status}`);
const data = (await response.json()) as {
buckets?: Array<{ modelId?: string; remainingFraction?: number }>;
};
let minPro = 1;
let minFlash = 1;
let hasPro = false;
let hasFlash = false;
for (const bucket of data.buckets ?? []) {
const id = bucket.modelId?.toLowerCase() ?? "";
const remaining = typeof bucket.remainingFraction === "number" ? bucket.remainingFraction : 1;
if (id.includes("pro")) {
hasPro = true;
minPro = Math.min(minPro, remaining);
}
if (id.includes("flash")) {
hasFlash = true;
minFlash = Math.min(minFlash, remaining);
}
}
const windows: RateWindow[] = [];
if (hasPro) windows.push({ label: "Pro", usedPercent: clampPercent((1 - minPro) * 100) });
if (hasFlash) windows.push({ label: "Flash", usedPercent: clampPercent((1 - minFlash) * 100) });
if (windows.length === 0) throw new Error("no gemini usage windows");
const now = Date.now();
return {
provider: "gemini",
displayName: "Gemini Plan",
windows,
fetchedAt: now,
lastSuccessAt: now,
};
}
function parseInlineObject(raw: string): Record<string, unknown> {
const normalized = raw.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, "$1\"$2\"$3");
return JSON.parse(normalized) as Record<string, unknown>;
}
async function fetchOpenCodeGoUsage(): Promise<UsageSnapshot> {
const { workspaceId, authCookie } = loadOpenCodeGoCredentials();
if (!workspaceId || !authCookie) throw new Error(`missing ${OPENCODE_CONFIG_FILE} credentials`);
if (!/^wrk_[a-zA-Z0-9]+$/.test(workspaceId)) throw new Error("invalid workspace id format");
const url = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Cookie: `auth=${authCookie}`,
},
});
if (!response.ok) throw new Error(`opencode-go http ${response.status}`);
const html = await response.text();
const patterns: Record<string, RegExp> = {
Rolling: /rollingUsage:\$R\[\d+\]=(\{[^}]+\})/,
Weekly: /weeklyUsage:\$R\[\d+\]=(\{[^}]+\})/,
Monthly: /monthlyUsage:\$R\[\d+\]=(\{[^}]+\})/,
};
const windows: RateWindow[] = [];
for (const [label, pattern] of Object.entries(patterns)) {
const match = html.match(pattern);
if (!match?.[1]) continue;
try {
const parsed = parseInlineObject(match[1]);
const usagePercent = clampPercent(Number(parsed.usagePercent));
const resetSec = Number(parsed.resetInSec);
const resetDescription = formatDuration(Number.isFinite(resetSec) ? resetSec : undefined);
const resetAt = Number.isFinite(resetSec) && resetSec > 0
? new Date(Date.now() + resetSec * 1000).toISOString()
: undefined;
windows.push({
label,
usedPercent: usagePercent,
resetDescription,
resetAt,
});
} catch {
// Ignore malformed chunks and keep parsing others
}
}
if (windows.length === 0) throw new Error("could not parse opencode-go usage from page");
const now = Date.now();
return {
provider: "opencode-go",
displayName: "OpenCode Go",
windows,
fetchedAt: now,
lastSuccessAt: now,
};
}
async function fetchUsage(provider: ProviderName): Promise<UsageSnapshot> {
switch (provider) {
case "anthropic":
return fetchAnthropicUsage();
case "codex":
return fetchCodexUsage();
case "gemini":
return fetchGeminiUsage();
case "opencode-go":
return fetchOpenCodeGoUsage();
}
}
export default function createSubBarLocal(pi: ExtensionAPI) {
let lastCtx: ExtensionContext | undefined;
let activeProvider: ProviderName | "auto" = "auto";
let widgetEnabled = true;
let barStyle: "thin" | "thick" = "thick";
let refreshTimer: NodeJS.Timeout | undefined;
let anthropicRetryAfter = 0;
const cache: Partial<Record<ProviderName, ProviderCache>> = {};
function getSelectedProvider(ctx?: ExtensionContext): ProviderName | undefined {
if (activeProvider !== "auto") return activeProvider;
return modelToProvider(ctx?.model?.provider);
}
function render(ctx: ExtensionContext): void {
if (!ctx.hasUI) return;
if (!widgetEnabled) {
ctx.ui.setWidget("sub-bar-local", undefined);
return;
}
const provider = getSelectedProvider(ctx);
if (!provider) {
ctx.ui.setWidget("sub-bar-local", undefined);
return;
}
const snapshot = cache[provider]?.usage;
const setWidgetWithPlacement = (ctx.ui as unknown as { setWidget: (...args: unknown[]) => void }).setWidget;
setWidgetWithPlacement(
"sub-bar-local",
(_tui: unknown, theme: Theme) => ({
render(width: number) {
const safeWidth = Math.max(1, width);
const topDivider = theme.fg("dim", "─".repeat(safeWidth));
if (!snapshot) {
const cooldown = provider === "anthropic" && anthropicRetryAfter > Date.now()
? formatDuration(Math.max(1, Math.floor((anthropicRetryAfter - Date.now()) / 1000)))
: undefined;
const text = cooldown
? `anthropic usage limited, retry in ${cooldown}`
: `sub bar loading ${provider} usage...`;
const loading = truncateToWidth(theme.fg("dim", text), safeWidth);
return [topDivider, padToWidth(loading, safeWidth)];
}
const cooldown = provider === "anthropic" && anthropicRetryAfter > Date.now()
? formatDuration(Math.max(1, Math.floor((anthropicRetryAfter - Date.now()) / 1000)))
: undefined;
const statusNote = snapshot.error
? snapshot.error
: cooldown
? `usage endpoint limited, retry in ${cooldown}`
: undefined;
const lines = formatUsageTwoLines(theme, snapshot, safeWidth, statusNote, barStyle);
const output = [topDivider, lines.top];
if (lines.bottom) output.push(lines.bottom);
return output;
},
invalidate() {},
}),
{ placement: "aboveEditor" },
);
}
async function refreshCurrent(ctx: ExtensionContext, force = false): Promise<void> {
const provider = getSelectedProvider(ctx);
if (!provider) {
ctx.ui.setWidget("sub-bar-local", undefined);
return;
}
if (provider === "anthropic" && anthropicRetryAfter > Date.now()) {
render(ctx);
return;
}
const cached = cache[provider]?.usage;
const ttl = provider === "anthropic" ? ANTHROPIC_CACHE_TTL_MS : CACHE_TTL_MS;
if (!force && cached && Date.now() - cached.fetchedAt < ttl) {
render(ctx);
return;
}
try {
const fresh = await fetchUsage(provider);
if (provider === "anthropic") {
anthropicRetryAfter = 0;
}
cache[provider] = {
usage: {
...fresh,
fromCache: false,
lastSuccessAt: Date.now(),
},
lastSuccessAt: Date.now(),
};
} catch (error) {
const message = error instanceof Error ? error.message : "fetch failed";
const fallback = cache[provider]?.usage;
const isAnthropic429 = provider === "anthropic" && message.includes("429");
if (isAnthropic429) {
anthropicRetryAfter = Date.now() + 30 * 60 * 1000;
}
if (isAnthropic429 && fallback) {
cache[provider] = {
usage: {
...fallback,
fetchedAt: Date.now(),
fromCache: true,
},
lastSuccessAt: cache[provider]?.lastSuccessAt,
};
} else if (isAnthropic429) {
delete cache[provider];
} else {
cache[provider] = {
usage: {
provider,
displayName: fallback?.displayName ?? provider,
windows: fallback?.windows ?? [],
error: message,
fetchedAt: Date.now(),
lastSuccessAt: cache[provider]?.lastSuccessAt,
fromCache: Boolean(fallback),
},
lastSuccessAt: cache[provider]?.lastSuccessAt,
};
}
}
render(ctx);
}
function startRefreshLoop(ctx: ExtensionContext): void {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (!lastCtx) return;
void refreshCurrent(lastCtx);
}, REFRESH_MS);
refreshTimer.unref?.();
void refreshCurrent(ctx, true);
}
pi.registerCommand("sub:refresh", {
description: "Refresh sub bar usage now",
handler: async (_args, ctx) => {
await refreshCurrent(ctx, true);
},
});
pi.registerCommand("sub:provider", {
description: "Set sub bar provider (auto|anthropic|codex|gemini|opencode-go)",
handler: async (args, ctx) => {
const raw = String(args ?? "").trim().toLowerCase();
if (!raw || raw === "auto") {
activeProvider = "auto";
ctx.ui.notify("sub bar provider: auto", "info");
await refreshCurrent(ctx, true);
return;
}
if (PROVIDER_ORDER.includes(raw as ProviderName)) {
activeProvider = raw as ProviderName;
ctx.ui.notify(`sub bar provider: ${activeProvider}`, "info");
await refreshCurrent(ctx, true);
return;
}
ctx.ui.notify("invalid provider. use: auto|anthropic|codex|gemini|opencode-go", "warning");
},
});
pi.registerCommand("sub:toggle", {
description: "Toggle sub bar on/off",
handler: async (_args, ctx) => {
widgetEnabled = !widgetEnabled;
await showToggleState(ctx, widgetEnabled, async () => refreshCurrent(ctx, true));
},
});
pi.registerShortcut(SHORTCUT_TOGGLE as import("@mariozechner/pi-tui").KeyId, {
description: "Toggle sub bar on/off",
handler: async (ctx) => {
widgetEnabled = !widgetEnabled;
await showToggleState(ctx, widgetEnabled, async () => refreshCurrent(ctx, true));
},
});
pi.registerCommand("sub:bars", {
description: "Set sub bar style (thin|thick|toggle)",
handler: async (args, ctx) => {
const raw = String(args ?? "").trim().toLowerCase();
if (!raw || raw === "toggle") {
barStyle = barStyle === "thin" ? "thick" : "thin";
} else if (raw === "thin" || raw === "thick") {
barStyle = raw;
} else {
ctx.ui.notify("invalid style. use: thin|thick|toggle", "warning");
return;
}
ctx.ui.notify(`sub bar style: ${barStyle}`, "info");
render(ctx);
},
});
pi.registerShortcut(SHORTCUT_BAR_STYLE as import("@mariozechner/pi-tui").KeyId, {
description: "Toggle sub bar bar style",
handler: async (ctx) => {
barStyle = barStyle === "thin" ? "thick" : "thin";
ctx.ui.notify(`sub bar style: ${barStyle}`, "info");
render(ctx);
},
});
pi.on("session_start", async (_event, ctx) => {
lastCtx = ctx;
if (!ctx.hasUI) return;
render(ctx);
startRefreshLoop(ctx);
});
pi.on("model_select", async (_event, ctx) => {
lastCtx = ctx;
if (!ctx.hasUI) return;
render(ctx);
if (activeProvider === "auto") {
await refreshCurrent(ctx, false);
}
});
pi.on("session_shutdown", async () => {
lastCtx = undefined;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"name": "sub-bar",
"version": "1.3.0",
"description": "Usage widget extension for pi-coding-agent",
"type": "module",
"license": "MIT",
"pi": {
"extensions": [
"./index.ts",
"./sub-core/index.ts"
]
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
}
}
@@ -0,0 +1,25 @@
/**
* Core settings fallbacks for sub-bar when sub-core settings are unavailable.
*/
import type { CoreSettings } from "./shared.js";
import type { Settings } from "./settings-types.js";
import { PROVIDERS, PROVIDER_METADATA } from "./providers/metadata.js";
export function getFallbackCoreSettings(settings: Settings): CoreSettings {
const providers = {} as CoreSettings["providers"];
for (const provider of PROVIDERS) {
providers[provider] = {
enabled: "auto",
fetchStatus: Boolean(PROVIDER_METADATA[provider]?.status),
};
}
return {
providers,
behavior: settings.behavior,
statusRefresh: settings.statusRefresh ?? settings.behavior,
providerOrder: settings.providerOrder,
defaultProvider: settings.defaultProvider ?? null,
};
}
@@ -0,0 +1,48 @@
import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
import { visibleWidth } from "@mariozechner/pi-tui";
import type { DividerCharacter } from "./settings-types.js";
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const DIVIDER_JOIN_MAP: Partial<Record<DividerCharacter, { top: string; bottom: string; line: string }>> = {
"|": { top: "┬", bottom: "┴", line: "─" },
"│": { top: "┬", bottom: "┴", line: "─" },
"┆": { top: "┬", bottom: "┴", line: "─" },
"┃": { top: "┳", bottom: "┻", line: "━" },
"┇": { top: "┳", bottom: "┻", line: "━" },
"║": { top: "╦", bottom: "╩", line: "═" },
};
export function buildDividerLine(
width: number,
baseLine: string,
dividerChar: DividerCharacter,
joinEnabled: boolean,
position: "top" | "bottom",
dividerColor: ThemeColor,
theme: Theme
): string {
let lineChar = "─";
let joinChar: string | undefined;
if (joinEnabled) {
const joinInfo = DIVIDER_JOIN_MAP[dividerChar];
if (joinInfo) {
lineChar = joinInfo.line;
joinChar = position === "top" ? joinInfo.top : joinInfo.bottom;
}
}
const lineChars = Array.from(lineChar.repeat(Math.max(1, width)));
if (joinChar) {
const stripped = baseLine.replace(ANSI_REGEX, "");
let pos = 0;
for (const { segment } of SEGMENTER.segment(stripped)) {
if (pos >= lineChars.length) break;
if (segment === dividerChar) {
lineChars[pos] = joinChar;
}
pos += Math.max(1, visibleWidth(segment));
}
}
return theme.fg(dividerColor, lineChars.join(""));
}
@@ -0,0 +1,71 @@
/**
* Error utilities for the sub-bar extension
*/
import type { UsageError, UsageErrorCode } from "./types.js";
export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
return { code, message, httpStatus };
}
export function noCredentials(): UsageError {
return createError("NO_CREDENTIALS", "No credentials found");
}
export function noCli(cliName: string): UsageError {
return createError("NO_CLI", `${cliName} CLI not found`);
}
export function notLoggedIn(): UsageError {
return createError("NOT_LOGGED_IN", "Not logged in");
}
export function fetchFailed(reason?: string): UsageError {
return createError("FETCH_FAILED", reason ?? "Fetch failed");
}
export function httpError(status: number): UsageError {
return createError("HTTP_ERROR", `HTTP ${status}`, status);
}
export function apiError(message: string): UsageError {
return createError("API_ERROR", message);
}
export function timeout(): UsageError {
return createError("TIMEOUT", "Request timed out");
}
/**
* Check if an error should be considered "no data available" vs actual error
* These are expected states when provider isn't configured
*/
export function isExpectedMissingData(error: UsageError): boolean {
const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
return ignoreCodes.has(error.code);
}
/**
* Format error for display in the usage widget
*/
export function formatErrorForDisplay(error: UsageError): string {
switch (error.code) {
case "NO_CREDENTIALS":
return "No creds";
case "NO_CLI":
return "No CLI";
case "NOT_LOGGED_IN":
return "Not logged in";
case "HTTP_ERROR":
if (error.httpStatus === 401) {
return "token no longer valid please /login again";
}
return `${error.httpStatus}`;
case "FETCH_FAILED":
case "API_ERROR":
case "TIMEOUT":
case "UNKNOWN":
default:
return "Fetch failed";
}
}
@@ -0,0 +1,937 @@
/**
* UI formatting utilities for the sub-bar extension
*/
import type { Theme } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { RateWindow, UsageSnapshot, ProviderStatus, ModelInfo } from "./types.js";
import type {
BaseTextColor,
BarStyle,
BarType,
BarCharacter,
BarWidth,
ColorScheme,
DividerBlanks,
ResetTimerContainment,
Settings,
} from "./settings-types.js";
import { isBackgroundColor, resolveBaseTextColor, resolveDividerColor } from "./settings-types.js";
import { formatErrorForDisplay, isExpectedMissingData } from "./errors.js";
import { getStatusIcon, getStatusLabel } from "./status.js";
import { shouldShowWindow } from "./providers/windows.js";
import { getUsageExtras } from "./providers/extras.js";
import { normalizeTokens } from "./utils.js";
export interface UsageWindowParts {
label: string;
bar: string;
pct: string;
reset: string;
}
/**
* Context window usage info from the pi framework
*/
export interface ContextInfo {
tokens: number;
contextWindow: number;
percent: number;
}
type ModelInput = ModelInfo | string | undefined;
function resolveModelInfo(model?: ModelInput): ModelInfo | undefined {
if (!model) return undefined;
return typeof model === "string" ? { id: model } : model;
}
function isCodexSparkModel(model?: ModelInput): boolean {
const tokens = normalizeTokens(typeof model === "string" ? model : model?.id ?? "");
return tokens.includes("codex") && tokens.includes("spark");
}
function isCodexSparkWindow(window: RateWindow): boolean {
const tokens = normalizeTokens(window.label ?? "");
return tokens.includes("codex") && tokens.includes("spark");
}
function getDisplayWindowLabel(window: RateWindow, model?: ModelInput): string {
if (!isCodexSparkWindow(window)) return window.label;
if (!isCodexSparkModel(model)) return window.label;
const parts = window.label.trim().split(/\s+/);
const suffix = parts.at(-1) ?? "";
if (/^\d+h$/i.test(suffix) || /^day$/i.test(suffix) || /^week$/i.test(suffix)) {
return suffix;
}
return window.label;
}
/**
* Get the characters to use for progress bars
*/
function getBarCharacters(barCharacter: BarCharacter): { filled: string; empty: string } {
let filled = "━";
let empty = "━";
switch (barCharacter) {
case "light":
filled = "─";
empty = "─";
break;
case "heavy":
filled = "━";
empty = "━";
break;
case "double":
filled = "═";
empty = "═";
break;
case "block":
filled = "█";
empty = "█";
break;
default: {
const raw = String(barCharacter);
const trimmed = raw.trim();
if (!trimmed) return { filled, empty };
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const segments = Array.from(segmenter.segment(raw), (entry) => entry.segment);
const first = segments[0] ?? trimmed[0] ?? "━";
const second = segments[1];
filled = first;
empty = second ?? first;
break;
}
}
return { filled, empty };
}
/**
* Get color based on percentage and color scheme
*/
function getUsageColor(
percent: number,
isRemaining: boolean,
colorScheme: ColorScheme,
errorThreshold: number = 25,
warningThreshold: number = 50,
successThreshold: number = 75
): "error" | "warning" | "base" | "success" {
if (colorScheme === "monochrome") {
return "base";
}
// For remaining percentage (Codex style), invert the logic
const effectivePercent = isRemaining ? percent : 100 - percent;
if (colorScheme === "success-base-warning-error") {
// >75%: success, >50%: base, >25%: warning, <=25%: error
if (effectivePercent < errorThreshold) return "error";
if (effectivePercent < warningThreshold) return "warning";
if (effectivePercent < successThreshold) return "base";
return "success";
}
// base-warning-error (default)
// >50%: base, >25%: warning, <=25%: error
if (effectivePercent < errorThreshold) return "error";
if (effectivePercent < warningThreshold) return "warning";
return "base";
}
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function getStatusColor(
indicator: NonNullable<UsageSnapshot["status"]>["indicator"],
colorScheme: ColorScheme
): "error" | "warning" | "success" | "base" {
if (colorScheme === "monochrome") {
return "base";
}
if (indicator === "minor" || indicator === "maintenance") {
return "warning";
}
if (indicator === "major" || indicator === "critical") {
return "error";
}
if (indicator === "none") {
return colorScheme === "success-base-warning-error" ? "success" : "base";
}
return "base";
}
function resolveStatusTintColor(
color: "error" | "warning" | "success" | "base",
baseTextColor: BaseTextColor
): BaseTextColor {
return color === "base" ? baseTextColor : color;
}
function fgFromBgAnsi(ansi: string): string {
return ansi.replace(/\x1b\[48;/g, "\x1b[38;").replace(/\x1b\[49m/g, "\x1b[39m");
}
function applyBaseTextColor(theme: Theme, color: BaseTextColor, text: string): string {
if (isBackgroundColor(color)) {
const fgAnsi = fgFromBgAnsi(theme.getBgAnsi(color as Parameters<Theme["getBgAnsi"]>[0]));
return `${fgAnsi}${text}\x1b[39m`;
}
return theme.fg(resolveDividerColor(color), text);
}
function resolveUsageColorTargets(settings?: Settings): {
title: boolean;
timer: boolean;
bar: boolean;
usageLabel: boolean;
status: boolean;
} {
const targets = settings?.display.usageColorTargets;
return {
title: targets?.title ?? true,
timer: targets?.timer ?? true,
bar: targets?.bar ?? true,
usageLabel: targets?.usageLabel ?? true,
status: targets?.status ?? true,
};
}
function formatElapsedSince(timestamp: number): string {
const diffMs = Date.now() - timestamp;
if (diffMs < 60000) {
const seconds = Math.max(1, Math.floor(diffMs / 1000));
return `${seconds}s`;
}
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
}
const RESET_CONTAINMENT_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
function wrapResetContainment(text: string, containment: ResetTimerContainment): { wrapped: string; attachWithSpace: boolean } {
switch (containment) {
case "none":
return { wrapped: text, attachWithSpace: true };
case "blank":
return { wrapped: text, attachWithSpace: true };
case "[]":
return { wrapped: `[${text}]`, attachWithSpace: true };
case "<>":
return { wrapped: `<${text}>`, attachWithSpace: true };
case "()":
return { wrapped: `(${text})`, attachWithSpace: true };
default: {
const trimmed = String(containment).trim();
if (!trimmed) return { wrapped: `(${text})`, attachWithSpace: true };
const segments = Array.from(RESET_CONTAINMENT_SEGMENTER.segment(trimmed), (entry) => entry.segment)
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length === 0) return { wrapped: `(${text})`, attachWithSpace: true };
const left = segments[0];
const right = segments[1] ?? left;
return { wrapped: `${left}${text}${right}`, attachWithSpace: true };
}
}
}
function formatResetDateTime(resetAt: string): string {
const date = new Date(resetAt);
if (Number.isNaN(date.getTime())) return resetAt;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getBarTypeLevels(barType: BarType): string[] | null {
switch (barType) {
case "horizontal-single":
return ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
case "vertical":
return ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
case "braille":
return ["⡀", "⡄", "⣄", "⣆", "⣇", "⣧", "⣷", "⣿"];
case "shade":
return ["░", "▒", "▓", "█"];
default:
return null;
}
}
function renderBarSegments(
percent: number,
width: number,
levels: string[],
options?: { allowMinimum?: boolean; emptyChar?: string }
): { segments: Array<{ char: string; filled: boolean }>; minimal: boolean } {
const totalUnits = Math.max(1, width) * levels.length;
let filledUnits = Math.round((percent / 100) * totalUnits);
let minimal = false;
if (options?.allowMinimum && percent > 0 && filledUnits === 0) {
filledUnits = 1;
minimal = true;
}
const emptyChar = options?.emptyChar ?? " ";
const segments: Array<{ char: string; filled: boolean }> = [];
for (let i = 0; i < Math.max(1, width); i++) {
if (filledUnits >= levels.length) {
segments.push({ char: levels[levels.length - 1], filled: true });
filledUnits -= levels.length;
continue;
}
if (filledUnits > 0) {
segments.push({ char: levels[Math.min(levels.length - 1, filledUnits - 1)], filled: true });
filledUnits = 0;
continue;
}
segments.push({ char: emptyChar, filled: false });
}
return { segments, minimal };
}
function formatProviderLabel(theme: Theme, usage: UsageSnapshot, settings?: Settings, model?: ModelInput): string {
const showProviderName = settings?.display.showProviderName ?? true;
const showStatus = settings?.providers[usage.provider]?.showStatus ?? true;
const error = usage.error;
const fetchError = Boolean(error && !isExpectedMissingData(error));
const baseStatus = showStatus ? usage.status : undefined;
const lastSuccessAt = usage.lastSuccessAt;
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
const fetchDescription = elapsed
? (elapsed === "just now" ? "Last upd.: just now" : `Last upd.: ${elapsed} ago`)
: "Fetch failed";
const fetchStatus: ProviderStatus | undefined = fetchError
? { indicator: "minor", description: fetchDescription }
: undefined;
const status = showStatus ? (fetchStatus ?? baseStatus) : undefined;
const statusDismissOk = settings?.display.statusDismissOk ?? true;
const statusModeRaw = settings?.display.statusIndicatorMode ?? "icon";
const statusMode = statusModeRaw === "icon" || statusModeRaw === "text" || statusModeRaw === "icon+text"
? statusModeRaw
: "icon";
const statusIconPack = settings?.display.statusIconPack ?? "emoji";
const statusIconCustom = settings?.display.statusIconCustom;
const providerLabelSetting = settings?.display.providerLabel ?? "none";
const showColon = settings?.display.providerLabelColon ?? true;
const boldProviderLabel = settings?.display.providerLabelBold ?? false;
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
const usageTargets = resolveUsageColorTargets(settings);
const statusActive = Boolean(status && (!statusDismissOk || status.indicator !== "none"));
const showIcon = statusActive && (statusMode === "icon" || statusMode === "icon+text");
const showText = statusActive && (statusMode === "text" || statusMode === "icon+text");
const labelSuffix = providerLabelSetting === "plan"
? "Plan"
: providerLabelSetting === "subscription"
? "Subscription"
: providerLabelSetting === "sub"
? "Sub."
: providerLabelSetting === "none"
? ""
: String(providerLabelSetting);
const rawName = usage.displayName?.trim() ?? "";
const baseName = rawName.replace(/\s+(plan|subscription|sub\.?)[\s]*$/i, "").trim();
const resolvedProviderName = baseName || rawName;
const isSpark = usage.provider === "codex" && isCodexSparkModel(model);
const providerName = isSpark ? `${resolvedProviderName} (Spark)` : resolvedProviderName;
const providerLabel = showProviderName
? [providerName, labelSuffix].filter(Boolean).join(" ")
: "";
const providerLabelWithColon = providerLabel && showColon ? `${providerLabel}:` : providerLabel;
const icon = showIcon && status ? getStatusIcon(status, statusIconPack, statusIconCustom) : "";
const statusText = showText && status ? getStatusLabel(status) : "";
const rawStatusColor = status
? getStatusColor(status.indicator, settings?.display.colorScheme ?? "base-warning-error")
: "base";
const statusTint = usageTargets.status
? resolveStatusTintColor(rawStatusColor, baseTextColor)
: baseTextColor;
const statusColor = statusTint;
const dividerEnabled = settings?.display.statusProviderDivider ?? false;
const dividerChar = settings?.display.dividerCharacter ?? "│";
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
const dividerGlyph = dividerChar === "none"
? ""
: dividerChar === "blank"
? " "
: dividerChar;
const statusParts: string[] = [];
if (icon) statusParts.push(applyBaseTextColor(theme, statusColor, icon));
if (statusText) statusParts.push(applyBaseTextColor(theme, statusColor, statusText));
const parts: string[] = [];
if (statusParts.length > 0) {
parts.push(statusParts.join(" "));
}
if (providerLabelWithColon) {
if (statusParts.length > 0 && dividerEnabled && dividerGlyph) {
parts.push(theme.fg(dividerColor, dividerGlyph));
}
const colored = applyBaseTextColor(theme, baseTextColor, providerLabelWithColon);
parts.push(boldProviderLabel ? theme.bold(colored) : colored);
}
if (parts.length === 0) return "";
return parts.join(" ");
}
/**
* Format a single usage window as a styled string
*/
export function formatUsageWindow(
theme: Theme,
window: RateWindow,
isCodex: boolean,
settings?: Settings,
usage?: UsageSnapshot,
options?: { useNormalColors?: boolean; barWidthOverride?: number },
model?: ModelInput
): string {
const parts = formatUsageWindowParts(theme, window, isCodex, settings, usage, options, model);
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
const usageTargets = resolveUsageColorTargets(settings);
// Special handling for Extra usage label
if (window.label.startsWith("Extra [")) {
const match = window.label.match(/^(Extra \[)(on|active)(\] .*)$/);
if (match) {
const [, prefix, status, suffix] = match;
const styledLabel =
status === "active"
? applyBaseTextColor(theme, baseTextColor, prefix)
+ theme.fg("text", status)
+ applyBaseTextColor(theme, baseTextColor, suffix)
: applyBaseTextColor(theme, baseTextColor, window.label);
const extraParts = [styledLabel, parts.bar, parts.pct].filter(Boolean);
return extraParts.join(" ");
}
if (!usageTargets.title) {
const extraParts = [applyBaseTextColor(theme, baseTextColor, window.label), parts.bar, parts.pct].filter(Boolean);
return extraParts.join(" ");
}
const extraColor = getUsageColor(window.usedPercent, false, settings?.display.colorScheme ?? "base-warning-error");
const extraTextColor = (options?.useNormalColors && extraColor === "base")
? "text"
: extraColor === "base"
? baseTextColor
: extraColor;
const extraParts = [applyBaseTextColor(theme, extraTextColor, window.label), parts.bar, parts.pct].filter(Boolean);
return extraParts.join(" ");
}
const joinedParts = [parts.label, parts.bar, parts.pct, parts.reset].filter(Boolean);
return joinedParts.join(" ");
}
export function formatUsageWindowParts(
theme: Theme,
window: RateWindow,
isCodex: boolean,
settings?: Settings,
usage?: UsageSnapshot,
options?: { useNormalColors?: boolean; barWidthOverride?: number },
model?: ModelInput
): UsageWindowParts {
const barStyle: BarStyle = settings?.display.barStyle ?? "both";
const barWidthSetting = settings?.display.barWidth;
const containBar = settings?.display.containBar ?? false;
const barWidth = options?.barWidthOverride ?? (typeof barWidthSetting === "number" ? barWidthSetting : 6);
const barType: BarType = settings?.display.barType ?? "horizontal-bar";
const brailleFillEmpty = settings?.display.brailleFillEmpty ?? false;
const brailleFullBlocks = settings?.display.brailleFullBlocks ?? false;
const barCharacter: BarCharacter = settings?.display.barCharacter ?? "heavy";
const colorScheme: ColorScheme = settings?.display.colorScheme ?? "base-warning-error";
const resetTimePosition = settings?.display.resetTimePosition ?? "front";
const resetTimeFormat = settings?.display.resetTimeFormat ?? "relative";
const showUsageLabels = settings?.display.showUsageLabels ?? true;
const showWindowTitle = settings?.display.showWindowTitle ?? true;
const boldWindowTitle = settings?.display.boldWindowTitle ?? false;
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
const errorThreshold = settings?.display.errorThreshold ?? 25;
const warningThreshold = settings?.display.warningThreshold ?? 50;
const successThreshold = settings?.display.successThreshold ?? 75;
const rawUsedPct = Math.round(window.usedPercent);
const usedPct = clampPercent(rawUsedPct);
const displayPct = isCodex ? clampPercent(100 - usedPct) : usedPct;
const isRemaining = isCodex;
const barPercent = clampPercent(displayPct);
const filled = Math.round((barPercent / 100) * barWidth);
const empty = Math.max(0, barWidth - filled);
const baseColor = getUsageColor(displayPct, isRemaining, colorScheme, errorThreshold, warningThreshold, successThreshold);
const usageTargets = resolveUsageColorTargets(settings);
const usageTextColor = (options?.useNormalColors && baseColor === "base")
? "text"
: baseColor === "base"
? baseTextColor
: baseColor;
const neutralTextColor = options?.useNormalColors ? "text" : baseTextColor;
const titleColor = usageTargets.title ? usageTextColor : neutralTextColor;
const timerColor = usageTargets.timer ? usageTextColor : neutralTextColor;
const usageLabelColor = usageTargets.usageLabel ? usageTextColor : neutralTextColor;
const barUsageColor = (options?.useNormalColors && baseColor === "base") ? "text" : baseColor === "base" ? "muted" : baseColor;
const neutralBarColor = baseTextColor === "dim" ? "dim" : "muted";
const barColor = usageTargets.bar ? barUsageColor : neutralBarColor;
const { filled: filledChar, empty: emptyChar } = getBarCharacters(barCharacter);
const emptyColor = "dim";
let barStr = "";
if ((barStyle === "bar" || barStyle === "both") && barWidth > 0) {
let levels = getBarTypeLevels(barType);
if (barType === "braille" && brailleFullBlocks) {
levels = ["⣿"];
}
if (!levels || barType === "horizontal-bar") {
const filledCharWidth = Math.max(1, visibleWidth(filledChar));
const emptyCharWidth = Math.max(1, visibleWidth(emptyChar));
const segmentCount = barWidth > 0 ? Math.floor(barWidth / filledCharWidth) : 0;
const filledSegments = segmentCount > 0 ? Math.round((barPercent / 100) * segmentCount) : 0;
const filledStr = filledChar.repeat(filledSegments);
const filledWidth = filledSegments * filledCharWidth;
const remainingWidth = Math.max(0, barWidth - filledWidth);
const emptySegments = emptyCharWidth > 0 ? Math.floor(remainingWidth / emptyCharWidth) : 0;
const emptyStr = emptyChar.repeat(emptySegments);
const emptyRendered = emptyChar === " " ? emptyStr : theme.fg(emptyColor, emptyStr);
barStr = theme.fg(barColor as Parameters<typeof theme.fg>[0], filledStr) + emptyRendered;
const barVisualWidth = visibleWidth(barStr);
if (barVisualWidth < barWidth) {
barStr += " ".repeat(barWidth - barVisualWidth);
}
} else {
const emptyChar = barType === "braille" && brailleFillEmpty && barWidth > 1 ? "⣿" : " ";
const { segments, minimal } = renderBarSegments(barPercent, barWidth, levels, {
allowMinimum: true,
emptyChar,
});
const filledColor = minimal ? "dim" : barColor;
barStr = segments
.map((segment) => {
if (segment.filled) {
return theme.fg(filledColor as Parameters<typeof theme.fg>[0], segment.char);
}
if (segment.char === " ") {
return segment.char;
}
return theme.fg("dim", segment.char);
})
.join("");
}
if (settings?.display.containBar && barStr) {
const leftCap = theme.fg(barColor as Parameters<typeof theme.fg>[0], "▕");
const rightCap = theme.fg(barColor as Parameters<typeof theme.fg>[0], "▏");
barStr = leftCap + barStr + rightCap;
}
}
let pctStr = "";
if (barStyle === "percentage" || barStyle === "both") {
// Special handling for Copilot Month window - can show percentage or requests
if (window.label === "Month" && usage?.provider === "copilot") {
const quotaDisplay = settings?.providers.copilot.quotaDisplay ?? "percentage";
if (quotaDisplay === "requests" && usage.requestsRemaining !== undefined && usage.requestsEntitlement !== undefined) {
const used = usage.requestsEntitlement - usage.requestsRemaining;
const suffix = showUsageLabels ? " used" : "";
pctStr = applyBaseTextColor(theme, usageLabelColor, `${used}/${usage.requestsEntitlement}${suffix}`);
} else {
const suffix = showUsageLabels ? " used" : "";
pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`);
}
} else if (isCodex) {
const suffix = showUsageLabels ? " rem." : "";
pctStr = applyBaseTextColor(theme, usageLabelColor, `${displayPct}%${suffix}`);
} else {
const suffix = showUsageLabels ? " used" : "";
pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`);
}
}
const isActiveReset = window.resetDescription === "__ACTIVE__";
const resetText = isActiveReset
? undefined
: resetTimeFormat === "datetime"
? (window.resetAt ? formatResetDateTime(window.resetAt) : window.resetDescription)
: window.resetDescription;
const resetContainment = settings?.display.resetTimeContainment ?? "()";
const leftSuffix = resetText && resetTimeFormat === "relative" && showUsageLabels ? " left" : "";
const displayLabel = getDisplayWindowLabel(window, model);
const coloredTitle = applyBaseTextColor(theme, titleColor, displayLabel);
const titlePart = showWindowTitle ? (boldWindowTitle ? theme.bold(coloredTitle) : coloredTitle) : "";
let labelPart = titlePart;
if (resetText) {
const resetBody = `${resetText}${leftSuffix}`;
const { wrapped, attachWithSpace } = wrapResetContainment(resetBody, resetContainment);
const coloredReset = applyBaseTextColor(theme, timerColor, wrapped);
if (resetTimePosition === "front") {
if (!titlePart) {
labelPart = coloredReset;
} else {
labelPart = attachWithSpace ? `${titlePart} ${coloredReset}` : `${titlePart}${coloredReset}`;
}
} else if (resetTimePosition === "integrated") {
labelPart = titlePart ? `${applyBaseTextColor(theme, timerColor, `${wrapped}/`)}${titlePart}` : coloredReset;
} else if (resetTimePosition === "back") {
labelPart = titlePart;
}
} else if (!titlePart) {
labelPart = "";
}
const resetPart =
resetTimePosition === "back" && resetText
? applyBaseTextColor(theme, timerColor, wrapResetContainment(`${resetText}${leftSuffix}`, resetContainment).wrapped)
: "";
return {
label: labelPart,
bar: barStr,
pct: pctStr,
reset: resetPart,
};
}
/**
* Format context window usage as a progress bar
*/
export function formatContextBar(
theme: Theme,
context: ContextInfo,
settings?: Settings,
options?: { barWidthOverride?: number }
): string {
// Create a pseudo-RateWindow for context display
const contextWindow: RateWindow = {
label: "Ctx",
usedPercent: context.percent,
// No reset description for context
};
// Format using the same window formatting logic, but with "used" semantics (not inverted)
return formatUsageWindow(theme, contextWindow, false, settings, undefined, options);
}
/**
* Format a complete usage snapshot as a usage line
*/
export function formatUsageStatus(
theme: Theme,
usage: UsageSnapshot,
model?: ModelInput,
settings?: Settings,
context?: ContextInfo
): string | undefined {
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
const modelInfo = resolveModelInfo(model);
const label = formatProviderLabel(theme, usage, settings, modelInfo);
// If no windows, just show the provider name with error
if (usage.windows.length === 0) {
const errorMsg = usage.error
? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`)
: "";
if (!label) {
return errorMsg;
}
return errorMsg ? `${label} ${errorMsg}` : label;
}
// Build usage bars
const parts: string[] = [];
const isCodex = usage.provider === "codex";
const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false);
const modelId = modelInfo?.id;
// Add context bar as leftmost element if enabled
const showContextBar = settings?.display.showContextBar ?? false;
if (showContextBar && context && context.contextWindow > 0) {
parts.push(formatContextBar(theme, context, settings));
}
for (const w of usage.windows) {
// Skip windows that are disabled in settings
if (!shouldShowWindow(usage, w, settings, modelInfo)) {
continue;
}
parts.push(formatUsageWindow(theme, w, invertUsage, settings, usage, undefined, modelInfo));
}
// Add extra usage lines (extra usage off, copilot multiplier, etc.)
const extras = getUsageExtras(usage, settings, modelId);
for (const extra of extras) {
parts.push(applyBaseTextColor(theme, baseTextColor, extra.label));
}
// Build divider from settings
const dividerChar = settings?.display.dividerCharacter ?? "•";
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
const blanksSetting = settings?.display.dividerBlanks ?? 1;
const showProviderDivider = settings?.display.showProviderDivider ?? false;
const blanksPerSide = typeof blanksSetting === "number" ? blanksSetting : 1;
const spacing = " ".repeat(blanksPerSide);
const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar;
const divider = charToDisplay ? spacing + theme.fg(dividerColor, charToDisplay) + spacing : spacing + spacing;
const labelGap = label && parts.length > 0
? showProviderDivider && charToDisplay !== ""
? divider
: spacing
: "";
return label + labelGap + parts.join(divider);
}
export function formatUsageStatusWithWidth(
theme: Theme,
usage: UsageSnapshot,
width: number,
model?: ModelInput,
settings?: Settings,
options?: { labelGapFill?: boolean },
context?: ContextInfo
): string | undefined {
const labelGapFill = options?.labelGapFill ?? false;
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
const modelInfo = resolveModelInfo(model);
const label = formatProviderLabel(theme, usage, settings, modelInfo);
const showContextBar = settings?.display.showContextBar ?? false;
const hasContext = showContextBar && context && context.contextWindow > 0;
// If no windows, just show the provider name with error
if (usage.windows.length === 0) {
const errorMsg = usage.error
? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`)
: "";
if (!label) {
return errorMsg;
}
return errorMsg ? `${label} ${errorMsg}` : label;
}
const barStyle: BarStyle = settings?.display.barStyle ?? "both";
const hasBar = barStyle === "bar" || barStyle === "both";
const barWidthSetting = settings?.display.barWidth ?? 6;
const dividerBlanksSetting = settings?.display.dividerBlanks ?? 1;
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
const showProviderDivider = settings?.display.showProviderDivider ?? false;
const containBar = settings?.display.containBar ?? false;
const barFill = barWidthSetting === "fill";
const barBaseWidth = typeof barWidthSetting === "number" ? barWidthSetting : (hasBar ? 1 : 0);
const barContainerExtra = containBar && hasBar ? 2 : 0;
const barBaseContentWidth = barFill ? 0 : barBaseWidth;
const barBaseWidthCalc = barFill ? 0 : barBaseContentWidth + barContainerExtra;
const barTotalBaseWidth = barBaseWidthCalc;
const baseDividerBlanks = typeof dividerBlanksSetting === "number" ? dividerBlanksSetting : 1;
const dividerFill = dividerBlanksSetting === "fill";
// Build usage windows
const windows: RateWindow[] = [];
const isCodex = usage.provider === "codex";
const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false);
const modelId = modelInfo?.id;
// Add context window as first entry if enabled
let contextWindowIndex = -1;
if (hasContext) {
contextWindowIndex = windows.length;
windows.push({
label: "Ctx",
usedPercent: context!.percent,
});
}
for (const w of usage.windows) {
if (!shouldShowWindow(usage, w, settings, modelInfo)) {
continue;
}
windows.push(w);
}
const barEligibleCount = hasBar ? windows.length : 0;
const extras = getUsageExtras(usage, settings, modelId);
const extraParts = extras.map((extra) => applyBaseTextColor(theme, baseTextColor, extra.label));
const barSpacerWidth = hasBar ? 1 : 0;
const baseWindowWidths = windows.map((w, i) => {
// Context window uses false for invertUsage (always show used percentage)
const isContext = i === contextWindowIndex;
return (
visibleWidth(
formatUsageWindow(
theme,
w,
isContext ? false : invertUsage,
settings,
isContext ? undefined : usage,
{ barWidthOverride: 0 },
modelInfo
)
) + barSpacerWidth
);
});
const extraWidths = extraParts.map((part) => visibleWidth(part));
const partCount = windows.length + extraParts.length;
const dividerCount = Math.max(0, partCount - 1);
const dividerChar = settings?.display.dividerCharacter ?? "•";
const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar;
const dividerBaseWidth = (charToDisplay ? 1 : 0) + baseDividerBlanks * 2;
const labelGapEnabled = partCount > 0 && (label !== "" || labelGapFill);
const providerDividerActive = showProviderDivider && charToDisplay !== "" && label !== "";
const labelGapBaseWidth = labelGapEnabled
? providerDividerActive
? dividerBaseWidth
: baseDividerBlanks
: 0;
const labelWidth = visibleWidth(label);
const baseTotalWidth =
labelWidth +
labelGapBaseWidth +
baseWindowWidths.reduce((sum, w) => sum + w, 0) +
extraWidths.reduce((sum, w) => sum + w, 0) +
(barEligibleCount * barTotalBaseWidth) +
(dividerCount * dividerBaseWidth);
let remainingWidth = width - baseTotalWidth;
if (remainingWidth < 0) {
remainingWidth = 0;
}
const useBars = barFill && barEligibleCount > 0;
const labelGapUnits = labelGapEnabled ? (providerDividerActive ? 2 : 1) : 0;
const dividerSlots = dividerCount + (labelGapEnabled ? 1 : 0);
const dividerUnits = dividerCount * 2 + labelGapUnits;
const useDividers = dividerFill && dividerUnits > 0;
let barExtraTotal = 0;
let dividerExtraTotal = 0;
if (remainingWidth > 0 && (useBars || useDividers)) {
const barWeight = useBars ? barEligibleCount : 0;
const dividerWeight = useDividers ? dividerUnits : 0;
const totalWeight = barWeight + dividerWeight;
if (totalWeight > 0) {
barExtraTotal = Math.floor((remainingWidth * barWeight) / totalWeight);
dividerExtraTotal = remainingWidth - barExtraTotal;
}
}
const barWidths: number[] = windows.map(() => barBaseWidthCalc);
if (useBars && barEligibleCount > 0) {
const perBar = Math.floor(barExtraTotal / barEligibleCount);
let remainder = barExtraTotal % barEligibleCount;
for (let i = 0; i < barWidths.length; i++) {
barWidths[i] = barBaseWidthCalc + perBar + (remainder > 0 ? 1 : 0);
if (remainder > 0) remainder -= 1;
}
}
let labelBlanks = labelGapEnabled ? baseDividerBlanks : 0;
const dividerBlanks: number[] = [];
if (dividerUnits > 0) {
const baseUnit = useDividers ? Math.floor(dividerExtraTotal / dividerUnits) : 0;
let remainderUnits = useDividers ? dividerExtraTotal % dividerUnits : 0;
if (labelGapEnabled) {
if (useDividers && providerDividerActive) {
let extraUnits = baseUnit * 2;
if (remainderUnits >= 2) {
extraUnits += 2;
remainderUnits -= 2;
}
labelBlanks = baseDividerBlanks + Math.floor(extraUnits / 2);
} else if (useDividers) {
labelBlanks = baseDividerBlanks + baseUnit + (remainderUnits > 0 ? 1 : 0);
if (remainderUnits > 0) remainderUnits -= 1;
}
}
for (let i = 0; i < dividerCount; i++) {
let extraUnits = baseUnit * 2;
if (remainderUnits >= 2) {
extraUnits += 2;
remainderUnits -= 2;
}
const blanks = baseDividerBlanks + Math.floor(extraUnits / 2);
dividerBlanks.push(blanks);
}
}
const parts: string[] = [];
for (let i = 0; i < windows.length; i++) {
const totalWidth = barWidths[i] ?? barBaseWidthCalc;
const contentWidth = containBar ? Math.max(0, totalWidth - barContainerExtra) : totalWidth;
const isContext = i === contextWindowIndex;
parts.push(
formatUsageWindow(
theme,
windows[i],
isContext ? false : invertUsage,
settings,
isContext ? undefined : usage,
{ barWidthOverride: contentWidth },
modelInfo
)
);
}
for (const extra of extraParts) {
parts.push(extra);
}
let rest = "";
for (let i = 0; i < parts.length; i++) {
rest += parts[i];
if (i < dividerCount) {
const blanks = dividerBlanks[i] ?? baseDividerBlanks;
const spacing = " ".repeat(Math.max(0, blanks));
rest += charToDisplay
? spacing + theme.fg(dividerColor, charToDisplay) + spacing
: spacing + spacing;
}
}
let labelGapExtra = 0;
if (labelGapFill && labelGapEnabled) {
const restWidth = visibleWidth(rest);
const labelGapWidth = providerDividerActive
? (Math.max(0, labelBlanks) * 2) + (charToDisplay ? 1 : 0)
: Math.max(0, labelBlanks);
const totalWidth = visibleWidth(label) + restWidth + labelGapWidth;
labelGapExtra = Math.max(0, width - totalWidth);
}
let output = label;
if (labelGapEnabled) {
if (providerDividerActive) {
const spacing = " ".repeat(Math.max(0, labelBlanks));
output += spacing + theme.fg(dividerColor, charToDisplay) + spacing + " ".repeat(labelGapExtra);
} else {
output += " ".repeat(Math.max(0, labelBlanks + labelGapExtra));
}
}
output += rest;
if (width > 0 && visibleWidth(output) > width) {
return truncateToWidth(output, width, "");
}
return output;
}
@@ -0,0 +1,21 @@
/**
* Shared path helpers for sub-bar settings storage.
*/
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const SETTINGS_FILE_NAME = "pi-sub-bar-settings.json";
export function getExtensionDir(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..");
}
export function getSettingsPath(): string {
return join(getAgentDir(), SETTINGS_FILE_NAME);
}
export function getLegacySettingsPath(): string {
return join(getExtensionDir(), "settings.json");
}
@@ -0,0 +1,21 @@
/**
* Provider-specific extra usage lines (non-window info).
*/
import type { UsageSnapshot } from "../types.js";
import type { Settings } from "../settings-types.js";
import { PROVIDER_METADATA, type UsageExtra } from "./metadata.js";
export type { UsageExtra } from "./metadata.js";
export function getUsageExtras(
usage: UsageSnapshot,
settings?: Settings,
modelId?: string
): UsageExtra[] {
const handler = PROVIDER_METADATA[usage.provider]?.getExtras;
if (handler) {
return handler(usage, settings, modelId);
}
return [];
}
@@ -0,0 +1,199 @@
/**
* Provider metadata shared across the extension.
*/
import type { RateWindow, UsageSnapshot, ProviderName, ModelInfo } from "../types.js";
import type { Settings } from "../settings-types.js";
import { getModelMultiplier, normalizeTokens } from "../utils.js";
import { PROVIDER_METADATA as BASE_METADATA, type ProviderMetadata as BaseProviderMetadata } from "../../shared.js";
export { PROVIDERS, PROVIDER_DISPLAY_NAMES } from "../../shared.js";
export type { ProviderStatusConfig, ProviderDetectionConfig } from "../../shared.js";
export interface UsageExtra {
label: string;
}
export interface ProviderMetadata extends BaseProviderMetadata {
isWindowVisible?: (usage: UsageSnapshot, window: RateWindow, settings?: Settings, model?: ModelInfo) => boolean;
getExtras?: (usage: UsageSnapshot, settings?: Settings, modelId?: string) => UsageExtra[];
}
const anthropicWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
if (!settings) return true;
const ps = settings.providers.anthropic;
if (window.label === "5h") return ps.windows.show5h;
if (window.label === "Week") return ps.windows.show7d;
if (window.label.startsWith("Extra [")) return ps.windows.showExtra;
return true;
};
const copilotWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
if (!settings) return true;
const ps = settings.providers.copilot;
if (window.label === "Month") return ps.windows.showMonth;
return true;
};
const geminiWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
if (!settings) return true;
const ps = settings.providers.gemini;
if (window.label === "Pro") return ps.windows.showPro;
if (window.label === "Flash") return ps.windows.showFlash;
return true;
};
const antigravityWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, model) => {
if (!settings) return true;
const ps = settings.providers.antigravity;
const label = window.label.trim();
const normalized = label.toLowerCase().replace(/\s+/g, "_");
if (normalized === "tab_flash_lite_preview") return false;
const labelTokens = normalizeTokens(label);
const modelProvider = model?.provider?.toLowerCase() ?? "";
const modelId = model?.id;
const providerMatches = modelProvider.includes("antigravity");
if (ps.showCurrentModel && providerMatches && modelId) {
const modelTokens = normalizeTokens(modelId);
const match = modelTokens.length > 0 && modelTokens.every((token) => labelTokens.includes(token));
if (match) return true;
}
if (ps.showScopedModels) {
const scopedPatterns = model?.scopedModelPatterns ?? [];
const matchesScoped = scopedPatterns.some((pattern) => {
if (!pattern) return false;
const [rawPattern] = pattern.split(":");
const trimmed = rawPattern?.trim();
if (!trimmed) return false;
const hasProvider = trimmed.includes("/");
if (!hasProvider) return false;
const providerPart = trimmed.slice(0, trimmed.indexOf("/")).trim().toLowerCase();
if (!providerPart.includes("antigravity")) return false;
const base = trimmed.slice(trimmed.lastIndexOf("/") + 1);
const tokens = normalizeTokens(base);
return tokens.length > 0 && tokens.every((token) => labelTokens.includes(token));
});
if (matchesScoped) return true;
}
const visibility = ps.modelVisibility?.[label];
return visibility === true;
};
const codexWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, model) => {
if (!settings) return true;
const ps = settings.providers.codex;
const isSparkModel = isCodexSparkModel(model);
const isSparkWindow = isCodexSparkWindow(window);
if (isSparkWindow) {
if (!isSparkModel) return false;
return shouldShowCodexWindowBySetting(ps, window);
}
if (isSparkModel) {
return false;
}
return shouldShowCodexWindowBySetting(ps, window);
};
const isCodexSparkModel = (model?: ModelInfo): boolean => {
const tokens = normalizeTokens(model?.id ?? "");
return tokens.includes("codex") && tokens.includes("spark");
};
const isCodexSparkWindow = (window: RateWindow): boolean => {
const tokens = normalizeTokens(window.label ?? "");
return tokens.includes("codex") && tokens.includes("spark");
};
const shouldShowCodexWindowBySetting = (
ps: Settings["providers"]["codex"],
window: RateWindow
): boolean => {
if (window.label === "") return true;
if (/\b\d+h$/.test(window.label.trim())) {
return ps.windows.showPrimary;
}
if (window.label === "Day" || window.label === "Week" || /\b(day|week)\b/.test(window.label.toLowerCase())) {
return ps.windows.showSecondary;
}
return true;
};
const kiroWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
if (!settings) return true;
const ps = settings.providers.kiro;
if (window.label === "Credits") return ps.windows.showCredits;
return true;
};
const zaiWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
if (!settings) return true;
const ps = settings.providers.zai;
if (window.label === "Tokens") return ps.windows.showTokens;
if (window.label === "Monthly") return ps.windows.showMonthly;
return true;
};
const anthropicExtras: ProviderMetadata["getExtras"] = (usage, settings) => {
const extras: UsageExtra[] = [];
const showExtraWindow = settings?.providers.anthropic.windows.showExtra ?? true;
if (showExtraWindow && usage.extraUsageEnabled === false) {
extras.push({ label: "Extra [off]" });
}
return extras;
};
const copilotExtras: ProviderMetadata["getExtras"] = (usage, settings, modelId) => {
const extras: UsageExtra[] = [];
const showMultiplier = settings?.providers.copilot.showMultiplier ?? true;
const showRequestsLeft = settings?.providers.copilot.showRequestsLeft ?? true;
if (!showMultiplier) return extras;
const multiplier = getModelMultiplier(modelId);
const remaining = usage.requestsRemaining;
if (multiplier !== undefined) {
let multiplierStr = `Model multiplier: ${multiplier}x`;
if (showRequestsLeft && remaining !== undefined) {
const leftCount = Math.floor(remaining / Math.max(multiplier, 0.0001));
multiplierStr += ` (${leftCount} req. left)`;
}
extras.push({ label: multiplierStr });
}
return extras;
};
export const PROVIDER_METADATA: Record<ProviderName, ProviderMetadata> = {
anthropic: {
...BASE_METADATA.anthropic,
isWindowVisible: anthropicWindowVisible,
getExtras: anthropicExtras,
},
copilot: {
...BASE_METADATA.copilot,
isWindowVisible: copilotWindowVisible,
getExtras: copilotExtras,
},
gemini: {
...BASE_METADATA.gemini,
isWindowVisible: geminiWindowVisible,
},
antigravity: {
...BASE_METADATA.antigravity,
isWindowVisible: antigravityWindowVisible,
},
codex: {
...BASE_METADATA.codex,
isWindowVisible: codexWindowVisible,
},
kiro: {
...BASE_METADATA.kiro,
isWindowVisible: kiroWindowVisible,
},
zai: {
...BASE_METADATA.zai,
isWindowVisible: zaiWindowVisible,
},
};
@@ -0,0 +1,359 @@
/**
* Provider-specific settings helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { ProviderName } from "../types.js";
import type {
Settings,
BaseProviderSettings,
AnthropicProviderSettings,
CopilotProviderSettings,
GeminiProviderSettings,
AntigravityProviderSettings,
CodexProviderSettings,
KiroProviderSettings,
ZaiProviderSettings,
} from "../settings-types.js";
function buildBaseProviderItems(ps: BaseProviderSettings): SettingItem[] {
return [
{
id: "showStatus",
label: "Show Status Indicator",
currentValue: ps.showStatus ? "on" : "off",
values: ["on", "off"],
description: "Show status indicator for this provider.",
},
];
}
function applyBaseProviderSetting(ps: BaseProviderSettings, id: string, value: string): boolean {
switch (id) {
case "showStatus":
ps.showStatus = value === "on";
return true;
default:
return false;
}
}
/**
* Build settings items for a specific provider.
*/
export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] {
const ps = settings.providers[provider];
const items: SettingItem[] = [...buildBaseProviderItems(ps)];
if (provider === "anthropic") {
const anthroSettings = ps as AnthropicProviderSettings;
items.push(
{
id: "show5h",
label: "Show 5h Window",
currentValue: anthroSettings.windows.show5h ? "on" : "off",
values: ["on", "off"],
description: "Show the 5-hour usage window.",
},
{
id: "show7d",
label: "Show Week Window",
currentValue: anthroSettings.windows.show7d ? "on" : "off",
values: ["on", "off"],
description: "Show the weekly usage window.",
},
{
id: "showExtra",
label: "Show Extra Window",
currentValue: anthroSettings.windows.showExtra ? "on" : "off",
values: ["on", "off"],
description: "Show the extra usage window.",
},
);
}
if (provider === "copilot") {
const copilotSettings = ps as CopilotProviderSettings;
items.push(
{
id: "showMultiplier",
label: "Show Model Multiplier",
currentValue: copilotSettings.showMultiplier ? "on" : "off",
values: ["on", "off"],
description: "Show request cost multiplier for the current model.",
},
{
id: "showRequestsLeft",
label: "Show Requests Remaining",
currentValue: copilotSettings.showRequestsLeft ? "on" : "off",
values: ["on", "off"],
description: "Estimate requests remaining based on the multiplier.",
},
{
id: "quotaDisplay",
label: "Show Quota in",
currentValue: copilotSettings.quotaDisplay,
values: ["percentage", "requests"],
description: "Display Copilot usage as percentage or requests.",
},
{
id: "showMonth",
label: "Show Month Window",
currentValue: copilotSettings.windows.showMonth ? "on" : "off",
values: ["on", "off"],
description: "Show the monthly usage window.",
},
);
}
if (provider === "gemini") {
const geminiSettings = ps as GeminiProviderSettings;
items.push(
{
id: "showPro",
label: "Show Pro Window",
currentValue: geminiSettings.windows.showPro ? "on" : "off",
values: ["on", "off"],
description: "Show the Pro quota window.",
},
{
id: "showFlash",
label: "Show Flash Window",
currentValue: geminiSettings.windows.showFlash ? "on" : "off",
values: ["on", "off"],
description: "Show the Flash quota window.",
},
);
}
if (provider === "antigravity") {
const antigravitySettings = ps as AntigravityProviderSettings;
items.push(
{
id: "showCurrentModel",
label: "Always Show Current Model",
currentValue: antigravitySettings.showCurrentModel ? "on" : "off",
values: ["on", "off"],
description: "Show the active Antigravity model even if hidden.",
},
{
id: "showScopedModels",
label: "Show Scoped Models",
currentValue: antigravitySettings.showScopedModels ? "on" : "off",
values: ["on", "off"],
description: "Show Antigravity models that are in the scoped model rotation.",
},
);
const modelVisibility = antigravitySettings.modelVisibility ?? {};
const modelOrder = antigravitySettings.modelOrder?.length
? antigravitySettings.modelOrder
: Object.keys(modelVisibility).sort((a, b) => a.localeCompare(b));
const seenModels = new Set<string>();
for (const model of modelOrder) {
if (!model || seenModels.has(model)) continue;
seenModels.add(model);
const normalized = model.toLowerCase().replace(/\s+/g, "_");
if (normalized === "tab_flash_lite_preview") continue;
const visible = modelVisibility[model] !== false;
items.push({
id: `model:${model}`,
label: model,
currentValue: visible ? "on" : "off",
values: ["on", "off"],
description: "Toggle this model window.",
});
}
}
if (provider === "codex") {
const codexSettings = ps as CodexProviderSettings;
items.push(
{
id: "invertUsage",
label: "Invert Usage",
currentValue: codexSettings.invertUsage ? "on" : "off",
values: ["on", "off"],
description: "Show remaining-style usage for Codex.",
},
{
id: "showPrimary",
label: "Show Primary Window",
currentValue: codexSettings.windows.showPrimary ? "on" : "off",
values: ["on", "off"],
description: "Show the primary usage window.",
},
{
id: "showSecondary",
label: "Show Secondary Window",
currentValue: codexSettings.windows.showSecondary ? "on" : "off",
values: ["on", "off"],
description: "Show secondary windows (day/week).",
},
);
}
if (provider === "kiro") {
const kiroSettings = ps as KiroProviderSettings;
items.push({
id: "showCredits",
label: "Show Credits Window",
currentValue: kiroSettings.windows.showCredits ? "on" : "off",
values: ["on", "off"],
description: "Show the credits usage window.",
});
}
if (provider === "zai") {
const zaiSettings = ps as ZaiProviderSettings;
items.push(
{
id: "showTokens",
label: "Show Tokens Window",
currentValue: zaiSettings.windows.showTokens ? "on" : "off",
values: ["on", "off"],
description: "Show the tokens usage window.",
},
{
id: "showMonthly",
label: "Show Monthly Window",
currentValue: zaiSettings.windows.showMonthly ? "on" : "off",
values: ["on", "off"],
description: "Show the monthly usage window.",
},
);
}
return items;
}
/**
* Apply a provider settings change in-place.
*/
export function applyProviderSettingsChange(
settings: Settings,
provider: ProviderName,
id: string,
value: string
): Settings {
const ps = settings.providers[provider];
if (applyBaseProviderSetting(ps, id, value)) {
return settings;
}
if (provider === "anthropic") {
const anthroSettings = ps as AnthropicProviderSettings;
switch (id) {
case "show5h":
anthroSettings.windows.show5h = value === "on";
break;
case "show7d":
anthroSettings.windows.show7d = value === "on";
break;
case "showExtra":
anthroSettings.windows.showExtra = value === "on";
break;
}
}
if (provider === "copilot") {
const copilotSettings = ps as CopilotProviderSettings;
switch (id) {
case "showMultiplier":
copilotSettings.showMultiplier = value === "on";
break;
case "showRequestsLeft":
copilotSettings.showRequestsLeft = value === "on";
break;
case "quotaDisplay":
copilotSettings.quotaDisplay = value as "percentage" | "requests";
break;
case "showMonth":
copilotSettings.windows.showMonth = value === "on";
break;
}
}
if (provider === "gemini") {
const geminiSettings = ps as GeminiProviderSettings;
switch (id) {
case "showPro":
geminiSettings.windows.showPro = value === "on";
break;
case "showFlash":
geminiSettings.windows.showFlash = value === "on";
break;
}
}
if (provider === "antigravity") {
const antigravitySettings = ps as AntigravityProviderSettings;
switch (id) {
case "showModels":
antigravitySettings.windows.showModels = value === "on";
break;
case "showCurrentModel":
antigravitySettings.showCurrentModel = value === "on";
break;
case "showScopedModels":
antigravitySettings.showScopedModels = value === "on";
break;
default:
if (id.startsWith("model:")) {
const model = id.slice("model:".length);
if (model) {
if (!antigravitySettings.modelVisibility) {
antigravitySettings.modelVisibility = {};
}
antigravitySettings.modelVisibility[model] = value === "on";
if (!antigravitySettings.modelOrder) {
antigravitySettings.modelOrder = [];
}
if (!antigravitySettings.modelOrder.includes(model)) {
antigravitySettings.modelOrder.push(model);
}
}
}
break;
}
}
if (provider === "codex") {
const codexSettings = ps as CodexProviderSettings;
switch (id) {
case "invertUsage":
codexSettings.invertUsage = value === "on";
break;
case "showPrimary":
codexSettings.windows.showPrimary = value === "on";
break;
case "showSecondary":
codexSettings.windows.showSecondary = value === "on";
break;
}
}
if (provider === "kiro") {
const kiroSettings = ps as KiroProviderSettings;
switch (id) {
case "showCredits":
kiroSettings.windows.showCredits = value === "on";
break;
}
}
if (provider === "zai") {
const zaiSettings = ps as ZaiProviderSettings;
switch (id) {
case "showTokens":
zaiSettings.windows.showTokens = value === "on";
break;
case "showMonthly":
zaiSettings.windows.showMonthly = value === "on";
break;
}
}
return settings;
}
@@ -0,0 +1,23 @@
/**
* Provider-specific window visibility rules.
*/
import type { RateWindow, UsageSnapshot, ModelInfo } from "../types.js";
import type { Settings } from "../settings-types.js";
import { PROVIDER_METADATA } from "./metadata.js";
/**
* Check if a window should be shown based on settings.
*/
export function shouldShowWindow(
usage: UsageSnapshot,
window: RateWindow,
settings?: Settings,
model?: ModelInfo
): boolean {
const handler = PROVIDER_METADATA[usage.provider]?.isWindowVisible;
if (handler) {
return handler(usage, window, settings, model);
}
return true;
}
@@ -0,0 +1,611 @@
/**
* Settings types and defaults for sub-bar
*/
import type { CoreSettings, ProviderName } from "./shared.js";
import { PROVIDERS } from "./shared.js";
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
/**
* Bar display style
*/
export type BarStyle = "bar" | "percentage" | "both";
/**
* Bar rendering type
*/
export type BarType = "horizontal-bar" | "horizontal-single" | "vertical" | "braille" | "shade";
/**
* Color scheme for usage bars
*/
export type ColorScheme = "monochrome" | "base-warning-error" | "success-base-warning-error";
/**
* Progress bar character style
*/
export type BarCharacter = "light" | "heavy" | "double" | "block" | (string & {});
/**
* Divider character style
*/
export type DividerCharacter =
| "none"
| "blank"
| "|"
| "│"
| "┃"
| "┆"
| "┇"
| "║"
| "•"
| "●"
| "○"
| "◇"
| (string & {});
/**
* Widget overflow mode
*/
export type OverflowMode = "truncate" | "wrap";
export type WidgetWrapping = OverflowMode;
/**
* Widget placement
*/
export type WidgetPlacement = "belowEditor";
/**
* Alignment for the widget
*/
export type DisplayAlignment = "left" | "center" | "right" | "split";
/**
* Provider label prefix
*/
export type ProviderLabel = "plan" | "subscription" | "sub" | "none" | (string & {});
/**
* Reset timer format
*/
export type ResetTimeFormat = "relative" | "datetime";
/**
* Reset timer containment style
*/
export type ResetTimerContainment = "none" | "blank" | "()" | "[]" | "<>" | (string & {});
/**
* Status indicator display mode
*/
export type StatusIndicatorMode = "icon" | "text" | "icon+text";
/**
* Status icon pack selection
*/
export type StatusIconPack = "minimal" | "emoji" | "custom";
export interface UsageColorTargets {
title: boolean;
timer: boolean;
bar: boolean;
usageLabel: boolean;
status: boolean;
}
/**
* Divider color options (subset of theme colors).
*/
export const DIVIDER_COLOR_OPTIONS = [
"primary",
"text",
"muted",
"dim",
"success",
"warning",
"error",
"border",
"borderMuted",
"borderAccent",
] as const;
export type DividerColor = (typeof DIVIDER_COLOR_OPTIONS)[number];
/**
* Background color options (theme background colors).
*/
export const BACKGROUND_COLOR_OPTIONS = [
"selectedBg",
"userMessageBg",
"customMessageBg",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
] as const;
export type BackgroundColor = (typeof BACKGROUND_COLOR_OPTIONS)[number];
/**
* Base text/background color options.
*/
export const BASE_COLOR_OPTIONS = [...DIVIDER_COLOR_OPTIONS, ...BACKGROUND_COLOR_OPTIONS] as const;
/**
* Base text color for widget labels
*/
export type BaseTextColor = (typeof BASE_COLOR_OPTIONS)[number];
export function normalizeDividerColor(value?: string): DividerColor {
if (!value) return "borderMuted";
if (value === "accent" || value === "primary") return "primary";
if ((DIVIDER_COLOR_OPTIONS as readonly string[]).includes(value)) {
return value as DividerColor;
}
return "borderMuted";
}
export function resolveDividerColor(value?: string): ThemeColor {
const normalized = normalizeDividerColor(value);
switch (normalized) {
case "primary":
return "accent";
case "border":
case "borderMuted":
case "borderAccent":
case "success":
case "warning":
case "error":
case "muted":
case "dim":
case "text":
return normalized as ThemeColor;
default:
return "borderMuted";
}
}
export function isBackgroundColor(value?: BaseTextColor): value is BackgroundColor {
return !!value && (BACKGROUND_COLOR_OPTIONS as readonly string[]).includes(value);
}
export function normalizeBaseTextColor(value?: string): BaseTextColor {
if (!value) return "dim";
if (value === "accent" || value === "primary") return "primary";
if ((BASE_COLOR_OPTIONS as readonly string[]).includes(value)) {
return value as BaseTextColor;
}
return "dim";
}
export function resolveBaseTextColor(value?: string): BaseTextColor {
return normalizeBaseTextColor(value);
}
/**
* Bar width configuration
*/
export type BarWidth = number | "fill";
/**
* Divider blank spacing configuration
*/
export type DividerBlanks = number | "fill";
/**
* Provider settings (UI-only)
*/
export interface BaseProviderSettings {
/** Show status indicator */
showStatus: boolean;
}
export interface AnthropicProviderSettings extends BaseProviderSettings {
windows: {
show5h: boolean;
show7d: boolean;
showExtra: boolean;
};
}
export interface CopilotProviderSettings extends BaseProviderSettings {
showMultiplier: boolean;
showRequestsLeft: boolean;
quotaDisplay: "percentage" | "requests";
windows: {
showMonth: boolean;
};
}
export interface GeminiProviderSettings extends BaseProviderSettings {
windows: {
showPro: boolean;
showFlash: boolean;
};
}
export interface AntigravityProviderSettings extends BaseProviderSettings {
showCurrentModel: boolean;
showScopedModels: boolean;
windows: {
showModels: boolean;
};
modelVisibility: Record<string, boolean>;
modelOrder: string[];
}
export interface CodexProviderSettings extends BaseProviderSettings {
invertUsage: boolean;
windows: {
showPrimary: boolean;
showSecondary: boolean;
};
}
export interface KiroProviderSettings extends BaseProviderSettings {
windows: {
showCredits: boolean;
};
}
export interface ZaiProviderSettings extends BaseProviderSettings {
windows: {
showTokens: boolean;
showMonthly: boolean;
};
}
export interface ProviderSettingsMap {
anthropic: AnthropicProviderSettings;
copilot: CopilotProviderSettings;
gemini: GeminiProviderSettings;
antigravity: AntigravityProviderSettings;
codex: CodexProviderSettings;
kiro: KiroProviderSettings;
zai: ZaiProviderSettings;
"opencode-go": BaseProviderSettings;
}
export type { BehaviorSettings, CoreSettings } from "./shared.js";
/**
* Keybinding settings.
* Values are key-combo strings accepted by pi's registerShortcut (e.g. "ctrl+alt+p").
* Use "none" to disable a shortcut.
* Changes take effect after pi restart.
*/
export interface KeybindingSettings {
/** Shortcut to cycle through providers */
cycleProvider: string;
/** Shortcut to toggle reset timer format */
toggleResetFormat: string;
}
/**
* Display settings
*/
export interface DisplaySettings {
/** Alignment */
alignment: DisplayAlignment;
/** Bar display style */
barStyle: BarStyle;
/** Bar type */
barType: BarType;
/** Width of the progress bar in characters */
barWidth: BarWidth;
/** Progress bar character */
barCharacter: BarCharacter;
/** Contain bar within ▕ and ▏ */
containBar: boolean;
/** Fill empty braille segments with dim full blocks */
brailleFillEmpty: boolean;
/** Use full braille blocks for filled segments */
brailleFullBlocks: boolean;
/** Color scheme for bars */
colorScheme: ColorScheme;
/** Elements colored by the usage scheme */
usageColorTargets: UsageColorTargets;
/** Reset time display position */
resetTimePosition: "off" | "front" | "back" | "integrated";
/** Reset time format */
resetTimeFormat: ResetTimeFormat;
/** Reset timer containment */
resetTimeContainment: ResetTimerContainment;
/** Status indicator mode */
statusIndicatorMode: StatusIndicatorMode;
/** Status icon pack */
statusIconPack: StatusIconPack;
/** Custom status icon pack (four characters) */
statusIconCustom: string;
/** Show divider between status and provider */
statusProviderDivider: boolean;
/** Dismiss status when operational */
statusDismissOk: boolean;
/** Show provider display name */
showProviderName: boolean;
/** Provider label prefix */
providerLabel: ProviderLabel;
/** Show colon after provider label */
providerLabelColon: boolean;
/** Bold provider name and colon */
providerLabelBold: boolean;
/** Base text color for widget labels */
baseTextColor: BaseTextColor;
/** Background color for the widget line */
backgroundColor: BaseTextColor;
/** Show window titles (5h, Week, etc.) */
showWindowTitle: boolean;
/** Bold window titles (5h, Week, etc.) */
boldWindowTitle: boolean;
/** Show usage labels (used/rem.) */
showUsageLabels: boolean;
/** Divider character */
dividerCharacter: DividerCharacter;
/** Divider color */
dividerColor: DividerColor;
/** Blanks before and after divider */
dividerBlanks: DividerBlanks;
/** Show divider between provider label and usage */
showProviderDivider: boolean;
/** Connect divider glyphs to the bottom divider line */
dividerFooterJoin: boolean;
/** Show divider line above the bar */
showTopDivider: boolean;
/** Show divider line below the bar */
showBottomDivider: boolean;
/** Widget overflow mode */
overflow: OverflowMode;
/** Left padding inside widget */
paddingLeft: number;
/** Right padding inside widget */
paddingRight: number;
/** Widget placement */
widgetPlacement: WidgetPlacement;
/** Show context window usage as leftmost progress bar */
showContextBar: boolean;
/** Error threshold (percentage remaining below this = red) */
errorThreshold: number;
/** Warning threshold (percentage remaining below this = yellow) */
warningThreshold: number;
/** Success threshold (percentage remaining above this = green, gradient only) */
successThreshold: number;
}
/**
* All settings
*/
export interface DisplayTheme {
id: string;
name: string;
display: DisplaySettings;
source?: "saved" | "imported";
}
export interface Settings extends Omit<CoreSettings, "providers"> {
/** Version for migration */
version: number;
/** Provider-specific UI settings */
providers: ProviderSettingsMap;
/** Display settings */
display: DisplaySettings;
/** Stored display themes */
displayThemes: DisplayTheme[];
/** Snapshot of the previous display theme */
displayUserTheme: DisplaySettings | null;
/** Pinned provider override for display */
pinnedProvider: ProviderName | null;
/** Keybinding settings (changes require pi restart) */
keybindings: KeybindingSettings;
}
/**
* Current settings version
*/
export const SETTINGS_VERSION = 2;
/**
* Default settings
*/
export function getDefaultSettings(): Settings {
return {
version: SETTINGS_VERSION,
providers: {
anthropic: {
showStatus: true,
windows: {
show5h: true,
show7d: true,
showExtra: false,
},
},
copilot: {
showStatus: true,
showMultiplier: true,
showRequestsLeft: true,
quotaDisplay: "percentage",
windows: {
showMonth: true,
},
},
gemini: {
showStatus: true,
windows: {
showPro: true,
showFlash: true,
},
},
antigravity: {
showStatus: true,
showCurrentModel: true,
showScopedModels: true,
windows: {
showModels: true,
},
modelVisibility: {},
modelOrder: [],
},
codex: {
showStatus: true,
invertUsage: false,
windows: {
showPrimary: true,
showSecondary: true,
},
},
kiro: {
showStatus: false,
windows: {
showCredits: true,
},
},
zai: {
showStatus: false,
windows: {
showTokens: true,
showMonthly: true,
},
},
"opencode-go": {
showStatus: false,
},
},
display: {
alignment: "split",
barStyle: "both",
barType: "horizontal-bar",
barWidth: "fill",
barCharacter: "heavy",
containBar: false,
brailleFillEmpty: false,
brailleFullBlocks: false,
colorScheme: "base-warning-error",
usageColorTargets: {
title: true,
timer: true,
bar: true,
usageLabel: true,
status: true,
},
resetTimePosition: "front",
resetTimeFormat: "relative",
resetTimeContainment: "blank",
statusIndicatorMode: "icon",
statusIconPack: "emoji",
statusIconCustom: "✓⚠×?",
statusProviderDivider: false,
statusDismissOk: true,
showProviderName: true,
providerLabel: "none",
providerLabelColon: false,
providerLabelBold: true,
baseTextColor: "muted",
backgroundColor: "text",
showWindowTitle: true,
boldWindowTitle: true,
showUsageLabels: true,
dividerCharacter: "│",
dividerColor: "dim",
dividerBlanks: 1,
showProviderDivider: true,
dividerFooterJoin: true,
showTopDivider: false,
showBottomDivider: true,
paddingLeft: 1,
paddingRight: 1,
widgetPlacement: "belowEditor",
showContextBar: false,
errorThreshold: 25,
warningThreshold: 50,
overflow: "truncate",
successThreshold: 75,
},
displayThemes: [],
displayUserTheme: null,
pinnedProvider: null,
keybindings: {
cycleProvider: "ctrl+alt+p",
toggleResetFormat: "ctrl+alt+r",
},
behavior: {
refreshInterval: 60,
minRefreshInterval: 10,
refreshOnTurnStart: false,
refreshOnToolResult: false,
},
statusRefresh: {
refreshInterval: 60,
minRefreshInterval: 10,
refreshOnTurnStart: false,
refreshOnToolResult: false,
},
providerOrder: [...PROVIDERS],
defaultProvider: null,
};
}
/**
* Deep merge two objects
*/
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
const result = { ...target };
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = target[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue, sourceValue as Partial<typeof targetValue>);
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T];
}
}
return result;
}
/**
* Merge settings with defaults (no legacy migrations).
*/
export function mergeSettings(loaded: Partial<Settings>): Settings {
const migrated = migrateSettings(loaded);
return deepMerge(getDefaultSettings(), migrated);
}
function migrateDisplaySettings(display?: Partial<DisplaySettings> | null): void {
if (!display) return;
const displayAny = display as Partial<DisplaySettings> & { widgetWrapping?: OverflowMode; paddingX?: number };
if (displayAny.widgetWrapping !== undefined && displayAny.overflow === undefined) {
displayAny.overflow = displayAny.widgetWrapping;
}
if (displayAny.paddingX !== undefined) {
if (displayAny.paddingLeft === undefined) {
displayAny.paddingLeft = displayAny.paddingX;
}
if (displayAny.paddingRight === undefined) {
displayAny.paddingRight = displayAny.paddingX;
}
delete (displayAny as { paddingX?: unknown }).paddingX;
}
if ("widgetWrapping" in displayAny) {
delete (displayAny as { widgetWrapping?: unknown }).widgetWrapping;
}
}
function migrateSettings(loaded: Partial<Settings>): Partial<Settings> {
migrateDisplaySettings(loaded.display);
migrateDisplaySettings(loaded.displayUserTheme);
if (Array.isArray(loaded.displayThemes)) {
for (const theme of loaded.displayThemes) {
migrateDisplaySettings(theme.display as Partial<DisplaySettings> | undefined);
}
}
return loaded;
}
@@ -0,0 +1,5 @@
/**
* Settings UI entry point (re-export).
*/
export { showSettingsUI } from "./settings/ui.js";
@@ -0,0 +1,176 @@
/**
* Settings persistence for sub-bar
*/
import * as path from "node:path";
import type { Settings } from "./settings-types.js";
import { getDefaultSettings, mergeSettings } from "./settings-types.js";
import { getStorage } from "./storage.js";
import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
/**
* Settings file path
*/
export const SETTINGS_PATH = getSettingsPath();
const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
/**
* In-memory settings cache
*/
let cachedSettings: Settings | undefined;
/**
* Ensure the settings directory exists
*/
function ensureSettingsDir(): void {
const storage = getStorage();
const dir = path.dirname(SETTINGS_PATH);
storage.ensureDir(dir);
}
/**
* Parse settings file contents
*/
function parseSettings(content: string): Settings {
const loaded = JSON.parse(content) as Partial<Settings>;
return mergeSettings({
version: loaded.version,
display: loaded.display,
providers: loaded.providers,
displayThemes: loaded.displayThemes,
displayUserTheme: loaded.displayUserTheme,
pinnedProvider: loaded.pinnedProvider,
keybindings: loaded.keybindings,
} as Partial<Settings>);
}
function loadSettingsFromDisk(settingsPath: string): Settings | null {
const storage = getStorage();
if (storage.exists(settingsPath)) {
const content = storage.readFile(settingsPath);
if (content) {
return parseSettings(content);
}
}
return null;
}
function tryLoadSettings(settingsPath: string): Settings | null {
try {
return loadSettingsFromDisk(settingsPath);
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
return null;
}
}
/**
* Load settings from disk
*/
export function loadSettings(): Settings {
if (cachedSettings) {
return cachedSettings;
}
const diskSettings = tryLoadSettings(SETTINGS_PATH);
if (diskSettings) {
cachedSettings = diskSettings;
return cachedSettings;
}
const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
if (legacySettings) {
const saved = saveSettings(legacySettings);
if (saved) {
getStorage().removeFile(LEGACY_SETTINGS_PATH);
}
cachedSettings = legacySettings;
return cachedSettings;
}
// Return defaults if file doesn't exist or failed to load
cachedSettings = getDefaultSettings();
return cachedSettings;
}
/**
* Save settings to disk
*/
export function saveSettings(settings: Settings): boolean {
const storage = getStorage();
try {
ensureSettingsDir();
let next = settings;
if (cachedSettings) {
const diskSettings = loadSettingsFromDisk(SETTINGS_PATH);
if (diskSettings) {
const displayChanged = JSON.stringify(settings.display) !== JSON.stringify(cachedSettings.display);
const providersChanged = JSON.stringify(settings.providers) !== JSON.stringify(cachedSettings.providers);
const themesChanged = JSON.stringify(settings.displayThemes) !== JSON.stringify(cachedSettings.displayThemes);
const userThemeChanged = JSON.stringify(settings.displayUserTheme) !== JSON.stringify(cachedSettings.displayUserTheme);
const pinnedChanged = settings.pinnedProvider !== cachedSettings.pinnedProvider;
const keybindingsChanged = JSON.stringify(settings.keybindings) !== JSON.stringify(cachedSettings.keybindings);
next = {
...diskSettings,
version: settings.version,
display: displayChanged ? settings.display : diskSettings.display,
providers: providersChanged ? settings.providers : diskSettings.providers,
displayThemes: themesChanged ? settings.displayThemes : diskSettings.displayThemes,
displayUserTheme: userThemeChanged ? settings.displayUserTheme : diskSettings.displayUserTheme,
pinnedProvider: pinnedChanged ? settings.pinnedProvider : diskSettings.pinnedProvider,
keybindings: keybindingsChanged ? settings.keybindings : diskSettings.keybindings,
};
}
}
const content = JSON.stringify({
version: next.version,
display: next.display,
providers: next.providers,
displayThemes: next.displayThemes,
displayUserTheme: next.displayUserTheme,
pinnedProvider: next.pinnedProvider,
keybindings: next.keybindings,
}, null, 2);
storage.writeFile(SETTINGS_PATH, content);
cachedSettings = next;
return true;
} catch (error) {
console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
return false;
}
}
/**
* Reset settings to defaults
*/
export function resetSettings(): Settings {
const defaults = getDefaultSettings();
const current = getSettings();
const next = {
...current,
display: defaults.display,
providers: defaults.providers,
displayThemes: defaults.displayThemes,
displayUserTheme: defaults.displayUserTheme,
pinnedProvider: defaults.pinnedProvider,
keybindings: defaults.keybindings,
version: defaults.version,
};
saveSettings(next);
return next;
}
/**
* Get current settings (cached)
*/
export function getSettings(): Settings {
return loadSettings();
}
/**
* Clear the settings cache (force reload on next access)
*/
export function clearSettingsCache(): void {
cachedSettings = undefined;
}
@@ -0,0 +1,718 @@
/**
* Display settings UI helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type {
Settings,
BarStyle,
BarType,
ColorScheme,
BarCharacter,
DividerCharacter,
WidgetWrapping,
DisplayAlignment,
BarWidth,
DividerBlanks,
ProviderLabel,
BaseTextColor,
ResetTimeFormat,
ResetTimerContainment,
StatusIndicatorMode,
StatusIconPack,
DividerColor,
UsageColorTargets,
} from "../settings-types.js";
import {
BASE_COLOR_OPTIONS,
DIVIDER_COLOR_OPTIONS,
normalizeBaseTextColor,
normalizeDividerColor,
} from "../settings-types.js";
import { CUSTOM_OPTION } from "../ui/settings-list.js";
export function buildDisplayLayoutItems(settings: Settings): SettingItem[] {
return [
{
id: "showContextBar",
label: "Show Context Bar",
currentValue: settings.display.showContextBar ? "on" : "off",
values: ["on", "off"],
description: "Show context window usage as leftmost progress bar.",
},
{
id: "alignment",
label: "Alignment",
currentValue: settings.display.alignment,
values: ["left", "center", "right", "split"] as DisplayAlignment[],
description: "Align the usage line inside the widget.",
},
{
id: "overflow",
label: "Overflow",
currentValue: settings.display.overflow,
values: ["truncate", "wrap"] as WidgetWrapping[],
description: "Wrap the usage line or truncate with ellipsis (requires bar width ≠ fill and alignment ≠ split).",
},
{
id: "paddingLeft",
label: "Padding Left",
currentValue: String(settings.display.paddingLeft ?? 0),
values: ["0", "1", "2", "3", "4", CUSTOM_OPTION],
description: "Add left padding inside the widget.",
},
{
id: "paddingRight",
label: "Padding Right",
currentValue: String(settings.display.paddingRight ?? 0),
values: ["0", "1", "2", "3", "4", CUSTOM_OPTION],
description: "Add right padding inside the widget.",
},
];
}
export function buildDisplayResetItems(settings: Settings): SettingItem[] {
return [
{
id: "resetTimePosition",
label: "Reset Timer",
currentValue: settings.display.resetTimePosition,
values: ["off", "front", "back", "integrated"],
description: "Where to show the reset timer in each window.",
},
{
id: "resetTimeFormat",
label: "Reset Timer Format",
currentValue: settings.display.resetTimeFormat ?? "relative",
values: ["relative", "datetime"] as ResetTimeFormat[],
description: "Show relative countdown or reset datetime.",
},
{
id: "resetTimeContainment",
label: "Reset Timer Containment",
currentValue: settings.display.resetTimeContainment ?? "()",
values: ["none", "blank", "()", "[]", "<>", CUSTOM_OPTION] as ResetTimerContainment[],
description: "Wrapping characters for the reset timer (custom supported).",
},
];
}
export function resolveUsageColorTargets(targets?: UsageColorTargets): UsageColorTargets {
return {
title: targets?.title ?? true,
timer: targets?.timer ?? true,
bar: targets?.bar ?? true,
usageLabel: targets?.usageLabel ?? true,
status: targets?.status ?? true,
};
}
export function formatUsageColorTargetsSummary(targets?: UsageColorTargets): string {
const resolved = resolveUsageColorTargets(targets);
const enabled = [
resolved.title ? "Title" : null,
resolved.timer ? "Timer" : null,
resolved.bar ? "Bar" : null,
resolved.usageLabel ? "Usage label" : null,
resolved.status ? "Status" : null,
].filter(Boolean) as string[];
if (enabled.length === 0) return "off";
if (enabled.length === 5) return "all";
return enabled.join(", ");
}
export function buildUsageColorTargetItems(settings: Settings): SettingItem[] {
const targets = resolveUsageColorTargets(settings.display.usageColorTargets);
return [
{
id: "usageColorTitle",
label: "Title",
currentValue: targets.title ? "on" : "off",
values: ["on", "off"],
description: "Color the window title by usage.",
},
{
id: "usageColorTimer",
label: "Timer",
currentValue: targets.timer ? "on" : "off",
values: ["on", "off"],
description: "Color the reset timer by usage.",
},
{
id: "usageColorBar",
label: "Bar",
currentValue: targets.bar ? "on" : "off",
values: ["on", "off"],
description: "Color the usage bar by usage.",
},
{
id: "usageColorLabel",
label: "Usage label",
currentValue: targets.usageLabel ? "on" : "off",
values: ["on", "off"],
description: "Color the percentage text by usage.",
},
{
id: "usageColorStatus",
label: "Status",
currentValue: targets.status ? "on" : "off",
values: ["on", "off"],
description: "Color the status indicator by status.",
},
];
}
export function buildDisplayColorItems(settings: Settings): SettingItem[] {
return [
{
id: "baseTextColor",
label: "Base Color",
currentValue: normalizeBaseTextColor(settings.display.baseTextColor),
values: [...BASE_COLOR_OPTIONS] as BaseTextColor[],
description: "Base color for neutral labels and dividers.",
},
{
id: "backgroundColor",
label: "Background Color",
currentValue: normalizeBaseTextColor(settings.display.backgroundColor),
values: [...BASE_COLOR_OPTIONS] as BaseTextColor[],
description: "Background color for the widget line.",
},
{
id: "colorScheme",
label: "Color Indicator Scheme",
currentValue: settings.display.colorScheme,
values: [
"base-warning-error",
"success-base-warning-error",
"monochrome",
] as ColorScheme[],
description: "Choose how usage/status indicators are color-coded.",
},
{
id: "usageColorTargets",
label: "Color Indicator Targets",
currentValue: formatUsageColorTargetsSummary(settings.display.usageColorTargets),
description: "Pick which elements use the indicator colors.",
},
{
id: "errorThreshold",
label: "Error Threshold (%)",
currentValue: String(settings.display.errorThreshold),
values: ["10", "15", "20", "25", "30", "35", "40", CUSTOM_OPTION],
description: "Percent remaining below which usage is red.",
},
{
id: "warningThreshold",
label: "Warning Threshold (%)",
currentValue: String(settings.display.warningThreshold),
values: ["30", "40", "50", "60", "70", CUSTOM_OPTION],
description: "Percent remaining below which usage is yellow.",
},
{
id: "successThreshold",
label: "Success Threshold (%)",
currentValue: String(settings.display.successThreshold),
values: ["60", "70", "75", "80", "90", CUSTOM_OPTION],
description: "Percent remaining above which usage is green.",
},
];
}
export function buildDisplayBarItems(settings: Settings): SettingItem[] {
const items: SettingItem[] = [
{
id: "barType",
label: "Bar Type",
currentValue: settings.display.barType,
values: [
"horizontal-bar",
"horizontal-single",
"vertical",
"braille",
"shade",
] as BarType[],
description: "Choose the bar glyph style for usage.",
},
];
if (settings.display.barType === "horizontal-bar") {
items.push({
id: "barCharacter",
label: "H. Bar Character",
currentValue: settings.display.barCharacter,
values: ["light", "heavy", "double", "block", CUSTOM_OPTION],
description: "Custom bar character(s), set 1 or 2 (fill/empty)",
});
}
items.push(
{
id: "barWidth",
label: "Bar Width",
currentValue: String(settings.display.barWidth),
values: ["1", "4", "6", "8", "10", "12", "fill", CUSTOM_OPTION],
description: "Set the bar width or fill available space.",
},
{
id: "containBar",
label: "Contain Bar",
currentValue: settings.display.containBar ? "on" : "off",
values: ["on", "off"],
description: "Wrap the bar with ▕ and ▏ caps.",
},
);
if (settings.display.barType === "braille") {
items.push(
{
id: "brailleFillEmpty",
label: "Braille Empty Fill",
currentValue: settings.display.brailleFillEmpty ? "on" : "off",
values: ["on", "off"],
description: "Fill empty braille cells with dim blocks.",
},
{
id: "brailleFullBlocks",
label: "Braille Full Blocks",
currentValue: settings.display.brailleFullBlocks ? "on" : "off",
values: ["on", "off"],
description: "Use full 8-dot braille blocks for filled segments.",
},
);
}
items.push({
id: "barStyle",
label: "Bar Style",
currentValue: settings.display.barStyle,
values: ["bar", "percentage", "both"] as BarStyle[],
description: "Show bar, percentage, or both.",
});
return items;
}
export function buildDisplayProviderItems(settings: Settings): SettingItem[] {
return [
{
id: "showProviderName",
label: "Show Provider Name",
currentValue: settings.display.showProviderName ? "on" : "off",
values: ["on", "off"],
description: "Toggle the provider name prefix.",
},
{
id: "providerLabel",
label: "Provider Label",
currentValue: settings.display.providerLabel,
values: ["none", "plan", "subscription", "sub", CUSTOM_OPTION] as (ProviderLabel | typeof CUSTOM_OPTION)[],
description: "Suffix appended after the provider name.",
},
{
id: "providerLabelColon",
label: "Provider Label Colon",
currentValue: settings.display.providerLabelColon ? "on" : "off",
values: ["on", "off"],
description: "Show a colon after the provider label.",
},
{
id: "providerLabelBold",
label: "Show in Bold",
currentValue: settings.display.providerLabelBold ? "on" : "off",
values: ["on", "off"],
description: "Bold the provider name and colon.",
},
{
id: "showUsageLabels",
label: "Show Usage Labels",
currentValue: settings.display.showUsageLabels ? "on" : "off",
values: ["on", "off"],
description: "Show “used/rem.” labels after percentages.",
},
{
id: "showWindowTitle",
label: "Show Title",
currentValue: settings.display.showWindowTitle ? "on" : "off",
values: ["on", "off"],
description: "Show window titles like 5h, Week, etc.",
},
{
id: "boldWindowTitle",
label: "Bold Title",
currentValue: settings.display.boldWindowTitle ? "on" : "off",
values: ["on", "off"],
description: "Bold window titles like 5h, Week, etc.",
},
];
}
const STATUS_ICON_PACK_PREVIEW = {
minimal: "minimal (✓ ⚠ × ?)",
emoji: "emoji (✅ ⚠️ 🔴 ❓)",
faces: "faces (😎 😳 😵 🤔)",
} as const;
const STATUS_ICON_FACES_PRESET = "😎😳😵🤔";
const STATUS_ICON_CUSTOM_FALLBACK = ["✓", "⚠", "×", "?"];
const STATUS_ICON_CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
function resolveCustomStatusIcons(value?: string): [string, string, string, string] {
if (!value) return STATUS_ICON_CUSTOM_FALLBACK as [string, string, string, string];
const segments = Array.from(STATUS_ICON_CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment)
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length < 3) return STATUS_ICON_CUSTOM_FALLBACK as [string, string, string, string];
if (segments.length === 3) {
return [segments[0], segments[1], segments[2], STATUS_ICON_CUSTOM_FALLBACK[3]] as [string, string, string, string];
}
return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string];
}
function formatCustomStatusIcons(value?: string): string {
return resolveCustomStatusIcons(value).join(" ");
}
function formatStatusIconPack(pack: Exclude<StatusIconPack, "custom">): string {
return STATUS_ICON_PACK_PREVIEW[pack] ?? pack;
}
function parseStatusIconPack(value: string): StatusIconPack {
if (value.startsWith("minimal")) return "minimal";
if (value.startsWith("emoji")) return "emoji";
return "emoji";
}
export function buildDisplayStatusItems(settings: Settings): SettingItem[] {
const rawMode = settings.display.statusIndicatorMode ?? "icon";
const mode: StatusIndicatorMode = rawMode === "text" || rawMode === "icon+text" || rawMode === "icon"
? rawMode
: "icon";
const items: SettingItem[] = [
{
id: "statusIndicatorMode",
label: "Status Mode",
currentValue: mode,
values: ["icon", "text", "icon+text"] as StatusIndicatorMode[],
description: "Use icons, text, or both for status indicators.",
},
];
if (mode === "icon" || mode === "icon+text") {
const pack = settings.display.statusIconPack ?? "emoji";
const customIcons = settings.display.statusIconCustom;
items.push({
id: "statusIconPack",
label: "Status Icon Pack",
currentValue: pack === "custom" ? formatCustomStatusIcons(customIcons) : formatStatusIconPack(pack),
values: [
formatStatusIconPack("minimal"),
formatStatusIconPack("emoji"),
STATUS_ICON_PACK_PREVIEW.faces,
CUSTOM_OPTION,
],
description: "Pick the icon set used for status indicators. Choose custom to edit icons (OK/warn/error/unknown).",
});
}
items.push(
{
id: "statusDismissOk",
label: "Dismiss Operational Status",
currentValue: settings.display.statusDismissOk ? "on" : "off",
values: ["on", "off"],
description: "Hide status indicators when there are no incidents.",
}
);
return items;
}
export function buildDisplayDividerItems(settings: Settings): SettingItem[] {
return [
{
id: "dividerCharacter",
label: "Divider Character",
currentValue: settings.display.dividerCharacter,
values: ["none", "blank", "|", "│", "┃", "┆", "┇", "║", "•", "●", "○", "◇", CUSTOM_OPTION] as DividerCharacter[],
description: "Choose the divider glyph between windows.",
},
{
id: "dividerColor",
label: "Divider Color",
currentValue: normalizeDividerColor(settings.display.dividerColor ?? "borderMuted"),
values: [...DIVIDER_COLOR_OPTIONS] as DividerColor[],
description: "Color used for divider glyphs and lines.",
},
{
id: "statusProviderDivider",
label: "Status/Provider Divider",
currentValue: settings.display.statusProviderDivider ? "on" : "off",
values: ["on", "off"],
description: "Add a divider between status and provider label.",
},
{
id: "dividerBlanks",
label: "Blanks Before/After Divider",
currentValue: String(settings.display.dividerBlanks),
values: ["0", "1", "2", "3", "fill", CUSTOM_OPTION],
description: "Padding around the divider character.",
},
{
id: "showProviderDivider",
label: "Show Provider Divider",
currentValue: settings.display.showProviderDivider ? "on" : "off",
values: ["on", "off"],
description: "Show the divider after the provider label.",
},
{
id: "showTopDivider",
label: "Show Top Divider",
currentValue: settings.display.showTopDivider ? "on" : "off",
values: ["on", "off"],
description: "Show a divider line above the widget.",
},
{
id: "showBottomDivider",
label: "Show Bottom Divider",
currentValue: settings.display.showBottomDivider ? "on" : "off",
values: ["on", "off"],
description: "Show a divider line below the widget.",
},
{
id: "dividerFooterJoin",
label: "Connect Dividers",
currentValue: settings.display.dividerFooterJoin ? "on" : "off",
values: ["on", "off"],
description: "Draw reverse-T connectors for top/bottom dividers.",
},
];
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function parseClampedNumber(value: string, min: number, max: number): number | null {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) return null;
return clampNumber(parsed, min, max);
}
export function applyDisplayChange(settings: Settings, id: string, value: string): Settings {
switch (id) {
case "alignment":
settings.display.alignment = value as DisplayAlignment;
break;
case "barType":
settings.display.barType = value as BarType;
break;
case "barStyle":
settings.display.barStyle = value as BarStyle;
break;
case "barWidth": {
if (value === "fill") {
settings.display.barWidth = "fill" as BarWidth;
break;
}
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.barWidth = parsed;
}
break;
}
case "containBar":
settings.display.containBar = value === "on";
break;
case "barCharacter":
settings.display.barCharacter = value as BarCharacter;
break;
case "brailleFillEmpty":
settings.display.brailleFillEmpty = value === "on";
break;
case "brailleFullBlocks":
settings.display.brailleFullBlocks = value === "on";
break;
case "colorScheme":
settings.display.colorScheme = value as ColorScheme;
break;
case "usageColorTitle":
settings.display.usageColorTargets = {
...resolveUsageColorTargets(settings.display.usageColorTargets),
title: value === "on",
};
break;
case "usageColorTimer":
settings.display.usageColorTargets = {
...resolveUsageColorTargets(settings.display.usageColorTargets),
timer: value === "on",
};
break;
case "usageColorBar":
settings.display.usageColorTargets = {
...resolveUsageColorTargets(settings.display.usageColorTargets),
bar: value === "on",
};
break;
case "usageColorLabel":
settings.display.usageColorTargets = {
...resolveUsageColorTargets(settings.display.usageColorTargets),
usageLabel: value === "on",
};
break;
case "usageColorStatus":
settings.display.usageColorTargets = {
...resolveUsageColorTargets(settings.display.usageColorTargets),
status: value === "on",
};
break;
case "usageColorTargets":
settings.display.usageColorTargets = resolveUsageColorTargets(settings.display.usageColorTargets);
break;
case "resetTimePosition":
settings.display.resetTimePosition = value as "off" | "front" | "back" | "integrated";
break;
case "resetTimeFormat":
settings.display.resetTimeFormat = value as ResetTimeFormat;
break;
case "resetTimeContainment":
if (value === CUSTOM_OPTION) {
break;
}
settings.display.resetTimeContainment = value as ResetTimerContainment;
break;
case "statusIndicatorMode":
settings.display.statusIndicatorMode = value as StatusIndicatorMode;
break;
case "statusIconPack":
if (value === CUSTOM_OPTION) {
settings.display.statusIconPack = "custom";
break;
}
if (value.startsWith("minimal") || value.startsWith("emoji")) {
settings.display.statusIconPack = parseStatusIconPack(value);
break;
}
if (value.startsWith("faces")) {
settings.display.statusIconCustom = STATUS_ICON_FACES_PRESET;
settings.display.statusIconPack = "custom";
break;
}
settings.display.statusIconCustom = value;
settings.display.statusIconPack = "custom";
break;
case "statusIconCustom":
settings.display.statusIconCustom = value;
settings.display.statusIconPack = "custom";
break;
case "statusProviderDivider":
settings.display.statusProviderDivider = value === "on";
break;
case "statusDismissOk":
settings.display.statusDismissOk = value === "on";
break;
case "showProviderName":
settings.display.showProviderName = value === "on";
break;
case "providerLabel":
settings.display.providerLabel = value as ProviderLabel;
break;
case "providerLabelColon":
settings.display.providerLabelColon = value === "on";
break;
case "providerLabelBold":
settings.display.providerLabelBold = value === "on";
break;
case "baseTextColor":
settings.display.baseTextColor = normalizeBaseTextColor(value);
break;
case "backgroundColor":
settings.display.backgroundColor = normalizeBaseTextColor(value);
break;
case "showUsageLabels":
settings.display.showUsageLabels = value === "on";
break;
case "showWindowTitle":
settings.display.showWindowTitle = value === "on";
break;
case "boldWindowTitle":
settings.display.boldWindowTitle = value === "on";
break;
case "showContextBar":
settings.display.showContextBar = value === "on";
break;
case "paddingLeft": {
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.paddingLeft = parsed;
}
break;
}
case "paddingRight": {
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.paddingRight = parsed;
}
break;
}
case "dividerCharacter":
settings.display.dividerCharacter = value as DividerCharacter;
break;
case "dividerColor":
settings.display.dividerColor = normalizeDividerColor(value);
break;
case "dividerBlanks": {
if (value === "fill") {
settings.display.dividerBlanks = "fill" as DividerBlanks;
break;
}
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.dividerBlanks = parsed;
}
break;
}
case "showProviderDivider":
settings.display.showProviderDivider = value === "on";
break;
case "dividerFooterJoin":
settings.display.dividerFooterJoin = value === "on";
break;
case "showTopDivider":
settings.display.showTopDivider = value === "on";
break;
case "showBottomDivider":
settings.display.showBottomDivider = value === "on";
break;
case "overflow":
settings.display.overflow = value as WidgetWrapping;
break;
case "widgetWrapping":
settings.display.overflow = value as WidgetWrapping;
break;
case "errorThreshold": {
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.errorThreshold = parsed;
}
break;
}
case "warningThreshold": {
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.warningThreshold = parsed;
}
break;
}
case "successThreshold": {
const parsed = parseClampedNumber(value, 0, 100);
if (parsed !== null) {
settings.display.successThreshold = parsed;
}
break;
}
}
return settings;
}
@@ -0,0 +1,183 @@
/**
* Settings menu item builders.
*/
import type { SelectItem } from "@mariozechner/pi-tui";
import type { CoreProviderSettingsMap } from "../../shared.js";
import type { Settings } from "../settings-types.js";
import type { ProviderName } from "../types.js";
import { PROVIDERS, PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
export type TooltipSelectItem = SelectItem & { tooltip?: string };
export function buildMainMenuItems(settings: Settings, pinnedProvider?: ProviderName | null): TooltipSelectItem[] {
const pinnedLabel = pinnedProvider ? PROVIDER_DISPLAY_NAMES[pinnedProvider] : "auto (current provider)";
const kb = settings.keybindings;
const kbDesc = `cycle: ${kb.cycleProvider}, reset: ${kb.toggleResetFormat}`;
return [
{
value: "display-theme",
label: "Themes",
description: "save, manage, share",
tooltip: "Save, load, and share display themes.",
},
{
value: "display",
label: "Adv. Display Settings",
description: "layout, bars, colors",
tooltip: "Adjust layout, colors, bar styling, status indicators, and dividers.",
},
{
value: "providers",
label: "Provider Settings",
description: "provider specific settings",
tooltip: "Configure provider display toggles and window visibility.",
},
{
value: "pin-provider",
label: "Provider Shown",
description: pinnedLabel,
tooltip: "Select which provider is shown in the widget.",
},
{
value: "keybindings",
label: "Keybindings",
description: kbDesc,
tooltip: "Configure keyboard shortcuts. Changes take effect after pi restart.",
},
{
value: "open-core-settings",
label: "Additional settings",
description: "in /sub-core:settings",
tooltip: "Open /sub-core:settings for refresh behavior and provider enablement.",
},
];
}
export function buildProviderListItems(settings: Settings, coreProviders?: CoreProviderSettingsMap): TooltipSelectItem[] {
const orderedProviders = settings.providerOrder.length > 0 ? settings.providerOrder : PROVIDERS;
const items: TooltipSelectItem[] = orderedProviders.map((provider) => {
const ps = settings.providers[provider];
const core = coreProviders?.[provider];
const enabledValue = core
? core.enabled === "auto"
? "auto"
: core.enabled === true || core.enabled === "on"
? "on"
: "off"
: "auto";
const status = ps.showStatus ? "status on" : "status off";
return {
value: `provider-${provider}`,
label: PROVIDER_DISPLAY_NAMES[provider],
description: `enabled ${enabledValue}, ${status}`,
tooltip: `Configure ${PROVIDER_DISPLAY_NAMES[provider]} display settings.`,
};
});
items.push({
value: "reset-providers",
label: "Reset Provider Defaults",
description: "restore provider settings",
tooltip: "Restore provider display settings to their defaults.",
});
return items;
}
export function buildDisplayMenuItems(): TooltipSelectItem[] {
return [
{
value: "display-layout",
label: "Layout & Structure",
description: "alignment, wrapping, padding",
tooltip: "Control alignment, wrapping, and padding.",
},
{
value: "display-bar",
label: "Bars",
description: "style, width, character",
tooltip: "Customize bar type, width, and bar styling.",
},
{
value: "display-provider",
label: "Labels & Text",
description: "labels, titles, usage text",
tooltip: "Adjust provider label visibility and text styling.",
},
{
value: "display-reset",
label: "Reset Timer",
description: "position, format, wrapping",
tooltip: "Control reset timer placement and formatting.",
},
{
value: "display-status",
label: "Status",
description: "mode, icons, text",
tooltip: "Configure status mode and icon packs.",
},
{
value: "display-divider",
label: "Dividers",
description: "character, blanks, status divider, lines",
tooltip: "Change divider character, spacing, status separator, and divider lines.",
},
{
value: "display-color",
label: "Colors",
description: "base, scheme, thresholds",
tooltip: "Tune base colors, color scheme, and thresholds.",
},
];
}
export function buildDisplayThemeMenuItems(): TooltipSelectItem[] {
return [
{
value: "display-theme-save",
label: "Save Theme",
description: "store current theme",
tooltip: "Save the current display theme with a custom name.",
},
{
value: "display-theme-load",
label: "Load & Manage themes",
description: "load, share, rename and delete themes",
tooltip: "Load, share, delete, rename, and restore saved themes.",
},
{
value: "display-theme-share",
label: "Share Theme",
description: "share current theme",
tooltip: "Post a share string for the current theme.",
},
{
value: "display-theme-import",
label: "Import theme",
description: "from share string",
tooltip: "Import a shared theme string.",
},
{
value: "display-theme-random",
label: "Random theme",
description: "generate a new theme",
tooltip: "Generate a random display theme as inspiration or a starting point.",
},
{
value: "display-theme-restore",
label: "Restore previous state",
description: "restore your last theme",
tooltip: "Restore your previous display theme.",
},
];
}
export function buildProviderSettingsItems(settings: Settings): TooltipSelectItem[] {
return buildProviderListItems(settings);
}
export function getProviderFromCategory(category: string): ProviderName | null {
const match = category.match(/^provider-(\w+)$/);
return match ? (match[1] as ProviderName) : null;
}
@@ -0,0 +1,349 @@
import type { Settings } from "../settings-types.js";
import type { TooltipSelectItem } from "./menu.js";
type DisplaySettings = Settings["display"];
type BarType = DisplaySettings["barType"];
type BarStyle = DisplaySettings["barStyle"];
type BarCharacter = DisplaySettings["barCharacter"];
type BarWidth = DisplaySettings["barWidth"];
type DividerCharacter = DisplaySettings["dividerCharacter"];
type DividerBlanks = DisplaySettings["dividerBlanks"];
type DisplayAlignment = DisplaySettings["alignment"];
type OverflowMode = DisplaySettings["overflow"];
type BaseTextColor = DisplaySettings["baseTextColor"];
type DividerColor = DisplaySettings["dividerColor"];
type ResetTimeFormat = DisplaySettings["resetTimeFormat"];
type ResetTimerContainment = DisplaySettings["resetTimeContainment"];
type StatusIndicatorMode = DisplaySettings["statusIndicatorMode"];
type StatusIconPack = DisplaySettings["statusIconPack"];
type ProviderLabel = DisplaySettings["providerLabel"];
const RANDOM_BAR_TYPES: BarType[] = ["horizontal-bar", "horizontal-single", "vertical", "braille", "shade"];
const RANDOM_BAR_STYLES: BarStyle[] = ["bar", "percentage", "both"];
const RANDOM_BAR_WIDTHS: BarWidth[] = [1, 4, 6, 8, 10, 12, "fill"];
const RANDOM_BAR_CHARACTERS: BarCharacter[] = [
"light",
"heavy",
"double",
"block",
"▮▯",
"■□",
"●○",
"▲△",
"◆◇",
"🚀_",
];
const RANDOM_ALIGNMENTS: DisplayAlignment[] = ["left", "center", "right", "split"];
const RANDOM_OVERFLOW: OverflowMode[] = ["truncate", "wrap"];
const RANDOM_RESET_POSITIONS: DisplaySettings["resetTimePosition"][] = ["off", "front", "back", "integrated"];
const RANDOM_RESET_FORMATS: ResetTimeFormat[] = ["relative", "datetime"];
const RANDOM_RESET_CONTAINMENTS: ResetTimerContainment[] = ["none", "blank", "()", "[]", "<>"];
const RANDOM_STATUS_MODES: StatusIndicatorMode[] = ["icon", "text", "icon+text"];
const RANDOM_STATUS_PACKS: StatusIconPack[] = ["minimal", "emoji"];
const RANDOM_PROVIDER_LABELS: ProviderLabel[] = ["plan", "subscription", "sub", "none"];
const RANDOM_DIVIDER_CHARACTERS: DividerCharacter[] = ["none", "blank", "|", "│", "┃", "┆", "┇", "║", "•", "●", "○", "◇"];
const RANDOM_DIVIDER_BLANKS: DividerBlanks[] = [0, 1, 2, 3];
const RANDOM_COLOR_SCHEMES: DisplaySettings["colorScheme"][] = [
"base-warning-error",
"success-base-warning-error",
"monochrome",
];
const RANDOM_BASE_TEXT_COLORS: BaseTextColor[] = ["dim", "muted", "text", "primary", "success", "warning", "error", "border", "borderMuted"];
const RANDOM_BACKGROUND_COLORS: BaseTextColor[] = [
"text",
"selectedBg",
"userMessageBg",
"customMessageBg",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
];
const RANDOM_DIVIDER_COLORS: DividerColor[] = [
"primary",
"text",
"muted",
"dim",
"success",
"warning",
"error",
"border",
"borderMuted",
"borderAccent",
];
const RANDOM_PADDING: number[] = [0, 1, 2, 3, 4];
function pickRandom<T>(items: readonly T[]): T {
return items[Math.floor(Math.random() * items.length)] ?? items[0]!;
}
function randomBool(probability = 0.5): boolean {
return Math.random() < probability;
}
const THEME_ID_LENGTH = 24;
const THEME_ID_FALLBACK = "theme";
function buildThemeId(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, THEME_ID_LENGTH) || THEME_ID_FALLBACK;
}
export interface DisplayThemeTarget {
id?: string;
name: string;
display: Settings["display"];
deletable: boolean;
}
export function buildDisplayThemeItems(
settings: Settings,
): TooltipSelectItem[] {
const items: TooltipSelectItem[] = [];
items.push({
value: "user",
label: "Restore backup",
description: "restore your last theme",
tooltip: "Restore your previous display theme.",
});
items.push({
value: "default",
label: "Default",
description: "restore default settings",
tooltip: "Reset display settings to defaults.",
});
items.push({
value: "minimal",
label: "Default Minimal",
description: "compact display",
tooltip: "Apply the default minimal theme.",
});
for (const theme of settings.displayThemes) {
const description = theme.source === "imported" ? "manually imported theme" : "manually saved theme";
items.push({
value: `theme:${theme.id}`,
label: theme.name,
description,
tooltip: `Manage ${theme.name}.`,
});
}
return items;
}
export function resolveDisplayThemeTarget(
value: string,
settings: Settings,
defaults: Settings,
fallbackUser: Settings["display"] | null,
): DisplayThemeTarget | null {
if (value === "user") {
const display = settings.displayUserTheme ?? fallbackUser ?? settings.display;
return { name: "Restore backup", display, deletable: false };
}
if (value === "default") {
return { name: "Default", display: { ...defaults.display }, deletable: false };
}
if (value === "minimal") {
return {
name: "Default Minimal",
display: {
...defaults.display,
alignment: "split",
barStyle: "percentage",
barType: "horizontal-bar",
barWidth: 1,
barCharacter: "heavy",
containBar: true,
brailleFillEmpty: false,
brailleFullBlocks: false,
colorScheme: "base-warning-error",
usageColorTargets: {
title: true,
timer: true,
bar: true,
usageLabel: true,
status: true,
},
resetTimePosition: "off",
resetTimeFormat: "relative",
resetTimeContainment: "blank",
statusIndicatorMode: "icon",
statusIconPack: "minimal",
statusProviderDivider: false,
statusDismissOk: true,
showProviderName: false,
providerLabel: "none",
providerLabelColon: false,
providerLabelBold: true,
baseTextColor: "muted",
backgroundColor: "text",
showWindowTitle: false,
boldWindowTitle: true,
showUsageLabels: false,
dividerCharacter: "none",
dividerColor: "dim",
dividerBlanks: 1,
showProviderDivider: true,
dividerFooterJoin: true,
showTopDivider: false,
showBottomDivider: false,
paddingLeft: 1,
paddingRight: 1,
widgetPlacement: "belowEditor",
errorThreshold: 25,
warningThreshold: 50,
overflow: "truncate",
successThreshold: 75,
},
deletable: false,
};
}
if (value.startsWith("theme:")) {
const id = value.replace("theme:", "");
const theme = settings.displayThemes.find((entry) => entry.id === id);
if (!theme) return null;
return { id: theme.id, name: theme.name, display: theme.display, deletable: true };
}
return null;
}
export function buildRandomDisplay(base: DisplaySettings): DisplaySettings {
const display: DisplaySettings = { ...base };
display.alignment = pickRandom(RANDOM_ALIGNMENTS);
display.overflow = pickRandom(RANDOM_OVERFLOW);
const padding = pickRandom(RANDOM_PADDING);
display.paddingLeft = padding;
display.paddingRight = padding;
display.barStyle = pickRandom(RANDOM_BAR_STYLES);
display.barType = pickRandom(RANDOM_BAR_TYPES);
display.barWidth = pickRandom(RANDOM_BAR_WIDTHS);
display.barCharacter = pickRandom(RANDOM_BAR_CHARACTERS);
display.containBar = randomBool();
display.brailleFillEmpty = randomBool();
display.brailleFullBlocks = randomBool();
display.colorScheme = pickRandom(RANDOM_COLOR_SCHEMES);
const usageColorTargets = {
title: randomBool(),
timer: randomBool(),
bar: randomBool(),
usageLabel: randomBool(),
status: randomBool(),
};
if (!usageColorTargets.title && !usageColorTargets.timer && !usageColorTargets.bar && !usageColorTargets.usageLabel && !usageColorTargets.status) {
usageColorTargets.bar = true;
}
display.usageColorTargets = usageColorTargets;
display.resetTimePosition = pickRandom(RANDOM_RESET_POSITIONS);
display.resetTimeFormat = pickRandom(RANDOM_RESET_FORMATS);
display.resetTimeContainment = pickRandom(RANDOM_RESET_CONTAINMENTS);
display.statusIndicatorMode = pickRandom(RANDOM_STATUS_MODES);
display.statusIconPack = pickRandom(RANDOM_STATUS_PACKS);
display.statusProviderDivider = randomBool();
display.statusDismissOk = randomBool();
display.showProviderName = randomBool();
display.providerLabel = pickRandom(RANDOM_PROVIDER_LABELS);
display.providerLabelColon = display.providerLabel !== "none" && randomBool();
display.providerLabelBold = randomBool();
display.baseTextColor = pickRandom(RANDOM_BASE_TEXT_COLORS);
display.backgroundColor = pickRandom(RANDOM_BACKGROUND_COLORS);
display.boldWindowTitle = randomBool();
display.showUsageLabels = randomBool();
display.dividerCharacter = pickRandom(RANDOM_DIVIDER_CHARACTERS);
display.dividerColor = pickRandom(RANDOM_DIVIDER_COLORS);
display.dividerBlanks = pickRandom(RANDOM_DIVIDER_BLANKS);
display.showProviderDivider = randomBool();
display.dividerFooterJoin = randomBool();
display.showTopDivider = randomBool();
display.showBottomDivider = randomBool();
if (display.dividerCharacter === "none") {
display.showProviderDivider = false;
display.dividerFooterJoin = false;
display.showTopDivider = false;
display.showBottomDivider = false;
}
if (display.providerLabel === "none") {
display.providerLabelColon = false;
}
return display;
}
export function buildThemeActionItems(target: DisplayThemeTarget): TooltipSelectItem[] {
const items: TooltipSelectItem[] = [
{
value: "load",
label: "Load",
description: "apply this theme",
tooltip: "Apply the selected theme.",
},
{
value: "share",
label: "Share",
description: "post share string",
tooltip: "Post a shareable theme string to chat.",
},
];
if (target.deletable) {
items.push({
value: "rename",
label: "Rename",
description: "rename saved theme",
tooltip: "Rename this saved theme.",
});
items.push({
value: "delete",
label: "Delete",
description: "remove saved theme",
tooltip: "Remove this theme from saved themes.",
});
}
return items;
}
export function upsertDisplayTheme(
settings: Settings,
name: string,
display: Settings["display"],
source?: "saved" | "imported",
): Settings {
const trimmed = name.trim() || "Theme";
const id = buildThemeId(trimmed);
const snapshot = { ...display };
const existing = settings.displayThemes.find((theme) => theme.id === id);
const resolvedSource = source ?? existing?.source ?? "saved";
if (existing) {
existing.name = trimmed;
existing.display = snapshot;
existing.source = resolvedSource;
} else {
settings.displayThemes.push({ id, name: trimmed, display: snapshot, source: resolvedSource });
}
return settings;
}
export function renameDisplayTheme(settings: Settings, id: string, name: string): Settings {
const trimmed = name.trim() || "Theme";
const nextId = buildThemeId(trimmed);
const existing = settings.displayThemes.find((theme) => theme.id === id);
if (!existing) return settings;
if (nextId === id) {
existing.name = trimmed;
return settings;
}
const collision = settings.displayThemes.find((theme) => theme.id === nextId);
if (collision) {
collision.name = trimmed;
collision.display = existing.display;
collision.source = existing.source;
settings.displayThemes = settings.displayThemes.filter((theme) => theme.id !== id);
return settings;
}
existing.id = nextId;
existing.name = trimmed;
return settings;
}
export function saveDisplayTheme(settings: Settings, name: string): Settings {
return upsertDisplayTheme(settings, name, settings.display, "saved");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
/**
* Display theme share helpers.
*/
import type { Settings } from "./settings-types.js";
import { mergeSettings } from "./settings-types.js";
const SHARE_SEPARATOR = ":";
const DISPLAY_SHARE_VERSION = 1;
export interface DisplaySharePayload {
v: number;
display: Settings["display"];
}
export interface DecodedDisplayShare {
name: string;
display: Settings["display"];
version: number;
isNewerVersion: boolean;
hasName: boolean;
}
function encodeDisplaySharePayload(display: Settings["display"]): string {
const payload: DisplaySharePayload = { v: DISPLAY_SHARE_VERSION, display };
return Buffer.from(JSON.stringify(payload)).toString("base64url");
}
export function buildDisplayShareString(name: string, display: Settings["display"]): string {
const encoded = encodeDisplaySharePayload(display);
const trimmedName = name.trim() || "custom";
return `${trimmedName}${SHARE_SEPARATOR}${encoded}`;
}
export function buildDisplayShareStringWithoutName(display: Settings["display"]): string {
return encodeDisplaySharePayload(display);
}
export function decodeDisplayShareString(input: string): DecodedDisplayShare | null {
const trimmed = input.trim();
if (!trimmed) return null;
let name = "custom";
let hasName = false;
let payload = trimmed;
const separatorIndex = trimmed.indexOf(SHARE_SEPARATOR);
if (separatorIndex >= 0) {
const candidateName = trimmed.slice(0, separatorIndex).trim();
payload = trimmed.slice(separatorIndex + 1).trim();
if (candidateName) {
name = candidateName;
hasName = true;
}
}
if (!payload) return null;
try {
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
const parsed = JSON.parse(decoded) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const displayCandidate = (parsed as DisplaySharePayload).display ?? parsed;
if (!displayCandidate || typeof displayCandidate !== "object" || Array.isArray(displayCandidate)) {
return null;
}
const merged = mergeSettings({ display: displayCandidate } as Partial<Settings>).display;
const version = typeof (parsed as DisplaySharePayload).v === "number" ? (parsed as DisplaySharePayload).v : 0;
return {
name,
display: merged,
version,
isNewerVersion: version > DISPLAY_SHARE_VERSION,
hasName,
};
} catch {
return null;
}
}
@@ -0,0 +1,229 @@
/**
* Shared types and metadata for sub-* extensions.
*/
export const PROVIDERS = ["anthropic", "copilot", "gemini", "antigravity", "codex", "kiro", "zai", "opencode-go"] as const;
export type ProviderName = (typeof PROVIDERS)[number];
export type StatusIndicator = "none" | "minor" | "major" | "critical" | "maintenance" | "unknown";
export interface ProviderStatus {
indicator: StatusIndicator;
description?: string;
}
export interface RateWindow {
label: string;
usedPercent: number;
resetDescription?: string;
resetAt?: string;
}
export interface UsageSnapshot {
provider: ProviderName;
displayName: string;
windows: RateWindow[];
extraUsageEnabled?: boolean;
fiveHourUsage?: number;
lastSuccessAt?: number;
error?: UsageError;
status?: ProviderStatus;
requestsSummary?: string;
requestsRemaining?: number;
requestsEntitlement?: number;
}
export type UsageErrorCode =
| "NO_CREDENTIALS"
| "NO_CLI"
| "NOT_LOGGED_IN"
| "FETCH_FAILED"
| "HTTP_ERROR"
| "API_ERROR"
| "TIMEOUT"
| "UNKNOWN";
export interface UsageError {
code: UsageErrorCode;
message: string;
httpStatus?: number;
}
export interface ProviderUsageEntry {
provider: ProviderName;
usage?: UsageSnapshot;
}
export type ProviderEnabledSetting = "auto" | "on" | "off" | boolean;
export interface CoreProviderSettings {
enabled: ProviderEnabledSetting;
displayName?: string;
fetchStatus: boolean;
extraUsageCurrencySymbol?: string;
extraUsageDecimalSeparator?: "." | ",";
}
export interface CoreProviderSettingsMap {
anthropic: CoreProviderSettings;
copilot: CoreProviderSettings;
gemini: CoreProviderSettings;
antigravity: CoreProviderSettings;
codex: CoreProviderSettings;
kiro: CoreProviderSettings;
zai: CoreProviderSettings;
"opencode-go": CoreProviderSettings;
}
export interface BehaviorSettings {
refreshInterval: number;
minRefreshInterval: number;
refreshOnTurnStart: boolean;
refreshOnToolResult: boolean;
}
export const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettings = {
refreshInterval: 60,
minRefreshInterval: 10,
refreshOnTurnStart: false,
refreshOnToolResult: false,
};
export function getDefaultCoreProviderSettings(): CoreProviderSettingsMap {
const defaults = {} as CoreProviderSettingsMap;
for (const provider of PROVIDERS) {
defaults[provider] = {
enabled: "auto" as ProviderEnabledSetting,
fetchStatus: Boolean(PROVIDER_METADATA[provider]?.status),
};
}
return defaults;
}
export function getDefaultCoreSettings(): CoreSettings {
return {
providers: getDefaultCoreProviderSettings(),
behavior: { ...DEFAULT_BEHAVIOR_SETTINGS },
statusRefresh: { ...DEFAULT_BEHAVIOR_SETTINGS },
providerOrder: [...PROVIDERS],
defaultProvider: null,
};
}
export interface CoreSettings {
providers: CoreProviderSettingsMap;
behavior: BehaviorSettings;
statusRefresh: BehaviorSettings;
providerOrder: ProviderName[];
defaultProvider: ProviderName | null;
}
export type SubCoreState = {
provider?: ProviderName;
usage?: UsageSnapshot;
};
export type SubCoreAllState = {
provider?: ProviderName;
entries: ProviderUsageEntry[];
};
export type SubCoreEvents =
| { type: "sub-core:ready"; state: SubCoreState }
| { type: "sub-core:update-current"; state: SubCoreState }
| { type: "sub-core:update-all"; state: SubCoreAllState };
export interface StatusPageComponentMatch {
id?: string;
name?: string;
}
export type ProviderStatusConfig =
| { type: "statuspage"; url: string; component?: StatusPageComponentMatch }
| { type: "google-workspace" };
export interface ProviderDetectionConfig {
providerTokens: string[];
modelTokens: string[];
}
export interface ProviderMetadata {
displayName: string;
detection?: ProviderDetectionConfig;
status?: ProviderStatusConfig;
}
export const PROVIDER_METADATA: Record<ProviderName, ProviderMetadata> = {
anthropic: {
displayName: "Anthropic (Claude)",
status: { type: "statuspage", url: "https://status.anthropic.com/api/v2/status.json" },
detection: { providerTokens: ["anthropic"], modelTokens: ["claude"] },
},
copilot: {
displayName: "GitHub Copilot",
status: { type: "statuspage", url: "https://www.githubstatus.com/api/v2/status.json" },
detection: { providerTokens: ["copilot", "github"], modelTokens: [] },
},
gemini: {
displayName: "Google Gemini",
status: { type: "google-workspace" },
detection: { providerTokens: ["google", "gemini"], modelTokens: ["gemini"] },
},
antigravity: {
displayName: "Antigravity",
status: { type: "google-workspace" },
detection: { providerTokens: ["antigravity"], modelTokens: ["antigravity"] },
},
codex: {
displayName: "OpenAI Codex",
status: {
type: "statuspage",
url: "https://status.openai.com/api/v2/status.json",
component: {
id: "01JVCV8YSWZFRSM1G5CVP253SK",
name: "Codex",
},
},
detection: { providerTokens: ["openai", "codex"], modelTokens: ["gpt", "o1", "o3"] },
},
kiro: {
displayName: "AWS Kiro",
detection: { providerTokens: ["kiro", "aws"], modelTokens: [] },
},
zai: {
displayName: "z.ai",
detection: { providerTokens: ["zai", "z.ai", "xai"], modelTokens: [] },
},
"opencode-go": {
displayName: "OpenCode (MiniMax)",
detection: { providerTokens: ["opencode-go", "opencode", "minimax"], modelTokens: ["kimi", "minimax", "m2.5"] },
},
};
export const PROVIDER_DISPLAY_NAMES = Object.fromEntries(
PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
) as Record<ProviderName, string>;
export const MODEL_MULTIPLIERS: Record<string, number> = {
"Claude Haiku 4.5": 0.33,
"Claude Opus 4.1": 10,
"Claude Opus 4.5": 3,
"Claude Sonnet 4": 1,
"Claude Sonnet 4.5": 1,
"Gemini 2.5 Pro": 1,
"Gemini 3 Flash": 0.33,
"Gemini 3 Pro": 1,
"GPT-4.1": 0,
"GPT-4o": 0,
"GPT-5": 1,
"GPT-5 mini": 0,
"GPT-5-Codex": 1,
"GPT-5.1": 1,
"GPT-5.1-Codex": 1,
"GPT-5.1-Codex-Mini": 0.33,
"GPT-5.1-Codex-Max": 1,
"GPT-5.2": 1,
"Grok Code Fast 1": 0.25,
"Raptor mini": 0,
};
@@ -0,0 +1,103 @@
/**
* Status indicator helpers.
*/
import type { ProviderStatus } from "./types.js";
import type { StatusIconPack } from "./settings-types.js";
const STATUS_ICON_PACKS: Record<Exclude<StatusIconPack, "custom">, Record<ProviderStatus["indicator"], string>> = {
minimal: {
none: "✓",
minor: "⚠",
major: "⚠",
critical: "×",
maintenance: "~",
unknown: "?",
},
emoji: {
none: "✅",
minor: "⚠️",
major: "🟠",
critical: "🔴",
maintenance: "🔧",
unknown: "❓",
},
};
const DEFAULT_CUSTOM_ICONS = ["✓", "⚠", "×", "?"];
const CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
function parseCustomIcons(value?: string): [string, string, string, string] {
if (!value) return DEFAULT_CUSTOM_ICONS as [string, string, string, string];
const segments = Array.from(CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment)
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length < 3) return DEFAULT_CUSTOM_ICONS as [string, string, string, string];
if (segments.length === 3) {
return [segments[0], segments[1], segments[2], DEFAULT_CUSTOM_ICONS[3]] as [string, string, string, string];
}
return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string];
}
function buildCustomPack(custom?: string): Record<ProviderStatus["indicator"], string> {
const [ok, warn, error, unknown] = parseCustomIcons(custom);
return {
none: ok,
minor: warn,
major: error,
critical: error,
maintenance: warn,
unknown,
};
}
export function getStatusIcon(
status: ProviderStatus | undefined,
pack: StatusIconPack,
custom?: string,
): string {
if (!status) return "";
if (pack === "custom") {
return buildCustomPack(custom)[status.indicator] ?? "";
}
return STATUS_ICON_PACKS[pack][status.indicator] ?? "";
}
export function getStatusLabel(
status: ProviderStatus | undefined,
useAbbreviated = false,
): string {
if (!status) return "";
if (useAbbreviated) {
switch (status.indicator) {
case "none":
return "Status OK";
case "minor":
return "Status Degr.";
case "major":
case "critical":
return "Status Crit.";
case "maintenance":
return "Status Maint.";
case "unknown":
default:
return "Status Unk.";
}
}
if (status.description) return status.description;
switch (status.indicator) {
case "none":
return "Operational";
case "minor":
return "Degraded";
case "major":
return "Outage";
case "critical":
return "Outage";
case "maintenance":
return "Maintenance";
case "unknown":
default:
return "Status Unknown";
}
}
@@ -0,0 +1,61 @@
/**
* Storage abstraction for settings persistence.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export interface StorageAdapter {
readFile(path: string): string | undefined;
writeFile(path: string, contents: string): void;
writeFileExclusive(path: string, contents: string): boolean;
exists(path: string): boolean;
removeFile(path: string): void;
ensureDir(path: string): void;
}
export function createFsStorage(): StorageAdapter {
return {
readFile(filePath: string): string | undefined {
try {
return fs.readFileSync(filePath, "utf-8");
} catch {
return undefined;
}
},
writeFile(filePath: string, contents: string): void {
fs.writeFileSync(filePath, contents, "utf-8");
},
writeFileExclusive(filePath: string, contents: string): boolean {
try {
fs.writeFileSync(filePath, contents, { flag: "wx" });
return true;
} catch {
return false;
}
},
exists(filePath: string): boolean {
return fs.existsSync(filePath);
},
removeFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch {
// Ignore remove errors
}
},
ensureDir(dirPath: string): void {
fs.mkdirSync(path.resolve(dirPath), { recursive: true });
},
};
}
let activeStorage: StorageAdapter = createFsStorage();
export function getStorage(): StorageAdapter {
return activeStorage;
}
export function setStorage(storage: StorageAdapter): void {
activeStorage = storage;
}
@@ -0,0 +1,25 @@
/**
* Core types for the sub-bar extension
*/
export type {
ProviderName,
StatusIndicator,
ProviderStatus,
RateWindow,
UsageSnapshot,
UsageError,
UsageErrorCode,
ProviderUsageEntry,
SubCoreState,
SubCoreAllState,
SubCoreEvents,
} from "./shared.js";
export { PROVIDERS } from "./shared.js";
export type ModelInfo = {
provider?: string;
id?: string;
scopedModelPatterns?: string[];
};
@@ -0,0 +1,304 @@
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
import {
Input,
fuzzyFilter,
getEditorKeybindings,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@mariozechner/pi-tui";
export interface SettingsListOptions {
enableSearch?: boolean;
}
export const CUSTOM_OPTION = "__custom__";
export const CUSTOM_LABEL = "custom";
export type { SettingItem, SettingsListTheme };
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
getSelectedId(): string | null {
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const item = displayItems[this.selectedIndex];
return item?.id ?? null;
}
setSelectedId(id: string): void {
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const index = displayItems.findIndex((item) => item.id === id);
if (index >= 0) {
this.selectedIndex = index;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines);
}
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (displayItems.length === 0) {
lines.push(this.theme.hint(" No matching settings"));
this.addHintLine(lines);
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
displayItems.length - this.maxVisible,
),
);
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
const optionLines = isSelected && item.values && item.values.length > 0
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
: null;
const valueText = optionLines
? optionLines[0] ?? ""
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
const line = prefix + labelText + separator + valueText;
lines.push(truncateToWidth(line, width, ""));
if (optionLines && optionLines.length > 1) {
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
for (const continuation of optionLines.slice(1)) {
lines.push(truncateToWidth(indent + continuation, width, ""));
}
}
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrapWidth = Math.max(1, width - 4);
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
for (const line of wrappedDesc) {
const prefixed = ` ${line}`;
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
}
}
// Add hint
this.addHintLine(lines);
return lines;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
const kb = getEditorKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "selectUp")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "selectDown")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (kb.matches(data, "cursorLeft")) {
this.stepValue(-1);
} else if (kb.matches(data, "cursorRight")) {
this.stepValue(1);
} else if (kb.matches(data, "selectConfirm") || data === " ") {
this.activateItem();
} else if (kb.matches(data, "selectCancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");
if (!sanitized) {
return;
}
this.searchInput.handleInput(sanitized);
this.applyFilter(this.searchInput.getValue());
}
}
private stepValue(direction: -1 | 1): void {
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const item = displayItems[this.selectedIndex];
if (!item || !item.values || item.values.length === 0) return;
const values = item.values;
let currentIndex = values.indexOf(item.currentValue);
if (currentIndex === -1) {
currentIndex = direction > 0 ? 0 : values.length - 1;
}
const nextIndex = (currentIndex + direction + values.length) % values.length;
const newValue = values[nextIndex];
if (newValue === CUSTOM_OPTION) {
item.currentValue = newValue;
this.onChange(item.id, newValue);
return;
}
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
private activateItem(): void {
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return;
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
if (item.submenu && hasCustom) {
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
this.openSubmenu(item);
}
return;
}
if (item.submenu) {
this.openSubmenu(item);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private formatOptionsInline(item: SettingItem, values: string[]): string {
const separator = this.theme.description(" • ");
const hasCustom = values.includes(CUSTOM_OPTION);
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
return values
.map((value) => {
const label = value === CUSTOM_OPTION
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
: value;
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
return this.theme.value(label, selected);
})
.join(separator);
}
private openSubmenu(item: SettingItem): void {
if (!item.submenu) return;
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
});
}
private addHintLine(lines: string[]): void {
lines.push("");
lines.push(
this.theme.hint(
this.searchEnabled
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
),
);
}
}
@@ -0,0 +1,5 @@
/**
* Usage data types shared across modules.
*/
export type { ProviderUsageEntry } from "../../shared.js";
@@ -0,0 +1,42 @@
/**
* Utility functions for the sub-bar display layer.
*/
import { MODEL_MULTIPLIERS } from "./shared.js";
export function normalizeTokens(value: string): string[] {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.split(" ")
.filter(Boolean);
}
const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
label,
multiplier,
tokens: normalizeTokens(label),
}));
/**
* Get the request multiplier for a model ID
* Uses fuzzy matching against known model names
*/
export function getModelMultiplier(modelId: string | undefined): number | undefined {
if (!modelId) return undefined;
const modelTokens = normalizeTokens(modelId);
if (modelTokens.length === 0) return undefined;
let bestMatch: { multiplier: number; tokenCount: number } | undefined;
for (const entry of MODEL_MULTIPLIER_TOKENS) {
const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
if (!isMatch) continue;
const tokenCount = entry.tokens.length;
if (!bestMatch || tokenCount > bestMatch.tokenCount) {
bestMatch = { multiplier: entry.multiplier, tokenCount };
}
}
return bestMatch?.multiplier;
}
@@ -0,0 +1,535 @@
/**
* sub-core - Shared usage data core for sub-* extensions.
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import * as fs from "node:fs";
import type { Dependencies, ProviderName, SubCoreState, UsageSnapshot } from "./src/types.js";
import { getDefaultSettings, type Settings } from "./src/settings-types.js";
import type { ProviderUsageEntry } from "./src/usage/types.js";
import { createDefaultDependencies } from "./src/dependencies.js";
import { createUsageController, type UsageUpdate } from "./src/usage/controller.js";
import { fetchUsageEntries, getCachedUsageEntries } from "./src/usage/fetch.js";
import { onCacheSnapshot, onCacheUpdate, watchCacheUpdates, type Cache } from "./src/cache.js";
import { isExpectedMissingData } from "./src/errors.js";
import { getStorage } from "./src/storage.js";
import { clearSettingsCache, loadSettings, saveSettings, SETTINGS_PATH } from "./src/settings.js";
import { showSettingsUI } from "./src/settings-ui.js";
type SubCoreRequest =
| {
type?: "current";
includeSettings?: boolean;
reply: (payload: { state: SubCoreState; settings?: Settings }) => void;
}
| {
type: "entries";
force?: boolean;
reply: (payload: { entries: ProviderUsageEntry[] }) => void;
};
type SubCoreAction = {
type: "refresh" | "cycleProvider";
force?: boolean;
};
const TOOL_NAMES = {
usage: ["sub_get_usage", "get_current_usage"],
allUsage: ["sub_get_all_usage", "get_all_usage"],
} as const;
type ToolName = (typeof TOOL_NAMES)[keyof typeof TOOL_NAMES][number];
type SubCoreGlobalState = { active: boolean };
const subCoreGlobal = globalThis as typeof globalThis & { __piSubCore?: SubCoreGlobalState };
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
const result = { ...target } as T;
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = result[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T];
}
}
return result;
}
function stripUsageProvider(usage?: UsageSnapshot): Omit<UsageSnapshot, "provider"> | undefined {
if (!usage) return undefined;
const { provider: _provider, ...rest } = usage;
return rest;
}
/**
* Create the extension
*/
export default function createExtension(pi: ExtensionAPI, deps: Dependencies = createDefaultDependencies()): void {
if (subCoreGlobal.__piSubCore?.active) {
return;
}
subCoreGlobal.__piSubCore = { active: true };
let usageRefreshInterval: ReturnType<typeof setInterval> | undefined;
let statusRefreshInterval: ReturnType<typeof setInterval> | undefined;
let lastContext: ExtensionContext | undefined;
let lastUsageRefreshAt = 0;
let lastStatusRefreshAt = 0;
let settings: Settings = getDefaultSettings();
let settingsLoaded = false;
let toolsRegistered = false;
let lastState: SubCoreState = {};
let settingsSnapshot = "";
let settingsMtimeMs = 0;
let settingsDebounce: NodeJS.Timeout | undefined;
let settingsWatcher: fs.FSWatcher | undefined;
let settingsPoll: NodeJS.Timeout | undefined;
let settingsWatchStarted = false;
const controller = createUsageController(deps);
const controllerState = {
currentProvider: undefined as ProviderName | undefined,
cachedUsage: undefined as UsageSnapshot | undefined,
providerCycleIndex: 0,
};
let lastAllSnapshot = "";
let lastCurrentSnapshot = "";
const emitCurrentUpdate = (provider?: ProviderName, usage?: UsageSnapshot): void => {
lastState = { provider, usage };
const payload = JSON.stringify(lastState);
if (payload === lastCurrentSnapshot) return;
lastCurrentSnapshot = payload;
pi.events.emit("sub-core:update-current", { state: lastState });
};
const unsubscribeCacheSnapshot = onCacheSnapshot((cache: Cache) => {
const ttlMs = settings.behavior.refreshInterval * 1000;
const now = Date.now();
const entries: ProviderUsageEntry[] = [];
for (const provider of settings.providerOrder) {
const entry = cache[provider];
if (!entry || !entry.usage) continue;
if (now - entry.fetchedAt >= ttlMs) continue;
const usage = { ...entry.usage, status: entry.status };
if (usage.error && isExpectedMissingData(usage.error)) continue;
entries.push({ provider, usage });
}
const payload = JSON.stringify({ provider: controllerState.currentProvider, entries });
if (payload === lastAllSnapshot) return;
lastAllSnapshot = payload;
pi.events.emit("sub-core:update-all", {
state: { provider: controllerState.currentProvider, entries },
});
});
const unsubscribeCache = onCacheUpdate((provider, entry) => {
if (!controllerState.currentProvider || provider !== controllerState.currentProvider) return;
const usage = entry?.usage ? { ...entry.usage, status: entry.status } : undefined;
controllerState.cachedUsage = usage;
emitCurrentUpdate(controllerState.currentProvider, usage);
});
let stopCacheWatch: (() => void) | undefined;
let cacheWatchStarted = false;
const startCacheWatch = (): void => {
if (cacheWatchStarted) return;
cacheWatchStarted = true;
stopCacheWatch = watchCacheUpdates();
};
function emitUpdate(update: UsageUpdate): void {
emitCurrentUpdate(update.provider, update.usage);
}
async function refresh(
ctx: ExtensionContext,
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
) {
lastContext = ctx;
ensureSettingsLoaded();
try {
await controller.refresh(ctx, settings, controllerState, emitUpdate, options);
} finally {
if (!options?.skipFetch) {
lastUsageRefreshAt = Date.now();
}
}
}
async function refreshStatus(
ctx: ExtensionContext,
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
) {
lastContext = ctx;
ensureSettingsLoaded();
try {
await controller.refreshStatus(ctx, settings, controllerState, emitUpdate, options);
} finally {
if (!options?.skipFetch) {
lastStatusRefreshAt = Date.now();
}
}
}
async function cycleProvider(ctx: ExtensionContext): Promise<void> {
ensureSettingsLoaded();
await controller.cycleProvider(ctx, settings, controllerState, emitUpdate);
}
function setupRefreshInterval(): void {
if (usageRefreshInterval) {
clearInterval(usageRefreshInterval);
usageRefreshInterval = undefined;
}
if (statusRefreshInterval) {
clearInterval(statusRefreshInterval);
statusRefreshInterval = undefined;
}
const usageIntervalMs = settings.behavior.refreshInterval * 1000;
if (usageIntervalMs > 0) {
const usageTickMs = Math.min(usageIntervalMs, 10000);
usageRefreshInterval = setInterval(() => {
if (!lastContext) return;
const elapsed = lastUsageRefreshAt ? Date.now() - lastUsageRefreshAt : usageIntervalMs + 1;
if (elapsed >= usageIntervalMs) {
void refresh(lastContext);
}
}, usageTickMs);
usageRefreshInterval.unref?.();
}
const statusIntervalMs = settings.statusRefresh.refreshInterval * 1000;
if (statusIntervalMs > 0) {
const statusTickMs = Math.min(statusIntervalMs, 10000);
statusRefreshInterval = setInterval(() => {
if (!lastContext) return;
const elapsed = lastStatusRefreshAt ? Date.now() - lastStatusRefreshAt : statusIntervalMs + 1;
if (elapsed >= statusIntervalMs) {
void refreshStatus(lastContext);
}
}, statusTickMs);
statusRefreshInterval.unref?.();
}
}
function applySettingsPatch(patch: Partial<Settings>): void {
ensureSettingsLoaded();
settings = deepMerge(settings, patch);
saveSettings(settings);
setupRefreshInterval();
pi.events.emit("sub-core:settings:updated", { settings });
}
function readSettingsFile(): string | undefined {
try {
return fs.readFileSync(SETTINGS_PATH, "utf-8");
} catch {
return undefined;
}
}
function applySettingsFromDisk(): void {
clearSettingsCache();
settings = loadSettings();
registerToolsFromSettings(settings);
setupRefreshInterval();
pi.events.emit("sub-core:settings:updated", { settings });
if (lastContext) {
void refresh(lastContext, { allowStaleCache: true, skipFetch: true });
void refreshStatus(lastContext, { allowStaleCache: true, skipFetch: true });
}
}
function refreshSettingsSnapshot(): void {
const content = readSettingsFile();
if (!content || content === settingsSnapshot) return;
try {
JSON.parse(content);
} catch {
return;
}
settingsSnapshot = content;
applySettingsFromDisk();
}
function checkSettingsFile(): void {
try {
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
if (!stat || !stat.mtimeMs) return;
if (stat.mtimeMs === settingsMtimeMs) return;
settingsMtimeMs = stat.mtimeMs;
refreshSettingsSnapshot();
} catch {
// Ignore missing files
}
}
function scheduleSettingsRefresh(): void {
if (settingsDebounce) clearTimeout(settingsDebounce);
settingsDebounce = setTimeout(() => checkSettingsFile(), 200);
}
function startSettingsWatch(): void {
if (settingsWatchStarted) return;
settingsWatchStarted = true;
if (!settingsSnapshot) {
const content = readSettingsFile();
if (content) {
settingsSnapshot = content;
try {
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs;
} catch {
// Ignore
}
}
}
try {
settingsWatcher = fs.watch(SETTINGS_PATH, scheduleSettingsRefresh);
settingsWatcher.unref?.();
} catch {
settingsWatcher = undefined;
}
settingsPoll = setInterval(() => checkSettingsFile(), 2000);
settingsPoll.unref?.();
}
async function getEntries(force?: boolean): Promise<ProviderUsageEntry[]> {
ensureSettingsLoaded();
const enabledProviders = controller.getEnabledProviders(settings);
if (enabledProviders.length === 0) return [];
if (force) {
return fetchUsageEntries(deps, settings, enabledProviders, { force: true });
}
return getCachedUsageEntries(enabledProviders, settings);
}
const registerUsageTool = (name: ToolName): void => {
pi.registerTool({
name,
label: "Sub Usage",
description: "Refresh and return the latest subscription usage snapshot.",
parameters: Type.Object({
force: Type.Optional(Type.Boolean({ description: "Force refresh" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { force } = params as { force?: boolean };
await refresh(ctx, { force: force ?? true });
const payload = { provider: lastState.provider, usage: stripUsageProvider(lastState.usage) };
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
};
},
});
};
const registerAllUsageTool = (name: ToolName): void => {
pi.registerTool({
name,
label: "Sub All Usage",
description: "Refresh and return usage snapshots for all enabled providers.",
parameters: Type.Object({
force: Type.Optional(Type.Boolean({ description: "Force refresh" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { force } = params as { force?: boolean };
const entries = await getEntries(force ?? true);
const payload = entries.map((entry) => ({
provider: entry.provider,
usage: stripUsageProvider(entry.usage),
}));
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: { entries: payload },
};
},
});
};
function registerToolsFromSettings(nextSettings: Settings): void {
if (toolsRegistered) return;
const usageToolEnabled = nextSettings.tools?.usageTool ?? false;
const allUsageToolEnabled = nextSettings.tools?.allUsageTool ?? false;
if (usageToolEnabled) {
for (const name of TOOL_NAMES.usage) {
registerUsageTool(name);
}
}
if (allUsageToolEnabled) {
for (const name of TOOL_NAMES.allUsage) {
registerAllUsageTool(name);
}
}
toolsRegistered = true;
}
function ensureSettingsLoaded(): void {
if (settingsLoaded) return;
settings = loadSettings();
settingsLoaded = true;
registerToolsFromSettings(settings);
setupRefreshInterval();
const watchTimer = setTimeout(() => {
startCacheWatch();
startSettingsWatch();
}, 0);
watchTimer.unref?.();
}
pi.registerCommand("sub-core:settings", {
description: "Open sub-core settings",
handler: async (_args, ctx) => {
ensureSettingsLoaded();
const handleSettingsChange = async (updatedSettings: Settings) => {
applySettingsPatch(updatedSettings);
if (lastContext) {
await refresh(lastContext);
}
};
const newSettings = await showSettingsUI(ctx, handleSettingsChange);
settings = newSettings;
applySettingsPatch(newSettings);
if (lastContext) {
await refresh(lastContext);
}
},
});
pi.events.on("sub-core:request", async (payload) => {
ensureSettingsLoaded();
const request = payload as SubCoreRequest;
if (request.type === "entries") {
const entries = await getEntries(request.force);
if (lastContext && settings.statusRefresh.refreshInterval > 0) {
await refreshStatus(lastContext, { force: request.force });
}
request.reply({ entries });
return;
}
request.reply({
state: lastState,
settings: request.includeSettings ? settings : undefined,
});
});
pi.events.on("sub-core:settings:patch", (payload) => {
const patch = (payload as { patch?: Partial<Settings> }).patch;
if (!patch) return;
applySettingsPatch(patch);
if (lastContext) {
void refresh(lastContext);
}
});
pi.events.on("sub-core:action", (payload) => {
const action = payload as SubCoreAction;
if (!lastContext) return;
switch (action.type) {
case "refresh":
void refresh(lastContext, { force: action.force });
break;
case "cycleProvider":
void cycleProvider(lastContext);
break;
}
});
pi.on("session_start", async (_event, ctx) => {
lastContext = ctx;
ensureSettingsLoaded();
void refresh(ctx, { allowStaleCache: true, skipFetch: true });
void refreshStatus(ctx, { allowStaleCache: true, skipFetch: true });
pi.events.emit("sub-core:ready", { state: lastState, settings });
});
pi.on("turn_start", async (_event, ctx) => {
if (settings.behavior.refreshOnTurnStart) {
await refresh(ctx);
}
if (settings.statusRefresh.refreshOnTurnStart) {
await refreshStatus(ctx);
}
});
pi.on("tool_result", async (_event, ctx) => {
if (settings.behavior.refreshOnToolResult) {
await refresh(ctx, { force: true });
}
if (settings.statusRefresh.refreshOnToolResult) {
await refreshStatus(ctx, { force: true });
}
});
pi.on("turn_end", async (_event, ctx) => {
await refresh(ctx, { force: true });
});
pi.on("session_switch", async (_event, ctx) => {
controllerState.currentProvider = undefined;
controllerState.cachedUsage = undefined;
await refresh(ctx);
await refreshStatus(ctx);
});
pi.on("session_branch" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
controllerState.currentProvider = undefined;
controllerState.cachedUsage = undefined;
await refresh(ctx);
await refreshStatus(ctx);
});
pi.on("model_select" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
controllerState.currentProvider = undefined;
controllerState.cachedUsage = undefined;
void refresh(ctx, { force: true, allowStaleCache: true });
void refreshStatus(ctx, { force: true, allowStaleCache: true });
});
pi.on("session_shutdown", async () => {
if (usageRefreshInterval) {
clearInterval(usageRefreshInterval);
usageRefreshInterval = undefined;
}
if (statusRefreshInterval) {
clearInterval(statusRefreshInterval);
statusRefreshInterval = undefined;
}
if (settingsDebounce) {
clearTimeout(settingsDebounce);
settingsDebounce = undefined;
}
if (settingsPoll) {
clearInterval(settingsPoll);
settingsPoll = undefined;
}
settingsWatcher?.close();
settingsWatcher = undefined;
settingsWatchStarted = false;
settingsSnapshot = "";
settingsMtimeMs = 0;
unsubscribeCache();
unsubscribeCacheSnapshot();
stopCacheWatch?.();
stopCacheWatch = undefined;
cacheWatchStarted = false;
lastContext = undefined;
subCoreGlobal.__piSubCore = undefined;
});
}
@@ -0,0 +1,35 @@
{
"name": "@marckrenn/pi-sub-core",
"version": "1.3.0",
"description": "Shared usage data core for pi extensions",
"keywords": [
"pi-package"
],
"type": "module",
"license": "MIT",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"scripts": {
"check": "tsc --noEmit",
"check:watch": "tsc --noEmit --watch",
"test": "tsx test/all.test.ts"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.2",
"typescript": "^5.8.0"
},
"dependencies": {
"@marckrenn/pi-sub-shared": "^1.3.0"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
}
}
@@ -0,0 +1,489 @@
/**
* Cache management for sub-bar
* Shared cache across all pi instances to avoid redundant API calls
*/
import * as path from "node:path";
import * as fs from "node:fs";
import type { ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
import { isExpectedMissingData } from "./errors.js";
import { getStorage } from "./storage.js";
import {
getCachePath,
getCacheLockPath,
getLegacyAgentCacheLockPath,
getLegacyAgentCachePath,
getLegacyCacheLockPath,
getLegacyCachePath,
} from "./paths.js";
import { tryAcquireFileLock, releaseFileLock, waitForLockRelease } from "./storage/lock.js";
/**
* Cache entry for a provider
*/
export interface CacheEntry {
fetchedAt: number;
statusFetchedAt?: number;
usage?: UsageSnapshot;
status?: ProviderStatus;
}
/**
* Cache structure
*/
export interface Cache {
[provider: string]: CacheEntry;
}
export type CacheUpdateListener = (provider: ProviderName, entry?: CacheEntry) => void;
export type CacheSnapshotListener = (cache: Cache) => void;
const cacheUpdateListeners = new Set<CacheUpdateListener>();
const cacheSnapshotListeners = new Set<CacheSnapshotListener>();
let lastCacheSnapshot: Cache | null = null;
let lastCacheContent = "";
let lastCacheMtimeMs = 0;
let legacyCacheMigrated = false;
function updateCacheSnapshot(cache: Cache, content: string, mtimeMs: number): void {
lastCacheSnapshot = cache;
lastCacheContent = content;
lastCacheMtimeMs = mtimeMs;
}
function resetCacheSnapshot(): void {
lastCacheSnapshot = {};
lastCacheContent = "";
lastCacheMtimeMs = 0;
}
function migrateLegacyCache(): void {
if (legacyCacheMigrated) return;
legacyCacheMigrated = true;
const storage = getStorage();
try {
const legacyCachePaths = [LEGACY_AGENT_CACHE_PATH, LEGACY_CACHE_PATH];
if (!storage.exists(CACHE_PATH)) {
const legacyPath = legacyCachePaths.find((path) => storage.exists(path));
if (legacyPath) {
const content = storage.readFile(legacyPath);
if (content) {
ensureCacheDir();
storage.writeFile(CACHE_PATH, content);
}
}
}
for (const legacyPath of legacyCachePaths) {
if (storage.exists(legacyPath)) {
storage.removeFile(legacyPath);
}
}
for (const legacyLockPath of [LEGACY_AGENT_LOCK_PATH, LEGACY_LOCK_PATH]) {
if (storage.exists(legacyLockPath)) {
storage.removeFile(legacyLockPath);
}
}
} catch (error) {
console.error("Failed to migrate cache:", error);
}
}
export function onCacheUpdate(listener: CacheUpdateListener): () => void {
cacheUpdateListeners.add(listener);
return () => {
cacheUpdateListeners.delete(listener);
};
}
export function onCacheSnapshot(listener: CacheSnapshotListener): () => void {
cacheSnapshotListeners.add(listener);
return () => {
cacheSnapshotListeners.delete(listener);
};
}
function emitCacheUpdate(provider: ProviderName, entry?: CacheEntry): void {
for (const listener of cacheUpdateListeners) {
try {
listener(provider, entry);
} catch (error) {
console.error("Failed to notify cache update:", error);
}
}
}
function emitCacheSnapshot(cache: Cache): void {
for (const listener of cacheSnapshotListeners) {
try {
listener(cache);
} catch (error) {
console.error("Failed to notify cache snapshot:", error);
}
}
}
/**
* Cache file path
*/
export const CACHE_PATH = getCachePath();
const LEGACY_CACHE_PATH = getLegacyCachePath();
const LEGACY_AGENT_CACHE_PATH = getLegacyAgentCachePath();
/**
* Lock file path
*/
const LOCK_PATH = getCacheLockPath();
const LEGACY_LOCK_PATH = getLegacyCacheLockPath();
const LEGACY_AGENT_LOCK_PATH = getLegacyAgentCacheLockPath();
/**
* Lock timeout in milliseconds
*/
const LOCK_TIMEOUT_MS = 5000;
/**
* Ensure cache directory exists
*/
function ensureCacheDir(): void {
const storage = getStorage();
const dir = path.dirname(CACHE_PATH);
storage.ensureDir(dir);
}
/**
* Read cache from disk
*/
export function readCache(): Cache {
migrateLegacyCache();
const storage = getStorage();
try {
const cacheExists = storage.exists(CACHE_PATH);
if (!cacheExists) {
if (lastCacheMtimeMs !== 0 || lastCacheContent) {
resetCacheSnapshot();
}
return lastCacheSnapshot ?? {};
}
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
if (stat && stat.mtimeMs === lastCacheMtimeMs && lastCacheSnapshot) {
return lastCacheSnapshot;
}
const content = storage.readFile(CACHE_PATH);
if (!content) {
updateCacheSnapshot({}, "", stat?.mtimeMs ?? 0);
return {};
}
if (!stat && content === lastCacheContent && lastCacheSnapshot) {
return lastCacheSnapshot;
}
try {
const parsed = JSON.parse(content) as Cache;
updateCacheSnapshot(parsed, content, stat?.mtimeMs ?? Date.now());
return parsed;
} catch (error) {
const lastBrace = content.lastIndexOf("}");
if (lastBrace > 0) {
const trimmed = content.slice(0, lastBrace + 1);
try {
const parsed = JSON.parse(trimmed) as Cache;
if (stat) {
writeCache(parsed);
} else {
updateCacheSnapshot(parsed, trimmed, Date.now());
}
return parsed;
} catch {
// fall through to log below
}
}
console.error("Failed to read cache:", error);
}
} catch (error) {
console.error("Failed to read cache:", error);
}
return {};
}
/**
* Write cache to disk
*/
function writeCache(cache: Cache): void {
migrateLegacyCache();
const storage = getStorage();
try {
ensureCacheDir();
const content = JSON.stringify(cache, null, 2);
const cacheExists = storage.exists(CACHE_PATH);
if (cacheExists && content === lastCacheContent) {
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? lastCacheMtimeMs);
return;
}
const tempPath = `${CACHE_PATH}.${process.pid}.tmp`;
fs.writeFileSync(tempPath, content, "utf-8");
fs.renameSync(tempPath, CACHE_PATH);
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? Date.now());
} catch (error) {
console.error("Failed to write cache:", error);
}
}
export interface CacheWatchOptions {
debounceMs?: number;
pollIntervalMs?: number;
lockRetryMs?: number;
}
export function watchCacheUpdates(options?: CacheWatchOptions): () => void {
migrateLegacyCache();
const debounceMs = options?.debounceMs ?? 250;
const pollIntervalMs = options?.pollIntervalMs ?? 5000;
const lockRetryMs = options?.lockRetryMs ?? 1000;
let debounceTimer: NodeJS.Timeout | undefined;
let pollTimer: NodeJS.Timeout | undefined;
let lockRetryPending = false;
let lastSnapshot = "";
let lastMtimeMs = 0;
let stopped = false;
const scheduleLockRetry = () => {
if (lockRetryPending || stopped) return;
lockRetryPending = true;
void waitForLockRelease(LOCK_PATH, lockRetryMs).then((released) => {
lockRetryPending = false;
if (released) {
emitFromCache();
}
});
};
const emitFromCache = () => {
try {
if (fs.existsSync(LOCK_PATH)) {
scheduleLockRetry();
return;
}
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
if (!stat || !stat.mtimeMs) return;
if (stat.mtimeMs === lastMtimeMs) return;
lastMtimeMs = stat.mtimeMs;
const content = fs.readFileSync(CACHE_PATH, "utf-8");
if (content === lastSnapshot) return;
lastSnapshot = content;
const cache = JSON.parse(content) as Cache;
updateCacheSnapshot(cache, content, stat.mtimeMs);
emitCacheSnapshot(cache);
for (const [provider, entry] of Object.entries(cache)) {
emitCacheUpdate(provider as ProviderName, entry);
}
} catch {
// Ignore parse or read errors (likely mid-write)
}
};
const scheduleEmit = () => {
if (stopped) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => emitFromCache(), debounceMs);
};
let watcher: fs.FSWatcher | undefined;
try {
watcher = fs.watch(CACHE_PATH, scheduleEmit);
watcher.unref?.();
} catch {
watcher = undefined;
}
pollTimer = setInterval(() => emitFromCache(), pollIntervalMs);
pollTimer.unref?.();
return () => {
stopped = true;
if (debounceTimer) clearTimeout(debounceTimer);
if (pollTimer) clearInterval(pollTimer);
watcher?.close();
};
}
/**
* Wait for lock to be released and re-check cache
* Returns the cache entry if it became fresh while waiting
*/
async function waitForLockAndRecheck(
provider: ProviderName,
ttlMs: number,
maxWaitMs: number = 3000
): Promise<CacheEntry | null> {
const released = await waitForLockRelease(LOCK_PATH, maxWaitMs);
if (!released) {
return null;
}
const cache = readCache();
const entry = cache[provider];
if (entry && entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
return null;
}
if (entry && Date.now() - entry.fetchedAt < ttlMs) {
return entry;
}
return null;
}
/**
* Get cached data for a provider if fresh, or null if stale/missing
*/
export async function getCachedData(
provider: ProviderName,
ttlMs: number,
cacheSnapshot?: Cache
): Promise<CacheEntry | null> {
const cache = cacheSnapshot ?? readCache();
const entry = cache[provider];
if (!entry) {
return null;
}
if (entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
return null;
}
const age = Date.now() - entry.fetchedAt;
if (age < ttlMs) {
return entry;
}
return null;
}
/**
* Fetch data with lock coordination
* Returns cached data if fresh, or executes fetchFn if cache is stale
*/
export async function fetchWithCache<T extends { usage?: UsageSnapshot; status?: ProviderStatus; statusFetchedAt?: number }>(
provider: ProviderName,
ttlMs: number,
fetchFn: () => Promise<T>,
options?: { force?: boolean }
): Promise<T> {
const forceRefresh = options?.force === true;
if (!forceRefresh) {
// Check cache first
const cached = await getCachedData(provider, ttlMs);
if (cached) {
return { usage: cached.usage, status: cached.status } as T;
}
}
// Cache is stale or forced refresh, try to acquire lock
const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
if (!lockAcquired) {
// Another process is fetching, wait and re-check cache
const freshEntry = await waitForLockAndRecheck(provider, ttlMs);
if (freshEntry) {
return { usage: freshEntry.usage, status: freshEntry.status } as T;
}
// Timeout or cache still stale, fetch anyway
}
try {
// Fetch fresh data
const result = await fetchFn();
// Only cache if we got valid usage data (not just no-credentials/errors)
const hasCredentialError = result.usage?.error && isExpectedMissingData(result.usage.error);
const hasError = Boolean(result.usage?.error);
const shouldCache = result.usage && !hasCredentialError && !hasError;
const cache = readCache();
if (shouldCache) {
// Update cache with valid data
const fetchedAt = Date.now();
const previous = cache[provider];
const statusFetchedAt = result.statusFetchedAt ?? (result.status ? fetchedAt : previous?.statusFetchedAt);
cache[provider] = {
fetchedAt,
statusFetchedAt,
usage: result.usage,
status: result.status,
};
writeCache(cache);
emitCacheUpdate(provider, cache[provider]);
emitCacheSnapshot(cache);
} else if (hasCredentialError) {
// Remove from cache if no credentials
if (cache[provider]) {
delete cache[provider];
writeCache(cache);
emitCacheUpdate(provider, undefined);
emitCacheSnapshot(cache);
}
}
return result;
} finally {
if (lockAcquired) {
releaseFileLock(LOCK_PATH);
}
}
}
export async function updateCacheStatus(
provider: ProviderName,
status: ProviderStatus,
options?: { statusFetchedAt?: number }
): Promise<void> {
const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
if (!lockAcquired) {
await waitForLockRelease(LOCK_PATH, 3000);
}
try {
const cache = readCache();
const entry = cache[provider];
const statusFetchedAt = options?.statusFetchedAt ?? Date.now();
cache[provider] = {
fetchedAt: entry?.fetchedAt ?? 0,
statusFetchedAt,
usage: entry?.usage,
status,
};
writeCache(cache);
emitCacheUpdate(provider, cache[provider]);
emitCacheSnapshot(cache);
} finally {
if (lockAcquired) {
releaseFileLock(LOCK_PATH);
}
}
}
/**
* Clear cache for a specific provider or all providers
*/
export function clearCache(provider?: ProviderName): void {
const storage = getStorage();
if (provider) {
const cache = readCache();
delete cache[provider];
writeCache(cache);
} else {
try {
if (storage.exists(CACHE_PATH)) {
storage.removeFile(CACHE_PATH);
}
resetCacheSnapshot();
} catch (error) {
console.error("Failed to clear cache:", error);
}
}
}
@@ -0,0 +1,35 @@
/**
* Configuration constants for the sub-bar extension
*/
/**
* Google Workspace status API endpoint
*/
export const GOOGLE_STATUS_URL = "https://www.google.com/appsstatus/dashboard/incidents.json";
/**
* Google product ID for Gemini in the status API
*/
export const GEMINI_PRODUCT_ID = "npdyhgECDJ6tB66MxXyo";
/**
* Model multipliers for Copilot request counting
* Maps model display names to their request multiplier
*/
export { MODEL_MULTIPLIERS } from "../../src/shared.js";
/**
* Timeout for API requests in milliseconds
*/
export const API_TIMEOUT_MS = 5000;
/**
* Timeout for CLI commands in milliseconds
*/
export const CLI_TIMEOUT_MS = 10000;
/**
* Interval for automatic usage refresh in milliseconds
*/
export const REFRESH_INTERVAL_MS = 60_000;
@@ -0,0 +1,37 @@
/**
* Default dependencies using real implementations
*/
import * as fs from "node:fs";
import * as os from "node:os";
import { execFileSync } from "node:child_process";
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
import type { Dependencies } from "./types.js";
/**
* Create default dependencies using Node.js APIs
*/
export function createDefaultDependencies(): Dependencies {
return {
fetch: globalThis.fetch,
readFile: (path: string) => {
try {
return fs.readFileSync(path, "utf-8");
} catch {
return undefined;
}
},
fileExists: (path: string) => {
try {
return fs.existsSync(path);
} catch {
return false;
}
},
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => {
return execFileSync(file, args, options) as string;
},
homedir: () => os.homedir(),
env: process.env,
};
}
@@ -0,0 +1,71 @@
/**
* Error utilities for the sub-bar extension
*/
import type { UsageError, UsageErrorCode } from "./types.js";
export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
return { code, message, httpStatus };
}
export function noCredentials(): UsageError {
return createError("NO_CREDENTIALS", "No credentials found");
}
export function noCli(cliName: string): UsageError {
return createError("NO_CLI", `${cliName} CLI not found`);
}
export function notLoggedIn(): UsageError {
return createError("NOT_LOGGED_IN", "Not logged in");
}
export function fetchFailed(reason?: string): UsageError {
return createError("FETCH_FAILED", reason ?? "Fetch failed");
}
export function httpError(status: number): UsageError {
return createError("HTTP_ERROR", `HTTP ${status}`, status);
}
export function apiError(message: string): UsageError {
return createError("API_ERROR", message);
}
export function timeout(): UsageError {
return createError("TIMEOUT", "Request timed out");
}
/**
* Check if an error should be considered "no data available" vs actual error
* These are expected states when provider isn't configured
*/
export function isExpectedMissingData(error: UsageError): boolean {
const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
return ignoreCodes.has(error.code);
}
/**
* Format error for display in the usage widget
*/
export function formatErrorForDisplay(error: UsageError): string {
switch (error.code) {
case "NO_CREDENTIALS":
return "No creds";
case "NO_CLI":
return "No CLI";
case "NOT_LOGGED_IN":
return "Not logged in";
case "HTTP_ERROR":
if (error.httpStatus === 401) {
return "token no longer valid please /login again";
}
return `${error.httpStatus}`;
case "FETCH_FAILED":
case "API_ERROR":
case "TIMEOUT":
case "UNKNOWN":
default:
return "Fetch failed";
}
}
@@ -0,0 +1,55 @@
/**
* Shared path helpers for sub-core storage.
*/
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const SETTINGS_FILE_NAME = "pi-sub-core-settings.json";
const CACHE_DIR_NAME = "cache";
const CACHE_NAMESPACE_DIR = "sub-core";
const CACHE_FILE_NAME = "cache.json";
const CACHE_LOCK_FILE_NAME = "cache.lock";
const LEGACY_AGENT_CACHE_FILE_NAME = "pi-sub-core-cache.json";
const LEGACY_AGENT_LOCK_FILE_NAME = "pi-sub-core-cache.lock";
export function getExtensionDir(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..");
}
export function getCacheDir(): string {
return join(getAgentDir(), CACHE_DIR_NAME, CACHE_NAMESPACE_DIR);
}
export function getCachePath(): string {
return join(getCacheDir(), CACHE_FILE_NAME);
}
export function getCacheLockPath(): string {
return join(getCacheDir(), CACHE_LOCK_FILE_NAME);
}
export function getLegacyCachePath(): string {
return join(getExtensionDir(), "cache.json");
}
export function getLegacyCacheLockPath(): string {
return join(getExtensionDir(), "cache.lock");
}
export function getLegacyAgentCachePath(): string {
return join(getAgentDir(), LEGACY_AGENT_CACHE_FILE_NAME);
}
export function getLegacyAgentCacheLockPath(): string {
return join(getAgentDir(), LEGACY_AGENT_LOCK_FILE_NAME);
}
export function getSettingsPath(): string {
return join(getAgentDir(), SETTINGS_FILE_NAME);
}
export function getLegacySettingsPath(): string {
return join(getExtensionDir(), "settings.json");
}
@@ -0,0 +1,66 @@
/**
* Provider interface and registry
*/
import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
/**
* Interface for a usage provider
*/
export interface UsageProvider {
readonly name: ProviderName;
readonly displayName: string;
/**
* Fetch current usage data for this provider
*/
fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
/**
* Fetch current status for this provider (optional)
*/
fetchStatus?(deps: Dependencies): Promise<ProviderStatus>;
/**
* Check if credentials are available (optional)
*/
hasCredentials?(deps: Dependencies): boolean;
}
/**
* Base class for providers with common functionality
*/
export abstract class BaseProvider implements UsageProvider {
abstract readonly name: ProviderName;
abstract readonly displayName: string;
abstract fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
hasCredentials(_deps: Dependencies): boolean {
return true;
}
/**
* Create an empty snapshot with an error
*/
protected emptySnapshot(error?: import("./types.js").UsageError): UsageSnapshot {
return {
provider: this.name,
displayName: this.displayName,
windows: [],
error,
};
}
/**
* Create a snapshot with usage data
*/
protected snapshot(data: Partial<Omit<UsageSnapshot, "provider" | "displayName">>): UsageSnapshot {
return {
provider: this.name,
displayName: this.displayName,
windows: [],
...data,
};
}
}
@@ -0,0 +1,51 @@
/**
* Provider detection helpers.
*/
import type { ProviderName } from "../types.js";
import { PROVIDERS } from "../types.js";
import { PROVIDER_METADATA } from "./metadata.js";
interface ProviderDetectionHint {
provider: ProviderName;
providerTokens: string[];
modelTokens: string[];
}
const PROVIDER_DETECTION_HINTS: ProviderDetectionHint[] = PROVIDERS.map((provider) => {
const detection = PROVIDER_METADATA[provider].detection ?? { providerTokens: [], modelTokens: [] };
return {
provider,
providerTokens: detection.providerTokens,
modelTokens: detection.modelTokens,
};
});
/**
* Detect the provider from model metadata.
*/
export function detectProviderFromModel(
model: { provider?: string; id?: string } | undefined
): ProviderName | undefined {
if (!model) return undefined;
const providerValue = model.provider?.toLowerCase() || "";
const idValue = model.id?.toLowerCase() || "";
if (providerValue.includes("antigravity") || idValue.includes("antigravity")) {
return "antigravity";
}
for (const hint of PROVIDER_DETECTION_HINTS) {
if (hint.providerTokens.some((token) => providerValue.includes(token))) {
return hint.provider;
}
}
for (const hint of PROVIDER_DETECTION_HINTS) {
if (hint.modelTokens.some((token) => idValue.includes(token))) {
return hint.provider;
}
}
return undefined;
}
@@ -0,0 +1,174 @@
/**
* Anthropic/Claude usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
import { getSettings } from "../../settings.js";
/**
* Load Claude API token from various sources
*/
function loadClaudeToken(deps: Dependencies): string | undefined {
// Explicit override via env var (useful in CI / menu bar apps)
const envToken = deps.env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (envToken) return envToken;
// Try pi auth.json next
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
if (data.anthropic?.access) return data.anthropic.access;
}
} catch {
// Ignore parse errors
}
// Try macOS Keychain (Claude Code credentials)
try {
const keychainData = deps.execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
).trim();
if (keychainData) {
const parsed = JSON.parse(keychainData);
const scopes = parsed.claudeAiOauth?.scopes || [];
if (scopes.includes("user:profile") && parsed.claudeAiOauth?.accessToken) {
return parsed.claudeAiOauth.accessToken;
}
}
} catch {
// Keychain access failed
}
return undefined;
}
type ExtraUsageFormat = {
symbol: string;
decimalSeparator: "." | ",";
};
function getExtraUsageFormat(): ExtraUsageFormat {
const settings = getSettings();
const providerSettings = settings.providers.anthropic;
return {
symbol: providerSettings.extraUsageCurrencySymbol?.trim() ?? "",
decimalSeparator: providerSettings.extraUsageDecimalSeparator === "," ? "," : ".",
};
}
function formatExtraUsageCredits(credits: number, format: ExtraUsageFormat): string {
const amount = (credits / 100).toFixed(2);
const formatted = format.decimalSeparator === "," ? amount.replace(".", ",") : amount;
return format.symbol ? `${format.symbol}${formatted}` : formatted;
}
export class AnthropicProvider extends BaseProvider {
readonly name = "anthropic" as const;
readonly displayName = "Claude Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadClaudeToken(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const token = loadClaudeToken(deps);
if (!token) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://api.anthropic.com/api/oauth/usage", {
headers: {
Authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
},
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
five_hour?: { utilization?: number; resets_at?: string };
seven_day?: { utilization?: number; resets_at?: string };
extra_usage?: {
is_enabled?: boolean;
used_credits?: number;
monthly_limit?: number;
utilization?: number;
};
};
const windows: RateWindow[] = [];
if (data.five_hour?.utilization !== undefined) {
const resetAt = data.five_hour.resets_at ? new Date(data.five_hour.resets_at) : undefined;
windows.push({
label: "5h",
usedPercent: data.five_hour.utilization,
resetDescription: resetAt ? formatReset(resetAt) : undefined,
resetAt: resetAt?.toISOString(),
});
}
if (data.seven_day?.utilization !== undefined) {
const resetAt = data.seven_day.resets_at ? new Date(data.seven_day.resets_at) : undefined;
windows.push({
label: "Week",
usedPercent: data.seven_day.utilization,
resetDescription: resetAt ? formatReset(resetAt) : undefined,
resetAt: resetAt?.toISOString(),
});
}
// Extra usage
const extraUsageEnabled = data.extra_usage?.is_enabled === true;
const fiveHourUsage = data.five_hour?.utilization ?? 0;
if (extraUsageEnabled) {
const extra = data.extra_usage!;
const usedCredits = extra.used_credits || 0;
const monthlyLimit = extra.monthly_limit;
const utilization = extra.utilization || 0;
const format = getExtraUsageFormat();
// "active" when 5h >= 99%, otherwise "on"
const extraStatus = fiveHourUsage >= 99 ? "active" : "on";
let label: string;
if (monthlyLimit && monthlyLimit > 0) {
label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}/${formatExtraUsageCredits(monthlyLimit, format)}`;
} else {
label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}`;
}
windows.push({
label,
usedPercent: utilization,
resetDescription: extraStatus === "active" ? "__ACTIVE__" : undefined,
});
}
return this.snapshot({
windows,
extraUsageEnabled,
fiveHourUsage,
});
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,226 @@
/**
* Google Antigravity usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { createTimeoutController, formatReset } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
const ANTIGRAVITY_ENDPOINTS = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
] as const;
const ANTIGRAVITY_HEADERS = {
"User-Agent": "antigravity/1.11.5 darwin/arm64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
const ANTIGRAVITY_HIDDEN_MODELS = new Set(["tab_flash_lite_preview"]);
interface AntigravityAuth {
access?: string;
accessToken?: string;
token?: string;
key?: string;
projectId?: string;
project?: string;
}
interface CloudCodeQuotaResponse {
models?: Record<string, {
displayName?: string;
model?: string;
isInternal?: boolean;
quotaInfo?: {
remainingFraction?: number;
limit?: string;
resetTime?: string;
};
}>;
}
interface ParsedModelQuota {
name: string;
remainingFraction: number;
resetAt?: Date;
}
/**
* Load Antigravity access token from auth.json
*/
function loadAntigravityAuth(deps: Dependencies): AntigravityAuth | undefined {
// Explicit override via env var
const envProjectId = (deps.env.GOOGLE_ANTIGRAVITY_PROJECT_ID || deps.env.GOOGLE_ANTIGRAVITY_PROJECT)?.trim();
const envToken = (deps.env.GOOGLE_ANTIGRAVITY_OAUTH_TOKEN || deps.env.ANTIGRAVITY_OAUTH_TOKEN)?.trim();
if (envToken) {
return { token: envToken, projectId: envProjectId || undefined };
}
// Also support passing pi-ai style JSON api key: { token, projectId }
const envApiKey = (deps.env.GOOGLE_ANTIGRAVITY_API_KEY || deps.env.ANTIGRAVITY_API_KEY)?.trim();
if (envApiKey) {
try {
const parsed = JSON.parse(envApiKey) as { token?: string; projectId?: string };
if (parsed?.token) {
return { token: parsed.token, projectId: parsed.projectId || envProjectId || undefined };
}
} catch {
// not JSON
}
return { token: envApiKey, projectId: envProjectId || undefined };
}
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
const entry = data["google-antigravity"];
if (!entry) return undefined;
if (typeof entry === "string") {
return { token: entry };
}
return {
access: entry.access,
accessToken: entry.accessToken,
token: entry.token,
key: entry.key,
projectId: entry.projectId ?? entry.project,
};
}
} catch {
// Ignore parse errors
}
return undefined;
}
function resolveAntigravityToken(auth: AntigravityAuth | undefined): string | undefined {
return auth?.access ?? auth?.accessToken ?? auth?.token ?? auth?.key;
}
function parseResetTime(value?: string): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return date;
}
function toUsedPercent(remainingFraction: number): number {
const fraction = Number.isFinite(remainingFraction) ? remainingFraction : 1;
const used = (1 - fraction) * 100;
return Math.max(0, Math.min(100, used));
}
async function fetchAntigravityQuota(
deps: Dependencies,
endpoint: string,
token: string,
projectId?: string
): Promise<{ data?: CloudCodeQuotaResponse; status?: number }> {
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const payload = projectId ? { project: projectId } : {};
const res = await deps.fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...ANTIGRAVITY_HEADERS,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clear();
if (!res.ok) return { status: res.status };
const data = (await res.json()) as CloudCodeQuotaResponse;
return { data };
} catch {
clear();
return {};
}
}
export class AntigravityProvider extends BaseProvider {
readonly name = "antigravity" as const;
readonly displayName = "Antigravity";
hasCredentials(deps: Dependencies): boolean {
return Boolean(resolveAntigravityToken(loadAntigravityAuth(deps)));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const auth = loadAntigravityAuth(deps);
const token = resolveAntigravityToken(auth);
if (!token) {
return this.emptySnapshot(noCredentials());
}
let data: CloudCodeQuotaResponse | undefined;
let lastStatus: number | undefined;
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
const result = await fetchAntigravityQuota(deps, endpoint, token, auth?.projectId);
if (result.data) {
data = result.data;
break;
}
if (result.status) {
lastStatus = result.status;
}
}
if (!data) {
return lastStatus ? this.emptySnapshot(httpError(lastStatus)) : this.emptySnapshot(fetchFailed());
}
const modelByName = new Map<string, ParsedModelQuota>();
for (const [modelId, model] of Object.entries(data.models ?? {})) {
if (model.isInternal) continue;
if (modelId && ANTIGRAVITY_HIDDEN_MODELS.has(modelId.toLowerCase())) continue;
const name = model.displayName ?? modelId ?? model.model ?? "unknown";
if (!name) continue;
if (ANTIGRAVITY_HIDDEN_MODELS.has(name.toLowerCase())) continue;
const remainingFraction = model.quotaInfo?.remainingFraction ?? 1;
const resetAt = parseResetTime(model.quotaInfo?.resetTime);
const existing = modelByName.get(name);
if (!existing) {
modelByName.set(name, { name, remainingFraction, resetAt });
continue;
}
let next = existing;
if (remainingFraction < existing.remainingFraction) {
next = { name, remainingFraction, resetAt };
} else if (remainingFraction === existing.remainingFraction && resetAt) {
if (!existing.resetAt || resetAt.getTime() < existing.resetAt.getTime()) {
next = { ...existing, resetAt };
}
} else if (!existing.resetAt && resetAt) {
next = { ...existing, resetAt };
}
if (next !== existing) {
modelByName.set(name, next);
}
}
const parsedModels = Array.from(modelByName.values()).sort((a, b) => a.name.localeCompare(b.name));
const buildWindow = (label: string, remainingFraction: number, resetAt?: Date): RateWindow => ({
label,
usedPercent: toUsedPercent(remainingFraction),
resetDescription: resetAt ? formatReset(resetAt) : undefined,
resetAt: resetAt?.toISOString(),
});
const windows = parsedModels.map((model) => buildWindow(model.name, model.remainingFraction, model.resetAt));
return this.snapshot({ windows });
}
}
@@ -0,0 +1,186 @@
/**
* OpenAI Codex usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
interface CodexRateWindow {
reset_at?: number;
limit_window_seconds?: number;
used_percent?: number;
}
interface CodexRateLimit {
primary_window?: CodexRateWindow;
secondary_window?: CodexRateWindow;
}
interface CodexAdditionalRateLimit {
limit_name?: string;
metered_feature?: string;
rate_limit?: CodexRateLimit;
}
/**
* Load Codex credentials from auth.json
* First tries pi's auth.json, then falls back to legacy codex location
*/
function loadCodexCredentials(deps: Dependencies): { accessToken?: string; accountId?: string } {
// Explicit override via env var
const envAccessToken = (
deps.env.OPENAI_CODEX_OAUTH_TOKEN ||
deps.env.OPENAI_CODEX_ACCESS_TOKEN ||
deps.env.CODEX_OAUTH_TOKEN ||
deps.env.CODEX_ACCESS_TOKEN
)?.trim();
const envAccountId = (deps.env.OPENAI_CODEX_ACCOUNT_ID || deps.env.CHATGPT_ACCOUNT_ID)?.trim();
if (envAccessToken) {
return { accessToken: envAccessToken, accountId: envAccountId || undefined };
}
// Try pi's auth.json first
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
if (data["openai-codex"]?.access) {
return {
accessToken: data["openai-codex"].access,
accountId: data["openai-codex"].accountId,
};
}
}
} catch {
// Ignore parse errors, try legacy location
}
// Fall back to legacy codex location
const codexHome = deps.env.CODEX_HOME || path.join(deps.homedir(), ".codex");
const authPath = path.join(codexHome, "auth.json");
try {
if (deps.fileExists(authPath)) {
const data = JSON.parse(deps.readFile(authPath) ?? "{}");
if (data.OPENAI_API_KEY) {
return { accessToken: data.OPENAI_API_KEY };
} else if (data.tokens?.access_token) {
return {
accessToken: data.tokens.access_token,
accountId: data.tokens.account_id,
};
}
}
} catch {
// Ignore parse errors
}
return {};
}
function getWindowLabel(windowSeconds?: number, fallbackWindowSeconds?: number): string {
const safeWindowSeconds =
typeof windowSeconds === "number" && windowSeconds > 0
? windowSeconds
: typeof fallbackWindowSeconds === "number" && fallbackWindowSeconds > 0
? fallbackWindowSeconds
: 0;
if (!safeWindowSeconds) {
return "0h";
}
const windowHours = Math.round(safeWindowSeconds / 3600);
if (windowHours >= 144) return "Week";
if (windowHours >= 24) return "Day";
return `${windowHours}h`;
}
function pushWindow(
windows: RateWindow[],
prefix: string | undefined,
window: CodexRateWindow | undefined,
fallbackWindowSeconds?: number
): void {
if (!window) return;
const resetDate = window.reset_at ? new Date(window.reset_at * 1000) : undefined;
const label = getWindowLabel(window.limit_window_seconds, fallbackWindowSeconds);
const windowLabel = prefix ? `${prefix} ${label}` : label;
windows.push({
label: windowLabel,
usedPercent: window.used_percent || 0,
resetDescription: resetDate ? formatReset(resetDate) : undefined,
resetAt: resetDate?.toISOString(),
});
}
function addRateWindows(windows: RateWindow[], rateLimit: CodexRateLimit | undefined, prefix?: string): void {
pushWindow(windows, prefix, rateLimit?.primary_window, 10800);
pushWindow(windows, prefix, rateLimit?.secondary_window, 86400);
}
export class CodexProvider extends BaseProvider {
readonly name = "codex" as const;
readonly displayName = "Codex Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadCodexCredentials(deps).accessToken);
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const { accessToken, accountId } = loadCodexCredentials(deps);
if (!accessToken) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
if (accountId) {
headers["ChatGPT-Account-Id"] = accountId;
}
const res = await deps.fetch("https://chatgpt.com/backend-api/wham/usage", {
headers,
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
rate_limit?: CodexRateLimit;
additional_rate_limits?: CodexAdditionalRateLimit[];
};
const windows: RateWindow[] = [];
addRateWindows(windows, data.rate_limit);
if (Array.isArray(data.additional_rate_limits)) {
for (const entry of data.additional_rate_limits) {
if (!entry || typeof entry !== "object") continue;
const prefix =
typeof entry.limit_name === "string" && entry.limit_name.trim().length > 0
? entry.limit_name.trim()
: typeof entry.metered_feature === "string" && entry.metered_feature.trim().length > 0
? entry.metered_feature.trim()
: "Additional";
addRateWindows(windows, entry.rate_limit, prefix);
}
}
return this.snapshot({ windows });
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,176 @@
/**
* GitHub Copilot usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
/**
* Copilot token entries stored by legacy GitHub Copilot CLI
*/
type CopilotHostEntry = {
oauth_token?: string;
user_token?: string;
github_token?: string;
token?: string;
};
const COPILOT_TOKEN_KEYS: Array<keyof CopilotHostEntry> = [
"oauth_token",
"user_token",
"github_token",
"token",
];
function getTokenFromHostEntry(entry: CopilotHostEntry | undefined): string | undefined {
if (!entry) return undefined;
for (const key of COPILOT_TOKEN_KEYS) {
const value = entry[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
}
return undefined;
}
function loadLegacyCopilotToken(deps: Dependencies): string | undefined {
const configHome = deps.env.XDG_CONFIG_HOME || path.join(deps.homedir(), ".config");
const legacyPaths = [
path.join(configHome, "github-copilot", "hosts.json"),
path.join(deps.homedir(), ".github-copilot", "hosts.json"),
];
for (const hostsPath of legacyPaths) {
try {
if (!deps.fileExists(hostsPath)) continue;
const data = JSON.parse(deps.readFile(hostsPath) ?? "{}");
if (!data || typeof data !== "object") continue;
const normalizedHosts: Record<string, CopilotHostEntry> = {};
for (const [host, entry] of Object.entries(data as Record<string, CopilotHostEntry>)) {
normalizedHosts[host.toLowerCase()] = entry;
}
const preferredToken =
getTokenFromHostEntry(normalizedHosts["github.com"]) ||
getTokenFromHostEntry(normalizedHosts["api.github.com"]);
if (preferredToken) return preferredToken;
for (const entry of Object.values(normalizedHosts)) {
const token = getTokenFromHostEntry(entry);
if (token) return token;
}
} catch {
// Ignore parse errors
}
}
return undefined;
}
/**
* Load Copilot token from pi auth.json first, then fallback to legacy locations.
*/
function loadCopilotToken(deps: Dependencies): string | undefined {
// Explicit override via env var
const envToken = (deps.env.COPILOT_GITHUB_TOKEN || deps.env.GH_TOKEN || deps.env.GITHUB_TOKEN || deps.env.COPILOT_TOKEN)?.trim();
if (envToken) return envToken;
const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(authPath)) {
const data = JSON.parse(deps.readFile(authPath) ?? "{}");
// Prefer refresh token (GitHub access token) for GitHub API endpoints.
const piToken = data["github-copilot"]?.refresh || data["github-copilot"]?.access;
if (typeof piToken === "string" && piToken.length > 0) return piToken;
}
} catch {
// Ignore parse errors
}
return loadLegacyCopilotToken(deps);
}
export class CopilotProvider extends BaseProvider {
readonly name = "copilot" as const;
readonly displayName = "Copilot Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadCopilotToken(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const token = loadCopilotToken(deps);
if (!token) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://api.github.com/copilot_internal/user", {
headers: {
"Editor-Version": "vscode/1.96.2",
"User-Agent": "GitHubCopilotChat/0.26.7",
"X-Github-Api-Version": "2025-04-01",
Accept: "application/json",
Authorization: `token ${token}`,
},
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
quota_reset_date_utc?: string;
quota_snapshots?: {
premium_interactions?: {
percent_remaining?: number;
remaining?: number;
entitlement?: number;
};
};
};
const windows: RateWindow[] = [];
const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
const resetDesc = resetDate ? formatReset(resetDate) : undefined;
let requestsRemaining: number | undefined;
let requestsEntitlement: number | undefined;
if (data.quota_snapshots?.premium_interactions) {
const pi = data.quota_snapshots.premium_interactions;
const monthUsedPercent = Math.max(0, 100 - (pi.percent_remaining || 0));
windows.push({
label: "Month",
usedPercent: monthUsedPercent,
resetDescription: resetDesc,
resetAt: resetDate?.toISOString(),
});
const remaining = pi.remaining ?? 0;
const entitlement = pi.entitlement ?? 0;
requestsRemaining = remaining;
requestsEntitlement = entitlement;
}
return this.snapshot({
windows,
requestsRemaining,
requestsEntitlement,
});
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,130 @@
/**
* Google Gemini usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
/**
* Load Gemini access token from various sources
*/
function loadGeminiToken(deps: Dependencies): string | undefined {
// Explicit override via env var
const envToken = (
deps.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN ||
deps.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN ||
deps.env.GEMINI_OAUTH_TOKEN ||
deps.env.GOOGLE_GEMINI_OAUTH_TOKEN
)?.trim();
if (envToken) return envToken;
// Try pi auth.json first
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
if (data["google-gemini-cli"]?.access) return data["google-gemini-cli"].access;
}
} catch {
// Ignore parse errors
}
// Try ~/.gemini/oauth_creds.json
const credPath = path.join(deps.homedir(), ".gemini", "oauth_creds.json");
try {
if (deps.fileExists(credPath)) {
const data = JSON.parse(deps.readFile(credPath) ?? "{}");
if (data.access_token) return data.access_token;
}
} catch {
// Ignore parse errors
}
return undefined;
}
export class GeminiProvider extends BaseProvider {
readonly name = "gemini" as const;
readonly displayName = "Gemini Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadGeminiToken(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const token = loadGeminiToken(deps);
if (!token) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: "{}",
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
buckets?: Array<{
modelId?: string;
remainingFraction?: number;
}>;
};
// Aggregate quotas by model type
const quotas: Record<string, number> = {};
for (const bucket of data.buckets || []) {
const model = bucket.modelId || "unknown";
const frac = bucket.remainingFraction ?? 1;
if (!quotas[model] || frac < quotas[model]) {
quotas[model] = frac;
}
}
const windows: RateWindow[] = [];
let proMin = 1;
let flashMin = 1;
let hasProModel = false;
let hasFlashModel = false;
for (const [model, frac] of Object.entries(quotas)) {
if (model.toLowerCase().includes("pro")) {
hasProModel = true;
if (frac < proMin) proMin = frac;
}
if (model.toLowerCase().includes("flash")) {
hasFlashModel = true;
if (frac < flashMin) flashMin = frac;
}
}
if (hasProModel) {
windows.push({ label: "Pro", usedPercent: (1 - proMin) * 100 });
}
if (hasFlashModel) {
windows.push({ label: "Flash", usedPercent: (1 - flashMin) * 100 });
}
return this.snapshot({ windows });
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,92 @@
/**
* AWS Kiro usage provider
*/
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCli, notLoggedIn, fetchFailed } from "../../errors.js";
import { formatReset, stripAnsi, whichSync } from "../../utils.js";
import { CLI_TIMEOUT_MS } from "../../config.js";
export class KiroProvider extends BaseProvider {
readonly name = "kiro" as const;
readonly displayName = "Kiro Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(whichSync("kiro-cli", deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const kiroBinary = whichSync("kiro-cli", deps);
if (!kiroBinary) {
return this.emptySnapshot(noCli("kiro-cli"));
}
try {
// Check if logged in
try {
deps.execFileSync(kiroBinary, ["whoami"], {
encoding: "utf-8",
timeout: API_TIMEOUT_MS,
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
return this.emptySnapshot(notLoggedIn());
}
// Get usage
const output = deps.execFileSync(kiroBinary, ["chat", "--no-interactive", "/usage"], {
encoding: "utf-8",
timeout: CLI_TIMEOUT_MS,
env: { ...deps.env, TERM: "xterm-256color" },
stdio: ["ignore", "pipe", "pipe"],
});
const stripped = stripAnsi(output);
const windows: RateWindow[] = [];
// Parse credits percentage from "████...█ X%"
let creditsPercent = 0;
const percentMatch = stripped.match(/█+\s*(\d+)%/);
if (percentMatch) {
creditsPercent = parseInt(percentMatch[1], 10);
}
// Parse credits used/total from "(X.XX of Y covered in plan)"
const creditsMatch = stripped.match(/\((\d+\.?\d*)\s+of\s+(\d+)\s+covered/);
if (creditsMatch && !percentMatch) {
const creditsUsed = parseFloat(creditsMatch[1]);
const creditsTotal = parseFloat(creditsMatch[2]);
if (creditsTotal > 0) {
creditsPercent = (creditsUsed / creditsTotal) * 100;
}
}
// Parse reset date from "resets on 01/01"
let resetsAt: Date | undefined;
const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/);
if (resetMatch) {
const [month, day] = resetMatch[1].split("/").map(Number);
const now = new Date();
const year = now.getFullYear();
resetsAt = new Date(year, month - 1, day);
if (resetsAt < now) resetsAt.setFullYear(year + 1);
}
windows.push({
label: "Credits",
usedPercent: creditsPercent,
resetDescription: resetsAt ? formatReset(resetsAt) : undefined,
resetAt: resetsAt?.toISOString(),
});
return this.snapshot({ windows });
} catch {
return this.emptySnapshot(fetchFailed());
}
}
// Kiro doesn't have a public status page
}
const API_TIMEOUT_MS = 5000;
@@ -0,0 +1,120 @@
/**
* z.ai usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError, apiError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
/**
* Load z.ai API key from environment or auth.json
*/
function loadZaiApiKey(deps: Dependencies): string | undefined {
// Try environment variable first
if (deps.env.ZAI_API_KEY) {
return deps.env.ZAI_API_KEY;
}
if (deps.env.Z_AI_API_KEY) {
return deps.env.Z_AI_API_KEY;
}
// Try pi auth.json
const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(authPath)) {
const auth = JSON.parse(deps.readFile(authPath) ?? "{}");
return auth["z-ai"]?.access || auth["z-ai"]?.key || auth["zai"]?.access || auth["zai"]?.key;
}
} catch {
// Ignore parse errors
}
return undefined;
}
export class ZaiProvider extends BaseProvider {
readonly name = "zai" as const;
readonly displayName = "z.ai Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadZaiApiKey(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const apiKey = loadZaiApiKey(deps);
if (!apiKey) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
success?: boolean;
code?: number;
msg?: string;
data?: {
limits?: Array<{
type?: string;
unit?: number;
number?: number;
percentage?: number;
nextResetTime?: string;
}>;
};
};
if (!data.success || data.code !== 200) {
return this.emptySnapshot(apiError(data.msg || "API error"));
}
const windows: RateWindow[] = [];
const limits = data.data?.limits || [];
for (const limit of limits) {
const percent = limit.percentage || 0;
const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime) : undefined;
if (limit.type === "TOKENS_LIMIT") {
windows.push({
label: "Tokens",
usedPercent: percent,
resetDescription: nextReset ? formatReset(nextReset) : undefined,
resetAt: nextReset?.toISOString(),
});
} else if (limit.type === "TIME_LIMIT") {
windows.push({
label: "Monthly",
usedPercent: percent,
resetDescription: nextReset ? formatReset(nextReset) : undefined,
resetAt: nextReset?.toISOString(),
});
}
}
return this.snapshot({ windows });
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
// z.ai doesn't have a public status page
}
@@ -0,0 +1,5 @@
/**
* Provider registry exports.
*/
export * from "./registry.js";
@@ -0,0 +1,16 @@
/**
* Provider metadata shared across the core.
*/
export {
PROVIDERS,
PROVIDER_METADATA,
PROVIDER_DISPLAY_NAMES,
} from "../../../src/shared.js";
export type {
ProviderName,
ProviderMetadata,
ProviderStatusConfig,
ProviderDetectionConfig,
} from "../../../src/shared.js";
@@ -0,0 +1,54 @@
/**
* Provider registry - exports all providers
*/
export { AnthropicProvider } from "./impl/anthropic.js";
export { CopilotProvider } from "./impl/copilot.js";
export { GeminiProvider } from "./impl/gemini.js";
export { AntigravityProvider } from "./impl/antigravity.js";
export { CodexProvider } from "./impl/codex.js";
export { KiroProvider } from "./impl/kiro.js";
export { ZaiProvider } from "./impl/zai.js";
import type { Dependencies, ProviderName } from "../types.js";
import type { UsageProvider } from "../provider.js";
import { PROVIDERS } from "./metadata.js";
import { AnthropicProvider } from "./impl/anthropic.js";
import { CopilotProvider } from "./impl/copilot.js";
import { GeminiProvider } from "./impl/gemini.js";
import { AntigravityProvider } from "./impl/antigravity.js";
import { CodexProvider } from "./impl/codex.js";
import { KiroProvider } from "./impl/kiro.js";
import { ZaiProvider } from "./impl/zai.js";
const PROVIDER_FACTORIES: Record<ProviderName, () => UsageProvider> = {
anthropic: () => new AnthropicProvider(),
copilot: () => new CopilotProvider(),
gemini: () => new GeminiProvider(),
antigravity: () => new AntigravityProvider(),
codex: () => new CodexProvider(),
kiro: () => new KiroProvider(),
zai: () => new ZaiProvider(),
};
/**
* Create a provider instance by name
*/
export function createProvider(name: ProviderName): UsageProvider {
return PROVIDER_FACTORIES[name]();
}
/**
* Get all provider instances
*/
export function getAllProviders(): UsageProvider[] {
return PROVIDERS.map((name) => PROVIDER_FACTORIES[name]());
}
export function hasProviderCredentials(name: ProviderName, deps: Dependencies): boolean {
const provider = createProvider(name);
if (provider.hasCredentials) {
return provider.hasCredentials(deps);
}
return true;
}
@@ -0,0 +1,109 @@
/**
* Provider-specific settings helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { ProviderName } from "../types.js";
import type { Settings, CoreProviderSettings } from "../settings-types.js";
import { CUSTOM_OPTION } from "../ui/settings-list.js";
function buildBaseProviderItems(ps: CoreProviderSettings): SettingItem[] {
const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off";
return [
{
id: "enabled",
label: "Enabled",
currentValue: enabledValue,
values: ["auto", "on", "off"],
description: "Auto enables if credentials are detected.",
},
{
id: "fetchStatus",
label: "Fetch Status",
currentValue: ps.fetchStatus ? "on" : "off",
values: ["on", "off"],
description: "Fetch status page indicator for this provider.",
},
];
}
function resolveEnabledValue(value: string): CoreProviderSettings["enabled"] {
if (value === "auto") return "auto";
return value === "on";
}
function applyBaseProviderSetting(ps: CoreProviderSettings, id: string, value: string): boolean {
switch (id) {
case "enabled":
ps.enabled = resolveEnabledValue(value);
return true;
case "fetchStatus":
ps.fetchStatus = value === "on";
return true;
default:
return false;
}
}
/**
* Build settings items for a specific provider.
*/
export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] {
const ps = settings.providers[provider];
const items = buildBaseProviderItems(ps);
if (provider === "anthropic") {
const currencySymbol = ps.extraUsageCurrencySymbol?.trim();
items.push(
{
id: "extraUsageCurrencySymbol",
label: "Extra Usage Currency Symbol",
currentValue: currencySymbol ? currencySymbol : "none",
values: ["none", CUSTOM_OPTION],
description: "Prefix symbol for Extra usage amounts.",
},
{
id: "extraUsageDecimalSeparator",
label: "Extra Usage Decimal Separator",
currentValue: ps.extraUsageDecimalSeparator === "," ? "," : ".",
values: [".", ","],
description: "Decimal separator for Extra usage amounts.",
},
);
}
return items;
}
/**
* Apply a provider settings change in-place.
*/
export function applyProviderSettingsChange(
settings: Settings,
provider: ProviderName,
id: string,
value: string
): Settings {
const ps = settings.providers[provider];
if (applyBaseProviderSetting(ps, id, value)) {
return settings;
}
switch (id) {
case "extraUsageCurrencySymbol":
if (value === CUSTOM_OPTION) {
return settings;
}
if (value === "none") {
delete ps.extraUsageCurrencySymbol;
return settings;
}
ps.extraUsageCurrencySymbol = value;
return settings;
case "extraUsageDecimalSeparator":
ps.extraUsageDecimalSeparator = value === "," ? "," : ".";
return settings;
default:
return settings;
}
}
@@ -0,0 +1,25 @@
/**
* Provider status handling helpers.
*/
import type { Dependencies, ProviderName, ProviderStatus } from "../types.js";
import { fetchProviderStatus } from "../status.js";
import { PROVIDER_METADATA } from "./metadata.js";
export function providerHasStatus(
provider: ProviderName,
providerInstance?: { fetchStatus?: (deps: Dependencies) => Promise<ProviderStatus> }
): boolean {
return Boolean(providerInstance?.fetchStatus) || Boolean(PROVIDER_METADATA[provider]?.status);
}
export async function fetchProviderStatusWithFallback(
provider: ProviderName,
providerInstance: { fetchStatus?: (deps: Dependencies) => Promise<ProviderStatus> },
deps: Dependencies
): Promise<ProviderStatus> {
if (providerInstance.fetchStatus) {
return providerInstance.fetchStatus(deps);
}
return fetchProviderStatus(provider, deps);
}
@@ -0,0 +1,95 @@
/**
* Settings types and defaults for sub-core
*/
import type {
CoreSettings,
CoreProviderSettingsMap,
CoreProviderSettings,
BehaviorSettings,
ProviderName,
ProviderEnabledSetting,
} from "../../src/shared.js";
import { PROVIDERS, getDefaultCoreSettings } from "../../src/shared.js";
export type {
CoreProviderSettings,
CoreProviderSettingsMap,
BehaviorSettings,
CoreSettings,
ProviderEnabledSetting,
} from "../../src/shared.js";
/**
* Tool registration settings
*/
export interface ToolSettings {
usageTool: boolean;
allUsageTool: boolean;
}
/**
* All settings
*/
export interface Settings extends CoreSettings {
/** Version for migration */
version: number;
/** Tool registration settings */
tools: ToolSettings;
}
/**
* Current settings version
*/
export const SETTINGS_VERSION = 3;
/**
* Default settings
*/
export function getDefaultSettings(): Settings {
const coreDefaults = getDefaultCoreSettings();
return {
version: SETTINGS_VERSION,
tools: {
usageTool: false,
allUsageTool: false,
},
providers: coreDefaults.providers,
behavior: coreDefaults.behavior,
statusRefresh: coreDefaults.statusRefresh,
providerOrder: coreDefaults.providerOrder,
defaultProvider: coreDefaults.defaultProvider,
};
}
/**
* Deep merge two objects
*/
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
const result = { ...target } as T;
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = result[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T];
}
}
return result;
}
/**
* Merge settings with defaults (no legacy migrations).
*/
export function mergeSettings(loaded: Partial<Settings>): Settings {
return deepMerge(getDefaultSettings(), loaded);
}
@@ -0,0 +1 @@
export { showSettingsUI } from "./settings/ui.js";
@@ -0,0 +1,137 @@
/**
* Settings persistence for sub-core
*/
import * as path from "node:path";
import type { Settings } from "./settings-types.js";
import { getDefaultSettings, mergeSettings, SETTINGS_VERSION } from "./settings-types.js";
import { getStorage } from "./storage.js";
import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
import { clearCache } from "./cache.js";
/**
* Settings file path
*/
export const SETTINGS_PATH = getSettingsPath();
const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
/**
* In-memory settings cache
*/
let cachedSettings: Settings | undefined;
type LoadedSettings = {
settings: Settings;
loadedVersion: number;
};
/**
* Ensure the settings directory exists
*/
function ensureSettingsDir(): void {
const storage = getStorage();
const dir = path.dirname(SETTINGS_PATH);
storage.ensureDir(dir);
}
function loadSettingsFromDisk(settingsPath: string): LoadedSettings | null {
const storage = getStorage();
if (!storage.exists(settingsPath)) return null;
const content = storage.readFile(settingsPath);
if (!content) return null;
const loaded = JSON.parse(content) as Partial<Settings>;
const loadedVersion = typeof loaded.version === "number" ? loaded.version : 0;
const merged = mergeSettings(loaded);
return { settings: merged, loadedVersion };
}
function applyVersionMigration(settings: Settings, loadedVersion: number): { settings: Settings; needsSave: boolean } {
if (loadedVersion < SETTINGS_VERSION) {
clearCache();
return { settings: { ...settings, version: SETTINGS_VERSION }, needsSave: true };
}
return { settings, needsSave: false };
}
function tryLoadSettings(settingsPath: string): LoadedSettings | null {
try {
return loadSettingsFromDisk(settingsPath);
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
return null;
}
}
/**
* Load settings from disk
*/
export function loadSettings(): Settings {
if (cachedSettings) {
return cachedSettings;
}
const diskSettings = tryLoadSettings(SETTINGS_PATH);
if (diskSettings) {
const { settings: next, needsSave } = applyVersionMigration(diskSettings.settings, diskSettings.loadedVersion);
if (needsSave) {
saveSettings(next);
}
cachedSettings = next;
return cachedSettings;
}
const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
if (legacySettings) {
const { settings: next } = applyVersionMigration(legacySettings.settings, legacySettings.loadedVersion);
const saved = saveSettings(next);
if (saved) {
getStorage().removeFile(LEGACY_SETTINGS_PATH);
}
cachedSettings = next;
return cachedSettings;
}
// Return defaults if file doesn't exist or failed to load
cachedSettings = getDefaultSettings();
return cachedSettings;
}
/**
* Save settings to disk
*/
export function saveSettings(settings: Settings): boolean {
const storage = getStorage();
try {
ensureSettingsDir();
const content = JSON.stringify(settings, null, 2);
storage.writeFile(SETTINGS_PATH, content);
cachedSettings = settings;
return true;
} catch (error) {
console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
return false;
}
}
/**
* Reset settings to defaults
*/
export function resetSettings(): Settings {
const defaults = getDefaultSettings();
saveSettings(defaults);
return defaults;
}
/**
* Get current settings (cached)
*/
export function getSettings(): Settings {
return loadSettings();
}
/**
* Clear the settings cache (force reload on next access)
*/
export function clearSettingsCache(): void {
cachedSettings = undefined;
}
@@ -0,0 +1,58 @@
/**
* Behavior settings UI helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { BehaviorSettings } from "../settings-types.js";
import { CUSTOM_OPTION } from "../ui/settings-list.js";
export function buildRefreshItems(settings: BehaviorSettings): SettingItem[] {
return [
{
id: "refreshInterval",
label: "Auto-refresh Interval",
currentValue: settings.refreshInterval === 0 ? "off" : `${settings.refreshInterval}s`,
values: ["off", "15s", "30s", "60s", "120s", "300s", CUSTOM_OPTION],
description: "How often to refresh automatically.",
},
{
id: "minRefreshInterval",
label: "Minimum Refresh Interval",
currentValue: settings.minRefreshInterval === 0 ? "off" : `${settings.minRefreshInterval}s`,
values: ["off", "5s", "10s", "15s", "30s", "60s", "120s", CUSTOM_OPTION],
description: "Cap refreshes even when triggered each turn.",
},
{
id: "refreshOnTurnStart",
label: "Refresh on Turn Start",
currentValue: settings.refreshOnTurnStart ? "on" : "off",
values: ["on", "off"],
description: "Refresh when a new turn starts.",
},
{
id: "refreshOnToolResult",
label: "Refresh on Tool Result",
currentValue: settings.refreshOnToolResult ? "on" : "off",
values: ["on", "off"],
description: "Refresh after tool executions.",
},
];
}
export function applyRefreshChange(settings: BehaviorSettings, id: string, value: string): BehaviorSettings {
switch (id) {
case "refreshInterval":
settings.refreshInterval = value === "off" ? 0 : parseInt(value, 10);
break;
case "minRefreshInterval":
settings.minRefreshInterval = value === "off" ? 0 : parseInt(value, 10);
break;
case "refreshOnTurnStart":
settings.refreshOnTurnStart = value === "on";
break;
case "refreshOnToolResult":
settings.refreshOnToolResult = value === "on";
break;
}
return settings;
}
@@ -0,0 +1,83 @@
/**
* Settings menu item builders.
*/
import type { SelectItem } from "@mariozechner/pi-tui";
import type { Settings } from "../settings-types.js";
import type { ProviderName } from "../types.js";
import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
export type TooltipSelectItem = SelectItem & { tooltip?: string };
export function buildMainMenuItems(settings: Settings): TooltipSelectItem[] {
const enabledCount = Object.values(settings.providers).filter((p) => p.enabled !== "off" && p.enabled !== false).length;
const totalCount = Object.keys(settings.providers).length;
const toolEnabledCount = [settings.tools.usageTool, settings.tools.allUsageTool].filter(Boolean).length;
const toolTotalCount = 2;
return [
{
value: "providers",
label: "Provider Settings",
description: `${enabledCount}/${totalCount} enabled`,
tooltip: "Enable providers, toggle status fetch, and adjust provider settings.",
},
{
value: "behavior",
label: "Usage Refresh Settings",
description: `refresh ${settings.behavior.refreshInterval}s`,
tooltip: "Control usage refresh interval and triggers.",
},
{
value: "status-refresh",
label: "Status Refresh Settings",
description: `refresh ${settings.statusRefresh.refreshInterval}s`,
tooltip: "Control status refresh interval and triggers.",
},
{
value: "tools",
label: "Tool Settings",
description: `${toolEnabledCount}/${toolTotalCount} enabled`,
tooltip: "Enable sub-core tools (requires /reload to take effect).",
},
{
value: "provider-order",
label: "Provider Order",
description: settings.providerOrder.slice(0, 3).join(", ") + "...",
tooltip: "Reorder providers for cycling and auto-selection.",
},
{
value: "reset",
label: "Reset to Defaults",
description: "restore all settings",
tooltip: "Restore all sub-core settings to defaults.",
},
];
}
export function buildProviderListItems(settings: Settings): TooltipSelectItem[] {
return settings.providerOrder.map((provider) => {
const ps = settings.providers[provider];
const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off";
const statusIcon = ps.fetchStatus ? ", status fetch on" : "";
return {
value: `provider-${provider}`,
label: PROVIDER_DISPLAY_NAMES[provider],
description: `enabled ${enabledValue}${statusIcon}`,
tooltip: `Enable ${PROVIDER_DISPLAY_NAMES[provider]} and configure status fetching.`,
};
});
}
export function buildProviderOrderItems(settings: Settings): TooltipSelectItem[] {
const activeProviders = settings.providerOrder.filter((provider) => {
const enabled = settings.providers[provider].enabled;
return enabled !== "off" && enabled !== false;
});
return activeProviders.map((provider, index) => ({
value: provider,
label: `${index + 1}. ${PROVIDER_DISPLAY_NAMES[provider]}`,
tooltip: "Reorder enabled providers (Space to toggle move mode).",
}));
}
@@ -0,0 +1,38 @@
/**
* Tool settings UI helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { Settings, ToolSettings } from "../settings-types.js";
export function buildToolItems(settings: ToolSettings): SettingItem[] {
return [
{
id: "usageTool",
label: "Usage Tool",
currentValue: settings.usageTool ? "on" : "off",
values: ["on", "off"],
description: "Expose sub_get_usage/get_current_usage (requires /reload).",
},
{
id: "allUsageTool",
label: "All Usage Tool",
currentValue: settings.allUsageTool ? "on" : "off",
values: ["on", "off"],
description: "Expose sub_get_all_usage/get_all_usage (requires /reload).",
},
];
}
export function applyToolChange(settings: Settings, id: string, value: string): Settings {
const enabled = value === "on";
switch (id) {
case "usageTool":
settings.tools.usageTool = enabled;
break;
case "allUsageTool":
settings.tools.allUsageTool = enabled;
break;
}
return settings;
}
@@ -0,0 +1,450 @@
/**
* Settings UI for sub-core
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
import { Container, Input, type SelectItem, SelectList, Spacer, Text } from "@mariozechner/pi-tui";
import { SettingsList, type SettingItem, CUSTOM_OPTION } from "../ui/settings-list.js";
import type { ProviderName } from "../types.js";
import type { Settings } from "../settings-types.js";
import { getDefaultSettings } from "../settings-types.js";
import { getSettings, saveSettings, resetSettings } from "../settings.js";
import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
import { buildProviderSettingsItems, applyProviderSettingsChange } from "../providers/settings.js";
import { buildRefreshItems, applyRefreshChange } from "./behavior.js";
import { buildToolItems, applyToolChange } from "./tools.js";
import { buildMainMenuItems, buildProviderListItems, buildProviderOrderItems, type TooltipSelectItem } from "./menu.js";
/**
* Settings category
*/
type ProviderCategory = `provider-${ProviderName}`;
type SettingsCategory =
| "main"
| "providers"
| ProviderCategory
| "behavior"
| "status-refresh"
| "tools"
| "provider-order";
/**
* Extract provider name from category
*/
function getProviderFromCategory(category: SettingsCategory): ProviderName | null {
const match = category.match(/^provider-(\w+)$/);
if (match && match[1] !== "order") {
return match[1] as ProviderName;
}
return null;
}
/**
* Show the settings UI
*/
export async function showSettingsUI(
ctx: ExtensionContext,
onSettingsChange?: (settings: Settings) => void | Promise<void>
): Promise<Settings> {
let settings = getSettings();
let currentCategory: SettingsCategory = "main";
let providerOrderSelectedIndex = 0;
let providerOrderReordering = false;
let suppressProviderOrderChange = false;
return new Promise((resolve) => {
ctx.ui.custom<Settings>((tui, theme, _kb, done) => {
let container = new Container();
let activeList: SelectList | SettingsList | null = null;
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
const buildInputSubmenu = (
label: string,
parseValue: (value: string) => string | null,
formatInitial?: (value: string) => string,
) => {
return (currentValue: string, done: (selectedValue?: string) => void) => {
const input = new Input();
input.focused = true;
input.setValue(formatInitial ? formatInitial("") : "");
input.onSubmit = (value) => {
const parsed = parseValue(value);
if (!parsed) return;
done(parsed);
};
input.onEscape = () => {
done();
};
const inputContainer = new Container();
inputContainer.addChild(new Text(theme.fg("muted", label), 1, 0));
inputContainer.addChild(new Spacer(1));
inputContainer.addChild(input);
return {
render: (width: number) => inputContainer.render(width),
invalidate: () => inputContainer.invalidate(),
handleInput: (data: string) => input.handleInput(data),
};
};
};
const parseRefreshInterval = (raw: string): string | null => {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
ctx.ui.notify("Enter a value", "warning");
return null;
}
if (trimmed === "off") return "off";
const cleaned = trimmed.replace(/s$/, "");
const parsed = Number.parseInt(cleaned, 10);
if (Number.isNaN(parsed)) {
ctx.ui.notify("Enter seconds", "warning");
return null;
}
const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
return clamped === 0 ? "off" : `${clamped}s`;
};
const parseMinRefreshInterval = (raw: string): string | null => {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
ctx.ui.notify("Enter a value", "warning");
return null;
}
if (trimmed === "off") return "off";
const cleaned = trimmed.replace(/s$/, "");
const parsed = Number.parseInt(cleaned, 10);
if (Number.isNaN(parsed)) {
ctx.ui.notify("Enter seconds", "warning");
return null;
}
const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
return clamped === 0 ? "off" : `${clamped}s`;
};
const parseCurrencySymbol = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) {
ctx.ui.notify("Enter a symbol or 'none'", "warning");
return null;
}
if (trimmed.toLowerCase() === "none") return "none";
return trimmed;
};
function rebuild(): void {
container = new Container();
let tooltipText: Text | null = null;
const attachTooltip = (items: TooltipSelectItem[], selectList: SelectList): void => {
if (!items.some((item) => item.tooltip)) return;
const tooltipComponent = new Text("", 1, 0);
const setTooltip = (item?: TooltipSelectItem | null) => {
const tooltip = item?.tooltip?.trim();
tooltipComponent.setText(tooltip ? theme.fg("dim", tooltip) : "");
};
setTooltip(selectList.getSelectedItem() as TooltipSelectItem | null);
const existingHandler = selectList.onSelectionChange;
selectList.onSelectionChange = (item) => {
if (existingHandler) existingHandler(item);
setTooltip(item as TooltipSelectItem);
tui.requestRender();
};
tooltipText = tooltipComponent;
};
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
const titles: Record<string, string> = {
main: "sub-core Settings",
providers: "Provider Settings",
behavior: "Usage Refresh Settings",
"status-refresh": "Status Refresh Settings",
tools: "Tool Settings",
"provider-order": "Provider Order",
};
const providerCategory = getProviderFromCategory(currentCategory);
const title = providerCategory
? `${PROVIDER_DISPLAY_NAMES[providerCategory]} Settings`
: titles[currentCategory] ?? "sub-core Settings";
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
container.addChild(new Spacer(1));
if (currentCategory === "main") {
const items = buildMainMenuItems(settings);
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => theme.fg("accent", t),
selectedText: (t: string) => theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
attachTooltip(items, selectList);
selectList.onSelect = (item) => {
if (item.value === "reset") {
settings = resetSettings();
if (onSettingsChange) void onSettingsChange(settings);
ctx.ui.notify("Settings reset to defaults", "info");
rebuild();
tui.requestRender();
} else {
currentCategory = item.value as SettingsCategory;
rebuild();
tui.requestRender();
}
};
selectList.onCancel = () => {
saveSettings(settings);
done(settings);
};
activeList = selectList;
container.addChild(selectList);
} else if (currentCategory === "providers") {
const items = buildProviderListItems(settings);
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => theme.fg("accent", t),
selectedText: (t: string) => theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
attachTooltip(items, selectList);
selectList.onSelect = (item) => {
currentCategory = item.value as SettingsCategory;
rebuild();
tui.requestRender();
};
selectList.onCancel = () => {
currentCategory = "main";
rebuild();
tui.requestRender();
};
activeList = selectList;
container.addChild(selectList);
} else if (currentCategory === "provider-order") {
const items = buildProviderOrderItems(settings);
const isReordering = providerOrderReordering;
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
selectedText: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
if (items.length > 0) {
suppressProviderOrderChange = true;
providerOrderSelectedIndex = Math.min(providerOrderSelectedIndex, items.length - 1);
selectList.setSelectedIndex(providerOrderSelectedIndex);
suppressProviderOrderChange = false;
}
selectList.onSelectionChange = (item) => {
if (suppressProviderOrderChange) return;
const newIndex = items.findIndex((listItem) => listItem.value === item.value);
if (newIndex === -1) return;
if (!providerOrderReordering) {
providerOrderSelectedIndex = newIndex;
return;
}
const activeProviders = settings.providerOrder.filter((provider) => {
const enabled = settings.providers[provider].enabled;
return enabled !== "off" && enabled !== false;
});
const oldIndex = providerOrderSelectedIndex;
if (newIndex === oldIndex) return;
if (oldIndex < 0 || oldIndex >= activeProviders.length) return;
const provider = activeProviders[oldIndex];
const updatedActive = [...activeProviders];
updatedActive.splice(oldIndex, 1);
updatedActive.splice(newIndex, 0, provider);
let activeIndex = 0;
settings.providerOrder = settings.providerOrder.map((existing) => {
const enabled = settings.providers[existing].enabled;
if (enabled === "off" || enabled === false) return existing;
const next = updatedActive[activeIndex];
activeIndex += 1;
return next;
});
providerOrderSelectedIndex = newIndex;
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
rebuild();
tui.requestRender();
};
attachTooltip(items, selectList);
selectList.onSelect = () => {
if (items.length === 0) return;
providerOrderReordering = !providerOrderReordering;
rebuild();
tui.requestRender();
};
selectList.onCancel = () => {
if (providerOrderReordering) {
providerOrderReordering = false;
rebuild();
tui.requestRender();
return;
}
currentCategory = "main";
rebuild();
tui.requestRender();
};
activeList = selectList;
container.addChild(selectList);
} else {
let items: SettingItem[];
let handleChange: (id: string, value: string) => void;
let backCategory: SettingsCategory = "main";
const provider = getProviderFromCategory(currentCategory);
if (provider) {
items = buildProviderSettingsItems(settings, provider);
const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {};
if (provider === "anthropic") {
customHandlers.extraUsageCurrencySymbol = buildInputSubmenu(
"Extra Usage Currency Symbol",
parseCurrencySymbol,
undefined,
);
}
for (const item of items) {
if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
item.submenu = customHandlers[item.id];
}
}
handleChange = (id, value) => {
settings = applyProviderSettingsChange(settings, provider, id, value);
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
};
backCategory = "providers";
} else if (currentCategory === "tools") {
items = buildToolItems(settings.tools);
handleChange = (id, value) => {
settings = applyToolChange(settings, id, value);
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
};
backCategory = "main";
} else {
const refreshTarget = currentCategory === "status-refresh" ? settings.statusRefresh : settings.behavior;
items = buildRefreshItems(refreshTarget);
const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {
refreshInterval: buildInputSubmenu("Auto-refresh Interval (seconds)", parseRefreshInterval),
minRefreshInterval: buildInputSubmenu("Minimum Refresh Interval (seconds)", parseMinRefreshInterval),
};
for (const item of items) {
if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
item.submenu = customHandlers[item.id];
}
}
handleChange = (id, value) => {
applyRefreshChange(refreshTarget, id, value);
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
};
backCategory = "main";
}
const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel";
const customTheme = {
...getSettingsListTheme(),
hint: (text: string) => {
if (text.includes("Enter/Space")) {
return theme.fg("dim", settingsHintText);
}
return theme.fg("dim", text);
},
};
const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
customTheme,
handleChange,
() => {
currentCategory = backCategory;
rebuild();
tui.requestRender();
}
);
activeList = settingsList;
container.addChild(settingsList);
}
const usesSettingsList =
currentCategory === "behavior" ||
currentCategory === "status-refresh" ||
currentCategory === "tools" ||
getProviderFromCategory(currentCategory) !== null;
if (!usesSettingsList) {
let helpText: string;
if (currentCategory === "main" || currentCategory === "providers") {
helpText = "↑↓ navigate • Enter/Space select • Esc back";
} else if (currentCategory === "provider-order") {
helpText = providerOrderReordering
? "↑↓ move provider • Esc back"
: "↑↓ navigate • Enter/Space select • Esc back";
} else {
helpText = "↑↓ navigate • Enter/Space to change • Esc to cancel";
}
if (tooltipText) {
container.addChild(new Spacer(1));
container.addChild(tooltipText);
}
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", helpText), 1, 0));
}
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
}
rebuild();
return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
if (data === " ") {
if (currentCategory === "provider-order") {
providerOrderReordering = !providerOrderReordering;
rebuild();
tui.requestRender();
return;
}
if (activeList && "handleInput" in activeList && activeList.handleInput) {
activeList.handleInput("\r");
}
tui.requestRender();
return;
}
if (activeList && "handleInput" in activeList && activeList.handleInput) {
activeList.handleInput(data);
}
tui.requestRender();
},
};
}).then(resolve);
});
}
@@ -0,0 +1,245 @@
/**
* Status polling for providers
*/
import type { Dependencies, ProviderName, ProviderStatus, StatusIndicator } from "./types.js";
import type { ProviderStatusConfig } from "./providers/metadata.js";
import { GOOGLE_STATUS_URL, GEMINI_PRODUCT_ID, API_TIMEOUT_MS } from "./config.js";
import { PROVIDER_METADATA } from "./providers/metadata.js";
import { createTimeoutController } from "./utils.js";
type StatusPageStatusConfig = Extract<ProviderStatusConfig, { type: "statuspage" }>;
interface StatusPageSummary {
status?: {
indicator?: string;
description?: string;
};
components?: Array<{
id?: string;
name?: string;
status?: string;
}>;
}
interface StatusPageStatus {
indicator?: string;
description?: string;
}
interface StatusPageResponse {
status?: StatusPageStatus;
}
function toSummaryUrl(url: string): string {
if (url.endsWith("/summary.json")) return url;
if (url.endsWith("/status.json")) {
return `${url.slice(0, -"/status.json".length)}/summary.json`;
}
if (!url.endsWith("/")) return `${url}/summary.json`;
return `${url}summary.json`;
}
function isComponentMatch(component: { id?: string; name?: string }, config?: StatusPageStatusConfig): boolean {
if (!config?.component) return false;
if (config.component.id && component.id) {
return component.id === config.component.id;
}
if (config.component.name && component.name) {
return component.name.trim().toLowerCase() === config.component.name.trim().toLowerCase();
}
return false;
}
function mapStatusIndicator(indicator?: string): StatusIndicator {
switch (indicator) {
case "none":
return "none";
case "minor":
return "minor";
case "major":
return "major";
case "critical":
return "critical";
case "maintenance":
return "maintenance";
default:
return "unknown";
}
}
function mapComponentStatus(indicator?: string): StatusIndicator {
switch ((indicator || "").toLowerCase()) {
case "operational":
return "none";
case "under_maintenance":
return "maintenance";
case "degraded_performance":
return "minor";
case "partial_outage":
return "major";
case "major_outage":
return "critical";
default:
return "unknown";
}
}
function formatComponentLabel(rawStatus?: string): string {
switch ((rawStatus || "").toLowerCase()) {
case "operational":
return "Operational";
case "under_maintenance":
return "Under maintenance";
case "degraded_performance":
return "Degraded performance";
case "partial_outage":
return "Partial outage";
case "major_outage":
return "Major outage";
default:
return rawStatus ? rawStatus.replace(/_/g, " ") : "Unknown";
}
}
/**
* Fetch status from a standard statuspage.io API
*/
async function fetchStatuspageStatus(
url: string,
deps: Dependencies,
config?: StatusPageStatusConfig
): Promise<ProviderStatus> {
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const fetchUrl = config?.component ? toSummaryUrl(url) : url;
const res = await deps.fetch(fetchUrl, { signal: controller.signal });
clear();
if (!res.ok) {
return { indicator: "unknown" };
}
if (!config?.component) {
const data = (await res.json()) as StatusPageResponse;
const indicator = mapStatusIndicator(data.status?.indicator);
return { indicator, description: data.status?.description };
}
const data = (await res.json()) as StatusPageSummary;
const summaryIndicator = mapStatusIndicator(data.status?.indicator);
const component = (data.components ?? []).find((entry) => isComponentMatch(entry, config));
if (component) {
const componentIndicator = mapComponentStatus(component.status);
const componentDescription =
componentIndicator === "none"
? undefined
: `${component.name ?? "Component"}: ${formatComponentLabel(component.status)}`;
return {
indicator: componentIndicator,
description: componentDescription,
};
}
return {
indicator: summaryIndicator,
description: data.status?.description,
};
} catch {
clear();
return { indicator: "unknown" };
}
}
/**
* Fetch Gemini status from Google Workspace status API
*/
async function fetchGeminiStatus(deps: Dependencies): Promise<ProviderStatus> {
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch(GOOGLE_STATUS_URL, { signal: controller.signal });
clear();
if (!res.ok) return { indicator: "unknown" };
const incidents = (await res.json()) as Array<{
end?: string;
currently_affected_products?: Array<{ id: string }>;
affected_products?: Array<{ id: string }>;
most_recent_update?: { status?: string };
status_impact?: string;
external_desc?: string;
}>;
const activeIncidents = incidents.filter((inc) => {
if (inc.end) return false;
const affected = inc.currently_affected_products || inc.affected_products || [];
return affected.some((p) => p.id === GEMINI_PRODUCT_ID);
});
if (activeIncidents.length === 0) {
return { indicator: "none" };
}
let worstIndicator: StatusIndicator = "minor";
let description: string | undefined;
for (const inc of activeIncidents) {
const status = inc.most_recent_update?.status || inc.status_impact;
if (status === "SERVICE_OUTAGE") {
worstIndicator = "critical";
description = inc.external_desc;
} else if (status === "SERVICE_DISRUPTION" && worstIndicator !== "critical") {
worstIndicator = "major";
description = inc.external_desc;
}
}
return { indicator: worstIndicator, description };
} catch {
clear();
return { indicator: "unknown" };
}
}
/**
* Fetch status for a provider
*/
export async function fetchProviderStatus(provider: ProviderName, deps: Dependencies): Promise<ProviderStatus> {
const statusConfig = PROVIDER_METADATA[provider]?.status;
if (!statusConfig) {
return { indicator: "none" };
}
if (statusConfig.type === "google-workspace") {
return fetchGeminiStatus(deps);
}
return fetchStatuspageStatus(statusConfig.url, deps, statusConfig);
}
/**
* Get emoji for a status indicator
*/
export function getStatusEmoji(status?: ProviderStatus): string {
if (!status) return "";
switch (status.indicator) {
case "none":
return "✅";
case "minor":
return "⚠️";
case "major":
return "🟠";
case "critical":
return "🔴";
case "maintenance":
return "🔧";
default:
return "";
}
}
@@ -0,0 +1,61 @@
/**
* Storage abstraction for settings and cache persistence.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export interface StorageAdapter {
readFile(path: string): string | undefined;
writeFile(path: string, contents: string): void;
writeFileExclusive(path: string, contents: string): boolean;
exists(path: string): boolean;
removeFile(path: string): void;
ensureDir(path: string): void;
}
export function createFsStorage(): StorageAdapter {
return {
readFile(filePath: string): string | undefined {
try {
return fs.readFileSync(filePath, "utf-8");
} catch {
return undefined;
}
},
writeFile(filePath: string, contents: string): void {
fs.writeFileSync(filePath, contents, "utf-8");
},
writeFileExclusive(filePath: string, contents: string): boolean {
try {
fs.writeFileSync(filePath, contents, { flag: "wx" });
return true;
} catch {
return false;
}
},
exists(filePath: string): boolean {
return fs.existsSync(filePath);
},
removeFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch {
// Ignore remove errors
}
},
ensureDir(dirPath: string): void {
fs.mkdirSync(path.resolve(dirPath), { recursive: true });
},
};
}
let activeStorage: StorageAdapter = createFsStorage();
export function getStorage(): StorageAdapter {
return activeStorage;
}
export function setStorage(storage: StorageAdapter): void {
activeStorage = storage;
}
@@ -0,0 +1,60 @@
/**
* File lock helpers for storage-backed locks.
*/
import { getStorage } from "../storage.js";
export function tryAcquireFileLock(lockPath: string, staleAfterMs: number): boolean {
const storage = getStorage();
try {
if (storage.writeFileExclusive(lockPath, String(Date.now()))) {
return true;
}
} catch {
// ignore
}
try {
if (storage.exists(lockPath)) {
const lockContent = storage.readFile(lockPath) ?? "";
const lockTime = parseInt(lockContent, 10);
if (Date.now() - lockTime > staleAfterMs) {
storage.writeFile(lockPath, String(Date.now()));
return true;
}
}
} catch {
// Ignore, lock is held by another process
}
return false;
}
export function releaseFileLock(lockPath: string): void {
const storage = getStorage();
try {
if (storage.exists(lockPath)) {
storage.removeFile(lockPath);
}
} catch {
// Ignore
}
}
export async function waitForLockRelease(
lockPath: string,
maxWaitMs: number,
pollMs: number = 100
): Promise<boolean> {
const storage = getStorage();
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
if (!storage.exists(lockPath)) {
return true;
}
}
return false;
}
@@ -0,0 +1,33 @@
/**
* Core types for the sub-bar extension
*/
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
export type {
ProviderName,
StatusIndicator,
ProviderStatus,
RateWindow,
UsageSnapshot,
UsageError,
UsageErrorCode,
ProviderUsageEntry,
SubCoreState,
SubCoreEvents,
} from "../../src/shared.js";
export { PROVIDERS } from "../../src/shared.js";
/**
* Dependencies that can be injected for testing
*/
export interface Dependencies {
fetch: typeof globalThis.fetch;
readFile: (path: string) => string | undefined;
fileExists: (path: string) => boolean;
// Use static commands/args only (no user-controlled input).
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => string;
homedir: () => string;
env: NodeJS.ProcessEnv;
}
@@ -0,0 +1,290 @@
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
import {
Input,
fuzzyFilter,
getEditorKeybindings,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@mariozechner/pi-tui";
export interface SettingsListOptions {
enableSearch?: boolean;
}
export const CUSTOM_OPTION = "__custom__";
export const CUSTOM_LABEL = "custom";
export type { SettingItem, SettingsListTheme };
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines);
}
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (displayItems.length === 0) {
lines.push(this.theme.hint(" No matching settings"));
this.addHintLine(lines);
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
displayItems.length - this.maxVisible,
),
);
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
const optionLines = isSelected && item.values && item.values.length > 0
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
: null;
const valueText = optionLines
? optionLines[0] ?? ""
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
const line = prefix + labelText + separator + valueText;
lines.push(truncateToWidth(line, width, ""));
if (optionLines && optionLines.length > 1) {
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
for (const continuation of optionLines.slice(1)) {
lines.push(truncateToWidth(indent + continuation, width, ""));
}
}
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrapWidth = Math.max(1, width - 4);
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
for (const line of wrappedDesc) {
const prefixed = ` ${line}`;
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
}
}
// Add hint
this.addHintLine(lines);
return lines;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
const kb = getEditorKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "selectUp")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "selectDown")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (kb.matches(data, "cursorLeft")) {
this.stepValue(-1);
} else if (kb.matches(data, "cursorRight")) {
this.stepValue(1);
} else if (kb.matches(data, "selectConfirm") || data === " ") {
this.activateItem();
} else if (kb.matches(data, "selectCancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");
if (!sanitized) {
return;
}
this.searchInput.handleInput(sanitized);
this.applyFilter(this.searchInput.getValue());
}
}
private stepValue(direction: -1 | 1): void {
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const item = displayItems[this.selectedIndex];
if (!item || !item.values || item.values.length === 0) return;
const values = item.values;
let currentIndex = values.indexOf(item.currentValue);
if (currentIndex === -1) {
currentIndex = direction > 0 ? 0 : values.length - 1;
}
const nextIndex = (currentIndex + direction + values.length) % values.length;
const newValue = values[nextIndex];
if (newValue === CUSTOM_OPTION) {
item.currentValue = newValue;
this.onChange(item.id, newValue);
return;
}
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
private activateItem(): void {
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return;
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
if (item.submenu && hasCustom) {
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
this.openSubmenu(item);
}
return;
}
if (item.submenu) {
this.openSubmenu(item);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private formatOptionsInline(item: SettingItem, values: string[]): string {
const separator = this.theme.description(" • ");
const hasCustom = values.includes(CUSTOM_OPTION);
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
return values
.map((value) => {
const label = value === CUSTOM_OPTION
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
: value;
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
return this.theme.value(label, selected);
})
.join(separator);
}
private openSubmenu(item: SettingItem): void {
if (!item.submenu) return;
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
});
}
private addHintLine(lines: string[]): void {
lines.push("");
lines.push(
this.theme.hint(
this.searchEnabled
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
),
);
}
}
@@ -0,0 +1,250 @@
/**
* Usage refresh and provider selection controller.
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { ProviderName, UsageSnapshot } from "../types.js";
import type { Settings } from "../settings-types.js";
import { detectProviderFromModel } from "../providers/detection.js";
import { isExpectedMissingData } from "../errors.js";
import { formatElapsedSince } from "../utils.js";
import { fetchUsageForProvider, refreshStatusForProvider } from "./fetch.js";
import type { Dependencies } from "../types.js";
import { getCachedData, readCache } from "../cache.js";
import { hasProviderCredentials } from "../providers/registry.js";
export interface UsageControllerState {
currentProvider?: ProviderName;
cachedUsage?: UsageSnapshot;
lastSuccessAt?: number;
providerCycleIndex: number;
}
export interface UsageUpdate {
provider?: ProviderName;
usage?: UsageSnapshot;
}
export type UsageUpdateHandler = (update: UsageUpdate) => void;
export function createUsageController(deps: Dependencies) {
function isProviderAvailable(
settings: Settings,
provider: ProviderName,
options?: { skipCredentials?: boolean }
): boolean {
const setting = settings.providers[provider];
if (setting.enabled === "off" || setting.enabled === false) return false;
if (setting.enabled === "on" || setting.enabled === true) return true;
if (options?.skipCredentials) return true;
return hasProviderCredentials(provider, deps);
}
function getEnabledProviders(settings: Settings): ProviderName[] {
return settings.providerOrder.filter((p) => isProviderAvailable(settings, p));
}
function resolveProvider(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
options?: { skipCredentials?: boolean }
): ProviderName | undefined {
const detected = detectProviderFromModel(ctx.model);
if (detected && isProviderAvailable(settings, detected, options)) {
return detected;
}
return undefined;
}
function emitUpdate(state: UsageControllerState, onUpdate: UsageUpdateHandler): void {
onUpdate({
provider: state.currentProvider,
usage: state.cachedUsage,
});
}
async function refresh(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
onUpdate: UsageUpdateHandler,
options?: { force?: boolean; allowStaleCache?: boolean; forceStatus?: boolean; skipFetch?: boolean }
): Promise<void> {
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
if (!provider) {
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
return;
}
const providerChanged = provider !== state.currentProvider;
state.currentProvider = provider;
if (providerChanged) {
state.cachedUsage = undefined;
}
const cache = readCache();
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
if (!cachedEntry && options?.allowStaleCache) {
cachedEntry = cache[provider] ?? null;
}
if (cachedEntry?.usage) {
state.cachedUsage = {
...cachedEntry.usage,
status: cachedEntry.status,
lastSuccessAt: cachedEntry.fetchedAt,
};
if (!cachedEntry.usage.error) {
state.lastSuccessAt = cachedEntry.fetchedAt;
}
}
emitUpdate(state, onUpdate);
if (options?.skipFetch) {
return;
}
const result = await fetchUsageForProvider(deps, settings, provider, options);
const error = result.usage?.error;
const fetchError = Boolean(error && !isExpectedMissingData(error));
if (fetchError) {
let fallback = state.cachedUsage;
let fallbackFetchedAt = state.lastSuccessAt;
if (!fallback || fallback.windows.length === 0) {
const cachedEntry = cache[provider];
const cachedUsage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
fallback = cachedUsage && cachedUsage.windows.length > 0 ? cachedUsage : undefined;
if (cachedEntry?.fetchedAt) fallbackFetchedAt = cachedEntry.fetchedAt;
}
if (fallback && fallback.windows.length > 0) {
const lastSuccessAt = fallbackFetchedAt ?? state.lastSuccessAt;
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
const description = elapsed ? (elapsed === "just now" ? "just now" : `${elapsed} ago`) : "Fetch failed";
state.cachedUsage = {
...fallback,
lastSuccessAt,
error,
status: { indicator: "minor", description },
};
} else {
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
}
} else {
const successAt = Date.now();
state.cachedUsage = result.usage
? { ...result.usage, status: result.status, lastSuccessAt: successAt }
: undefined;
if (result.usage && !result.usage.error) {
state.lastSuccessAt = successAt;
}
}
emitUpdate(state, onUpdate);
}
async function refreshStatus(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
onUpdate: UsageUpdateHandler,
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
): Promise<void> {
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
if (!provider) {
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
return;
}
const providerChanged = provider !== state.currentProvider;
state.currentProvider = provider;
if (providerChanged) {
state.cachedUsage = undefined;
}
const cache = readCache();
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
if (!cachedEntry && options?.allowStaleCache) {
cachedEntry = cache[provider] ?? null;
}
if (cachedEntry?.usage) {
state.cachedUsage = {
...cachedEntry.usage,
status: cachedEntry.status,
lastSuccessAt: cachedEntry.fetchedAt,
};
if (!cachedEntry.usage.error) {
state.lastSuccessAt = cachedEntry.fetchedAt;
}
}
if (options?.skipFetch) {
emitUpdate(state, onUpdate);
return;
}
const status = await refreshStatusForProvider(deps, settings, provider, { force: options?.force });
if (status && state.cachedUsage) {
state.cachedUsage = { ...state.cachedUsage, status };
}
emitUpdate(state, onUpdate);
}
async function cycleProvider(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
onUpdate: UsageUpdateHandler
): Promise<void> {
const enabledProviders = getEnabledProviders(settings);
if (enabledProviders.length === 0) {
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
return;
}
const currentIndex = state.currentProvider
? enabledProviders.indexOf(state.currentProvider)
: -1;
if (currentIndex >= 0) {
state.providerCycleIndex = currentIndex;
}
const total = enabledProviders.length;
for (let i = 0; i < total; i += 1) {
state.providerCycleIndex = (state.providerCycleIndex + 1) % total;
const nextProvider = enabledProviders[state.providerCycleIndex];
const result = await fetchUsageForProvider(deps, settings, nextProvider);
if (!isUsageAvailable(result.usage)) {
continue;
}
state.currentProvider = nextProvider;
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
emitUpdate(state, onUpdate);
return;
}
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
}
function isUsageAvailable(usage: UsageSnapshot | undefined): usage is UsageSnapshot {
if (!usage) return false;
if (usage.windows.length > 0) return true;
if (!usage.error) return false;
return !isExpectedMissingData(usage.error);
}
return {
getEnabledProviders,
resolveProvider,
refresh,
refreshStatus,
cycleProvider,
};
}
@@ -0,0 +1,215 @@
/**
* Usage fetching helpers with cache integration.
*/
import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "../types.js";
import type { Settings } from "../settings-types.js";
import type { ProviderUsageEntry } from "./types.js";
import { createProvider } from "../providers/registry.js";
import { fetchWithCache, getCachedData, readCache, updateCacheStatus, type Cache } from "../cache.js";
import { fetchProviderStatusWithFallback, providerHasStatus } from "../providers/status.js";
import { hasProviderCredentials } from "../providers/registry.js";
import { isExpectedMissingData } from "../errors.js";
export function getCacheTtlMs(settings: Settings): number {
return settings.behavior.refreshInterval * 1000;
}
export function getMinRefreshIntervalMs(settings: Settings): number {
return settings.behavior.minRefreshInterval * 1000;
}
export function getStatusCacheTtlMs(settings: Settings): number {
return settings.statusRefresh.refreshInterval * 1000;
}
export function getStatusMinRefreshIntervalMs(settings: Settings): number {
return settings.statusRefresh.minRefreshInterval * 1000;
}
const PROVIDER_FETCH_CONCURRENCY = 3;
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T, index: number) => Promise<R>
): Promise<R[]> {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.min(limit, items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const currentIndex = nextIndex++;
if (currentIndex >= items.length) {
return;
}
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
});
await Promise.all(workers);
return results;
}
function resolveStatusFetchedAt(entry?: { fetchedAt: number; statusFetchedAt?: number } | null): number | undefined {
if (!entry) return undefined;
return entry.statusFetchedAt ?? entry.fetchedAt;
}
function isWithinMinInterval(fetchedAt: number | undefined, minIntervalMs: number): boolean {
if (!fetchedAt || minIntervalMs <= 0) return false;
return Date.now() - fetchedAt < minIntervalMs;
}
function shouldRefreshStatus(
settings: Settings,
entry?: { fetchedAt: number; statusFetchedAt?: number } | null,
options?: { force?: boolean }
): boolean {
const fetchedAt = resolveStatusFetchedAt(entry);
const minIntervalMs = getStatusMinRefreshIntervalMs(settings);
if (isWithinMinInterval(fetchedAt, minIntervalMs)) return false;
if (options?.force) return true;
const ttlMs = getStatusCacheTtlMs(settings);
if (ttlMs <= 0) return true;
if (!fetchedAt) return true;
return Date.now() - fetchedAt >= ttlMs;
}
export async function refreshStatusForProvider(
deps: Dependencies,
settings: Settings,
provider: ProviderName,
options?: { force?: boolean }
): Promise<ProviderStatus | undefined> {
const enabledSetting = settings.providers[provider].enabled;
if (enabledSetting === "off" || enabledSetting === false) {
return undefined;
}
if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
return undefined;
}
if (!settings.providers[provider].fetchStatus) {
return undefined;
}
const cache = readCache();
const entry = cache[provider];
const providerInstance = createProvider(provider);
const shouldFetch = providerHasStatus(provider, providerInstance) && shouldRefreshStatus(settings, entry, options);
if (!shouldFetch) {
return entry?.status;
}
const status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
await updateCacheStatus(provider, status, { statusFetchedAt: Date.now() });
return status;
}
export async function fetchUsageForProvider(
deps: Dependencies,
settings: Settings,
provider: ProviderName,
options?: { force?: boolean; forceStatus?: boolean }
): Promise<{ usage?: UsageSnapshot; status?: ProviderStatus }> {
const enabledSetting = settings.providers[provider].enabled;
if (enabledSetting === "off" || enabledSetting === false) {
return {};
}
if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
return {};
}
const ttlMs = getCacheTtlMs(settings);
const cache = readCache();
const cachedEntry = cache[provider];
const cachedStatus = cachedEntry?.status;
const minIntervalMs = getMinRefreshIntervalMs(settings);
if (cachedEntry?.usage && isWithinMinInterval(cachedEntry.fetchedAt, minIntervalMs)) {
const usage = { ...cachedEntry.usage, status: cachedEntry.status } as UsageSnapshot;
return { usage, status: cachedEntry.status };
}
const providerInstance = createProvider(provider);
const shouldFetchStatus = Boolean(options?.forceStatus)
&& settings.providers[provider].fetchStatus
&& providerHasStatus(provider, providerInstance);
if (!options?.force) {
const cachedUsage = await getCachedData(provider, ttlMs, cache);
if (cachedUsage) {
let status = cachedUsage.status;
if (shouldFetchStatus) {
status = await refreshStatusForProvider(deps, settings, provider, { force: options?.forceStatus ?? options?.force });
}
const usage = cachedUsage.usage ? { ...cachedUsage.usage, status } : undefined;
return { usage, status };
}
}
return fetchWithCache(
provider,
ttlMs,
async () => {
const usage = await providerInstance.fetchUsage(deps);
let status = cachedStatus;
let statusFetchedAt = resolveStatusFetchedAt(cachedEntry);
if (shouldFetchStatus) {
status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
statusFetchedAt = Date.now();
} else if (!status) {
status = { indicator: "none" as const };
}
return { usage, status, statusFetchedAt };
},
options,
);
}
export async function getCachedUsageEntry(
provider: ProviderName,
settings: Settings,
cacheSnapshot?: Cache
): Promise<ProviderUsageEntry | undefined> {
const ttlMs = getCacheTtlMs(settings);
const cachedEntry = await getCachedData(provider, ttlMs, cacheSnapshot);
const usage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
return undefined;
}
return { provider, usage };
}
export async function getCachedUsageEntries(
providers: ProviderName[],
settings: Settings
): Promise<ProviderUsageEntry[]> {
const cache = readCache();
const entries: ProviderUsageEntry[] = [];
for (const provider of providers) {
const entry = await getCachedUsageEntry(provider, settings, cache);
if (entry) {
entries.push(entry);
}
}
return entries;
}
export async function fetchUsageEntries(
deps: Dependencies,
settings: Settings,
providers: ProviderName[],
options?: { force?: boolean }
): Promise<ProviderUsageEntry[]> {
const concurrency = Math.max(1, Math.min(PROVIDER_FETCH_CONCURRENCY, providers.length));
const results = await mapWithConcurrency(providers, concurrency, async (provider) => {
const result = await fetchUsageForProvider(deps, settings, provider, options);
const usage = result.usage
? ({ ...result.usage, status: result.status } as UsageSnapshot)
: undefined;
if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
return undefined;
}
return { provider, usage } as ProviderUsageEntry;
});
return results.filter(Boolean) as ProviderUsageEntry[];
}
@@ -0,0 +1,5 @@
/**
* Usage data types shared across modules.
*/
export type { ProviderUsageEntry } from "../../../src/shared.js";
@@ -0,0 +1,122 @@
/**
* Utility functions for the sub-bar extension
*/
import type { Dependencies } from "./types.js";
import { MODEL_MULTIPLIERS } from "./config.js";
// Only allow simple CLI names (no spaces/paths) to avoid unsafe command execution.
const SAFE_CLI_NAME = /^[a-zA-Z0-9._-]+$/;
/**
* Format a reset date as a relative time string
*/
export function formatReset(date: Date): string {
const diffMs = date.getTime() - Date.now();
if (diffMs < 0) return "now";
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
}
/**
* Format elapsed time since a timestamp (milliseconds)
*/
export function formatElapsedSince(timestamp: number): string {
const diffMs = Date.now() - timestamp;
if (diffMs < 60000) return "just now";
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
}
/**
* Strip ANSI escape codes from a string
*/
export function stripAnsi(text: string): string {
return text.replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, "");
}
/**
* Normalize a string into tokens for fuzzy matching
*/
export function normalizeTokens(value: string): string[] {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.split(" ")
.filter(Boolean);
}
// Pre-computed token entries for model multiplier matching
const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
label,
multiplier,
tokens: normalizeTokens(label),
}));
/**
* Get the request multiplier for a model ID
* Uses fuzzy matching against known model names
*/
export function getModelMultiplier(modelId: string | undefined): number | undefined {
if (!modelId) return undefined;
const modelTokens = normalizeTokens(modelId);
if (modelTokens.length === 0) return undefined;
let bestMatch: { multiplier: number; tokenCount: number } | undefined;
for (const entry of MODEL_MULTIPLIER_TOKENS) {
const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
if (!isMatch) continue;
const tokenCount = entry.tokens.length;
if (!bestMatch || tokenCount > bestMatch.tokenCount) {
bestMatch = { multiplier: entry.multiplier, tokenCount };
}
}
return bestMatch?.multiplier;
}
/**
* Check if a command exists in PATH
*/
export function whichSync(cmd: string, deps: Dependencies): string | null {
if (!SAFE_CLI_NAME.test(cmd)) {
return null;
}
try {
return deps.execFileSync("which", [cmd], { encoding: "utf-8" }).trim();
} catch {
return null;
}
}
/**
* Create an abort controller with a timeout
*/
export function createTimeoutController(timeoutMs: number): { controller: AbortController; clear: () => void } {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
clear: () => clearTimeout(timeoutId),
};
}

Some files were not shown because too many files have changed in this diff Show More