Compare commits
28 Commits
67b3c5b504
..
nixos
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 9ad10a016c | |||
| ec88e63a07 | |||
| ed70a369e5 |
@@ -224,8 +224,8 @@
|
|||||||
"networkPreference": "wifi",
|
"networkPreference": "wifi",
|
||||||
"iconTheme": "System Default",
|
"iconTheme": "System Default",
|
||||||
"cursorSettings": {
|
"cursorSettings": {
|
||||||
"theme": "Qogir",
|
"theme": "Adwaita",
|
||||||
"size": 32,
|
"size": 24,
|
||||||
"niri": {
|
"niri": {
|
||||||
"hideWhenTyping": false,
|
"hideWhenTyping": false,
|
||||||
"hideAfterInactiveMs": 0
|
"hideAfterInactiveMs": 0
|
||||||
@@ -377,7 +377,7 @@
|
|||||||
"osdPosition": 5,
|
"osdPosition": 5,
|
||||||
"osdVolumeEnabled": true,
|
"osdVolumeEnabled": true,
|
||||||
"osdMediaVolumeEnabled": true,
|
"osdMediaVolumeEnabled": true,
|
||||||
"osdMediaPlaybackEnabled": true,
|
"osdMediaPlaybackEnabled": false,
|
||||||
"osdBrightnessEnabled": true,
|
"osdBrightnessEnabled": true,
|
||||||
"osdIdleInhibitorEnabled": true,
|
"osdIdleInhibitorEnabled": true,
|
||||||
"osdMicMuteEnabled": true,
|
"osdMicMuteEnabled": true,
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ end
|
|||||||
|
|
||||||
status is-interactive; and begin
|
status is-interactive; and begin
|
||||||
|
|
||||||
|
# On macOS SSH sessions, normalize TERM if remote terminfo is missing
|
||||||
|
# (eg. TERM=alacritty from Linux host), otherwise tools like jj/less warn
|
||||||
|
if test (uname) = Darwin; and set -q SSH_TTY
|
||||||
|
if not infocmp "$TERM" >/dev/null 2>&1
|
||||||
|
set -gx TERM xterm-256color
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Abbreviations
|
# Abbreviations
|
||||||
abbr -a tx 'tmux'
|
abbr -a tx 'tmux'
|
||||||
abbr -a txa 'tmux attach'
|
abbr -a txa 'tmux attach'
|
||||||
@@ -34,6 +42,8 @@ status is-interactive; and begin
|
|||||||
abbr -a txk 'tmux kill-session -t'
|
abbr -a txk 'tmux kill-session -t'
|
||||||
abbr -a txks 'tmux kill-server'
|
abbr -a txks 'tmux kill-server'
|
||||||
|
|
||||||
|
abbr -a zj 'zellij'
|
||||||
|
|
||||||
# Aliases
|
# Aliases
|
||||||
alias che chezmoi
|
alias che chezmoi
|
||||||
alias gb 'git branch'
|
alias gb 'git branch'
|
||||||
@@ -73,6 +83,10 @@ status is-interactive; and begin
|
|||||||
|
|
||||||
if test "$TERM" != dumb
|
if test "$TERM" != dumb
|
||||||
fzf --fish | source
|
fzf --fish | source
|
||||||
|
bind --erase \ct
|
||||||
|
bind -M insert --erase \ct
|
||||||
|
bind \cf fzf-file-widget
|
||||||
|
bind -M insert \cf fzf-file-widget
|
||||||
end
|
end
|
||||||
|
|
||||||
# add completions generated by Home Manager to $fish_complete_path
|
# add completions generated by Home Manager to $fish_complete_path
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
function y
|
function y
|
||||||
set tmp (mktemp -t "yazi-cwd.XXXXXX")
|
set tmp (mktemp -t "yazi-cwd.XXXXXX")
|
||||||
yazi $argv --cwd-file="$tmp"
|
|
||||||
|
# In Zellij + Alacritty, force Yazi away from graphical adapters and into Chafa.
|
||||||
|
# This gives text/blocks previews instead of broken/blank image panes.
|
||||||
|
if set -q ZELLIJ
|
||||||
|
env -u DISPLAY -u WAYLAND_DISPLAY -u SWAYSOCK -u HYPRLAND_INSTANCE_SIGNATURE -u WAYFIRE_SOCKET TERM=xterm-256color yazi $argv --cwd-file="$tmp"
|
||||||
|
else
|
||||||
|
yazi $argv --cwd-file="$tmp"
|
||||||
|
end
|
||||||
|
|
||||||
if set cwd (command cat -- "$tmp"); and [ -n "$cwd" ]; and [ "$cwd" != "$PWD" ]
|
if set cwd (command cat -- "$tmp"); and [ -n "$cwd" ]; and [ "$cwd" != "$PWD" ]
|
||||||
builtin cd -- "$cwd"
|
builtin cd -- "$cwd"
|
||||||
end
|
end
|
||||||
|
|
||||||
rm -f -- "$tmp"
|
rm -f -- "$tmp"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
[templates.ghostty]
|
[templates.ghostty]
|
||||||
input_path = '~/.config/matugen/templates/ghostty-theme'
|
input_path = '~/.config/matugen/templates/ghostty-theme'
|
||||||
output_path = '~/.config/ghostty/themes/matugen'
|
output_path = '~/.config/ghostty/themes/matugen'
|
||||||
post_hook = 'pkill -SIGUSR2 ghostty'
|
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]
|
[templates.kitty]
|
||||||
input_path = '~/.config/matugen/templates/kitty-colors.conf'
|
input_path = '~/.config/matugen/templates/kitty-colors.conf'
|
||||||
output_path = '~/.config/kitty/colors.conf'
|
output_path = '~/.config/kitty/colors.conf'
|
||||||
|
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/kitty/colors.conf ~/.config/kitty/colors.conf >/dev/null 2>&1 &"
|
||||||
|
|
||||||
[templates.foot]
|
[templates.foot]
|
||||||
input_path = '~/.config/matugen/templates/foot-theme'
|
input_path = '~/.config/matugen/templates/foot-theme'
|
||||||
@@ -24,10 +25,12 @@ output_path = '~/.config/gtk-4.0/colors.css'
|
|||||||
[templates.fish-prompt]
|
[templates.fish-prompt]
|
||||||
input_path = '~/.config/matugen/templates/fish-prompt-colors.fish'
|
input_path = '~/.config/matugen/templates/fish-prompt-colors.fish'
|
||||||
output_path = '~/.config/fish/conf.d/prompt-colors.fish'
|
output_path = '~/.config/fish/conf.d/prompt-colors.fish'
|
||||||
|
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/fish/conf.d/prompt-colors.fish ~/.config/fish/conf.d/prompt-colors.fish >/dev/null 2>&1 &"
|
||||||
|
|
||||||
[templates.yazi]
|
[templates.yazi]
|
||||||
input_path = '~/.config/matugen/templates/yazi-theme.toml'
|
input_path = '~/.config/matugen/templates/yazi-theme.toml'
|
||||||
output_path = '~/.config/yazi/theme.toml'
|
output_path = '~/.config/yazi/theme.toml'
|
||||||
|
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/yazi/theme.toml ~/.config/yazi/theme.toml >/dev/null 2>&1 &"
|
||||||
|
|
||||||
[templates.qt5ct]
|
[templates.qt5ct]
|
||||||
input_path = '~/.config/matugen/templates/qtct-colors.conf'
|
input_path = '~/.config/matugen/templates/qtct-colors.conf'
|
||||||
@@ -44,28 +47,29 @@ output_path = '~/.config/niri/colors.kdl'
|
|||||||
[templates.tmux]
|
[templates.tmux]
|
||||||
input_path = '~/.config/matugen/templates/tmux-colors.conf'
|
input_path = '~/.config/matugen/templates/tmux-colors.conf'
|
||||||
output_path = '~/.config/tmux/colors.conf'
|
output_path = '~/.config/tmux/colors.conf'
|
||||||
post_hook = 'tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true && nohup ~/.config/matugen/scripts/sync-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]
|
[templates.zellij]
|
||||||
input_path = '~/.config/matugen/templates/zellij-colors.kdl'
|
input_path = '~/.config/matugen/templates/zellij-colors.kdl'
|
||||||
output_path = '~/.config/zellij/themes/matugen.kdl'
|
output_path = '~/.config/zellij/themes/matugen.kdl'
|
||||||
post_hook = 'touch ~/.config/zellij/config.kdl && nohup ~/.config/matugen/scripts/sync-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]
|
[templates.jjui]
|
||||||
input_path = '~/.config/matugen/templates/jjui-theme.toml'
|
input_path = '~/.config/matugen/templates/jjui-theme.toml'
|
||||||
output_path = '~/.config/jjui/themes/matugen.toml'
|
output_path = '~/.config/jjui/themes/matugen.toml'
|
||||||
|
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/jjui/themes/matugen.toml ~/.config/jjui/themes/matugen.toml >/dev/null 2>&1 &"
|
||||||
|
|
||||||
[templates.nvim]
|
[templates.nvim]
|
||||||
input_path = '~/.config/matugen/templates/neovim.lua'
|
input_path = '~/.config/matugen/templates/neovim.lua'
|
||||||
output_path = '~/.config/nvim/lua/plugins/dankcolors.lua'
|
output_path = '~/.config/nvim/lua/plugins/dankcolors.lua'
|
||||||
post_hook = 'nohup ~/.config/matugen/scripts/sync-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]
|
[templates.pi]
|
||||||
input_path = '~/.config/matugen/templates/pi-theme.json'
|
input_path = '~/.config/matugen/templates/pi-theme.json'
|
||||||
output_path = '~/.pi/agent/themes/matugen.json.tmp'
|
output_path = '~/.pi/agent/themes/matugen.json.tmp'
|
||||||
post_hook = 'cat ~/.pi/agent/themes/matugen.json.tmp > ~/.pi/agent/themes/matugen.json && nohup ~/.config/matugen/scripts/sync-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]
|
[templates.wallpaper]
|
||||||
input_path = '~/.config/matugen/templates/wallpaper-path.txt'
|
input_path = '~/.config/matugen/templates/wallpaper-path.txt'
|
||||||
output_path = '~/.cache/matugen-last-image'
|
output_path = '~/.cache/matugen-last-image'
|
||||||
post_hook = 'nohup ~/.config/matugen/scripts/sync-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 {
|
window-rule {
|
||||||
match app-id="steam" title=r#"^notificationtoasts_\d+_desktop$"#
|
match app-id="steam" title=r#"notificationtoasts_\d+_desktop$"#
|
||||||
default-floating-position x=10 y=10 relative-to="bottom-right"
|
default-floating-position x=10 y=10 relative-to="bottom-right"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ return {
|
|||||||
end, "[T]oggle Inlay [H]ints")
|
end, "[T]oggle Inlay [H]ints")
|
||||||
end
|
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
|
-- https://github.com/sveltejs/language-tools/issues/2008#issuecomment-2351976230
|
||||||
if client and client.name == "svelte" then
|
if client and client.name == "svelte" then
|
||||||
vim.api.nvim_create_autocmd("BufWritePost", {
|
vim.api.nvim_create_autocmd("BufWritePost", {
|
||||||
|
|||||||
@@ -51,27 +51,29 @@ return {
|
|||||||
-- end,
|
-- end,
|
||||||
-- desc = "Git Blame Line",
|
-- desc = "Git Blame Line",
|
||||||
-- },
|
-- },
|
||||||
{
|
--
|
||||||
"<leader>gf",
|
-- Commented out LazyGit in favor of separated jj
|
||||||
function()
|
-- {
|
||||||
Snacks.lazygit.log_file()
|
-- "<leader>gf",
|
||||||
end,
|
-- function()
|
||||||
desc = "Lazygit Current File History",
|
-- Snacks.lazygit.log_file()
|
||||||
},
|
-- end,
|
||||||
{
|
-- desc = "Lazygit Current File History",
|
||||||
"<leader>lg",
|
-- },
|
||||||
function()
|
-- {
|
||||||
Snacks.lazygit()
|
-- "<leader>lg",
|
||||||
end,
|
-- function()
|
||||||
desc = "Lazygit",
|
-- Snacks.lazygit()
|
||||||
},
|
-- end,
|
||||||
{
|
-- desc = "Lazygit",
|
||||||
"<leader>gl",
|
-- },
|
||||||
function()
|
-- {
|
||||||
Snacks.lazygit.log()
|
-- "<leader>gl",
|
||||||
end,
|
-- function()
|
||||||
desc = "Lazygit Log (cwd)",
|
-- Snacks.lazygit.log()
|
||||||
},
|
-- end,
|
||||||
|
-- desc = "Lazygit Log (cwd)",
|
||||||
|
-- },
|
||||||
{
|
{
|
||||||
"<leader>dn",
|
"<leader>dn",
|
||||||
function()
|
function()
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ return {
|
|||||||
lazy = false,
|
lazy = false,
|
||||||
build = ":TSUpdate",
|
build = ":TSUpdate",
|
||||||
config = function()
|
config = function()
|
||||||
require("nvim-treesitter").setup({
|
local ts = require("nvim-treesitter")
|
||||||
|
ts.setup({
|
||||||
install_dir = vim.fn.stdpath("data") .. "/site",
|
install_dir = vim.fn.stdpath("data") .. "/site",
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Install parsers (async, no-op if already installed)
|
-- Install parsers (async, no-op if already installed)
|
||||||
require("nvim-treesitter").install({
|
ts.install({
|
||||||
"vimdoc",
|
"vimdoc",
|
||||||
"javascript",
|
"javascript",
|
||||||
"typescript",
|
"typescript",
|
||||||
@@ -28,6 +29,75 @@ return {
|
|||||||
"nix",
|
"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,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ vim.o.confirm = true
|
|||||||
|
|
||||||
-- vim.o.winborder = "rounded"
|
-- vim.o.winborder = "rounded"
|
||||||
|
|
||||||
|
-- Clipboard: keep default y/p behavior; over SSH, route + register through OSC52
|
||||||
|
if vim.env.SSH_TTY then
|
||||||
|
vim.g.clipboard = "osc52"
|
||||||
|
end
|
||||||
|
|
||||||
-- Highlight text on yank
|
-- Highlight text on yank
|
||||||
vim.api.nvim_create_autocmd("TextYankPost", {
|
vim.api.nvim_create_autocmd("TextYankPost", {
|
||||||
callback = function()
|
callback = function()
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"activePack": "glados",
|
||||||
|
"volume": 1,
|
||||||
|
"muted": false,
|
||||||
|
"enabledCategories": {
|
||||||
|
"session.start": true,
|
||||||
|
"task.acknowledge": true,
|
||||||
|
"task.complete": true,
|
||||||
|
"task.error": false,
|
||||||
|
"input.required": true,
|
||||||
|
"resource.limit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
"file": "sounds/IKnowYoureThere.mp3",
|
||||||
"label": "I know you're there. I can feel you here.",
|
"label": "I know you're there. I can feel you here.",
|
||||||
"sha256": "df3780607b7a480fd3968c8aae5e0a397ea956008a5c7a47fecb887a05d61622"
|
"sha256": "df3780607b7a480fd3968c8aae5e0a397ea956008a5c7a47fecb887a05d61622"
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "sounds/HelloImbecile.mp3",
|
|
||||||
"label": "Hello, imbecile!",
|
|
||||||
"sha256": "dd10461e79bb4b1319f436cef5f0541f18a9505638824a6e765b9f2824a3380f"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ return {
|
|||||||
"**/node_modules",
|
"**/node_modules",
|
||||||
"**/pnpm-lock.yaml",
|
"**/pnpm-lock.yaml",
|
||||||
"**/.pnpm",
|
"**/.pnpm",
|
||||||
|
"**/auth.json",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"lastChangelogVersion": "0.57.1",
|
"lastChangelogVersion": "0.63.1",
|
||||||
"defaultProvider": "opencode-go",
|
"defaultProvider": "openai-codex",
|
||||||
"defaultModel": "minimax-m2.5",
|
"defaultModel": "gpt-5.3-codex",
|
||||||
"defaultThinkingLevel": "medium",
|
"defaultThinkingLevel": "high",
|
||||||
"theme": "matugen",
|
"theme": "matugen",
|
||||||
"lsp": {
|
"lsp": {
|
||||||
"hookMode": "edit_write"
|
"hookMode": "edit_write"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -4,9 +4,15 @@ When the user provides a screenshot path (e.g., `/tmp/pi-clipboard-xxx.png`), **
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
|
||||||
|
**Prefer jj (Jujutsu) over git.** If a project has a colocated jj repo (`.jj` directory), use `jj` commands for all version control operations — rebasing, branching, log, etc. Only fall back to git when jj doesn't support something or the project isn't set up for it.
|
||||||
|
|
||||||
|
After pushing changes, always run `jj new` to start a fresh working copy commit.
|
||||||
|
|
||||||
# Git commits and PRs
|
# Git commits and PRs
|
||||||
|
|
||||||
Before writing any commits or PR titles, check recent 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:
|
My commit style:
|
||||||
|
|
||||||
@@ -61,6 +67,27 @@ npx mcporter emit-ts <server> --mode types
|
|||||||
|
|
||||||
Config location: `~/.mcporter/mcporter.json` (or `config/mcporter.json` in project)
|
Config location: `~/.mcporter/mcporter.json` (or `config/mcporter.json` in project)
|
||||||
|
|
||||||
|
### configuring servers quickly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# add a server to project-local config
|
||||||
|
npx mcporter config add <name> <url>
|
||||||
|
# example
|
||||||
|
npx mcporter config add svelte https://mcp.svelte.dev/mcp
|
||||||
|
|
||||||
|
# verify server + tool schemas
|
||||||
|
npx mcporter list
|
||||||
|
npx mcporter list svelte --schema
|
||||||
|
|
||||||
|
# inspect exact merged config source
|
||||||
|
npx mcporter config list --json
|
||||||
|
|
||||||
|
# if OAuth server, complete auth
|
||||||
|
npx mcporter auth <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
If `mcporter list` shows no servers, first run `npx mcporter config add ...` (or import from editor config with `npx mcporter config import cursor --copy`).
|
||||||
|
|
||||||
## Commonly Used MCP Servers
|
## Commonly Used MCP Servers
|
||||||
|
|
||||||
Only use these servers and read about them when applicable.
|
Only use these servers and read about them when applicable.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
|
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||||
import type { TUI, Theme, KeybindingsManager, Component } from "@mariozechner/pi-tui";
|
import type { TUI, KeybindingsManager, Component } from "@mariozechner/pi-tui";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
export default function editSessionExtension(pi: ExtensionAPI) {
|
export default function editSessionExtension(pi: ExtensionAPI) {
|
||||||
@@ -59,7 +59,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
|
|||||||
ctx.ui.notify(`Editor exited with code ${result.status}`, "warning");
|
ctx.ui.notify(`Editor exited with code ${result.status}`, "warning");
|
||||||
}
|
}
|
||||||
|
|
||||||
done();
|
done(undefined);
|
||||||
|
|
||||||
// Return dummy component
|
// Return dummy component
|
||||||
return createDummyComponent();
|
return createDummyComponent();
|
||||||
@@ -69,7 +69,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
|
|||||||
await ctx.ui.custom<void>(factory);
|
await ctx.ui.custom<void>(factory);
|
||||||
|
|
||||||
// Signal that we're about to reload the session (so confirm-destructive skips)
|
// Signal that we're about to reload the session (so confirm-destructive skips)
|
||||||
pi.events.emit("edit-session:reload");
|
pi.events.emit("edit-session:reload", undefined);
|
||||||
|
|
||||||
// Reload the session by switching to the same file (forces re-read from disk)
|
// Reload the session by switching to the same file (forces re-read from disk)
|
||||||
ctx.ui.notify("Reloading session...", "info");
|
ctx.ui.notify("Reloading session...", "info");
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ export default function (pi: ExtensionAPI) {
|
|||||||
loader.onAbort = () => done(null);
|
loader.onAbort = () => done(null);
|
||||||
|
|
||||||
const doGenerate = async () => {
|
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 = {
|
const userMessage: Message = {
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -96,7 +97,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const response = await complete(
|
const response = await complete(
|
||||||
ctx.model!,
|
ctx.model!,
|
||||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||||
{ apiKey, signal: loader.signal },
|
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.stopReason === "aborted") {
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,10 +13,11 @@
|
|||||||
"vscode-languageserver-protocol": "^3.17.5"
|
"vscode-languageserver-protocol": "^3.17.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.56.3",
|
"@mariozechner/pi-ai": "^0.63.1",
|
||||||
"@mariozechner/pi-coding-agent": "^0.56.3",
|
"@mariozechner/pi-coding-agent": "^0.63.1",
|
||||||
"@mariozechner/pi-tui": "^0.56.3",
|
"@mariozechner/pi-tui": "^0.63.1",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
},
|
},
|
||||||
"pi": {},
|
"pi": {},
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function(pi: ExtensionAPI) {
|
||||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
// const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||||
|
const dangerousPatterns = [/\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||||
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
if (event.toolName !== "bash") return undefined;
|
if (event.toolName !== "bash") return undefined;
|
||||||
|
|||||||
@@ -211,12 +211,12 @@ function updateWidget(ctx: ExtensionContext): void {
|
|||||||
(resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
|
(resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0));
|
ctx.ui.setWidget("web-activity", lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEntryLine(
|
function formatEntryLine(
|
||||||
entry: ActivityEntry,
|
entry: ActivityEntry,
|
||||||
theme: { fg: (color: string, text: string) => string },
|
theme: ExtensionContext["ui"]["theme"],
|
||||||
): string {
|
): string {
|
||||||
const typeStr = entry.type === "api" ? "API" : "GET";
|
const typeStr = entry.type === "api" ? "API" : "GET";
|
||||||
const target =
|
const target =
|
||||||
@@ -550,7 +550,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
} else {
|
} else {
|
||||||
widgetUnsubscribe?.();
|
widgetUnsubscribe?.();
|
||||||
widgetUnsubscribe = null;
|
widgetUnsubscribe = null;
|
||||||
ctx.ui.setWidget("web-activity", 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 queryList = params.queries ?? (params.query ? [params.query] : []);
|
||||||
const isMultiQuery = queryList.length > 1;
|
const isMultiQuery = queryList.length > 1;
|
||||||
const shouldCurate = params.curate !== false && ctx?.hasUI !== false;
|
const shouldCurate = params.curate !== false && ctx?.hasUI !== false;
|
||||||
@@ -613,7 +613,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (shouldCurate) {
|
if (shouldCurate) {
|
||||||
closeCurator();
|
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 includeContent = params.includeContent ?? false;
|
||||||
const searchResults = new Map<number, QueryResultData>();
|
const searchResults = new Map<number, QueryResultData>();
|
||||||
const allUrls: string[] = [];
|
const allUrls: string[] = [];
|
||||||
@@ -637,7 +640,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
queryList,
|
queryList,
|
||||||
includeContent,
|
includeContent,
|
||||||
numResults: params.numResults,
|
numResults: params.numResults,
|
||||||
recencyFilter: params.recencyFilter,
|
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||||
domainFilter: params.domainFilter,
|
domainFilter: params.domainFilter,
|
||||||
availableProviders,
|
availableProviders,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
@@ -684,7 +687,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const { answer, results } = await search(queryList[qi], {
|
const { answer, results } = await search(queryList[qi], {
|
||||||
provider: defaultProvider as SearchProvider | undefined,
|
provider: defaultProvider as SearchProvider | undefined,
|
||||||
numResults: params.numResults,
|
numResults: params.numResults,
|
||||||
recencyFilter: params.recencyFilter,
|
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||||
domainFilter: params.domainFilter,
|
domainFilter: params.domainFilter,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -754,7 +757,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`;
|
text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text" as const, text }],
|
||||||
details: {
|
details: {
|
||||||
phase: "curate-window",
|
phase: "curate-window",
|
||||||
searchCount: searchResults.size,
|
searchCount: searchResults.size,
|
||||||
@@ -824,7 +827,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const { answer, results } = await search(query, {
|
const { answer, results } = await search(query, {
|
||||||
provider: resolvedProvider as SearchProvider | undefined,
|
provider: resolvedProvider as SearchProvider | undefined,
|
||||||
numResults: params.numResults,
|
numResults: params.numResults,
|
||||||
recencyFilter: params.recencyFilter,
|
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||||
domainFilter: params.domainFilter,
|
domainFilter: params.domainFilter,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -1117,7 +1120,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
`Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
|
`Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content: Array<{ 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) {
|
if (result.frames?.length) {
|
||||||
for (const frame of result.frames) {
|
for (const frame of result.frames) {
|
||||||
content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
|
content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
|
||||||
@@ -1290,7 +1296,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
|
urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx): Promise<any> {
|
||||||
const data = getResult(params.responseId);
|
const data = getResult(params.responseId);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return {
|
return {
|
||||||
@@ -1477,7 +1483,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.sendMessage({
|
pi.sendMessage({
|
||||||
customType: "web-search-results",
|
customType: "web-search-results",
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
display: "tool",
|
display: true,
|
||||||
details: { queryCount: results.length, totalResults: urls.length },
|
details: { queryCount: results.length, totalResults: urls.length },
|
||||||
}, { triggerTurn: true, deliverAs: "followUp" });
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export async function extractPDFToMarkdown(
|
|||||||
|
|
||||||
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
||||||
const metadata = await pdf.getMetadata();
|
const metadata = await pdf.getMetadata();
|
||||||
|
const info = (metadata.info ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
// Extract title from metadata or URL
|
// Extract title from metadata or URL
|
||||||
const metaTitle = metadata.info?.Title as string | undefined;
|
const metaTitle = typeof info.Title === "string" ? info.Title : undefined;
|
||||||
const urlTitle = extractTitleFromURL(url);
|
const urlTitle = extractTitleFromURL(url);
|
||||||
const title = metaTitle?.trim() || urlTitle;
|
const title = metaTitle?.trim() || urlTitle;
|
||||||
|
|
||||||
@@ -79,8 +80,9 @@ export async function extractPDFToMarkdown(
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`> Source: ${url}`);
|
lines.push(`> Source: ${url}`);
|
||||||
lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`);
|
lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`);
|
||||||
if (metadata.info?.Author) {
|
const author = typeof info.Author === "string" ? info.Author : undefined;
|
||||||
lines.push(`> Author: ${metadata.info.Author}`);
|
if (author) {
|
||||||
|
lines.push(`> Author: ${author}`);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("---");
|
lines.push("---");
|
||||||
|
|||||||
@@ -245,8 +245,8 @@ export async function condenseSearchResults(
|
|||||||
const model = ctx.modelRegistry.find(provider, modelId);
|
const model = ctx.modelRegistry.find(provider, modelId);
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
|
||||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||||
if (!apiKey) return null;
|
if (!auth.ok) return null;
|
||||||
|
|
||||||
const queryData = [...results.entries()]
|
const queryData = [...results.entries()]
|
||||||
.sort((a, b) => a[0] - b[0])
|
.sort((a, b) => a[0] - b[0])
|
||||||
@@ -281,7 +281,8 @@ export async function condenseSearchResults(
|
|||||||
: timeoutSignal;
|
: timeoutSignal;
|
||||||
|
|
||||||
const response = await complete(model, aiContext, {
|
const response = await complete(model, aiContext, {
|
||||||
apiKey,
|
apiKey: auth.apiKey,
|
||||||
|
headers: auth.headers,
|
||||||
signal: combinedSignal,
|
signal: combinedSignal,
|
||||||
max_tokens: MAX_TOKENS,
|
max_tokens: MAX_TOKENS,
|
||||||
} as any);
|
} as any);
|
||||||
|
|||||||
Generated
+31
-22
@@ -34,17 +34,20 @@ importers:
|
|||||||
version: 3.17.5
|
version: 3.17.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.56.3
|
specifier: ^0.63.1
|
||||||
version: 0.56.3(ws@8.19.0)(zod@4.3.6)
|
version: 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||||
'@mariozechner/pi-coding-agent':
|
'@mariozechner/pi-coding-agent':
|
||||||
specifier: ^0.56.3
|
specifier: ^0.63.1
|
||||||
version: 0.56.3(ws@8.19.0)(zod@4.3.6)
|
version: 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||||
'@mariozechner/pi-tui':
|
'@mariozechner/pi-tui':
|
||||||
specifier: ^0.56.3
|
specifier: ^0.63.1
|
||||||
version: 0.56.3
|
version: 0.63.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.3.3
|
specifier: ^25.3.3
|
||||||
version: 25.3.3
|
version: 25.3.3
|
||||||
|
'@types/turndown':
|
||||||
|
specifier: ^5.0.6
|
||||||
|
version: 5.0.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -289,22 +292,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
|
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.56.3':
|
'@mariozechner/pi-agent-core@0.63.1':
|
||||||
resolution: {integrity: sha512-TsI1zENf3wqqKPaERnj486Q4i6Y/y6lAZipLNcfDYUDxDrLwNfQ9EW9xukkbJfTZ8zjG3VZ2pBZe3C7wM51dVQ==}
|
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.56.3':
|
'@mariozechner/pi-ai@0.63.1':
|
||||||
resolution: {integrity: sha512-l4J+cVyVeBLAlGOY/osGDvsbTz0DySCQmR171G6SdbPvIeLGhIi6siZ+zHwq91GJYjv/wtu/08M08ag2mGZKeA==}
|
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@mariozechner/pi-coding-agent@0.56.3':
|
'@mariozechner/pi-coding-agent@0.63.1':
|
||||||
resolution: {integrity: sha512-yHgnadye+TT/4NWKBirZUjw/LWdNWTa7M4HJdX2RxRbwuj4q7RZ0Aqy+lQbOHEPDQYhxK3kZb9hjiAbbGficZQ==}
|
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
|
||||||
engines: {node: '>=20.6.0'}
|
engines: {node: '>=20.6.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@mariozechner/pi-tui@0.56.3':
|
'@mariozechner/pi-tui@0.63.1':
|
||||||
resolution: {integrity: sha512-eZ1P9QRKHp78hwx+lITr/mujZqe+eCwL/bOS9vXXkFP070RW4VYum0j7TJ4BrFEH/nNkXRS1tYCXYU05une1bA==}
|
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@mistralai/mistralai@1.14.1':
|
'@mistralai/mistralai@1.14.1':
|
||||||
@@ -568,6 +571,9 @@ packages:
|
|||||||
'@types/retry@0.12.0':
|
'@types/retry@0.12.0':
|
||||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||||
|
|
||||||
|
'@types/turndown@5.0.6':
|
||||||
|
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||||
|
|
||||||
@@ -1722,9 +1728,9 @@ snapshots:
|
|||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
yoctocolors: 2.1.2
|
yoctocolors: 2.1.2
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.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:
|
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:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
- aws-crt
|
- aws-crt
|
||||||
@@ -1734,7 +1740,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- 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:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||||
'@aws-sdk/client-bedrock-runtime': 3.1002.0
|
'@aws-sdk/client-bedrock-runtime': 3.1002.0
|
||||||
@@ -1758,13 +1764,14 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- 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:
|
dependencies:
|
||||||
'@mariozechner/jiti': 2.6.5
|
'@mariozechner/jiti': 2.6.5
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@mariozechner/pi-tui': 0.56.3
|
'@mariozechner/pi-tui': 0.63.1
|
||||||
'@silvia-odwyer/photon-node': 0.3.4
|
'@silvia-odwyer/photon-node': 0.3.4
|
||||||
|
ajv: 8.18.0
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
diff: 8.0.3
|
diff: 8.0.3
|
||||||
@@ -1790,7 +1797,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-tui@0.56.3':
|
'@mariozechner/pi-tui@0.63.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime-types': 2.1.4
|
'@types/mime-types': 2.1.4
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
@@ -2166,6 +2173,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/retry@0.12.0': {}
|
'@types/retry@0.12.0': {}
|
||||||
|
|
||||||
|
'@types/turndown@5.0.6': {}
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.3.3
|
'@types/node': 25.3.3
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import { complete, type Message } from "@mariozechner/pi-ai";
|
|||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
BorderedLoader,
|
BorderedLoader,
|
||||||
convertToLlm,
|
convertToLlm,
|
||||||
serializeConversation,
|
serializeConversation,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -43,306 +43,307 @@ Output ONLY the session name, nothing else.`;
|
|||||||
const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5");
|
const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5");
|
||||||
|
|
||||||
// Number of messages before auto-naming kicks in
|
// Number of messages before auto-naming kicks in
|
||||||
const AUTO_NAME_THRESHOLD = 2;
|
const AUTO_NAME_THRESHOLD = 1;
|
||||||
|
|
||||||
// Debug log file
|
// Debug log file
|
||||||
const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log");
|
const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log");
|
||||||
|
|
||||||
function log(message: string) {
|
function log(message: string) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const entry = `[${timestamp}] ${message}\n`;
|
const entry = `[${timestamp}] ${message}\n`;
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(LOG_FILE, entry);
|
fs.appendFileSync(LOG_FILE, entry);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore write errors
|
// ignore write errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function(pi: ExtensionAPI) {
|
||||||
// Track if we've already attempted auto-naming for this session
|
// Track if we've already attempted auto-naming for this session
|
||||||
let autoNamedAttempted = false;
|
let autoNamedAttempted = false;
|
||||||
|
|
||||||
// Listen for agent_end to auto-name sessions (non-blocking)
|
// Listen for agent_end to auto-name sessions (non-blocking)
|
||||||
pi.on("agent_end", (_event, ctx) => {
|
pi.on("agent_end", (_event, ctx) => {
|
||||||
log("=== agent_end triggered ===");
|
log("=== agent_end triggered ===");
|
||||||
log(`hasUI: ${ctx.hasUI}`);
|
log(`hasUI: ${ctx.hasUI}`);
|
||||||
log(`current session name: ${pi.getSessionName()}`);
|
log(`current session name: ${pi.getSessionName()}`);
|
||||||
log(`autoNamedAttempted: ${autoNamedAttempted}`);
|
log(`autoNamedAttempted: ${autoNamedAttempted}`);
|
||||||
|
|
||||||
// Skip if already has a name or already attempted
|
// Skip if already has a name or already attempted
|
||||||
if (pi.getSessionName() || autoNamedAttempted) {
|
if (pi.getSessionName() || autoNamedAttempted) {
|
||||||
log("Skipping: already has name or already attempted");
|
log("Skipping: already has name or already attempted");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count user messages in the branch
|
// Count user messages in the branch
|
||||||
const branch = ctx.sessionManager.getBranch();
|
const branch = ctx.sessionManager.getBranch();
|
||||||
const userMessages = branch.filter(
|
const userMessages = branch.filter(
|
||||||
(entry): entry is SessionEntry & { type: "message" } =>
|
(entry): entry is SessionEntry & { type: "message" } =>
|
||||||
entry.type === "message" && entry.message.role === "user",
|
entry.type === "message" && entry.message.role === "user",
|
||||||
);
|
);
|
||||||
|
|
||||||
log(`Total entries in branch: ${branch.length}`);
|
log(`Total entries in branch: ${branch.length}`);
|
||||||
log(`User messages: ${userMessages.length}`);
|
log(`User messages: ${userMessages.length}`);
|
||||||
log(`Threshold: ${AUTO_NAME_THRESHOLD}`);
|
log(`Threshold: ${AUTO_NAME_THRESHOLD}`);
|
||||||
|
|
||||||
// Only auto-name after threshold is reached
|
// Only auto-name after threshold is reached
|
||||||
if (userMessages.length < AUTO_NAME_THRESHOLD) {
|
if (userMessages.length < AUTO_NAME_THRESHOLD) {
|
||||||
log("Skipping: below threshold");
|
log("Skipping: below threshold");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as attempted so we don't try again
|
// Mark as attempted so we don't try again
|
||||||
autoNamedAttempted = true;
|
autoNamedAttempted = true;
|
||||||
log("Threshold reached, attempting auto-name");
|
log("Threshold reached, attempting auto-name");
|
||||||
|
|
||||||
// Only auto-name in interactive mode
|
// Only auto-name in interactive mode
|
||||||
if (!ctx.hasUI) {
|
if (!ctx.hasUI) {
|
||||||
log("Skipping: no UI (non-interactive mode)");
|
log("Skipping: no UI (non-interactive mode)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather conversation context
|
// Gather conversation context
|
||||||
const messages = branch
|
const messages = branch
|
||||||
.filter(
|
.filter(
|
||||||
(entry): entry is SessionEntry & { type: "message" } =>
|
(entry): entry is SessionEntry & { type: "message" } =>
|
||||||
entry.type === "message",
|
entry.type === "message",
|
||||||
)
|
)
|
||||||
.map((entry) => entry.message);
|
.map((entry) => entry.message);
|
||||||
|
|
||||||
log(`Total messages to analyze: ${messages.length}`);
|
log(`Total messages to analyze: ${messages.length}`);
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
log("No messages found, aborting");
|
log("No messages found, aborting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to LLM format and serialize
|
// Convert to LLM format and serialize
|
||||||
const llmMessages = convertToLlm(messages);
|
const llmMessages = convertToLlm(messages);
|
||||||
const conversationText = serializeConversation(llmMessages);
|
const conversationText = serializeConversation(llmMessages);
|
||||||
|
|
||||||
log(`Conversation text length: ${conversationText.length}`);
|
log(`Conversation text length: ${conversationText.length}`);
|
||||||
|
|
||||||
// Truncate if too long (keep costs low)
|
// Truncate if too long (keep costs low)
|
||||||
const maxChars = 4000;
|
const maxChars = 4000;
|
||||||
const truncatedText =
|
const truncatedText =
|
||||||
conversationText.length > maxChars
|
conversationText.length > maxChars
|
||||||
? conversationText.slice(0, maxChars) + "\n..."
|
? conversationText.slice(0, maxChars) + "\n..."
|
||||||
: conversationText;
|
: conversationText;
|
||||||
|
|
||||||
log(`Truncated text length: ${truncatedText.length}`);
|
log(`Truncated text length: ${truncatedText.length}`);
|
||||||
log("Starting background auto-name...");
|
log("Starting background auto-name...");
|
||||||
|
|
||||||
// Fire-and-forget: run auto-naming in background without blocking
|
// Fire-and-forget: run auto-naming in background without blocking
|
||||||
const doAutoName = async () => {
|
const doAutoName = async () => {
|
||||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL);
|
||||||
log(`Got API key: ${apiKey ? "yes" : "no"}`);
|
log(`Got API key: ${auth.ok ? "yes" : "no"}`);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!auth.ok) {
|
||||||
log("No API key available, aborting");
|
log(`No API key available, aborting: ${auth.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await complete(
|
const response = await complete(
|
||||||
AUTO_NAME_MODEL,
|
AUTO_NAME_MODEL,
|
||||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||||
{ apiKey },
|
{ apiKey: auth.apiKey, headers: auth.headers },
|
||||||
);
|
);
|
||||||
|
|
||||||
log(`Response received, stopReason: ${response.stopReason}`);
|
log(`Response received, stopReason: ${response.stopReason}`);
|
||||||
|
|
||||||
if (response.stopReason === "aborted") {
|
if (response.stopReason === "aborted") {
|
||||||
log("Request was aborted");
|
log("Request was aborted");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = response.content
|
const name = response.content
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
.map((c) => c.text.trim())
|
.map((c) => c.text.trim())
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
||||||
|
|
||||||
log(`Generated name: "${name}"`);
|
log(`Generated name: "${name}"`);
|
||||||
|
|
||||||
// Clean up the generated name
|
// Clean up the generated name
|
||||||
const cleanName = name
|
const cleanName = name
|
||||||
.replace(/\n/g, " ")
|
.replace(/\n/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 50); // Max 50 chars
|
.slice(0, 50); // Max 50 chars
|
||||||
|
|
||||||
log(`Cleaned name: "${cleanName}"`);
|
log(`Cleaned name: "${cleanName}"`);
|
||||||
|
|
||||||
if (cleanName) {
|
if (cleanName) {
|
||||||
pi.setSessionName(cleanName);
|
pi.setSessionName(cleanName);
|
||||||
ctx.ui.notify(`Auto-named: ${cleanName}`, "info");
|
ctx.ui.notify(`Auto-named: ${cleanName}`, "info");
|
||||||
log(`Successfully set session name to: ${cleanName}`);
|
log(`Successfully set session name to: ${cleanName}`);
|
||||||
} else {
|
} else {
|
||||||
log("Cleaned name was empty, not setting");
|
log("Cleaned name was empty, not setting");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run in background without awaiting - don't block the agent
|
// Run in background without awaiting - don't block the agent
|
||||||
doAutoName().catch((err) => {
|
doAutoName().catch((err) => {
|
||||||
log(`ERROR: ${err}`);
|
log(`ERROR: ${err}`);
|
||||||
console.error("Auto-naming failed:", err);
|
console.error("Auto-naming failed:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset flag on new session
|
// Reset flag on new session
|
||||||
pi.on("session_start", () => {
|
pi.on("session_start", () => {
|
||||||
log("=== session_start ===");
|
log("=== session_start ===");
|
||||||
autoNamedAttempted = false;
|
autoNamedAttempted = false;
|
||||||
log("Reset autoNamedAttempted to false");
|
log("Reset autoNamedAttempted to false");
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_switch", () => {
|
pi.on("session_switch", () => {
|
||||||
log("=== session_switch ===");
|
log("=== session_switch ===");
|
||||||
autoNamedAttempted = false;
|
autoNamedAttempted = false;
|
||||||
log("Reset autoNamedAttempted to false");
|
log("Reset autoNamedAttempted to false");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manual command for setting/getting session name
|
// Manual command for setting/getting session name
|
||||||
pi.registerCommand("session-name", {
|
pi.registerCommand("session-name", {
|
||||||
description:
|
description:
|
||||||
"Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)",
|
"Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)",
|
||||||
handler: async (args, ctx) => {
|
handler: async (args, ctx) => {
|
||||||
const trimmedArgs = args.trim();
|
const trimmedArgs = args.trim();
|
||||||
|
|
||||||
// Show current name if no args
|
// Show current name if no args
|
||||||
if (!trimmedArgs) {
|
if (!trimmedArgs) {
|
||||||
const current = pi.getSessionName();
|
const current = pi.getSessionName();
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
current ? `Session: ${current}` : "No session name set",
|
current ? `Session: ${current}` : "No session name set",
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-generate name using AI
|
// Auto-generate name using AI
|
||||||
if (trimmedArgs === "--auto" || trimmedArgs === "-a") {
|
if (trimmedArgs === "--auto" || trimmedArgs === "-a") {
|
||||||
if (!ctx.hasUI) {
|
if (!ctx.hasUI) {
|
||||||
ctx.ui.notify("Auto-naming requires interactive mode", "error");
|
ctx.ui.notify("Auto-naming requires interactive mode", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather conversation context
|
// Gather conversation context
|
||||||
const branch = ctx.sessionManager.getBranch();
|
const branch = ctx.sessionManager.getBranch();
|
||||||
const messages = branch
|
const messages = branch
|
||||||
.filter(
|
.filter(
|
||||||
(entry): entry is SessionEntry & { type: "message" } =>
|
(entry): entry is SessionEntry & { type: "message" } =>
|
||||||
entry.type === "message",
|
entry.type === "message",
|
||||||
)
|
)
|
||||||
.map((entry) => entry.message);
|
.map((entry) => entry.message);
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
ctx.ui.notify("No conversation to analyze", "error");
|
ctx.ui.notify("No conversation to analyze", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to LLM format and serialize
|
// Convert to LLM format and serialize
|
||||||
const llmMessages = convertToLlm(messages);
|
const llmMessages = convertToLlm(messages);
|
||||||
const conversationText = serializeConversation(llmMessages);
|
const conversationText = serializeConversation(llmMessages);
|
||||||
|
|
||||||
// Truncate if too long (keep costs low)
|
// Truncate if too long (keep costs low)
|
||||||
const maxChars = 4000;
|
const maxChars = 4000;
|
||||||
const truncatedText =
|
const truncatedText =
|
||||||
conversationText.length > maxChars
|
conversationText.length > maxChars
|
||||||
? conversationText.slice(0, maxChars) + "\n..."
|
? conversationText.slice(0, maxChars) + "\n..."
|
||||||
: conversationText;
|
: conversationText;
|
||||||
|
|
||||||
// Generate name with loader UI
|
// Generate name with loader UI
|
||||||
const result = await ctx.ui.custom<string | null>(
|
const result = await ctx.ui.custom<string | null>(
|
||||||
(tui, theme, _kb, done) => {
|
(tui, theme, _kb, done) => {
|
||||||
const loader = new BorderedLoader(
|
const loader = new BorderedLoader(
|
||||||
tui,
|
tui,
|
||||||
theme,
|
theme,
|
||||||
"Generating session name...",
|
"Generating session name...",
|
||||||
);
|
);
|
||||||
loader.onAbort = () => done(null);
|
loader.onAbort = () => done(null);
|
||||||
|
|
||||||
const doGenerate = async () => {
|
const doGenerate = async () => {
|
||||||
const 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 = {
|
const userMessage: Message = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await complete(
|
const response = await complete(
|
||||||
AUTO_NAME_MODEL,
|
AUTO_NAME_MODEL,
|
||||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||||
{ apiKey, signal: loader.signal },
|
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.stopReason === "aborted") {
|
if (response.stopReason === "aborted") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = response.content
|
const name = response.content
|
||||||
.filter(
|
.filter(
|
||||||
(c): c is { type: "text"; text: string } => c.type === "text",
|
(c): c is { type: "text"; text: string } => c.type === "text",
|
||||||
)
|
)
|
||||||
.map((c) => c.text.trim())
|
.map((c) => c.text.trim())
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
||||||
|
|
||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
doGenerate()
|
doGenerate()
|
||||||
.then(done)
|
.then(done)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Auto-naming failed:", err);
|
console.error("Auto-naming failed:", err);
|
||||||
done(null);
|
done(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
return loader;
|
return loader;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
ctx.ui.notify("Auto-naming cancelled", "info");
|
ctx.ui.notify("Auto-naming cancelled", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the generated name
|
// Clean up the generated name
|
||||||
const cleanName = result
|
const cleanName = result
|
||||||
.replace(/\n/g, " ")
|
.replace(/\n/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 50); // Max 50 chars
|
.slice(0, 50); // Max 50 chars
|
||||||
|
|
||||||
if (!cleanName) {
|
if (!cleanName) {
|
||||||
ctx.ui.notify("Failed to generate name", "error");
|
ctx.ui.notify("Failed to generate name", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pi.setSessionName(cleanName);
|
pi.setSessionName(cleanName);
|
||||||
ctx.ui.notify(`Session auto-named: ${cleanName}`, "info");
|
ctx.ui.notify(`Session auto-named: ${cleanName}`, "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual naming
|
// Manual naming
|
||||||
pi.setSessionName(trimmedArgs);
|
pi.setSessionName(trimmedArgs);
|
||||||
ctx.ui.notify(`Session named: ${trimmedArgs}`, "info");
|
ctx.ui.notify(`Session named: ${trimmedArgs}`, "info");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* - Injects timestamp markers without triggering extra turns
|
* - Injects timestamp markers without triggering extra turns
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
import { Box, Text } from "@mariozechner/pi-tui";
|
import { Box, Text } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
// Track session time
|
// Track session time
|
||||||
@@ -41,12 +41,7 @@ function formatDuration(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
const updateStatus = (ctx: {
|
const updateStatus = (ctx: ExtensionContext) => {
|
||||||
ui: {
|
|
||||||
setStatus: (id: string, text: string | undefined) => void;
|
|
||||||
theme: { fg: (color: string, text: string) => string };
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const elapsed = Date.now() - sessionStart;
|
const elapsed = Date.now() - sessionStart;
|
||||||
let status = ctx.ui.theme.fg("dim", `⏱ ${formatElapsed(elapsed)}`);
|
let status = ctx.ui.theme.fg("dim", `⏱ ${formatElapsed(elapsed)}`);
|
||||||
if (lastTurnDuration !== null) {
|
if (lastTurnDuration !== null) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"selectModel": "ctrl+space"
|
"app.model.select": "ctrl+space"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
name: linear
|
||||||
|
description: Access Linear issue tracker - search, view, create, update issues, list teams/projects, and manage comments. Use when the user asks about Linear issues, tasks, tickets, or project management in Linear.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Linear
|
||||||
|
|
||||||
|
Manage Linear issues, projects, and teams via the Linear SDK.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Run once before first use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd {baseDir} && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a `LINEAR_API_KEY` environment variable. Generate one at: https://linear.app/settings/api (Personal API keys).
|
||||||
|
|
||||||
|
Set it in your shell profile or pi settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LINEAR_API_KEY=lin_api_...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-me.js # Show authenticated user
|
||||||
|
node {baseDir}/linear-me.js --issues # Show user + their active issues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-search.js "query" # Text search
|
||||||
|
node {baseDir}/linear-search.js "query" -n 20 # More results
|
||||||
|
node {baseDir}/linear-search.js "query" --team ENG # Filter by team
|
||||||
|
node {baseDir}/linear-search.js "query" --state "In Progress" # Filter by state
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Issues (with filters)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-issues.js # All recent issues
|
||||||
|
node {baseDir}/linear-issues.js --team ENG # By team
|
||||||
|
node {baseDir}/linear-issues.js --state "In Progress" # By state
|
||||||
|
node {baseDir}/linear-issues.js --assignee me # My issues
|
||||||
|
node {baseDir}/linear-issues.js --assignee "John" # By assignee name
|
||||||
|
node {baseDir}/linear-issues.js --label "Bug" # By label
|
||||||
|
node {baseDir}/linear-issues.js --project "Q1 Goals" # By project
|
||||||
|
node {baseDir}/linear-issues.js --team ENG --state Todo -n 50 # Combined filters
|
||||||
|
```
|
||||||
|
|
||||||
|
## View Issue Details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-issue.js ATT-1234 # Full issue details
|
||||||
|
node {baseDir}/linear-issue.js ATT-1234 --comments # Include comments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create Issue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-create.js --team ENG --title "Fix login bug"
|
||||||
|
node {baseDir}/linear-create.js --team ENG --title "New feature" --description "Details here" --state Todo --priority 2 --assignee me --label "Feature"
|
||||||
|
node {baseDir}/linear-create.js --team ENG --title "Sub-task" --parent ATT-100
|
||||||
|
```
|
||||||
|
|
||||||
|
Priority values: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low
|
||||||
|
|
||||||
|
## Update Issue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-update.js ATT-1234 --state "In Progress"
|
||||||
|
node {baseDir}/linear-update.js ATT-1234 --assignee me --priority 2
|
||||||
|
node {baseDir}/linear-update.js ATT-1234 --title "New title" --description "Updated desc"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Comment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-comment.js ATT-1234 "This is done in PR #567"
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Teams
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-teams.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Projects
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node {baseDir}/linear-projects.js # All projects
|
||||||
|
node {baseDir}/linear-projects.js --team ENG # By team
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Use `--assignee me` to filter by the authenticated user
|
||||||
|
- Issue identifiers follow the pattern `TEAM-NUMBER` (e.g. `ATT-1234`, `ENG-567`)
|
||||||
|
- Descriptions support markdown formatting
|
||||||
|
- State names are case-insensitive (e.g. "todo", "Todo", "TODO" all work)
|
||||||
|
- When creating issues, the team key is required; use `linear-teams.js` to find available teams
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { LinearClient } from "@linear/sdk";
|
||||||
|
|
||||||
|
export function getClient() {
|
||||||
|
const apiKey = process.env.LINEAR_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error("Error: LINEAR_API_KEY environment variable is required.");
|
||||||
|
console.error(
|
||||||
|
"Generate one at: https://linear.app/settings/api (Personal API keys)"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return new LinearClient({ apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date) {
|
||||||
|
if (!date) return "";
|
||||||
|
return new Date(date).toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str, len = 120) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.length > len ? str.slice(0, len) + "…" : str;
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Add a comment to a Linear issue
|
||||||
|
// Usage: linear-comment.js <identifier> <body>
|
||||||
|
|
||||||
|
import { getClient } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const identifier = args[0];
|
||||||
|
const body = args.slice(1).join(" ");
|
||||||
|
|
||||||
|
if (!identifier || !body) {
|
||||||
|
console.log("Usage: linear-comment.js <identifier> <body>");
|
||||||
|
console.log("\nExamples:");
|
||||||
|
console.log(' linear-comment.js ATT-1234 "This is fixed in the latest PR"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const results = await client.searchIssues(identifier, { first: 1 });
|
||||||
|
const issue = results.nodes[0];
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue '${identifier}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.createComment({ issueId: issue.id, body });
|
||||||
|
console.log(`Comment added to ${issue.identifier}.`);
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Create a new Linear issue
|
||||||
|
// Usage: linear-create.js --team <key> --title <title> [--description <desc>] [--state <name>] [--priority <0-4>] [--assignee <name|me>] [--label <name>] [--parent <identifier>]
|
||||||
|
|
||||||
|
import { getClient } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function extractArg(flag) {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx !== -1 && args[idx + 1]) {
|
||||||
|
const val = args[idx + 1];
|
||||||
|
args.splice(idx, 2);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamKey = extractArg("--team");
|
||||||
|
const title = extractArg("--title");
|
||||||
|
const description = extractArg("--description");
|
||||||
|
const stateName = extractArg("--state");
|
||||||
|
const priority = extractArg("--priority");
|
||||||
|
const assigneeName = extractArg("--assignee");
|
||||||
|
const labelName = extractArg("--label");
|
||||||
|
const parentId = extractArg("--parent");
|
||||||
|
|
||||||
|
if (!teamKey || !title) {
|
||||||
|
console.log("Usage: linear-create.js --team <key> --title <title> [options]");
|
||||||
|
console.log("\nRequired:");
|
||||||
|
console.log(" --team <key> Team key (e.g. ENG)");
|
||||||
|
console.log(' --title <title> Issue title');
|
||||||
|
console.log("\nOptional:");
|
||||||
|
console.log(" --description <text> Issue description (markdown)");
|
||||||
|
console.log(" --state <name> Initial state (e.g. 'Todo')");
|
||||||
|
console.log(" --priority <0-4> Priority: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low");
|
||||||
|
console.log(" --assignee <name|me> Assignee name or 'me'");
|
||||||
|
console.log(" --label <name> Label name");
|
||||||
|
console.log(" --parent <id> Parent issue identifier (e.g. ATT-100)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
// Resolve team
|
||||||
|
const teams = await client.teams({ filter: { key: { eq: teamKey.toUpperCase() } } });
|
||||||
|
const team = teams.nodes[0];
|
||||||
|
if (!team) {
|
||||||
|
console.error(`Team '${teamKey}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
teamId: team.id,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (description) input.description = description;
|
||||||
|
if (priority) input.priority = parseInt(priority, 10);
|
||||||
|
|
||||||
|
// Resolve state
|
||||||
|
if (stateName) {
|
||||||
|
const states = await team.states();
|
||||||
|
const state = states.nodes.find(
|
||||||
|
(s) => s.name.toLowerCase() === stateName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (state) input.stateId = state.id;
|
||||||
|
else console.warn(`Warning: State '${stateName}' not found, using default.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve assignee
|
||||||
|
if (assigneeName) {
|
||||||
|
if (assigneeName.toLowerCase() === "me") {
|
||||||
|
const me = await client.viewer;
|
||||||
|
input.assigneeId = me.id;
|
||||||
|
} else {
|
||||||
|
const users = await client.users({ filter: { name: { containsIgnoreCase: assigneeName } } });
|
||||||
|
if (users.nodes[0]) input.assigneeId = users.nodes[0].id;
|
||||||
|
else console.warn(`Warning: User '${assigneeName}' not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve label
|
||||||
|
if (labelName) {
|
||||||
|
const labels = await client.issueLabels({ filter: { name: { eqIgnoreCase: labelName } } });
|
||||||
|
if (labels.nodes[0]) input.labelIds = [labels.nodes[0].id];
|
||||||
|
else console.warn(`Warning: Label '${labelName}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve parent
|
||||||
|
if (parentId) {
|
||||||
|
const parentSearch = await client.searchIssues(parentId, { first: 1 });
|
||||||
|
if (parentSearch.nodes[0]) input.parentId = parentSearch.nodes[0].id;
|
||||||
|
else console.warn(`Warning: Parent '${parentId}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.createIssue(input);
|
||||||
|
const issue = await result.issue;
|
||||||
|
|
||||||
|
console.log(`Created: ${issue.identifier} - ${issue.title}`);
|
||||||
|
console.log(`URL: ${issue.url}`);
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Get details for a specific Linear issue
|
||||||
|
// Usage: linear-issue.js <identifier> [--comments]
|
||||||
|
|
||||||
|
import { getClient, formatDate } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
const showComments = args.includes("--comments");
|
||||||
|
const filtered = args.filter((a) => a !== "--comments");
|
||||||
|
|
||||||
|
const identifier = filtered[0];
|
||||||
|
|
||||||
|
if (!identifier) {
|
||||||
|
console.log("Usage: linear-issue.js <identifier> [--comments]");
|
||||||
|
console.log("\nExamples:");
|
||||||
|
console.log(" linear-issue.js ATT-1234");
|
||||||
|
console.log(" linear-issue.js ATT-1234 --comments");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
// Parse team key and issue number from identifier (e.g. "SIP-1205")
|
||||||
|
const parts = identifier.match(/^([A-Za-z]+)-(\d+)$/);
|
||||||
|
if (!parts) {
|
||||||
|
console.error(`Invalid identifier format: ${identifier}. Expected format: TEAM-123`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamKey = parts[1].toUpperCase();
|
||||||
|
const issueNumber = parseInt(parts[2], 10);
|
||||||
|
|
||||||
|
// Find the issue by team key + number
|
||||||
|
const issues = await client.issues({
|
||||||
|
filter: {
|
||||||
|
team: { key: { eq: teamKey } },
|
||||||
|
number: { eq: issueNumber },
|
||||||
|
},
|
||||||
|
first: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = issues.nodes[0];
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue ${identifier} not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await issue.state;
|
||||||
|
const team = await issue.team;
|
||||||
|
const assignee = await issue.assignee;
|
||||||
|
const labels = await issue.labels();
|
||||||
|
const parent = await issue.parent;
|
||||||
|
const project = await issue.project;
|
||||||
|
const cycle = await issue.cycle;
|
||||||
|
|
||||||
|
console.log(`=== ${issue.identifier}: ${issue.title} ===`);
|
||||||
|
console.log(`URL: ${issue.url}`);
|
||||||
|
console.log(`State: ${state?.name || "Unknown"}`);
|
||||||
|
console.log(`Priority: ${issue.priorityLabel}`);
|
||||||
|
console.log(`Team: ${team?.key || "?"}`);
|
||||||
|
console.log(`Assignee: ${assignee?.name || "Unassigned"}`);
|
||||||
|
if (project) console.log(`Project: ${project.name}`);
|
||||||
|
if (cycle) console.log(`Cycle: ${cycle.name || cycle.number}`);
|
||||||
|
if (parent) console.log(`Parent: ${parent.identifier} - ${parent.title}`);
|
||||||
|
if (labels.nodes.length > 0) {
|
||||||
|
console.log(`Labels: ${labels.nodes.map((l) => l.name).join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log(`Created: ${formatDate(issue.createdAt)}`);
|
||||||
|
console.log(`Updated: ${formatDate(issue.updatedAt)}`);
|
||||||
|
if (issue.dueDate) console.log(`Due: ${issue.dueDate}`);
|
||||||
|
console.log(`\nDescription:\n${issue.description || "(empty)"}`);
|
||||||
|
|
||||||
|
if (showComments) {
|
||||||
|
const comments = await issue.comments();
|
||||||
|
if (comments.nodes.length > 0) {
|
||||||
|
console.log(`\n--- Comments (${comments.nodes.length}) ---`);
|
||||||
|
for (const comment of comments.nodes) {
|
||||||
|
const author = await comment.user;
|
||||||
|
console.log(`\n[${formatDate(comment.createdAt)}] ${author?.name || "Unknown"}:`);
|
||||||
|
console.log(comment.body);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("\nNo comments.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// List Linear issues with filters
|
||||||
|
// Usage: linear-issues.js [--team <key>] [--state <name>] [--assignee <name|me>] [--label <name>] [--project <name>] [-n <num>]
|
||||||
|
|
||||||
|
import { getClient, formatDate, truncate } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function extractArg(flag) {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx !== -1 && args[idx + 1]) {
|
||||||
|
const val = args[idx + 1];
|
||||||
|
args.splice(idx, 2);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numResults = parseInt(extractArg("-n") || "25", 10);
|
||||||
|
const teamKey = extractArg("--team");
|
||||||
|
const stateName = extractArg("--state");
|
||||||
|
const assigneeName = extractArg("--assignee");
|
||||||
|
const labelName = extractArg("--label");
|
||||||
|
const projectName = extractArg("--project");
|
||||||
|
|
||||||
|
if (args.includes("--help") || args.includes("-h")) {
|
||||||
|
console.log("Usage: linear-issues.js [options]");
|
||||||
|
console.log("\nOptions:");
|
||||||
|
console.log(" --team <key> Filter by team key (e.g. ENG)");
|
||||||
|
console.log(" --state <name> Filter by state (e.g. 'In Progress', 'Todo')");
|
||||||
|
console.log(" --assignee <name> Filter by assignee name or 'me'");
|
||||||
|
console.log(" --label <name> Filter by label name");
|
||||||
|
console.log(" --project <name> Filter by project name");
|
||||||
|
console.log(" -n <num> Number of results (default: 25)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = {};
|
||||||
|
|
||||||
|
if (teamKey) {
|
||||||
|
filter.team = { key: { eq: teamKey.toUpperCase() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateName) {
|
||||||
|
filter.state = { name: { eqIgnoreCase: stateName } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigneeName) {
|
||||||
|
if (assigneeName.toLowerCase() === "me") {
|
||||||
|
const me = await client.viewer;
|
||||||
|
filter.assignee = { id: { eq: me.id } };
|
||||||
|
} else {
|
||||||
|
filter.assignee = { name: { containsIgnoreCase: assigneeName } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelName) {
|
||||||
|
filter.labels = { name: { eqIgnoreCase: labelName } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectName) {
|
||||||
|
filter.project = { name: { containsIgnoreCase: projectName } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = await client.issues({
|
||||||
|
filter,
|
||||||
|
first: numResults,
|
||||||
|
orderBy: "updatedAt",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (issues.nodes.length === 0) {
|
||||||
|
console.log("No issues found matching filters.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const issue of issues.nodes) {
|
||||||
|
const state = await issue.state;
|
||||||
|
const team = await issue.team;
|
||||||
|
const assignee = await issue.assignee;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${issue.identifier.padEnd(12)} ${(state?.name || "?").padEnd(14)} ${(issue.priorityLabel || "").padEnd(8)} ${(assignee?.name || "Unassigned").padEnd(20)} ${truncate(issue.title, 80)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${issues.nodes.length} issue(s) shown.`);
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Show current authenticated user and their assigned issues
|
||||||
|
// Usage: linear-me.js [--issues]
|
||||||
|
|
||||||
|
import { getClient, truncate } from "./lib.js";
|
||||||
|
|
||||||
|
const showIssues = process.argv.includes("--issues");
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const me = await client.viewer;
|
||||||
|
console.log(`User: ${me.name}`);
|
||||||
|
console.log(`Email: ${me.email}`);
|
||||||
|
console.log(`ID: ${me.id}`);
|
||||||
|
|
||||||
|
if (showIssues) {
|
||||||
|
const issues = await me.assignedIssues({
|
||||||
|
first: 25,
|
||||||
|
filter: {
|
||||||
|
state: { type: { nin: ["completed", "canceled"] } },
|
||||||
|
},
|
||||||
|
orderBy: "updatedAt",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n--- Active Assigned Issues (${issues.nodes.length}) ---`);
|
||||||
|
for (const issue of issues.nodes) {
|
||||||
|
const state = await issue.state;
|
||||||
|
console.log(
|
||||||
|
`${issue.identifier.padEnd(12)} ${(state?.name || "?").padEnd(14)} ${(issue.priorityLabel || "").padEnd(8)} ${truncate(issue.title, 80)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// List Linear projects
|
||||||
|
// Usage: linear-projects.js [--team <key>] [-n <num>]
|
||||||
|
|
||||||
|
import { getClient, formatDate } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function extractArg(flag) {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx !== -1 && args[idx + 1]) {
|
||||||
|
const val = args[idx + 1];
|
||||||
|
args.splice(idx, 2);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numResults = parseInt(extractArg("-n") || "25", 10);
|
||||||
|
const teamKey = extractArg("--team");
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const filter = {};
|
||||||
|
if (teamKey) {
|
||||||
|
filter.accessibleTeams = { key: { eq: teamKey.toUpperCase() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await client.projects({ filter, first: numResults });
|
||||||
|
|
||||||
|
if (projects.nodes.length === 0) {
|
||||||
|
console.log("No projects found.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const project of projects.nodes) {
|
||||||
|
const lead = await project.lead;
|
||||||
|
console.log(`--- ${project.name} ---`);
|
||||||
|
console.log(`State: ${project.state} | Progress: ${Math.round(project.progress * 100)}%`);
|
||||||
|
if (lead) console.log(`Lead: ${lead.name}`);
|
||||||
|
if (project.targetDate) console.log(`Target: ${project.targetDate}`);
|
||||||
|
console.log(`URL: ${project.url}`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Search Linear issues by text query
|
||||||
|
// Usage: linear-search.js <query> [-n <num>] [--team <key>] [--state <name>]
|
||||||
|
|
||||||
|
import { getClient, formatDate, truncate } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
let numResults = 10;
|
||||||
|
const nIdx = args.indexOf("-n");
|
||||||
|
if (nIdx !== -1 && args[nIdx + 1]) {
|
||||||
|
numResults = parseInt(args[nIdx + 1], 10);
|
||||||
|
args.splice(nIdx, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let teamFilter = null;
|
||||||
|
const teamIdx = args.indexOf("--team");
|
||||||
|
if (teamIdx !== -1 && args[teamIdx + 1]) {
|
||||||
|
teamFilter = args[teamIdx + 1];
|
||||||
|
args.splice(teamIdx, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stateFilter = null;
|
||||||
|
const stateIdx = args.indexOf("--state");
|
||||||
|
if (stateIdx !== -1 && args[stateIdx + 1]) {
|
||||||
|
stateFilter = args[stateIdx + 1];
|
||||||
|
args.splice(stateIdx, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = args.join(" ");
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
console.log("Usage: linear-search.js <query> [-n <num>] [--team <key>] [--state <name>]");
|
||||||
|
console.log("\nOptions:");
|
||||||
|
console.log(" -n <num> Number of results (default: 10)");
|
||||||
|
console.log(" --team <key> Filter by team key (e.g. ENG)");
|
||||||
|
console.log(" --state <name> Filter by state name (e.g. 'In Progress')");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const results = await client.searchIssues(query, { first: numResults });
|
||||||
|
|
||||||
|
for (const issue of results.nodes) {
|
||||||
|
const state = await issue.state;
|
||||||
|
const team = await issue.team;
|
||||||
|
const assignee = await issue.assignee;
|
||||||
|
|
||||||
|
if (teamFilter && team?.key?.toLowerCase() !== teamFilter.toLowerCase()) continue;
|
||||||
|
if (stateFilter && state?.name?.toLowerCase() !== stateFilter.toLowerCase()) continue;
|
||||||
|
|
||||||
|
console.log(`--- ${issue.identifier} ---`);
|
||||||
|
console.log(`Title: ${issue.title}`);
|
||||||
|
console.log(`State: ${state?.name || "Unknown"}`);
|
||||||
|
console.log(`Priority: ${issue.priorityLabel}`);
|
||||||
|
console.log(`Team: ${team?.key || "?"} | Assignee: ${assignee?.name || "Unassigned"}`);
|
||||||
|
console.log(`Created: ${formatDate(issue.createdAt)} | Updated: ${formatDate(issue.updatedAt)}`);
|
||||||
|
if (issue.description) console.log(`Description: ${truncate(issue.description, 200)}`);
|
||||||
|
console.log(`URL: ${issue.url}`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.nodes.length === 0) {
|
||||||
|
console.log("No results found.");
|
||||||
|
}
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// List all Linear teams
|
||||||
|
// Usage: linear-teams.js
|
||||||
|
|
||||||
|
import { getClient } from "./lib.js";
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const teams = await client.teams();
|
||||||
|
|
||||||
|
console.log("Teams:");
|
||||||
|
for (const team of teams.nodes) {
|
||||||
|
console.log(` ${team.key.padEnd(8)} ${team.name}`);
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Update an existing Linear issue
|
||||||
|
// Usage: linear-update.js <identifier> [--title <title>] [--state <name>] [--priority <0-4>] [--assignee <name|me>] [--description <text>]
|
||||||
|
|
||||||
|
import { getClient } from "./lib.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
const identifier = args[0];
|
||||||
|
if (!identifier || identifier.startsWith("--")) {
|
||||||
|
console.log("Usage: linear-update.js <identifier> [options]");
|
||||||
|
console.log("\nOptions:");
|
||||||
|
console.log(" --title <title> New title");
|
||||||
|
console.log(" --state <name> New state (e.g. 'In Progress')");
|
||||||
|
console.log(" --priority <0-4> New priority");
|
||||||
|
console.log(" --assignee <name|me> New assignee");
|
||||||
|
console.log(" --description <text> New description");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.shift();
|
||||||
|
|
||||||
|
function extractArg(flag) {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx !== -1 && args[idx + 1]) {
|
||||||
|
const val = args[idx + 1];
|
||||||
|
args.splice(idx, 2);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = extractArg("--title");
|
||||||
|
const stateName = extractArg("--state");
|
||||||
|
const priority = extractArg("--priority");
|
||||||
|
const assigneeName = extractArg("--assignee");
|
||||||
|
const description = extractArg("--description");
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
// Find the issue
|
||||||
|
const results = await client.searchIssues(identifier, { first: 1 });
|
||||||
|
const issue = results.nodes[0];
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue '${identifier}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {};
|
||||||
|
|
||||||
|
if (title) input.title = title;
|
||||||
|
if (description) input.description = description;
|
||||||
|
if (priority) input.priority = parseInt(priority, 10);
|
||||||
|
|
||||||
|
// Resolve state
|
||||||
|
if (stateName) {
|
||||||
|
const team = await issue.team;
|
||||||
|
const states = await team.states();
|
||||||
|
const state = states.nodes.find(
|
||||||
|
(s) => s.name.toLowerCase() === stateName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (state) input.stateId = state.id;
|
||||||
|
else {
|
||||||
|
console.error(`State '${stateName}' not found. Available states:`);
|
||||||
|
for (const s of states.nodes) console.error(` - ${s.name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve assignee
|
||||||
|
if (assigneeName) {
|
||||||
|
if (assigneeName.toLowerCase() === "me") {
|
||||||
|
const me = await client.viewer;
|
||||||
|
input.assigneeId = me.id;
|
||||||
|
} else {
|
||||||
|
const users = await client.users({ filter: { name: { containsIgnoreCase: assigneeName } } });
|
||||||
|
if (users.nodes[0]) input.assigneeId = users.nodes[0].id;
|
||||||
|
else {
|
||||||
|
console.error(`User '${assigneeName}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(input).length === 0) {
|
||||||
|
console.log("No updates specified. Use --title, --state, --priority, --assignee, or --description.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.updateIssue(issue.id, input);
|
||||||
|
console.log(`Updated ${issue.identifier}: ${issue.title}`);
|
||||||
|
console.log(`URL: ${issue.url}`);
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"name": "linear-skill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "linear-skill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@linear/sdk": "^37.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@graphql-typed-document-node/core": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@linear/sdk": {
|
||||||
|
"version": "37.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-37.0.0.tgz",
|
||||||
|
"integrity": "sha512-EAZCXtV414Nwtvrwn7Ucu3E8BbYYKsc3HqZCGf1mHUE7FhZGtfISu295DOVv89WhhXlp2N344EMg3K0nnhLxtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@graphql-typed-document-node/core": "^3.1.0",
|
||||||
|
"graphql": "^15.4.0",
|
||||||
|
"isomorphic-unfetch": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.x",
|
||||||
|
"yarn": "1.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graphql": {
|
||||||
|
"version": "15.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz",
|
||||||
|
"integrity": "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isomorphic-unfetch": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.1",
|
||||||
|
"unfetch": "^4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/unfetch": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "linear-skill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Linear API skill for pi - manage issues, projects, and teams",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@linear/sdk": "^37.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ description: Interactive browser automation via Chrome DevTools Protocol. Use wh
|
|||||||
|
|
||||||
# Browser Tools
|
# Browser Tools
|
||||||
|
|
||||||
Chrome DevTools Protocol tools for agent-assisted web automation. These tools connect to Chrome running on `:9222` with remote debugging enabled.
|
Chrome DevTools Protocol tools for agent-assisted web automation. These tools connect to a Chromium-based browser running on `:9222` with remote debugging enabled. Supports **Helium** (recommended on Linux), Chrome, Chromium, Brave, and Edge.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -18,14 +18,20 @@ npm install
|
|||||||
|
|
||||||
where baseDir is usually ~/.pi/agent/skills/pi-skills/browser-tools/
|
where baseDir is usually ~/.pi/agent/skills/pi-skills/browser-tools/
|
||||||
|
|
||||||
## Start Chrome
|
**Note:** On NixOS/Linux, Helium is recommended:
|
||||||
|
```bash
|
||||||
|
nix-env -iA nixpkgs.helium
|
||||||
|
# or via home-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start Browser
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
{baseDir}/browser-start.js # Fresh profile
|
{baseDir}/browser-start.js # Fresh profile
|
||||||
{baseDir}/browser-start.js --profile # Copy user's profile (cookies, logins)
|
{baseDir}/browser-start.js --profile # Copy your profile (cookies, logins)
|
||||||
```
|
```
|
||||||
|
|
||||||
Launch Chrome with remote debugging on `:9222`. Use `--profile` to preserve user's authentication state.
|
Launches the first available browser (Helium → chromium → chrome → brave → edge) with remote debugging on `:9222`. Use `--profile` to preserve your authentication state.
|
||||||
|
|
||||||
## Navigate
|
## Navigate
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ Host mac mac-attio
|
|||||||
LocalForward 8082 localhost:8082
|
LocalForward 8082 localhost:8082
|
||||||
LocalForward 54043 localhost:54043
|
LocalForward 54043 localhost:54043
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
|
SetEnv TERM=xterm-256color
|
||||||
|
|
||||||
Host linux-pc 192.168.1.80
|
Host linux-pc 192.168.1.80
|
||||||
HostName 192.168.1.80
|
HostName 192.168.1.80
|
||||||
|
|||||||
+27
-19
@@ -1,3 +1,7 @@
|
|||||||
|
//
|
||||||
|
// THIS FILE WAS AUTOGENERATED BY ZELLIJ, THE PREVIOUS FILE AT THIS LOCATION WAS COPIED TO: /Users/thomasglopes/.config/zellij/config.kdl.bak
|
||||||
|
//
|
||||||
|
|
||||||
keybinds clear-defaults=true {
|
keybinds clear-defaults=true {
|
||||||
locked {
|
locked {
|
||||||
bind "Ctrl l" { SwitchToMode "normal"; }
|
bind "Ctrl l" { SwitchToMode "normal"; }
|
||||||
@@ -76,10 +80,10 @@ keybinds clear-defaults=true {
|
|||||||
bind "up" { MovePane "up"; }
|
bind "up" { MovePane "up"; }
|
||||||
bind "right" { MovePane "right"; }
|
bind "right" { MovePane "right"; }
|
||||||
bind "h" { MovePane "left"; }
|
bind "h" { MovePane "left"; }
|
||||||
bind "Ctrl m" { SwitchToMode "normal"; }
|
|
||||||
bind "j" { MovePane "down"; }
|
bind "j" { MovePane "down"; }
|
||||||
bind "k" { MovePane "up"; }
|
bind "k" { MovePane "up"; }
|
||||||
bind "l" { MovePane "right"; }
|
bind "l" { MovePane "right"; }
|
||||||
|
bind "Ctrl m" { SwitchToMode "normal"; }
|
||||||
bind "n" { MovePane; }
|
bind "n" { MovePane; }
|
||||||
bind "p" { MovePaneBackwards; }
|
bind "p" { MovePaneBackwards; }
|
||||||
bind "tab" { MovePane; }
|
bind "tab" { MovePane; }
|
||||||
@@ -133,33 +137,39 @@ keybinds clear-defaults=true {
|
|||||||
SwitchToMode "normal"
|
SwitchToMode "normal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
shared {
|
||||||
|
bind "F1" {
|
||||||
|
MessagePlugin "compact-bar" {
|
||||||
|
name "toggle_tooltip"
|
||||||
|
payload "1"
|
||||||
|
floating false
|
||||||
|
tooltip "F1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
shared_except "locked" {
|
shared_except "locked" {
|
||||||
bind "Alt left" { MoveFocusOrTab "left"; }
|
bind "Alt left" { MoveFocusOrTab "left"; }
|
||||||
|
bind "Alt Shift left" { GoToPreviousTab; }
|
||||||
bind "Alt down" { MoveFocus "down"; }
|
bind "Alt down" { MoveFocus "down"; }
|
||||||
bind "Alt up" { MoveFocus "up"; }
|
bind "Alt up" { MoveFocus "up"; }
|
||||||
bind "Alt right" { MoveFocusOrTab "right"; }
|
bind "Alt right" { MoveFocusOrTab "right"; }
|
||||||
|
bind "Alt Shift right" { GoToNextTab; }
|
||||||
bind "Alt +" { Resize "Increase"; }
|
bind "Alt +" { Resize "Increase"; }
|
||||||
bind "Alt -" { Resize "Decrease"; }
|
bind "Alt -" { Resize "Decrease"; }
|
||||||
bind "Alt =" { Resize "Increase"; }
|
bind "Alt =" { Resize "Increase"; }
|
||||||
bind "Alt [" { PreviousSwapLayout; }
|
bind "Alt [" { PreviousSwapLayout; }
|
||||||
bind "Alt ]" { NextSwapLayout; }
|
bind "Alt ]" { NextSwapLayout; }
|
||||||
bind "Alt f" { ToggleFloatingPanes; }
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
bind "Ctrl l" { SwitchToMode "locked"; }
|
|
||||||
bind "Alt h" { MoveFocusOrTab "left"; }
|
bind "Alt h" { MoveFocusOrTab "left"; }
|
||||||
bind "Alt i" { MoveTab "left"; }
|
bind "Alt i" { MoveTab "left"; }
|
||||||
bind "Alt j" { MoveFocus "down"; }
|
bind "Alt j" { MoveFocus "down"; }
|
||||||
bind "Alt k" { MoveFocus "up"; }
|
bind "Alt k" { MoveFocus "up"; }
|
||||||
|
bind "Ctrl l" { SwitchToMode "locked"; }
|
||||||
bind "Alt l" { MoveFocusOrTab "right"; }
|
bind "Alt l" { MoveFocusOrTab "right"; }
|
||||||
bind "Alt n" { NewPane; }
|
bind "Alt n" { NewPane; }
|
||||||
bind "Alt o" { MoveTab "right"; }
|
bind "Alt o" { MoveTab "right"; }
|
||||||
bind "Alt p" { TogglePaneInGroup; }
|
bind "Alt p" { TogglePaneInGroup; }
|
||||||
bind "Alt Shift p" { ToggleGroupMarking; }
|
bind "Alt Shift p" { ToggleGroupMarking; }
|
||||||
bind "Alt Shift left" { GoToPreviousTab; }
|
|
||||||
bind "Alt Shift right" { GoToNextTab; }
|
|
||||||
// bind "Ctrl q" { Quit; }
|
|
||||||
}
|
|
||||||
shared_except "locked" "resize" {
|
|
||||||
bind "Ctrl x" { SwitchToMode "resize"; }
|
|
||||||
}
|
}
|
||||||
shared_except "locked" "move" {
|
shared_except "locked" "move" {
|
||||||
bind "Ctrl m" { SwitchToMode "move"; }
|
bind "Ctrl m" { SwitchToMode "move"; }
|
||||||
@@ -179,6 +189,9 @@ keybinds clear-defaults=true {
|
|||||||
shared_except "locked" "pane" {
|
shared_except "locked" "pane" {
|
||||||
bind "Ctrl p" { SwitchToMode "pane"; }
|
bind "Ctrl p" { SwitchToMode "pane"; }
|
||||||
}
|
}
|
||||||
|
shared_except "locked" "resize" {
|
||||||
|
bind "Ctrl x" { SwitchToMode "resize"; }
|
||||||
|
}
|
||||||
shared_except "normal" "locked" "entersearch" {
|
shared_except "normal" "locked" "entersearch" {
|
||||||
bind "enter" { SwitchToMode "normal"; }
|
bind "enter" { SwitchToMode "normal"; }
|
||||||
}
|
}
|
||||||
@@ -250,9 +263,9 @@ keybinds clear-defaults=true {
|
|||||||
// changing these requires a restart to take effect
|
// changing these requires a restart to take effect
|
||||||
plugins {
|
plugins {
|
||||||
about location="zellij:about"
|
about location="zellij:about"
|
||||||
compact-bar location="zellij:compact-bar" {
|
compact-bar location="zellij:compact-bar" {
|
||||||
tooltip "F1"
|
tooltip "F1"
|
||||||
}
|
}
|
||||||
configuration location="zellij:configuration"
|
configuration location="zellij:configuration"
|
||||||
filepicker location="zellij:strider" {
|
filepicker location="zellij:strider" {
|
||||||
cwd "/"
|
cwd "/"
|
||||||
@@ -286,10 +299,6 @@ web_client {
|
|||||||
// Choose the theme that is specified in the themes section.
|
// Choose the theme that is specified in the themes section.
|
||||||
// Default: default
|
// Default: default
|
||||||
//
|
//
|
||||||
// theme "dracula"
|
|
||||||
|
|
||||||
// Load the matugen-generated theme.
|
|
||||||
source_file "~/.config/zellij/themes/matugen.kdl"
|
|
||||||
theme "matugen"
|
theme "matugen"
|
||||||
|
|
||||||
// Choose the base input mode of zellij.
|
// Choose the base input mode of zellij.
|
||||||
@@ -299,7 +308,7 @@ theme "matugen"
|
|||||||
|
|
||||||
// Choose the path to the default shell that zellij will use for opening new panes
|
// Choose the path to the default shell that zellij will use for opening new panes
|
||||||
// Default: $SHELL
|
// Default: $SHELL
|
||||||
|
//
|
||||||
default_shell "fish"
|
default_shell "fish"
|
||||||
|
|
||||||
// Choose the path to override cwd that zellij will use for opening new panes
|
// Choose the path to override cwd that zellij will use for opening new panes
|
||||||
@@ -308,9 +317,8 @@ default_shell "fish"
|
|||||||
|
|
||||||
// The name of the default layout to load on startup
|
// The name of the default layout to load on startup
|
||||||
// Default: "default"
|
// Default: "default"
|
||||||
|
//
|
||||||
default_layout "compact"
|
default_layout "compact"
|
||||||
|
|
||||||
|
|
||||||
// The folder in which Zellij will look for layouts
|
// The folder in which Zellij will look for layouts
|
||||||
// (Requires restart)
|
// (Requires restart)
|
||||||
@@ -512,7 +520,7 @@ default_layout "compact"
|
|||||||
// Whether to show tips on startup
|
// Whether to show tips on startup
|
||||||
// Default: true
|
// Default: true
|
||||||
//
|
//
|
||||||
// show_startup_tips false
|
show_startup_tips false
|
||||||
|
|
||||||
// Whether to show release notes on first version run
|
// Whether to show release notes on first version run
|
||||||
// Default: true
|
// Default: true
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
layout {
|
layout {
|
||||||
default_tab_template {
|
default_tab_template {
|
||||||
children
|
children
|
||||||
pane size=1 borderless=true {
|
pane size=1 borderless=true {
|
||||||
plugin location="zellij:compact-bar"
|
plugin location="zellij:compact-bar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tab name="dotfiles" cwd="/home/thomasgl/.dotfiles" {
|
tab name="nvim + jjui" {
|
||||||
pane split_direction="vertical" {
|
pane stacked=true {
|
||||||
pane stacked=true {
|
pane command="nvim"
|
||||||
pane
|
pane command="jjui"
|
||||||
pane command="nvim"
|
}
|
||||||
}
|
}
|
||||||
pane size="40%" command="pi"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tab name="NixOS" cwd="/home/thomasgl/etc/nixos" {
|
tab name="pi + shell" {
|
||||||
pane split_direction="vertical" {
|
pane stacked=true {
|
||||||
pane stacked=true {
|
pane command="pi"
|
||||||
pane
|
pane
|
||||||
pane command="nvim"
|
}
|
||||||
}
|
}
|
||||||
pane size="40%" command="pi"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user