chatdeck 0.1.1__tar.gz → 0.2.1__tar.gz
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-0.1.1/src/chatdeck.egg-info → chatdeck-0.2.1}/PKG-INFO +5 -2
- {chatdeck-0.1.1 → chatdeck-0.2.1}/README.md +4 -1
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/__init__.py +1 -1
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/hooks.py +3 -3
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/server.py +99 -18
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/static/index.html +64 -17
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/transcripts.py +9 -2
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/usage.py +8 -4
- {chatdeck-0.1.1 → chatdeck-0.2.1/src/chatdeck.egg-info}/PKG-INFO +5 -2
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck.egg-info/SOURCES.txt +4 -1
- {chatdeck-0.1.1 → chatdeck-0.2.1}/tests/test_chatdeck.py +52 -3
- chatdeck-0.2.1/tests/test_delete_safety.py +59 -0
- chatdeck-0.2.1/tests/test_hook_quoting.py +66 -0
- chatdeck-0.2.1/tests/test_usage_fixes.py +46 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/LICENSE +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/pyproject.toml +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/setup.cfg +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/__main__.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/autostart.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/cli.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/config.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/demo.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/desktop.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/static/logo-192.png +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/static/logo-512.png +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/static/logo.ico +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/static/logo.svg +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/terminals.py +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/windows/bubble.ps1 +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/windows/focus_wt.ps1 +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck/windows/start.ps1 +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck.egg-info/dependency_links.txt +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck.egg-info/entry_points.txt +0 -0
- {chatdeck-0.1.1 → chatdeck-0.2.1}/src/chatdeck.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatdeck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A local dashboard for your Claude Code chats: what's running, what needs you, and what it costs.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Abdifatah09/chatdeck
|
|
@@ -37,7 +37,10 @@ Python 3.9+ standard library only, no dependencies.
|
|
|
37
37
|
- **Cleanup** – delete empty chats or chats older than 7, 30 or 90 days. Shows disk usage.
|
|
38
38
|
Running chats are never deleted.
|
|
39
39
|
- **By project** view: one section per project (git repo, including its worktrees and subfolders)
|
|
40
|
-
with its running and saved chats, and a **New chat** button that starts Claude Code in that project.
|
|
40
|
+
with its running and saved chats, and a **New chat** button that starts Claude Code in that project.
|
|
41
|
+
**Add project** lists a folder before it has any chats; **Delete project** deletes its saved chats
|
|
42
|
+
and takes it off the list (the folder itself is never touched). Drag a project's header to move it
|
|
43
|
+
(**Reset order** goes back to the automatic order). Sort by recent, size, cost or messages. Light and dark themes.
|
|
41
44
|
Keyboard shortcuts: `/` search, `j`/`k` move, `Enter` read, `r` resume, `e` rename, `x` select, `Del` delete, `?` help.
|
|
42
45
|
- **Windows corner button and tray icon** – pop the dashboard up from the corner of the screen.
|
|
43
46
|
|
|
@@ -19,7 +19,10 @@ Python 3.9+ standard library only, no dependencies.
|
|
|
19
19
|
- **Cleanup** – delete empty chats or chats older than 7, 30 or 90 days. Shows disk usage.
|
|
20
20
|
Running chats are never deleted.
|
|
21
21
|
- **By project** view: one section per project (git repo, including its worktrees and subfolders)
|
|
22
|
-
with its running and saved chats, and a **New chat** button that starts Claude Code in that project.
|
|
22
|
+
with its running and saved chats, and a **New chat** button that starts Claude Code in that project.
|
|
23
|
+
**Add project** lists a folder before it has any chats; **Delete project** deletes its saved chats
|
|
24
|
+
and takes it off the list (the folder itself is never touched). Drag a project's header to move it
|
|
25
|
+
(**Reset order** goes back to the automatic order). Sort by recent, size, cost or messages. Light and dark themes.
|
|
23
26
|
Keyboard shortcuts: `/` search, `j`/`k` move, `Enter` read, `r` resume, `e` rename, `x` select, `Del` delete, `?` help.
|
|
24
27
|
- **Windows corner button and tray icon** – pop the dashboard up from the corner of the screen.
|
|
25
28
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""chatdeck: a local dashboard for Claude Code chats."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.2.1"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
their status line looks the same as before.
|
|
6
6
|
- Notification (`chatdeck hook waiting`): marks a chat as waiting on a permission prompt or a
|
|
7
7
|
question. The dashboard treats the mark as cleared once the transcript moves on."""
|
|
8
|
-
import json, os, re, shutil, subprocess, sys, time
|
|
8
|
+
import json, os, re, shlex, shutil, subprocess, sys, time
|
|
9
9
|
|
|
10
10
|
from . import config
|
|
11
11
|
|
|
@@ -55,9 +55,9 @@ def command():
|
|
|
55
55
|
"""How settings.json should call chatdeck: the absolute path of the installed command,
|
|
56
56
|
or this Python running the package from wherever it lives."""
|
|
57
57
|
exe = shutil.which("chatdeck")
|
|
58
|
-
if exe: return exe
|
|
58
|
+
if exe: return shlex.quote(exe)
|
|
59
59
|
src = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
60
|
-
return f"PYTHONPATH={src} {sys.executable} -m chatdeck"
|
|
60
|
+
return f"PYTHONPATH={shlex.quote(src)} {shlex.quote(sys.executable)} -m chatdeck"
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def _ours(cmd):
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""chatdeck: a local dashboard for Claude Code chats.
|
|
2
2
|
Lists running sessions (claude agents --json) and saved transcripts under
|
|
3
3
|
~/.claude/projects, and can view, search, rename, export and delete saved chats, stop
|
|
4
|
-
sessions, and open chats in a terminal (see terminals.py). Binds 127.0.0.1 only; every API call needs
|
|
4
|
+
sessions, add and delete projects, and open chats in a terminal (see terminals.py). Binds 127.0.0.1 only; every API call needs
|
|
5
5
|
the per-run token embedded in the page (custom header => cross-site pages can't call it)."""
|
|
6
|
-
import json, os, glob, secrets, shlex, signal, subprocess, threading, time
|
|
6
|
+
import json, os, glob, secrets, shlex, signal, subprocess, sys, threading, time
|
|
7
7
|
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
8
8
|
from urllib.parse import urlparse, parse_qs, quote
|
|
9
9
|
|
|
@@ -28,19 +28,25 @@ INDEX = transcripts.Index(PROJECTS)
|
|
|
28
28
|
LIMITS = usage.LimitHistory(os.path.join(DATA, "limits-history.jsonl"))
|
|
29
29
|
|
|
30
30
|
_agents = (0.0, {})
|
|
31
|
+
_agents_ok = True # did the latest fetch work?
|
|
31
32
|
_agents_lock = threading.Lock()
|
|
32
33
|
def _fetch_agents():
|
|
33
|
-
|
|
34
|
+
"""Refresh the cache; True if that worked. A failed check (non-zero exit, timeout, no output) keeps the last good list."""
|
|
35
|
+
global _agents, _agents_ok
|
|
36
|
+
_agents_ok = False
|
|
34
37
|
try:
|
|
35
38
|
if config.DEMO:
|
|
36
39
|
with open(os.path.join(DATA, "demo-agents.json")) as f: out = f.read()
|
|
37
40
|
else:
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
r = subprocess.run(["claude", "agents", "--json"], capture_output=True, text=True, timeout=30)
|
|
42
|
+
if r.returncode != 0 or not r.stdout.strip(): return False
|
|
43
|
+
out = r.stdout
|
|
44
|
+
res = {s["sessionId"]: s for s in json.loads(out) if s.get("sessionId")}
|
|
40
45
|
with _agents_lock: _agents = (time.time(), res)
|
|
46
|
+
_agents_ok = True
|
|
47
|
+
return True
|
|
41
48
|
except Exception:
|
|
42
|
-
|
|
43
|
-
return _agents[1]
|
|
49
|
+
return False
|
|
44
50
|
|
|
45
51
|
def _agents_loop():
|
|
46
52
|
while True:
|
|
@@ -49,7 +55,16 @@ def _agents_loop():
|
|
|
49
55
|
def agents(fresh=False):
|
|
50
56
|
"""Running sessions. Normal reads use the list a background thread keeps fresh, so pages never wait;
|
|
51
57
|
destructive actions (delete/stop) ask for a fresh copy first."""
|
|
52
|
-
|
|
58
|
+
if fresh: _fetch_agents()
|
|
59
|
+
return _agents[1]
|
|
60
|
+
|
|
61
|
+
def agents_checked():
|
|
62
|
+
"""A fresh running-sessions list, or None if we couldn't get one. Destructive actions use this, so a
|
|
63
|
+
failed check can never look like 'nothing is running'."""
|
|
64
|
+
act = agents(fresh=True)
|
|
65
|
+
return act if _agents_ok else None
|
|
66
|
+
|
|
67
|
+
NO_CHECK = "couldn't check which chats are running"
|
|
53
68
|
|
|
54
69
|
def waiting(act, chats):
|
|
55
70
|
"""{sid: message} for running chats stuck on a permission prompt or question. A mark from the
|
|
@@ -139,16 +154,16 @@ def state():
|
|
|
139
154
|
a = act.get(sid, {})
|
|
140
155
|
out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or e.title or "(empty)",
|
|
141
156
|
"cwd": (a.get("cwd") or e.cwd or "").replace(HOME, "~"), "project": chat_project(e, a.get("cwd")),
|
|
142
|
-
"ctx": e.ctx, "msgs": e.msgs, "mtime": e.mtime, "size": e.size, "cost": round(e.cost, 2),
|
|
157
|
+
"ctx": e.ctx, "msgs": e.msgs, "mtime": e.mtime, "active": e.active or e.mtime, "size": e.size, "cost": round(e.cost, 2),
|
|
143
158
|
"status": a.get("status"), "kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
|
|
144
159
|
for sid, a in act.items(): # running but no transcript yet
|
|
145
160
|
if sid not in chats:
|
|
146
161
|
out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or "(new chat)", "cwd": (a.get("cwd") or "").replace(HOME, "~"),
|
|
147
162
|
"project": project(a.get("cwd")),
|
|
148
|
-
"ctx": 0, "msgs": 0, "mtime": time.time(), "size": 0, "cost": 0, "status": a.get("status"),
|
|
163
|
+
"ctx": 0, "msgs": 0, "mtime": time.time(), "active": time.time(), "size": 0, "cost": 0, "status": a.get("status"),
|
|
149
164
|
"kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
|
|
150
165
|
out.sort(key=lambda x: -x["mtime"])
|
|
151
|
-
return {"chats": out, "now": time.time(), "usage": usage.summary([e for _, e in chats.values()], STATUS, LIMITS, project=chat_project)}
|
|
166
|
+
return {"chats": out, "projects": added_projects(), "now": time.time(), "usage": usage.summary([e for _, e in chats.values()], STATUS, LIMITS, project=chat_project)}
|
|
152
167
|
|
|
153
168
|
def manual(cwd, command):
|
|
154
169
|
"""What to paste into a terminal when no terminal could be opened for the person."""
|
|
@@ -176,16 +191,22 @@ def transcript(sid):
|
|
|
176
191
|
return hits[0]
|
|
177
192
|
|
|
178
193
|
def delete(sids):
|
|
179
|
-
|
|
194
|
+
"""Delete saved chats. Their projects stay listed, empty, until the project itself is deleted."""
|
|
195
|
+
act = agents_checked()
|
|
196
|
+
if act is None: return {"deleted": [], "refused": list(sids), "error": NO_CHECK}
|
|
197
|
+
done, refused = [], []; kept = added_projects(); keep = False
|
|
180
198
|
for sid in sids:
|
|
181
199
|
p = transcript(sid)
|
|
182
200
|
if not p or sid in act:
|
|
183
201
|
refused.append(sid); continue
|
|
202
|
+
e = INDEX.get(p); proj = chat_project(e)
|
|
203
|
+
if proj and proj not in kept: kept[proj] = e.active or e.mtime; keep = True
|
|
184
204
|
os.remove(p); INDEX.forget(p)
|
|
185
205
|
side = p[:-6] # sibling folder with this chat's tool output / subagents
|
|
186
206
|
if os.path.isdir(side):
|
|
187
207
|
subprocess.run(["rm", "-rf", "--", side])
|
|
188
208
|
done.append(sid)
|
|
209
|
+
if keep: _set_added(kept)
|
|
189
210
|
return {"deleted": done, "refused": refused}
|
|
190
211
|
|
|
191
212
|
def _demo_rename(sid, title):
|
|
@@ -239,7 +260,9 @@ def compact(sid):
|
|
|
239
260
|
|
|
240
261
|
def stop(sid):
|
|
241
262
|
if config.DEMO: return DEMO_OFF
|
|
242
|
-
|
|
263
|
+
act = agents_checked()
|
|
264
|
+
if act is None: return {"ok": False, "error": NO_CHECK}
|
|
265
|
+
a = act.get(sid)
|
|
243
266
|
if not a or not a.get("pid"): return {"ok": False, "error": "not running"}
|
|
244
267
|
pid = int(a["pid"])
|
|
245
268
|
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True).stdout
|
|
@@ -260,11 +283,47 @@ def open_chat(sid, view=False):
|
|
|
260
283
|
if terminals.launch(cwd, inner): return {"ok": True}
|
|
261
284
|
return {"ok": False, "copy": manual(cwd, inner)}
|
|
262
285
|
|
|
286
|
+
# Projects added by hand ({project: when added}), so a folder shows up before it has any chats.
|
|
287
|
+
def added_projects():
|
|
288
|
+
return config.load().get("projects") or {}
|
|
289
|
+
|
|
290
|
+
def _set_added(projects):
|
|
291
|
+
cfg = config.load(); cfg["projects"] = projects; config.save(cfg)
|
|
292
|
+
|
|
293
|
+
def known_projects():
|
|
294
|
+
return ({chat_project(e) for _, e in INDEX.all().values()} | {project(a.get("cwd")) for a in agents().values()}
|
|
295
|
+
| set(added_projects()))
|
|
296
|
+
|
|
297
|
+
def add_project(path):
|
|
298
|
+
"""List a folder as a project. A folder inside a git repo adds the repo, like its chats would."""
|
|
299
|
+
path = str(path or "").strip()
|
|
300
|
+
if not path: return {"ok": False, "error": "type the folder's path"}
|
|
301
|
+
full = os.path.abspath(os.path.expanduser(path))
|
|
302
|
+
if not os.path.isdir(full): return {"ok": False, "error": f"there's no folder at {path}"}
|
|
303
|
+
proj = project(full)
|
|
304
|
+
if proj in known_projects(): return {"ok": True, "project": proj, "already": True}
|
|
305
|
+
_set_added(dict(added_projects(), **{proj: time.time()}))
|
|
306
|
+
return {"ok": True, "project": proj}
|
|
307
|
+
|
|
308
|
+
def delete_project(proj):
|
|
309
|
+
"""Take a project off the list: delete its saved chats and forget it was added. Its folder is never touched.
|
|
310
|
+
Refused while any of its chats are running, so nothing is half-deleted."""
|
|
311
|
+
act = agents_checked()
|
|
312
|
+
if act is None: return {"ok": False, "error": NO_CHECK}
|
|
313
|
+
chats = INDEX.all()
|
|
314
|
+
if any((chat_project(chats[sid][1], a.get("cwd")) if sid in chats else project(a.get("cwd"))) == proj for sid, a in act.items()):
|
|
315
|
+
return {"ok": False, "error": "it has running chats - stop them first"}
|
|
316
|
+
sids = [sid for sid, (_, e) in chats.items() if chat_project(e) == proj]
|
|
317
|
+
added = added_projects()
|
|
318
|
+
if not sids and proj not in added: return {"ok": False, "error": "unknown project"}
|
|
319
|
+
r = delete(sids); added = added_projects() # re-read: delete() keeps the emptied project listed
|
|
320
|
+
if proj in added: del added[proj]; _set_added(added)
|
|
321
|
+
return {"ok": True, "deleted": len(r["deleted"])}
|
|
322
|
+
|
|
263
323
|
def new_chat(proj):
|
|
264
324
|
"""Start a new Claude Code chat in a project folder. Only folders chatdeck lists as projects are allowed."""
|
|
265
325
|
if config.DEMO: return DEMO_OFF
|
|
266
|
-
|
|
267
|
-
if proj not in known: return {"ok": False, "error": "unknown project"}
|
|
326
|
+
if proj not in known_projects(): return {"ok": False, "error": "unknown project"}
|
|
268
327
|
cwd = os.path.join(HOME, proj[2:]) if proj.startswith("~/") else HOME if proj == "~" else proj
|
|
269
328
|
if not os.path.isdir(cwd): return {"ok": False, "error": f"{proj} doesn't exist any more"}
|
|
270
329
|
if terminals.launch(cwd, "claude"): return {"ok": True}
|
|
@@ -328,18 +387,40 @@ class H(BaseHTTPRequestHandler):
|
|
|
328
387
|
if self.path == "/api/compact": return self.send(200, compact(body.get("sid", "")))
|
|
329
388
|
if self.path == "/api/open": return self.send(200, open_chat(body.get("sid", ""), body.get("view", False)))
|
|
330
389
|
if self.path == "/api/new": return self.send(200, new_chat(body.get("project", "")))
|
|
390
|
+
if self.path == "/api/project/add": return self.send(200, add_project(body.get("path", "")))
|
|
391
|
+
if self.path == "/api/project/delete": return self.send(200, delete_project(body.get("project", "")))
|
|
331
392
|
if self.path == "/api/open-running": return self.send(200, open_running(body.get("sid", "")))
|
|
332
393
|
self.send(404, {"error": "not found"})
|
|
333
394
|
|
|
395
|
+
CODE = os.path.dirname(os.path.abspath(__file__))
|
|
396
|
+
def _code_stamp():
|
|
397
|
+
"""When chatdeck's Python files last changed. The page is read from disk on every request, so only these go stale."""
|
|
398
|
+
return max((os.path.getmtime(p) for p in glob.glob(os.path.join(CODE, "*.py"))), default=0)
|
|
399
|
+
|
|
400
|
+
def _watch_code(httpd, restart):
|
|
401
|
+
"""Stop the server once its code changes (a git pull or reinstall), so serve() can start the new code.
|
|
402
|
+
Waits until the files stop changing, so it never starts on a half-finished pull."""
|
|
403
|
+
start = seen = _code_stamp()
|
|
404
|
+
while True:
|
|
405
|
+
time.sleep(2); now = _code_stamp()
|
|
406
|
+
if now != start and now == seen: restart.set(); httpd.shutdown(); return
|
|
407
|
+
seen = now
|
|
408
|
+
|
|
334
409
|
def serve(port):
|
|
335
|
-
"""Run the dashboard until stopped. Raises OSError if the port is taken."""
|
|
410
|
+
"""Run the dashboard until stopped. Raises OSError if the port is taken. Restarts itself when its code changes."""
|
|
336
411
|
httpd = ThreadingHTTPServer(("127.0.0.1", port), H)
|
|
337
412
|
os.makedirs(DATA, exist_ok=True)
|
|
338
413
|
pidfile = os.path.join(DATA, "server.pid")
|
|
339
414
|
with open(pidfile, "w") as f: f.write(str(os.getpid()))
|
|
340
415
|
threading.Thread(target=_agents_loop, daemon=True).start()
|
|
416
|
+
restart = threading.Event()
|
|
417
|
+
threading.Thread(target=_watch_code, args=(httpd, restart), daemon=True).start()
|
|
341
418
|
try:
|
|
342
419
|
httpd.serve_forever()
|
|
343
420
|
finally:
|
|
344
|
-
|
|
345
|
-
|
|
421
|
+
httpd.server_close()
|
|
422
|
+
if not restart.is_set():
|
|
423
|
+
try: os.remove(pidfile)
|
|
424
|
+
except OSError: pass
|
|
425
|
+
# same pid, so server.pid and the desktop bubble stay valid; the open page reloads when its old token is refused
|
|
426
|
+
os.execv(sys.executable, [sys.executable, "-m", "chatdeck", "serve", "--port", str(port)])
|
|
@@ -46,7 +46,11 @@ mark{background:var(--mark);color:inherit;border-radius:2px;padding:0 1px}
|
|
|
46
46
|
.phead{display:flex;align-items:baseline;gap:8px;padding:9px 11px;cursor:pointer;user-select:none}.phead:hover{background:var(--panel2)}
|
|
47
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
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 .
|
|
49
|
+
.phead{flex-wrap:wrap}.phead .acts{align-self:center;margin-left:auto;display:flex;gap:6px}
|
|
50
|
+
.addp{background:var(--panel);border:1px solid var(--line2);border-radius:10px;padding:10px 11px;margin-bottom:9px}.addp[hidden]{display:none}
|
|
51
|
+
.addp .row input{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}
|
|
52
|
+
.addp .meta{white-space:normal;margin-top:6px}.addp .err{color:var(--red)}
|
|
53
|
+
.psec.dragging{opacity:.45}.psec.dropBefore{box-shadow:0 -3px 0 var(--green)}.psec.dropAfter{box-shadow:0 3px 0 var(--green)}
|
|
50
54
|
.caret{display:inline-block;transition:transform .15s;color:var(--dim);width:10px}.psec.closed .caret{transform:rotate(-90deg)}
|
|
51
55
|
.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
56
|
.pbody .saved:last-child{border-bottom:0}
|
|
@@ -91,11 +95,14 @@ details.r pre{background:var(--code);border-radius:6px;padding:6px 8px;overflow:
|
|
|
91
95
|
<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
96
|
<div id="off">Dashboard server not reachable – retrying…</div>
|
|
93
97
|
<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>
|
|
98
|
+
<div class="chatsHead"><h2 style="margin:0;flex:1">Chats</h2><button class="btn" id="porderReset" title="Go back to the automatic order: projects with running chats first, then the most recently used">Reset order</button><button class="btn" id="addBtn" title="List a folder as a project, so you can start chats in it">+ Add project</button><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
99
|
<div class="tool"><input type="search" id="q" placeholder="Search names and messages ( / )"></div>
|
|
96
100
|
<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
101
|
<span style="flex:1"></span><select class="btn danger" id="cleanup" title="Delete old or empty chats"></select>
|
|
98
102
|
<button class="btn danger" id="delSel" style="display:none">Delete selected</button></div>
|
|
103
|
+
<div class="addp" id="addProj" hidden><div class="row"><input id="apPath" placeholder="Folder path, e.g. ~/code/my-app" spellcheck="false" autocomplete="off">
|
|
104
|
+
<button class="btn" id="apOk">Add</button><button class="btn" id="apCancel">Cancel</button></div>
|
|
105
|
+
<div class="meta" id="apMsg">A folder inside a git repo adds the whole repo.</div></div>
|
|
99
106
|
<div id="list"></div></main>
|
|
100
107
|
<div id="viewer"><header><div class="top"><button class="btn" id="vBack" title="Back (Esc)">←</button><span id="vTitle"></span>
|
|
101
108
|
<button class="btn" id="vRen" title="Give this chat a name you'll recognise (e)">Rename</button></div>
|
|
@@ -109,7 +116,7 @@ details.r pre{background:var(--code);border-radius:6px;padding:6px 8px;overflow:
|
|
|
109
116
|
<div id="toast"></div>
|
|
110
117
|
<script>
|
|
111
118
|
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();
|
|
119
|
+
const $=id=>document.getElementById(id);let S=[],P={},sel=new Set(),now=Date.now()/1000,hits={},cur=null,V=null,first=true,waitSeen=new Set();
|
|
113
120
|
const store={get(k,d){try{return localStorage.getItem(k)??d}catch{return d}},set(k,v){try{localStorage.setItem(k,v)}catch{}}};
|
|
114
121
|
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
115
122
|
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"};
|
|
@@ -159,7 +166,7 @@ function renderUsage(U){if(!U)return;const t=U.today,w=U.week_tot,fresh=a=>a[0]+
|
|
|
159
166
|
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
167
|
$("usage").innerHTML=`<div class="ugrid"><div class="tile"><div class="v">${k(fresh(t))}</div><div class="l">tokens today</div></div>
|
|
161
168
|
<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">
|
|
169
|
+
<div class="tile"><div class="v">${U.cost_24h?("$"+U.cost_24h.toFixed(2)):"–"}</div><div class="l">total cost of chats active in 24h</div></div></div>
|
|
163
170
|
${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
171
|
<div class="proj">Top this week: ${U.by_project.map(([p,n])=>`<b>${esc(p)}</b> ${k(n)}`).join(" · ")||"–"}</div>`}
|
|
165
172
|
|
|
@@ -168,19 +175,23 @@ const mode=()=>store.get("mode","list")==="project"?"project":"list";
|
|
|
168
175
|
const pkey=x=>x.project||x.cwd||"(no folder)";
|
|
169
176
|
const pname=k=>k==="~"?"Home folder":k.split("/").filter(Boolean).pop()||k;
|
|
170
177
|
let collapsed=new Set();try{collapsed=new Set(JSON.parse(store.get("collapsed","[]")))}catch{}
|
|
178
|
+
let porder=[];try{porder=JSON.parse(store.get("porder","[]"))}catch{} // project keys in the order you dragged them into
|
|
171
179
|
function savedFiltered(){const ws=words();let L=S.filter(x=>!x.status);
|
|
172
180
|
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.
|
|
181
|
+
const by={recent:x=>-x.active,size:x=>-x.size,cost:x=>-x.cost,msgs:x=>-x.msgs}[$("sort").value];return L.sort((a,b)=>by(a)-by(b))}
|
|
174
182
|
/* orders stay put between refreshes (start time, not last activity) so nothing jumps while you aim at it */
|
|
175
183
|
const running=()=>S.filter(x=>x.status).sort((a,b)=>!!b.waiting-!!a.waiting||(b.started||0)-(a.started||0));
|
|
176
184
|
/* one entry per project: its running chats, its saved chats (after search and sort), and totals */
|
|
177
185
|
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
|
|
186
|
+
for(const [key,at] of Object.entries(P))get(key).added=at; // added by hand: listed even with no chats
|
|
187
|
+
for(const x of S){const g=get(pkey(x));g.last=Math.max(g.last,x.active);if(x.status)g.run.push(x);else g.all.push(x)}
|
|
179
188
|
for(const x of savedFiltered())get(pkey(x)).saved.push(x);
|
|
180
|
-
for(const g of m.values())g.run.sort((a,b)=>b.
|
|
189
|
+
for(const g of m.values())g.run.sort((a,b)=>b.active-a.active); // the chat you last talked to on top
|
|
181
190
|
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.
|
|
183
|
-
|
|
191
|
+
for(const g of m.values())g.lastSaved=Math.max(g.added||0,...g.all.map(x=>x.active));
|
|
192
|
+
G.sort((a,b)=>!a.run.length-!b.run.length||(a.run.length?pname(a.key).localeCompare(pname(b.key)):b.lastSaved-a.lastSaved));
|
|
193
|
+
if(porder.length){const at=k=>porder.indexOf(k);G=G.map((g,i)=>[g,i]).sort(([a,i],[b,j])=>(at(a.key)-at(b.key))||i-j).map(([g])=>g)} // projects new since you arranged them go on top
|
|
194
|
+
return G}
|
|
184
195
|
/* saved chats in the order they're on screen, for j/k */
|
|
185
196
|
function visibleSaved(){return mode()==="list"?savedFiltered():groups().filter(g=>!collapsed.has(g.key)).flatMap(g=>g.saved)}
|
|
186
197
|
const where=(x,inProject)=>inProject?(x.cwd&&x.cwd!==x.project?esc(x.cwd)+" · ":""):esc(x.cwd||"–")+" · ";
|
|
@@ -191,16 +202,17 @@ function runCard(x,inProject){return `<div class="card${x.waiting?" waiting":""}
|
|
|
191
202
|
<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
203
|
${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
204
|
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.
|
|
205
|
+
<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.active)}</div>
|
|
195
206
|
${snip?`<div class="snip">${hl(snip)}</div>`:""}</div>
|
|
196
207
|
<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.
|
|
208
|
+
function cleanupSets(){const s=S.filter(x=>!x.status),old=d=>s.filter(x=>now-x.active>d*86400);
|
|
198
209
|
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
210
|
function render(){
|
|
200
211
|
const run=running(),all=S.filter(x=>!x.status),nw=run.filter(x=>x.waiting).length,M=mode();
|
|
201
212
|
$("nWait").innerHTML=nw?`<span class="w">${nw} waiting for you</span> · `:"";
|
|
202
213
|
$("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
214
|
document.querySelectorAll(".seg button").forEach(b=>b.classList.toggle("on",b.dataset.mode===M));
|
|
215
|
+
$("addBtn").style.display=M==="project"?"":"none";$("porderReset").style.display=M==="project"&&porder.length?"":"none";if(M!=="project")$("addProj").hidden=true;
|
|
204
216
|
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
217
|
for(const s of [...sel]) if(!all.some(x=>x.sid===s)) sel.delete(s);
|
|
206
218
|
if(cur&&!visibleSaved().some(x=>x.sid===cur))cur=null;
|
|
@@ -211,10 +223,11 @@ function render(){
|
|
|
211
223
|
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
224
|
+`<div class="sub">Saved</div>`+(saved.length?saved.map(x=>savedRow(x,false)).join(""):q?noMatch:'<div class="empty">No saved chats.</div>')}
|
|
213
225
|
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
|
-
|
|
226
|
+
const real=g.key.startsWith("/")||g.key.startsWith("~");
|
|
227
|
+
return `<div class="psec${closed?" closed":""}" data-pkey="${esc(g.key)}"><div class="phead" data-ptoggle="${esc(g.key)}" draggable="true" title="${closed?"Show":"Hide"} this project's chats · drag to move it">
|
|
215
228
|
<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
|
|
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
|
|
229
|
+
<span class="pc">${w?`<span class="w">${w} waiting</span> · `:""}${g.run.length?`<span class="r">${g.run.length} running</span> · `:""}${g.all.length?`${g.all.length} saved · ${mb(g.all.reduce((a,x)=>a+x.size,0))}`:g.run.length?"0 saved":"no chats yet"}</span>${real?`<span class="acts"><button class="btn" data-newchat="${esc(g.key)}" title="Start a new Claude Code chat in ${esc(g.key)}">+ New chat</button><button class="btn danger" data-pdel="${esc(g.key)}" title="Take this project off the list and delete its saved chats. The folder itself isn't touched">Delete project</button></span>`:""}</div>
|
|
230
|
+
<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">${g.all.length?"No chats.":real?"No chats yet – start one with + New chat.":"No chats."}</div>`:""}</div></div>`}).join(""):noMatch}
|
|
218
231
|
$("list").innerHTML=html;
|
|
219
232
|
}
|
|
220
233
|
let lastSig="",lastDraw=0,pressing=false,hovering=false,heldSince=0,held=null;
|
|
@@ -225,11 +238,11 @@ document.addEventListener("mouseover",e=>{hovering=!!e.target.closest(HOT);if(!h
|
|
|
225
238
|
document.documentElement.addEventListener("mouseleave",()=>{hovering=false;flush()});
|
|
226
239
|
/* a redraw that arrived while the mouse was on a control; drawn when it moves off, or after 8s at most */
|
|
227
240
|
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)}
|
|
241
|
+
function draw(d){S=d.chats;P=d.projects||{};now=d.now;lastDraw=Date.now();render();renderUsage(d.usage)}
|
|
229
242
|
let inflight=false;
|
|
230
243
|
async function refresh(force){if(inflight&&!force)return;inflight=true;try{const d=await getJ("/api/state");$("off").style.display="none";
|
|
231
244
|
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]);
|
|
245
|
+
const sig=JSON.stringify([d.chats.map(x=>[x.sid,x.name,x.status,x.ctx,x.msgs,x.size,x.cost,x.waiting,x.active]),d.projects,d.usage]);
|
|
233
246
|
const stale=Date.now()-lastDraw>60000; // refresh the "x min ago" labels once a minute
|
|
234
247
|
if(force||sig!==lastSig||stale){lastSig=sig;
|
|
235
248
|
if(!force&&(editing||(pressing||hovering)&&(!held||Date.now()-heldSince<8000))){if(!held)heldSince=Date.now();held=d}else{held=null;draw(d)}}
|
|
@@ -242,9 +255,25 @@ function onSearch(){render();clearTimeout(searchT);const q=$("q").value.trim();i
|
|
|
242
255
|
|
|
243
256
|
/* actions */
|
|
244
257
|
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}
|
|
258
|
+
const r=await getJ("/api/delete",{sids});if(r.error){toast("Couldn't delete: "+r.error,4000);return false}sids.forEach(s=>sel.delete(s));toast(`Deleted ${r.deleted.length}`+(r.refused.length?` · ${r.refused.length} skipped (running)`:""));refresh(true);return true}
|
|
246
259
|
/* when no terminal could be opened, hand over the command to paste instead */
|
|
247
260
|
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"))}
|
|
261
|
+
/* projects: add a folder by its path; delete takes the project off the list along with its saved chats */
|
|
262
|
+
function showAdd(on){$("addProj").hidden=!on;$("apMsg").className="meta";$("apMsg").textContent="A folder inside a git repo adds the whole repo.";
|
|
263
|
+
if(on){$("apPath").value="";$("apPath").focus()}}
|
|
264
|
+
function showProject(key){collapsed.delete(key);store.set("collapsed",JSON.stringify([...collapsed]));render();
|
|
265
|
+
const el=[...document.querySelectorAll("[data-pkey]")].find(e=>e.dataset.pkey===key);if(el)el.scrollIntoView({block:"nearest"})}
|
|
266
|
+
async function addProject(){const path=$("apPath").value.trim();if(!path)return $("apPath").focus();
|
|
267
|
+
const r=await getJ("/api/project/add",{path});
|
|
268
|
+
if(!r.ok){$("apMsg").className="meta err";$("apMsg").textContent="Couldn't add it: "+r.error;return $("apPath").focus()}
|
|
269
|
+
showAdd(false);toast(r.already?`${pname(r.project)} is already listed`:`Added ${pname(r.project)}`);
|
|
270
|
+
await refresh(true);showProject(r.project)}
|
|
271
|
+
async function deleteProject(key){const g=groups().find(x=>x.key===key)||{run:[],all:[]},n=g.all.length,name=pname(key);
|
|
272
|
+
if(g.run.length)return toast(`${name} has ${g.run.length} running chat${g.run.length>1?"s":""} – stop ${g.run.length>1?"them":"it"} first`,4000);
|
|
273
|
+
if(!confirm(n?`Delete the project "${name}"?\n\nThis permanently deletes its ${n} saved chat${n>1?"s":""} (${mb(g.all.reduce((a,x)=>a+x.size,0))}).\n\nThe folder ${key} and your files in it are not touched.`
|
|
274
|
+
:`Remove "${name}" from the list?\n\nThe folder ${key} isn't touched.`))return;
|
|
275
|
+
const r=await getJ("/api/project/delete",{project:key});
|
|
276
|
+
toast(r.ok?(r.deleted?`Deleted ${name} and its ${r.deleted} chat${r.deleted>1?"s":""}`:`Removed ${name}`):"Couldn't delete: "+r.error,4000);refresh(true)}
|
|
248
277
|
async function resume(sid){await opened(await getJ("/api/open",{sid}),"Opening in a new terminal tab…")}
|
|
249
278
|
|
|
250
279
|
/* viewer */
|
|
@@ -290,6 +319,11 @@ document.addEventListener("click",async e=>{if(e.target.classList.contains("ren"
|
|
|
290
319
|
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
320
|
else if(d.mode){store.set("mode",d.mode);cur=null;render()}
|
|
292
321
|
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))}
|
|
322
|
+
else if(d.pdel){await deleteProject(d.pdel)}
|
|
323
|
+
else if(t.id==="addBtn"){showAdd($("addProj").hidden)}
|
|
324
|
+
else if(t.id==="porderReset"){porder=[];store.set("porder","[]");render();toast("Projects back in the automatic order")}
|
|
325
|
+
else if(t.id==="apOk"){await addProject()}
|
|
326
|
+
else if(t.id==="apCancel"){showAdd(false)}
|
|
293
327
|
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
328
|
else if(d.open){await resume(d.open)}
|
|
295
329
|
else if(d.del){await del([d.del])}
|
|
@@ -337,6 +371,19 @@ document.addEventListener("keydown",async e=>{const typing=/INPUT|SELECT|TEXTARE
|
|
|
337
371
|
else return;
|
|
338
372
|
render();const r=document.querySelector(`[data-row="${cur}"]`);if(r)r.scrollIntoView({block:"nearest"})}catch(err){toast("Something went wrong: "+err.message)}});
|
|
339
373
|
$("q").addEventListener("input",onSearch);
|
|
374
|
+
/* dragging a project's header moves the project; refreshes wait until it's dropped (see pressing) */
|
|
375
|
+
let dragKey=null;const psecs=()=>[...document.querySelectorAll(".psec")],clearDrop=()=>psecs().forEach(e=>e.classList.remove("dropBefore","dropAfter"));
|
|
376
|
+
const below=(el,e)=>{const r=el.getBoundingClientRect();return e.clientY>r.top+r.height/2};
|
|
377
|
+
$("list").addEventListener("dragstart",e=>{const h=e.target.closest&&e.target.closest(".phead");if(!h)return;dragKey=h.dataset.ptoggle;pressing=true;
|
|
378
|
+
e.dataTransfer.effectAllowed="move";e.dataTransfer.setData("text/plain",pname(dragKey));h.parentElement.classList.add("dragging")});
|
|
379
|
+
$("list").addEventListener("dragover",e=>{const el=dragKey&&e.target.closest(".psec");if(!el)return;e.preventDefault();clearDrop();
|
|
380
|
+
if(el.dataset.pkey!==dragKey)el.classList.add(below(el,e)?"dropAfter":"dropBefore")});
|
|
381
|
+
$("list").addEventListener("drop",e=>{const el=dragKey&&e.target.closest(".psec");if(!el)return;e.preventDefault();
|
|
382
|
+
const keys=groups().map(g=>g.key).filter(k=>k!==dragKey),i=keys.indexOf(el.dataset.pkey);
|
|
383
|
+
if(i>=0){keys.splice(below(el,e)?i+1:i,0,dragKey);porder=[...keys,...porder.filter(k=>!keys.includes(k))];store.set("porder",JSON.stringify(porder))}
|
|
384
|
+
dragKey=null;render()});
|
|
385
|
+
$("list").addEventListener("dragend",()=>{dragKey=null;clearDrop();psecs().forEach(e=>e.classList.remove("dragging"));pressing=false;flush()});
|
|
386
|
+
$("apPath").addEventListener("keydown",e=>{e.stopPropagation();if(e.key==="Enter")addProject().catch(err=>toast("Something went wrong: "+err.message));if(e.key==="Escape")showAdd(false)});
|
|
340
387
|
$("sort").value=store.get("sort","recent");applyTheme();applySound();
|
|
341
388
|
refresh(true);setInterval(refresh,4000);
|
|
342
389
|
</script></body></html>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The index reads each file incrementally: it remembers how far it got and only parses
|
|
4
4
|
lines appended since, so a 30 MB chat that grows by one message costs one message."""
|
|
5
|
-
import json, os, re, threading
|
|
5
|
+
import datetime, json, os, re, threading
|
|
6
6
|
|
|
7
7
|
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
8
8
|
TAG = re.compile(r"<(system-reminder|local-command-[a-z]+|command-[a-z]+)>.*?</\1>", re.S)
|
|
@@ -19,6 +19,11 @@ def _user_text(c):
|
|
|
19
19
|
return None
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _secs(ts):
|
|
23
|
+
try: return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
|
|
24
|
+
except (AttributeError, ValueError): return None
|
|
25
|
+
|
|
26
|
+
|
|
22
27
|
def _assistant_text(c):
|
|
23
28
|
if not isinstance(c, list): return ""
|
|
24
29
|
return "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text").strip()
|
|
@@ -26,13 +31,14 @@ def _assistant_text(c):
|
|
|
26
31
|
|
|
27
32
|
class Entry:
|
|
28
33
|
__slots__ = ("ino", "offset", "size", "mtime", "custom", "ai", "summary", "first", "cwd", "ctx", "msgs",
|
|
29
|
-
"days", "seen", "cost", "text", "text_lc", "cwds")
|
|
34
|
+
"days", "seen", "cost", "text", "text_lc", "cwds", "active")
|
|
30
35
|
|
|
31
36
|
def __init__(self, ino):
|
|
32
37
|
self.ino = ino; self.offset = 0; self.size = 0; self.mtime = 0.0
|
|
33
38
|
self.custom = self.ai = self.summary = self.first = self.cwd = None
|
|
34
39
|
self.ctx = self.msgs = 0; self.days = {}; self.seen = set(); self.cost = 0.0
|
|
35
40
|
self.text = []; self.text_lc = ""; self.cwds = {} # folder -> lines written there, to tell where the chat mostly worked
|
|
41
|
+
self.active = None # when the person last typed a message. Not mtime: idle chats keep appending bookkeeping lines
|
|
36
42
|
|
|
37
43
|
@property
|
|
38
44
|
def title(self):
|
|
@@ -54,6 +60,7 @@ class Entry:
|
|
|
54
60
|
txt = _user_text(m.get("content"))
|
|
55
61
|
if txt:
|
|
56
62
|
self.msgs += 1; self.first = self.first or txt.replace("\n", " ")[:90]
|
|
63
|
+
self.active = _secs(d.get("timestamp")) or self.active
|
|
57
64
|
self._add_text(txt)
|
|
58
65
|
elif t == "assistant":
|
|
59
66
|
u = m.get("usage") or {}
|
|
@@ -19,8 +19,11 @@ class LimitHistory:
|
|
|
19
19
|
self.path = path; self.keep = keep; self.samples = []; self.lock = threading.Lock()
|
|
20
20
|
try:
|
|
21
21
|
with open(path) as f:
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
for l in f:
|
|
23
|
+
try: s = json.loads(l)
|
|
24
|
+
except ValueError: continue
|
|
25
|
+
if isinstance(s, dict) and all(k in s for k in "wtpr"): self.samples.append(s)
|
|
26
|
+
except OSError:
|
|
24
27
|
pass
|
|
25
28
|
|
|
26
29
|
def add(self, at, data):
|
|
@@ -58,12 +61,13 @@ class LimitHistory:
|
|
|
58
61
|
s = [x for x in self.samples if x["w"] == key]
|
|
59
62
|
if not s: return None
|
|
60
63
|
cur = s[-1]
|
|
64
|
+
if now >= cur["r"]: return {"eta": None, "rate": None} # that window has already reset
|
|
61
65
|
win = [x for x in s if x["r"] == cur["r"] and x["t"] >= cur["t"] - lookback]
|
|
62
66
|
if len(win) < 2 or cur["t"] - win[0]["t"] < 900: return {"eta": None, "rate": None}
|
|
63
67
|
rate = (cur["p"] - win[0]["p"]) / (cur["t"] - win[0]["t"]) # % per second
|
|
64
68
|
if rate <= 0: return {"eta": None, "rate": 0}
|
|
65
69
|
eta = cur["t"] + (100 - cur["p"]) / rate
|
|
66
|
-
return {"eta": eta if eta < cur["r"] else None, "rate": round(rate * 3600, 1)}
|
|
70
|
+
return {"eta": eta if now <= eta < cur["r"] else None, "rate": round(rate * 3600, 1)}
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
def summary(entries, status_dir, history, today=None, project=None):
|
|
@@ -84,7 +88,7 @@ def summary(entries, status_dir, history, today=None, project=None):
|
|
|
84
88
|
try:
|
|
85
89
|
with open(f) as fh: d = json.load(fh)
|
|
86
90
|
at = os.path.getmtime(f)
|
|
87
|
-
if at > now - 86400: cost += float((d.get("cost") or {}).get("total_cost_usd") or 0)
|
|
91
|
+
if at > now - 86400: cost += float((d.get("cost") or {}).get("total_cost_usd") or 0) # lifetime cost; status files keep no history
|
|
88
92
|
if d.get("rate_limits"):
|
|
89
93
|
limits = {"data": d["rate_limits"], "at": at}
|
|
90
94
|
history.add(at, d["rate_limits"])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatdeck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A local dashboard for your Claude Code chats: what's running, what needs you, and what it costs.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Abdifatah09/chatdeck
|
|
@@ -37,7 +37,10 @@ Python 3.9+ standard library only, no dependencies.
|
|
|
37
37
|
- **Cleanup** – delete empty chats or chats older than 7, 30 or 90 days. Shows disk usage.
|
|
38
38
|
Running chats are never deleted.
|
|
39
39
|
- **By project** view: one section per project (git repo, including its worktrees and subfolders)
|
|
40
|
-
with its running and saved chats, and a **New chat** button that starts Claude Code in that project.
|
|
40
|
+
with its running and saved chats, and a **New chat** button that starts Claude Code in that project.
|
|
41
|
+
**Add project** lists a folder before it has any chats; **Delete project** deletes its saved chats
|
|
42
|
+
and takes it off the list (the folder itself is never touched). Drag a project's header to move it
|
|
43
|
+
(**Reset order** goes back to the automatic order). Sort by recent, size, cost or messages. Light and dark themes.
|
|
41
44
|
Keyboard shortcuts: `/` search, `j`/`k` move, `Enter` read, `r` resume, `e` rename, `x` select, `Del` delete, `?` help.
|
|
42
45
|
- **Windows corner button and tray icon** – pop the dashboard up from the corner of the screen.
|
|
43
46
|
|
|
@@ -26,4 +26,7 @@ src/chatdeck/static/logo.svg
|
|
|
26
26
|
src/chatdeck/windows/bubble.ps1
|
|
27
27
|
src/chatdeck/windows/focus_wt.ps1
|
|
28
28
|
src/chatdeck/windows/start.ps1
|
|
29
|
-
tests/test_chatdeck.py
|
|
29
|
+
tests/test_chatdeck.py
|
|
30
|
+
tests/test_delete_safety.py
|
|
31
|
+
tests/test_hook_quoting.py
|
|
32
|
+
tests/test_usage_fixes.py
|
|
@@ -64,6 +64,15 @@ class IndexTest(Tmp):
|
|
|
64
64
|
self.assertEqual(e.days["2026-10-20"], [100, 7, 50, 3])
|
|
65
65
|
self.assertEqual(e.ctx, 153)
|
|
66
66
|
|
|
67
|
+
def test_active_is_when_you_last_typed_not_when_the_file_changed(self):
|
|
68
|
+
self.write(user("hello") + assistant([{"type": "text", "text": "hi"}], ts="2026-10-20T10:05:00Z"))
|
|
69
|
+
e = self.idx.get(self.path); typed = e.active
|
|
70
|
+
self.assertEqual(typed, 1792490400.0)
|
|
71
|
+
self.write(line(type="system", subtype="away_summary", isMeta=True, timestamp="2026-10-20T10:08:00Z")
|
|
72
|
+
+ line(type="artifact-autoreact-ledger", sessionId=SID)
|
|
73
|
+
+ line(type="user", message={"content": [{"type": "tool_result", "content": "ok"}]}, timestamp="2026-10-20T10:09:00Z"))
|
|
74
|
+
self.assertEqual(self.idx.get(self.path).active, typed)
|
|
75
|
+
|
|
67
76
|
def test_search_needs_every_word(self):
|
|
68
77
|
self.write(user("deploy is slow") + assistant([{"type": "text", "text": "The Docker build cache was cold."}]))
|
|
69
78
|
self.assertIn(SID, self.idx.search("docker cold"))
|
|
@@ -112,11 +121,11 @@ class ForecastTest(unittest.TestCase):
|
|
|
112
121
|
return h
|
|
113
122
|
|
|
114
123
|
def test_eta_from_pace(self):
|
|
115
|
-
f = self.hist([(0, 10), (1800, 20)]).forecast("five_hour") # 10% per 30 min
|
|
124
|
+
f = self.hist([(0, 10), (1800, 20)]).forecast("five_hour", T0 + 1800) # 10% per 30 min
|
|
116
125
|
self.assertEqual(f["rate"], 20.0); self.assertAlmostEqual(f["eta"], T0 + 1800 + 4 * 3600)
|
|
117
126
|
|
|
118
127
|
def test_no_eta_when_reset_comes_first(self):
|
|
119
|
-
f = self.hist([(0, 10), (1800, 20)], reset=5000).forecast("five_hour")
|
|
128
|
+
f = self.hist([(0, 10), (1800, 20)], reset=5000).forecast("five_hour", T0 + 1800)
|
|
120
129
|
self.assertIsNone(f["eta"]); self.assertGreater(f["rate"], 0)
|
|
121
130
|
|
|
122
131
|
def test_needs_enough_history_and_ignores_old_window(self):
|
|
@@ -124,7 +133,7 @@ class ForecastTest(unittest.TestCase):
|
|
|
124
133
|
h = self.hist([(0, 90)], reset=100)
|
|
125
134
|
h.add(T0 + 1000, {"five_hour": {"used_percentage": 1, "resets_at": T0 + 20000}})
|
|
126
135
|
h.add(T0 + 2000, {"five_hour": {"used_percentage": 1, "resets_at": T0 + 20000}})
|
|
127
|
-
self.assertEqual(h.forecast("five_hour")["rate"], 0)
|
|
136
|
+
self.assertEqual(h.forecast("five_hour", T0 + 2000)["rate"], 0)
|
|
128
137
|
|
|
129
138
|
def test_history_survives_restart(self):
|
|
130
139
|
h = self.hist([(0, 10), (1800, 20)])
|
|
@@ -303,6 +312,46 @@ class NewChatTest(Tmp):
|
|
|
303
312
|
self.assertEqual(server.new_chat("/etc")["error"], "unknown project")
|
|
304
313
|
|
|
305
314
|
|
|
315
|
+
class ProjectsTest(Tmp):
|
|
316
|
+
def test_added_folder_is_listed_and_can_start_chats(self):
|
|
317
|
+
from chatdeck import server
|
|
318
|
+
proj = tempfile.mkdtemp(); launched = []
|
|
319
|
+
with unittest.mock.patch.object(server, "INDEX", self.idx), unittest.mock.patch.object(server, "agents", lambda fresh=False: {}), \
|
|
320
|
+
unittest.mock.patch.object(server.terminals, "launch", lambda cwd, cmd: launched.append((cwd, cmd)) or True):
|
|
321
|
+
self.assertIn("no folder", server.add_project("/no/such/dir")["error"])
|
|
322
|
+
r = server.add_project(proj); key = r["project"]
|
|
323
|
+
self.assertTrue(r["ok"]); self.assertIn(key, server.state()["projects"])
|
|
324
|
+
self.assertTrue(server.add_project(proj)["already"])
|
|
325
|
+
self.assertTrue(server.new_chat(key)["ok"]); self.assertEqual(launched, [(proj, "claude")])
|
|
326
|
+
self.assertTrue(server.delete_project(key)["ok"])
|
|
327
|
+
self.assertNotIn(key, server.state()["projects"]); self.assertTrue(os.path.isdir(proj)) # folder kept
|
|
328
|
+
|
|
329
|
+
def test_delete_removes_its_chats_but_not_while_one_runs(self):
|
|
330
|
+
from chatdeck import server
|
|
331
|
+
proj = tempfile.mkdtemp(); running = {SID: {"cwd": proj}}
|
|
332
|
+
self.write(line(type="user", message={"role": "user", "content": "hi"}, cwd=proj))
|
|
333
|
+
with unittest.mock.patch.object(server, "INDEX", self.idx), unittest.mock.patch.object(server, "PROJECTS", self.dir), \
|
|
334
|
+
unittest.mock.patch.object(server, "agents", lambda fresh=False: running):
|
|
335
|
+
key = server.project(proj)
|
|
336
|
+
self.assertIn("running", server.delete_project(key)["error"]); self.assertTrue(os.path.exists(self.path))
|
|
337
|
+
running.clear()
|
|
338
|
+
self.assertEqual(server.delete_project(key), {"ok": True, "deleted": 1})
|
|
339
|
+
self.assertFalse(os.path.exists(self.path)); self.assertTrue(os.path.isdir(proj))
|
|
340
|
+
self.assertEqual(server.delete_project(key)["error"], "unknown project")
|
|
341
|
+
|
|
342
|
+
def test_deleting_every_chat_leaves_the_project_listed_empty(self):
|
|
343
|
+
from chatdeck import server
|
|
344
|
+
proj = tempfile.mkdtemp()
|
|
345
|
+
self.write(line(type="user", message={"role": "user", "content": "hi"}, cwd=proj))
|
|
346
|
+
with unittest.mock.patch.object(server, "INDEX", self.idx), unittest.mock.patch.object(server, "PROJECTS", self.dir), \
|
|
347
|
+
unittest.mock.patch.object(server, "agents", lambda fresh=False: {}):
|
|
348
|
+
key = server.project(proj)
|
|
349
|
+
self.assertEqual(server.delete([SID])["deleted"], [SID])
|
|
350
|
+
self.assertIn(key, server.state()["projects"])
|
|
351
|
+
self.assertEqual(server.delete_project(key), {"ok": True, "deleted": 0})
|
|
352
|
+
self.assertNotIn(key, server.state()["projects"])
|
|
353
|
+
|
|
354
|
+
|
|
306
355
|
class WslLaunchTest(unittest.TestCase):
|
|
307
356
|
def test_new_window_by_default_tabs_when_asked(self):
|
|
308
357
|
from chatdeck import terminals
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json, os, subprocess, sys, tempfile, unittest, unittest.mock
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
4
|
+
os.environ["CHATDECK_DIR"] = tempfile.mkdtemp() # never touch the real ~/.chatdeck
|
|
5
|
+
from chatdeck import config, server
|
|
6
|
+
|
|
7
|
+
SID = "11111111-2222-3333-4444-555555555555"
|
|
8
|
+
RUNNING = json.dumps([{"sessionId": SID, "pid": 1}])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def done(code, out=""): return subprocess.CompletedProcess([], code, out, "")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeleteSafetyTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.dir = tempfile.mkdtemp(); os.makedirs(os.path.join(self.dir, "-w-proj"))
|
|
17
|
+
self.path = os.path.join(self.dir, "-w-proj", SID + ".jsonl")
|
|
18
|
+
with open(self.path, "w") as f: f.write("{}\n")
|
|
19
|
+
for k, v in [("PROJECTS", self.dir), ("INDEX", server.transcripts.Index(self.dir))]:
|
|
20
|
+
p = unittest.mock.patch.object(server, k, v); p.start(); self.addCleanup(p.stop)
|
|
21
|
+
p = unittest.mock.patch.object(config, "DEMO", False); p.start(); self.addCleanup(p.stop)
|
|
22
|
+
server._agents = (0.0, {})
|
|
23
|
+
|
|
24
|
+
def run_delete(self, **kw):
|
|
25
|
+
with unittest.mock.patch.object(server.subprocess, "run", **kw): return server.delete([SID])
|
|
26
|
+
|
|
27
|
+
def test_nonzero_exit_refuses(self):
|
|
28
|
+
r = self.run_delete(return_value=done(1))
|
|
29
|
+
self.assertEqual((r["deleted"], r["refused"]), ([], [SID])); self.assertIn("couldn't check", r["error"])
|
|
30
|
+
self.assertTrue(os.path.exists(self.path))
|
|
31
|
+
|
|
32
|
+
def test_empty_stdout_refuses(self):
|
|
33
|
+
r = self.run_delete(return_value=done(0, ""))
|
|
34
|
+
self.assertEqual(r["refused"], [SID]); self.assertTrue(os.path.exists(self.path))
|
|
35
|
+
|
|
36
|
+
def test_timeout_refuses(self):
|
|
37
|
+
r = self.run_delete(side_effect=subprocess.TimeoutExpired("claude", 30))
|
|
38
|
+
self.assertEqual(r["refused"], [SID]); self.assertTrue(os.path.exists(self.path))
|
|
39
|
+
|
|
40
|
+
def test_failure_keeps_last_good_cache(self):
|
|
41
|
+
self.run_delete(return_value=done(0, RUNNING)); self.run_delete(return_value=done(1))
|
|
42
|
+
self.assertIn(SID, server.agents())
|
|
43
|
+
|
|
44
|
+
def test_running_chat_refused(self):
|
|
45
|
+
r = self.run_delete(return_value=done(0, RUNNING))
|
|
46
|
+
self.assertEqual(r["refused"], [SID]); self.assertNotIn("error", r); self.assertTrue(os.path.exists(self.path))
|
|
47
|
+
|
|
48
|
+
def test_deleted_when_check_succeeds_and_not_running(self):
|
|
49
|
+
r = self.run_delete(return_value=done(0, "[]"))
|
|
50
|
+
self.assertEqual((r["deleted"], r["refused"]), ([SID], [])); self.assertFalse(os.path.exists(self.path))
|
|
51
|
+
|
|
52
|
+
def test_delete_project_and_stop_refuse_on_failed_check(self):
|
|
53
|
+
with unittest.mock.patch.object(server.subprocess, "run", return_value=done(1)):
|
|
54
|
+
self.assertFalse(server.delete_project("/w/proj")["ok"])
|
|
55
|
+
self.assertFalse(server.stop(SID)["ok"])
|
|
56
|
+
self.assertTrue(os.path.exists(self.path))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__": unittest.main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import json, os, shlex, sys, tempfile, unittest, unittest.mock
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
4
|
+
os.environ["CHATDECK_DIR"] = tempfile.mkdtemp() # never touch the real ~/.chatdeck
|
|
5
|
+
from chatdeck import config, hooks
|
|
6
|
+
|
|
7
|
+
SPACED = "/Users/jo smith/.local/bin/chatdeck"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuotingTest(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.path = os.path.join(tempfile.mkdtemp(), "settings.json")
|
|
13
|
+
config.save({}) # other tests may have left a wrapped status line behind
|
|
14
|
+
|
|
15
|
+
def write(self, d):
|
|
16
|
+
with open(self.path, "w") as f: json.dump(d, f)
|
|
17
|
+
|
|
18
|
+
def read(self):
|
|
19
|
+
with open(self.path) as f: return json.load(f)
|
|
20
|
+
|
|
21
|
+
def old_install(self):
|
|
22
|
+
old = SPACED + " hook"
|
|
23
|
+
self.write({"statusLine": {"type": "command", "command": old + " statusline"},
|
|
24
|
+
"hooks": {"Notification": [{"matcher": hooks.WAITING_MATCHER,
|
|
25
|
+
"hooks": [{"type": "command", "command": old + " waiting"}]}]}})
|
|
26
|
+
|
|
27
|
+
def test_command_quotes_spaced_paths(self):
|
|
28
|
+
with unittest.mock.patch("shutil.which", return_value=SPACED):
|
|
29
|
+
self.assertEqual(shlex.split(hooks.command()), [SPACED])
|
|
30
|
+
with unittest.mock.patch("shutil.which", return_value=None), \
|
|
31
|
+
unittest.mock.patch.object(sys, "executable", "/opt/my py/python3"):
|
|
32
|
+
parts = shlex.split(hooks.command())
|
|
33
|
+
self.assertTrue(parts[0].startswith("PYTHONPATH="))
|
|
34
|
+
self.assertEqual(parts[1:], ["/opt/my py/python3", "-m", "chatdeck"])
|
|
35
|
+
|
|
36
|
+
def test_normal_path_unchanged(self):
|
|
37
|
+
with unittest.mock.patch("shutil.which", return_value="/usr/local/bin/chatdeck"):
|
|
38
|
+
self.assertEqual(hooks.command(), "/usr/local/bin/chatdeck")
|
|
39
|
+
|
|
40
|
+
def test_install_writes_quoted_commands_and_uninstall_removes(self):
|
|
41
|
+
with unittest.mock.patch("shutil.which", return_value=SPACED):
|
|
42
|
+
hooks.install(self.path)
|
|
43
|
+
s = self.read()
|
|
44
|
+
self.assertEqual(shlex.split(s["statusLine"]["command"]), [SPACED, "hook", "statusline"])
|
|
45
|
+
self.assertEqual(shlex.split(s["hooks"]["Notification"][0]["hooks"][0]["command"]), [SPACED, "hook", "waiting"])
|
|
46
|
+
hooks.uninstall(self.path)
|
|
47
|
+
self.assertEqual(self.read(), {})
|
|
48
|
+
|
|
49
|
+
def test_reinstall_over_old_unquoted_install(self):
|
|
50
|
+
self.old_install()
|
|
51
|
+
with unittest.mock.patch("shutil.which", return_value=SPACED):
|
|
52
|
+
hooks.install(self.path)
|
|
53
|
+
s = self.read()
|
|
54
|
+
self.assertEqual(shlex.split(s["statusLine"]["command"]), [SPACED, "hook", "statusline"])
|
|
55
|
+
self.assertEqual(len(s["hooks"]["Notification"]), 1)
|
|
56
|
+
self.assertEqual(len(s["hooks"]["Notification"][0]["hooks"]), 1)
|
|
57
|
+
self.assertNotIn("statusline_command", config.load()) # old install wasn't mistaken for the user's own
|
|
58
|
+
|
|
59
|
+
def test_uninstall_removes_old_unquoted_install(self):
|
|
60
|
+
self.old_install()
|
|
61
|
+
hooks.uninstall(self.path)
|
|
62
|
+
self.assertEqual(self.read(), {})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
unittest.main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json, os, sys, tempfile, time, unittest
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
4
|
+
os.environ["CHATDECK_DIR"] = tempfile.mkdtemp() # never touch the real ~/.chatdeck
|
|
5
|
+
from chatdeck import usage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
T = time.time() - 3600
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sample(t, p, r=10000, w="five_hour"): return {"t": t, "w": w, "p": p, "r": r}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HistoryTest(unittest.TestCase):
|
|
15
|
+
def path(self, *lines):
|
|
16
|
+
p = os.path.join(tempfile.mkdtemp(), "h.jsonl")
|
|
17
|
+
with open(p, "w") as f: f.write("\n".join(lines) + "\n")
|
|
18
|
+
return p
|
|
19
|
+
|
|
20
|
+
def test_corrupt_and_malformed_lines_skipped(self):
|
|
21
|
+
good = [sample(T + 1, 5), sample(T + 2, 6)]
|
|
22
|
+
h = usage.LimitHistory(self.path(json.dumps(good[0]), '{"t": 3, "w": "five', '{"w": "five_hour"}', "[1]", json.dumps(good[1])))
|
|
23
|
+
self.assertEqual(h.samples, good)
|
|
24
|
+
h.add(T + 5, {"five_hour": {"used_percentage": 7, "resets_at": 10000}}) # no KeyError
|
|
25
|
+
self.assertEqual(len(h.samples), 3)
|
|
26
|
+
|
|
27
|
+
def test_stale_window_has_no_eta(self):
|
|
28
|
+
h = usage.LimitHistory(self.path()); h.samples = [sample(0, 10), sample(3600, 20)]
|
|
29
|
+
self.assertEqual(h.forecast("five_hour", now=5000)["rate"], 10.0)
|
|
30
|
+
self.assertEqual(h.forecast("five_hour", now=10000), {"eta": None, "rate": None})
|
|
31
|
+
self.assertIsNone(h.forecast("five_hour", now=9000)["eta"]) # eta (36000) is past the reset
|
|
32
|
+
h.samples = [sample(0, 10, 99999), sample(3600, 20, 99999)]
|
|
33
|
+
self.assertEqual(h.forecast("five_hour", now=3700)["eta"], 3600 + 80 * 360)
|
|
34
|
+
self.assertIsNone(h.forecast("five_hour", now=3600 + 80 * 360 + 1)["eta"])
|
|
35
|
+
|
|
36
|
+
def test_cost_counts_only_recent_status_files(self):
|
|
37
|
+
d = tempfile.mkdtemp()
|
|
38
|
+
for name, cost, age in (("a", 1.5, 100), ("b", 2.0, 3 * 86400)):
|
|
39
|
+
p = os.path.join(d, name + ".json")
|
|
40
|
+
with open(p, "w") as f: json.dump({"cost": {"total_cost_usd": cost}}, f)
|
|
41
|
+
os.utime(p, (time.time() - age,) * 2)
|
|
42
|
+
h = usage.LimitHistory(os.path.join(d, "h.jsonl"))
|
|
43
|
+
self.assertEqual(usage.summary([], d, h)["cost_24h"], 1.5)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__": unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|