[Update] bulk update
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user