From 43a8ae821b6ef775c6ad22a69f03531426dde46d Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Thu, 26 Mar 2026 10:38:32 +0100 Subject: [PATCH 1/3] Add token burn analyzer script and interactive dashboard Script scans ~/.claude session data, aggregates token usage by date/model/machine. Dashboard is a self-contained HTML playground with log scale, model filtering, and editable pricing. --- scripts/token-burn.py | 306 +++++++++++++++ token-burn-dashboard.html | 798 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1104 insertions(+) create mode 100755 scripts/token-burn.py create mode 100644 token-burn-dashboard.html diff --git a/scripts/token-burn.py b/scripts/token-burn.py new file mode 100755 index 0000000..9f77eaa --- /dev/null +++ b/scripts/token-burn.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Claude Code token burn rate analyzer. + +Scans ~/.claude/projects/*/*.jsonl session files and aggregates token usage +by day, session, model, and project. Outputs CSV or JSON for dashboarding. + +Usage: + python3 token-burn.py # last 7 days, table + python3 token-burn.py --days 30 # last 30 days + python3 token-burn.py --csv # CSV to stdout + python3 token-burn.py --json # JSON to stdout + python3 token-burn.py --by session # per-session breakdown + python3 token-burn.py --by project # per-project breakdown + python3 token-burn.py --by model # per-model breakdown + python3 token-burn.py --by hour # hourly heatmap +""" + +import argparse +import csv +import glob +import json +import os +import sys +from collections import defaultdict +from datetime import datetime, timedelta, timezone + +CLAUDE_DIR = os.path.expanduser("~/.claude/projects") +SESSIONS_DIR = os.path.expanduser("~/.claude/sessions") + +# Pricing per 1M tokens (as of 2026-03, Opus 4.6 / Sonnet 4.6) +PRICING = { + "claude-opus-4-6": {"input": 5.0, "output": 25.0, "cache_write": 10.0, "cache_read": 0.50}, + "claude-sonnet-4-6": {"input": 3.0, "output": 15.0, "cache_write": 6.0, "cache_read": 0.30}, + "claude-sonnet-4-5-20250929": {"input": 3.0, "output": 15.0, "cache_write": 6.0, "cache_read": 0.30}, + "claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0, "cache_write": 2.0, "cache_read": 0.10}, + "MiniMax-M2.7": {"input": 0.30, "output": 1.20, "cache_write": 0.375, "cache_read": 0.06}, +} +DEFAULT_PRICING = {"input": 5.0, "output": 25.0, "cache_write": 10.0, "cache_read": 0.50} + + +def estimate_cost(model, usage): + """Estimate USD cost from usage dict.""" + p = PRICING.get(model, DEFAULT_PRICING) + input_tok = usage.get("input_tokens", 0) + output_tok = usage.get("output_tokens", 0) + cache_write = usage.get("cache_creation_input_tokens", 0) + cache_read = usage.get("cache_read_input_tokens", 0) + cost = ( + input_tok * p["input"] / 1_000_000 + + output_tok * p["output"] / 1_000_000 + + cache_write * p["cache_write"] / 1_000_000 + + cache_read * p["cache_read"] / 1_000_000 + ) + return cost + + +def load_session_meta(): + """Load session metadata (name, cwd) from sessions dir.""" + meta = {} + for f in glob.glob(f"{SESSIONS_DIR}/*.json"): + try: + with open(f) as fh: + d = json.load(fh) + meta[d.get("sessionId", "")] = { + "name": d.get("name", ""), + "cwd": d.get("cwd", ""), + "started": d.get("startedAt", 0), + } + except (json.JSONDecodeError, KeyError): + pass + return meta + + +def scan_sessions(since_date): + """Scan all session JSONL files, yield per-message token records.""" + jsonl_files = glob.glob(f"{CLAUDE_DIR}/**/*.jsonl", recursive=True) + + # Filter by modification time for speed + cutoff_ts = since_date.timestamp() + jsonl_files = [f for f in jsonl_files if os.path.getmtime(f) >= cutoff_ts] + + for filepath in jsonl_files: + project = os.path.basename(os.path.dirname(filepath)) + session_id = os.path.splitext(os.path.basename(filepath))[0] + + try: + with open(filepath) as fh: + for line in fh: + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + + if obj.get("type") != "assistant": + continue + + msg = obj.get("message", {}) + usage = msg.get("usage") + if not usage: + continue + + ts_str = obj.get("timestamp", "") + if not ts_str: + continue + + try: + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + continue + + if ts.date() < since_date.date(): + continue + + model = msg.get("model", "unknown") + input_tok = usage.get("input_tokens", 0) + output_tok = usage.get("output_tokens", 0) + cache_write = usage.get("cache_creation_input_tokens", 0) + cache_read = usage.get("cache_read_input_tokens", 0) + total = input_tok + output_tok + cache_write + cache_read + cost = estimate_cost(model, usage) + + yield { + "timestamp": ts, + "date": ts.strftime("%Y-%m-%d"), + "hour": ts.hour, + "session_id": session_id, + "project": project, + "model": model, + "input_tokens": input_tok, + "output_tokens": output_tok, + "cache_write_tokens": cache_write, + "cache_read_tokens": cache_read, + "total_tokens": total, + "cost_usd": cost, + } + except (IOError, OSError): + continue + + +def aggregate(records, group_by="date"): + """Aggregate records by the given key.""" + buckets = defaultdict(lambda: { + "input_tokens": 0, + "output_tokens": 0, + "cache_write_tokens": 0, + "cache_read_tokens": 0, + "total_tokens": 0, + "cost_usd": 0.0, + "messages": 0, + "sessions": set(), + }) + + for r in records: + if group_by == "hour": + key = f"{r['hour']:02d}:00" + else: + key = r[group_by] + + b = buckets[key] + b["input_tokens"] += r["input_tokens"] + b["output_tokens"] += r["output_tokens"] + b["cache_write_tokens"] += r["cache_write_tokens"] + b["cache_read_tokens"] += r["cache_read_tokens"] + b["total_tokens"] += r["total_tokens"] + b["cost_usd"] += r["cost_usd"] + b["messages"] += 1 + b["sessions"].add(r["session_id"]) + + # Convert sets to counts + result = {} + for key, b in sorted(buckets.items()): + b["session_count"] = len(b.pop("sessions")) + result[key] = b + + return result + + +def format_tokens(n): + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 1_000: + return f"{n / 1_000:.1f}K" + return str(n) + + +def print_table(agg, group_label="Date"): + """Print a readable ASCII table.""" + print(f"\n{'─' * 90}") + print(f" {group_label:<20} {'Input':>8} {'Output':>8} {'CacheW':>8} {'CacheR':>8} {'Total':>9} {'Cost':>8} {'Msgs':>5}") + print(f"{'─' * 90}") + + grand = defaultdict(float) + grand["messages"] = 0 + + for key, b in agg.items(): + label = key[:20] if len(str(key)) > 20 else key + print( + f" {label:<20} " + f"{format_tokens(b['input_tokens']):>8} " + f"{format_tokens(b['output_tokens']):>8} " + f"{format_tokens(b['cache_write_tokens']):>8} " + f"{format_tokens(b['cache_read_tokens']):>8} " + f"{format_tokens(b['total_tokens']):>9} " + f"${b['cost_usd']:>6.2f} " + f"{b['messages']:>5}" + ) + for k in ["input_tokens", "output_tokens", "cache_write_tokens", "cache_read_tokens", "total_tokens", "cost_usd"]: + grand[k] += b[k] + grand["messages"] += b["messages"] + + print(f"{'─' * 90}") + print( + f" {'TOTAL':<20} " + f"{format_tokens(int(grand['input_tokens'])):>8} " + f"{format_tokens(int(grand['output_tokens'])):>8} " + f"{format_tokens(int(grand['cache_write_tokens'])):>8} " + f"{format_tokens(int(grand['cache_read_tokens'])):>8} " + f"{format_tokens(int(grand['total_tokens'])):>9} " + f"${grand['cost_usd']:>6.2f} " + f"{int(grand['messages']):>5}" + ) + print(f"{'─' * 90}\n") + + # Burn rate summary + days = len(agg) + if days > 0: + avg_day = grand["cost_usd"] / days + avg_tok = int(grand["total_tokens"]) / days + print(f" Avg/day: {format_tokens(int(avg_tok))} tokens | ${avg_day:.2f}") + print(f" Projected/month: {format_tokens(int(avg_tok * 30))} tokens | ${avg_day * 30:.2f}") + print() + + +def print_csv(agg, group_label="date"): + """Print CSV to stdout.""" + writer = csv.writer(sys.stdout) + writer.writerow([ + group_label, "input_tokens", "output_tokens", "cache_write_tokens", + "cache_read_tokens", "total_tokens", "cost_usd", "messages", "session_count" + ]) + for key, b in agg.items(): + writer.writerow([ + key, b["input_tokens"], b["output_tokens"], b["cache_write_tokens"], + b["cache_read_tokens"], b["total_tokens"], f"{b['cost_usd']:.4f}", + b["messages"], b["session_count"] + ]) + + +def print_json(agg, group_label="date"): + """Print JSON to stdout.""" + out = [] + for key, b in agg.items(): + row = {group_label: key} + row.update(b) + row["cost_usd"] = round(row["cost_usd"], 4) + out.append(row) + json.dump(out, sys.stdout, indent=2, default=str) + print() + + +def main(): + parser = argparse.ArgumentParser(description="Claude Code token burn rate analyzer") + parser.add_argument("--days", type=int, default=7, help="Look back N days (default: 7)") + parser.add_argument("--since", type=str, help="Start date YYYY-MM-DD (overrides --days)") + parser.add_argument("--by", choices=["date", "session", "project", "model", "hour"], + default="date", help="Group by (default: date)") + parser.add_argument("--csv", action="store_true", help="Output CSV") + parser.add_argument("--json", action="store_true", help="Output JSON") + args = parser.parse_args() + + if args.since: + since = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc) + else: + since = datetime.now(timezone.utc) - timedelta(days=args.days) + + group_map = { + "date": "date", + "session": "session_id", + "project": "project", + "model": "model", + "hour": "hour", + } + + sys.stderr.write(f"Scanning sessions since {since.strftime('%Y-%m-%d')}...\n") + records = list(scan_sessions(since)) + sys.stderr.write(f"Found {len(records)} assistant messages with token data\n") + + if not records: + sys.stderr.write("No data found.\n") + return + + agg = aggregate(records, group_by=group_map[args.by]) + + label_map = {"date": "Date", "session": "Session", "project": "Project", "model": "Model", "hour": "Hour"} + + if args.csv: + print_csv(agg, args.by) + elif args.json: + print_json(agg, args.by) + else: + print_table(agg, label_map[args.by]) + + +if __name__ == "__main__": + main() diff --git a/token-burn-dashboard.html b/token-burn-dashboard.html new file mode 100644 index 0000000..f328d59 --- /dev/null +++ b/token-burn-dashboard.html @@ -0,0 +1,798 @@ + + + + + +Token Burn Dashboard + + + +
+ + +
+
+ +
+ + + + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+

Cost Breakdown by Model + Token Type

+
+
+
+ +
+
+

Token Type Distribution

+ +
+
+

Cache Efficiency (Read / Total Cache)

+ +
+
+
+
+ + + + From 60d948083978beaa02fa60d9958dcc25dc28c2dd Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Sat, 28 Mar 2026 00:27:11 +0100 Subject: [PATCH 2/3] Simplify TUI: remove dead code, deduplicate, -153 lines - Remove unused glow animation (glowPos/glowDir/150ms tick that never rendered) - Remove dead approveAllSafe(), hand-rolled itoa(), formatDuration() - Use prKey() helper everywhere instead of 5 inline fmt.Sprintf calls - Extract handleMergePick() from 4 copy-pasted merge picker cases - Compute pillName() once in disambiguateNames instead of twice per session - Compute checks passing/total once in renderPRZoom instead of twice - Derive failingPRs inside renderStatusBar, removing a parameter - Fix redundant bounds checks in trackSelection and scroll logic - Use truncateMiddle instead of hand-rolled truncation - Replace section banner comments with single-line comments --- tui/internal/tui/app.go | 183 ++++++++------------------------- tui/internal/tui/app_test.go | 19 ---- tui/internal/tui/pill.go | 6 +- tui/internal/tui/pr_zoom.go | 52 +++------- tui/internal/tui/strip.go | 21 ++-- tui/internal/tui/strip_test.go | 31 +++--- tui/internal/tui/zoom.go | 32 ++---- tui/internal/tui/zoom_test.go | 7 +- 8 files changed, 99 insertions(+), 252 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 0a0e42a..3639f42 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -4,6 +4,7 @@ package tui import ( "fmt" "os/exec" + "strconv" "strings" "sync" "time" @@ -25,9 +26,6 @@ type disconnectedMsg struct{} // reconnectTickMsg triggers a reconnection attempt. type reconnectTickMsg struct{} -// glowTickMsg advances the glow sweep animation. -type glowTickMsg struct{} - // actionResultMsg carries the result of an approve/reject/autopilot action. type actionResultMsg struct { action string // "approve", "reject", "autopilot" @@ -61,8 +59,6 @@ type Model struct { height int flash string // temporary status message flashStyle lipgloss.Style - glowPos int - glowDir int // 1 or -1 for ping-pong inputMode bool // text input active (for + add PR) inputBuffer string // text being typed mergePickerVisible bool // merge method picker showing @@ -81,16 +77,9 @@ func NewModel(c *client.Client) Model { } } -// glowTick returns a command that sends a glowTickMsg every 150ms. -func glowTick() tea.Cmd { - return tea.Tick(150*time.Millisecond, func(_ time.Time) tea.Msg { - return glowTickMsg{} - }) -} - -// Init starts the subscription and the glow animation. +// Init starts the subscription. func (m Model) Init() tea.Cmd { - return tea.Batch(m.subscribeCmd(), glowTick()) + return m.subscribeCmd() } // subscribeCmd attempts to connect and subscribe to the daemon. @@ -166,23 +155,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case reconnectTickMsg: return m, m.subscribeCmd() - case glowTickMsg: - // Advance glow position with ping-pong across max label length. - maxLen := 20 - if m.glowDir == 0 { - m.glowDir = 1 - } - m.glowPos += m.glowDir - if m.glowPos >= maxLen { - m.glowPos = maxLen - 1 - m.glowDir = -1 - } - if m.glowPos <= 0 { - m.glowPos = 0 - m.glowDir = 1 - } - return m, glowTick() - case stateMsg: m.sessions = msg.Sessions m.prs = msg.PRs @@ -193,8 +165,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.selectedPRKey != "" { // Selected item was a PR — find it by key. for i, p := range m.prs { - key := fmt.Sprintf("%s/%s#%d", p.Owner, p.Repo, p.Number) - if key == m.selectedPRKey { + if prKey(p) == m.selectedPRKey { m.selectedIdx = len(m.sessions) + i found = true break @@ -236,7 +207,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Try to find the PR in the current list; it may already be polled. var found *client.TrackedPR for i := range m.prs { - if prKey(&m.prs[i]) == msg.prKey { + if prKey(m.prs[i]) == msg.prKey { found = &m.prs[i] break } @@ -421,9 +392,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } else if pr := m.selectedPR(); pr != nil { // PR autopilot: off → auto → yolo → off. - key := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + k := prKey(*pr) return m, func() tea.Msg { - err := m.client.CyclePRAutopilot(key) + err := m.client.CyclePRAutopilot(k) return actionResultMsg{action: "PR autopilot", err: err} } } @@ -488,9 +459,9 @@ end tell`, tabIdx, tabIdx) case "-": // Remove selected PR from tracking. if pr := m.selectedPR(); pr != nil { - key := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + k := prKey(*pr) return m, func() tea.Msg { - err := m.client.RemovePR(key) + err := m.client.RemovePR(k) return actionResultMsg{action: "removed PR", err: err} } } @@ -508,13 +479,13 @@ end tell`, tabIdx, tabIdx) case "r": // Toggle code review on/off for selected PR. if pr := m.selectedPR(); pr != nil { - key := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + k := prKey(*pr) label := "review enabled" if pr.ReviewEnabled { label = "review disabled" } return m, func() tea.Msg { - err := m.client.TogglePRReview(key) + err := m.client.TogglePRReview(k) return actionResultMsg{action: label, err: err} } } @@ -523,61 +494,26 @@ end tell`, tabIdx, tabIdx) // Set merge method for selected PR — daemon handles the actual merge. if pr := m.selectedPR(); pr != nil { m.mergePickerPR = pr - m.mergePickerPRKey = prKey(pr) + m.mergePickerPRKey = prKey(*pr) m.mergePickerVisible = true return m, nil } case "1": - // Merge picker: squash. if m.mergePickerVisible { - key := m.mergePickerPRKey - m.mergePickerVisible = false - m.mergePickerPR = nil - m.mergePickerPRKey = "" - return m, func() tea.Msg { - _ = m.client.SetMergeMethod(key, "squash") - return actionResultMsg{action: "merge method: squash"} - } + return m.handleMergePick("squash", "squash") } - case "2": - // Merge picker: rebase. if m.mergePickerVisible { - key := m.mergePickerPRKey - m.mergePickerVisible = false - m.mergePickerPR = nil - m.mergePickerPRKey = "" - return m, func() tea.Msg { - _ = m.client.SetMergeMethod(key, "rebase") - return actionResultMsg{action: "merge method: rebase"} - } + return m.handleMergePick("rebase", "rebase") } - case "3": - // Merge picker: Aviator. if m.mergePickerVisible { - key := m.mergePickerPRKey - m.mergePickerVisible = false - m.mergePickerPR = nil - m.mergePickerPRKey = "" - return m, func() tea.Msg { - _ = m.client.SetMergeMethod(key, "aviator") - return actionResultMsg{action: "merge method: aviator"} - } + return m.handleMergePick("aviator", "aviator") } - case "4": - // Merge picker: merge commit. if m.mergePickerVisible { - key := m.mergePickerPRKey - m.mergePickerVisible = false - m.mergePickerPR = nil - m.mergePickerPRKey = "" - return m, func() tea.Msg { - _ = m.client.SetMergeMethod(key, "merge") - return actionResultMsg{action: "merge method: merge commit"} - } + return m.handleMergePick("merge", "merge commit") } case "d": @@ -621,24 +557,6 @@ end tell`, tabIdx, tabIdx) return m, nil } -// approveAllSafe sends approve for every session that has only safe pending tools. -func (m Model) approveAllSafe() tea.Cmd { - var cmds []tea.Cmd - for _, s := range m.sessions { - for _, pt := range s.PendingTools { - if pt.Safety != "destructive" { - sid := s.SessionID - cmds = append(cmds, func() tea.Msg { - err := m.client.Approve(sid) - return actionResultMsg{action: "approve", err: err} - }) - break - } - } - } - return tea.Batch(cmds...) -} - // selected returns the currently selected session, or nil. func (m Model) selected() *client.Session { if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { @@ -659,7 +577,7 @@ func (m Model) selectedPR() *client.TrackedPR { } // prKey returns the canonical "owner/repo#N" key for a PR. -func prKey(pr *client.TrackedPR) string { +func prKey(pr client.TrackedPR) string { return fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) } @@ -686,21 +604,30 @@ func (m Model) totalItems() int { // trackSelection updates selectedSID/selectedPRKey based on current selectedIdx. func (m *Model) trackSelection() { - if m.selectedIdx < len(m.sessions) { - if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { - m.selectedSID = m.sessions[m.selectedIdx].SessionID - m.selectedPRKey = "" - } + if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { + m.selectedSID = m.sessions[m.selectedIdx].SessionID + m.selectedPRKey = "" } else { prIdx := m.selectedIdx - len(m.sessions) if prIdx >= 0 && prIdx < len(m.prs) { - p := m.prs[prIdx] - m.selectedPRKey = fmt.Sprintf("%s/%s#%d", p.Owner, p.Repo, p.Number) + m.selectedPRKey = prKey(m.prs[prIdx]) m.selectedSID = "" } } } +// handleMergePick handles a merge method picker selection. +func (m Model) handleMergePick(method, label string) (tea.Model, tea.Cmd) { + key := m.mergePickerPRKey + m.mergePickerVisible = false + m.mergePickerPR = nil + m.mergePickerPRKey = "" + return m, func() tea.Msg { + _ = m.client.SetMergeMethod(key, method) + return actionResultMsg{action: "merge method: " + label} + } +} + // View renders the entire TUI. func (m Model) View() string { if m.width == 0 || m.height == 0 { @@ -716,7 +643,7 @@ func (m Model) View() string { } // Render bottom sections first to calculate remaining height. - strip := renderUnifiedStrip(m.sessions, m.prs, m.selectedIdx, w, m.glowPos) + strip := renderUnifiedStrip(m.sessions, m.prs, m.selectedIdx, w) stripHeight := lipgloss.Height(strip) isSession := m.isSessionSelected() @@ -759,13 +686,7 @@ func (m Model) View() string { lipgloss.NewStyle().Foreground(colorDimFg).Render(" (Enter add, Esc cancel)") + "\n" + lipgloss.NewStyle().Foreground(colorBorder).Render(strings.Repeat("─", w)) } else { - failingPRs := 0 - for _, p := range m.prs { - if p.State == "checks_failing" { - failingPRs++ - } - } - statusLine = renderStatusBar(m.connected, m.sessions, m.prs, failingPRs, m.flash, m.flashStyle, m.defaultAutopilot, w) + statusLine = renderStatusBar(m.connected, m.sessions, m.prs, m.flash, m.flashStyle, m.defaultAutopilot, w) } statusHeight := lipgloss.Height(statusLine) @@ -802,7 +723,7 @@ func (m Model) View() string { } // renderStatusBar renders the top status bar with branding, connection info, and flash. -func renderStatusBar(connected bool, sessions []client.Session, prs []client.TrackedPR, failingPRs int, flash string, flashStyle lipgloss.Style, defaultAutopilot string, width int) string { +func renderStatusBar(connected bool, sessions []client.Session, prs []client.TrackedPR, flash string, flashStyle lipgloss.Style, defaultAutopilot string, width int) string { logo := lipgloss.NewStyle(). Bold(true). Foreground(colorAccent). @@ -825,19 +746,20 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra prCount := "" prBreakdownStr := "" + failingPRs := 0 if len(prs) > 0 { prCount = " " + lipgloss.NewStyle(). Foreground(colorDimFg). Render(pluralize(len(prs), "PR", "PRs")) // PR state breakdown: passing / failing / running counts. - passing, failing, running, merged := 0, 0, 0, 0 + passing, running, merged := 0, 0, 0 for _, p := range prs { switch p.State { case "checks_passing", "approved": passing++ case "checks_failing": - failing++ + failingPRs++ case "checks_running": running++ case "merged": @@ -849,9 +771,9 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra prParts = append(prParts, lipgloss.NewStyle().Foreground(colorRunning). Render(fmt.Sprintf("%d\u2713", passing))) } - if failing > 0 { + if failingPRs > 0 { prParts = append(prParts, lipgloss.NewStyle().Foreground(colorDestructive). - Render(fmt.Sprintf("%d\u2717", failing))) + Render(fmt.Sprintf("%d\u2717", failingPRs))) } if running > 0 { prParts = append(prParts, lipgloss.NewStyle().Foreground(colorWaiting). @@ -898,7 +820,6 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra failingStr := "" if failingPRs > 0 { - // Badge-style: dark text on red background, consistent with pending badge. failingBadge := lipgloss.NewStyle(). Foreground(lipgloss.ANSIColor(0)). Background(colorDestructive). @@ -1026,27 +947,7 @@ func pluralize(n int, singular, plural string) string { if n == 1 { return "1 " + singular } - return itoa(n) + " " + plural -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - s := "" - neg := false - if n < 0 { - neg = true - n = -n - } - for n > 0 { - s = string(rune('0'+n%10)) + s - n /= 10 - } - if neg { - s = "-" + s - } - return s + return strconv.Itoa(n) + " " + plural } var ( diff --git a/tui/internal/tui/app_test.go b/tui/internal/tui/app_test.go index f6bf5de..558953d 100644 --- a/tui/internal/tui/app_test.go +++ b/tui/internal/tui/app_test.go @@ -393,25 +393,6 @@ func TestHandleKey_CtrlCAlwaysQuits(t *testing.T) { // === Iteration 20h: itoa edge cases === -func TestItoa(t *testing.T) { - tests := []struct { - n int - want string - }{ - {0, "0"}, - {1, "1"}, - {-1, "-1"}, - {42, "42"}, - {-999, "-999"}, - {100, "100"}, - } - for _, tt := range tests { - if got := itoa(tt.n); got != tt.want { - t.Errorf("itoa(%d) = %q, want %q", tt.n, got, tt.want) - } - } -} - // === Iteration 20i: View with PRs selected === func TestView_PRSelected(t *testing.T) { diff --git a/tui/internal/tui/pill.go b/tui/internal/tui/pill.go index 359c2cb..a28d0a2 100644 --- a/tui/internal/tui/pill.go +++ b/tui/internal/tui/pill.go @@ -81,8 +81,8 @@ func pillName(s client.Session) string { // renderPill renders a single session pill with state-colored background, // icon, name, and optional pending-tool count badge. -func renderPill(s client.Session, selected bool, glowPos int) string { - return renderPillWithName(s, pillName(s), selected, glowPos) +func renderPill(s client.Session, selected bool, _ int) string { + return renderPillWithName(s, pillName(s), selected) } // isPassiveState returns true if the session state is idle or dead (not actively working). @@ -107,7 +107,7 @@ func pillNameMaxLen(state string, selected bool) int { // renderPillWithName renders a pill using a pre-computed display name // (which may include a disambiguator). // Name length is tiered by state and selection for visual hierarchy. -func renderPillWithName(s client.Session, displayName string, selected bool, glowPos int) string { +func renderPillWithName(s client.Session, displayName string, selected bool) string { sc := stateColor(s.State) dimBg := stateColorDim(s.State) icon := stateIcon(s.State) diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index 4748145..b6f7018 100644 --- a/tui/internal/tui/pr_zoom.go +++ b/tui/internal/tui/pr_zoom.go @@ -9,13 +9,6 @@ import ( "github.com/pchaganti/claude-session-manager/tui/internal/client" ) -func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) -} - // renderPRZoom renders the PR detail panel. func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) string { if width < 10 || height < 4 { @@ -99,7 +92,15 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri bodyLines = append(bodyLines, sep) } - // ── Merge readiness summary line (skip for done PRs) ── + // Pre-compute checks passing/total (used in both summary and details). + checksPassing, checksTotal := 0, len(pr.Checks) + for _, c := range pr.Checks { + if c.Conclusion == "SUCCESS" || c.Conclusion == "NEUTRAL" { + checksPassing++ + } + } + + // Merge readiness summary line (skip for done PRs). if !isDone { var summaryParts []string @@ -127,28 +128,20 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri lipgloss.NewStyle().Foreground(colorDimFg).Render("○ no review")) } - // Checks summary. - if len(pr.Checks) > 0 { - passing, total := 0, len(pr.Checks) - for _, c := range pr.Checks { - if c.Conclusion == "SUCCESS" || c.Conclusion == "NEUTRAL" { - passing++ - } - } - if passing == total { + if checksTotal > 0 { + if checksPassing == checksTotal { summaryParts = append(summaryParts, styleSafe.Render("✓")+" "+ lipgloss.NewStyle().Foreground(colorDimFg). - Render(fmt.Sprintf("checks (%d/%d)", passing, total))) + Render(fmt.Sprintf("checks (%d/%d)", checksPassing, checksTotal))) } else { summaryParts = append(summaryParts, styleDestructive.Render("✗")+" "+ lipgloss.NewStyle().Foreground(colorDestructive). - Render(fmt.Sprintf("checks (%d/%d)", passing, total))) + Render(fmt.Sprintf("checks (%d/%d)", checksPassing, checksTotal))) } } - // Mergeable. switch pr.Mergeable { case "MERGEABLE": summaryParts = append(summaryParts, @@ -160,7 +153,6 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri lipgloss.NewStyle().Foreground(colorDestructive).Render("conflicts")) } - // Merge method. if pr.MergeMethod != "" { summaryParts = append(summaryParts, lipgloss.NewStyle().Foreground(colorAccent).Render("⎇ "+pr.MergeMethod)) @@ -177,15 +169,9 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri } // Checks section. - if len(pr.Checks) > 0 { - passing, total := 0, len(pr.Checks) - for _, c := range pr.Checks { - if c.Conclusion == "SUCCESS" || c.Conclusion == "NEUTRAL" { - passing++ - } - } + if checksTotal > 0 { bodyLines = append(bodyLines, styleSectionLabel.Render( - fmt.Sprintf("── Checks (%d/%d passing)", passing, total))) + fmt.Sprintf("── Checks (%d/%d passing)", checksPassing, checksTotal))) for _, c := range pr.Checks { icon := checkIcon(c) @@ -213,7 +199,7 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri bodyLines = append(bodyLines, fmt.Sprintf(" %s %s running (%s)", lipgloss.NewStyle().Foreground(colorWaiting).Render("🤖"), lipgloss.NewStyle().Foreground(colorFg).Render(agentLabel), - lipgloss.NewStyle().Foreground(colorDimFg).Render(formatDuration(elapsed)), + lipgloss.NewStyle().Foreground(colorDimFg).Render(formatAge(elapsed)), )) } @@ -265,12 +251,8 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri state := lipgloss.NewStyle().Foreground(colorDimFg).Render(strings.ToLower(r.State)) body := "" if r.Body != "" { - bodyTrunc := r.Body - if len(bodyTrunc) > 50 { - bodyTrunc = bodyTrunc[:50] + "…" - } body = " " + lipgloss.NewStyle().Foreground(colorDimFg).Italic(true). - Render("\""+bodyTrunc+"\"") + Render("\""+truncateMiddle(r.Body, 50)+"\"") } at := "" if r.At != "" { diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index c39a0b2..95374d2 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -11,8 +11,8 @@ import ( ) // renderStrip renders the horizontal session pill strip at the bottom (sessions only). -func renderStrip(sessions []client.Session, selectedIdx int, width int, glowPos int) string { - return renderUnifiedStrip(sessions, nil, selectedIdx, width, glowPos) +func renderStrip(sessions []client.Session, selectedIdx int, width int, _ int) string { + return renderUnifiedStrip(sessions, nil, selectedIdx, width) } // disambiguateNames detects duplicate pill names across sessions and appends @@ -20,17 +20,18 @@ func renderStrip(sessions []client.Session, selectedIdx int, width int, glowPos func disambiguateNames(sessions []client.Session) map[string]string { result := make(map[string]string, len(sessions)) - // Count how many sessions share each name. + // Compute pill names once. + names := make([]string, len(sessions)) nameCounts := make(map[string]int) - for _, s := range sessions { - name := pillName(s) - nameCounts[name]++ + for i, s := range sessions { + names[i] = pillName(s) + nameCounts[names[i]]++ } // For duplicates, append disambiguator. nameSeq := make(map[string]int) - for _, s := range sessions { - name := pillName(s) + for i, s := range sessions { + name := names[i] if nameCounts[name] > 1 { nameSeq[name]++ if s.PID > 0 { @@ -70,7 +71,7 @@ func statePriority(s client.Session) int { // renderUnifiedStrip renders sessions + PRs in one strip with a separator. // It caps visible pills to fit within the given width, showing a "+N" // overflow indicator when pills are hidden. -func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selectedIdx int, width int, glowPos int) string { +func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selectedIdx int, width int) string { if len(sessions) == 0 && len(prs) == 0 { emptyStyle := lipgloss.NewStyle().Foreground(colorDimFg).Italic(true) return styleStripBar.Width(width).Render( @@ -151,7 +152,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec // Build all pill entries. var allPills []pillEntry for i, s := range sessions { - p := renderPillWithName(s, nameMap[s.SessionID], i == selectedIdx, glowPos) + p := renderPillWithName(s, nameMap[s.SessionID], i == selectedIdx) allPills = append(allPills, pillEntry{ rendered: p, width: lipgloss.Width(p), diff --git a/tui/internal/tui/strip_test.go b/tui/internal/tui/strip_test.go index f63811d..29c2d2a 100644 --- a/tui/internal/tui/strip_test.go +++ b/tui/internal/tui/strip_test.go @@ -1,6 +1,7 @@ package tui import ( + "strconv" "strings" "testing" @@ -69,9 +70,9 @@ func TestRenderStrip_ManySessions(t *testing.T) { var sessions []client.Session for i := 0; i < 12; i++ { sessions = append(sessions, client.Session{ - SessionID: "s" + itoa(i), + SessionID: "s" + strconv.Itoa(i), State: "running", - ProjectName: "proj-" + itoa(i), + ProjectName: "proj-" + strconv.Itoa(i), }) } @@ -210,7 +211,7 @@ func TestRenderUnifiedStrip_SessionsAndPRs(t *testing.T) { {Owner: "owner", Repo: "project", Number: 7, Title: "Add feature", State: "checks_failing"}, } - out := renderUnifiedStrip(sessions, prs, 0, 120, 0) + out := renderUnifiedStrip(sessions, prs, 0, 120) if out == "" { t.Error("unified strip should produce output") } @@ -225,7 +226,7 @@ func TestRenderUnifiedStrip_SessionsOnly(t *testing.T) { {SessionID: "s1", State: "running", ProjectName: "alpha"}, } - out := renderUnifiedStrip(sessions, nil, 0, 100, 0) + out := renderUnifiedStrip(sessions, nil, 0, 100) if out == "" { t.Error("sessions-only strip should produce output") } @@ -241,14 +242,14 @@ func TestRenderUnifiedStrip_PRsOnly(t *testing.T) { } // Use selectedIdx=-1 so no PR is selected (avoids RoundedBorder which contains │). - out := renderUnifiedStrip(nil, prs, -1, 100, 0) + out := renderUnifiedStrip(nil, prs, -1, 100) if out == "" { t.Error("PRs-only strip should produce output") } } func TestRenderUnifiedStrip_Empty(t *testing.T) { - out := renderUnifiedStrip(nil, nil, 0, 100, 0) + out := renderUnifiedStrip(nil, nil, 0, 100) if out == "" { t.Error("empty strip should produce output (empty state message)") } @@ -266,7 +267,7 @@ func TestRenderUnifiedStrip_PRSelected(t *testing.T) { } // Selected index = 1 means PR is selected (sessions count = 1). - out := renderUnifiedStrip(sessions, prs, 1, 120, 0) + out := renderUnifiedStrip(sessions, prs, 1, 120) if out == "" { t.Error("strip with PR selected should produce output") } @@ -280,8 +281,8 @@ func TestRenderUnifiedStrip_HeightConsistentWithPRs(t *testing.T) { {Owner: "o", Repo: "r", Number: 1, Title: "PR", State: "checks_passing"}, } - h0 := lipgloss.Height(renderUnifiedStrip(sessions, prs, 0, 120, 0)) - h1 := lipgloss.Height(renderUnifiedStrip(sessions, prs, 1, 120, 0)) + h0 := lipgloss.Height(renderUnifiedStrip(sessions, prs, 0, 120)) + h1 := lipgloss.Height(renderUnifiedStrip(sessions, prs, 1, 120)) // Height might differ slightly due to PR selected border, but should be close. // What matters: both produce valid output. @@ -387,14 +388,14 @@ func TestRenderUnifiedStrip_OverflowIndicator(t *testing.T) { var sessions []client.Session for i := 0; i < 10; i++ { sessions = append(sessions, client.Session{ - SessionID: "s" + itoa(i), + SessionID: "s" + strconv.Itoa(i), State: "running", - ProjectName: "project-number-" + itoa(i), + ProjectName: "project-number-" + strconv.Itoa(i), PID: 1000 + i, }) } - out := renderUnifiedStrip(sessions, nil, 0, 60, 0) + out := renderUnifiedStrip(sessions, nil, 0, 60) h := lipgloss.Height(out) // Strip must remain a single content line (plus border). if h > 2 { @@ -407,15 +408,15 @@ func TestRenderUnifiedStrip_SelectedAlwaysVisible(t *testing.T) { var sessions []client.Session for i := 0; i < 10; i++ { sessions = append(sessions, client.Session{ - SessionID: "s" + itoa(i), + SessionID: "s" + strconv.Itoa(i), State: "running", - ProjectName: "proj-" + itoa(i), + ProjectName: "proj-" + strconv.Itoa(i), PID: 1000 + i, }) } // Select last session. - out := renderUnifiedStrip(sessions, nil, 9, 60, 0) + out := renderUnifiedStrip(sessions, nil, 9, 60) // Should contain the selected session name. if !strings.Contains(out, "proj-9") { t.Error("selected pill should be visible even with overflow") diff --git a/tui/internal/tui/zoom.go b/tui/internal/tui/zoom.go index 08f3dc9..05e79d2 100644 --- a/tui/internal/tui/zoom.go +++ b/tui/internal/tui/zoom.go @@ -18,12 +18,8 @@ func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAu innerWidth := width - 4 - // ═══════════════════════════════════════════════════════════ - // FIXED HEADER — 2 lines, always visible - // ═══════════════════════════════════════════════════════════ + // Header var headerLines []string - - // Line 1: name STATE [AUTOPILOT|YOLO] ▸ branch stateStyle := lipgloss.NewStyle(). Foreground(lipgloss.ANSIColor(0)). Background(stateColor(s.State)). @@ -63,7 +59,6 @@ func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAu } headerLines = append(headerLines, line1) - // Line 2: PID cwd ⏱ ago var infoParts []string infoParts = append(infoParts, fmt.Sprintf("PID %d", s.PID)) infoParts = append(infoParts, truncateMiddle(s.CWD, innerWidth-35)) @@ -77,9 +72,7 @@ func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAu headerHeight := len(headerLines) bodyHeight := height - headerHeight - // ═══════════════════════════════════════════════════════════ - // SCROLLABLE BODY — activities, pending, last output - // ═══════════════════════════════════════════════════════════ + // Body var bodyLines []string sep := lipgloss.NewStyle().Foreground(colorBorder). @@ -172,11 +165,7 @@ func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAu } } - // ═══════════════════════════════════════════════════════════ - // SCROLL + CLIP - // ═══════════════════════════════════════════════════════════ - - // Clamp scroll offset + // Scroll + clip maxScroll := len(bodyLines) - bodyHeight if maxScroll < 0 { maxScroll = 0 @@ -198,10 +187,7 @@ func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAu // Scroll indicator scrollInfo := "" if maxScroll > 0 { - pct := 0 - if maxScroll > 0 { - pct = scrollOffset * 100 / maxScroll - } + pct := scrollOffset * 100 / maxScroll if scrollOffset > 0 { scrollInfo = lipgloss.NewStyle().Foreground(colorDimFg). Render(fmt.Sprintf(" \u2191\u2193 %d%%", pct)) @@ -213,9 +199,7 @@ func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAu headerLines[len(headerLines)-1] += scrollInfo } - // ═══════════════════════════════════════════════════════════ - // ASSEMBLE - // ═══════════════════════════════════════════════════════════ + // Assemble all := append(headerLines, visibleBody...) // Hard clip to exact height (header + body, no overflow). @@ -282,11 +266,7 @@ func toolDetail(pt client.PendingTool, maxLen int) string { } for _, key := range []string{"command", "file_path", "pattern", "query", "description", "prompt"} { if v, ok := pt.ToolInput[key]; ok { - s := fmt.Sprintf("%v", v) - if len(s) > maxLen && maxLen > 5 { - s = s[:maxLen-3] + "..." - } - return s + return truncateMiddle(fmt.Sprintf("%v", v), maxLen) } } return "" diff --git a/tui/internal/tui/zoom_test.go b/tui/internal/tui/zoom_test.go index d51002e..3ad3ac8 100644 --- a/tui/internal/tui/zoom_test.go +++ b/tui/internal/tui/zoom_test.go @@ -1,6 +1,7 @@ package tui import ( + "strconv" "strings" "testing" "time" @@ -122,7 +123,7 @@ func TestRenderZoom_ManyActivities_ShowsOverflow(t *testing.T) { s.Activities = append(s.Activities, client.Activity{ Timestamp: now.Add(-time.Duration(15-i) * time.Minute), ActivityType: "tool_use", - Summary: "Action " + itoa(i), + Summary: "Action " + strconv.Itoa(i), }) } @@ -205,8 +206,8 @@ func TestToolDetail_Truncation(t *testing.T) { if len(got) > 20 { t.Errorf("toolDetail length = %d, want <= 20", len(got)) } - if !strings.HasSuffix(got, "...") { - t.Error("truncated toolDetail should end with '...'") + if !strings.Contains(got, "\u2026") { + t.Error("truncated toolDetail should contain ellipsis") } } From 63df61a8d9cd9e34bdf045e1baa77a3c749057d9 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Sat, 28 Mar 2026 00:38:38 +0100 Subject: [PATCH 3/3] Fix default autopilot not applied to new sessions ShouldAutoApprove was called before RegisterSession (hook race), so the session had no mode set and the config default was never consulted. Now falls back to config.DefaultAutopilot when no per-session mode exists. --- daemon/internal/state/state.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/daemon/internal/state/state.go b/daemon/internal/state/state.go index 63d79f1..83c95af 100644 --- a/daemon/internal/state/state.go +++ b/daemon/internal/state/state.go @@ -307,6 +307,10 @@ func (m *Manager) ShouldAutoApprove(sid string, safety model.ToolSafety) (bool, // state was persisted from a previous run. mode = persisted } + // Fall back to default autopilot for sessions not yet registered. + if mode == "" { + mode = m.config.DefaultAutopilot + } switch mode { case model.AutopilotOn: // ON: approve safe+unknown, block destructive.