chatdeck 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chatdeck/__init__.py +2 -0
- chatdeck/__main__.py +3 -0
- chatdeck/autostart.py +91 -0
- chatdeck/cli.py +152 -0
- chatdeck/config.py +34 -0
- chatdeck/demo.py +141 -0
- chatdeck/desktop.py +93 -0
- chatdeck/hooks.py +131 -0
- chatdeck/server.py +345 -0
- chatdeck/static/index.html +342 -0
- chatdeck/static/logo-192.png +0 -0
- chatdeck/static/logo-512.png +0 -0
- chatdeck/static/logo.ico +0 -0
- chatdeck/static/logo.svg +13 -0
- chatdeck/terminals.py +218 -0
- chatdeck/transcripts.py +216 -0
- chatdeck/usage.py +96 -0
- chatdeck/windows/bubble.ps1 +303 -0
- chatdeck/windows/focus_wt.ps1 +68 -0
- chatdeck/windows/start.ps1 +35 -0
- chatdeck-0.1.0.dist-info/METADATA +164 -0
- chatdeck-0.1.0.dist-info/RECORD +26 -0
- chatdeck-0.1.0.dist-info/WHEEL +5 -0
- chatdeck-0.1.0.dist-info/entry_points.txt +2 -0
- chatdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatdeck-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2
|
+
<title>chatdeck</title>
|
|
3
|
+
<link rel="icon" href="/logo.svg" type="image/svg+xml"><link rel="icon" href="/logo-192.png" sizes="192x192"><link rel="manifest" href="/manifest.webmanifest">
|
|
4
|
+
<style>
|
|
5
|
+
:root{--bg:#0f1115;--panel:#171a21;--panel2:#1a1e26;--line:#262a33;--line2:#3a4150;--text:#e6e8ee;--dim:#8b92a1;--green:#3ecf8e;--amber:#f5b942;--red:#f06a6a;
|
|
6
|
+
--btn:#222733;--input:#14171d;--bar:#232733;--chart:#4a7fd4;--chart2:#7fb0ff;--toast:#24406b;--toasttext:#e6e8ee;--offbg:#3a1d1d;--offtext:#ffb4b4;
|
|
7
|
+
--you:#1d2a40;--mark:#6b5310;--code:#11141a;--amberbg:rgba(245,185,66,.08)}
|
|
8
|
+
:root[data-theme=light]{--bg:#f6f7f9;--panel:#fff;--panel2:#f0f2f5;--line:#e1e4ea;--line2:#c5cad4;--text:#1b1f27;--dim:#646c7a;--green:#13945e;--amber:#b7790a;--red:#c93c3c;
|
|
9
|
+
--btn:#fff;--input:#fff;--bar:#e6e9ef;--chart:#4a7fd4;--chart2:#2f62b8;--toast:#1b1f27;--toasttext:#fff;--offbg:#fde8e8;--offtext:#8a1f1f;
|
|
10
|
+
--you:#e7effc;--mark:#ffe08a;--code:#f0f2f5;--amberbg:rgba(183,121,10,.08)}
|
|
11
|
+
@media (prefers-color-scheme:light){:root:not([data-theme=dark]){--bg:#f6f7f9;--panel:#fff;--panel2:#f0f2f5;--line:#e1e4ea;--line2:#c5cad4;--text:#1b1f27;--dim:#646c7a;--green:#13945e;--amber:#b7790a;--red:#c93c3c;
|
|
12
|
+
--btn:#fff;--input:#fff;--bar:#e6e9ef;--chart:#4a7fd4;--chart2:#2f62b8;--toast:#1b1f27;--toasttext:#fff;--offbg:#fde8e8;--offtext:#8a1f1f;
|
|
13
|
+
--you:#e7effc;--mark:#ffe08a;--code:#f0f2f5;--amberbg:rgba(183,121,10,.08)}}
|
|
14
|
+
*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:var(--bg);color:var(--text);font:13px/1.4 "Segoe UI",system-ui,sans-serif;display:flex;flex-direction:column}
|
|
15
|
+
header{padding:12px 14px 10px;border-bottom:1px solid var(--line)}
|
|
16
|
+
.top{display:flex;align-items:center;gap:6px}h1{font-size:15px;margin:0;font-weight:600;flex:1}
|
|
17
|
+
.stats{color:var(--dim);font-size:12px;margin-top:4px}.stats b{color:var(--text)}.stats .w{color:var(--amber);font-weight:600}
|
|
18
|
+
main{flex:1;overflow:auto;padding:12px 14px}
|
|
19
|
+
h2{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:4px 0 8px;font-weight:600;display:flex;align-items:center;gap:6px}
|
|
20
|
+
.btn{background:var(--btn);border:1px solid var(--line);color:var(--text);padding:3px 8px;border-radius:6px;cursor:pointer;font-size:12px;white-space:nowrap;font-family:inherit}
|
|
21
|
+
.btn:hover{border-color:var(--line2)}.btn.busy{opacity:.55;cursor:progress}.btn.danger{color:var(--red)}.btn:disabled{opacity:.4;cursor:default}.btn.icon{padding:3px 6px;min-width:26px;display:inline-flex;align-items:center;justify-content:center;height:24px}.btn.icon svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
22
|
+
select.btn{padding:3px 4px}
|
|
23
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:9px 10px;margin-bottom:7px}
|
|
24
|
+
.card.waiting{border-color:var(--amber);background:linear-gradient(var(--amberbg),var(--amberbg)),var(--panel)}
|
|
25
|
+
.badge{display:inline-block;background:var(--amber);color:#1b1f27;font-size:11px;font-weight:600;border-radius:4px;padding:1px 6px;margin-top:5px}
|
|
26
|
+
.row{display:flex;align-items:center;gap:7px}.name{font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
27
|
+
.dot{width:8px;height:8px;border-radius:50%;background:var(--dim);flex:none}.dot.busy{background:var(--green);animation:p 1.6s infinite}.dot.wait{background:var(--amber);animation:pa 1.2s infinite}
|
|
28
|
+
@keyframes p{0%{box-shadow:0 0 0 0 rgba(62,207,142,.6)}70%{box-shadow:0 0 0 6px rgba(62,207,142,0)}100%{box-shadow:0 0 0 0 rgba(62,207,142,0)}}
|
|
29
|
+
@keyframes pa{0%{box-shadow:0 0 0 0 rgba(245,185,66,.7)}70%{box-shadow:0 0 0 7px rgba(245,185,66,0)}100%{box-shadow:0 0 0 0 rgba(245,185,66,0)}}
|
|
30
|
+
.meta{color:var(--dim);font-size:11.5px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
31
|
+
.bar{height:5px;background:var(--bar);border-radius:4px;overflow:hidden;margin-top:6px}.bar i{display:block;height:100%}
|
|
32
|
+
.ok{color:var(--green)}.warn{color:var(--amber)}.bad{color:var(--red)}
|
|
33
|
+
.tool{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;align-items:center}
|
|
34
|
+
input[type=search],input.ren{flex:1;min-width:120px;background:var(--input);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit}
|
|
35
|
+
label.chk{color:var(--dim);font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer}
|
|
36
|
+
.saved{display:flex;align-items:center;gap:8px;padding:6px 4px;border-bottom:1px solid var(--line);border-left:2px solid transparent}.saved:hover{background:var(--panel2)}
|
|
37
|
+
.saved.cur{background:var(--panel2);border-left-color:var(--chart)}
|
|
38
|
+
.saved .txt{flex:1;min-width:0;cursor:pointer}.saved .name{font-weight:500}
|
|
39
|
+
.snip{color:var(--dim);font-size:11.5px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic}
|
|
40
|
+
mark{background:var(--mark);color:inherit;border-radius:2px;padding:0 1px}
|
|
41
|
+
.chatsHead{display:flex;align-items:center;gap:8px;margin:16px 0 8px}
|
|
42
|
+
.seg{display:inline-flex;border:1px solid var(--line2);border-radius:7px;overflow:hidden}.seg button{background:var(--btn);color:var(--dim);border:0;padding:4px 11px;font:inherit;font-size:12px;cursor:pointer}
|
|
43
|
+
.seg button+button{border-left:1px solid var(--line2)}.seg button.on{background:var(--chart);color:#fff;font-weight:600}.seg button:not(.on):hover{color:var(--text)}
|
|
44
|
+
.sub{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:12px 0 6px;font-weight:600}
|
|
45
|
+
.psec{border:1px solid var(--line);border-radius:10px;margin-bottom:9px;background:var(--panel);overflow:hidden}
|
|
46
|
+
.phead{display:flex;align-items:baseline;gap:8px;padding:9px 11px;cursor:pointer;user-select:none}.phead:hover{background:var(--panel2)}
|
|
47
|
+
.phead .pn{font-weight:650;font-size:14px;white-space:nowrap}.phead .pp{color:var(--dim);font-size:11.5px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
48
|
+
.phead .pc{font-size:11.5px;color:var(--dim);white-space:nowrap}.phead .pc .r{color:var(--green);font-weight:600}.phead .pc .w{color:var(--amber);font-weight:600}
|
|
49
|
+
.phead{flex-wrap:wrap}.phead .btn{align-self:center;margin-left:auto}
|
|
50
|
+
.caret{display:inline-block;transition:transform .15s;color:var(--dim);width:10px}.psec.closed .caret{transform:rotate(-90deg)}
|
|
51
|
+
.pbody{padding:2px 10px 6px;border-top:1px solid var(--line)}.psec.closed .pbody{display:none}.pbody .card{background:var(--panel2);margin-top:7px}.pbody .card.waiting{background:linear-gradient(var(--amberbg),var(--amberbg)),var(--panel2)}
|
|
52
|
+
.pbody .saved:last-child{border-bottom:0}
|
|
53
|
+
.group{font-size:11.5px;color:var(--dim);margin:12px 0 4px;display:flex;gap:6px}#run .group:first-child{margin-top:0}.group b{color:var(--text);font-weight:600}
|
|
54
|
+
#off{display:none;background:var(--offbg);color:var(--offtext);padding:6px 14px;font-size:12px}
|
|
55
|
+
#toast{position:fixed;bottom:12px;left:12px;right:12px;background:var(--toast);color:var(--toasttext);padding:7px 10px;border-radius:7px;opacity:0;transition:.2s;pointer-events:none;z-index:20}#toast.on{opacity:1}
|
|
56
|
+
.empty{color:var(--dim);font-size:12px;padding:6px 2px}
|
|
57
|
+
.welcome{background:var(--panel);border:1px dashed var(--line2);border-radius:9px;padding:14px;color:var(--dim);font-size:12.5px;line-height:1.55}.welcome b{color:var(--text)}
|
|
58
|
+
code{font-family:Consolas,"Cascadia Mono",ui-monospace,monospace;font-size:12px;background:var(--code);border-radius:4px;padding:0 4px}
|
|
59
|
+
.ugrid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:7px;margin-bottom:8px}.tile{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:8px 10px}
|
|
60
|
+
.tile .v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}.tile .l{color:var(--dim);font-size:11px}
|
|
61
|
+
.chart{display:flex;align-items:flex-end;gap:5px;height:56px;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:8px 10px 18px;position:relative;margin-bottom:8px}
|
|
62
|
+
.chart div{flex:1;background:var(--chart);border-radius:3px 3px 0 0;position:relative;min-height:2px}.chart div.today{background:var(--chart2)}
|
|
63
|
+
.chart div span{position:absolute;bottom:-15px;left:50%;transform:translateX(-50%);font-size:10px;color:var(--dim)}
|
|
64
|
+
.lim{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:8px 10px;margin-bottom:8px}.lim .bar{margin:4px 0 4px}
|
|
65
|
+
.fc{font-size:11.5px;margin:0 0 7px}.fc:last-child{margin-bottom:0}
|
|
66
|
+
.proj{color:var(--dim);font-size:11.5px}.proj b{color:var(--text);font-weight:500}
|
|
67
|
+
/* chat viewer */
|
|
68
|
+
#viewer{position:fixed;inset:0;background:var(--bg);display:none;flex-direction:column;z-index:10}#viewer.on{display:flex}
|
|
69
|
+
#viewer header{display:flex;flex-direction:column;gap:6px}
|
|
70
|
+
#vTitle{font-size:14px;font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
71
|
+
#vBody{flex:1;overflow:auto;padding:12px 14px 24px}
|
|
72
|
+
.m{margin:0 0 10px;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:anywhere}
|
|
73
|
+
.m.you{background:var(--you);border-radius:9px;padding:8px 10px;margin-left:24px}
|
|
74
|
+
.m.ai{padding:0 2px}
|
|
75
|
+
.m pre{background:var(--code);border-radius:6px;padding:7px 9px;overflow:auto;white-space:pre;font:12px/1.45 Consolas,"Cascadia Mono",ui-monospace,monospace;margin:6px 0}
|
|
76
|
+
.who{font-size:11px;color:var(--dim);margin-bottom:2px;font-weight:600}
|
|
77
|
+
.t{font:12px/1.4 Consolas,"Cascadia Mono",ui-monospace,monospace;color:var(--dim);margin:-4px 0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
78
|
+
.t b{color:var(--text);font-weight:600}
|
|
79
|
+
details.r{margin:-6px 0 10px 12px;font-size:11.5px;color:var(--dim)}details.r summary{cursor:pointer}details.r.err summary{color:var(--red)}
|
|
80
|
+
details.r pre{background:var(--code);border-radius:6px;padding:6px 8px;overflow:auto;max-height:260px;font:11.5px/1.4 Consolas,"Cascadia Mono",ui-monospace,monospace;color:var(--text);white-space:pre-wrap}
|
|
81
|
+
.sep{text-align:center;color:var(--dim);font-size:11px;margin:14px 0;border-top:1px dashed var(--line);padding-top:6px}
|
|
82
|
+
#viewer.notools .t,#viewer.notools details.r{display:none}
|
|
83
|
+
#help{position:fixed;inset:auto 12px 50px 12px;background:var(--panel);border:1px solid var(--line2);border-radius:9px;padding:10px 12px;display:none;z-index:30;font-size:12px;box-shadow:0 6px 24px rgba(0,0,0,.3)}
|
|
84
|
+
#help.on{display:block}#help div{display:flex;justify-content:space-between;padding:2px 0}#help kbd{font:11px Consolas,ui-monospace,monospace;background:var(--code);border:1px solid var(--line);border-radius:4px;padding:0 5px}
|
|
85
|
+
</style></head><body>
|
|
86
|
+
<header><div class="top"><img src="/logo.svg" alt="" width="20" height="20" style="flex:none"><h1>chatdeck</h1>
|
|
87
|
+
<button class="btn icon" id="soundBtn" title="Sound when a chat needs you"></button>
|
|
88
|
+
<button class="btn icon" id="themeBtn" title="Theme"></button>
|
|
89
|
+
<button class="btn icon" id="helpBtn" title="Keyboard shortcuts (?)">?</button>
|
|
90
|
+
<button class="btn" id="viewBtn" title="Open Claude's own sessions view">Agents view</button></div>
|
|
91
|
+
<div class="stats"><span id="nWait"></span><b id="nRun">–</b> running · <b id="nSaved">–</b> saved (<b id="disk">–</b>) · open chats hold <b id="tok">–</b> tokens</div></header>
|
|
92
|
+
<div id="off">Dashboard server not reachable – retrying…</div>
|
|
93
|
+
<main><h2>Usage</h2><div id="usage"></div>
|
|
94
|
+
<div class="chatsHead"><h2 style="margin:0;flex:1">Chats</h2><div class="seg" title="How to list chats"><button data-mode="list">Running & saved</button><button data-mode="project">By project</button></div></div>
|
|
95
|
+
<div class="tool"><input type="search" id="q" placeholder="Search names and messages ( / )"></div>
|
|
96
|
+
<div class="tool"><select class="btn" id="sort" title="Sort saved chats"><option value="recent">Recent</option><option value="size">Largest</option><option value="cost">Most expensive</option><option value="msgs">Most messages</option></select>
|
|
97
|
+
<span style="flex:1"></span><select class="btn danger" id="cleanup" title="Delete old or empty chats"></select>
|
|
98
|
+
<button class="btn danger" id="delSel" style="display:none">Delete selected</button></div>
|
|
99
|
+
<div id="list"></div></main>
|
|
100
|
+
<div id="viewer"><header><div class="top"><button class="btn" id="vBack" title="Back (Esc)">←</button><span id="vTitle"></span>
|
|
101
|
+
<button class="btn" id="vRen" title="Give this chat a name you'll recognise (e)">Rename</button></div>
|
|
102
|
+
<div class="meta" id="vMeta" style="margin:0"></div>
|
|
103
|
+
<div class="row"><button class="btn" id="vResume">Resume</button><button class="btn" id="vExport">Export .md</button>
|
|
104
|
+
<label class="chk"><input type="checkbox" id="vTools" checked> Tool calls</label><span style="flex:1"></span><button class="btn danger" id="vDel">Delete</button></div></header>
|
|
105
|
+
<div id="vBody"></div></div>
|
|
106
|
+
<div id="help"><div><span>Search</span><kbd>/</kbd></div><div><span>Move through saved chats</span><span><kbd>j</kbd> <kbd>k</kbd></span></div>
|
|
107
|
+
<div><span>Read the chat</span><kbd>Enter</kbd></div><div><span>Resume in a terminal</span><kbd>r</kbd></div><div><span>Select for bulk delete</span><kbd>x</kbd></div>
|
|
108
|
+
<div><span>Delete</span><kbd>Del</kbd></div><div><span>Rename</span><kbd>e</kbd></div><div><span>Back / clear search</span><kbd>Esc</kbd></div></div>
|
|
109
|
+
<div id="toast"></div>
|
|
110
|
+
<script>
|
|
111
|
+
const TOKEN="__TOKEN__";if(new URLSearchParams(location.search).has("popup"))document.title="chatdeck\u200b"; // reads "chatdeck"; the zero-width space lets the desktop bubble tell its pop-up from the full window
|
|
112
|
+
const $=id=>document.getElementById(id);let S=[],sel=new Set(),now=Date.now()/1000,hits={},cur=null,V=null,first=true,waitSeen=new Set();
|
|
113
|
+
const store={get(k,d){try{return localStorage.getItem(k)??d}catch{return d}},set(k,v){try{localStorage.setItem(k,v)}catch{}}};
|
|
114
|
+
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
115
|
+
const ago=t=>{const s=now-t;return s<60?"just now":s<3600?Math.round(s/60)+" min ago":s<86400?Math.round(s/3600)+" h ago":Math.round(s/86400)+" d ago"};
|
|
116
|
+
const k=n=>n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e3?Math.round(n/1e3)+"k":String(n);
|
|
117
|
+
const mb=n=>n>=1073741824?(n/1073741824).toFixed(1)+" GB":(n/1048576).toFixed(1)+" MB";
|
|
118
|
+
const usd=n=>n?"$"+(n<10?n.toFixed(2):Math.round(n)):"";
|
|
119
|
+
const dur=s=>{s=Math.max(0,s);const h=Math.floor(s/3600),m=Math.round(s%3600/60);return h>=24?Math.round(h/24)+"d":h?`${h}h ${m}m`:`${m}m`};
|
|
120
|
+
const clock=t=>new Date(t*1000).toLocaleString([],{weekday:(t-now>86400?"short":undefined),hour:"2-digit",minute:"2-digit"});
|
|
121
|
+
const cls=n=>n<150e3?"ok":n<400e3?"warn":"bad";const col={ok:"var(--green)",warn:"var(--amber)",bad:"var(--red)"};
|
|
122
|
+
const toast=(m,ms=2200)=>{$("toast").textContent=m;$("toast").classList.add("on");clearTimeout(toast.t);toast.t=setTimeout(()=>$("toast").classList.remove("on"),ms)};
|
|
123
|
+
const words=()=>$("q").value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
124
|
+
const hl=(s,ws=words())=>{let h=esc(s);for(const w of ws){const e=esc(w).replace(/[.*+?^${}()|[\]\\]/g,"\\$&");if(e)h=h.replace(new RegExp(`(${e})(?![^<]*>)`,"gi"),"<mark>$1</mark>")}return h};
|
|
125
|
+
async function api(path,body){const ac=new AbortController();const to=setTimeout(()=>ac.abort(),body?45000:15000);const r=await fetch(path,{signal:ac.signal,method:body?"POST":"GET",headers:{"X-Token":TOKEN,"Content-Type":"application/json"},body:body?JSON.stringify(body):undefined}).finally(()=>clearTimeout(to));if(!r.ok)throw new Error(r.status);return r}
|
|
126
|
+
const getJ=async(p,b)=>(await api(p,b)).json();
|
|
127
|
+
|
|
128
|
+
/* theme + sound */
|
|
129
|
+
const themes=["auto","light","dark"];
|
|
130
|
+
const ICON={auto:'<circle cx="12" cy="12" r="9"/><path d="M12 3v18" /><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor"/>',
|
|
131
|
+
light:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
|
132
|
+
dark:'<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/>',
|
|
133
|
+
bell:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
|
|
134
|
+
mute:'<path d="M6 8a6 6 0 0 1 9.5-4.9M18 8c0 7 3 9 3 9H9"/><path d="M6 8c0 7-3 9-3 9h3"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="M3 3l18 18"/>'};
|
|
135
|
+
const svg=n=>`<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[n]}</svg>`;
|
|
136
|
+
function applyTheme(){const t=store.get("theme","auto");if(t==="auto")delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=t;
|
|
137
|
+
$("themeBtn").innerHTML=svg(t);$("themeBtn").title=`Theme: ${t} (click to change)`}
|
|
138
|
+
function applySound(){const on=store.get("sound","on")==="on";$("soundBtn").innerHTML=svg(on?"bell":"mute");$("soundBtn").title=on?"Sound on when a chat needs you":"Sound off"}
|
|
139
|
+
let actx;function beep(){if(store.get("sound","on")!=="on")return;try{actx=actx||new AudioContext();const t=actx.currentTime;
|
|
140
|
+
for(const [f,d] of [[880,0],[1175,.16]]){const o=actx.createOscillator(),g=actx.createGain();o.frequency.value=f;o.connect(g);g.connect(actx.destination);
|
|
141
|
+
g.gain.setValueAtTime(.0001,t+d);g.gain.exponentialRampToValueAtTime(.18,t+d+.02);g.gain.exponentialRampToValueAtTime(.0001,t+d+.22);o.start(t+d);o.stop(t+d+.25)}}catch{}}
|
|
142
|
+
|
|
143
|
+
/* usage */
|
|
144
|
+
function limitRow(label,o,fc,at){if(!o)return"";const pct=Math.round(o.used_percentage??o.utilization??o.percent??0);const c=pct<60?"ok":pct<85?"warn":"bad";
|
|
145
|
+
const rs=o.resets_at?(typeof o.resets_at==="number"&&o.resets_at<1e12?o.resets_at:Date.parse(o.resets_at)/1000):null;
|
|
146
|
+
let line="";
|
|
147
|
+
if(rs&&rs<now)line=`<span class="ok">Reset ${clock(rs)} – updates when a chat next runs</span>`;
|
|
148
|
+
else if(pct>=100)line=`<span class="bad">Limit reached</span>${rs?` · resets ${clock(rs)}`:""}`;
|
|
149
|
+
else{const r=rs?` · resets ${clock(rs)}`:"";
|
|
150
|
+
if(fc&&fc.eta)line=`<span class="${fc.eta-now<3600?"bad":"warn"}">~${dur(fc.eta-now)} left at this pace</span> (≈${clock(fc.eta)})${r}`;
|
|
151
|
+
else if(fc&&fc.rate>0)line=`<span class="ok">On pace to stay under</span>${r}`;
|
|
152
|
+
else if(fc&&fc.rate===0)line=`Not rising right now${r}`;
|
|
153
|
+
else line=`Forecast after ~15 min of use${r}`}
|
|
154
|
+
return `<div class="row"><span style="flex:1">${label}</span><b class="${c}">${pct}%</b></div><div class="bar"><i style="width:${Math.min(100,pct)}%;background:${col[c]}"></i></div><div class="meta fc">${line}</div>`}
|
|
155
|
+
function renderUsage(U){if(!U)return;const t=U.today,w=U.week_tot,fresh=a=>a[0]+a[1]+a[3];
|
|
156
|
+
const max=Math.max(1,...U.week.map(x=>x[1]));const dn=d=>["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][new Date(d+"T12:00").getDay()];
|
|
157
|
+
let lim="";if(U.limits&&U.limits.data){const L=U.limits.data,F=U.limits.forecast||{};
|
|
158
|
+
lim=`<div class="lim">${limitRow("5-hour limit",L.five_hour||L["5h"],F.five_hour)}${limitRow("Weekly limit",L.seven_day||L.weekly||L["7d"],F.seven_day)}</div>`}
|
|
159
|
+
else lim=`<div class="meta" style="margin-bottom:8px;white-space:normal">Plan limits appear here once a Claude session updates its status line (needs the status-line hook – see README).</div>`;
|
|
160
|
+
$("usage").innerHTML=`<div class="ugrid"><div class="tile"><div class="v">${k(fresh(t))}</div><div class="l">tokens today</div></div>
|
|
161
|
+
<div class="tile"><div class="v">${k(fresh(w))}</div><div class="l">last 7 days</div></div>
|
|
162
|
+
<div class="tile"><div class="v">${U.cost_24h?("$"+U.cost_24h.toFixed(2)):"–"}</div><div class="l">API-equivalent cost (24h)</div></div></div>
|
|
163
|
+
${lim}<div class="chart">${U.week.map((x,i)=>`<div class="${i===6?"today":""}" style="height:${Math.round(x[1]/max*100)}%" title="${k(x[1])} tokens"><span>${dn(x[0])}</span></div>`).join("")}</div>
|
|
164
|
+
<div class="proj">Top this week: ${U.by_project.map(([p,n])=>`<b>${esc(p)}</b> ${k(n)}`).join(" · ")||"–"}</div>`}
|
|
165
|
+
|
|
166
|
+
/* lists */
|
|
167
|
+
const mode=()=>store.get("mode","list")==="project"?"project":"list";
|
|
168
|
+
const pkey=x=>x.project||x.cwd||"(no folder)";
|
|
169
|
+
const pname=k=>k==="~"?"Home folder":k.split("/").filter(Boolean).pop()||k;
|
|
170
|
+
let collapsed=new Set();try{collapsed=new Set(JSON.parse(store.get("collapsed","[]")))}catch{}
|
|
171
|
+
function savedFiltered(){const ws=words();let L=S.filter(x=>!x.status);
|
|
172
|
+
if(ws.length)L=L.filter(x=>hits[x.sid]!==undefined||ws.every(w=>(x.name+" "+x.cwd).toLowerCase().includes(w)));
|
|
173
|
+
const by={recent:x=>-x.mtime,size:x=>-x.size,cost:x=>-x.cost,msgs:x=>-x.msgs}[$("sort").value];return L.sort((a,b)=>by(a)-by(b))}
|
|
174
|
+
/* orders stay put between refreshes (start time, not last activity) so nothing jumps while you aim at it */
|
|
175
|
+
const running=()=>S.filter(x=>x.status).sort((a,b)=>!!b.waiting-!!a.waiting||(b.started||0)-(a.started||0));
|
|
176
|
+
/* one entry per project: its running chats, its saved chats (after search and sort), and totals */
|
|
177
|
+
function groups(){const m=new Map(),get=k=>m.get(k)||(m.set(k,{key:k,run:[],saved:[],all:[],last:0}),m.get(k));
|
|
178
|
+
for(const x of S){const g=get(pkey(x));g.last=Math.max(g.last,x.mtime);if(x.status)g.run.push(x);else g.all.push(x)}
|
|
179
|
+
for(const x of savedFiltered())get(pkey(x)).saved.push(x);
|
|
180
|
+
for(const g of m.values())g.run.sort((a,b)=>!!b.waiting-!!a.waiting);
|
|
181
|
+
let G=[...m.values()];if(words().length)G=G.filter(g=>g.saved.length);
|
|
182
|
+
for(const g of m.values())g.lastSaved=Math.max(0,...g.all.map(x=>x.mtime));
|
|
183
|
+
return G.sort((a,b)=>!a.run.length-!b.run.length||(a.run.length?pname(a.key).localeCompare(pname(b.key)):b.lastSaved-a.lastSaved))}
|
|
184
|
+
/* saved chats in the order they're on screen, for j/k */
|
|
185
|
+
function visibleSaved(){return mode()==="list"?savedFiltered():groups().filter(g=>!collapsed.has(g.key)).flatMap(g=>g.saved)}
|
|
186
|
+
const where=(x,inProject)=>inProject?(x.cwd&&x.cwd!==x.project?esc(x.cwd)+" · ":""):esc(x.cwd||"–")+" · ";
|
|
187
|
+
function runCard(x,inProject){return `<div class="card${x.waiting?" waiting":""}"><div class="row"><span class="dot ${x.waiting?"wait":x.status==="busy"?"busy":""}"></span><span class="name" data-sid="${x.sid}" title="${esc(x.name)} – double-click to type your own name">${esc(x.name)}</span><button class="btn" data-autoren="${x.sid}" title="Run /rename in the chat, so Claude names it after what it's about">Rename</button><button class="btn" data-orun="${x.sid}">Open</button><button class="btn danger" data-stop="${x.sid}">Stop</button></div>
|
|
188
|
+
${x.waiting?`<div class="badge" title="${esc(x.waiting)}">Waiting for you</div> <span class="meta">${esc(x.waiting)}</span>`:""}
|
|
189
|
+
<div class="meta">${where(x,inProject)}${x.waiting?"needs you":x.status==="busy"?"working":"idle"} · ${esc(x.kind||"")} · ${x.msgs} msgs${x.cost?" · "+usd(x.cost):""}</div>
|
|
190
|
+
<div class="bar"><i style="width:${Math.min(100,x.ctx/1e4)}%;background:${col[cls(x.ctx)]}"></i></div>
|
|
191
|
+
<div class="row" style="margin-top:3px"><span class="meta" style="flex:1;margin:0"><b class="${cls(x.ctx)}">${k(x.ctx)} tokens</b>${x.ctx>=400e3?' · <span class="bad">expensive – every message re-sends all of this</span>':x.ctx>=150e3?' · getting big':''}</span>
|
|
192
|
+
${x.ctx>=150e3?`<button class="btn ${x.ctx>=400e3?"danger":""}" data-compact="${x.sid}" title="Run /compact in the chat: Claude summarises the conversation so far, so each message costs less">Compact</button>`:""}</div></div>`}
|
|
193
|
+
function savedRow(x,inProject){const snip=hits[x.sid];return `<div class="saved${cur===x.sid?" cur":""}" data-row="${x.sid}"><input type="checkbox" data-sel="${x.sid}" ${sel.has(x.sid)?"checked":""}>
|
|
194
|
+
<div class="txt" data-view="${x.sid}"><div class="name" title="${esc(x.name)}">${hl(x.name)}</div><div class="meta">${where(x,inProject)}${x.msgs} msgs · ${mb(x.size)}${x.cost?" · "+usd(x.cost):""} · ${ago(x.mtime)}</div>
|
|
195
|
+
${snip?`<div class="snip">${hl(snip)}</div>`:""}</div>
|
|
196
|
+
<button class="btn" data-ren="${x.sid}" title="Give this chat a name you'll recognise (e)">Rename</button><button class="btn" data-open="${x.sid}">Resume</button><button class="btn danger" data-del="${x.sid}">Delete</button></div>`}
|
|
197
|
+
function cleanupSets(){const s=S.filter(x=>!x.status),old=d=>s.filter(x=>now-x.mtime>d*86400);
|
|
198
|
+
return [["empty","Empty chats",s.filter(x=>!x.msgs)],["7","Older than 7 days",old(7)],["30","Older than 30 days",old(30)],["90","Older than 90 days",old(90)]]}
|
|
199
|
+
function render(){
|
|
200
|
+
const run=running(),all=S.filter(x=>!x.status),nw=run.filter(x=>x.waiting).length,M=mode();
|
|
201
|
+
$("nWait").innerHTML=nw?`<span class="w">${nw} waiting for you</span> · `:"";
|
|
202
|
+
$("nRun").textContent=run.length;$("nSaved").textContent=all.length;$("disk").textContent=mb(all.reduce((a,x)=>a+x.size,0));$("tok").textContent=k(run.reduce((a,x)=>a+x.ctx,0));
|
|
203
|
+
document.querySelectorAll(".seg button").forEach(b=>b.classList.toggle("on",b.dataset.mode===M));
|
|
204
|
+
const cs=cleanupSets();$("cleanup").innerHTML=`<option value="">Clean up…</option>`+cs.map(([v,l,L])=>`<option value="${v}" ${L.length?"":"disabled"}>${l} (${L.length}${L.length?", "+mb(L.reduce((a,x)=>a+x.size,0)):""})</option>`).join("");
|
|
205
|
+
for(const s of [...sel]) if(!all.some(x=>x.sid===s)) sel.delete(s);
|
|
206
|
+
if(cur&&!visibleSaved().some(x=>x.sid===cur))cur=null;
|
|
207
|
+
$("delSel").style.display=sel.size?"":"none";$("delSel").textContent=`Delete ${sel.size} selected`;
|
|
208
|
+
const q=words().length,noMatch=`<div class="empty">No saved chats match “${esc($("q").value.trim())}”.</div>`;let html="";
|
|
209
|
+
if(!S.length)html=`<div class="welcome"><b>Nothing here yet.</b><br>Start Claude Code in any folder with <code>claude</code>. Running chats show up here within a few seconds, and finished ones are kept so you can read, search, resume or clean them up.</div>`;
|
|
210
|
+
else if(M==="list"){const saved=savedFiltered();
|
|
211
|
+
html=`<div class="sub">Running now</div>`+(run.length?run.map(x=>runCard(x,false)).join(""):'<div class="empty">No Claude sessions running.</div>')
|
|
212
|
+
+`<div class="sub">Saved</div>`+(saved.length?saved.map(x=>savedRow(x,false)).join(""):q?noMatch:'<div class="empty">No saved chats.</div>')}
|
|
213
|
+
else{const G=groups();html=G.length?G.map(g=>{const w=g.run.filter(x=>x.waiting).length,closed=collapsed.has(g.key);
|
|
214
|
+
return `<div class="psec${closed?" closed":""}"><div class="phead" data-ptoggle="${esc(g.key)}" title="${closed?"Show":"Hide"} this project's chats">
|
|
215
|
+
<span class="caret">▾</span><span class="pn">${esc(pname(g.key))}</span><span class="pp">${esc(g.key)}</span>
|
|
216
|
+
<span class="pc">${w?`<span class="w">${w} waiting</span> · `:""}${g.run.length?`<span class="r">${g.run.length} running</span> · `:""}${g.all.length} saved${g.all.length?" · "+mb(g.all.reduce((a,x)=>a+x.size,0)):""}</span>${g.key.startsWith("/")||g.key.startsWith("~")?`<button class="btn" data-newchat="${esc(g.key)}" title="Start a new Claude Code chat in ${esc(g.key)}">+ New chat</button>`:""}</div>
|
|
217
|
+
<div class="pbody">${g.run.map(x=>runCard(x,true)).join("")}${g.saved.map(x=>savedRow(x,true)).join("")}${!g.run.length&&!g.saved.length?'<div class="empty">No chats.</div>':""}</div></div>`}).join(""):noMatch}
|
|
218
|
+
$("list").innerHTML=html;
|
|
219
|
+
}
|
|
220
|
+
let lastSig="",lastDraw=0,pressing=false,hovering=false,heldSince=0,held=null;
|
|
221
|
+
const HOT="button,select,input,.txt,.phead";
|
|
222
|
+
document.addEventListener("mousedown",()=>pressing=true,true);
|
|
223
|
+
window.addEventListener("mouseup",()=>setTimeout(()=>{pressing=false;flush()},300),true);window.addEventListener("blur",()=>{pressing=false;flush()});
|
|
224
|
+
document.addEventListener("mouseover",e=>{hovering=!!e.target.closest(HOT);if(!hovering)flush()});
|
|
225
|
+
document.documentElement.addEventListener("mouseleave",()=>{hovering=false;flush()});
|
|
226
|
+
/* a redraw that arrived while the mouse was on a control; drawn when it moves off, or after 8s at most */
|
|
227
|
+
function flush(){if(held&&!pressing){const d=held;held=null;draw(d)}}
|
|
228
|
+
function draw(d){S=d.chats;now=d.now;lastDraw=Date.now();render();renderUsage(d.usage)}
|
|
229
|
+
let inflight=false;
|
|
230
|
+
async function refresh(force){if(inflight&&!force)return;inflight=true;try{const d=await getJ("/api/state");$("off").style.display="none";
|
|
231
|
+
const ws=new Set(d.chats.filter(x=>x.waiting).map(x=>x.sid));if(!first&&[...ws].some(s=>!waitSeen.has(s)))beep();waitSeen=ws;first=false;
|
|
232
|
+
const sig=JSON.stringify([d.chats.map(x=>[x.sid,x.name,x.status,x.ctx,x.msgs,x.size,x.cost,x.waiting]),d.usage]);
|
|
233
|
+
const stale=Date.now()-lastDraw>60000; // refresh the "x min ago" labels once a minute
|
|
234
|
+
if(force||sig!==lastSig||stale){lastSig=sig;
|
|
235
|
+
if(!force&&(editing||(pressing||hovering)&&(!held||Date.now()-heldSince<8000))){if(!held)heldSince=Date.now();held=d}else{held=null;draw(d)}}
|
|
236
|
+
}catch(e){$("off").style.display="block";
|
|
237
|
+
if(e.message==="404"||e.message==="403")setTimeout(()=>location.reload(),1500) // server restarted with a new token
|
|
238
|
+
}finally{inflight=false}}
|
|
239
|
+
let searchT,searchN=0;
|
|
240
|
+
function onSearch(){render();clearTimeout(searchT);const q=$("q").value.trim();if(q.length<2){hits={};render();return}
|
|
241
|
+
searchT=setTimeout(async()=>{const n=++searchN;try{const h=await getJ("/api/search?q="+encodeURIComponent(q));if(n===searchN){hits=h;render()}}catch{}},220)}
|
|
242
|
+
|
|
243
|
+
/* actions */
|
|
244
|
+
async function del(sids){if(!sids.length)return false;if(!confirm(`Delete ${sids.length} saved chat${sids.length>1?"s":""} permanently?`))return false;
|
|
245
|
+
const r=await getJ("/api/delete",{sids});sids.forEach(s=>sel.delete(s));toast(`Deleted ${r.deleted.length}`+(r.refused.length?` · ${r.refused.length} skipped (running)`:""));refresh(true);return true}
|
|
246
|
+
/* when no terminal could be opened, hand over the command to paste instead */
|
|
247
|
+
async function opened(r,msg){if(r.ok)return toast(msg);if(r.copy){try{await navigator.clipboard.writeText(r.copy);toast("Couldn't open a terminal – command copied, paste it into one")}catch{prompt("Run this in a terminal:",r.copy)}}else toast("Couldn't open: "+(r.error||"unknown error"))}
|
|
248
|
+
async function resume(sid){await opened(await getJ("/api/open",{sid}),"Opening in a new terminal tab…")}
|
|
249
|
+
|
|
250
|
+
/* viewer */
|
|
251
|
+
function fmt(s){const parts=String(s).split(/```[^\n]*\n?/);return parts.map((p,i)=>i%2?`<pre>${esc(p.replace(/\n$/,""))}</pre>`:
|
|
252
|
+
hl(p).replace(/`([^`\n]+)`/g,"<code>$1</code>").replace(/\*\*([^*\n]+)\*\*/g,"<b>$1</b>")).join("")}
|
|
253
|
+
async function openViewer(sid){let d;try{d=await getJ("/api/chat?sid="+sid)}catch(e){return toast("Couldn't open that chat")}
|
|
254
|
+
V=d;$("viewer").classList.add("on");$("vTitle").textContent=d.name;$("vTitle").title=d.name;
|
|
255
|
+
$("vMeta").textContent=`${d.cwd||"–"} · ${mb(d.size)}${d.cost?" · "+usd(d.cost):""} · ${ago(d.mtime)}${d.running?" · running":""}`;
|
|
256
|
+
$("vDel").disabled=d.running;
|
|
257
|
+
let h=d.hidden?`<div class="sep">${d.hidden} earlier items not shown – export to see everything</div>`:"";
|
|
258
|
+
for(const it of d.items){
|
|
259
|
+
if(it.k==="user")h+=`<div class="m you"><div class="who">You</div>${fmt(it.text)}</div>`;
|
|
260
|
+
else if(it.k==="assistant")h+=`<div class="m ai"><div class="who">Claude</div>${fmt(it.text)}</div>`;
|
|
261
|
+
else if(it.k==="tool")h+=`<div class="t" title="${esc(it.text)}">▸ <b>${esc(it.name)}</b> ${esc(it.text)}</div>`;
|
|
262
|
+
else if(it.k==="result"){const n=it.text.split("\n").length;h+=`<details class="r${it.err?" err":""}"><summary>${it.err?"error":"output"} · ${n} line${n>1?"s":""}${it.cut?" (cut)":""}</summary><pre>${esc(it.text)}</pre></details>`}
|
|
263
|
+
else if(it.k==="command")h+=`<div class="t">▸ <b>${esc(it.text)}</b></div>`;
|
|
264
|
+
else if(it.k==="compact")h+=`<div class="sep">conversation compacted – earlier context was summarised</div>`}
|
|
265
|
+
$("vBody").innerHTML=h||'<div class="empty">This chat has no messages.</div>';
|
|
266
|
+
const m=$("vBody").querySelector("mark");if(m)m.scrollIntoView({block:"center"});else $("vBody").scrollTop=$("vBody").scrollHeight}
|
|
267
|
+
function closeViewer(){$("viewer").classList.remove("on");V=null}
|
|
268
|
+
/* rename in place: the name turns into a text box; Enter saves, Esc cancels. A running chat shows its new
|
|
269
|
+
name here straight away and gets it for good when it ends (Claude Code keeps its own name until then). */
|
|
270
|
+
let editing=false;
|
|
271
|
+
document.addEventListener("dblclick",e=>{const n=e.target.closest(".card .name[data-sid]");if(n)editName(n.dataset.sid,n)});
|
|
272
|
+
function editName(sid,el){const x=S.find(y=>y.sid===sid)||(V&&V.sid===sid?{name:V.name,status:V.running}:null);if(!x||!el)return;
|
|
273
|
+
const i=document.createElement("input");i.className="ren";i.value=x.name==="(empty)"?"":x.name;i.placeholder="Name this chat";i.maxLength=80;
|
|
274
|
+
el.replaceWith(i);i.focus();i.select();editing=true;
|
|
275
|
+
let done=false;const finish=async save=>{if(done)return;done=true;editing=false;const title=i.value.trim();i.replaceWith(el);
|
|
276
|
+
if(save&&title&&title!==x.name){const r=await getJ("/api/rename",{sid,title});
|
|
277
|
+
if(r.ok){el.textContent=r.title;if(V&&V.sid===sid)V.name=r.title;
|
|
278
|
+
toast(r.typed?"Renamed – ran /rename in the chat for you":r.pending?(r.busy?"Renamed here – the chat is busy, so Claude Code gets the name when it ends":
|
|
279
|
+
"Renamed here only – couldn't find its terminal tab (tabs with a locked title can't be found). Type /rename in it, or it gets the name when it ends"):"Renamed",r.pending?7000:2500)}
|
|
280
|
+
else toast("Couldn't rename: "+r.error)}
|
|
281
|
+
refresh(true)};
|
|
282
|
+
i.addEventListener("keydown",e=>{e.stopPropagation();if(e.key==="Enter")finish(true);if(e.key==="Escape")finish(false)});i.addEventListener("blur",()=>finish(true))}
|
|
283
|
+
function startRename(){if(V)editName(V.sid,$("vTitle"))}
|
|
284
|
+
async function exportMd(sid,name){const b=await(await api("/api/export?sid="+sid)).blob();const a=document.createElement("a");a.href=URL.createObjectURL(b);
|
|
285
|
+
a.download=(name||"chat").replace(/[^\w\- ]+/g,"-").slice(0,60)+".md";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);toast("Exported")}
|
|
286
|
+
|
|
287
|
+
document.addEventListener("click",async e=>{if(e.target.classList.contains("ren"))return;const t=e.target.closest("button")||e.target,d=t.dataset;
|
|
288
|
+
if(t.tagName==="BUTTON"){if(t.classList.contains("busy"))return;t.classList.add("busy")}try{
|
|
289
|
+
if(d.stop){const x=S.find(s=>s.sid===d.stop);if(confirm(`Stop "${x?x.name:"this session"}"? Unsaved work in progress will be interrupted.`)){const r=await getJ("/api/stop",{sid:d.stop});toast(r.ok?"Stopped":"Couldn't stop: "+r.error);setTimeout(()=>refresh(true),1500)}}
|
|
290
|
+
else if(d.orun){toast("Finding its terminal…",15000);const r=await getJ("/api/open-running",{sid:d.orun});await opened(r,"Opened – "+r.how)}
|
|
291
|
+
else if(d.mode){store.set("mode",d.mode);cur=null;render()}
|
|
292
|
+
else if(d.newchat){toast("Opening a new chat…",15000);await opened(await getJ("/api/new",{project:d.newchat}),"New chat opened in "+pname(d.newchat))}
|
|
293
|
+
else if(t.closest("[data-ptoggle]")){const g=t.closest("[data-ptoggle]").dataset.ptoggle;collapsed.has(g)?collapsed.delete(g):collapsed.add(g);store.set("collapsed",JSON.stringify([...collapsed]));render()}
|
|
294
|
+
else if(d.open){await resume(d.open)}
|
|
295
|
+
else if(d.del){await del([d.del])}
|
|
296
|
+
else if(t.closest("[data-view]")){const sid=t.closest("[data-view]").dataset.view;cur=sid;openViewer(sid)}
|
|
297
|
+
else if(t.id==="delSel"){await del([...sel])}
|
|
298
|
+
else if(t.id==="viewBtn"){await opened(await getJ("/api/open",{view:true}),"Opening agents view…")}
|
|
299
|
+
else if(t.id==="themeBtn"){store.set("theme",themes[(themes.indexOf(store.get("theme","auto"))+1)%3]);applyTheme()}
|
|
300
|
+
else if(t.id==="soundBtn"){const on=store.get("sound","on")==="on";store.set("sound",on?"off":"on");applySound();if(!on)beep()}
|
|
301
|
+
else if(t.id==="helpBtn"){$("help").classList.toggle("on")}
|
|
302
|
+
else if(t.id==="vBack"){closeViewer()}
|
|
303
|
+
else if(t.id==="vRen"){startRename()}
|
|
304
|
+
else if(d.compact){const x=S.find(s=>s.sid===d.compact);
|
|
305
|
+
if(confirm(`Compact "${x?x.name:"this chat"}"?\n\nClaude Code summarises the conversation so far and carries on from the summary, so each new message costs much less. Details from earlier may be summarised away.`)){
|
|
306
|
+
toast("Running /compact in the chat…",15000);const r=await getJ("/api/compact",{sid:d.compact});toast(r.ok?"Compacting – it'll shrink once Claude finishes the summary":"Couldn't compact: "+r.error,4000)}}
|
|
307
|
+
else if(d.autoren){toast("Running /rename in the chat…",15000);const r=await getJ("/api/rename",{sid:d.autoren,auto:true});
|
|
308
|
+
toast(r.ok?"Claude is naming it after what it's about – the new name shows in a few seconds":"Couldn't rename: "+r.error,r.ok?4000:7000);
|
|
309
|
+
if(r.ok){setTimeout(()=>refresh(true),4000);setTimeout(()=>refresh(true),9000)}}
|
|
310
|
+
else if(d.ren){const row=document.querySelector(`[data-row="${d.ren}"] .name`)||t.closest(".card")?.querySelector(".name");editName(d.ren,row)}
|
|
311
|
+
else if(t.id==="vResume"&&V){await resume(V.sid)}
|
|
312
|
+
else if(t.id==="vExport"&&V){await exportMd(V.sid,V.name)}
|
|
313
|
+
else if(t.id==="vDel"&&V){if(await del([V.sid]))closeViewer()}
|
|
314
|
+
if(t.id!=="helpBtn")$("help").classList.remove("on");
|
|
315
|
+
}catch(err){toast("Something went wrong: "+err.message)}finally{t.classList.remove("busy")}});
|
|
316
|
+
document.addEventListener("change",async e=>{const t=e.target,s=t.dataset.sel;
|
|
317
|
+
if(s){t.checked?sel.add(s):sel.delete(s);render()}
|
|
318
|
+
else if(t.id==="sort"){store.set("sort",t.value);render()}
|
|
319
|
+
else if(t.id==="vTools"){$("viewer").classList.toggle("notools",!t.checked)}
|
|
320
|
+
else if(t.id==="cleanup"){const hit=cleanupSets().find(c=>c[0]===t.value);t.value="";if(hit)try{await del(hit[2].map(x=>x.sid))}catch(err){toast("Something went wrong: "+err.message)}}});
|
|
321
|
+
document.addEventListener("keydown",async e=>{const typing=/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)&&document.activeElement.type!=="checkbox";
|
|
322
|
+
if(e.key==="Escape"){if($("help").classList.contains("on"))$("help").classList.remove("on");else if(V)closeViewer();else if(typing){$("q").value="";onSearch();document.activeElement.blur()}return}
|
|
323
|
+
if(typing||e.ctrlKey||e.metaKey||e.altKey)return;
|
|
324
|
+
try{
|
|
325
|
+
if(e.key==="/"){e.preventDefault();if(V)closeViewer();$("q").focus();return}
|
|
326
|
+
if(e.key==="?"){$("help").classList.toggle("on");return}
|
|
327
|
+
if(V){if(e.key==="e"){e.preventDefault();startRename()}else if(e.key==="r")await resume(V.sid);return}
|
|
328
|
+
const L=visibleSaved(),i=L.findIndex(x=>x.sid===cur);
|
|
329
|
+
if(e.key==="j"||e.key==="ArrowDown"){e.preventDefault();cur=(L[Math.min(L.length-1,i+1)]||{}).sid||null}
|
|
330
|
+
else if(e.key==="k"||e.key==="ArrowUp"){e.preventDefault();cur=(L[Math.max(0,i-1)]||{}).sid||null}
|
|
331
|
+
else if(!cur)return;
|
|
332
|
+
else if(e.key==="Enter"||e.key==="o"){openViewer(cur);return}
|
|
333
|
+
else if(e.key==="r"){await resume(cur);return}
|
|
334
|
+
else if(e.key==="e"){e.preventDefault();editName(cur,document.querySelector(`[data-row="${cur}"] .name`));return}
|
|
335
|
+
else if(e.key==="x"){sel.has(cur)?sel.delete(cur):sel.add(cur)}
|
|
336
|
+
else if(e.key==="Delete"||e.key==="d"){const nx=L[i+1]||L[i-1];if(await del([cur]))cur=nx?nx.sid:null}
|
|
337
|
+
else return;
|
|
338
|
+
render();const r=document.querySelector(`[data-row="${cur}"]`);if(r)r.scrollIntoView({block:"nearest"})}catch(err){toast("Something went wrong: "+err.message)}});
|
|
339
|
+
$("q").addEventListener("input",onSearch);
|
|
340
|
+
$("sort").value=store.get("sort","recent");applyTheme();applySound();
|
|
341
|
+
refresh(true);setInterval(refresh,4000);
|
|
342
|
+
</script></body></html>
|
|
Binary file
|
|
Binary file
|
chatdeck/static/logo.ico
ADDED
|
Binary file
|
chatdeck/static/logo.svg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<!-- chatdeck: a deck of chats - two cards behind a speech bubble -->
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="front" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#4C86F0"/><stop offset="1" stop-color="#2A5BD7"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="23" y="5" width="36" height="28" rx="7" fill="#A9C4F7"/>
|
|
9
|
+
<rect x="14" y="12" width="38" height="29" rx="7" fill="#6E9BEE"/>
|
|
10
|
+
<path d="M13 20h34a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8H24l-11 9 2-9h-2a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z" fill="url(#front)"/>
|
|
11
|
+
<rect x="14" y="30" width="26" height="4.5" rx="2.25" fill="#fff"/>
|
|
12
|
+
<rect x="14" y="38.5" width="17" height="4.5" rx="2.25" fill="#fff" opacity=".8"/>
|
|
13
|
+
</svg>
|
chatdeck/terminals.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Opening and finding terminals, per platform.
|
|
2
|
+
|
|
3
|
+
launch(cwd, command) runs a command in a new terminal tab or window.
|
|
4
|
+
focus(pid, name) brings the terminal a running Claude process lives in to the front.
|
|
5
|
+
Supported: WSL with Windows Terminal, macOS (Terminal and iTerm2), and Linux desktops
|
|
6
|
+
(gnome-terminal, konsole, kitty, alacritty, wezterm, xfce4-terminal, xterm; focusing needs
|
|
7
|
+
wmctrl or xdotool). Set "terminal" in the config (or CHATDECK_TERMINAL) to pick one by name. When nothing
|
|
8
|
+
works, callers fall back to giving the person the command to paste."""
|
|
9
|
+
import glob, os, platform, secrets, shlex, shutil, subprocess
|
|
10
|
+
|
|
11
|
+
from . import config
|
|
12
|
+
|
|
13
|
+
WINDOWS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "windows")
|
|
14
|
+
CHOICE = config.get("terminal", "")
|
|
15
|
+
DEVNULL = subprocess.DEVNULL
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def kind():
|
|
19
|
+
if platform.system() == "Darwin": return "mac"
|
|
20
|
+
if os.environ.get("WSL_DISTRO_NAME") or "microsoft" in platform.release().lower(): return "wsl"
|
|
21
|
+
if platform.system() == "Linux": return "linux"
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _spawn(args, **kw):
|
|
26
|
+
try:
|
|
27
|
+
subprocess.Popen(args, stdout=DEVNULL, stderr=DEVNULL, stdin=DEVNULL, start_new_session=True, **kw)
|
|
28
|
+
return True
|
|
29
|
+
except OSError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _shell_line(cwd, command):
|
|
34
|
+
"""cd into the folder, run the command, then leave an interactive shell open."""
|
|
35
|
+
shell = os.environ.get("SHELL") or "/bin/bash"
|
|
36
|
+
return f"cd {shlex.quote(cwd)} && {command}; exec {shlex.quote(shell)} -l"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tty_of(pid):
|
|
40
|
+
"""The terminal device a process is attached to, e.g. /dev/pts/3 or /dev/ttys004."""
|
|
41
|
+
try:
|
|
42
|
+
t = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
43
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
44
|
+
return None
|
|
45
|
+
if not t or t in ("?", "??"): return None
|
|
46
|
+
return t if t.startswith("/dev/") else "/dev/" + t
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _set_title(tty, title):
|
|
50
|
+
with open(tty, "w") as f: f.write(f"\033]0;{title}\007")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tagged(pid, name, find):
|
|
54
|
+
"""Give the process's terminal tab a unique title, call find(title), then give it its
|
|
55
|
+
name back. Titles alone can't tell chats apart (several tabs can share a folder name),
|
|
56
|
+
so this identifies the exact tab."""
|
|
57
|
+
tty = tty_of(pid)
|
|
58
|
+
if not tty: return False
|
|
59
|
+
mark = "chatdeck-" + secrets.token_hex(4)
|
|
60
|
+
try:
|
|
61
|
+
_set_title(tty, mark)
|
|
62
|
+
return find(mark)
|
|
63
|
+
except OSError:
|
|
64
|
+
return False
|
|
65
|
+
finally:
|
|
66
|
+
try: _set_title(tty, "✳ " + name if name else "Claude Code") # Claude Code's own idle title
|
|
67
|
+
except OSError: pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- WSL + Windows Terminal -------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _win_exe(name, *fallbacks):
|
|
73
|
+
p = shutil.which(name)
|
|
74
|
+
if p: return p
|
|
75
|
+
for pattern in fallbacks:
|
|
76
|
+
hits = glob.glob(pattern)
|
|
77
|
+
if hits: return hits[0]
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _wsl_launch(cwd, command):
|
|
82
|
+
wt = _win_exe("wt.exe", "/mnt/c/Users/*/AppData/Local/Microsoft/WindowsApps/wt.exe")
|
|
83
|
+
if not wt: return False
|
|
84
|
+
distro = os.environ.get("WSL_DISTRO_NAME") or "Ubuntu"
|
|
85
|
+
# Each chat gets a window of its own; set "tabs": true in the config to open tabs in the last window instead.
|
|
86
|
+
where = ["-w", "0", "new-tab"] if str(config.get("tabs", "")).lower() in ("1", "true") else ["-w", "new"]
|
|
87
|
+
return _spawn([wt, *where, "wsl.exe", "-d", distro, "--cd", cwd, "--", "bash", "-lic", command], cwd="/mnt/c")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _wsl_focus(pid, name, typed=None):
|
|
91
|
+
ps = _win_exe("powershell.exe", "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
|
92
|
+
if not ps: return False
|
|
93
|
+
with open(os.path.join(WINDOWS, "focus_wt.ps1")) as f: script = f.read()
|
|
94
|
+
def find(mark):
|
|
95
|
+
# Optional: VirtualDesktopLib.cs (github.com/MScholtes/VirtualDesktop) lets it switch to a
|
|
96
|
+
# window on another virtual desktop reliably. Give its Windows path as "vdlib" in the config.
|
|
97
|
+
env = dict(os.environ, DASH_MARK=mark, DASH_VDLIB=config.get("vdlib", ""),
|
|
98
|
+
DASH_TYPE="".join("{" + c + "}" if c in "+^%~(){}[]" else c for c in typed or ""), # SendKeys escaping
|
|
99
|
+
WSLENV="DASH_MARK:DASH_VDLIB:DASH_TYPE")
|
|
100
|
+
try:
|
|
101
|
+
r = subprocess.run([ps, "-NoProfile", "-Command", script], capture_output=True, text=True,
|
|
102
|
+
cwd="/mnt/c", env=env, timeout=30)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
return False
|
|
105
|
+
return r.stdout.strip().endswith("ok")
|
|
106
|
+
return _tagged(pid, name, find)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- macOS --------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _osa(*lines, args=()):
|
|
112
|
+
try:
|
|
113
|
+
r = subprocess.run(["osascript", *sum((["-e", l] for l in lines), []), *args], capture_output=True, text=True, timeout=15)
|
|
114
|
+
return r.returncode == 0, r.stdout.strip()
|
|
115
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
116
|
+
return False, ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _mac_app():
|
|
120
|
+
if CHOICE: return "iTerm" if CHOICE.lower().startswith("iterm") else "Terminal"
|
|
121
|
+
return "iTerm" if os.environ.get("TERM_PROGRAM") == "iTerm.app" else "Terminal"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _mac_launch(cwd, command):
|
|
125
|
+
line = _shell_line(cwd, command)
|
|
126
|
+
if _mac_app() == "iTerm":
|
|
127
|
+
ok, _ = _osa("on run argv", 'tell application "iTerm"', "activate",
|
|
128
|
+
"set w to (create window with default profile)",
|
|
129
|
+
"tell current session of w to write text (item 1 of argv)", "end tell", "end run", args=[line])
|
|
130
|
+
else:
|
|
131
|
+
ok, _ = _osa("on run argv", 'tell application "Terminal"', "activate", "do script (item 1 of argv)",
|
|
132
|
+
"end tell", "end run", args=[line])
|
|
133
|
+
return ok
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _mac_focus(pid, name):
|
|
137
|
+
tty = tty_of(pid)
|
|
138
|
+
if not tty: return False
|
|
139
|
+
# Both apps can report each tab's tty, so no title trick is needed.
|
|
140
|
+
ok, out = _osa("on run argv", 'if application "Terminal" is running then', 'tell application "Terminal"',
|
|
141
|
+
"repeat with w in windows", "repeat with t in tabs of w",
|
|
142
|
+
"if tty of t is (item 1 of argv) then",
|
|
143
|
+
"set selected of t to true", "set index of w to 1", "activate", 'return "ok"',
|
|
144
|
+
"end if", "end repeat", "end repeat", "end tell", "end if", "end run", args=[tty])
|
|
145
|
+
if out == "ok": return True
|
|
146
|
+
ok, out = _osa("on run argv", 'if application "iTerm" is running then', 'tell application "iTerm"',
|
|
147
|
+
"repeat with w in windows", "repeat with t in tabs of w", "repeat with s in sessions of t",
|
|
148
|
+
"if tty of s is (item 1 of argv) then",
|
|
149
|
+
"select w", "select t", "select s", "activate", 'return "ok"',
|
|
150
|
+
"end if", "end repeat", "end repeat", "end repeat", "end tell", "end if", "end run", args=[tty])
|
|
151
|
+
return out == "ok"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- Linux desktops -----------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
LINUX_TERMINALS = { # name -> args before the command (cwd is handled by the shell line)
|
|
157
|
+
"gnome-terminal": ["--"], "konsole": ["-e"], "kitty": [], "alacritty": ["-e"], "wezterm": ["start", "--"],
|
|
158
|
+
"xfce4-terminal": ["-x"], "tilix": ["-e"], "foot": [], "x-terminal-emulator": ["-e"], "xterm": ["-e"],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _linux_launch(cwd, command):
|
|
163
|
+
names = [CHOICE] if CHOICE else list(LINUX_TERMINALS)
|
|
164
|
+
for n in names:
|
|
165
|
+
exe = shutil.which(n)
|
|
166
|
+
if exe:
|
|
167
|
+
return _spawn([exe, *LINUX_TERMINALS.get(n, ["-e"]), "bash", "-lc", _shell_line(cwd, command)])
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _linux_focus(pid, name):
|
|
172
|
+
def find(mark):
|
|
173
|
+
for args in (["wmctrl", "-a", mark], ["xdotool", "search", "--name", mark, "windowactivate"]):
|
|
174
|
+
if shutil.which(args[0]):
|
|
175
|
+
for _ in range(6): # the terminal takes a moment to pick up the new title
|
|
176
|
+
if subprocess.run(args, stdout=DEVNULL, stderr=DEVNULL).returncode == 0: return True
|
|
177
|
+
subprocess.run(["sleep", "0.25"])
|
|
178
|
+
return False
|
|
179
|
+
return _tagged(pid, name, find)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def type_into(pid, name, text):
|
|
183
|
+
"""Bring a running chat's terminal to the front and type text into it, then Enter.
|
|
184
|
+
Callers must make sure the chat is idle at its prompt."""
|
|
185
|
+
k = kind()
|
|
186
|
+
try:
|
|
187
|
+
if k == "wsl": return _wsl_focus(pid, name, text)
|
|
188
|
+
if k == "mac" and _mac_focus(pid, name):
|
|
189
|
+
subprocess.run(["sleep", "0.4"])
|
|
190
|
+
ok, _ = _osa("on run argv", 'tell application "System Events"', "keystroke (item 1 of argv)", "key code 36",
|
|
191
|
+
"end tell", "end run", args=[text]) # needs Accessibility permission for the terminal
|
|
192
|
+
return ok
|
|
193
|
+
if k == "linux" and shutil.which("xdotool") and _linux_focus(pid, name):
|
|
194
|
+
subprocess.run(["sleep", "0.4"])
|
|
195
|
+
return subprocess.run(["xdotool", "type", "--delay", "8", "--", text], stdout=DEVNULL, stderr=DEVNULL).returncode == 0 \
|
|
196
|
+
and subprocess.run(["xdotool", "key", "Return"], stdout=DEVNULL, stderr=DEVNULL).returncode == 0
|
|
197
|
+
except OSError:
|
|
198
|
+
pass
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def launch(cwd, command):
|
|
203
|
+
k = kind()
|
|
204
|
+
if k == "wsl": return _wsl_launch(cwd, command)
|
|
205
|
+
if k == "mac": return _mac_launch(cwd, command)
|
|
206
|
+
if k == "linux": return _linux_launch(cwd, command)
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def focus(pid, name):
|
|
211
|
+
k = kind()
|
|
212
|
+
try:
|
|
213
|
+
if k == "wsl": return _wsl_focus(pid, name)
|
|
214
|
+
if k == "mac": return _mac_focus(pid, name)
|
|
215
|
+
if k == "linux": return _linux_focus(pid, name)
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
return False
|