Compare commits

...

28 Commits

Author SHA1 Message Date
thomas 85632c2e29 hook notifs 2026-03-31 12:30:12 +01:00
thomas 63caa82199 compat with claude skills + hooks support 2026-03-31 12:07:17 +01:00
thomas 39e7bddb35 update pi 2026-03-27 14:22:06 +00:00
thomas d5b4042b06 add gsf 2026-03-25 19:12:49 +00:00
thomas 4d19e7d320 fix nvim 2026-03-25 13:41:16 +00:00
thomas 227c1638f6 linear skill 2026-03-19 16:37:27 +00:00
thomas db41ec6e93 pi settings 2026-03-19 15:20:50 +00:00
thomas c44420ce7c osc52 or whatever for ssh clipboard 2026-03-19 15:20:27 +00:00
thomas f74242ed02 jj rules 2026-03-19 15:20:15 +00:00
thomas 335b12b0e4 small changes 2026-03-16 12:13:41 +00:00
Thomas G. Lopes 2e820d38e1 fix 2026-03-13 18:12:32 +00:00
thomas 008dac69f5 fix mac 2026-03-13 18:12:10 +00:00
thomas d0b1d3be4a sync mac scripts 2026-03-13 18:04:32 +00:00
Thomas G. Lopes c0bbff81a3 jj 2026-03-13 13:41:20 +00:00
thomas 58dd9d8c2b derive codex window labels from api 2026-03-13 13:40:19 +00:00
Thomas G. Lopes 3d314d944b add attio frontend rules skill 2026-03-13 13:29:03 +00:00
thomas 24bad1f5c4 i be working 2026-03-13 12:21:18 +00:00
thomas 442284c92f add mcporter mcp config steps 2026-03-13 11:01:35 +00:00
thomas 1da5caec0a finishing touches 2026-03-12 22:09:14 +00:00
Thomas G. Lopes b8b48747b1 fix config files 2026-03-12 21:43:43 +00:00
Thomas G. Lopes ae0920a4c1 zellij abbreviation 2026-03-12 21:41:29 +00:00
thomas 2bac33b2b6 tune sub bar behavior 2026-03-12 15:13:16 +00:00
thomas 0f99afae67 codex rules 2026-03-12 15:01:19 +00:00
thomas 32752b42e0 fuck sub bar 2026-03-12 14:18:06 +00:00
thomas 7a9e2b94ff add sub-bar usage widget extension with opencode-go support 2026-03-12 14:01:51 +00:00
thomas 9ad10a016c switch default pi model to claude-opus-4-6 2026-03-12 12:30:05 +00:00
thomas ec88e63a07 ignore auth.json in sigil 2026-03-12 12:30:01 +00:00
thomas ed70a369e5 allow rm rf 2026-03-12 12:13:39 +00:00
57 changed files with 3071 additions and 473 deletions
+3 -3
View File
@@ -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,
+14
View File
@@ -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
+10 -1
View File
@@ -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
+15
View File
@@ -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
+18
View File
@@ -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
+17
View File
@@ -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
}
+10 -6
View File
@@ -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 &"
+98
View File
@@ -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
-12
View File
@@ -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
-13
View File
@@ -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
-13
View File
@@ -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
-13
View File
@@ -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
+1 -1
View File
@@ -238,7 +238,7 @@ layer-rule {
} }
window-rule { window-rule {
match app-id="steam" title=r#"^notificationtoasts_\d+_desktop$"# match app-id="steam" title=r#"notificationtoasts_\d+_desktop$"#
default-floating-position x=10 y=10 relative-to="bottom-right" default-floating-position x=10 y=10 relative-to="bottom-right"
} }
+3 -1
View File
@@ -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", {
+23 -21
View File
@@ -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()
+73 -3
View File
@@ -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,
} }
+5
View File
@@ -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()
+13
View File
@@ -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
}
}
+13
View File
@@ -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"
} }
] ]
}, },
+1
View File
@@ -8,5 +8,6 @@ return {
"**/node_modules", "**/node_modules",
"**/pnpm-lock.yaml", "**/pnpm-lock.yaml",
"**/.pnpm", "**/.pnpm",
"**/auth.json",
}, },
} }
+4 -4
View File
@@ -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.
+28 -1
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
{}
+4 -4
View File
@@ -9,8 +9,8 @@
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'. * The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
*/ */
import type { ExtensionAPI, ExtensionCommandContext } 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");
+3 -2
View File
@@ -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",
);
}
});
}
+4 -3
View File
@@ -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": {},
+3 -2
View File
@@ -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);
+31 -22
View File
@@ -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
+250 -249
View File
@@ -14,9 +14,9 @@ import { complete, type Message } from "@mariozechner/pi-ai";
import { getModel } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai";
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
import { import {
BorderedLoader, BorderedLoader,
convertToLlm, convertToLlm,
serializeConversation, serializeConversation,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
@@ -43,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 };
});
}
+707
View File
@@ -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;
}
});
}
+2 -7
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{ {
"selectModel": "ctrl+space" "app.model.select": "ctrl+space"
} }
+105
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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}`);
+87
View File
@@ -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
View File
@@ -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.`);
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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.");
}
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
}
}
+10
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+18 -24
View File
@@ -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"
}
}
} }