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. 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)

+ +
+
+
+
+ + + + 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") } }