[object Object]

← back to Cc Command Center

Claude Code command-center web viewer (stats dashboard + floating session windows)

40f91afb66bfcc92746b961e13019789afb2b7aa · 2026-06-27 12:59:14 -0700 · Steve

Files touched

Diff

commit 40f91afb66bfcc92746b961e13019789afb2b7aa
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 27 12:59:14 2026 -0700

    Claude Code command-center web viewer (stats dashboard + floating session windows)
---
 .gitignore        |   8 +++
 README.md         |  23 ++++++
 build_stats.py    | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.py         |  30 ++++++++
 5 files changed, 458 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..455df06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+public/data/stats.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3c706fb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# Claude Code — Command Center (web viewer)
+
+A web recreation of the Claude Code desktop "command center" shown in
+@LLMJunky's tweet (x.com/LLMJunky/status/2070733200846909717): the
+"What's up next?" usage dashboard + draggable/tileable floating session windows.
+
+Unlike the demo, this runs on **your real data** — it scans
+`~/.claude/projects/**/*.jsonl` and aggregates sessions, messages, tokens,
+active days, streaks, peak hour, favorite model, a 53-week activity heatmap,
+and your recent sessions.
+
+## Run
+```
+python3 build_stats.py     # regenerate public/data/stats.json from session logs
+PORT=9774 python3 server.py  # serve public/ (Basic Auth: admin / DW2024!)
+```
+Then open http://127.0.0.1:9774 (or omit PORT for a random free port).
+
+- Click a session in **Recents** to pop it out as a floating window.
+- Drag the title bar to move, drag the corner to resize.
+- **Tile / Cascade / Close all** arrange the open windows.
+
+stats.json is gitignored (contains session snippets); rebuild it locally.
diff --git a/build_stats.py b/build_stats.py
new file mode 100644
index 0000000..fe30b25
--- /dev/null
+++ b/build_stats.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Aggregate real Claude Code usage from ~/.claude/projects/**/*.jsonl into
+public/data/stats.json — powers the command-center web viewer.
+
+Recreates the "What's up next?" dashboard from the LLMJunky tweet using Steve's
+own session data: stat cards, a 53-week activity heatmap, and a recents list
+rendered as draggable floating session windows.
+"""
+import os, json, glob, datetime as dt
+from collections import defaultdict, Counter
+
+HOME = os.path.expanduser("~")
+ROOT = os.path.join(HOME, ".claude", "projects")
+OUT  = os.path.join(os.path.dirname(__file__), "public", "data", "stats.json")
+USER = "Steve"
+
+def parse_ts(s):
+    if not s:
+        return None
+    try:
+        return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
+    except Exception:
+        return None
+
+sessions = []                 # one summary dict per session file
+day_counts = Counter()        # date -> assistant-message count (activity)
+hour_counts = Counter()       # hour -> count
+model_counts = Counter()      # model -> assistant messages
+total_tokens = 0
+total_user_msgs = 0
+active_dates = set()
+
+files = glob.glob(os.path.join(ROOT, "**", "*.jsonl"), recursive=True)
+
+for fp in files:
+    title = None
+    first_user = None
+    last_snippet = None
+    first_ts = None
+    last_ts = None
+    user_msgs = 0
+    sess_tokens = 0
+    sess_id = os.path.splitext(os.path.basename(fp))[0]
+    project = os.path.basename(os.path.dirname(fp)).lstrip("-").replace("-", "/")
+    try:
+        with open(fp, "r", errors="ignore") as fh:
+            for line in fh:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    d = json.loads(line)
+                except Exception:
+                    continue
+                t = d.get("type")
+                if t == "ai-title":
+                    title = (d.get("title") or d.get("text") or title)
+                elif t == "last-prompt":
+                    if not title:
+                        title = (d.get("prompt") or d.get("text") or title)
+                ts = parse_ts(d.get("timestamp"))
+                if ts:
+                    first_ts = first_ts or ts
+                    last_ts = ts
+                msg = d.get("message") if isinstance(d.get("message"), dict) else {}
+                if t == "user" and msg.get("role") == "user":
+                    user_msgs += 1
+                    content = msg.get("content")
+                    if isinstance(content, str) and content.strip() and not first_user:
+                        first_user = content.strip()[:240]
+                    if isinstance(content, str) and content.strip():
+                        last_snippet = content.strip()[:240]
+                elif t == "assistant" and msg.get("role") == "assistant":
+                    u = msg.get("usage") or {}
+                    tok = (u.get("input_tokens", 0) + u.get("output_tokens", 0)
+                           + u.get("cache_creation_input_tokens", 0)
+                           + u.get("cache_read_input_tokens", 0))
+                    sess_tokens += tok
+                    m = msg.get("model")
+                    if m:
+                        model_counts[m] += 1
+                    if ts:
+                        day_counts[ts.date().isoformat()] += 1
+                        hour_counts[ts.hour] += 1
+                        active_dates.add(ts.date())
+    except Exception:
+        continue
+
+    if user_msgs == 0 and sess_tokens == 0:
+        continue  # skip empty/metadata-only sessions
+
+    total_tokens += sess_tokens
+    total_user_msgs += user_msgs
+    title = (title or first_user or "Untitled session").strip().replace("\n", " ")[:90]
+    sessions.append({
+        "id": sess_id,
+        "project": project,
+        "title": title,
+        "snippet": (first_user or "")[:240],
+        "last": (last_snippet or first_user or "")[:400],
+        "user_msgs": user_msgs,
+        "tokens": sess_tokens,
+        "ts": last_ts.isoformat() if last_ts else None,
+        "mtime": os.path.getmtime(fp),
+    })
+
+# ---- streaks ----
+def streaks(dates):
+    if not dates:
+        return 0, 0
+    ds = sorted(dates)
+    longest = cur = 1
+    for i in range(1, len(ds)):
+        if (ds[i] - ds[i-1]).days == 1:
+            cur += 1
+            longest = max(longest, cur)
+        elif (ds[i] - ds[i-1]).days == 0:
+            continue
+        else:
+            cur = 1
+    # current streak counts back from the most recent active day
+    today = max(ds)
+    current = 1
+    i = len(ds) - 1
+    while i > 0 and (ds[i] - ds[i-1]).days in (0, 1):
+        if (ds[i] - ds[i-1]).days == 1:
+            current += 1
+        i -= 1
+    return current, longest
+
+current_streak, longest_streak = streaks(active_dates)
+peak_hour = hour_counts.most_common(1)[0][0] if hour_counts else 0
+fav_model = model_counts.most_common(1)[0][0] if model_counts else "—"
+
+MODEL_PRETTY = {
+    "claude-opus-4-8": "Opus 4.8", "claude-opus-4-8[1m]": "Opus 4.8",
+    "claude-sonnet-4-6": "Sonnet 4.6", "claude-haiku-4-5-20251001": "Haiku 4.5",
+    "claude-fable-5": "Fable 5",
+}
+def pretty_model(m):
+    return MODEL_PRETTY.get(m, m.replace("claude-", "").replace("-", " ").title())
+
+def fmt_hour(h):
+    ap = "AM" if h < 12 else "PM"
+    hh = h % 12 or 12
+    return f"{hh} {ap}"
+
+# ---- heatmap: last 53 weeks, GitHub-style ----
+today = dt.date.today()
+start = today - dt.timedelta(days=today.weekday() + 7*52)  # align to Monday a year back
+heat = []
+d = start
+while d <= today:
+    heat.append({"date": d.isoformat(), "count": day_counts.get(d.isoformat(), 0)})
+    d += dt.timedelta(days=1)
+
+sessions.sort(key=lambda s: s["mtime"], reverse=True)
+recents = sessions[:24]
+
+# token comparison flavor line (The Little Prince ~ 22k tokens)
+LITTLE_PRINCE = 22000
+mult = round(total_tokens / LITTLE_PRINCE) if total_tokens else 0
+
+stats = {
+    "user": USER,
+    "generated_at": dt.datetime.now().isoformat(timespec="seconds"),
+    "cards": {
+        "sessions": len(sessions),
+        "messages": total_user_msgs,
+        "total_tokens": total_tokens,
+        "active_days": len(active_dates),
+        "current_streak": current_streak,
+        "longest_streak": longest_streak,
+        "peak_hour": fmt_hour(peak_hour),
+        "favorite_model": pretty_model(fav_model),
+    },
+    "flavor": f"You've used ~{mult:,}× more tokens than The Little Prince.",
+    "models": [{"model": pretty_model(m), "count": c} for m, c in model_counts.most_common(8)],
+    "heatmap": heat,
+    "recents": recents,
+}
+
+os.makedirs(os.path.dirname(OUT), exist_ok=True)
+with open(OUT, "w") as fh:
+    json.dump(stats, fh)
+
+c = stats["cards"]
+print(f"Wrote {OUT}")
+print(f"  sessions={c['sessions']}  messages={c['messages']:,}  tokens={c['total_tokens']:,}")
+print(f"  active_days={c['active_days']}  current_streak={c['current_streak']}  longest={c['longest_streak']}")
+print(f"  peak_hour={c['peak_hour']}  fav_model={c['favorite_model']}")
+print(f"  {stats['flavor']}")
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..82b336e
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,205 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Claude Code — Command Center</title>
+<style>
+  :root{
+    --bg:#1c1c1f; --panel:#212125; --panel2:#26262b; --line:#33333a;
+    --ink:#e9e9ec; --mut:#9a9aa3; --mut2:#6f6f78; --accent:#d8674a;
+    --blue:#5b8def; --cell:#2a2a30; --c1:#1d3a63; --c2:#2f5ea8; --c3:#4f86e0; --c4:#7fb0ff;
+    --shadow:0 18px 50px rgba(0,0,0,.55);
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%}
+  body{background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;overflow:hidden}
+  .app{display:grid;grid-template-columns:236px 1fr;height:100vh}
+
+  /* sidebar */
+  .side{background:#19191c;border-right:1px solid var(--line);padding:10px;display:flex;flex-direction:column;gap:4px;overflow:auto}
+  .winbtns{display:flex;gap:7px;padding:6px 6px 12px}
+  .winbtns i{width:12px;height:12px;border-radius:50%;display:inline-block}
+  .winbtns i:nth-child(1){background:#ff5f57}.winbtns i:nth-child(2){background:#febc2e}.winbtns i:nth-child(3){background:#28c840}
+  .tabs{display:flex;gap:4px;background:#212125;border:1px solid var(--line);border-radius:9px;padding:3px;margin-bottom:8px}
+  .tabs button{flex:1;background:none;border:0;color:var(--mut);padding:5px;border-radius:6px;font-size:12.5px;cursor:pointer}
+  .tabs button.on{background:#34343b;color:var(--ink)}
+  .navi{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:8px;color:var(--ink);cursor:pointer;font-size:13.5px}
+  .navi:hover{background:#26262b}
+  .navi .ic{width:15px;text-align:center;color:var(--mut)}
+  .seclabel{color:var(--mut2);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:14px 9px 4px;font-weight:600}
+  .rec{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:7px;color:var(--mut);cursor:pointer;font-size:12.7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .rec:hover{background:#26262b;color:var(--ink)}
+  .rec .dot{width:6px;height:6px;border-radius:50%;background:#4a4a52;flex:none}
+  .who{margin-top:auto;display:flex;align-items:center;gap:9px;padding:9px;border-top:1px solid var(--line);color:var(--mut);font-size:12.5px}
+  .who .av{width:20px;height:20px;border-radius:6px;background:linear-gradient(135deg,#d8674a,#b23e6e);color:#fff;display:grid;place-items:center;font-size:11px;font-weight:700}
+
+  /* main */
+  .main{position:relative;overflow:hidden}
+  .canvas{position:absolute;inset:0;overflow:auto;padding:34px 40px}
+  h1{font-size:21px;font-weight:600;margin:0 0 22px;display:flex;align-items:center;gap:10px}
+  .star{color:var(--accent)}
+  .dash{max-width:760px}
+  .dashhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
+  .pillrow{display:flex;gap:6px}
+  .pill{background:#2a2a30;border:1px solid var(--line);color:var(--mut);font-size:12px;padding:3px 10px;border-radius:7px;cursor:pointer}
+  .pill.on{color:var(--ink);background:#34343b}
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
+  .grid{display:grid;grid-template-columns:repeat(4,1fr)}
+  .cell{padding:13px 15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
+  .cell:nth-child(4n){border-right:0}
+  .cell .k{color:var(--mut);font-size:12px;margin-bottom:5px}
+  .cell .v{font-size:19px;font-weight:600;letter-spacing:-.01em}
+  .heatwrap{padding:15px}
+  .heat{display:grid;grid-auto-flow:column;grid-template-rows:repeat(7,11px);gap:3px;overflow-x:auto}
+  .h{width:11px;height:11px;border-radius:2.5px;background:var(--cell)}
+  .h.l1{background:var(--c1)}.h.l2{background:var(--c2)}.h.l3{background:var(--c3)}.h.l4{background:var(--c4)}
+  .flavor{color:var(--mut);font-size:12.5px;padding:0 15px 15px}
+  .models{display:flex;flex-wrap:wrap;gap:7px;margin-top:16px}
+  .mchip{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 11px;font-size:12.5px;color:var(--mut)}
+  .mchip b{color:var(--ink);font-weight:600}
+  .hint{margin-top:26px;color:var(--mut2);font-size:12.5px}
+  .toolbar{margin-top:14px;display:flex;gap:8px;flex-wrap:wrap}
+  .tbtn{background:#2a2a30;border:1px solid var(--line);color:var(--ink);padding:7px 13px;border-radius:8px;cursor:pointer;font-size:12.5px}
+  .tbtn:hover{background:#34343b}
+
+  /* floating session windows */
+  .swin{position:absolute;width:430px;height:300px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden}
+  .swin .bar{display:flex;align-items:center;gap:8px;padding:9px 11px;border-bottom:1px solid var(--line);cursor:move;background:var(--panel2)}
+  .swin .bar .ttl{font-size:12.7px;font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .swin .bar .proj{font-size:10.5px;color:var(--mut2);background:#2a2a30;padding:2px 7px;border-radius:5px;border:1px solid var(--line);max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .swin .bar .x{cursor:pointer;color:var(--mut);width:18px;text-align:center}
+  .swin .bar .x:hover{color:var(--ink)}
+  .swin .body{flex:1;overflow:auto;padding:12px 13px;font-size:12.7px;color:var(--mut)}
+  .msg{margin-bottom:10px}
+  .msg .role{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut2);margin-bottom:3px}
+  .msg.u .bub{background:#2f5ea8;color:#eaf1ff;display:inline-block;padding:7px 11px;border-radius:10px}
+  .msg.a .bub{color:var(--ink)}
+  .swin .foot{border-top:1px solid var(--line);padding:8px 11px;display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--mut2)}
+  .swin .resz{position:absolute;right:2px;bottom:2px;width:15px;height:15px;cursor:nwse-resize;
+    background:linear-gradient(135deg,transparent 50%,var(--mut2) 50%,var(--mut2) 60%,transparent 60%,transparent 72%,var(--mut2) 72%,var(--mut2) 82%,transparent 82%)}
+  .gen{position:absolute;right:16px;bottom:12px;color:var(--mut2);font-size:11px}
+</style>
+</head>
+<body>
+<div class="app">
+  <aside class="side">
+    <div class="winbtns"><i></i><i></i><i></i></div>
+    <div class="tabs"><button>Chat</button><button>Cowork</button><button class="on">Code</button></div>
+    <div class="navi"><span class="ic">+</span> New session</div>
+    <div class="navi"><span class="ic">⟳</span> Routines</div>
+    <div class="navi"><span class="ic">▦</span> Artifacts</div>
+    <div class="navi"><span class="ic">⚙</span> Customize</div>
+    <div class="seclabel">Recents</div>
+    <div id="recents"></div>
+    <div class="who"><span class="av" id="avatar">S</span><span id="whoname">Steve · Designer Wallcoverings</span></div>
+  </aside>
+
+  <main class="main">
+    <div class="canvas" id="canvas">
+      <h1><span class="star">✳</span> What's up next, <span id="hname">Steve</span>?</h1>
+      <div class="dash">
+        <div class="card">
+          <div class="dashhead" style="padding:11px 15px 0">
+            <div class="pillrow"><span class="pill on">Overview</span><span class="pill">Models</span></div>
+            <div class="pillrow"><span class="pill on">All</span><span class="pill">30d</span><span class="pill">7d</span></div>
+          </div>
+          <div class="grid" id="cards" style="margin-top:11px"></div>
+          <div class="heatwrap"><div class="heat" id="heat"></div></div>
+          <div class="flavor" id="flavor"></div>
+        </div>
+        <div class="models" id="models"></div>
+        <div class="hint">Open a session from <b>Recents</b> to pop it out as a floating window — then drag the title bar, resize from the corner, or tile them all.</div>
+        <div class="toolbar">
+          <button class="tbtn" onclick="openTop(3)">Open recent 3</button>
+          <button class="tbtn" onclick="tileWins()">Tile</button>
+          <button class="tbtn" onclick="cascadeWins()">Cascade</button>
+          <button class="tbtn" onclick="closeAll()">Close all</button>
+        </div>
+      </div>
+    </div>
+    <div class="gen" id="gen"></div>
+  </main>
+</div>
+
+<script>
+let DATA=null, wins=[], z=10, cascade=0;
+const $=s=>document.querySelector(s);
+const esc=s=>(s||"").replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
+const nf=n=>n.toLocaleString();
+
+function heatClass(c){ if(!c)return''; if(c<5)return'l1'; if(c<15)return'l2'; if(c<40)return'l3'; return'l4'; }
+
+fetch('./data/stats.json').then(r=>r.json()).then(d=>{ DATA=d; render();
+  if(location.hash==='#demo'){ openTop(4); setTimeout(tileWins,50); } });
+
+function render(){
+  const c=DATA.cards;
+  $('#hname').textContent=DATA.user;
+  $('#whoname').firstChild ? $('#whoname').textContent=DATA.user+' · Designer Wallcoverings':0;
+  $('#avatar').textContent=DATA.user[0];
+  const cards=[
+    ['Sessions', nf(c.sessions)],['Messages', nf(c.messages)],
+    ['Total tokens', tok(c.total_tokens)],['Active days', nf(c.active_days)],
+    ['Current streak', c.current_streak+'d'],['Longest streak', c.longest_streak+'d'],
+    ['Peak hour', c.peak_hour],['Favorite model', c.favorite_model],
+  ];
+  $('#cards').innerHTML=cards.map(([k,v])=>`<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
+  $('#heat').innerHTML=DATA.heatmap.map(h=>`<div class="h ${heatClass(h.count)}" title="${h.date}: ${h.count}"></div>`).join('');
+  $('#flavor').textContent=DATA.flavor;
+  $('#models').innerHTML=DATA.models.map(m=>`<span class="mchip"><b>${esc(m.model)}</b> · ${nf(m.count)} msgs</span>`).join('');
+  $('#recents').innerHTML=DATA.recents.map((s,i)=>
+    `<div class="rec" title="${esc(s.title)}" onclick="openWin(${i})"><span class="dot"></span>${esc(s.title)}</div>`).join('');
+  $('#gen').textContent='generated '+DATA.generated_at;
+}
+function tok(n){ if(n>=1e9)return (n/1e9).toFixed(1)+'B'; if(n>=1e6)return (n/1e6).toFixed(1)+'M'; if(n>=1e3)return (n/1e3).toFixed(1)+'K'; return n; }
+
+function openWin(i){
+  const s=DATA.recents[i]; if(!s) return;
+  if(wins.find(w=>w.dataset.sid===s.id)) return; // no dupes
+  const el=document.createElement('div'); el.className='swin'; el.dataset.sid=s.id;
+  const x=70+(cascade*30)%360, y=60+(cascade*28)%240; cascade++;
+  el.style.left=x+'px'; el.style.top=y+'px'; el.style.zIndex=++z;
+  el.innerHTML=`
+    <div class="bar"><span class="proj">${esc(s.project)}</span>
+      <span class="ttl">${esc(s.title)}</span><span class="x">✕</span></div>
+    <div class="body">
+      ${s.snippet?`<div class="msg u"><div class="role">You</div><div class="bub">${esc(s.snippet)}</div></div>`:''}
+      <div class="msg a"><div class="role"><span style="color:var(--accent)">✳</span> Claude</div>
+        <div class="bub">${esc(s.last)||'(session resumed)'}</div></div>
+    </div>
+    <div class="foot"><span>${nf(s.user_msgs)} prompts · ${tok(s.tokens)} tokens</span>
+      <span>${s.ts?new Date(s.ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):''}</span></div>
+    <div class="resz"></div>`;
+  $('.main').appendChild(el); wins.push(el);
+  el.addEventListener('mousedown',()=>el.style.zIndex=++z);
+  el.querySelector('.x').onclick=()=>{ el.remove(); wins=wins.filter(w=>w!==el); };
+  drag(el, el.querySelector('.bar'));
+  resize(el, el.querySelector('.resz'));
+}
+function openTop(n){ for(let i=0;i<n;i++) openWin(i); }
+function closeAll(){ wins.forEach(w=>w.remove()); wins=[]; cascade=0; }
+function cascadeWins(){ cascade=0; wins.forEach(w=>{ const x=70+(cascade*30)%360,y=60+(cascade*28)%240; cascade++; w.style.left=x+'px';w.style.top=y+'px';w.style.zIndex=++z;}); }
+function tileWins(){
+  const n=wins.length; if(!n)return; const cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols);
+  const m=$('.main').getBoundingClientRect(), pad=12;
+  const w=(m.width-pad*(cols+1))/cols, h=(m.height-pad*(rows+1))/rows;
+  wins.forEach((el,i)=>{ const r=Math.floor(i/cols),col=i%cols;
+    el.style.left=(pad+col*(w+pad))+'px'; el.style.top=(pad+r*(h+pad))+'px';
+    el.style.width=w+'px'; el.style.height=h+'px'; });
+}
+function drag(el,handle){
+  let sx,sy,ox,oy,on=false;
+  handle.addEventListener('mousedown',e=>{ if(e.target.classList.contains('x'))return; on=true; sx=e.clientX;sy=e.clientY;ox=el.offsetLeft;oy=el.offsetTop; e.preventDefault();});
+  window.addEventListener('mousemove',e=>{ if(!on)return; el.style.left=(ox+e.clientX-sx)+'px'; el.style.top=(oy+e.clientY-sy)+'px';});
+  window.addEventListener('mouseup',()=>on=false);
+}
+function resize(el,h){
+  let sx,sy,ow,oh,on=false;
+  h.addEventListener('mousedown',e=>{ on=true; sx=e.clientX;sy=e.clientY;ow=el.offsetWidth;oh=el.offsetHeight; e.preventDefault();e.stopPropagation();});
+  window.addEventListener('mousemove',e=>{ if(!on)return; el.style.width=Math.max(280,ow+e.clientX-sx)+'px'; el.style.height=Math.max(160,oh+e.clientY-sy)+'px';});
+  window.addEventListener('mouseup',()=>on=false);
+}
+</script>
+</body>
+</html>
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..d35f8da
--- /dev/null
+++ b/server.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+"""Zero-dependency static server for the Claude Code Command Center viewer.
+Basic-auth gated (admin / DW2024!), serves ./public on a free OS-assigned port
+(or PORT env). Prints the URL on startup."""
+import base64, functools, http.server, os, socketserver
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public")
+USER, PW = os.environ.get("CC_USER", "admin"), os.environ.get("CC_PASS", "DW2024!")
+TOKEN = base64.b64encode(f"{USER}:{PW}".encode()).decode()
+PORT = int(os.environ.get("PORT", "0"))
+
+class Handler(http.server.SimpleHTTPRequestHandler):
+    def do_GET(self):
+        if self.headers.get("Authorization") != f"Basic {TOKEN}":
+            self.send_response(401)
+            self.send_header("WWW-Authenticate", 'Basic realm="Command Center"')
+            self.end_headers(); self.wfile.write(b"auth required"); return
+        return super().do_GET()
+    def log_message(self, *a): pass
+
+class TCP(socketserver.ThreadingTCPServer):
+    allow_reuse_address = True
+
+if __name__ == "__main__":
+    Handler.directory = ROOT
+    h = functools.partial(Handler, directory=ROOT)
+    with TCP(("127.0.0.1", PORT), h) as httpd:
+        port = httpd.server_address[1]
+        print(f"Command Center → http://127.0.0.1:{port}  (auth: {USER} / {PW})")
+        httpd.serve_forever()

(oldest)  ·  back to Cc Command Center  ·  (newest)