chatdeck 0.1.0__tar.gz → 0.2.0__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.
Files changed (31) hide show
  1. {chatdeck-0.1.0/src/chatdeck.egg-info → chatdeck-0.2.0}/PKG-INFO +6 -4
  2. {chatdeck-0.1.0 → chatdeck-0.2.0}/README.md +5 -3
  3. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/__init__.py +1 -1
  4. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/server.py +48 -7
  5. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/static/index.html +62 -15
  6. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/transcripts.py +9 -2
  7. {chatdeck-0.1.0 → chatdeck-0.2.0/src/chatdeck.egg-info}/PKG-INFO +6 -4
  8. {chatdeck-0.1.0 → chatdeck-0.2.0}/tests/test_chatdeck.py +49 -0
  9. {chatdeck-0.1.0 → chatdeck-0.2.0}/LICENSE +0 -0
  10. {chatdeck-0.1.0 → chatdeck-0.2.0}/pyproject.toml +0 -0
  11. {chatdeck-0.1.0 → chatdeck-0.2.0}/setup.cfg +0 -0
  12. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/__main__.py +0 -0
  13. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/autostart.py +0 -0
  14. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/cli.py +0 -0
  15. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/config.py +0 -0
  16. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/demo.py +0 -0
  17. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/desktop.py +0 -0
  18. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/hooks.py +0 -0
  19. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/static/logo-192.png +0 -0
  20. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/static/logo-512.png +0 -0
  21. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/static/logo.ico +0 -0
  22. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/static/logo.svg +0 -0
  23. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/terminals.py +0 -0
  24. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/usage.py +0 -0
  25. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/windows/bubble.ps1 +0 -0
  26. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/windows/focus_wt.ps1 +0 -0
  27. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck/windows/start.ps1 +0 -0
  28. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck.egg-info/SOURCES.txt +0 -0
  29. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck.egg-info/dependency_links.txt +0 -0
  30. {chatdeck-0.1.0 → chatdeck-0.2.0}/src/chatdeck.egg-info/entry_points.txt +0 -0
  31. {chatdeck-0.1.0 → chatdeck-0.2.0}/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.1.0
3
+ Version: 0.2.0
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,15 +37,17 @@ 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. Sort by recent, size, cost or messages. Light and dark themes.
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
 
44
47
  ## Install
45
48
 
46
49
  ```sh
47
- pipx install chatdeck # once it's on PyPI; until then:
48
- pipx install git+https://github.com/Abdifatah09/chatdeck
50
+ pipx install chatdeck # or: pip install chatdeck
49
51
  ```
50
52
 
51
53
  No pip? Clone the repo and run `./install.sh`. It puts a `chatdeck` command in `~/.local/bin`
@@ -19,15 +19,17 @@ 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. Sort by recent, size, cost or messages. Light and dark themes.
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
 
26
29
  ## Install
27
30
 
28
31
  ```sh
29
- pipx install chatdeck # once it's on PyPI; until then:
30
- pipx install git+https://github.com/Abdifatah09/chatdeck
32
+ pipx install chatdeck # or: pip install chatdeck
31
33
  ```
32
34
 
33
35
  No pip? Clone the repo and run `./install.sh`. It puts a `chatdeck` command in `~/.local/bin`
@@ -1,2 +1,2 @@
1
1
  """chatdeck: a local dashboard for Claude Code chats."""
2
- __version__ = "0.1.0"
2
+ __version__ = "0.2.0"
@@ -1,7 +1,7 @@
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
6
  import json, os, glob, secrets, shlex, signal, subprocess, threading, time
7
7
  from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
@@ -139,16 +139,16 @@ def state():
139
139
  a = act.get(sid, {})
140
140
  out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or e.title or "(empty)",
141
141
  "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),
142
+ "ctx": e.ctx, "msgs": e.msgs, "mtime": e.mtime, "active": e.active or e.mtime, "size": e.size, "cost": round(e.cost, 2),
143
143
  "status": a.get("status"), "kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
144
144
  for sid, a in act.items(): # running but no transcript yet
145
145
  if sid not in chats:
146
146
  out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or "(new chat)", "cwd": (a.get("cwd") or "").replace(HOME, "~"),
147
147
  "project": project(a.get("cwd")),
148
- "ctx": 0, "msgs": 0, "mtime": time.time(), "size": 0, "cost": 0, "status": a.get("status"),
148
+ "ctx": 0, "msgs": 0, "mtime": time.time(), "active": time.time(), "size": 0, "cost": 0, "status": a.get("status"),
149
149
  "kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
150
150
  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)}
151
+ return {"chats": out, "projects": added_projects(), "now": time.time(), "usage": usage.summary([e for _, e in chats.values()], STATUS, LIMITS, project=chat_project)}
152
152
 
153
153
  def manual(cwd, command):
154
154
  """What to paste into a terminal when no terminal could be opened for the person."""
@@ -176,16 +176,20 @@ def transcript(sid):
176
176
  return hits[0]
177
177
 
178
178
  def delete(sids):
179
- act = agents(fresh=True); done, refused = [], []
179
+ """Delete saved chats. Their projects stay listed, empty, until the project itself is deleted."""
180
+ act = agents(fresh=True); done, refused = [], []; kept = added_projects(); keep = False
180
181
  for sid in sids:
181
182
  p = transcript(sid)
182
183
  if not p or sid in act:
183
184
  refused.append(sid); continue
185
+ e = INDEX.get(p); proj = chat_project(e)
186
+ if proj and proj not in kept: kept[proj] = e.active or e.mtime; keep = True
184
187
  os.remove(p); INDEX.forget(p)
