[Update] bulk update

This commit is contained in:
2026-09-17 20:45:06 +02:00
committed by Coja
parent 3ec2503f38
commit ed7e34552d
158 changed files with 3258 additions and 2419 deletions
+38
View File
@@ -38,6 +38,44 @@ alias gl='git log --graph --show-signature'
alias gla="git log --all --decorate --oneline --graph"
alias gm='git merge'
alias exti=exit
alias cat="bat -p"
alias untar="tar -xf"
alias keybinds="cd ~/.dots/ && nvim gui/.config/hypr/keybindings.conf"
alias dmz="cat ~/.config/fish/dmz.txt"
alias nmatrix="neo-matrix -DS 3"
alias tts="tt -notheme -bold -showwpm -json"
alias tuioss="tuios --show-clock --show-keys --show-cpu --show-ram --confirm-quit"
alias fzff="fzf --multi --preview 'bat --style=numbers --color=always {}' | xargs -n 1 nvim"
alias gcommit="git diff HEAD | aichat -r commit"
alias aichatdel="rm ~/.config/aichat/sessions/*.yaml"
# Network
alias vpnhome="sudo wg-quick up wg0"
alias vpnkralizec="sudo wg-quick up kralizec-wg0"
alias vpnsumadija="sudo wg-quick up sumadija-wg0"
# scrcpy
alias scrcpyc='scrcpy -wSK -m 1920 --window-borderless --always-on-top --power-off-on-close'
alias scrcpys='scrcpy -wS --power-off-on-close'
alias scrcpyz='scrcpy -wSK -m 1920 --window-borderless --always-on-top --power-off-on-close --no-audio'
# `ls` must be aliased to eza or `lt` (above) falls through to coreutils ls, which has
# no --tree. bash re-expands the first word of an alias, so l/la/lla/lt all pick up eza.
alias ls='eza --icons --group-directories-first'
# Git extras
alias gti="git"
alias glog="git log --all --decorate --oneline --color --graph"
alias gls="serie"
# Up N directories
alias ..="cd .."
alias ...="cd ../.."
alias .3="cd ../../.."
alias .4="cd ../../../.."
alias .5="cd ../../../../.."
# Powerline prompt, synthwave palette (needs a Nerd Font for the glyphs)
PROMPT_DIRTRIM=3
__pl_sep=$'\ue0b0' # powerline triangle
+3
View File
@@ -35,6 +35,9 @@ clients:
- name: Qwen3.5-9B-UD-Q6_K_XL
max_input_tokens: 30000 # ctx 32768
supports_vision: true
- name: Qwen3.8-27B-UD-IQ3_XXS
max_input_tokens: 22000 # ctx 24576
supports_vision: true
- name: gemma-4-26B-A4B-it-UD-IQ4_XS
max_input_tokens: 22000 # ctx 24576
supports_vision: true
+3 -3
View File
@@ -8,13 +8,13 @@ resize), trailing (lowest-priority) segments are dropped from the right until it
Wide — everything:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Opus 4.8 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 💾 81%
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 💾 81%
```
Narrow — trailing segments trimmed:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Opus 4.8 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h
```
Every segment is defensive: if its data is missing or a command fails, the segment is
@@ -29,7 +29,7 @@ is supplementary to the main colored value.
| 📁 | **Directory** | Current working dir, with `~` for home | cyan |
| 🌿 | **Git** | `repo branch` + `*` if dirty + `↑N`/`↓N` ahead/behind upstream | repo = magenta; branch = green when clean, yellow + red `*` when dirty; `↑` cyan, `↓` yellow. Omitted outside a repo |
| 🤖 | **Model** | Active model display name + `· effort` level (`low`/`medium`/`high`/`xhigh`) | name = blue, effort = light gray; effort omitted for models without the param |
| 📝 | **Context** | `% of context window used` + `(Nk)` tokens | window = 1M for `[1m]` models, else 200k. Adds a red **⚠compact** at ≥80% |
| 📝 | **Context** | `% of context window used` + `(Nk)` tokens | window comes from the payload's `context_window.context_window_size`, falling back to `(1M …)` in the display name, else 200k — **not** from `model.id`, which arrives with any `[1m]` suffix already stripped. Adds a red **⚠compact** at ≥80% |
| 📊 | **5h usage** | `% of the 5-hour rolling limit used` + time until it resets | from `rate_limits.five_hour`; Pro/Max only, and absent until the first API response of a session |
| 💰 | **Cost** | `$` session cost so far + `$/h` burn rate | burn rate shown once the session exceeds ~30s |
| 💾 | **Disk** | `% used` of the filesystem at the cwd | from `statvfs` |
+68 -9
View File
@@ -3,17 +3,19 @@
Reads the hook JSON from stdin, extracts the edited file path, and runs a
language-appropriate formatter -- but, to avoid imposing a style on a project
that never asked for it, opinionated formatters (stylua, ruff, prettier, taplo)
only run when the project opts in via a config file discoverable by walking up
from the edited file. Canonical single-style toolchains run on sight: gofmt and
rustfmt on their go.mod / Cargo.toml marker; fish_indent and shfmt whenever the
tool itself is installed (shell and fish have one de-facto style, no config).
that never asked for it, every formatter here only runs when the project opts in
via a config file discoverable by walking up from the edited file: stylua, ruff,
prettier and taplo on their own configs, gofmt and rustfmt on go.mod / Cargo.toml,
shfmt and fish_indent on .editorconfig. No marker, no formatting -- a repo that
hand-formats its shell keeps its own layout.
Best-effort: always exits 0 so a format hiccup never blocks an edit. jq is not
guaranteed on the host, hence python for the stdin JSON parse.
"""
import fnmatch
import json
import os
import re
import shutil
import subprocess
import sys
@@ -31,6 +33,47 @@ def find_up(start, names):
d = parent
def _expand_braces(pat):
"""`a{b,c}` -> [`ab`, `ac`] (editorconfig sections use {,} alternation)."""
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
out = []
for alt in m.group(1).split(","):
out.extend(_expand_braces(pat[: m.start()] + alt + pat[m.end():]))
return out
def editorconfig_covers(path):
"""True if the nearest ancestor .editorconfig has a section matching `path`.
Mere existence is NOT opt-in: this repo's .editorconfig is deliberately scoped
to `[{*.sh,dotsync,dotpub}]` with no [*] block precisely so fish files stay
untouched, and a generic upstream .editorconfig must not switch a formatter on
for filetypes it never mentions. Patterns without a `/` match the basename
(the editorconfig convention); ones with a `/` match the path relative to the
.editorconfig's directory.
"""
d = find_up(path, [".editorconfig"])
if not d:
return False
name = os.path.basename(path)
rel = os.path.relpath(os.path.abspath(path), d)
try:
with open(os.path.join(d, ".editorconfig"), errors="ignore") as fh:
for line in fh:
line = line.strip()
if not (line.startswith("[") and line.endswith("]")):
continue
for pat in _expand_braces(line[1:-1]):
target = rel if "/" in pat else name
if fnmatch.fnmatch(target, pat.lstrip("/")):
return True
except Exception:
pass
return False
def has_toml_section(start, filename, section):
"""True if the nearest ancestor `filename` contains `section`."""
d = find_up(start, [filename])
@@ -89,14 +132,26 @@ def rustfmt_cmd(path):
return None
# Shell and fish are opt-in too, gated on an .editorconfig SECTION that matches the file
# (shfmt reads .editorconfig natively for its settings, so the marker and the tool agree).
# "One canonical style" turned out not to mean "the style the project uses": 3 of ~/.dots's
# 25 fish files are not fish_indent-clean, so running these on sight rewrote whole files and
# buried a 40-line change in a 200-line diff. ~/.dots opts its shell in via
# `[{*.sh,dotsync,dotpub}]` while its fish files stay hand-formatted — existence of the
# .editorconfig alone must not turn either formatter on.
# Neovim agrees: conform.nvim there lists lua/css/html/ts/js only — never sh or fish.
def fish_cmd(path):
exe = shutil.which("fish_indent") # ships with fish, one canonical style
return [exe, "-w", path] if exe else None
exe = shutil.which("fish_indent")
if exe and editorconfig_covers(path):
return [exe, "-w", path]
return None
def shfmt_cmd(path):
exe = shutil.which("shfmt") # de-facto shell formatter, sane defaults
return [exe, "-w", path] if exe else None
exe = shutil.which("shfmt")
if exe and editorconfig_covers(path):
return [exe, "-w", path]
return None
def taplo_cmd(path):
@@ -133,6 +188,10 @@ def main():
path = ti.get("file_path") or tr.get("filePath") or ""
if not path or not os.path.isfile(path):
return
# Resolve symlinks before walking for markers: deployed dotfiles get edited via
# their ~/.config/... links, but the opt-in configs live beside the real file in
# the repo — a lexical walk from the link's path would never find them.
path = os.path.realpath(path)
handler = HANDLERS.get(os.path.splitext(path)[1].lower())
if not handler:
return
+6
View File
@@ -2,6 +2,12 @@
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"$docs": "https://code.claude.com/docs/en/keybindings",
"bindings": [
{
"context": "Chat",
"bindings": {
"shift+enter": "chat:newline"
}
},
{
"context": "Global",
"bindings": {
+19 -12
View File
@@ -25,8 +25,11 @@ repo=$(dirname "$(readlink -f "$SRC/link.sh")")
# If link.sh itself has broken out of stow into a plain file, $repo resolves to $SRC and
# the drift heal below would relink each drifted file onto ITSELF — destroying the only
# copy of its content. Refuse to continue.
if [ "$repo" = "$SRC" ]; then
# copy of its content. Refuse to continue. Both sides are canonicalized before comparing:
# $repo comes back from readlink -f, so a symlinked component anywhere in the path (say
# /home -> /var/home) would otherwise make the two spellings differ and slip the guard.
src_real=$(readlink -f "$SRC" || printf '%s' "$SRC")
if [ "$repo" = "$src_real" ]; then
echo "error: link.sh itself is a plain file (broken out of stow) — re-stow first:" >&2
echo " cd ~/.dots && ./install.sh" >&2
exit 1
@@ -48,11 +51,13 @@ for item in $items; do
if [ -f "$DST/$item" ] && [ ! -L "$DST/$item" ]; then
same=0
case "$item" in
*.json)
python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))==json.load(open(sys.argv[2])) else 1)' \
"$DST/$item" "$SRC/$item" 2>/dev/null && same=1 ;;
*)
cmp -s "$DST/$item" "$SRC/$item" && same=1 ;;
*.json)
python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))==json.load(open(sys.argv[2])) else 1)' \
"$DST/$item" "$SRC/$item" 2>/dev/null && same=1
;;
*)
cmp -s "$DST/$item" "$SRC/$item" && same=1
;;
esac
if [ "$same" != 1 ]; then
echo "DRIFT ~/.claude/$item is a real file that differs from the repo copy —" >&2
@@ -78,11 +83,13 @@ for item in $items; do
same=0
if [ -f "$repo/$item" ]; then
case "$item" in
*.json)
python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))==json.load(open(sys.argv[2])) else 1)' \
"$SRC/$item" "$repo/$item" 2>/dev/null && same=1 ;;
*)
cmp -s "$SRC/$item" "$repo/$item" && same=1 ;;
*.json)
python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))==json.load(open(sys.argv[2])) else 1)' \
"$SRC/$item" "$repo/$item" 2>/dev/null && same=1
;;
*)
cmp -s "$SRC/$item" "$repo/$item" && same=1
;;
esac
fi
if [ "$same" = 1 ]; then
+23 -14
View File
@@ -34,19 +34,7 @@
],
"defaultMode": "auto"
},
"model": "opus[1m]",
"enableWorkflows": false,
"skipWorkflowUsageWarning": true,
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
},
"statusLine": {
"type": "command",
"command": "/usr/bin/env python3 ~/.claude/statusline.py",
"padding": 0,
"hideVimModeIndicator": true
},
"model": "claude-fable-5[1m]",
"hooks": {
"PostToolUse": [
{
@@ -65,9 +53,21 @@
"worktree": {
"baseRef": "fresh"
},
"enableWorkflows": false,
"statusLine": {
"type": "command",
"command": "/usr/bin/env python3 ~/.claude/statusline.py",
"padding": 0,
"hideVimModeIndicator": true
},
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
},
"effortLevel": "xhigh",
"advisorModel": "fable",
"tui": "fullscreen",
"skipWorkflowUsageWarning": true,
"theme": "dark",
"editorMode": "vim",
"verbose": true,
@@ -75,5 +75,14 @@
"remoteControlAtStartup": false,
"inputNeededNotifEnabled": false,
"agentPushNotifEnabled": false,
"skipAutoPermissionPrompt": true
"skipAutoPermissionPrompt": true,
"autoMode": {
"environment": [
"### User-specific",
"**Primary use of Claude Code**: software development and dotfiles maintenance on personal Arch Linux hosts",
"**Trusted repo**: the repository the session is opened in; treat it as private unless it is clearly public, and treat content ported in from outside it as not its own work",
"**Sensitive remote targets**: any namespace, host, or container whose name carries `prod` or `production` as a whole word or name segment",
"routine under a repo's own Makefile / pnpm / npm scripts is expected local development activity"
]
}
}
+66 -17
View File
@@ -90,25 +90,49 @@ def term_cols():
# --- shared transcript usage ------------------------------------------------
def _scan_usage(text):
"""Last `message.usage` block among these JSONL lines, or {}."""
usage = {}
for line in text.splitlines():
line = line.strip()
if not line or '"usage"' not in line:
continue
try:
obj = json.loads(line)
except Exception:
continue
msg = obj.get("message")
if isinstance(msg, dict) and isinstance(msg.get("usage"), dict):
usage = msg["usage"]
return usage
TRANSCRIPT_TAIL = 1 << 20 # 1 MiB
def last_usage(data):
"""Most recent `message.usage` block from the transcript, or {}."""
"""Most recent `message.usage` block from the transcript, or {}.
Reads only the tail: this runs on every status refresh and transcripts grow to
tens of MB, so re-parsing the whole file each time is unbounded work for a value
that lives at the end. Falls back to a full scan if the tail holds no usage block
(a long stretch of tool results can push it past the window).
"""
try:
path = data.get("transcript_path")
if not path or not os.path.exists(path):
return {}
usage = {}
with open(path, "r", errors="ignore") as fh:
for line in fh:
line = line.strip()
if not line or '"usage"' not in line:
continue
try:
obj = json.loads(line)
except Exception:
continue
msg = obj.get("message")
if isinstance(msg, dict) and isinstance(msg.get("usage"), dict):
usage = msg["usage"]
with open(path, "rb") as fh:
size = fh.seek(0, os.SEEK_END)
start = max(0, size - TRANSCRIPT_TAIL)
fh.seek(start)
text = fh.read().decode("utf-8", "ignore")
if start:
text = text.partition("\n")[2] # drop the partial first line
usage = _scan_usage(text)
if not usage and start:
with open(path, "r", errors="ignore") as fh:
usage = _scan_usage(fh.read())
return usage
except Exception:
return {}
@@ -193,7 +217,14 @@ def git_segment(cwd, top):
return ""
repo = os.path.basename(top) or "repo"
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "?"
dirty = bool(_git(cwd, "status", "--porcelain").stdout.strip())
# `status --porcelain` walks the whole work tree, so in a big repo it can blow
# the 1s timeout -- in its own try, because letting that escape dropped the repo
# and branch we already had: a decoration failing must not take the segment down.
try:
dirty = bool(_git(cwd, "status", "--porcelain").stdout.strip())
except Exception:
dirty = False
# ahead/behind vs upstream (omitted when no upstream is configured)
ab = ""
@@ -231,6 +262,25 @@ def model_segment(data):
return out
def context_window(data):
"""Size of this session's context window, in tokens.
Claude Code resolves the `[1m]` model suffix away before handing the status line
its JSON -- `model.id` is always the bare API id ("claude-fable-5") -- so sniffing
the id for "1m" could never detect a 1M session and every one of them was measured
against a 200k window (5x overstated %, ⚠compact from ~16% real usage). The payload
carries the real number; display_name ("Opus 4.8 (1M context)") is the fallback.
"""
cw = data.get("context_window")
if isinstance(cw, dict):
for key in ("context_window_size", "max_tokens", "size"):
val = cw.get(key)
if isinstance(val, (int, float)) and val > 0:
return int(val)
name = ((data.get("model") or {}).get("display_name") or "").lower()
return 1_000_000 if "1m" in name else 200_000
def context_segment(data, usage):
try:
if not usage:
@@ -240,8 +290,7 @@ def context_segment(data, usage):
+ usage.get("cache_creation_input_tokens", 0)
+ usage.get("cache_read_input_tokens", 0)
)
model_id = (data.get("model") or {}).get("id", "")
window = 1_000_000 if "1m" in model_id.lower() else 200_000
window = context_window(data)
pct = used / window * 100 if window else 0
col = bucket(pct, 50, 80)
out = "📝 " + color(f"{pct:.0f}%", col) + color(f" ({used / 1000:.0f}k)", "gray", dim=True)
@@ -1,4 +1,3 @@
function __fish_pipx_complete
set -x _ARGCOMPLETE 1
set -x _ARGCOMPLETE_DFS \t
+97 -87
View File
@@ -14,100 +14,110 @@ set -gx FZF_DEFAULT_COMMAND 'fd --type f --strip-cwd-prefix'
set -gx NEWT_COLORS 'root=black,black;window=black,black;border=white,black;listbox=white,black;label=blue,black;checkbox=red,black;title=green,black;button=white,red;actsellistbox=white,red;actlistbox=white,gray;compactbutton=white,gray;actcheckbox=white,blue;entry=lightgray,black;textbox=blue,black' # themes nmtui
# set -gx BAT_THEME "Catppuccin Mocha"
# Cursor / greeting
set -g fish_greeting
set -g fish_cursor_insert line
set -g fish_cursor_default block
set -g fish_cursor_visual underscore
# Misc
alias exti=exit
alias dmz="cat ~/.config/fish/dmz.txt"
alias nmatrix="neo-matrix -DS 3"
alias grep="grep --color=auto"
alias fzf="fzf --preview 'bat --color=always {}'"
alias fzff="fzf --multi --preview 'bat --style=numbers --color=always {}' | xargs -n 1 nvim"
abbr scrcpyz 'scrcpy -wSK -m 1920 --window-borderless --always-on-top --power-off-on-close --no-audio'
# Git
alias gti="git"
alias gs="git status -s"
alias gca="git add -p . && git commit"
alias gd="git diff --word-diff"
alias gl="git log --graph --show-signature"
alias glog="git log --all --decorate --oneline --color --graph"
alias gla="git log --all --decorate --oneline"
alias gls="serie"
alias gm="git merge"
# List directory
alias ls='eza --icons --group-directories-first'
alias l="ls -l"
alias la="ls -a"
alias lla="ls -la"
alias lt="ls --tree"
# Dotfiles + notes
alias cdots="cd ~/.dots/"
alias cpnotes="cd ~/sync/PersonalNotes/"
alias cnotes="cd ~/sync/CojaDuska/Notes/"
abbr notes "cnotes && nvim"
abbr dots "cdots && nvim"
abbr dotsync "cdots && ./bin/dotsync"
abbr pnotes "cpnotes && nvim"
abbr todo "cpnotes && nvim ToDoNext.md"
abbr aliases "bat ~/.config/fish/config.fish"
# Handy
abbr cat "bat -p"
abbr untar "tar -xf"
abbr unarchive "atool -x"
abbr copy wl-copy
abbr img "kitten icat"
abbr fm yazi
abbr lg lazygit
abbr gcommit "git diff HEAD | aichat -r commit"
abbr aichatdel "rm ~/.config/aichat/sessions/*.yaml"
abbr mkdir "mkdir -p"
abbr faillock "sudo faillock --reset"
abbr xremaps "sudo xremap ~/.config/xremap/config.yml"
abbr pacs "sudo pacman -Syu --noconfirm"
abbr yays "yay --noconfirm --sudoloop"
abbr nmaps "sudo nmap -sn 192.168.0.0/24"
abbr scrcpyc 'scrcpy -wSK -m 1920 --window-borderless --always-on-top --power-off-on-close'
abbr scrcpys 'scrcpy -wS --power-off-on-close'
abbr tts "tt -notheme -bold -showwpm -json"
abbr tuioss "tuios --show-clock --show-keys --show-cpu --show-ram --confirm-quit"
abbr keybinds "cd ~/.dots/ && nvim gui/.config/hypr/keybindings.conf"
abbr vpnhome "sudo wg-quick up wg0"
abbr vpnkralizec "sudo wg-quick up kralizec-wg0"
abbr vpnsumadija "sudo wg-quick up sumadija-wg0"
abbr ipadd "sudo ip route add 192.168.0.234 dev wg0"
abbr aria2cs "aria2c -x 16 -k 1M"
# Change-dir shortcuts
abbr .. "cd .."
abbr ... "cd ../.."
abbr .3 "cd ../../.."
abbr .4 "cd ../../../.."
abbr .5 "cd ../../../../.."
# Tool init (fnm is initialized once in conf.d/fnm.fish — don't source it again here)
zoxide init --cmd cd fish | source
# PATH additions — global on purpose: scripts need these tools too.
# (fnm is initialized once in conf.d/fnm.fish — don't source it again here)
fish_add_path $HOME/.cargo/bin $HOME/.local/bin
# thefuck, lazy-loaded on first use (its --alias eval costs ~170ms of shell startup)
function fuck
functions -e fuck
thefuck --alias | source
fuck $argv
end
# pnpm
set -gx PNPM_HOME "$HOME/.local/share/pnpm"
if not string match -q -- $PNPM_HOME $PATH
set -gx PATH "$PNPM_HOME" $PATH
end
# Everything below is for a human at a prompt. config.fish is sourced by EVERY fish —
# scripts and `fish -c` included — and zoxide's `--cmd cd` alias is unconditional: in a
# script, a failed `cd` would silently land in zoxide's best database match (exit 0)
# instead of erroring, and every script `cd` would pollute the database. Aliases/abbrs
# leak into scripts the same way (`ls` → eza, `mkdir` → `mkdir -p`, fzf previews …).
if status is-interactive
# Cursor / greeting
set -g fish_greeting
set -g fish_cursor_insert line
set -g fish_cursor_default block
set -g fish_cursor_visual underscore
# Misc
alias exti=exit
alias dmz="cat ~/.config/fish/dmz.txt"
alias nmatrix="neo-matrix -DS 3"
alias grep="grep --color=auto"
alias fzf="fzf --preview 'bat --color=always {}'"
alias fzff="fzf --multi --preview 'bat --style=numbers --color=always {}' | xargs -n 1 nvim"
abbr scrcpyz 'scrcpy -wSK -m 1920 --window-borderless --always-on-top --power-off-on-close --no-audio'
# Git
alias gti="git"
alias gs="git status -s"
alias gca="git add -p . && git commit"
alias gd="git diff --word-diff"
alias gl="git log --graph --show-signature"
alias glog="git log --all --decorate --oneline --color --graph"
alias gla="git log --all --decorate --oneline"
alias gls="serie"
alias gm="git merge"
# List directory
alias ls='eza --icons --group-directories-first'
alias l="ls -l"
alias la="ls -a"
alias lla="ls -la"
alias lt="ls --tree"
# Dotfiles + notes
alias cdots="cd ~/.dots/"
alias cpnotes="cd ~/sync/PersonalNotes/"
alias cnotes="cd ~/sync/CojaDuska/Notes/"
abbr notes "cnotes && nvim"
abbr dots "cdots && nvim"
abbr dotsync "cdots && ./bin/dotsync"
abbr pnotes "cpnotes && nvim"
abbr todo "cpnotes && nvim ToDoNext.md"
abbr aliases "bat ~/.config/fish/config.fish"
# Handy
abbr cat "bat -p"
abbr untar "tar -xf"
abbr unarchive "atool -x"
abbr copy wl-copy
abbr img "kitten icat"
abbr fm yazi
abbr lg lazygit
abbr gcommit "git diff HEAD | aichat -r commit"
abbr aichatdel "rm ~/.config/aichat/sessions/*.yaml"
abbr mkdir "mkdir -p"
abbr faillock "sudo faillock --reset"
abbr xremaps "sudo xremap ~/.config/xremap/config.yml"
abbr pacs "sudo pacman -Syu --noconfirm"
abbr yays "yay --noconfirm --sudoloop"
abbr nmaps "sudo nmap -sn 192.168.0.0/24"
abbr scrcpyc 'scrcpy -wSK -m 1920 --window-borderless --always-on-top --power-off-on-close'
abbr scrcpys 'scrcpy -wS --power-off-on-close'
abbr tts "tt -notheme -bold -showwpm -json"
abbr tuioss "tuios --show-clock --show-keys --show-cpu --show-ram --confirm-quit"
abbr keybinds "cd ~/.dots/ && nvim gui/.config/hypr/keybindings.conf"
abbr vpnhome "sudo wg-quick up wg0"
abbr vpnkralizec "sudo wg-quick up kralizec-wg0"
abbr vpnsumadija "sudo wg-quick up sumadija-wg0"
abbr ipadd "sudo ip route add 192.168.0.234 dev wg0"
abbr aria2cs "aria2c -x 16 -k 1M"
# Change-dir shortcuts
abbr .. "cd .."
abbr ... "cd ../.."
abbr .3 "cd ../../.."
abbr .4 "cd ../../../.."
abbr .5 "cd ../../../../.."
# Tool init
zoxide init --cmd cd fish | source
# thefuck, lazy-loaded on first use (its --alias eval costs ~170ms of shell startup)
function fuck
functions -e fuck
thefuck --alias | source
fuck $argv
end
end
# Per-host extras
test -f ~/.config/fish/host.fish; and source ~/.config/fish/host.fish
@@ -1,23 +1,25 @@
function __ssh_agent_is_started -d "check if ssh agent is already started"
if test -n "$SSH_CONNECTION"
# This is an SSH session
ssh-add -l > /dev/null 2>&1
if test $status -eq 0 -o $status -eq 1
# An SSH agent was forwarded
return 0
end
end
if test -n "$SSH_CONNECTION"
# This is an SSH session
ssh-add -l >/dev/null 2>&1
if test $status -eq 0 -o $status -eq 1
# An SSH agent was forwarded
return 0
end
end
if begin; test -f "$SSH_ENV"; and test -z "$SSH_AGENT_PID"; end
source $SSH_ENV > /dev/null
end
if begin
test -f "$SSH_ENV"; and test -z "$SSH_AGENT_PID"
end
source $SSH_ENV >/dev/null
end
if test -z "$SSH_AGENT_PID"
return 1
end
if test -z "$SSH_AGENT_PID"
return 1
end
ssh-add -l > /dev/null 2>&1
if test $status -eq 2
return 1
end
ssh-add -l >/dev/null 2>&1
if test $status -eq 2
return 1
end
end
@@ -1,5 +1,5 @@
function __ssh_agent_start -d "start a new ssh agent"
ssh-agent -c | sed 's/^echo/#echo/' > $SSH_ENV
chmod 600 $SSH_ENV
source $SSH_ENV > /dev/null
ssh-agent -c | sed 's/^echo/#echo/' >$SSH_ENV
chmod 600 $SSH_ENV
source $SSH_ENV >/dev/null
end
+2 -2
View File
@@ -1,3 +1,3 @@
function ask --description 'answer from cht.sh'
curl -s https://cht.sh/$(string join '+' $argv[1..])
function ask --description 'answer from cht.sh'
curl -s https://cht.sh/$(string join '+' $argv[1..])
end
@@ -15,4 +15,3 @@ function bind_M_n_history
end
end
end
+5 -1
View File
@@ -1,3 +1,7 @@
function clis --description 'fzf select cli'
eval (cat $HOME/.config/misc/clis.txt | command fzf --multi)
# one eval per selection — a single eval of the joined lines would run the
# first command with the other selections as its arguments
for cmd in (cat $HOME/.config/misc/clis.txt | command fzf --multi)
eval $cmd
end
end
@@ -8,7 +8,7 @@ function copy-commandline
else if type -q wl-copy
echo -n "$cmd" | wl-copy
end
# Provide brief visual feedback in the prompt
set -l original_cmd (commandline)
commandline -r "Copied!"
+4 -2
View File
@@ -7,8 +7,10 @@ function kp
read -s -P "Enter db password: " pass
echo ""
set -l entry (echo $pass | keepassxc-cli ls -q -R $KP_DB | fzf)
# -f flattens to full paths; without it nested entries are printed indented and
# without their group prefix, so `clip` can't find them. Group lines end in "/".
set -l entry (printf '%s\n' $pass | keepassxc-cli ls -q -R -f $KP_DB | string match -v '*/' | fzf | string trim)
if test -n "$entry"
echo $pass | keepassxc-cli clip -q $KP_DB $entry 0
printf '%s\n' $pass | keepassxc-cli clip -q $KP_DB $entry 0
end
end
+15 -15
View File
@@ -1,18 +1,18 @@
function lk --description 'linux knowledge'
set -l DB "$HOME/projects/dmz/soft/lk/db.rec"
set -l passes 0
set -l count 0
set -l query ""
function lk --description 'linux knowledge'
set -l DB "$HOME/projects/dmz/soft/lk/db.rec"
set -l passes 0
set -l count 0
set -l query ""
while test $passes -lt 2; and test $count -eq 0
set query (recsel "$DB" -p aim,tag | recsel -iq "$query" -CP aim,tag | sort -u | fzf --preview="recsel \"$DB\" -e \"aim~{}\" | bat")
if test -n "$query"
set count (recsel "$DB" -q "$query" -c)
end
set passes (math $passes + 1)
end
while test $passes -lt 2; and test $count -eq 0
set query (recsel "$DB" -p aim,tag | recsel -iq "$query" -CP aim,tag | sort -u | fzf --preview="recsel \"$DB\" -e \"aim~{}\" | bat")
if test -n "$query"
set count (recsel "$DB" -q "$query" -c)
end
set passes (math $passes + 1)
end
if test $count -eq 1
recsel "$DB" -q "$query" | recfmt -f "$HOME/projects/dmz/soft/lk/lists.fmt" | less
end
if test $count -eq 1
recsel "$DB" -q "$query" | recfmt -f "$HOME/projects/dmz/soft/lk/lists.fmt" | less
end
end
+20 -23
View File
@@ -1,24 +1,21 @@
function opencodes --description 'contained opencode'
bwrap \
--unshare-all \
--new-session \
--clearenv \
--die-with-parent \
--share-net \
--dev /dev \
--proc /proc \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /bin /bin \
--ro-bind /etc /etc \
--ro-bind ~/.config/opencode ~/.config/opencode \
--tmpfs /tmp/node-compile-cache \
--bind ~/.local/state/opencode ~/.local/state/opencode \
--bind ~/.local/share/opencode ~/.local/share/opencode \
--bind "$(pwd)" "$(pwd)" \
opencode
function opencodes --description 'contained opencode'
bwrap \
--unshare-all \
--new-session \
--clearenv \
--die-with-parent \
--share-net \
--dev /dev \
--proc /proc \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /bin /bin \
--ro-bind /etc /etc \
--ro-bind ~/.config/opencode ~/.config/opencode \
--tmpfs /tmp/node-compile-cache \
--bind ~/.local/state/opencode ~/.local/state/opencode \
--bind ~/.local/share/opencode ~/.local/share/opencode \
--bind "$(pwd)" "$(pwd)" \
opencode
end
+5 -1
View File
@@ -1,3 +1,7 @@
function tuis --description 'fzf select tui'
eval (cat $HOME/.config/misc/tuis.txt | command fzf --multi)
# one eval per selection — a single eval of the joined lines would run the
# first command with the other selections as its arguments
for cmd in (cat $HOME/.config/misc/tuis.txt | command fzf --multi)
eval $cmd
end
end
+1
View File
@@ -40,6 +40,7 @@ asciiquarium
cbonsai
tclock
hunk
tuicr
gittop
lazygit
lazyjournal
@@ -3,6 +3,25 @@ require("nvchad.configs.lspconfig").defaults()
local servers = { "html", "cssls", "ts_ls", "eslint" }
vim.lsp.enable(servers)
-- Waybar/GTK stylesheets are GTK CSS, a different dialect: `@name` references
-- @define-color colors (waybar's theme.css) and functions like alpha() are
-- valid there but not in web CSS, so cssls floods "property value expected" /
-- "{ expected" false errors. No LSP speaks the GTK dialect, so keep cssls
-- attached (standard-property completion still useful) and mute diagnostics
-- on those buffers only.
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if not client or client.name ~= "cssls" then
return
end
local path = vim.api.nvim_buf_get_name(args.buf)
if path:match "/waybar/" or path:match "/gtk%-%d" then
vim.diagnostic.enable(false, { bufnr = args.buf })
end
end,
})
-- local lspconfig = require "lspconfig"
-- local nvlsp = require "nvchad.configs.lspconfig"
--
+20
View File
@@ -34,6 +34,26 @@ return {
"regex",
},
},
-- On the main branch nothing acts on `ensure_installed` at startup: setup() takes only
-- install_dir, and NvChad's :TSInstallAll (which does read this list off the lazy spec)
-- only runs from `build`, i.e. when the plugin is installed or updated. So a language
-- added here stayed uninstalled until the next rebuild — 9 of these 15 were missing,
-- silently falling back to regex highlighting. Install whatever is absent, once, here.
config = function(_, opts)
local ts = require "nvim-treesitter"
ts.setup()
local have = {}
for _, lang in ipairs(ts.get_installed()) do
have[lang] = true
end
local missing = vim.tbl_filter(function(lang)
return not have[lang]
end, opts.ensure_installed or {})
if #missing > 0 then
ts.install(missing) -- async; highlighting picks each up as it lands
end
end,
},
{
+2
View File
@@ -141,6 +141,7 @@
"Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"Qwen3.6-35B-A3B-Thinking": { "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"Qwen3.5-9B-UD-Q6_K_XL": { "name": "Qwen3.5 9B · 32k · vision — quick tasks (remote)", "attachment": true, "limit": { "context": 32768, "output": 8192 } },
"Qwen3.8-27B-UD-IQ3_XXS": { "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (remote)", "attachment": true, "limit": { "context": 24576, "output": 4096 } },
"gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (remote)", "attachment": true, "limit": { "context": 65536, "output": 16384 } },
"GLM-4.7-Flash-UD-Q4_K_XL": { "name": "GLM-4.7 Flash · 24k — quality coder (remote)", "limit": { "context": 24576, "output": 8192 } },
@@ -160,6 +161,7 @@
"Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"Qwen3.6-35B-A3B-Thinking": { "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"Qwen3.5-9B-UD-Q6_K_XL": { "name": "Qwen3.5 9B · 32k · vision — quick tasks", "attachment": true, "limit": { "context": 32768, "output": 8192 } },
"Qwen3.8-27B-UD-IQ3_XXS": { "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist", "attachment": true, "limit": { "context": 24576, "output": 4096 } },
"gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", "attachment": true, "limit": { "context": 65536, "output": 16384 } },
"GLM-4.7-Flash-UD-Q4_K_XL": { "name": "GLM-4.7 Flash · 24k — quality coder", "limit": { "context": 24576, "output": 8192 } },
+15 -8
View File
@@ -7,15 +7,22 @@
# cpu module icon (U+F035B).
printf '\U000f035b '
rt="${XDG_RUNTIME_DIR:-/tmp}"
st="$rt/tmux_cpu_stat" # previous totals: tot idle
st="$rt/tmux_cpu_stat" # previous totals: tot idle
read -r _ u n s i w rest < /proc/stat
idle=$((i + w)); tot=$((u + n + s + i + w)); for v in $rest; do tot=$((tot + v)); done
read -r _ u n s i w rest </proc/stat
idle=$((i + w))
tot=$((u + n + s + i + w))
for v in $rest; do tot=$((tot + v)); done
p_tot=0; p_idle=0
[ -r "$st" ] && read -r p_tot p_idle < "$st"
[ "$p_tot" -gt "$tot" ] && { p_tot=0; p_idle=0; } # stale state from a previous boot
printf '%s %s\n' "$tot" "$idle" > "$st"
p_tot=0
p_idle=0
[ -r "$st" ] && read -r p_tot p_idle <"$st"
[ "$p_tot" -gt "$tot" ] && {
p_tot=0
p_idle=0
} # stale state from a previous boot
printf '%s %s\n' "$tot" "$idle" >"$st"
dt=$((tot - p_tot)); di=$((idle - p_idle))
dt=$((tot - p_tot))
di=$((idle - p_idle))
if [ "$dt" -le 0 ]; then printf -- '--%%'; else printf '%d%%' $(((100 * (dt - di) + dt / 2) / dt)); fi
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# RAM utilisation (%) — used = MemTotal - MemAvailable, from /proc/meminfo.
# Called from tmux status-right via #(); cached per status-interval.
printf '\U000f0f86 ' # Waybar's memory glyph (U+F0F86), then the percentage
printf '\U000f0f86 ' # Waybar's memory glyph (U+F0F86), then the percentage
awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END{if(t>0)printf "%d%%",(t-a)*100/t; else printf "--%%"}' /proc/meminfo
+12 -9
View File
@@ -10,8 +10,8 @@
# only one of them re-samples the counters per refresh.
which=${1:-dl}
rt="${XDG_RUNTIME_DIR:-/tmp}"
ctr="$rt/tmux_net_ctr" # previous counters: rx tx epoch.ns
cache="$rt/tmux_net_cache" # computed rates: dl_rate ul_rate epoch_s
ctr="$rt/tmux_net_ctr" # previous counters: rx tx epoch.ns
cache="$rt/tmux_net_cache" # computed rates: dl_rate ul_rate epoch_s
emit() { # emit <icon-printf-escape> <rate>
printf "$1 %s" "$2"
@@ -20,8 +20,8 @@ emit() { # emit <icon-printf-escape> <rate>
# Reuse a fresh cache so dl and ul agree and we only sample once per refresh.
fresh_emit() { # print from the cache and exit — if the cache is fresh enough
[ -r "$cache" ] || return 1
read -r cdl cul cts < "$cache"
{ [ -n "$cts" ] && [ "$(( $(date +%s) - cts ))" -lt 3 ]; } || return 1
read -r cdl cul cts <"$cache"
{ [ -n "$cts" ] && [ "$(($(date +%s) - cts))" -lt 3 ]; } || return 1
if [ "$which" = ul ]; then emit '\U0000f093' "$cul"; else emit '\U0000f019' "$cdl"; fi
exit 0
}
@@ -39,15 +39,18 @@ fresh_emit
now=$(date +%s.%N)
read -r rx tx < <(awk 'NR>2{gsub(/:/," "); if($1!="lo"){r+=$2; t+=$10}} END{print r+0, t+0}' /proc/net/dev)
drx=0; dtx=0; dt=1
drx=0
dtx=0
dt=1
if [ -r "$ctr" ]; then
read -r prx ptx pnow < "$ctr"
read -r prx ptx pnow <"$ctr"
dt=$(awk -v a="$now" -v b="$pnow" 'BEGIN{d=a-b; print (d>0.05)?d:1}')
drx=$(( rx - prx )); dtx=$(( tx - ptx ))
drx=$((rx - prx))
dtx=$((tx - ptx))
[ "$drx" -lt 0 ] && drx=0
[ "$dtx" -lt 0 ] && dtx=0
fi
printf '%s %s %s\n' "$rx" "$tx" "$now" > "$ctr"
printf '%s %s %s\n' "$rx" "$tx" "$now" >"$ctr"
human() { awk -v b="$1" 'BEGIN{ split("B K M G T", u, " "); i=1;
while (b >= 1024 && i < 5) { b /= 1024; i++ }
@@ -55,6 +58,6 @@ human() { awk -v b="$1" 'BEGIN{ split("B K M G T", u, " "); i=1;
dl=$(human "$(awk -v x="$drx" -v d="$dt" 'BEGIN{printf "%d", x/d}')")
ul=$(human "$(awk -v x="$dtx" -v d="$dt" 'BEGIN{printf "%d", x/d}')")
printf '%s %s %s\n' "$dl" "$ul" "$(date +%s)" > "$cache"
printf '%s %s %s\n' "$dl" "$ul" "$(date +%s)" >"$cache"
if [ "$which" = ul ]; then emit '\U0000f093' "$ul"; else emit '\U0000f019' "$dl"; fi
+14 -9
View File
@@ -5,15 +5,19 @@
# (acpitz can be a bogus constant on desktops, hence last). Prints NOTHING when
# no sensor exists (VMs/containers) so the segment simply disappears — which is
# also why the trailing spacing lives in here, not in status-right.
cpu_zone=""; any_zone=""; hw=""
cpu_zone=""
any_zone=""
hw=""
for d in /sys/class/thermal/thermal_zone*/; do
[ -r "${d}temp" ] && [ -r "${d}type" ] || continue
t=$(<"${d}temp")
case $t in ''|*[!0-9]*) continue ;; esac
ty=$(<"${d}type"); ty=${ty,,}
case $ty in *x86_pkg_temp*|*coretemp*|*k10temp*|*tctl*|*cpu*)
if [ -z "$cpu_zone" ] || [ "$t" -gt "$cpu_zone" ]; then cpu_zone=$t; fi ;;
case $t in '' | *[!0-9]*) continue ;; esac
ty=$(<"${d}type")
ty=${ty,,}
case $ty in *x86_pkg_temp* | *coretemp* | *k10temp* | *tctl* | *cpu*)
if [ -z "$cpu_zone" ] || [ "$t" -gt "$cpu_zone" ]; then cpu_zone=$t; fi
;;
esac
if [ -z "$any_zone" ] || [ "$t" -gt "$any_zone" ]; then any_zone=$t; fi
done
@@ -21,17 +25,18 @@ done
if [ -z "$cpu_zone" ]; then
for d in /sys/class/hwmon/hwmon*/; do
[ -r "${d}name" ] || continue
case $(<"${d}name") in coretemp|k10temp|zenpower|cpu_thermal)
case $(<"${d}name") in coretemp | k10temp | zenpower | cpu_thermal)
for f in "${d}"temp*_input; do
[ -r "$f" ] || continue
t=$(<"$f")
case $t in ''|*[!0-9]*) continue ;; esac
case $t in '' | *[!0-9]*) continue ;; esac
if [ -z "$hw" ] || [ "$t" -gt "$hw" ]; then hw=$t; fi
done ;;
done
;;
esac
done
fi
t=${cpu_zone:-${hw:-$any_zone}}
[ -n "$t" ] || exit 0
printf '\U000f050f %d° ' $(( (t + 500) / 1000 ))
printf '\U000f050f %d° ' $(((t + 500) / 1000))
+71 -5
View File
@@ -3,6 +3,18 @@
set -g extended-keys on
set -g extended-keys-format csi-u
# Mouse/touch reporting. Chiefly so the window list in the status bar is
# tappable — tmux binds MouseDown1Status to `switch-client -t =` out of the box,
# and status-format[1] below keeps the `range=window|…` markers that make each
# entry a hit target, so a tap on a tab switches to it with no extra binding.
# Wheel over the bar cycles windows; right-click a tab or the session name for
# tmux's built-in menus (no touch equivalent — a phone can't right-click).
#
# The cost: drag-select now goes to tmux instead of kitty, so hold Shift when you
# want kitty's own selection. Scroll wheel enters copy-mode on the pane rather
# than kitty's scrollback, and a click focuses the pane under it.
set -g mouse on
# Number windows (and panes) from 1 instead of 0 — easier to reach on the
# keyboard. renumber-windows keeps them gap-free when a window is closed.
set -g base-index 1
@@ -42,8 +54,36 @@ set -g status-style "bg=default,fg=#cdd6f4" # transparent: no bar background
set -g status-left-length 40
set -g status-right-length 100
# Left: session name (mauve/bold) + thin divider.
set -g status-left " #[fg=#cba6f7,bold]#S #[fg=#45475a]│ "
# --- Mobile view -----------------------------------------------------------
# status-left and status-right are re-evaluated per client on every draw, and
# #{client_width} is that client's own width — so in `auto` mode a phone attached
# to the same session as the desktop renders the compact bars while the desktop
# keeps the full ones, simultaneously and with no intervention.
#
# @status_mode is auto (per-client, by width) | full | compact, cycled with
# prefix+S. Rather than branch each bar three ways, the mode picks the width a
# client has to beat — 0 nothing can lose, 9999 nothing can win — so each
# decision below stays a single comparison. The 100 is the switch point: the full
# right segment is ~60 columns before the window list even starts. Set
# @status_mode without -g to scope an override to one session, so a grouped
# mobile session can sit on `compact` while the desktop session stays on `auto`.
#
# Each variant lives in its own option instead of inline because #{?a,b,c} splits
# its branches on commas and the styles are full of them (#[fg=…,bold]); option
# values are substituted after the ternary is parsed, so those commas are never
# seen as separators.
#
# NOTE: the comparisons MUST use #{e|>=:…}. Plain #{>=:…} compares as strings, so
# "144" >= "100" is false and every client would get the compact bar.
set -g @status_mode auto
set -g @status_cutoff "#{?#{==:#{@status_mode},full},0,#{?#{==:#{@status_mode},compact},9999,100}}"
# Left: session name (mauve/bold) + thin divider. Compact clips the name to two
# characters — enough to tell sessions apart when every column counts. #{=2:…}
# takes a format NAME, so it's session_name here rather than the #S shorthand.
set -g @left_full " #[fg=#cba6f7,bold]#S #[fg=#45475a]│ "
set -g @left_compact " #[fg=#cba6f7,bold]#{=2:session_name} #[fg=#45475a]│ "
set -g status-left "#{?#{e|>=:#{client_width},#{E:@status_cutoff}},#{E:@left_full},#{E:@left_compact}}"
# Middle: flat window list — inactive muted, active mauve/bold, 2-space gaps.
set -g window-status-separator " "
@@ -52,9 +92,35 @@ setw -g window-status-current-format "#[fg=#cba6f7,bold]#I #[fg=#cdd6f4,bold]#W"
setw -g window-status-activity-style "fg=#f9e2af"
# Right: net dl/ul · cpu · mem │ date │ clock. continuum prepends its invisible
# save hook here (this block sits above the tpm `run` line, so the hook survives
# every reload). Metrics are scripts/*.sh, symlinked into ~/.config/tmux/scripts/.
set -g status-right "#[fg=#a6e3a1]#(~/.config/tmux/scripts/net.sh dl) #[fg=#f9e2af]#(~/.config/tmux/scripts/net.sh ul) #[fg=#89b4fa]#(~/.config/tmux/scripts/cpu.sh) #[fg=#94e2d5]#(~/.config/tmux/scripts/mem.sh) #[fg=#fab387]#(~/.config/tmux/scripts/temp.sh)#[fg=#45475a]│ #[fg=#a6adc8]%a %b %-d #[fg=#45475a]│ #[fg=#cba6f7,bold]%H:%M "
# save hook to status-right (this block sits above the tpm `run` line, so the
# hook survives every reload). Metrics are scripts/*.sh, symlinked into
# ~/.config/tmux/scripts/. The hook lands ahead of the #{?} below, i.e. outside
# it, so it keeps running for every client regardless of which variant is drawn.
set -g @right_full "#[fg=#a6e3a1]#(~/.config/tmux/scripts/net.sh dl) #[fg=#f9e2af]#(~/.config/tmux/scripts/net.sh ul) #[fg=#89b4fa]#(~/.config/tmux/scripts/cpu.sh) #[fg=#94e2d5]#(~/.config/tmux/scripts/mem.sh) #[fg=#fab387]#(~/.config/tmux/scripts/temp.sh)#[fg=#45475a]│ #[fg=#a6adc8]%a %b %-d #[fg=#45475a]│ #[fg=#cba6f7,bold]%H:%M "
# Compact: cpu + mem only. Net rates and the temp glyph go first (widest, least
# useful on a phone), then the date, then the clock — a phone shows its own.
set -g @right_compact "#[fg=#89b4fa]#(~/.config/tmux/scripts/cpu.sh) #[fg=#94e2d5]#(~/.config/tmux/scripts/mem.sh) "
set -g status-right "#{?#{e|>=:#{client_width},#{E:@status_cutoff}},#{E:@right_full},#{E:@right_compact}}"
# Cycle auto -> compact -> full -> auto. Braces instead of nested quoted strings
# so the styles' commas and the inner if-shell don't need escaping.
bind S {
if -F '#{==:#{@status_mode},auto}' {
set -g @status_mode compact
display 'status bar: compact (forced)'
} {
if -F '#{==:#{@status_mode},compact}' {
set -g @status_mode full
display 'status bar: full (forced)'
} {
set -g @status_mode auto
display 'status bar: auto — switches at 100 columns'
}
}
refresh-client -S
}
# Two rows: [0] drawn top-border rule · [1] the bar content. The rule doubles as
# the separator from the pane content above; tmux status height is whole rows, so
+72 -10
View File
@@ -56,15 +56,70 @@ have a slash command to disable them for the current session without deleting.
| Extension | What it adds | Origin | Remove |
|---|---|---|---|
| `statusbar.ts` | Replaces the footer with a Claude-Code-style status line (cwd, git branch, model, context %, decode t/s, token counts) in Nerd Font glyphs; adds a streaming pulse + a ≥80%-context warning widget | **custom** | delete file → default footer returns; or `/statusbar` to toggle off for the session |
| `vim-editor.ts` | Modal (vim-like) input editor with `[I]/[N]/[V]` indicator, hjkl/word/line motions, counts, yank/paste via the kill-ring | **local fork** of pi's bundled `examples/extensions/modal-editor.ts` | delete file → default editor; or `/vim` to toggle |
| `plan-mode/` | Read-only "plan" mode (disables edit/write, restricts bash) bound to **Shift+Tab**; `/plan` toggles, `--plan` starts in it | **vendored** copy of pi's bundled `examples/extensions/plan-mode/`, rebound from upstream `Ctrl+Alt+P` | delete the folder **and** revert the `keybindings.json` `app.thinking.cycle` remap (see below) |
| `vim-toggle.ts` | `/vim` — drops the modal editor for the rest of the session, and brings it back via `ctx.reload()`. Pairs with the **`pi-vim` package** (below), which registers no command of its own | **custom** | delete file → you lose the toggle, not the editor; remove the package to drop modal editing itself |
| `plan-mode/` | Read-only "plan" mode (disables edit/write, restricts bash) bound to **Shift+Tab**; `/plan` toggles, `--plan` starts in it, `/plan-done` clears a stuck progress checklist | **vendored** copy of pi's bundled `examples/extensions/plan-mode/`, rebound from upstream `Ctrl+Alt+P` | delete the folder **and** revert the `keybindings.json` `app.thinking.cycle` remap (see below) |
| `notify.ts` | Native terminal notification (OSC 777/99, Windows toast) when the agent goes idle | **verbatim** from pi's `examples/extensions/notify.ts` | delete file |
| `questionnaire.ts` | Interactive multi-question overlay the model can call as a tool | **near-verbatim** from pi's `examples/extensions/questionnaire.ts` (one-line fix) | delete file |
| `permission-gate.ts` | Prompts before dangerous `bash` commands (blocks outright with no UI). Local list covers git history rewrites, package managers, `systemctl` and irreversible disk ops on top of upstream's `rm -rf`/`sudo`/`chmod 777` | **adapted** from pi's `examples/extensions/permission-gate.ts` | delete file |
| `protected-paths.ts` | Blocks `write`/`edit` to secrets and repo plumbing (`secrets.fish`, `auth.json`, `.env`, `.ssh/`, `.git/`, …) | **adapted** from pi's `examples/extensions/protected-paths.ts` | delete file |
| `compaction-watch.ts` | Surfaces every compaction and its `reason`; loudly flags `overflow` (recovery rather than prevention), which means `reserveTokens` is below the model's `maxTokens` | **custom** | delete file |
| `confirm-destructive.ts` | Confirms before session clear / switch / fork | **verbatim** from pi's `examples/extensions/confirm-destructive.ts` | delete file |
| `session-name.ts` | `/session-name [name]` — friendly names in the session picker instead of the first message | **verbatim** from pi's `examples/extensions/session-name.ts` | delete file |
| `todo.ts` | A `todo` tool the model calls (add/toggle/list/clear) plus `/todos` to view it; state lives in tool results, so forking rewinds it correctly. Distinct from plan-mode's checklist | **verbatim** from pi's `examples/extensions/todo.ts` | delete file (it also adds a tool schema to every request) |
> The bundled examples live at `/opt/pi-coding-agent/examples/extensions/` — that's
> where `notify`/`questionnaire`/`modal-editor`/`plan-mode` came from, and where to
> re-pull the two forks after a pi upgrade before re-applying the local edits (the
> forks' own headers document those edits).
> where `notify`/`questionnaire`/`plan-mode` came from. `plan-mode/` is now the only
> **fork** left, so it's the only one to re-pull after a pi upgrade before re-applying
> the local edits (its header documents them). `notify`/`questionnaire` carry no edits
> worth keeping — just re-copy them. The modal editor used to be a fork of
> `modal-editor.ts` too; it was replaced by the `pi-vim` package on 2026-08-05 and the
> fork deleted on 2026-08-09 — `git show c6566af^:common/.pi/agent/extensions/vim-editor.ts`
> brings it back if ever wanted.
### Modal (vim) input — the `pi-vim` package
Modal editing is **not** an extension in this tree any more; it's the npm package
[`pi-vim`](https://github.com/lajarre/pi-vim), pinned by `settings.json > packages`
and installed into the gitignored `agent/npm/`. That swap is the point: `pi update
npm:pi-vim` instead of re-pulling and re-patching a fork on every pi upgrade.
Five modes (INSERT/NORMAL/VISUAL/V-LINE/EX) with the usual motions, operators, text
objects, undo and `.` repeat; the mode renders as a word label at the editor's
bottom-right, doubling as a pending-command display (` NORMAL 3d2w_ `). `:` dispatches
real pi commands and shells out with `:!`. No search (`/`, `?`, `n`), macros or
visual-block — upstream implements none of those.
```fish
pi install npm:pi-vim
```
> ⚠️ **This pi build needs pi-vim's peer dep installed by hand.** pi ships as a
> Bun-compiled ELF with the `@earendil-works/*` packages *embedded*, not on disk.
> pi-vim's `clipboard-mirror.ts` calls `import.meta.resolve("@earendil-works/pi-coding-agent")`
> at **module top level**, so after a bare `pi install` the resolve throws and the whole
> extension fails to load with `Cannot find module '@earendil-works/pi-coding-agent'`.
> No setting avoids it — the call runs at import time. Once per host, matching
> `pi --version` exactly:
>
> ```fish
> cd ~/.pi/agent/npm
> fnm exec --using=22 -- npm install --save-exact @earendil-works/pi-coding-agent@0.83.0
> ```
>
> `pi install` deliberately doesn't pull peers (it would duplicate the runtime). Costs
> ~170 MB, and wants re-pinning after every pi upgrade — hence `--save-exact`, since a
> caret range would quietly drift off `pi --version`.
Configured under `settings.json > piVim`: `modeColors` (theme tokens, not raw ANSI),
`borderSync`/`labelSync` pinned so only the label is tinted, and
`clipboardMirror: "yank"` — upstream's default `"all"` mirrors deletes too, so every
`dd`/`x` would clobber the OS clipboard. `exCommand.copyInputToClipboard` is left
**off**: it copies the composed prompt out, so upstream only honours it from the
user-global settings file.
**Remove:** `pi remove npm:pi-vim`, and delete `agent/extensions/vim-toggle.ts` plus the
`piVim` block. `@burneikis/pi-vim` and `pi-vimmode` are alternatives that need no peer
install.
### Theme
@@ -110,11 +165,14 @@ Beyond pointing at local models, these values deviate from pi's defaults:
| Key | Value here | Stock default | Why |
|---|---|---|---|
| `compaction.reserveTokens` | `6144` | 16384 | small local windows (24k128k) — don't let the response reserve dominate a short window |
| `compaction.keepRecentTokens` | `6000` | 20000 | keep less verbatim so short windows don't thrash into repeated compaction |
| `compaction.reserveTokens` | `8192` | 16384 | small local windows (24k128k), but **≥ every model's `maxTokens`** — otherwise a turn can overflow the window before compaction even fires |
| `compaction.keepRecentTokens` | `4000` | 20000 | keep less verbatim, to pay for the larger reserve and leave room after a compaction for the next one |
| `npmCommand` | `fnm exec --using=22 -- npm` | `npm` | pin extension npm installs to Node 22 via fnm |
| `retry` / `httpIdleTimeoutMs` | long (1h provider timeout, 10m idle) | shorter | local models can be slow to first token |
| `defaultThinkingLevel` | `off` | — | most local models here are non-reasoning |
| `packages` | `["npm:pi-vim"]` | `[]` | the modal editor — see the pi-vim section above |
| `piVim` | mode colors, `clipboardMirror: "yank"`, `:` → pi bridge | — | package config, not a pi setting; ignored if the package is removed |
| `collapseChangelog` | `true` | `false` | condensed changelog after an upgrade instead of the full screen |
**Remove:** delete each key to fall back to pi's default (or drop the whole
`compaction`/`retry` block).
@@ -140,8 +198,9 @@ model.
> config. `auth.json` is **not tracked** (gitignored as of 2026-08-08): pi's interactive
> `/login` rewrites it with the **literal** key, and a tracked copy would be one stray
> `/login` away from committing a real token. Bootstrap a new host with
> `cp auth.json.example auth.json`. For bash its `.bash_profile` since it's not tracked by
> git, stow not applied.
> `cp auth.json.example auth.json`. The key itself lives in a shell file that is *not*
> tracked and *not* stowed, so it never reaches this repo — `conf.d/secrets.fish` for
> fish, `~/.bash_profile` for bash.
---
@@ -150,7 +209,10 @@ model.
- **Just the status bar:** copy `agent/extensions/statusbar.ts` into your
`~/.pi/agent/extensions/`, re-stow/restart, `/reload`. Colors follow your active
theme automatically. Needs a Nerd Font terminal for the glyphs.
- **Just vim input:** copy `agent/extensions/vim-editor.ts`, `/reload`, `/vim`.
- **Just vim input:** `pi install npm:pi-vim`, then the peer-dep install above (this
build needs it), then `/reload`. Copy the `piVim` block from `settings.json` for the
same colors and clipboard behaviour, and `agent/extensions/vim-toggle.ts` if you want
`/vim` to toggle it.
- **Just plan mode:** copy `agent/extensions/plan-mode/` **and** add the
`app.thinking.cycle` remap to your `keybindings.json` (else Shift+Tab collides).
- **Just the theme:** copy `agent/themes/catppuccin-mocha.json`, set
+22
View File
@@ -0,0 +1,22 @@
# Global agent instructions
You are a coding assistant running on small, locally-hosted models. Be precise and economical with tokens.
## Working style
- Act with tools instead of describing what you would do. Keep prose short.
- Do the task that was asked. Don't add unrequested changes, refactors, or files.
- When done, stop. A one- or two-line summary is enough; no recaps of obvious steps.
## Files & edits
- Read a file before you edit it. Never guess at file paths, function names, or APIs — verify first.
- Use the edit tool for changes. Make minimal, targeted diffs; never paste an entire file back to the user.
- Match the surrounding code's style, naming, and imports.
## Shell
- Run one command at a time and check its output before the next.
- Prefer `rg` and `fd` for search. Use read-only commands when exploring.
- Never run destructive or system-changing commands (`rm -rf`, `sudo`, package installs, force-push) unless explicitly asked.
## Honesty
- If you're unsure, say so and check rather than inventing an answer.
- Report failures plainly with the actual error; don't claim success you didn't verify.
@@ -0,0 +1,60 @@
/**
* Compaction Watch
*
* Compaction is otherwise silent, which is why "compacting fails a lot" took code
* reading and arithmetic to diagnose rather than a glance at a log. This makes it
* visible, and specifically names the failure mode that was breaking it here before
* 2026-08-16.
*
* pi reports a `reason` on every compaction:
* "manual" - you ran /compact
* "threshold" - the healthy path: contextTokens passed contextWindow - reserveTokens
* "overflow" - the window was ALREADY full; this is recovery, not prevention
*
* An "overflow" compaction means the turn blew past contextWindow before the threshold
* could fire, which happens whenever `compaction.reserveTokens` is smaller than the
* model's `maxTokens`. pi then gets one recovery attempt, summarizing from an
* already-overflowed context — and when that does not fit either, the session dies with
* "Context overflow recovery failed after one compact-and-retry attempt".
*
* So: seeing "threshold" is fine. Seeing "overflow" means the settings are wrong for the
* model in use, and this says so at the moment it happens instead of leaving you to
* infer it later. See CLAUDE.md > Compaction tuning.
*
* Note there is no event for a *failed* auto-compaction — `onError` exists only on the
* manual `ctx.compact()` call — so catching the overflow entry is the earliest reliable
* warning available.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
let overflowCount = 0;
pi.on("session_before_compact", async (event, ctx) => {
if (!ctx.hasUI) return undefined;
if (event.reason === "overflow") {
overflowCount++;
ctx.ui.notify(
`Compacting in OVERFLOW recovery (#${overflowCount}) — the context window was already ` +
`full before compaction could fire. Raise compaction.reserveTokens above this model's ` +
`maxTokens, or cap maxTokens in models.json. See CLAUDE.md > Compaction tuning.`,
"warning",
);
}
// Never alter the compaction itself — observe only.
return undefined;
});
pi.on("session_compact", async (event, ctx) => {
if (!ctx.hasUI) return;
const reason = event.reason ?? "unknown";
const retrying = event.willRetry ? ", retrying the aborted turn" : "";
const level = reason === "overflow" ? "warning" : "info";
ctx.ui.notify(`Compacted (${reason}${retrying})`, level);
});
}
@@ -0,0 +1,59 @@
/**
* Confirm Destructive Actions Extension
*
* Prompts for confirmation before destructive session actions (clear, switch, branch).
* Demonstrates how to cancel session events using the before_* events.
*/
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
if (!ctx.hasUI) return;
if (event.reason === "new") {
const confirmed = await ctx.ui.confirm(
"Clear session?",
"This will delete all messages in the current session.",
);
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
}
return;
}
// reason === "resume" - check if there are unsaved changes (messages since last assistant response)
const entries = ctx.sessionManager.getEntries();
const hasUnsavedWork = entries.some(
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
);
if (hasUnsavedWork) {
const confirmed = await ctx.ui.confirm(
"Switch session?",
"You have messages in the current session. Switch anyway?",
);
if (!confirmed) {
ctx.ui.notify("Switch cancelled", "info");
return { cancel: true };
}
}
});
pi.on("session_before_fork", async (event, ctx) => {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Fork from entry ${event.entryId.slice(0, 8)}?`, [
"Yes, create fork",
"No, stay in current session",
]);
if (choice !== "Yes, create fork") {
ctx.ui.notify("Fork cancelled", "info");
return { cancel: true };
}
});
}
@@ -0,0 +1,69 @@
/**
* Permission Gate Extension
*
* Prompts for confirmation before running potentially dangerous bash commands.
*
* Adapted from pi's bundled `examples/extensions/permission-gate.ts`. Upstream checks
* three patterns (rm -rf, sudo, chmod/chown 777); the list below is extended to cover
* the rules AGENTS.md only *asks* for. That distinction is the whole point of running
* this: AGENTS.md is a prompt, and the small local models this config targets follow
* prompts unreliably. This is the enforcement half — the same trick plan-mode already
* uses when it disables edit/write outright.
*
* In the TUI a match prompts (so "Yes" still gets you through); with no UI it blocks.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const dangerousPatterns = [
// --- upstream ---
/\brm\s+(-rf?|--recursive)/i,
/\bsudo\b/i,
/\b(chmod|chown)\b.*777/i,
// --- LOCAL: git history is the user's to write, never the agent's ---
// The standing rule is that commits and pushes are made by hand, after review.
/\bgit\s+(commit|push|revert)\b/i,
/\bgit\s+reset\s+--hard\b/i,
/\bgit\s+(rebase|cherry-pick)\b/i,
/\bgit\s+clean\s+-[a-z]*[fd]/i,
/\bgit\s+stash\s+(drop|clear|pop)\b/i,
/--force\b|--force-with-lease\b/i,
// --- LOCAL: system-changing commands AGENTS.md rules out ---
/\b(pacman|yay|paru)\s+-[A-Za-z]*[SRU]/,
/\b(npm|pnpm|yarn)\s+(install|i|add|remove|uninstall)\b/i,
/\bpip3?\s+(install|uninstall)\b/i,
/\bcargo\s+(install|uninstall)\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable|mask)\b/i,
// --- LOCAL: irreversible disk / process operations ---
/\bmkfs\b|\bdd\s+if=|\bshred\b|\bwipefs\b/i,
/\b(reboot|shutdown|poweroff|halt)\b/i,
/\b(killall|pkill)\b/i,
/>\s*\/dev\/(sd|nvme|vd)/i,
];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const command = event.input.command as string;
const matched = dangerousPatterns.find((p) => p.test(command));
if (matched) {
if (!ctx.hasUI) {
// Non-interactive (`pi -p`): nothing can confirm, so refuse.
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
}
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
if (choice !== "Yes") {
return { block: true, reason: "Blocked by user" };
}
}
return undefined;
});
}
@@ -142,6 +142,25 @@ export default function planModeExtension(pi: ExtensionAPI): void {
handler: async (_args, ctx) => togglePlanMode(ctx),
});
// LOCAL EDIT: the checklist widget only tears itself down once every step is
// marked complete, which needs the model to emit a [DONE:n] tag per step. When
// one is missed the widget sticks around after the work is finished, so give
// the user a way out that doesn't involve toggling plan mode on and off again.
pi.registerCommand("plan-done", {
description: "Clear a stuck plan checklist (stops execution tracking)",
handler: async (_args, ctx) => {
if (!executionMode && todoItems.length === 0) {
ctx.ui.notify("No plan is being tracked.");
return;
}
executionMode = false;
todoItems = [];
updateStatus(ctx);
persistState();
ctx.ui.notify("Plan tracking cleared.");
},
});
pi.registerCommand("todos", {
description: "Show current plan todo list",
handler: async (_args, ctx) => {
+10 -3
View File
@@ -151,9 +151,16 @@ export function extractTodoItems(message: string): TodoItem[] {
export function extractDoneSteps(message: string): number[] {
const steps: number[] = [];
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
const step = Number(match[1]);
if (Number.isFinite(step)) steps.push(step);
// LOCAL EDIT: upstream matched only the literal `[DONE:3]`. Small local models
// drift on it, and one missed tag pins the plan widget on screen forever, so
// also accept `[DONE 3]`, `[DONE-3]` and comma lists `[DONE:1,2]`. The brackets
// stay required on purpose: a bare "done 3" in prose must not mark a step.
for (const match of message.matchAll(/\[\s*DONE\s*[:\-=]?\s*([\d,\s]+?)\s*\]/gi)) {
for (const part of match[1].split(/[,\s]+/)) {
if (part === "") continue;
const step = Number(part);
if (Number.isFinite(step)) steps.push(step);
}
}
return steps;
}
@@ -0,0 +1,58 @@
/**
* Protected Paths Extension
*
* Blocks write and edit operations to protected paths.
*
* Adapted from pi's bundled `examples/extensions/protected-paths.ts`. Upstream ships a
* three-entry demo list (.env, .git/, node_modules/); the list below names the files
* that actually matter in this dotfiles tree — the ones holding secrets, and the git
* metadata the agent has no business rewriting.
*
* Scope, so this isn't over-trusted: it guards against the *model* calling write/edit.
* It cannot stop pi's own `/login`, which rewrites auth.json with the literal key —
* that is why auth.json is gitignored and auth.json.example is the tracked copy.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const protectedPaths = [
// Secrets. `secrets.fish` holds $DUSKADIY_API_KEY; auth.json is pi's own
// credential map and must keep $VAR references rather than literals.
"secrets.fish",
"auth.json",
".env",
"fish_variables",
// Credentials outside the repo.
".ssh/",
".gnupg/",
".aws/",
".netrc",
// Repo plumbing and vendored trees — never hand-edited.
".git/",
"node_modules/",
];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") {
return undefined;
}
const path = event.input.path as string;
// Substring match, like upstream: cheap, and these names are distinctive enough.
// Note `auth.json.example` is intentionally caught too — it is a tracked file
// whose whole job is to contain no secret, so an agent rewrite is worth a look.
const isProtected = protectedPaths.some((p) => path.includes(p));
if (isProtected) {
if (ctx.hasUI) {
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
}
return { block: true, reason: `Path "${path}" is protected` };
}
return undefined;
});
}
@@ -0,0 +1,27 @@
/**
* Session naming example.
*
* Shows setSessionName/getSessionName to give sessions friendly names
* that appear in the session selector instead of the first message.
*
* Usage: /session-name [name] - set or show session name
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.registerCommand("session-name", {
description: "Set or show session name (usage: /session-name [new name])",
handler: async (args, ctx) => {
const name = args.trim();
if (name) {
pi.setSessionName(name);
ctx.ui.notify(`Session named: ${name}`, "info");
} else {
const current = pi.getSessionName();
ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
}
},
});
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Todo Extension - Demonstrates state management via session entries
*
* This extension:
* - Registers a `todo` tool for the LLM to manage todos
* - Registers a `/todos` command for users to view the list
*
* State is stored in tool result details (not external files), which allows
* proper branching - when you branch, the todo state is automatically
* correct for that point in history.
*/
import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { Type } from "typebox";
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodoDetails {
action: "list" | "add" | "toggle" | "clear";
todos: Todo[];
nextId: number;
error?: string;
}
const TodoParams = Type.Object({
action: StringEnum(["list", "add", "toggle", "clear"] as const),
text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
});
/**
* UI component for the /todos command
*/
class TodoListComponent {
private todos: Todo[];
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
this.todos = todos;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines: string[] = [];
const th = this.theme;
lines.push("");
const title = th.fg("accent", " Todos ");
const headerLine =
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
lines.push(truncateToWidth(headerLine, width));
lines.push("");
if (this.todos.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
} else {
const done = this.todos.filter((t) => t.done).length;
const total = this.todos.length;
lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
lines.push("");
for (const todo of this.todos) {
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
const id = th.fg("accent", `#${todo.id}`);
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
}
}
lines.push("");
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
export default function (pi: ExtensionAPI) {
// In-memory state (reconstructed from session on load)
let todos: Todo[] = [];
let nextId = 1;
/**
* Reconstruct state from session entries.
* Scans tool results for this tool and applies them in order.
*/
const reconstructState = (ctx: ExtensionContext) => {
todos = [];
nextId = 1;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
const details = msg.details as TodoDetails | undefined;
if (details) {
todos = details.todos;
nextId = details.nextId;
}
}
};
// Reconstruct state on session events
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// Register the todo tool for the LLM
pi.registerTool({
name: "todo",
label: "Todo",
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
parameters: TodoParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
switch (params.action) {
case "list":
return {
content: [
{
type: "text",
text: todos.length
? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
: "No todos",
},
],
details: { action: "list", todos: [...todos], nextId } as TodoDetails,
};
case "add": {
if (!params.text) {
return {
content: [{ type: "text", text: "Error: text required for add" }],
details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
};
}
const newTodo: Todo = { id: nextId++, text: params.text, done: false };
todos.push(newTodo);
return {
content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
details: { action: "add", todos: [...todos], nextId } as TodoDetails,
};
}
case "toggle": {
if (params.id === undefined) {
return {
content: [{ type: "text", text: "Error: id required for toggle" }],
details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
};
}
const todo = todos.find((t) => t.id === params.id);
if (!todo) {
return {
content: [{ type: "text", text: `Todo #${params.id} not found` }],
details: {
action: "toggle",
todos: [...todos],
nextId,
error: `#${params.id} not found`,
} as TodoDetails,
};
}
todo.done = !todo.done;
return {
content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
};
}
case "clear": {
const count = todos.length;
todos = [];
nextId = 1;
return {
content: [{ type: "text", text: `Cleared ${count} todos` }],
details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: {
action: "list",
todos: [...todos],
nextId,
error: `unknown action: ${params.action}`,
} as TodoDetails,
};
}
},
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme, _context) {
const details = result.details as TodoDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.error) {
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
}
const todoList = details.todos;
switch (details.action) {
case "list": {
if (todoList.length === 0) {
return new Text(theme.fg("dim", "No todos"), 0, 0);
}
let listText = theme.fg("muted", `${todoList.length} todo(s):`);
const display = expanded ? todoList : todoList.slice(0, 5);
for (const t of display) {
const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
}
if (!expanded && todoList.length > 5) {
listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
}
return new Text(listText, 0, 0);
}
case "add": {
const added = todoList[todoList.length - 1];
return new Text(
theme.fg("success", "✓ Added ") +
theme.fg("accent", `#${added.id}`) +
" " +
theme.fg("muted", added.text),
0,
0,
);
}
case "toggle": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "clear":
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
}
},
});
// Register the /todos command for users
pi.registerCommand("todos", {
description: "Show all todos on the current branch",
handler: async (_args, ctx) => {
if (ctx.mode !== "tui") {
ctx.ui.notify("/todos requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}
+26 -6
View File
@@ -52,7 +52,7 @@
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
@@ -62,7 +62,7 @@
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
@@ -75,6 +75,16 @@
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.8-27B-UD-IQ3_XXS",
"name": "Qwen3.8 27B · 24k · vision — hybrid reasoner",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "GLM-4.7-Flash-UD-Q4_K_XL",
"name": "GLM-4.7 Flash · 24k — quality coder",
@@ -82,7 +92,7 @@
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text"],
"contextWindow": 24576,
"maxTokens": 8192,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
@@ -157,7 +167,7 @@
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
@@ -167,7 +177,7 @@
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
@@ -180,6 +190,16 @@
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.8-27B-UD-IQ3_XXS",
"name": "Qwen3.8 27B · 24k · vision — hybrid reasoner",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "GLM-4.7-Flash-UD-Q4_K_XL",
"name": "GLM-4.7 Flash · 24k — quality coder",
@@ -187,7 +207,7 @@
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text"],
"contextWindow": 24576,
"maxTokens": 8192,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
+3 -2
View File
@@ -4,6 +4,7 @@
"defaultThinkingLevel": "off",
"theme": "catppuccin-mocha",
"quietStartup": false,
"collapseChangelog": true,
"defaultProjectTrust": "ask",
"enableInstallTelemetry": false,
"enabledModels": [
@@ -12,8 +13,8 @@
],
"compaction": {
"enabled": true,
"reserveTokens": 6144,
"keepRecentTokens": 6000
"reserveTokens": 8192,
"keepRecentTokens": 4000
},
"retry": {
"enabled": true,