Compare commits
48 Commits
2b1edf3806
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 62337a078c | |||
| 8e810418a5 | |||
| a2d283a5c5 | |||
| ca0708a8ee | |||
| 96060c899d | |||
| dc0e75eb46 | |||
| f3d9d42745 | |||
| 191cfbf182 | |||
| 0bfdbd350e | |||
| 9a7669af28 | |||
| 534ec8b99f | |||
| c004356b5a | |||
| 8fa80f58ea | |||
| b42a9ecffa | |||
| 966e40e71b | |||
| 4af7031922 | |||
| 6003f41a12 | |||
| 587c54060b | |||
| cad0540600 | |||
| fd2307eb0c | |||
| 6d525d0971 | |||
| 51073c07a8 | |||
| e4b6fbabc6 | |||
| 85632c2e29 | |||
| 63caa82199 | |||
| 39e7bddb35 | |||
| d5b4042b06 | |||
| 4d19e7d320 | |||
| 227c1638f6 | |||
| db41ec6e93 | |||
| c44420ce7c | |||
| f74242ed02 | |||
| 335b12b0e4 | |||
| 2e820d38e1 | |||
| 008dac69f5 | |||
| d0b1d3be4a | |||
| c0bbff81a3 | |||
| 58dd9d8c2b | |||
| 3d314d944b | |||
| 24bad1f5c4 | |||
| 442284c92f | |||
| 1da5caec0a | |||
| b8b48747b1 | |||
| ae0920a4c1 | |||
| 2bac33b2b6 | |||
| 0f99afae67 | |||
| 32752b42e0 | |||
| 7a9e2b94ff |
@@ -194,6 +194,17 @@
|
||||
"centeringMode": "index",
|
||||
"clockDateFormat": "d MMM yyyy",
|
||||
"lockDateFormat": "",
|
||||
"greeterRememberLastSession": true,
|
||||
"greeterRememberLastUser": true,
|
||||
"greeterEnableFprint": false,
|
||||
"greeterEnableU2f": false,
|
||||
"greeterWallpaperPath": "",
|
||||
"greeterUse24HourClock": true,
|
||||
"greeterShowSeconds": false,
|
||||
"greeterPadHours12Hour": false,
|
||||
"greeterLockDateFormat": "",
|
||||
"greeterFontFamily": "",
|
||||
"greeterWallpaperFillMode": "",
|
||||
"mediaSize": 1,
|
||||
"appLauncherViewMode": "list",
|
||||
"spotlightModalViewMode": "list",
|
||||
@@ -224,8 +235,8 @@
|
||||
"networkPreference": "wifi",
|
||||
"iconTheme": "System Default",
|
||||
"cursorSettings": {
|
||||
"theme": "Qogir",
|
||||
"size": 32,
|
||||
"theme": "Adwaita",
|
||||
"size": 24,
|
||||
"niri": {
|
||||
"hideWhenTyping": false,
|
||||
"hideAfterInactiveMs": 0
|
||||
@@ -314,6 +325,7 @@
|
||||
"matugenTemplateKcolorscheme": true,
|
||||
"matugenTemplateVscode": true,
|
||||
"matugenTemplateEmacs": true,
|
||||
"matugenTemplateZed": true,
|
||||
"showDock": false,
|
||||
"dockAutoHide": false,
|
||||
"dockSmartAutoHide": false,
|
||||
@@ -355,6 +367,8 @@
|
||||
"lockAtStartup": false,
|
||||
"enableFprint": false,
|
||||
"maxFprintTries": 3,
|
||||
"enableU2f": false,
|
||||
"u2fMode": "or",
|
||||
"lockScreenActiveMonitor": "all",
|
||||
"lockScreenInactiveColor": "#000000",
|
||||
"lockScreenNotificationMode": 0,
|
||||
@@ -377,7 +391,7 @@
|
||||
"osdPosition": 5,
|
||||
"osdVolumeEnabled": true,
|
||||
"osdMediaVolumeEnabled": true,
|
||||
"osdMediaPlaybackEnabled": true,
|
||||
"osdMediaPlaybackEnabled": false,
|
||||
"osdBrightnessEnabled": true,
|
||||
"osdIdleInhibitorEnabled": true,
|
||||
"osdMicMuteEnabled": true,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
if test (uname) = Darwin
|
||||
fnm env --use-on-cd --shell fish | source
|
||||
fnm env --use-on-cd --log-level=quiet --shell fish | source
|
||||
end
|
||||
|
||||
+25
-1
@@ -27,6 +27,14 @@ end
|
||||
|
||||
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
|
||||
abbr -a tx 'tmux'
|
||||
abbr -a txa 'tmux attach'
|
||||
@@ -34,6 +42,8 @@ status is-interactive; and begin
|
||||
abbr -a txk 'tmux kill-session -t'
|
||||
abbr -a txks 'tmux kill-server'
|
||||
|
||||
abbr -a zj 'zellij'
|
||||
|
||||
# Aliases
|
||||
alias che chezmoi
|
||||
alias gb 'git branch'
|
||||
@@ -73,6 +83,10 @@ status is-interactive; and begin
|
||||
|
||||
if test "$TERM" != dumb
|
||||
fzf --fish | source
|
||||
bind --erase \ct
|
||||
bind -M insert --erase \ct
|
||||
bind \cf fzf-file-widget
|
||||
bind -M insert \cf fzf-file-widget
|
||||
end
|
||||
|
||||
# add completions generated by Home Manager to $fish_complete_path
|
||||
@@ -88,10 +102,20 @@ status is-interactive; and begin
|
||||
|
||||
end
|
||||
|
||||
# Add user local bin to PATH
|
||||
# PATH ordering on Linux: keep privileged wrapper binaries first (sudo, etc.)
|
||||
if test (uname) = Linux
|
||||
fish_add_path -m /run/wrappers/bin
|
||||
fish_add_path -a -m /run/current-system/sw/bin
|
||||
end
|
||||
|
||||
# Add user local bin to PATH, but keep it after system paths on Linux
|
||||
if test -d "$HOME/.local/bin"
|
||||
if test (uname) = Linux
|
||||
fish_add_path -a -m "$HOME/.local/bin"
|
||||
else
|
||||
fish_add_path "$HOME/.local/bin"
|
||||
end
|
||||
end
|
||||
|
||||
# pnpm
|
||||
switch (uname)
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
function y
|
||||
set tmp (mktemp -t "yazi-cwd.XXXXXX")
|
||||
|
||||
# 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" ]
|
||||
builtin cd -- "$cwd"
|
||||
end
|
||||
|
||||
rm -f -- "$tmp"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
---@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
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -4,3 +4,6 @@ email = "thomasgl@pm.me"
|
||||
|
||||
[git]
|
||||
write-change-id-header = true
|
||||
|
||||
[snapshot]
|
||||
auto-update-stale = true
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
[templates.ghostty]
|
||||
input_path = '~/.config/matugen/templates/ghostty-theme'
|
||||
output_path = '~/.config/ghostty/themes/matugen'
|
||||
post_hook = 'pkill -SIGUSR2 ghostty'
|
||||
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 &"
|
||||
|
||||
[templates.kitty]
|
||||
input_path = '~/.config/matugen/templates/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]
|
||||
input_path = '~/.config/matugen/templates/foot-theme'
|
||||
@@ -24,10 +25,12 @@ output_path = '~/.config/gtk-4.0/colors.css'
|
||||
[templates.fish-prompt]
|
||||
input_path = '~/.config/matugen/templates/fish-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]
|
||||
input_path = '~/.config/matugen/templates/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]
|
||||
input_path = '~/.config/matugen/templates/qtct-colors.conf'
|
||||
@@ -44,28 +47,29 @@ output_path = '~/.config/niri/colors.kdl'
|
||||
[templates.tmux]
|
||||
input_path = '~/.config/matugen/templates/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-tmux-mac.sh >/dev/null 2>&1 &'
|
||||
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 &"
|
||||
|
||||
[templates.zellij]
|
||||
input_path = '~/.config/matugen/templates/zellij-colors.kdl'
|
||||
output_path = '~/.config/zellij/themes/matugen.kdl'
|
||||
post_hook = 'touch ~/.config/zellij/config.kdl && nohup ~/.config/matugen/scripts/sync-zellij-mac.sh >/dev/null 2>&1 &'
|
||||
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 &"
|
||||
|
||||
[templates.jjui]
|
||||
input_path = '~/.config/matugen/templates/jjui-theme.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]
|
||||
input_path = '~/.config/matugen/templates/neovim.lua'
|
||||
output_path = '~/.config/nvim/lua/plugins/dankcolors.lua'
|
||||
post_hook = 'nohup ~/.config/matugen/scripts/sync-nvim-mac.sh >/dev/null 2>&1 &'
|
||||
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 &"
|
||||
|
||||
[templates.pi]
|
||||
input_path = '~/.config/matugen/templates/pi-theme.json'
|
||||
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-pi-mac.sh >/dev/null 2>&1 &'
|
||||
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 &"
|
||||
|
||||
[templates.wallpaper]
|
||||
input_path = '~/.config/matugen/templates/wallpaper-path.txt'
|
||||
output_path = '~/.cache/matugen-last-image'
|
||||
post_hook = 'nohup ~/.config/matugen/scripts/sync-wallpaper-mac.sh >/dev/null 2>&1 &'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh wallpaper ~/.cache/matugen-last-image >/dev/null 2>&1 &"
|
||||
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/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
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -238,7 +238,7 @@ layer-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"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
screenshot_dir="$HOME/Pictures/Screenshots"
|
||||
remote_target="mac-attio:~/screenshot.png"
|
||||
timeout=3 # seconds
|
||||
file_timeout=8 # seconds to wait for screenshot file to appear
|
||||
upload_timeout=10 # seconds
|
||||
|
||||
notify() {
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" \
|
||||
@@ -15,12 +18,13 @@ shopt -s nullglob
|
||||
existing_files=("$screenshot_dir"/*.png)
|
||||
existing_count=${#existing_files[@]}
|
||||
|
||||
# Take screenshot
|
||||
niri msg action screenshot
|
||||
# Take screenshot (no timeout here so interactive capture isn't canceled)
|
||||
niri msg action screenshot >/dev/null 2>&1
|
||||
|
||||
# Wait for new file (timeout in 0.1s intervals)
|
||||
deadline=$((timeout * 10))
|
||||
deadline=$((file_timeout * 10))
|
||||
count=0
|
||||
files=("$screenshot_dir"/*.png)
|
||||
|
||||
while (( count < deadline )); do
|
||||
files=("$screenshot_dir"/*.png)
|
||||
@@ -37,12 +41,20 @@ if (( ${#files[@]} <= existing_count )); then
|
||||
fi
|
||||
|
||||
# Get the new file (most recent)
|
||||
latest_file=$(ls -1t "${files[@]}" | head -n 1)
|
||||
latest_file=$(ls -1t -- "${files[@]}" | head -n 1)
|
||||
|
||||
# Small delay to ensure file is fully written
|
||||
sleep 0.1
|
||||
|
||||
# Upload
|
||||
if scp -q "$latest_file" "$remote_target"; then
|
||||
# Upload with strict SSH options so it never blocks waiting for prompts
|
||||
if timeout "${upload_timeout}s" scp -q \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=5 \
|
||||
-o ConnectionAttempts=1 \
|
||||
-o ServerAliveInterval=2 \
|
||||
-o ServerAliveCountMax=1 \
|
||||
-- "$latest_file" "$remote_target"; then
|
||||
notify "Screenshot" "Uploaded to Mac"
|
||||
else
|
||||
notify "Screenshot" "Upload to Mac failed"
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
return {
|
||||
cmd = { "biome", "lsp-proxy" },
|
||||
cmd = { "npx", "biome", "lsp-proxy" },
|
||||
filetypes = {
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
|
||||
@@ -32,6 +32,23 @@ return {
|
||||
},
|
||||
"folke/lazydev.nvim",
|
||||
},
|
||||
config = function(_, opts)
|
||||
-- Monkey-patch blink's text_edits.get_from_item to clamp textEdit ranges
|
||||
-- that extend past the cursor. Workaround for tsgo sending bad ranges
|
||||
-- that eat text (e.g. in JSX string attributes like className="...").
|
||||
local text_edits = require("blink.cmp.lib.text_edits")
|
||||
local original_get_from_item = text_edits.get_from_item
|
||||
text_edits.get_from_item = function(item)
|
||||
local text_edit = original_get_from_item(item)
|
||||
local cursor_col = require("blink.cmp.completion.trigger.context").get_cursor()[2]
|
||||
if text_edit.range and text_edit.range["end"].character > cursor_col then
|
||||
text_edit.range["end"].character = cursor_col
|
||||
end
|
||||
return text_edit
|
||||
end
|
||||
|
||||
require("blink.cmp").setup(opts)
|
||||
end,
|
||||
--- @module 'blink.cmp'
|
||||
--- @type blink.cmp.Config
|
||||
opts = {
|
||||
@@ -76,9 +93,16 @@ return {
|
||||
},
|
||||
|
||||
sources = {
|
||||
default = { "lsp", "path", "snippets", "lazydev" },
|
||||
default = { "lsp", "path", "snippets", "lazydev", "minuet" },
|
||||
providers = {
|
||||
lazydev = { module = "lazydev.integrations.blink", score_offset = 100 },
|
||||
minuet = {
|
||||
name = "minuet",
|
||||
module = "minuet.blink",
|
||||
async = true,
|
||||
timeout_ms = 3000,
|
||||
score_offset = 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
return {
|
||||
"HotThoughts/jjui.nvim",
|
||||
enabled = false,
|
||||
cmd = {
|
||||
"JJUI",
|
||||
"JJUICurrentFile",
|
||||
|
||||
@@ -6,12 +6,12 @@ return {
|
||||
-- Allows extra capabilities provided by blink.cmp
|
||||
{
|
||||
"saghen/blink.cmp",
|
||||
config = function(_, opts)
|
||||
require("blink.cmp").setup(opts)
|
||||
opts = function(_, opts)
|
||||
-- Add blink.cmp capabilities to the default LSP client capabilities
|
||||
vim.lsp.config("*", {
|
||||
capabilities = require("blink.cmp").get_lsp_capabilities(),
|
||||
})
|
||||
return opts
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -119,7 +119,9 @@ return {
|
||||
end, "[T]oggle Inlay [H]ints")
|
||||
end
|
||||
|
||||
-- Svelte-specific: notify on TS/JS file changes
|
||||
-- Svelte-specific: keep treesitter highlighting in control
|
||||
-- (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
|
||||
if client and client.name == "svelte" then
|
||||
vim.api.nvim_create_autocmd("BufWritePost", {
|
||||
|
||||
@@ -28,34 +28,16 @@ return {
|
||||
end,
|
||||
},
|
||||
{ "nvim-lua/plenary.nvim" },
|
||||
-- optional, if you are using virtual-text frontend, blink is not required.
|
||||
-- Minuet blink.cmp integration (merged into main blink.lua spec via opts)
|
||||
{
|
||||
"Saghen/blink.cmp",
|
||||
config = function()
|
||||
require("blink-cmp").setup({
|
||||
keymap = {
|
||||
-- Manually invoke minuet completion.
|
||||
["<A-y>"] = require("minuet").make_blink_map(),
|
||||
},
|
||||
sources = {
|
||||
-- Enable minuet for autocomplete
|
||||
default = { "lsp", "path", "buffer", "snippets", "minuet" },
|
||||
-- For manual completion only, remove 'minuet' from default
|
||||
providers = {
|
||||
minuet = {
|
||||
name = "minuet",
|
||||
module = "minuet.blink",
|
||||
async = true,
|
||||
-- Should match minuet.config.request_timeout * 1000,
|
||||
-- since minuet.config.request_timeout is in seconds
|
||||
timeout_ms = 3000,
|
||||
score_offset = 50, -- Gives minuet higher priority among suggestions
|
||||
},
|
||||
},
|
||||
},
|
||||
-- Recommended to avoid unnecessary request
|
||||
completion = { trigger = { prefetch_on_insert = false } },
|
||||
})
|
||||
"saghen/blink.cmp",
|
||||
opts = function(_, opts)
|
||||
opts.keymap = opts.keymap or {}
|
||||
opts.keymap["<A-y>"] = require("minuet").make_blink_map()
|
||||
opts.completion = opts.completion or {}
|
||||
opts.completion.trigger = opts.completion.trigger or {}
|
||||
opts.completion.trigger.prefetch_on_insert = false
|
||||
return opts
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,27 +51,29 @@ return {
|
||||
-- end,
|
||||
-- desc = "Git Blame Line",
|
||||
-- },
|
||||
{
|
||||
"<leader>gf",
|
||||
function()
|
||||
Snacks.lazygit.log_file()
|
||||
end,
|
||||
desc = "Lazygit Current File History",
|
||||
},
|
||||
{
|
||||
"<leader>lg",
|
||||
function()
|
||||
Snacks.lazygit()
|
||||
end,
|
||||
desc = "Lazygit",
|
||||
},
|
||||
{
|
||||
"<leader>gl",
|
||||
function()
|
||||
Snacks.lazygit.log()
|
||||
end,
|
||||
desc = "Lazygit Log (cwd)",
|
||||
},
|
||||
--
|
||||
-- Commented out LazyGit in favor of separated jj
|
||||
-- {
|
||||
-- "<leader>gf",
|
||||
-- function()
|
||||
-- Snacks.lazygit.log_file()
|
||||
-- end,
|
||||
-- desc = "Lazygit Current File History",
|
||||
-- },
|
||||
-- {
|
||||
-- "<leader>lg",
|
||||
-- function()
|
||||
-- Snacks.lazygit()
|
||||
-- end,
|
||||
-- desc = "Lazygit",
|
||||
-- },
|
||||
-- {
|
||||
-- "<leader>gl",
|
||||
-- function()
|
||||
-- Snacks.lazygit.log()
|
||||
-- end,
|
||||
-- desc = "Lazygit Log (cwd)",
|
||||
-- },
|
||||
{
|
||||
"<leader>dn",
|
||||
function()
|
||||
@@ -216,12 +218,12 @@ return {
|
||||
},
|
||||
-- git
|
||||
{
|
||||
"<leader>gcb",
|
||||
"<leader>jc",
|
||||
function()
|
||||
local cwd = vim.fn.getcwd()
|
||||
|
||||
-- Helper to run git commands and capture both stdout and stderr
|
||||
local function git_cmd(cmd)
|
||||
-- Helper to run commands and capture both stdout and stderr
|
||||
local function run_cmd(cmd)
|
||||
local full_cmd = "cd " .. vim.fn.shellescape(cwd) .. " && " .. cmd .. " 2>&1"
|
||||
local handle = io.popen(full_cmd)
|
||||
local result = handle and handle:read("*a") or ""
|
||||
@@ -232,7 +234,7 @@ return {
|
||||
end
|
||||
|
||||
-- Check if in a git repo
|
||||
local git_dir = git_cmd("git rev-parse --git-dir")
|
||||
local git_dir = run_cmd("git rev-parse --git-dir")
|
||||
if git_dir == "" or git_dir:match("^fatal") then
|
||||
vim.notify("Not in a git repository", vim.log.levels.WARN)
|
||||
return
|
||||
@@ -240,7 +242,7 @@ return {
|
||||
|
||||
-- Get the default branch
|
||||
local function branch_exists(branch)
|
||||
local result = git_cmd("git rev-parse --verify refs/remotes/origin/" .. branch)
|
||||
local result = run_cmd("git rev-parse --verify refs/remotes/origin/" .. branch)
|
||||
-- If branch exists, rev-parse returns a hash; if not, it returns fatal error
|
||||
return not result:match("^fatal")
|
||||
end
|
||||
@@ -257,19 +259,9 @@ return {
|
||||
return
|
||||
end
|
||||
|
||||
-- Get current branch
|
||||
local current_branch = git_cmd("git branch --show-current")
|
||||
if current_branch == "" then
|
||||
current_branch = "HEAD"
|
||||
end
|
||||
|
||||
local compare_target = "origin/" .. default_branch
|
||||
|
||||
-- Get files that differ from origin/main (includes committed + uncommitted changes)
|
||||
local result = git_cmd("git diff --name-only " .. compare_target)
|
||||
|
||||
-- Also get untracked files
|
||||
local untracked = git_cmd("git ls-files --others --exclude-standard")
|
||||
local result = run_cmd("jj diff --from " .. default_branch .. "@origin --to @ --summary | awk '{print $2}'")
|
||||
|
||||
-- Combine results
|
||||
local all_files = {}
|
||||
@@ -282,20 +274,13 @@ return {
|
||||
end
|
||||
end
|
||||
|
||||
for line in untracked:gmatch("[^\r\n]+") do
|
||||
if line ~= "" and not seen[line] then
|
||||
seen[line] = true
|
||||
table.insert(all_files, { text = line .. " [untracked]", file = line })
|
||||
end
|
||||
end
|
||||
|
||||
if #all_files == 0 then
|
||||
vim.notify("No modified files (vs " .. compare_target .. ")", vim.log.levels.INFO)
|
||||
vim.notify("No modified files", vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
Snacks.picker({
|
||||
title = "Modified Files (vs " .. compare_target .. ")",
|
||||
title = "Modified Files",
|
||||
items = all_files,
|
||||
layout = { preset = "default" },
|
||||
confirm = function(picker, item)
|
||||
|
||||
@@ -4,12 +4,13 @@ return {
|
||||
lazy = false,
|
||||
build = ":TSUpdate",
|
||||
config = function()
|
||||
require("nvim-treesitter").setup({
|
||||
local ts = require("nvim-treesitter")
|
||||
ts.setup({
|
||||
install_dir = vim.fn.stdpath("data") .. "/site",
|
||||
})
|
||||
|
||||
-- Install parsers (async, no-op if already installed)
|
||||
require("nvim-treesitter").install({
|
||||
ts.install({
|
||||
"vimdoc",
|
||||
"javascript",
|
||||
"typescript",
|
||||
@@ -28,6 +29,75 @@ return {
|
||||
"nix",
|
||||
})
|
||||
|
||||
-- Enable treesitter highlighting - indentexpr set automatically per filetype
|
||||
vim.api.nvim_create_autocmd("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,
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ vim.o.confirm = true
|
||||
|
||||
-- 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
|
||||
vim.api.nvim_create_autocmd("TextYankPost", {
|
||||
callback = function()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"activePack": "glados",
|
||||
"volume": 1,
|
||||
"muted": true,
|
||||
"enabledCategories": {
|
||||
"session.start": true,
|
||||
"task.acknowledge": true,
|
||||
"task.complete": true,
|
||||
"task.error": false,
|
||||
"input.required": true,
|
||||
"resource.limit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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,11 +21,6 @@
|
||||
"file": "sounds/IKnowYoureThere.mp3",
|
||||
"label": "I know you're there. I can feel you here.",
|
||||
"sha256": "df3780607b7a480fd3968c8aae5e0a397ea956008a5c7a47fecb887a05d61622"
|
||||
},
|
||||
{
|
||||
"file": "sounds/HelloImbecile.mp3",
|
||||
"label": "Hello, imbecile!",
|
||||
"sha256": "dd10461e79bb4b1319f436cef5f0541f18a9505638824a6e765b9f2824a3380f"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.57.1",
|
||||
"defaultProvider": "anthropic",
|
||||
"defaultModel": "claude-opus-4-6",
|
||||
"lastChangelogVersion": "0.67.3",
|
||||
"defaultProvider": "openai-codex",
|
||||
"defaultModel": "gpt-5.3-codex",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"theme": "matugen",
|
||||
"lsp": {
|
||||
"hookMode": "edit_write"
|
||||
},
|
||||
"hideThinkingBlock": false
|
||||
"hideThinkingBlock": false,
|
||||
"slowtool": {
|
||||
"timeoutSeconds": 300,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.57.1",
|
||||
"lastChangelogVersion": "0.67.68",
|
||||
"defaultProvider": "anthropic",
|
||||
"defaultModel": "claude-opus-4-6",
|
||||
"defaultModel": "claude-opus-4-7",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"theme": "matugen",
|
||||
"lsp": {
|
||||
"hookMode": "edit_write"
|
||||
},
|
||||
"hideThinkingBlock": false
|
||||
"hideThinkingBlock": true,
|
||||
"slowtool": {
|
||||
"timeoutSeconds": 300,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
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,12 +1,28 @@
|
||||
# Tool usage
|
||||
|
||||
FUCKING ALWAYS use timeout on tool usage because sometimes you're stupid, and hang on things because you assume its non interactive. And by that I don't mean appending `timeout` to bash or something, but you have a way to add a timeout to tool calling somehow. I don't know the inner workings of the harness.
|
||||
|
||||
# Validations
|
||||
|
||||
Sometimes some repositories (stupidly) ask you to run validations after changes or some shit. Thing is, you're smart. Your edit tools already contain formatting and LSP hooks. So, you may ask the user if they want you to run said "required" validations, but they're not really required.
|
||||
|
||||
---
|
||||
|
||||
# Screenshots
|
||||
|
||||
When the user provides a screenshot path (e.g., `/tmp/pi-clipboard-xxx.png`), **ALWAYS** use the `read` tool to read the image file. Do NOT assume you can see the screenshot contents without reading it first.
|
||||
|
||||
---
|
||||
|
||||
# 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. Don't squash unnecessarily! seriously don't squash all the time.
|
||||
|
||||
# Git commits and PRs
|
||||
|
||||
Before writing any commits or PR titles, check recent git history with `git log --oneline -20` to match my style.
|
||||
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.
|
||||
|
||||
My commit style:
|
||||
|
||||
@@ -61,6 +77,27 @@ npx mcporter emit-ts <server> --mode types
|
||||
|
||||
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
|
||||
|
||||
Only use these servers and read about them when applicable.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const TARGET = "about pi itself, its SDK, extensions, themes, skills, or TUI";
|
||||
const REPLACEMENT = "about pi, its SDK, extensions, themes, skills, or TUI";
|
||||
|
||||
export default function(pi: ExtensionAPI) {
|
||||
pi.on("before_agent_start", (event, ctx) => {
|
||||
if (ctx.model?.provider !== "anthropic") return;
|
||||
if (!event.systemPrompt.includes(TARGET)) return;
|
||||
return {
|
||||
systemPrompt: event.systemPrompt.replace(TARGET, REPLACEMENT),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@@ -1,604 +0,0 @@
|
||||
/**
|
||||
* Custom Provider Example
|
||||
*
|
||||
* Demonstrates registering a custom provider with:
|
||||
* - Custom API identifier ("custom-anthropic-api")
|
||||
* - Custom streamSimple implementation
|
||||
* - OAuth support for /login
|
||||
* - API key support via environment variable
|
||||
* - Two model definitions
|
||||
*
|
||||
* Usage:
|
||||
* # First install dependencies
|
||||
* cd packages/coding-agent/examples/extensions/custom-provider && npm install
|
||||
*
|
||||
* # With OAuth (run /login custom-anthropic first)
|
||||
* pi -e ./packages/coding-agent/examples/extensions/custom-provider
|
||||
*
|
||||
* # With API key
|
||||
* CUSTOM_ANTHROPIC_API_KEY=sk-ant-... pi -e ./packages/coding-agent/examples/extensions/custom-provider
|
||||
*
|
||||
* Then use /model to select custom-anthropic/claude-sonnet-4-5
|
||||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { ContentBlockParam, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages.js";
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEventStream,
|
||||
type Context,
|
||||
calculateCost,
|
||||
createAssistantMessageEventStream,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
type Model,
|
||||
type OAuthCredentials,
|
||||
type OAuthLoginCallbacks,
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
type Tool,
|
||||
type ToolCall,
|
||||
type ToolResultMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// =============================================================================
|
||||
// OAuth Implementation (copied from packages/ai/src/utils/oauth/anthropic.ts)
|
||||
// =============================================================================
|
||||
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
|
||||
const SCOPES = "org:create_api_key user:profile user:inference";
|
||||
|
||||
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
const verifier = btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
async function loginAnthropic(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
});
|
||||
|
||||
callbacks.onAuth({ url: `${AUTHORIZE_URL}?${authParams.toString()}` });
|
||||
|
||||
const authCode = await callbacks.onPrompt({ message: "Paste the authorization code:" });
|
||||
const [code, state] = authCode.split("#");
|
||||
|
||||
const tokenResponse = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
||||
}
|
||||
|
||||
const data = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAnthropicToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: credentials.refresh,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Streaming Implementation (simplified from packages/ai/src/providers/anthropic.ts)
|
||||
// =============================================================================
|
||||
|
||||
// Claude Code tool names for OAuth stealth mode
|
||||
const claudeCodeTools = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"Grep",
|
||||
"Glob",
|
||||
"AskUserQuestion",
|
||||
"TodoWrite",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
];
|
||||
const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t]));
|
||||
const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name;
|
||||
const fromClaudeCodeName = (name: string, tools?: Tool[]) => {
|
||||
const lowerName = name.toLowerCase();
|
||||
const matched = tools?.find((t) => t.name.toLowerCase() === lowerName);
|
||||
return matched?.name ?? name;
|
||||
};
|
||||
|
||||
function isOAuthToken(apiKey: string): boolean {
|
||||
return apiKey.includes("sk-ant-oat");
|
||||
}
|
||||
|
||||
function sanitizeSurrogates(text: string): string {
|
||||
return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
|
||||
}
|
||||
|
||||
function convertContentBlocks(
|
||||
content: (TextContent | ImageContent)[],
|
||||
): string | Array<{ type: "text"; text: string } | { type: "image"; source: any }> {
|
||||
const hasImages = content.some((c) => c.type === "image");
|
||||
if (!hasImages) {
|
||||
return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n"));
|
||||
}
|
||||
|
||||
const blocks = content.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return { type: "text" as const, text: sanitizeSurrogates(block.text) };
|
||||
}
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: block.mimeType,
|
||||
data: block.data,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!blocks.some((b) => b.type === "text")) {
|
||||
blocks.unshift({ type: "text" as const, text: "(see attached image)" });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function convertMessages(messages: Message[], isOAuth: boolean, _tools?: Tool[]): any[] {
|
||||
const params: any[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.content.trim()) {
|
||||
params.push({ role: "user", content: sanitizeSurrogates(msg.content) });
|
||||
}
|
||||
} else {
|
||||
const blocks: ContentBlockParam[] = msg.content.map((item) =>
|
||||
item.type === "text"
|
||||
? { type: "text" as const, text: sanitizeSurrogates(item.text) }
|
||||
: {
|
||||
type: "image" as const,
|
||||
source: { type: "base64" as const, media_type: item.mimeType as any, data: item.data },
|
||||
},
|
||||
);
|
||||
if (blocks.length > 0) {
|
||||
params.push({ role: "user", content: blocks });
|
||||
}
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
const blocks: ContentBlockParam[] = [];
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text" && block.text.trim()) {
|
||||
blocks.push({ type: "text", text: sanitizeSurrogates(block.text) });
|
||||
} else if (block.type === "thinking" && block.thinking.trim()) {
|
||||
if ((block as ThinkingContent).thinkingSignature) {
|
||||
blocks.push({
|
||||
type: "thinking" as any,
|
||||
thinking: sanitizeSurrogates(block.thinking),
|
||||
signature: (block as ThinkingContent).thinkingSignature!,
|
||||
});
|
||||
} else {
|
||||
blocks.push({ type: "text", text: sanitizeSurrogates(block.thinking) });
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: isOAuth ? toClaudeCodeName(block.name) : block.name,
|
||||
input: block.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blocks.length > 0) {
|
||||
params.push({ role: "assistant", content: blocks });
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
const toolResults: any[] = [];
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: msg.toolCallId,
|
||||
content: convertContentBlocks(msg.content),
|
||||
is_error: msg.isError,
|
||||
});
|
||||
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].role === "toolResult") {
|
||||
const nextMsg = messages[j] as ToolResultMessage;
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: nextMsg.toolCallId,
|
||||
content: convertContentBlocks(nextMsg.content),
|
||||
is_error: nextMsg.isError,
|
||||
});
|
||||
j++;
|
||||
}
|
||||
i = j - 1;
|
||||
params.push({ role: "user", content: toolResults });
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache control to last user message
|
||||
if (params.length > 0) {
|
||||
const last = params[params.length - 1];
|
||||
if (last.role === "user" && Array.isArray(last.content)) {
|
||||
const lastBlock = last.content[last.content.length - 1];
|
||||
if (lastBlock) {
|
||||
lastBlock.cache_control = { type: "ephemeral" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function convertTools(tools: Tool[], isOAuth: boolean): any[] {
|
||||
return tools.map((tool) => ({
|
||||
name: isOAuth ? toClaudeCodeName(tool.name) : tool.name,
|
||||
description: tool.description,
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: (tool.parameters as any).properties || {},
|
||||
required: (tool.parameters as any).required || [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function mapStopReason(reason: string): StopReason {
|
||||
switch (reason) {
|
||||
case "end_turn":
|
||||
case "pause_turn":
|
||||
case "stop_sequence":
|
||||
return "stop";
|
||||
case "max_tokens":
|
||||
return "length";
|
||||
case "tool_use":
|
||||
return "toolUse";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function streamCustomAnthropic(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
const apiKey = options?.apiKey ?? "";
|
||||
const isOAuth = isOAuthToken(apiKey);
|
||||
|
||||
// Configure client based on auth type
|
||||
const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14"];
|
||||
const clientOptions: any = {
|
||||
baseURL: model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
};
|
||||
|
||||
if (isOAuth) {
|
||||
clientOptions.apiKey = null;
|
||||
clientOptions.authToken = apiKey;
|
||||
clientOptions.defaultHeaders = {
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`,
|
||||
"user-agent": "claude-cli/2.1.2 (external, cli)",
|
||||
"x-app": "cli",
|
||||
};
|
||||
} else {
|
||||
clientOptions.apiKey = apiKey;
|
||||
clientOptions.defaultHeaders = {
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-beta": betaFeatures.join(","),
|
||||
};
|
||||
}
|
||||
|
||||
const client = new Anthropic(clientOptions);
|
||||
|
||||
// Build request params
|
||||
const params: MessageCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
messages: convertMessages(context.messages, isOAuth, context.tools),
|
||||
max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3),
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// System prompt with Claude Code identity for OAuth
|
||||
if (isOAuth) {
|
||||
params.system = [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
];
|
||||
if (context.systemPrompt) {
|
||||
params.system.push({
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
cache_control: { type: "ephemeral" },
|
||||
});
|
||||
}
|
||||
} else if (context.systemPrompt) {
|
||||
params.system = [
|
||||
{
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (context.tools) {
|
||||
params.tools = convertTools(context.tools, isOAuth);
|
||||
}
|
||||
|
||||
// Handle thinking/reasoning
|
||||
if (options?.reasoning && model.reasoning) {
|
||||
const defaultBudgets: Record<string, number> = {
|
||||
minimal: 1024,
|
||||
low: 4096,
|
||||
medium: 10240,
|
||||
high: 20480,
|
||||
};
|
||||
const customBudget = options.thinkingBudgets?.[options.reasoning as keyof typeof options.thinkingBudgets];
|
||||
params.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: customBudget ?? defaultBudgets[options.reasoning] ?? 10240,
|
||||
};
|
||||
}
|
||||
|
||||
const anthropicStream = client.messages.stream({ ...params }, { signal: options?.signal });
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };
|
||||
const blocks = output.content as Block[];
|
||||
|
||||
for await (const event of anthropicStream) {
|
||||
if (event.type === "message_start") {
|
||||
output.usage.input = event.message.usage.input_tokens || 0;
|
||||
output.usage.output = event.message.usage.output_tokens || 0;
|
||||
output.usage.cacheRead = (event.message.usage as any).cache_read_input_tokens || 0;
|
||||
output.usage.cacheWrite = (event.message.usage as any).cache_creation_input_tokens || 0;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
} else if (event.type === "content_block_start") {
|
||||
if (event.content_block.type === "text") {
|
||||
output.content.push({ type: "text", text: "", index: event.index } as any);
|
||||
stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
|
||||
} else if (event.content_block.type === "thinking") {
|
||||
output.content.push({
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: "",
|
||||
index: event.index,
|
||||
} as any);
|
||||
stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });
|
||||
} else if (event.content_block.type === "tool_use") {
|
||||
output.content.push({
|
||||
type: "toolCall",
|
||||
id: event.content_block.id,
|
||||
name: isOAuth
|
||||
? fromClaudeCodeName(event.content_block.name, context.tools)
|
||||
: event.content_block.name,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
index: event.index,
|
||||
} as any);
|
||||
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
||||
}
|
||||
} else if (event.type === "content_block_delta") {
|
||||
const index = blocks.findIndex((b) => b.index === event.index);
|
||||
const block = blocks[index];
|
||||
if (!block) continue;
|
||||
|
||||
if (event.delta.type === "text_delta" && block.type === "text") {
|
||||
block.text += event.delta.text;
|
||||
stream.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: output });
|
||||
} else if (event.delta.type === "thinking_delta" && block.type === "thinking") {
|
||||
block.thinking += event.delta.thinking;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: index,
|
||||
delta: event.delta.thinking,
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.delta.type === "input_json_delta" && block.type === "toolCall") {
|
||||
(block as any).partialJson += event.delta.partial_json;
|
||||
try {
|
||||
block.arguments = JSON.parse((block as any).partialJson);
|
||||
} catch {}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: index,
|
||||
delta: event.delta.partial_json,
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.delta.type === "signature_delta" && block.type === "thinking") {
|
||||
block.thinkingSignature = (block.thinkingSignature || "") + (event.delta as any).signature;
|
||||
}
|
||||
} else if (event.type === "content_block_stop") {
|
||||
const index = blocks.findIndex((b) => b.index === event.index);
|
||||
const block = blocks[index];
|
||||
if (!block) continue;
|
||||
|
||||
delete (block as any).index;
|
||||
if (block.type === "text") {
|
||||
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
|
||||
} else if (block.type === "thinking") {
|
||||
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
||||
} else if (block.type === "toolCall") {
|
||||
try {
|
||||
block.arguments = JSON.parse((block as any).partialJson);
|
||||
} catch {}
|
||||
delete (block as any).partialJson;
|
||||
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
||||
}
|
||||
} else if (event.type === "message_delta") {
|
||||
if ((event.delta as any).stop_reason) {
|
||||
output.stopReason = mapStopReason((event.delta as any).stop_reason);
|
||||
}
|
||||
output.usage.input = (event.usage as any).input_tokens || 0;
|
||||
output.usage.output = (event.usage as any).output_tokens || 0;
|
||||
output.usage.cacheRead = (event.usage as any).cache_read_input_tokens || 0;
|
||||
output.usage.cacheWrite = (event.usage as any).cache_creation_input_tokens || 0;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) delete (block as any).index;
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Extension Entry Point
|
||||
// =============================================================================
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerProvider("custom-anthropic", {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "CUSTOM_ANTHROPIC_API_KEY",
|
||||
api: "custom-anthropic-api",
|
||||
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5 (Custom)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5 (Custom)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
],
|
||||
|
||||
oauth: {
|
||||
name: "Custom Anthropic (Claude Pro/Max)",
|
||||
login: loginAnthropic,
|
||||
refreshToken: refreshAnthropicToken,
|
||||
getApiKey: (cred) => cred.access,
|
||||
},
|
||||
|
||||
streamSimple: streamCustomAnthropic,
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { TUI, Theme, KeybindingsManager, Component } from "@mariozechner/pi-tui";
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { TUI, KeybindingsManager, Component } from "@mariozechner/pi-tui";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
done();
|
||||
done(undefined);
|
||||
|
||||
// Return dummy component
|
||||
return createDummyComponent();
|
||||
@@ -69,7 +69,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
|
||||
await ctx.ui.custom<void>(factory);
|
||||
|
||||
// Signal that we're about to reload the session (so confirm-destructive skips)
|
||||
pi.events.emit("edit-session:reload");
|
||||
pi.events.emit("edit-session:reload", undefined);
|
||||
|
||||
// Reload the session by switching to the same file (forces re-read from disk)
|
||||
ctx.ui.notify("Reloading session...", "info");
|
||||
|
||||
@@ -80,7 +80,8 @@ export default function (pi: ExtensionAPI) {
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
const doGenerate = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
|
||||
if (!auth.ok) throw new Error(auth.error);
|
||||
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
@@ -96,7 +97,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"@mariozechner/jiti": "^2.6.5",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"linkedom": "^0.16.0",
|
||||
@@ -13,10 +14,11 @@
|
||||
"vscode-languageserver-protocol": "^3.17.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-ai": "^0.56.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.56.3",
|
||||
"@mariozechner/pi-tui": "^0.56.3",
|
||||
"@mariozechner/pi-ai": "^0.63.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.63.1",
|
||||
"@mariozechner/pi-tui": "^0.63.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"pi": {},
|
||||
|
||||
@@ -308,9 +308,24 @@ function pickSound(categoryConfig: CategoryConfig, category: Category): Sound |
|
||||
|
||||
// ============ SOUND PLAYBACK ============
|
||||
function play(category: Category): void {
|
||||
if (config.muted) return;
|
||||
if (!config.enabledCategories[category]) return;
|
||||
|
||||
const notificationMessages: Record<Category, { title: string; message: string } | null> = {
|
||||
"session.start": null,
|
||||
"task.acknowledge": null,
|
||||
"task.complete": { title: "Pi", message: "Task complete" },
|
||||
"task.error": { title: "Pi", message: "Task failed" },
|
||||
"input.required": { title: "Pi", message: "Input required" },
|
||||
"resource.limit": { title: "Pi", message: "Rate limited" },
|
||||
};
|
||||
|
||||
const notification = notificationMessages[category];
|
||||
if (notification) {
|
||||
sendNotification(notification.title, notification.message);
|
||||
}
|
||||
|
||||
if (config.muted) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPlayed < DEBOUNCE_MS) {
|
||||
return;
|
||||
@@ -345,20 +360,6 @@ function play(category: Category): void {
|
||||
}
|
||||
|
||||
playSound(soundPath, config.volume);
|
||||
|
||||
const notificationMessages: Record<Category, { title: string; message: string } | null> = {
|
||||
"session.start": null,
|
||||
"task.acknowledge": null,
|
||||
"task.complete": { title: "Pi", message: "Task complete" },
|
||||
"task.error": { title: "Pi", message: "Task failed" },
|
||||
"input.required": { title: "Pi", message: "Input required" },
|
||||
"resource.limit": { title: "Pi", message: "Rate limited" },
|
||||
};
|
||||
|
||||
const notification = notificationMessages[category];
|
||||
if (notification) {
|
||||
sendNotification(notification.title, notification.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ COMMANDS ============
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ben Vargas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,115 @@
|
||||
# @benvargas/pi-claude-code-use
|
||||
|
||||
`pi-claude-code-use` keeps Pi's built-in `anthropic` provider intact and applies the smallest payload changes needed for Anthropic OAuth subscription use in Pi.
|
||||
|
||||
It does not register a new provider or replace Pi's Anthropic request transport. Pi core remains in charge of OAuth transport, headers, model definitions, and streaming.
|
||||
|
||||
## What It Changes
|
||||
|
||||
When Pi is using Anthropic OAuth, this extension intercepts outbound API requests via the `before_provider_request` hook and:
|
||||
|
||||
- **System prompt rewrite** -- rewrites a small set of Pi-identifying prompt phrases in system prompt text:
|
||||
- `pi itself` → `the cli itself`
|
||||
- `pi .md files` → `cli .md files`
|
||||
- `pi packages` → `cli packages`
|
||||
Preserves Pi's original `system[]` structure, `cache_control` metadata, and non-text blocks.
|
||||
- **Tool filtering** -- passes through core Claude Code tools, Anthropic-native typed tools (e.g. `web_search`), and any tool prefixed with `mcp__`. Unknown flat-named tools are filtered out.
|
||||
- **Companion tool remapping** -- renames known companion extension tools from their flat names to MCP-style aliases (e.g. `web_search_exa` becomes `mcp__exa__web_search`). Duplicate flat entries are removed after remapping.
|
||||
- **tool_choice remapping** -- if `tool_choice` references a flat companion name that was remapped, the reference is updated to the MCP alias. If it references a tool that was filtered out, `tool_choice` is removed from the payload.
|
||||
- **Message history rewriting** -- `tool_use` blocks in conversation history that reference flat companion names are rewritten to their MCP aliases so the model sees consistent tool names across the conversation.
|
||||
- **Companion alias registration** -- at session start and before each agent turn, discovers loaded companion extensions, captures their tool definitions via a jiti-based shim, and registers MCP-alias copies so the model can invoke them under Claude Code-compatible names.
|
||||
- **Alias activation tracking** -- auto-activates MCP aliases when their flat counterpart is active under Anthropic OAuth. Tracks provenance (auto-managed vs user-selected) so that disabling OAuth only removes auto-activated aliases, preserving any the user explicitly enabled.
|
||||
|
||||
Non-OAuth Anthropic usage and non-Anthropic providers are left completely unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pi install npm:@benvargas/pi-claude-code-use
|
||||
```
|
||||
|
||||
Or load it directly without installing:
|
||||
|
||||
```bash
|
||||
pi -e /path/to/pi-packages/packages/pi-claude-code-use/extensions/index.ts
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Install the package and continue using the normal `anthropic` provider with Anthropic OAuth login:
|
||||
|
||||
```bash
|
||||
/login anthropic
|
||||
/model anthropic/claude-opus-4-6
|
||||
```
|
||||
|
||||
No extra configuration is required.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `PI_CLAUDE_CODE_USE_DEBUG_LOG` | Set to a file path to enable debug logging. Writes two JSON entries per Anthropic OAuth request: one with `"stage": "before"` (the original payload from Pi) and one with `"stage": "after"` (the transformed payload sent to Anthropic). |
|
||||
| `PI_CLAUDE_CODE_USE_DISABLE_TOOL_FILTER` | Set to `1` to disable tool filtering. System prompt rewriting still applies, but all tools pass through unchanged. Useful for debugging whether a tool-filtering issue is causing a problem. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
PI_CLAUDE_CODE_USE_DEBUG_LOG=/tmp/pi-claude-debug.log pi -e /path/to/extensions/index.ts --model anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
## Companion Tool Aliases
|
||||
|
||||
When these companion extensions from this monorepo are loaded alongside `pi-claude-code-use`, MCP aliases are automatically registered and remapped:
|
||||
|
||||
| Flat name | MCP alias |
|
||||
|---|---|
|
||||
| `web_search_exa` | `mcp__exa__web_search` |
|
||||
| `get_code_context_exa` | `mcp__exa__get_code_context` |
|
||||
| `firecrawl_scrape` | `mcp__firecrawl__scrape` |
|
||||
| `firecrawl_map` | `mcp__firecrawl__map` |
|
||||
| `firecrawl_search` | `mcp__firecrawl__search` |
|
||||
| `generate_image` | `mcp__antigravity__generate_image` |
|
||||
| `image_quota` | `mcp__antigravity__image_quota` |
|
||||
|
||||
### How companion discovery works
|
||||
|
||||
The extension identifies companion tools by matching `sourceInfo` metadata that Pi attaches to each registered tool:
|
||||
|
||||
1. **baseDir match** -- if the tool's `sourceInfo.baseDir` directory name matches the companion's directory name (e.g. `pi-exa-mcp`).
|
||||
2. **Path match** -- if the tool's `sourceInfo.path` contains the companion's scoped package name (e.g. `@benvargas/pi-exa-mcp`) or directory name as a path segment. This handles npm installs, git clones, and monorepo layouts where `baseDir` points to the repo root rather than the individual package.
|
||||
|
||||
Once a companion tool is identified, its extension factory is loaded via jiti into a capture shim to obtain the full tool definition, which is then re-registered under the MCP alias name.
|
||||
|
||||
## Core Tools Allowlist
|
||||
|
||||
The following tool names always pass through filtering (case-insensitive). This list mirrors Pi core's `claudeCodeTools` in `packages/ai/src/providers/anthropic.ts`:
|
||||
|
||||
`Read`, `Write`, `Edit`, `Bash`, `Grep`, `Glob`, `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `KillShell`, `NotebookEdit`, `Skill`, `Task`, `TaskOutput`, `TodoWrite`, `WebFetch`, `WebSearch`
|
||||
|
||||
Additionally, any tool with a `type` field (Anthropic-native tools like `web_search`) and any tool prefixed with `mcp__` always passes through.
|
||||
|
||||
## Guidance For Extension Authors
|
||||
|
||||
Anthropic's OAuth subscription path appears to fingerprint tool names. Flat extension tool names such as `web_search_exa` were rejected in live testing, while MCP-style names such as `mcp__exa__web_search` were accepted.
|
||||
|
||||
If you want a custom tool to survive Anthropic OAuth filtering cleanly, prefer registering it directly under an MCP-style name:
|
||||
|
||||
```text
|
||||
mcp__<server>__<tool>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `mcp__exa__web_search`
|
||||
- `mcp__firecrawl__scrape`
|
||||
- `mcp__mytools__lookup_customer`
|
||||
|
||||
If an extension keeps a flat legacy name for non-Anthropic use, it can also register an MCP-style alias alongside it. `pi-claude-code-use` already does this centrally for the known companion tools in this repo, but unknown non-MCP tool names will still be filtered out on Anthropic OAuth requests.
|
||||
|
||||
## Notes
|
||||
|
||||
- The extension activates for all Anthropic OAuth requests regardless of model, rather than using a fixed model allowlist.
|
||||
- Non-OAuth Anthropic usage (API key auth) is left unchanged.
|
||||
- In practice, unknown non-MCP extension tools were the remaining trigger for Anthropic's extra-usage classification, so this package keeps core tools, keeps MCP-style tools, auto-aliases the known companion tools above, and filters the rest.
|
||||
- Pi may show its built-in OAuth subscription warning banner even when the request path works correctly. That banner is UI logic in Pi, not a signal that the upstream request is being billed as extra usage.
|
||||
@@ -0,0 +1,641 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { createJiti } from "@mariozechner/jiti";
|
||||
import * as piAiModule from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import * as piCodingAgentModule from "@mariozechner/pi-coding-agent";
|
||||
import * as typeboxModule from "@sinclair/typebox";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface CompanionSpec {
|
||||
dirName: string;
|
||||
packageName: string;
|
||||
aliases: ReadonlyArray<readonly [flatName: string, mcpName: string]>;
|
||||
}
|
||||
|
||||
type ToolRegistration = Parameters<ExtensionAPI["registerTool"]>[0];
|
||||
type ToolInfo = ReturnType<ExtensionAPI["getAllTools"]>[number];
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Core Claude Code tool names that always pass through Anthropic OAuth filtering.
|
||||
* Stored lowercase for case-insensitive matching.
|
||||
* Mirrors Pi core's claudeCodeTools list in packages/ai/src/providers/anthropic.ts
|
||||
*/
|
||||
const CORE_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"glob",
|
||||
"askuserquestion",
|
||||
"enterplanmode",
|
||||
"exitplanmode",
|
||||
"killshell",
|
||||
"notebookedit",
|
||||
"skill",
|
||||
"task",
|
||||
"taskoutput",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
]);
|
||||
|
||||
/** Flat companion tool name → MCP-style alias. */
|
||||
const FLAT_TO_MCP = new Map<string, string>([
|
||||
["web_search_exa", "mcp__exa__web_search"],
|
||||
["get_code_context_exa", "mcp__exa__get_code_context"],
|
||||
["firecrawl_scrape", "mcp__firecrawl__scrape"],
|
||||
["firecrawl_map", "mcp__firecrawl__map"],
|
||||
["firecrawl_search", "mcp__firecrawl__search"],
|
||||
["generate_image", "mcp__antigravity__generate_image"],
|
||||
["image_quota", "mcp__antigravity__image_quota"],
|
||||
]);
|
||||
|
||||
/** Known companion extensions and the tools they provide. */
|
||||
const COMPANIONS: CompanionSpec[] = [
|
||||
{
|
||||
dirName: "pi-exa-mcp",
|
||||
packageName: "@benvargas/pi-exa-mcp",
|
||||
aliases: [
|
||||
["web_search_exa", "mcp__exa__web_search"],
|
||||
["get_code_context_exa", "mcp__exa__get_code_context"],
|
||||
],
|
||||
},
|
||||
{
|
||||
dirName: "pi-firecrawl",
|
||||
packageName: "@benvargas/pi-firecrawl",
|
||||
aliases: [
|
||||
["firecrawl_scrape", "mcp__firecrawl__scrape"],
|
||||
["firecrawl_map", "mcp__firecrawl__map"],
|
||||
["firecrawl_search", "mcp__firecrawl__search"],
|
||||
],
|
||||
},
|
||||
{
|
||||
dirName: "pi-antigravity-image-gen",
|
||||
packageName: "@benvargas/pi-antigravity-image-gen",
|
||||
aliases: [
|
||||
["generate_image", "mcp__antigravity__generate_image"],
|
||||
["image_quota", "mcp__antigravity__image_quota"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Reverse lookup: flat tool name → its companion spec. */
|
||||
const TOOL_TO_COMPANION = new Map<string, CompanionSpec>(
|
||||
COMPANIONS.flatMap((spec) => spec.aliases.map(([flat]) => [flat, spec] as const)),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function lower(name: string | undefined): string {
|
||||
return (name ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System prompt rewrite (PRD §1.1)
|
||||
//
|
||||
// Replace "pi itself" → "the cli itself" in system prompt text.
|
||||
// Preserves cache_control, non-text blocks, and payload shape.
|
||||
// ============================================================================
|
||||
|
||||
function rewritePromptText(text: string): string {
|
||||
return text
|
||||
.replaceAll("pi itself", "the cli itself")
|
||||
.replaceAll("pi .md files", "cli .md files")
|
||||
.replaceAll("pi packages", "cli packages");
|
||||
}
|
||||
|
||||
function rewriteSystemField(system: unknown): unknown {
|
||||
if (typeof system === "string") {
|
||||
return rewritePromptText(system);
|
||||
}
|
||||
if (!Array.isArray(system)) {
|
||||
return system;
|
||||
}
|
||||
return system.map((block) => {
|
||||
if (!isPlainObject(block) || block.type !== "text" || typeof block.text !== "string") {
|
||||
return block;
|
||||
}
|
||||
const rewritten = rewritePromptText(block.text);
|
||||
return rewritten === block.text ? block : { ...block, text: rewritten };
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool filtering and MCP alias remapping (PRD §1.2)
|
||||
//
|
||||
// Rules applied per tool:
|
||||
// 1. Anthropic-native typed tools (have a `type` field) → pass through
|
||||
// 2. Core Claude Code tool names → pass through
|
||||
// 3. Tools already prefixed with mcp__ → pass through
|
||||
// 4. Known companion tools whose MCP alias is also advertised → rename to alias
|
||||
// 5. Known companion tools without an advertised alias → filtered out
|
||||
// 6. Unknown flat-named tools → filtered out (unless disableFilter)
|
||||
// ============================================================================
|
||||
|
||||
function collectToolNames(tools: unknown[]): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
if (isPlainObject(tool) && typeof tool.name === "string") {
|
||||
names.add(lower(tool.name));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function filterAndRemapTools(tools: unknown[] | undefined, disableFilter: boolean): unknown[] | undefined {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
|
||||
const advertised = collectToolNames(tools);
|
||||
const emitted = new Set<string>();
|
||||
const result: unknown[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!isPlainObject(tool)) continue;
|
||||
|
||||
// Rule 1: native typed tools always pass through
|
||||
if (typeof tool.type === "string" && tool.type.trim().length > 0) {
|
||||
result.push(tool);
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = typeof tool.name === "string" ? tool.name : "";
|
||||
if (!name) continue;
|
||||
const nameLc = lower(name);
|
||||
|
||||
// Rules 2 & 3: core tools and mcp__-prefixed pass through (with dedup)
|
||||
if (CORE_TOOL_NAMES.has(nameLc) || nameLc.startsWith("mcp__")) {
|
||||
if (!emitted.has(nameLc)) {
|
||||
emitted.add(nameLc);
|
||||
result.push(tool);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rules 4 & 5: known companion tool
|
||||
const mcpAlias = FLAT_TO_MCP.get(nameLc);
|
||||
if (mcpAlias) {
|
||||
const aliasLc = lower(mcpAlias);
|
||||
if (advertised.has(aliasLc) && !emitted.has(aliasLc)) {
|
||||
// Alias exists in tool list → rename flat to alias, dedup
|
||||
emitted.add(aliasLc);
|
||||
result.push({ ...tool, name: mcpAlias });
|
||||
} else if (disableFilter && !emitted.has(nameLc)) {
|
||||
// Filter disabled: keep flat name if not yet emitted
|
||||
emitted.add(nameLc);
|
||||
result.push(tool);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rule 6: unknown flat-named tool
|
||||
if (disableFilter && !emitted.has(nameLc)) {
|
||||
emitted.add(nameLc);
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function remapToolChoice(
|
||||
toolChoice: Record<string, unknown>,
|
||||
survivingNames: Map<string, string>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (toolChoice.type !== "tool" || typeof toolChoice.name !== "string") {
|
||||
return toolChoice;
|
||||
}
|
||||
|
||||
const nameLc = lower(toolChoice.name);
|
||||
const actualName = survivingNames.get(nameLc);
|
||||
if (actualName) {
|
||||
return actualName === toolChoice.name ? toolChoice : { ...toolChoice, name: actualName };
|
||||
}
|
||||
|
||||
const mcpAlias = FLAT_TO_MCP.get(nameLc);
|
||||
if (mcpAlias && survivingNames.has(lower(mcpAlias))) {
|
||||
return { ...toolChoice, name: mcpAlias };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function remapMessageToolNames(messages: unknown[], survivingNames: Map<string, string>): unknown[] {
|
||||
let anyChanged = false;
|
||||
const result = messages.map((msg) => {
|
||||
if (!isPlainObject(msg) || !Array.isArray(msg.content)) return msg;
|
||||
|
||||
let msgChanged = false;
|
||||
const content = (msg.content as unknown[]).map((block) => {
|
||||
if (!isPlainObject(block) || block.type !== "tool_use" || typeof block.name !== "string") {
|
||||
return block;
|
||||
}
|
||||
const mcpAlias = FLAT_TO_MCP.get(lower(block.name));
|
||||
if (mcpAlias && survivingNames.has(lower(mcpAlias))) {
|
||||
msgChanged = true;
|
||||
return { ...block, name: mcpAlias };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
if (msgChanged) {
|
||||
anyChanged = true;
|
||||
return { ...msg, content };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return anyChanged ? result : messages;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Full payload transform
|
||||
// ============================================================================
|
||||
|
||||
function transformPayload(raw: Record<string, unknown>, disableFilter: boolean): Record<string, unknown> {
|
||||
// Deep clone to avoid mutating the original
|
||||
const payload = JSON.parse(JSON.stringify(raw)) as Record<string, unknown>;
|
||||
|
||||
// 1. System prompt rewrite (always applies)
|
||||
if (payload.system !== undefined) {
|
||||
payload.system = rewriteSystemField(payload.system);
|
||||
}
|
||||
|
||||
// When escape hatch is active, skip all tool filtering/remapping
|
||||
if (disableFilter) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// 2. Tool filtering and alias remapping
|
||||
payload.tools = filterAndRemapTools(payload.tools as unknown[] | undefined, false);
|
||||
|
||||
// 3. Build map of tool names that survived filtering (lowercase → actual name)
|
||||
const survivingNames = new Map<string, string>();
|
||||
if (Array.isArray(payload.tools)) {
|
||||
for (const tool of payload.tools) {
|
||||
if (isPlainObject(tool) && typeof tool.name === "string") {
|
||||
survivingNames.set(lower(tool.name), tool.name as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remap tool_choice if it references a renamed or filtered tool
|
||||
if (isPlainObject(payload.tool_choice)) {
|
||||
const remapped = remapToolChoice(payload.tool_choice, survivingNames);
|
||||
if (remapped === undefined) {
|
||||
delete payload.tool_choice;
|
||||
} else {
|
||||
payload.tool_choice = remapped;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Rewrite historical tool_use blocks in message history
|
||||
if (Array.isArray(payload.messages)) {
|
||||
payload.messages = remapMessageToolNames(payload.messages, survivingNames);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Debug logging (PRD §1.4)
|
||||
// ============================================================================
|
||||
|
||||
const debugLogPath = process.env.PI_CLAUDE_CODE_USE_DEBUG_LOG;
|
||||
|
||||
function writeDebugLog(payload: unknown): void {
|
||||
if (!debugLogPath) return;
|
||||
try {
|
||||
appendFileSync(debugLogPath, `${new Date().toISOString()}\n${JSON.stringify(payload, null, 2)}\n---\n`, "utf-8");
|
||||
} catch {
|
||||
// Debug logging must never break actual requests
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Companion alias registration (PRD §1.3)
|
||||
//
|
||||
// Discovers loaded companion extensions, captures their tool definitions via
|
||||
// a shim ExtensionAPI, and registers MCP-alias versions so the model can
|
||||
// invoke them under Claude Code-compatible names.
|
||||
// ============================================================================
|
||||
|
||||
const registeredMcpAliases = new Set<string>();
|
||||
const autoActivatedAliases = new Set<string>();
|
||||
let lastManagedToolList: string[] | undefined;
|
||||
|
||||
const captureCache = new Map<string, Promise<Map<string, ToolRegistration>>>();
|
||||
let jitiLoader: { import(path: string, opts?: { default?: boolean }): Promise<unknown> } | undefined;
|
||||
|
||||
function getJitiLoader() {
|
||||
if (!jitiLoader) {
|
||||
jitiLoader = createJiti(import.meta.url, {
|
||||
moduleCache: false,
|
||||
tryNative: false,
|
||||
virtualModules: {
|
||||
"@mariozechner/pi-ai": piAiModule,
|
||||
"@mariozechner/pi-coding-agent": piCodingAgentModule,
|
||||
"@sinclair/typebox": typeboxModule,
|
||||
},
|
||||
});
|
||||
}
|
||||
return jitiLoader;
|
||||
}
|
||||
|
||||
async function loadFactory(baseDir: string): Promise<((pi: ExtensionAPI) => void | Promise<void>) | undefined> {
|
||||
const dir = baseDir.replace(/\/$/, "");
|
||||
const candidates = [`${dir}/index.ts`, `${dir}/index.js`, `${dir}/extensions/index.ts`, `${dir}/extensions/index.js`];
|
||||
|
||||
const loader = getJitiLoader();
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
const mod = await loader.import(path, { default: true });
|
||||
if (typeof mod === "function") return mod as (pi: ExtensionAPI) => void | Promise<void>;
|
||||
} catch {
|
||||
// Try next candidate
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isCompanionSource(tool: ToolInfo | undefined, spec: CompanionSpec): boolean {
|
||||
if (!tool?.sourceInfo) return false;
|
||||
|
||||
const baseDir = tool.sourceInfo.baseDir;
|
||||
if (baseDir) {
|
||||
const dirName = basename(baseDir);
|
||||
if (dirName === spec.dirName) return true;
|
||||
if (dirName === "extensions" && basename(dirname(baseDir)) === spec.dirName) return true;
|
||||
}
|
||||
|
||||
const fullPath = tool.sourceInfo.path;
|
||||
if (typeof fullPath !== "string") return false;
|
||||
// Normalize backslashes for Windows paths before segment-bounded check
|
||||
const normalized = fullPath.replaceAll("\\", "/");
|
||||
// Check for scoped package name (npm install) or directory name (git/monorepo)
|
||||
return normalized.includes(`/${spec.packageName}/`) || normalized.includes(`/${spec.dirName}/`);
|
||||
}
|
||||
|
||||
function buildCaptureShim(realPi: ExtensionAPI, captured: Map<string, ToolRegistration>): ExtensionAPI {
|
||||
const shimFlags = new Set<string>();
|
||||
return {
|
||||
registerTool(def) {
|
||||
captured.set(def.name, def as unknown as ToolRegistration);
|
||||
},
|
||||
registerFlag(name, _options) {
|
||||
shimFlags.add(name);
|
||||
},
|
||||
getFlag(name) {
|
||||
return shimFlags.has(name) ? realPi.getFlag(name) : undefined;
|
||||
},
|
||||
on() {},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerMessageRenderer() {},
|
||||
registerProvider() {},
|
||||
unregisterProvider() {},
|
||||
sendMessage() {},
|
||||
sendUserMessage() {},
|
||||
appendEntry() {},
|
||||
setSessionName() {},
|
||||
getSessionName() {
|
||||
return undefined;
|
||||
},
|
||||
setLabel() {},
|
||||
exec(command, args, options) {
|
||||
return realPi.exec(command, args, options);
|
||||
},
|
||||
getActiveTools() {
|
||||
return realPi.getActiveTools();
|
||||
},
|
||||
getAllTools() {
|
||||
return realPi.getAllTools();
|
||||
},
|
||||
setActiveTools(names) {
|
||||
realPi.setActiveTools(names);
|
||||
},
|
||||
getCommands() {
|
||||
return realPi.getCommands();
|
||||
},
|
||||
setModel(model) {
|
||||
return realPi.setModel(model);
|
||||
},
|
||||
getThinkingLevel() {
|
||||
return realPi.getThinkingLevel();
|
||||
},
|
||||
setThinkingLevel(level) {
|
||||
realPi.setThinkingLevel(level);
|
||||
},
|
||||
events: realPi.events,
|
||||
} as ExtensionAPI;
|
||||
}
|
||||
|
||||
async function captureCompanionTools(baseDir: string, realPi: ExtensionAPI): Promise<Map<string, ToolRegistration>> {
|
||||
let pending = captureCache.get(baseDir);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const factory = await loadFactory(baseDir);
|
||||
if (!factory) return new Map<string, ToolRegistration>();
|
||||
const tools = new Map<string, ToolRegistration>();
|
||||
await factory(buildCaptureShim(realPi, tools));
|
||||
return tools;
|
||||
})();
|
||||
captureCache.set(baseDir, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function registerAliasesForLoadedCompanions(pi: ExtensionAPI): Promise<void> {
|
||||
// Clear capture cache so flag/config changes since last call take effect
|
||||
captureCache.clear();
|
||||
|
||||
const allTools = pi.getAllTools();
|
||||
const toolIndex = new Map<string, ToolInfo>();
|
||||
const knownNames = new Set<string>();
|
||||
for (const tool of allTools) {
|
||||
toolIndex.set(lower(tool.name), tool);
|
||||
knownNames.add(lower(tool.name));
|
||||
}
|
||||
|
||||
for (const spec of COMPANIONS) {
|
||||
for (const [flatName, mcpName] of spec.aliases) {
|
||||
if (registeredMcpAliases.has(mcpName) || knownNames.has(lower(mcpName))) continue;
|
||||
|
||||
const tool = toolIndex.get(lower(flatName));
|
||||
if (!tool || !isCompanionSource(tool, spec)) continue;
|
||||
|
||||
// Prefer the extension file's directory for loading (sourceInfo.path is the actual
|
||||
// entry point). Fall back to baseDir only if path is unavailable. baseDir can be
|
||||
// the monorepo root which doesn't contain the extension entry point directly.
|
||||
const loadDir = tool.sourceInfo?.path ? dirname(tool.sourceInfo.path) : tool.sourceInfo?.baseDir;
|
||||
if (!loadDir) continue;
|
||||
|
||||
const captured = await captureCompanionTools(loadDir, pi);
|
||||
const def = captured.get(flatName);
|
||||
if (!def) continue;
|
||||
|
||||
pi.registerTool({
|
||||
...def,
|
||||
name: mcpName,
|
||||
label: def.label?.startsWith("MCP ") ? def.label : `MCP ${def.label ?? mcpName}`,
|
||||
});
|
||||
registeredMcpAliases.add(mcpName);
|
||||
knownNames.add(lower(mcpName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize MCP alias tool activation with the current model state.
|
||||
* When OAuth is active, auto-activate aliases for any active companion tools.
|
||||
* When OAuth is inactive, remove auto-activated aliases (but preserve user-selected ones).
|
||||
*/
|
||||
function syncAliasActivation(pi: ExtensionAPI, enableAliases: boolean): void {
|
||||
const activeNames = pi.getActiveTools();
|
||||
const allNames = new Set(pi.getAllTools().map((t) => t.name));
|
||||
|
||||
if (enableAliases) {
|
||||
// Determine which aliases should be active based on their flat counterpart being active
|
||||
const activeLc = new Set(activeNames.map(lower));
|
||||
const desiredAliases: string[] = [];
|
||||
for (const [flat, mcp] of FLAT_TO_MCP) {
|
||||
if (activeLc.has(flat) && allNames.has(mcp) && registeredMcpAliases.has(mcp)) {
|
||||
desiredAliases.push(mcp);
|
||||
}
|
||||
}
|
||||
const desiredSet = new Set(desiredAliases);
|
||||
|
||||
// Promote auto-activated aliases to user-selected when the user explicitly kept
|
||||
// the alias while removing its flat counterpart from the tool picker.
|
||||
// We detect this by checking: (a) user changed the tool list since our last sync,
|
||||
// (b) the flat tool was previously managed but is no longer active, and
|
||||
// (c) the alias is still active. This means the user deliberately kept the alias.
|
||||
if (lastManagedToolList !== undefined) {
|
||||
const activeSet = new Set(activeNames);
|
||||
const lastManaged = new Set(lastManagedToolList);
|
||||
for (const alias of autoActivatedAliases) {
|
||||
if (!activeSet.has(alias) || desiredSet.has(alias)) continue;
|
||||
// Find the flat name for this alias
|
||||
const flatName = [...FLAT_TO_MCP.entries()].find(([, mcp]) => mcp === alias)?.[0];
|
||||
if (flatName && lastManaged.has(flatName) && !activeSet.has(flatName)) {
|
||||
// User removed the flat tool but kept the alias → promote to user-selected
|
||||
autoActivatedAliases.delete(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find registered aliases currently in the active list
|
||||
const activeRegistered = activeNames.filter((n) => registeredMcpAliases.has(n) && allNames.has(n));
|
||||
|
||||
// Per-alias provenance: an alias is "user-selected" if it's active and was NOT
|
||||
// auto-activated by us. Only preserve those; auto-activated aliases get re-derived
|
||||
// from the desired set each sync.
|
||||
const preserved = activeRegistered.filter((n) => !autoActivatedAliases.has(n));
|
||||
|
||||
// Build result: non-alias tools + preserved user aliases + desired aliases
|
||||
const nonAlias = activeNames.filter((n) => !registeredMcpAliases.has(n));
|
||||
const next = Array.from(new Set([...nonAlias, ...preserved, ...desiredAliases]));
|
||||
|
||||
// Update auto-activation tracking: aliases we added this sync that weren't user-preserved
|
||||
const preservedSet = new Set(preserved);
|
||||
autoActivatedAliases.clear();
|
||||
for (const name of desiredAliases) {
|
||||
if (!preservedSet.has(name)) {
|
||||
autoActivatedAliases.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (next.length !== activeNames.length || next.some((n, i) => n !== activeNames[i])) {
|
||||
pi.setActiveTools(next);
|
||||
lastManagedToolList = [...next];
|
||||
}
|
||||
} else {
|
||||
// Remove only auto-activated aliases; user-selected ones are preserved
|
||||
const next = activeNames.filter((n) => !autoActivatedAliases.has(n));
|
||||
autoActivatedAliases.clear();
|
||||
|
||||
if (next.length !== activeNames.length || next.some((n, i) => n !== activeNames[i])) {
|
||||
pi.setActiveTools(next);
|
||||
lastManagedToolList = [...next];
|
||||
} else {
|
||||
lastManagedToolList = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension entry point
|
||||
// ============================================================================
|
||||
|
||||
export default async function piClaudeCodeUse(pi: ExtensionAPI): Promise<void> {
|
||||
pi.on("session_start", async () => {
|
||||
await registerAliasesForLoadedCompanions(pi);
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
await registerAliasesForLoadedCompanions(pi);
|
||||
const model = ctx.model;
|
||||
const isOAuth = model?.provider === "anthropic" && ctx.modelRegistry.isUsingOAuth(model);
|
||||
syncAliasActivation(pi, isOAuth);
|
||||
});
|
||||
|
||||
pi.on("before_provider_request", (event, ctx) => {
|
||||
const model = ctx.model;
|
||||
if (!model || model.provider !== "anthropic" || !ctx.modelRegistry.isUsingOAuth(model)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isPlainObject(event.payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
writeDebugLog({ stage: "before", payload: event.payload });
|
||||
const disableFilter = process.env.PI_CLAUDE_CODE_USE_DISABLE_TOOL_FILTER === "1";
|
||||
const transformed = transformPayload(event.payload as Record<string, unknown>, disableFilter);
|
||||
writeDebugLog({ stage: "after", payload: transformed });
|
||||
return transformed;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test exports
|
||||
// ============================================================================
|
||||
|
||||
export const _test = {
|
||||
CORE_TOOL_NAMES,
|
||||
FLAT_TO_MCP,
|
||||
COMPANIONS,
|
||||
TOOL_TO_COMPANION,
|
||||
autoActivatedAliases,
|
||||
buildCaptureShim,
|
||||
collectToolNames,
|
||||
filterAndRemapTools,
|
||||
getLastManagedToolList: () => lastManagedToolList,
|
||||
isCompanionSource,
|
||||
isPlainObject,
|
||||
lower,
|
||||
registerAliasesForLoadedCompanions,
|
||||
registeredMcpAliases,
|
||||
remapMessageToolNames,
|
||||
remapToolChoice,
|
||||
rewritePromptText,
|
||||
rewriteSystemField,
|
||||
setLastManagedToolList: (v: string[] | undefined) => {
|
||||
lastManagedToolList = v;
|
||||
},
|
||||
syncAliasActivation,
|
||||
transformPayload,
|
||||
};
|
||||
@@ -211,12 +211,12 @@ function updateWidget(ctx: ExtensionContext): void {
|
||||
(resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
|
||||
);
|
||||
|
||||
ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0));
|
||||
ctx.ui.setWidget("web-activity", lines);
|
||||
}
|
||||
|
||||
function formatEntryLine(
|
||||
entry: ActivityEntry,
|
||||
theme: { fg: (color: string, text: string) => string },
|
||||
theme: ExtensionContext["ui"]["theme"],
|
||||
): string {
|
||||
const typeStr = entry.type === "api" ? "API" : "GET";
|
||||
const target =
|
||||
@@ -550,7 +550,7 @@ export default function (pi: ExtensionAPI) {
|
||||
} else {
|
||||
widgetUnsubscribe?.();
|
||||
widgetUnsubscribe = null;
|
||||
ctx.ui.setWidget("web-activity", null);
|
||||
ctx.ui.setWidget("web-activity", undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -598,7 +598,7 @@ export default function (pi: ExtensionAPI) {
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<any> {
|
||||
const queryList = params.queries ?? (params.query ? [params.query] : []);
|
||||
const isMultiQuery = queryList.length > 1;
|
||||
const shouldCurate = params.curate !== false && ctx?.hasUI !== false;
|
||||
@@ -613,7 +613,10 @@ export default function (pi: ExtensionAPI) {
|
||||
if (shouldCurate) {
|
||||
closeCurator();
|
||||
|
||||
const { promise, resolve: resolvePromise } = Promise.withResolvers<unknown>();
|
||||
let resolvePromise!: (value: unknown) => void;
|
||||
const promise = new Promise<unknown>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
const includeContent = params.includeContent ?? false;
|
||||
const searchResults = new Map<number, QueryResultData>();
|
||||
const allUrls: string[] = [];
|
||||
@@ -637,7 +640,7 @@ export default function (pi: ExtensionAPI) {
|
||||
queryList,
|
||||
includeContent,
|
||||
numResults: params.numResults,
|
||||
recencyFilter: params.recencyFilter,
|
||||
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||
domainFilter: params.domainFilter,
|
||||
availableProviders,
|
||||
defaultProvider,
|
||||
@@ -684,7 +687,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const { answer, results } = await search(queryList[qi], {
|
||||
provider: defaultProvider as SearchProvider | undefined,
|
||||
numResults: params.numResults,
|
||||
recencyFilter: params.recencyFilter,
|
||||
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||
domainFilter: params.domainFilter,
|
||||
signal,
|
||||
});
|
||||
@@ -754,7 +757,7 @@ export default function (pi: ExtensionAPI) {
|
||||
text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
content: [{ type: "text" as const, text }],
|
||||
details: {
|
||||
phase: "curate-window",
|
||||
searchCount: searchResults.size,
|
||||
@@ -824,7 +827,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const { answer, results } = await search(query, {
|
||||
provider: resolvedProvider as SearchProvider | undefined,
|
||||
numResults: params.numResults,
|
||||
recencyFilter: params.recencyFilter,
|
||||
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||
domainFilter: params.domainFilter,
|
||||
signal,
|
||||
});
|
||||
@@ -1117,7 +1120,10 @@ export default function (pi: ExtensionAPI) {
|
||||
`Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
|
||||
}
|
||||
|
||||
const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [];
|
||||
const content: Array<
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| { type: "text"; text: string }
|
||||
> = [];
|
||||
if (result.frames?.length) {
|
||||
for (const frame of result.frames) {
|
||||
content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
|
||||
@@ -1290,7 +1296,7 @@ export default function (pi: ExtensionAPI) {
|
||||
urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx): Promise<any> {
|
||||
const data = getResult(params.responseId);
|
||||
if (!data) {
|
||||
return {
|
||||
@@ -1477,7 +1483,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.sendMessage({
|
||||
customType: "web-search-results",
|
||||
content: [{ type: "text", text }],
|
||||
display: "tool",
|
||||
display: true,
|
||||
details: { queryCount: results.length, totalResults: urls.length },
|
||||
}, { triggerTurn: true, deliverAs: "followUp" });
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ export async function extractPDFToMarkdown(
|
||||
|
||||
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
||||
const metadata = await pdf.getMetadata();
|
||||
const info = (metadata.info ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Extract title from metadata or URL
|
||||
const metaTitle = metadata.info?.Title as string | undefined;
|
||||
const metaTitle = typeof info.Title === "string" ? info.Title : undefined;
|
||||
const urlTitle = extractTitleFromURL(url);
|
||||
const title = metaTitle?.trim() || urlTitle;
|
||||
|
||||
@@ -79,8 +80,9 @@ export async function extractPDFToMarkdown(
|
||||
lines.push("");
|
||||
lines.push(`> Source: ${url}`);
|
||||
lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`);
|
||||
if (metadata.info?.Author) {
|
||||
lines.push(`> Author: ${metadata.info.Author}`);
|
||||
const author = typeof info.Author === "string" ? info.Author : undefined;
|
||||
if (author) {
|
||||
lines.push(`> Author: ${author}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
|
||||
@@ -245,8 +245,8 @@ export async function condenseSearchResults(
|
||||
const model = ctx.modelRegistry.find(provider, modelId);
|
||||
if (!model) return null;
|
||||
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) return null;
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (!auth.ok) return null;
|
||||
|
||||
const queryData = [...results.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
@@ -281,7 +281,8 @@ export async function condenseSearchResults(
|
||||
: timeoutSignal;
|
||||
|
||||
const response = await complete(model, aiContext, {
|
||||
apiKey,
|
||||
apiKey: auth.apiKey,
|
||||
headers: auth.headers,
|
||||
signal: combinedSignal,
|
||||
max_tokens: MAX_TOKENS,
|
||||
} as any);
|
||||
|
||||
Generated
+34
-22
@@ -11,6 +11,9 @@ importers:
|
||||
'@anthropic-ai/sdk':
|
||||
specifier: ^0.52.0
|
||||
version: 0.52.0
|
||||
'@mariozechner/jiti':
|
||||
specifier: ^2.6.5
|
||||
version: 2.6.5
|
||||
'@mozilla/readability':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
@@ -34,17 +37,20 @@ importers:
|
||||
version: 3.17.5
|
||||
devDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.56.3
|
||||
version: 0.56.3(ws@8.19.0)(zod@4.3.6)
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.56.3
|
||||
version: 0.56.3(ws@8.19.0)(zod@4.3.6)
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.56.3
|
||||
version: 0.56.3
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.1
|
||||
'@types/node':
|
||||
specifier: ^25.3.3
|
||||
version: 25.3.3
|
||||
'@types/turndown':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
@@ -289,22 +295,22 @@ packages:
|
||||
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-agent-core@0.56.3':
|
||||
resolution: {integrity: sha512-TsI1zENf3wqqKPaERnj486Q4i6Y/y6lAZipLNcfDYUDxDrLwNfQ9EW9xukkbJfTZ8zjG3VZ2pBZe3C7wM51dVQ==}
|
||||
'@mariozechner/pi-agent-core@0.63.1':
|
||||
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-ai@0.56.3':
|
||||
resolution: {integrity: sha512-l4J+cVyVeBLAlGOY/osGDvsbTz0DySCQmR171G6SdbPvIeLGhIi6siZ+zHwq91GJYjv/wtu/08M08ag2mGZKeA==}
|
||||
'@mariozechner/pi-ai@0.63.1':
|
||||
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.56.3':
|
||||
resolution: {integrity: sha512-yHgnadye+TT/4NWKBirZUjw/LWdNWTa7M4HJdX2RxRbwuj4q7RZ0Aqy+lQbOHEPDQYhxK3kZb9hjiAbbGficZQ==}
|
||||
'@mariozechner/pi-coding-agent@0.63.1':
|
||||
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-tui@0.56.3':
|
||||
resolution: {integrity: sha512-eZ1P9QRKHp78hwx+lITr/mujZqe+eCwL/bOS9vXXkFP070RW4VYum0j7TJ4BrFEH/nNkXRS1tYCXYU05une1bA==}
|
||||
'@mariozechner/pi-tui@0.63.1':
|
||||
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
@@ -568,6 +574,9 @@ packages:
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/turndown@5.0.6':
|
||||
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -1722,9 +1731,9 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
'@mariozechner/pi-agent-core@0.56.3(ws@8.19.0)(zod@4.3.6)':
|
||||
'@mariozechner/pi-agent-core@0.63.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 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)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
@@ -1734,7 +1743,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@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)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1002.0
|
||||
@@ -1758,13 +1767,14 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.56.3(ws@8.19.0)(zod@4.3.6)':
|
||||
'@mariozechner/pi-coding-agent@0.63.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.56.3(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.56.3
|
||||
'@mariozechner/pi-agent-core': 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.63.1
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
ajv: 8.18.0
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
diff: 8.0.3
|
||||
@@ -1790,7 +1800,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-tui@0.56.3':
|
||||
'@mariozechner/pi-tui@0.63.1':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
chalk: 5.6.2
|
||||
@@ -2166,6 +2176,8 @@ snapshots:
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/turndown@5.0.6': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.3.3
|
||||
|
||||
@@ -43,7 +43,7 @@ Output ONLY the session name, nothing else.`;
|
||||
const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5");
|
||||
|
||||
// Number of messages before auto-naming kicks in
|
||||
const AUTO_NAME_THRESHOLD = 2;
|
||||
const AUTO_NAME_THRESHOLD = 1;
|
||||
|
||||
// Debug log file
|
||||
const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log");
|
||||
@@ -135,11 +135,11 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// Fire-and-forget: run auto-naming in background without blocking
|
||||
const doAutoName = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
log(`Got API key: ${apiKey ? "yes" : "no"}`);
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL);
|
||||
log(`Got API key: ${auth.ok ? "yes" : "no"}`);
|
||||
|
||||
if (!apiKey) {
|
||||
log("No API key available, aborting");
|
||||
if (!auth.ok) {
|
||||
log(`No API key available, aborting: ${auth.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers },
|
||||
);
|
||||
|
||||
log(`Response received, stopReason: ${response.stopReason}`);
|
||||
@@ -273,7 +273,8 @@ export default function (pi: ExtensionAPI) {
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
const doGenerate = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL);
|
||||
if (!auth.ok) throw new Error(auth.error);
|
||||
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
@@ -289,7 +290,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
interface ToolTimeout {
|
||||
toolCallId: string;
|
||||
@@ -28,6 +31,8 @@ interface ToolTimeout {
|
||||
// Configuration
|
||||
let timeoutSeconds = 30;
|
||||
let enabled = true;
|
||||
const SETTINGS_NAMESPACE = "slowtool";
|
||||
const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
||||
|
||||
// Track running tools
|
||||
const runningTools: Map<string, ToolTimeout> = new Map();
|
||||
@@ -43,6 +48,55 @@ function formatDuration(ms: number): string {
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readSettingsFile(filePath: string): Record<string, unknown> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return asRecord(parsed) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadGlobalConfig(): { timeoutSeconds: number; enabled: boolean } {
|
||||
const settings = readSettingsFile(globalSettingsPath);
|
||||
const slowtoolSettings = asRecord(settings[SETTINGS_NAMESPACE]);
|
||||
|
||||
const configuredTimeout = slowtoolSettings?.timeoutSeconds;
|
||||
const nextTimeout =
|
||||
typeof configuredTimeout === "number" && Number.isFinite(configuredTimeout) && configuredTimeout >= 1
|
||||
? Math.floor(configuredTimeout)
|
||||
: 30;
|
||||
|
||||
const configuredEnabled = slowtoolSettings?.enabled;
|
||||
const nextEnabled = typeof configuredEnabled === "boolean" ? configuredEnabled : true;
|
||||
|
||||
return { timeoutSeconds: nextTimeout, enabled: nextEnabled };
|
||||
}
|
||||
|
||||
function saveGlobalConfig(next: { timeoutSeconds: number; enabled: boolean }): boolean {
|
||||
try {
|
||||
const settings = readSettingsFile(globalSettingsPath);
|
||||
const existing = asRecord(settings[SETTINGS_NAMESPACE]) ?? {};
|
||||
settings[SETTINGS_NAMESPACE] = {
|
||||
...existing,
|
||||
timeoutSeconds: next.timeoutSeconds,
|
||||
enabled: next.enabled,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandPreview(args: unknown): string | undefined {
|
||||
if (!args) return undefined;
|
||||
const anyArgs = args as Record<string, unknown>;
|
||||
@@ -77,6 +131,29 @@ function notifyTimeout(pi: ExtensionAPI, tool: ToolTimeout): void {
|
||||
// ============ EVENT HANDLERS ============
|
||||
|
||||
export default function(pi: ExtensionAPI) {
|
||||
const applyPersistedConfig = () => {
|
||||
const persisted = loadGlobalConfig();
|
||||
timeoutSeconds = persisted.timeoutSeconds;
|
||||
enabled = persisted.enabled;
|
||||
};
|
||||
|
||||
const persistCurrentConfig = (ctx: ExtensionCommandContext): void => {
|
||||
const ok = saveGlobalConfig({ timeoutSeconds, enabled });
|
||||
if (!ok) {
|
||||
ctx.ui.notify("Failed to persist slowtool settings", "warning");
|
||||
}
|
||||
};
|
||||
|
||||
applyPersistedConfig();
|
||||
|
||||
pi.on("session_start", async (_event, _ctx) => {
|
||||
applyPersistedConfig();
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, _ctx) => {
|
||||
applyPersistedConfig();
|
||||
});
|
||||
|
||||
// Register commands
|
||||
pi.registerCommand("slowtool:timeout", {
|
||||
description: "Set timeout threshold in seconds (default: 30)",
|
||||
@@ -91,6 +168,7 @@ export default function(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
timeoutSeconds = newTimeout;
|
||||
persistCurrentConfig(ctx);
|
||||
ctx.ui.notify(`Timeout set to ${timeoutSeconds}s`, "info");
|
||||
},
|
||||
});
|
||||
@@ -99,6 +177,7 @@ export default function(pi: ExtensionAPI) {
|
||||
description: "Enable slow tool notifications",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
enabled = true;
|
||||
persistCurrentConfig(ctx);
|
||||
ctx.ui.notify("Slow tool notifications enabled", "info");
|
||||
},
|
||||
});
|
||||
@@ -107,6 +186,7 @@ export default function(pi: ExtensionAPI) {
|
||||
description: "Disable slow tool notifications",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
enabled = false;
|
||||
persistCurrentConfig(ctx);
|
||||
ctx.ui.notify("Slow tool notifications disabled", "info");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
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
-2718
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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(""));
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
@@ -1,937 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
@@ -1,359 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Settings UI entry point (re-export).
|
||||
*/
|
||||
|
||||
export { showSettingsUI } from "./settings/ui.js";
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
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
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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[];
|
||||
};
|
||||
@@ -1,304 +0,0 @@
|
||||
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",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Usage data types shared across modules.
|
||||
*/
|
||||
|
||||
export type { ProviderUsageEntry } from "../../shared.js";
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"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": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Provider registry exports.
|
||||
*/
|
||||
|
||||
export * from "./registry.js";
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user