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