185
188
  side = p[:-6] # sibling folder with this chat's tool output / subagents
186
189
  if os.path.isdir(side):
187
190
  subprocess.run(["rm", "-rf", "--", side])
188
191
  done.append(sid)
192
+ if keep: _set_added(kept)
189
193
  return {"deleted": done, "refused": refused}
190
194
 
191
195
  def _demo_rename(sid, title):
@@ -260,11 +264,46 @@ def open_chat(sid, view=False):
260
264
  if terminals.launch(cwd, inner): return {"ok": True}
261
265
  return {"ok": False, "copy": manual(cwd, inner)}
262
266
 
267
+ # Projects added by hand ({project: when added}), so a folder shows up before it has any chats.
268
+ def added_projects():
269
+ return config.load().get("projects") or {}
270
+
271
+ def _set_added(projects):
272
+ cfg = config.load(); cfg["projects"] = projects; config.save(cfg)
273
+
274
+ def known_projects():
275
+ return ({chat_project(e) for _, e in INDEX.all().values()} | {project(a.get("cwd")) for a in agents().values()}
276
+ | set(added_projects()))
277
+
278
+ def add_project(path):
279
+ """List a folder as a project. A folder inside a git repo adds the repo, like its chats would."""
280
+ path = str(path or "").strip()
281
+ if not path: return {"ok": False, "error": "type the folder's path"}
282
+ full = os.path.abspath(os.path.expanduser(path))
283
+ if not os.path.isdir(full): return {"ok": False, "error": f"there's no folder at {path}"}
284
+ proj = project(full)
285
+ if proj in known_projects(): return {"ok": True, "project": proj, "already": True}
286
+ _set_added(dict(added_projects(), **{proj: time.time()}))
287
+ return {"ok": True, "project": proj}
288
+
289
+ def delete_project(proj):
290
+ """Take a project off the list: delete its saved chats and forget it was added. Its folder is never touched.
291
+ Refused while any of its chats are running, so nothing is half-deleted."""
292
+ act = agents(fresh=True)
293
+ chats = INDEX.all()
294
+ 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()):
295
+ return {"ok": False, "error": "it has running chats - stop them first"}
296
+ sids = [sid for sid, (_, e) in chats.items() if chat_project(e) == proj]
297
+ added = added_projects()
298
+ if not sids and proj not in added: return {"ok": False, "error": "unknown project"}
299
+ r = delete(sids); added = added_projects() # re-read: delete() keeps the emptied project listed
300
+ if proj in added: del added[proj]; _set_added(added)
301
+ return {"ok": True, "deleted": len(r["deleted"])}
302
+
263
303
  def new_chat(proj):
264
304
  """Start a new Claude Code chat in a project folder. Only folders chatdeck lists as projects are allowed."""
265
305
  if config.DEMO: return DEMO_OFF
266
- known = {chat_project(e) for _, e in INDEX.all().values()} | {project(a.get("cwd")) for a in agents().values()}
267
- if proj not in known: return {"ok": False, "error": "unknown project"}
306
+ if proj not in known_projects(): return {"ok": False, "error": "unknown project"}
268
307
  cwd = os.path.join(HOME, proj[2:]) if proj.startswith("~/") else HOME if proj == "~" else proj
269
308
  if not os.path.isdir(cwd): return {"ok": False, "error": f"{proj} doesn't exist any more"}
270
309
  if terminals.launch(cwd, "claude"): return {"ok": True}
@@ -328,6 +367,8 @@ class H(BaseHTTPRequestHandler):
328
367
  if self.path == "/api/compact": return self.send(200, compact(body.get("sid", "")))
329
368
  if self.path == "/api/open": return self.send(200, open_chat(body.get("sid", ""), body.get("view", False)))
330
369
  if self.path == "/api/new": return self.send(200, new_chat(body.get("project", "")))
370
+ if self.path == "/api/project/add": return self.send(200, add_project(body.get("path", "")))
371
+ if self.path == "/api/project/delete": return self.send(200, delete_project(body.get("project", "")))
331
372
  if self.path == "/api/open-running": return self.send(200, open_running(body.get("sid", "")))
332
373
  self.send(404, {"error": "not found"})
333
374
 
@@ -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 .btn{align-self:center;margin-left:auto}
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 &amp; 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 &amp; 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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"};
@@ -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.mtime,size:x=>-x.size,cost:x=>-x.cost,msgs:x=>-x.msgs}[$("sort").value];return L.sort((a,b)=>by(a)-by(b))}
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 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)}
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.waiting-!!a.waiting);
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.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))}
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.mtime)}</div>
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.mtime>d*86400);
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
- return `<div class="psec${closed?" closed":""}"><div class="phead" data-ptoggle="${esc(g.key)}" title="${closed?"Show":"Hide"} this project's chats">
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} 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}
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)}}
@@ -245,6 +258,22 @@ async function del(sids){if(!sids.length)return false;if(!confirm(`Delete ${sids
245
258
  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
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 {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatdeck
3
- Version: 0.1.0
3
+ Version: 0.2.0
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,15 +37,17 @@ 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. Sort by recent, size, cost or messages. Light and dark themes.
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
 
44
47
  ## Install
45
48
 
46
49
  ```sh
47
- pipx install chatdeck # once it's on PyPI; until then:
48
- pipx install git+https://github.com/Abdifatah09/chatdeck
50
+ pipx install chatdeck # or: pip install chatdeck
49
51
  ```
50
52
 
51
53
  No pip? Clone the repo and run `./install.sh`. It puts a `chatdeck` command in `~/.local/bin`
@@ -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"))
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes