[Sync] Lua Hyprland chain, licence cleanup
This commit is contained in:
1 parent
93b4f695ce
commit
47166d877f
183 files changed
+5618
-5839
No files matched your search
@@ -2,13 +2,15 @@
|
||||
"""Claude Code status line.
|
||||
|
||||
One line: vim mode | dir (repo-relative, hidden at the root) |
|
||||
git(repo/branch +dirty +ahead/behind) | model (+effort) |
|
||||
git(repo/branch +dirty +ahead/behind) | PR review threads (open count, Codex's share) | model (+effort) |
|
||||
context% (+compact warn) | 5h usage (+reset eta) | cost (+burn rate) |
|
||||
session tokens (cumulative in/out) | prompt-cache hit% (+ttl, cold marker) | disk. If the
|
||||
session tokens (cumulative in/out) | decode speed t/s (thinking blocks) |
|
||||
prompt-cache hit% (+ttl, cold marker) | disk. If the
|
||||
rendered line would overflow the terminal, trailing (lowest-priority) segments are
|
||||
dropped until it fits.
|
||||
(Dormant, re-addable in main(): ram, cpu%, temp -- retired 2026-07-26 because the
|
||||
status line only refreshes on message events, so system metrics sat visibly stale;
|
||||
status line then only refreshed on message events, so system metrics sat visibly stale
|
||||
(settings.json's 60 s refreshInterval, added for the PR count, is still too coarse for them);
|
||||
tmux's status bar owns those now -- plus velocity, cache hit %, api ratio, version,
|
||||
style.)
|
||||
|
||||
@@ -16,6 +18,7 @@ Reads the status JSON from stdin (Claude Code statusLine command). Every segment
|
||||
is wrapped defensively so the status line can never crash the UI -- on any error
|
||||
a segment is simply omitted.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -159,6 +162,9 @@ def last_usage(data):
|
||||
# --- session token totals (transcript, incremental) ---------------------------
|
||||
TOKENS_STATE_DIR = os.path.expanduser("~/.claude/cache")
|
||||
TOKENS_RECENT_IDS = 20 # message ids remembered across runs for de-duplication
|
||||
TPS_SAMPLES = 8 # thinking blocks the t/s readout is averaged over
|
||||
TPS_MIN_MS, TPS_MIN_TOKENS = 1000, 50 # shorter blocks are first-token latency, not decode speed
|
||||
TOKENS_SCAN_BUDGET = 8 << 20 # bytes parsed per refresh (~0.1 s); see session_tokens
|
||||
TOKENS_STATE_MAX_AGE = 7 * 86400 # prune per-session state files older than this
|
||||
|
||||
|
||||
@@ -183,9 +189,10 @@ def _tokens_state_path(data, path):
|
||||
|
||||
|
||||
def _prune_tokens_state(keep):
|
||||
"""Drop stale per-session state files (one scandir, only when the dir has piled up)."""
|
||||
"""Drop stale per-session token and per-branch PR state files (one scandir, only when
|
||||
the dir has piled up)."""
|
||||
try:
|
||||
entries = [e for e in os.scandir(TOKENS_STATE_DIR) if e.name.startswith("statusline.tokens.")]
|
||||
entries = [e for e in os.scandir(TOKENS_STATE_DIR) if e.name.startswith(("statusline.tokens.", "statusline.pr."))]
|
||||
if len(entries) < 50:
|
||||
return
|
||||
cutoff = time.time() - TOKENS_STATE_MAX_AGE
|
||||
@@ -197,12 +204,21 @@ def _prune_tokens_state(keep):
|
||||
|
||||
|
||||
def session_tokens(data):
|
||||
"""Cumulative (input, output) tokens this session, summed over the transcript's
|
||||
assistant `message.usage` blocks -- or None when there is no transcript.
|
||||
"""Cumulative session token totals plus decode-speed samples, from the transcript --
|
||||
`{"in", "out", "tps"}` or None when there is no transcript.
|
||||
|
||||
Input = input + cache_creation + cache_read (everything the API billed as input), so
|
||||
on a 1M session it dwarfs output: every call re-reads the whole cached prefix.
|
||||
Only the main transcript is counted; subagent usage lives in separate sidechain files.
|
||||
in/out: summed over assistant `message.usage` blocks. Input = input + cache_creation +
|
||||
cache_read (everything the API billed as input), so on a 1M session it dwarfs output:
|
||||
every call re-reads the whole cached prefix. Only the main transcript is counted;
|
||||
subagent usage lives in separate sidechain files.
|
||||
|
||||
tps: the last TPS_SAMPLES `[thinking_tokens, thinkingDurationMs]` pairs -- the only
|
||||
clean per-call timing Claude Code records. The status payload's cost.total_api_duration_ms
|
||||
is useless for a rate: subagent and advisor calls land in the same counter (it grew 28s
|
||||
in a 12s wall interval with no main-conversation completion), and the payload's
|
||||
output_tokens is sometimes a mid-stream partial. thinkingDurationMs is main-conversation
|
||||
only; it includes first-token latency, so short blocks read low and long ones converge
|
||||
on the real decode speed -- hence the duration-weighted mean and the minimum-size filter.
|
||||
|
||||
Incremental: the status line runs on every refresh and transcripts grow to tens of
|
||||
MB, so a per-session state file (~/.claude/cache/statusline.tokens.<session>.json)
|
||||
@@ -210,12 +226,18 @@ def session_tokens(data):
|
||||
de-duplicated by message id: the transcript writes one line per content block of a
|
||||
turn, all carrying the same id and the same usage block. Only complete lines are
|
||||
consumed (the writer may be mid-line); a shrunken file (truncate/rotate) resets.
|
||||
|
||||
A cold scan is spread over refreshes, TOKENS_SCAN_BUDGET bytes each: Claude Code kills
|
||||
a status script when the next refresh fires, and a multi-second scan of a 100 MB
|
||||
transcript that never gets to write its state would start from zero every time.
|
||||
Totals climb for a few refreshes on a huge resumed transcript, then settle.
|
||||
"""
|
||||
path = data.get("transcript_path")
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
state_path = _tokens_state_path(data, path)
|
||||
state = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": []}
|
||||
fresh = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": [], "tps": [], "tps_ids": []}
|
||||
state = dict(fresh)
|
||||
try:
|
||||
with open(state_path) as fh:
|
||||
s = json.load(fh)
|
||||
@@ -225,27 +247,36 @@ def session_tokens(data):
|
||||
and all(isinstance(s.get(k), int) and s[k] >= 0 for k in ("offset", "in", "out"))
|
||||
and isinstance(s.get("ids"), list)
|
||||
):
|
||||
state = s
|
||||
state = s # "tps"/"tps_ids" are optional: older state files predate them
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def result(st):
|
||||
return {"in": st["in"], "out": st["out"], "tps": st.get("tps") or []}
|
||||
|
||||
size = os.path.getsize(path)
|
||||
if size < state["offset"]: # truncated or rotated under us: start over
|
||||
state = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": []}
|
||||
state = dict(fresh)
|
||||
if size == state["offset"]:
|
||||
return state["in"], state["out"]
|
||||
return result(state)
|
||||
|
||||
with open(path, "rb") as fh:
|
||||
fh.seek(state["offset"])
|
||||
chunk = fh.read()
|
||||
end = chunk.rfind(b"\n")
|
||||
chunk = fh.read(TOKENS_SCAN_BUDGET)
|
||||
end = chunk.rfind(b"\n")
|
||||
if end < 0 and len(chunk) == TOKENS_SCAN_BUDGET: # one line bigger than the budget: finish it
|
||||
chunk += fh.readline()
|
||||
end = chunk.rfind(b"\n")
|
||||
if end < 0: # no complete new line yet
|
||||
return state["in"], state["out"]
|
||||
return result(state)
|
||||
text = chunk[: end + 1].decode("utf-8", "ignore")
|
||||
|
||||
tin, tout = state["in"], state["out"]
|
||||
ids = [i for i in state["ids"] if isinstance(i, str)]
|
||||
seen = set(ids)
|
||||
tps = [s for s in (state.get("tps") or []) if isinstance(s, list) and len(s) == 2]
|
||||
tps_ids = [i for i in (state.get("tps_ids") or []) if isinstance(i, str)]
|
||||
tps_seen = set(tps_ids)
|
||||
for line in text.splitlines():
|
||||
if '"usage"' not in line:
|
||||
continue
|
||||
@@ -258,19 +289,38 @@ def session_tokens(data):
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
mid = msg.get("id") or obj.get("requestId") or obj.get("uuid")
|
||||
if not isinstance(mid, str) or mid in seen:
|
||||
if not isinstance(mid, str):
|
||||
continue
|
||||
|
||||
def num(d, k):
|
||||
v = d.get(k, 0) if isinstance(d, dict) else 0
|
||||
return int(v) if isinstance(v, (int, float)) else 0
|
||||
|
||||
# Decode-speed sample: checked on every line (not just the first-wins one below) so
|
||||
# it does not depend on the thinking block being the message's first line.
|
||||
ms = num(obj, "thinkingDurationMs")
|
||||
toks = num(usage.get("output_tokens_details"), "thinking_tokens")
|
||||
if ms >= TPS_MIN_MS and toks >= TPS_MIN_TOKENS and mid not in tps_seen:
|
||||
tps_seen.add(mid)
|
||||
tps_ids.append(mid)
|
||||
tps.append([toks, ms])
|
||||
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
ids.append(mid)
|
||||
tin += num(usage, "input_tokens") + num(usage, "cache_creation_input_tokens") + num(usage, "cache_read_input_tokens")
|
||||
tout += num(usage, "output_tokens")
|
||||
|
||||
def num(k):
|
||||
v = usage.get(k, 0)
|
||||
return int(v) if isinstance(v, (int, float)) else 0
|
||||
|
||||
tin += num("input_tokens") + num("cache_creation_input_tokens") + num("cache_read_input_tokens")
|
||||
tout += num("output_tokens")
|
||||
|
||||
state = {"path": path, "offset": state["offset"] + end + 1, "in": tin, "out": tout, "ids": ids[-TOKENS_RECENT_IDS:]}
|
||||
state = {
|
||||
"path": path,
|
||||
"offset": state["offset"] + end + 1,
|
||||
"in": tin,
|
||||
"out": tout,
|
||||
"ids": ids[-TOKENS_RECENT_IDS:],
|
||||
"tps": tps[-TPS_SAMPLES:],
|
||||
"tps_ids": tps_ids[-TOKENS_RECENT_IDS:],
|
||||
}
|
||||
try:
|
||||
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
|
||||
tmp = f"{state_path}.{os.getpid()}.tmp"
|
||||
@@ -280,7 +330,7 @@ def session_tokens(data):
|
||||
_prune_tokens_state(state_path)
|
||||
except Exception:
|
||||
pass
|
||||
return tin, tout
|
||||
return result(state)
|
||||
|
||||
|
||||
# --- row 1: work ------------------------------------------------------------
|
||||
@@ -395,6 +445,122 @@ def git_segment(cwd, top):
|
||||
return ""
|
||||
|
||||
|
||||
# --- PR review threads (gh, cached, fetched in the background) ------------------
|
||||
PR_TTL = 90 # seconds a fetched count stays fresh
|
||||
PR_ERR_TTL = 600 # back-off after a failed fetch (no GitHub remote, gh not authed, offline)
|
||||
PR_CLAIM = 30 # a refresher spawned this recently is still running: don't start another
|
||||
PR_CODEX = re.compile(r"codex", re.I) # chatgpt-codex-connector; same match as /pr-loop
|
||||
PR_QUERY = """query($owner:String!,$repo:String!,$branch:String!){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequests(headRefName:$branch,states:OPEN,first:1,orderBy:{field:UPDATED_AT,direction:DESC}){
|
||||
nodes{ number reviewThreads(first:100){ pageInfo{hasNextPage}
|
||||
nodes{ isResolved comments(first:1){ nodes{ author{login} } } } } } } } }"""
|
||||
|
||||
|
||||
def _pr_cache_path(top, branch):
|
||||
key = hashlib.sha1(f"{top}\0{branch}".encode()).hexdigest()[:16]
|
||||
return os.path.join(TOKENS_STATE_DIR, f"statusline.pr.{key}.json")
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
d = json.load(fh)
|
||||
return d if isinstance(d, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def pr_refresh(cwd, branch, path):
|
||||
"""Background half of pr_segment (`statusline.py --pr-refresh CWD BRANCH CACHE`): fetch the
|
||||
branch's open PR and write its unresolved review-thread counts to the cache. `{owner}` /
|
||||
`{repo}` are gh placeholders resolved from the repo at `cwd`. A failed fetch keeps the last
|
||||
counts and flags them stale; a branch without an open PR clears them."""
|
||||
state = _read_json(path)
|
||||
state["ts"] = time.time()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["gh", "api", "graphql", "-F", "owner={owner}", "-F", "repo={repo}",
|
||||
"-f", f"branch={branch}", "-f", f"query={PR_QUERY}"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(r.stderr)
|
||||
nodes = json.loads(r.stdout)["data"]["repository"]["pullRequests"]["nodes"]
|
||||
state = {"ts": state["ts"]}
|
||||
if nodes:
|
||||
threads = nodes[0]["reviewThreads"]
|
||||
# a thread belongs to whoever opened it: its first comment's author
|
||||
authors = [((t["comments"]["nodes"] or [{}])[0].get("author") or {}).get("login") or ""
|
||||
for t in threads["nodes"] if not t["isResolved"]]
|
||||
state.update(number=nodes[0]["number"], open=len(authors),
|
||||
codex=sum(1 for a in authors if PR_CODEX.search(a)),
|
||||
more=threads["pageInfo"]["hasNextPage"])
|
||||
except Exception:
|
||||
state["error"] = True
|
||||
try:
|
||||
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
|
||||
tmp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(state, fh)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pr_segment(cwd, top):
|
||||
"""Unresolved review threads (any author, outdated ones included) on the current branch's
|
||||
open PR: `💬 #N ✓` when none are left, else the count with Codex's share broken out.
|
||||
|
||||
A GitHub round trip takes ~0.7 s, too slow for a render, so this only reads a per-branch
|
||||
cache (~/.claude/cache/statusline.pr.<hash>.json) and, once it is older than PR_TTL,
|
||||
spawns a detached `--pr-refresh` child to update it -- the new count shows on the next
|
||||
render. PR-conversation comments are not counted: they have no resolved state, so a
|
||||
summary or an LGTM would keep the count from ever reaching zero.
|
||||
"""
|
||||
try:
|
||||
if not top or not shutil.which("gh"):
|
||||
return ""
|
||||
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
||||
if not branch or branch == "HEAD":
|
||||
return ""
|
||||
path = _pr_cache_path(top, branch)
|
||||
state = _read_json(path)
|
||||
if time.time() - (state.get("ts") or 0) > (PR_ERR_TTL if state.get("error") else PR_TTL):
|
||||
claim = path + ".lock"
|
||||
try:
|
||||
claimed = time.time() - os.path.getmtime(claim) < PR_CLAIM
|
||||
except OSError:
|
||||
claimed = False
|
||||
if not claimed:
|
||||
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
|
||||
with open(claim, "w"):
|
||||
pass
|
||||
# own session + no inherited stdout: Claude Code reads ours to EOF, and
|
||||
# cancels an in-flight render when the next one triggers
|
||||
subprocess.Popen(
|
||||
[sys.executable, os.path.abspath(__file__), "--pr-refresh", cwd, branch, path],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
num = state.get("number")
|
||||
if not num:
|
||||
return ""
|
||||
n, codex = state.get("open") or 0, state.get("codex") or 0
|
||||
out = "💬 " + color(f"#{num} ", "gray")
|
||||
if not n:
|
||||
out += color("✓", "green")
|
||||
else:
|
||||
out += color(f"{n}{'+' if state.get('more') else ''}", "yellow", bold=True)
|
||||
if codex:
|
||||
out += color(" · ", "gray") + color(f"codex {codex}", "red")
|
||||
if state.get("error"):
|
||||
out += color(" ?", "gray") # last fetch failed: these counts are stale
|
||||
return out
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def model_segment(data):
|
||||
name = (data.get("model") or {}).get("display_name")
|
||||
if not name:
|
||||
@@ -503,13 +669,12 @@ def velocity_segment(data):
|
||||
return ""
|
||||
|
||||
|
||||
def tokens_segment(data):
|
||||
def tokens_segment(totals):
|
||||
"""Cumulative session tokens: ↑ input (incl. cache reads/writes) and ↓ output."""
|
||||
try:
|
||||
totals = session_tokens(data)
|
||||
if not totals:
|
||||
return ""
|
||||
tin, tout = totals
|
||||
tin, tout = totals["in"], totals["out"]
|
||||
if not (tin or tout):
|
||||
return ""
|
||||
return "🔢 " + color(f"↑{fmt_tokens(tin)}", "cyan") + " " + color(f"↓{fmt_tokens(tout)}", "green")
|
||||
@@ -517,6 +682,22 @@ def tokens_segment(data):
|
||||
return ""
|
||||
|
||||
|
||||
def tps_segment(totals):
|
||||
"""Decode speed in tokens/s: duration-weighted mean over the last few thinking blocks
|
||||
(see session_tokens). Omitted until a thinking block ≥1s has completed -- so never
|
||||
shown with thinking disabled."""
|
||||
try:
|
||||
samples = (totals or {}).get("tps") or []
|
||||
toks = sum(s[0] for s in samples)
|
||||
ms = sum(s[1] for s in samples)
|
||||
if toks <= 0 or ms <= 0:
|
||||
return ""
|
||||
rate = toks / ms * 1000
|
||||
return "⚡ " + color(f"{rate:.0f} t/s", bucket(rate, 20, 40, invert=True))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def cache_segment(usage, data=None):
|
||||
"""Prompt-cache hit % (higher is better) + the cache TTL, `·cold` when the cache has
|
||||
expired. Hit % from the 2.1.260+ payload `prompt_cache.hit_ratio` (a 0..1 fraction, null
|
||||
@@ -697,6 +878,9 @@ def style_segment(data):
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:2] == ["--pr-refresh"] and len(sys.argv) == 5:
|
||||
pr_refresh(*sys.argv[2:])
|
||||
return
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
@@ -708,6 +892,7 @@ def main():
|
||||
or os.getcwd()
|
||||
)
|
||||
usage = payload_usage(data) or last_usage(data)
|
||||
totals = session_tokens(data)
|
||||
top = git_top(cwd)
|
||||
|
||||
# One row: work (left) then system (right), joined. If it would overflow the
|
||||
@@ -720,11 +905,13 @@ def main():
|
||||
vim_segment(data),
|
||||
dir_segment(cwd, top),
|
||||
git_segment(cwd, top),
|
||||
pr_segment(cwd, top),
|
||||
model_segment(data),
|
||||
context_segment(data, usage),
|
||||
usage_segment(data),
|
||||
cost_segment(data),
|
||||
tokens_segment(data),
|
||||
tokens_segment(totals),
|
||||
tps_segment(totals),
|
||||
cache_segment(usage, data),
|
||||
) if s]
|
||||
system = [s for s in (
|
||||
|
||||
Reference in new issue
Block a user