chatdeck 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ """Reading Claude Code transcripts (~/.claude/projects/<project>/<session>.jsonl).
2
+
3
+ The index reads each file incrementally: it remembers how far it got and only parses
4
+ lines appended since, so a 30 MB chat that grows by one message costs one message."""
5
+ import json, os, re, threading
6
+
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
+ TAG = re.compile(r"<(system-reminder|local-command-[a-z]+|command-[a-z]+)>.*?</\1>", re.S)
9
+
10
+
11
+ def _user_text(c):
12
+ """Text the person typed, or None for tool results, command output and injected context."""
13
+ if isinstance(c, str):
14
+ return None if c.lstrip().startswith("<") else c.strip() or None
15
+ if isinstance(c, list):
16
+ parts = [b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text"]
17
+ t = TAG.sub("", "\n".join(parts)).strip()
18
+ return t or None
19
+ return None
20
+
21
+
22
+ def _assistant_text(c):
23
+ if not isinstance(c, list): return ""
24
+ return "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text").strip()
25
+
26
+
27
+ class Entry:
28
+ __slots__ = ("ino", "offset", "size", "mtime", "custom", "ai", "summary", "first", "cwd", "ctx", "msgs",
29
+ "days", "seen", "cost", "text", "text_lc", "cwds")
30
+
31
+ def __init__(self, ino):
32
+ self.ino = ino; self.offset = 0; self.size = 0; self.mtime = 0.0
33
+ self.custom = self.ai = self.summary = self.first = self.cwd = None
34
+ self.ctx = self.msgs = 0; self.days = {}; self.seen = set(); self.cost = 0.0
35
+ self.text = []; self.text_lc = ""; self.cwds = {} # folder -> lines written there, to tell where the chat mostly worked
36
+
37
+ @property
38
+ def title(self):
39
+ return self.custom or self.ai or self.summary or self.first
40
+
41
+ def feed(self, d):
42
+ t = d.get("type")
43
+ if t == "custom-title" and d.get("customTitle"): self.custom = d["customTitle"]
44
+ elif t == "ai-title" and d.get("aiTitle"): self.ai = d["aiTitle"]
45
+ elif t == "summary" and d.get("summary"): self.summary = d["summary"]
46
+ elif t == "cost-state": self.cost = float(d.get("totalCostUSD") or 0)
47
+ elif t == "system" and d.get("subtype") == "compact_boundary": # context shrinks right away, not at the next reply
48
+ self.ctx = (d.get("compactMetadata") or {}).get("postTokens") or self.ctx
49
+ if d.get("cwd"):
50
+ self.cwd = d["cwd"]; self.cwds[self.cwd] = self.cwds.get(self.cwd, 0) + 1
51
+ if d.get("isSidechain") or d.get("isMeta"): return
52
+ m = d.get("message") or {}
53
+ if t == "user":
54
+ txt = _user_text(m.get("content"))
55
+ if txt:
56
+ self.msgs += 1; self.first = self.first or txt.replace("\n", " ")[:90]
57
+ self._add_text(txt)
58
+ elif t == "assistant":
59
+ u = m.get("usage") or {}
60
+ v = u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0) + u.get("cache_creation_input_tokens", 0)
61
+ if v: self.ctx = v
62
+ mid = m.get("id"); ts = d.get("timestamp", "")
63
+ if u and mid and mid not in self.seen and len(ts) >= 10:
64
+ self.seen.add(mid); day = self.days.setdefault(ts[:10], [0, 0, 0, 0])
65
+ day[0] += u.get("input_tokens", 0); day[1] += u.get("output_tokens", 0)
66
+ day[2] += u.get("cache_read_input_tokens", 0); day[3] += u.get("cache_creation_input_tokens", 0)
67
+ txt = _assistant_text(m.get("content"))
68
+ if txt: self._add_text(txt)
69
+
70
+ def _add_text(self, s):
71
+ self.text.append(s); self.text_lc += s.lower() + "\n"
72
+
73
+ def info(self):
74
+ return {"title": self.title, "cwd": self.cwd, "ctx": self.ctx, "msgs": self.msgs, "days": self.days,
75
+ "cost": round(self.cost, 2), "size": self.size, "mtime": self.mtime}
76
+
77
+
78
+ class Index:
79
+ def __init__(self, root):
80
+ self.root = root; self.entries = {}; self.lock = threading.Lock()
81
+
82
+ def files(self):
83
+ out = {}
84
+ try:
85
+ for proj in os.scandir(self.root):
86
+ if not proj.is_dir(): continue
87
+ for f in os.scandir(proj.path):
88
+ if f.name.endswith(".jsonl") and UUID.match(f.name[:-6]): out[f.name[:-6]] = f.path
89
+ except FileNotFoundError:
90
+ pass
91
+ return out
92
+
93
+ def get(self, path):
94
+ """The entry for a transcript, reading only what was appended since last time."""
95
+ with self.lock:
96
+ st = os.stat(path); e = self.entries.get(path)
97
+ if e is None or e.ino != st.st_ino or st.st_size < e.offset: # new, replaced or truncated
98
+ e = self.entries[path] = Entry(st.st_ino)
99
+ if st.st_size > e.offset:
100
+ with open(path, "rb") as f:
101
+ f.seek(e.offset); chunk = f.read(st.st_size - e.offset)
102
+ end = chunk.rfind(b"\n") + 1 # leave a half-written last line for next time
103
+ for line in chunk[:end].splitlines():
104
+ try: e.feed(json.loads(line))
105
+ except Exception: pass
106
+ e.offset += end
107
+ e.size = st.st_size; e.mtime = st.st_mtime
108
+ return e
109
+
110
+ def all(self):
111
+ """{sid: (path, Entry)} for every transcript; forgets deleted ones."""
112
+ fs = self.files(); out = {}
113
+ for sid, p in fs.items():
114
+ try: out[sid] = (p, self.get(p))
115
+ except FileNotFoundError: pass
116
+ with self.lock:
117
+ for p in [p for p in self.entries if p not in set(fs.values())]: del self.entries[p]
118
+ return out
119
+
120
+ def forget(self, path):
121
+ with self.lock: self.entries.pop(path, None)
122
+
123
+ def search(self, q, limit=200):
124
+ """Chats whose messages contain every word of q, with a snippet around the first hit."""
125
+ words = q.lower().split()
126
+ if not words: return {}
127
+ hits = {}
128
+ for sid, (_, e) in self.all().items():
129
+ if all(w in e.text_lc for w in words):
130
+ hits[sid] = snippet(e, words[0])
131
+ if len(hits) >= limit: break
132
+ return hits
133
+
134
+
135
+ def snippet(e, word, pad=60):
136
+ for s in e.text:
137
+ i = s.lower().find(word)
138
+ if i >= 0:
139
+ a, b = max(0, i - pad), min(len(s), i + len(word) + pad)
140
+ return ("…" if a else "") + s[a:b].replace("\n", " ") + ("…" if b < len(s) else "")
141
+ return ""
142
+
143
+
144
+ def _tool_summary(name, inp):
145
+ if not isinstance(inp, dict): return ""
146
+ for k in ("command", "file_path", "pattern", "url", "query", "description", "prompt", "skill"):
147
+ if inp.get(k): return str(inp[k])[:300]
148
+ return json.dumps(inp)[:300]
149
+
150
+
151
+ def _result_text(c):
152
+ if isinstance(c, str): return c
153
+ if isinstance(c, list):
154
+ return "\n".join(b.get("text", "") if b.get("type") == "text" else f"[{b.get('type')}]" for b in c if isinstance(b, dict))
155
+ return ""
156
+
157
+
158
+ def messages(path, max_items=1500, result_chars=1500):
159
+ """The conversation as a flat list of items for the viewer:
160
+ {"k": "user"|"assistant"|"tool"|"result"|"command"|"compact", "text": ..., "ts": ..., ...}"""
161
+ items = []
162
+ with open(path, "rb") as f:
163
+ for line in f:
164
+ try: d = json.loads(line)
165
+ except Exception: continue
166
+ if d.get("isSidechain") or d.get("isMeta"): continue
167
+ t = d.get("type"); ts = d.get("timestamp"); m = d.get("message") or {}; c = m.get("content")
168
+ if t == "system" and d.get("subtype") == "compact_boundary":
169
+ items.append({"k": "compact", "ts": ts})
170
+ elif t == "user":
171
+ if isinstance(c, str) and "<command-name>" in c:
172
+ cmd = re.search(r"<command-name>(.*?)</command-name>", c, re.S)
173
+ args = re.search(r"<command-args>(.*?)</command-args>", c, re.S)
174
+ if cmd: items.append({"k": "command", "text": (cmd.group(1) + " " + (args.group(1) if args else "")).strip(), "ts": ts})
175
+ continue
176
+ txt = _user_text(c)
177
+ if txt: items.append({"k": "user", "text": txt, "ts": ts})
178
+ if isinstance(c, list):
179
+ for b in c:
180
+ if isinstance(b, dict) and b.get("type") == "tool_result":
181
+ r = _result_text(b.get("content"))
182
+ items.append({"k": "result", "text": r[:result_chars], "cut": len(r) > result_chars,
183
+ "err": bool(b.get("is_error")), "ts": ts})
184
+ elif t == "assistant" and isinstance(c, list):
185
+ for b in c:
186
+ if not isinstance(b, dict): continue
187
+ if b.get("type") == "text" and b.get("text", "").strip():
188
+ items.append({"k": "assistant", "text": b["text"].strip(), "ts": ts})
189
+ elif b.get("type") == "tool_use":
190
+ items.append({"k": "tool", "name": b.get("name", ""), "text": _tool_summary(b.get("name"), b.get("input")), "ts": ts})
191
+ return {"items": items[-max_items:], "hidden": max(0, len(items) - max_items)}
192
+
193
+
194
+ def markdown(path, title):
195
+ out = [f"# {title}", ""]
196
+ for it in messages(path, max_items=10**9)["items"]:
197
+ k = it["k"]
198
+ if k == "user": out += ["## You", "", it["text"], ""]
199
+ elif k == "assistant": out += ["## Claude", "", it["text"], ""]
200
+ elif k == "tool":
201
+ cmd = it["text"].replace("`", "'")[:200]
202
+ out += [f"> **{it['name']}** `{cmd}`", ""]
203
+ elif k == "command": out += [f"> `{it['text']}`", ""]
204
+ elif k == "compact": out += ["---", "*conversation compacted*", ""]
205
+ return "\n".join(out)
206
+
207
+
208
+ def rename(path, sid, title):
209
+ """Give a saved chat a new name, the same way Claude Code's /rename records it."""
210
+ with open(path, "rb") as f:
211
+ f.seek(0, 2); ends_clean = True
212
+ if f.tell(): f.seek(-1, 2); ends_clean = f.read(1) == b"\n"
213
+ with open(path, "a") as f:
214
+ if not ends_clean: f.write("\n")
215
+ f.write(json.dumps({"type": "custom-title", "customTitle": title, "sessionId": sid}) + "\n")
216
+ f.write(json.dumps({"type": "agent-name", "agentName": title, "sessionId": sid}) + "\n")
chatdeck/usage.py ADDED
@@ -0,0 +1,96 @@
1
+ """Token usage from transcripts, plan limits from saved status-line payloads, and a forecast
2
+ of when each limit runs out at the current pace."""
3
+ import datetime, glob, json, os, threading, time
4
+
5
+ WINDOWS = {"five_hour": ("5-hour limit", 90 * 60), "seven_day": ("Weekly limit", 12 * 3600)} # key -> (label, lookback secs)
6
+
7
+
8
+ def _secs(t):
9
+ if isinstance(t, (int, float)): return t / 1000 if t > 1e12 else t
10
+ try: return datetime.datetime.fromisoformat(str(t).replace("Z", "+00:00")).timestamp()
11
+ except Exception: return None
12
+
13
+
14
+ class LimitHistory:
15
+ """Samples of (time, window, used %, reset time), kept in memory and in a small JSONL file
16
+ so the forecast survives restarts."""
17
+
18
+ def __init__(self, path, keep=8 * 86400):
19
+ self.path = path; self.keep = keep; self.samples = []; self.lock = threading.Lock()
20
+ try:
21
+ with open(path) as f:
22
+ self.samples = [json.loads(l) for l in f if l.strip()]
23
+ except (FileNotFoundError, ValueError):
24
+ pass
25
+
26
+ def add(self, at, data):
27
+ new = []
28
+ with self.lock:
29
+ for key in WINDOWS:
30
+ o = data.get(key) or {}
31
+ pct = o.get("used_percentage"); reset = _secs(o.get("resets_at"))
32
+ if pct is None or reset is None: continue
33
+ last = next((s for s in reversed(self.samples) if s["w"] == key), None)
34
+ if last and last["t"] >= at: continue
35
+ if last and last["p"] == pct and last["r"] == reset and at - last["t"] < 300: continue
36
+ new.append({"t": at, "w": key, "p": pct, "r": reset})
37
+ if not new: return
38
+ self.samples += new
39
+ cut = time.time() - self.keep
40
+ if self.samples[0]["t"] < cut:
41
+ self.samples = [s for s in self.samples if s["t"] >= cut]
42
+ self._write(self.samples, "w")
43
+ else:
44
+ self._write(new, "a")
45
+
46
+ def _write(self, rows, mode):
47
+ try:
48
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
49
+ with open(self.path, mode) as f:
50
+ f.writelines(json.dumps(r) + "\n" for r in rows)
51
+ except OSError:
52
+ pass
53
+
54
+ def forecast(self, key, now=None):
55
+ """{"eta": when it hits 100% at the current pace, or None, "rate": % per hour}."""
56
+ now = now or time.time(); lookback = WINDOWS[key][1]
57
+ with self.lock:
58
+ s = [x for x in self.samples if x["w"] == key]
59
+ if not s: return None
60
+ cur = s[-1]
61
+ win = [x for x in s if x["r"] == cur["r"] and x["t"] >= cur["t"] - lookback]
62
+ if len(win) < 2 or cur["t"] - win[0]["t"] < 900: return {"eta": None, "rate": None}
63
+ rate = (cur["p"] - win[0]["p"]) / (cur["t"] - win[0]["t"]) # % per second
64
+ if rate <= 0: return {"eta": None, "rate": 0}
65
+ eta = cur["t"] + (100 - cur["p"]) / rate
66
+ return {"eta": eta if eta < cur["r"] else None, "rate": round(rate * 3600, 1)}
67
+
68
+
69
+ def summary(entries, status_dir, history, today=None, project=None):
70
+ """entries: iterable of transcript Entry objects; project(entry) names the project a chat belongs to."""
71
+ today = today or datetime.date.today()
72
+ week = {(today - datetime.timedelta(days=i)).isoformat(): 0 for i in range(6, -1, -1)}
73
+ by_proj = {}; tot = {"today": [0, 0, 0, 0], "week": [0, 0, 0, 0]}
74
+ for e in entries:
75
+ proj = os.path.basename((project(e) if project else e.cwd or "").rstrip("/")) or "~"
76
+ for day, v in e.days.items():
77
+ if day in week:
78
+ n = v[0] + v[1] + v[3] # fresh input + output + cache writes (cache reads are cheap re-reads)
79
+ week[day] += n; by_proj[proj] = by_proj.get(proj, 0) + n
80
+ tot["week"] = [a + b for a, b in zip(tot["week"], v)]
81
+ if day == today.isoformat(): tot["today"] = [a + b for a, b in zip(tot["today"], v)]
82
+ limits = None; cost = 0.0; now = time.time()
83
+ for f in sorted(glob.glob(os.path.join(status_dir, "*.json")), key=os.path.getmtime):
84
+ try:
85
+ with open(f) as fh: d = json.load(fh)
86
+ at = os.path.getmtime(f)
87
+ if at > now - 86400: cost += float((d.get("cost") or {}).get("total_cost_usd") or 0)
88
+ if d.get("rate_limits"):
89
+ limits = {"data": d["rate_limits"], "at": at}
90
+ history.add(at, d["rate_limits"])
91
+ except Exception:
92
+ pass
93
+ if limits:
94
+ limits["forecast"] = {k: history.forecast(k) for k in WINDOWS}
95
+ return {"week": [[k, v] for k, v in week.items()], "by_project": sorted(by_proj.items(), key=lambda x: -x[1])[:5],
96
+ "today": tot["today"], "week_tot": tot["week"], "limits": limits, "cost_24h": round(cost, 2)}
@@ -0,0 +1,303 @@
1
+ <#
2
+ chatdeck bubble: a small always-on-top button in the bottom-left corner of the screen.
3
+ Click it to pop the dashboard up above it; click it again, or anywhere else, to tuck it away.
4
+ Drag it to move it. Right-click to hide it or for more. Starting it again while it is hidden
5
+ brings it back. The badge counts running chats and turns amber, with a pulsing ring, when a
6
+ chat is waiting for you. The same badge sits in the notification area (by the clock), where a
7
+ click pops the dashboard up in the bottom-right corner instead.
8
+
9
+ Needs the dashboard server running, and Chrome or Edge for the pop-up.
10
+ powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File bubble.ps1 [-Port 47123]
11
+ #>
12
+ param([int]$Port = 47123, [int]$PopupWidth = 460, [int]$PopupHeight = 760)
13
+
14
+ # One bubble at a time: starting it again just asks the running one to show itself.
15
+ $created = $false
16
+ $script:mutex = New-Object System.Threading.Mutex($true, "Local\ChatdeckBubble", [ref]$created)
17
+ $script:showSignal = New-Object System.Threading.EventWaitHandle($false, [System.Threading.EventResetMode]::AutoReset, "Local\ChatdeckBubbleShow")
18
+ if (-not $created) { $script:showSignal.Set() | Out-Null; exit }
19
+
20
+ Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Windows.Forms, System.Drawing
21
+ Add-Type @'
22
+ using System; using System.Runtime.InteropServices;
23
+ public static class U {
24
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c, string t);
25
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
26
+ [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint f);
27
+ [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr h);
28
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
29
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
30
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
31
+ [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int w, int hh, uint f);
32
+ [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
33
+ [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr h);
34
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n);
35
+ public static string ClassOf(IntPtr h) { var s = new System.Text.StringBuilder(128); GetClassName(h, s, 128); return s.ToString(); }
36
+ [DllImport("user32.dll")] static extern void keybd_event(byte k, byte s, uint f, UIntPtr e);
37
+ public static void Front(IntPtr h) { // an Alt tap lets a background process take focus
38
+ keybd_event(0x12, 0, 0, UIntPtr.Zero); keybd_event(0x12, 0, 2, UIntPtr.Zero); SetForegroundWindow(h);
39
+ }
40
+ }
41
+ '@
42
+ [System.Net.WebRequest]::DefaultWebProxy = $null # skip proxy detection, which adds seconds per request
43
+
44
+ $Base = "http://127.0.0.1:$Port"
45
+ $PopupTitle = "chatdeck" + [char]0x200B # reads "chatdeck"; the zero-width space tells the pop-up apart from the full window
46
+ $Size = 64
47
+ $StateFile = Join-Path $env:APPDATA "chatdeck\bubble.json"
48
+ $script:token = $null
49
+ $script:shown = $false
50
+ $script:shownAt = [DateTime]::MinValue
51
+ $script:hiddenUntil = $null # set while the bubble itself is hidden
52
+ $script:anchor = "bubble" # where the pop-up opens: above the bubble, or above the tray
53
+
54
+ # --- talking to the server -------------------------------------------------------------------
55
+ function Get-Summary {
56
+ if (-not $script:token) {
57
+ $html = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 "$Base/").Content
58
+ if ($html -match 'TOKEN="([0-9a-f]+)"') { $script:token = $Matches[1] }
59
+ }
60
+ try { Invoke-RestMethod -TimeoutSec 2 -Headers @{ "X-Token" = $script:token } "$Base/api/summary" }
61
+ catch { $script:token = $null; throw } # the server may have restarted with a new token
62
+ }
63
+
64
+ # --- the logo: the same shapes as assets/logo.svg (a deck of chat cards) -----------------------
65
+ $LogoXaml = @'
66
+ <Canvas xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" Width="64" Height="64">
67
+ <Rectangle Canvas.Left="23" Canvas.Top="5" Width="36" Height="28" RadiusX="7" RadiusY="7" Fill="#A9C4F7"/>
68
+ <Rectangle Canvas.Left="14" Canvas.Top="12" Width="38" Height="29" RadiusX="7" RadiusY="7" Fill="#6E9BEE"/>
69
+ <Path Data="M13 20h34a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8H24l-11 9 2-9h-2a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z">
70
+ <Path.Fill><LinearGradientBrush StartPoint="0,0" EndPoint="1,1"><GradientStop Offset="0" Color="#4C86F0"/><GradientStop Offset="1" Color="#2A5BD7"/></LinearGradientBrush></Path.Fill>
71
+ </Path>
72
+ <Rectangle Canvas.Left="14" Canvas.Top="30" Width="26" Height="4.5" RadiusX="2.25" RadiusY="2.25" Fill="White"/>
73
+ <Rectangle Canvas.Left="14" Canvas.Top="38.5" Width="17" Height="4.5" RadiusX="2.25" RadiusY="2.25" Fill="White" Opacity="0.8"/>
74
+ </Canvas>
75
+ '@
76
+
77
+ function Logo-Bitmap($px) {
78
+ # The logo rendered to a System.Drawing bitmap, for the tray icon.
79
+ $vb = New-Object System.Windows.Controls.Viewbox
80
+ $vb.Child = [Windows.Markup.XamlReader]::Parse($LogoXaml)
81
+ $vb.Measure((New-Object System.Windows.Size($px, $px))); $vb.Arrange((New-Object System.Windows.Rect(0, 0, $px, $px)))
82
+ $rtb = New-Object System.Windows.Media.Imaging.RenderTargetBitmap($px, $px, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32)
83
+ $rtb.Render($vb)
84
+ $enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder
85
+ $enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($rtb))
86
+ $ms = New-Object System.IO.MemoryStream; $enc.Save($ms); $ms.Position = 0
87
+ New-Object System.Drawing.Bitmap($ms)
88
+ }
89
+
90
+ # --- the bubble ------------------------------------------------------------------------------
91
+ [xml]$xaml = @"
92
+ <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
93
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
94
+ Title="chatdeck bubble" Width="$Size" Height="$Size" WindowStyle="None" AllowsTransparency="True"
95
+ Background="Transparent" Topmost="True" ShowInTaskbar="False" ResizeMode="NoResize" Cursor="Hand">
96
+ <Grid>
97
+ <Ellipse x:Name="Ring" Width="58" Height="58" Stroke="#F5B942" StrokeThickness="3" Opacity="0"/>
98
+ <Ellipse x:Name="Disc" Width="48" Height="48" Fill="#1B1F27" Stroke="#3A4150" StrokeThickness="1">
99
+ <Ellipse.Effect><DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.45"/></Ellipse.Effect>
100
+ </Ellipse>
101
+ <Viewbox Width="30" Height="30" Margin="0,1,0,0">$LogoXaml</Viewbox>
102
+ <Border x:Name="Badge" Background="#3ECF8E" CornerRadius="9" MinWidth="18" Height="18" HorizontalAlignment="Right"
103
+ VerticalAlignment="Top" Margin="0,5,5,0" Padding="4,0" Visibility="Collapsed">
104
+ <TextBlock x:Name="Count" Foreground="#0F1115" FontSize="11" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"/>
105
+ </Border>
106
+ </Grid>
107
+ </Window>
108
+ "@
109
+ $win = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))
110
+ $ring = $win.FindName("Ring"); $disc = $win.FindName("Disc")
111
+ $badge = $win.FindName("Badge"); $count = $win.FindName("Count")
112
+ $win.ToolTip = "chatdeck"
113
+
114
+ $pulse = New-Object System.Windows.Media.Animation.DoubleAnimation(0.25, 1, [TimeSpan]::FromMilliseconds(700))
115
+ $pulse.AutoReverse = $true; $pulse.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever
116
+
117
+ function Home-Position {
118
+ $wa = [System.Windows.SystemParameters]::WorkArea
119
+ $win.Left = $wa.Left + 12; $win.Top = $wa.Bottom - $Size - 12
120
+ }
121
+ function Save-Position {
122
+ try {
123
+ New-Item -ItemType Directory -Force (Split-Path $StateFile) | Out-Null
124
+ @{ left = $win.Left; top = $win.Top } | ConvertTo-Json | Set-Content $StateFile
125
+ } catch {}
126
+ }
127
+ Home-Position
128
+ try {
129
+ $p = Get-Content $StateFile -Raw | ConvertFrom-Json
130
+ $vs = [System.Windows.SystemParameters]
131
+ if ($p.left -ge $vs::VirtualScreenLeft -and $p.left -le $vs::VirtualScreenLeft + $vs::VirtualScreenWidth - $Size -and
132
+ $p.top -ge $vs::VirtualScreenTop -and $p.top -le $vs::VirtualScreenTop + $vs::VirtualScreenHeight - $Size) {
133
+ $win.Left = $p.left; $win.Top = $p.top
134
+ }
135
+ } catch {}
136
+
137
+ # --- the pop-up: a Chrome/Edge app window placed above the bubble ----------------------------
138
+ function Popup-Handle { [U]::FindWindow("Chrome_WidgetWin_1", $PopupTitle) }
139
+
140
+ function Browser {
141
+ foreach ($b in @("$env:ProgramFiles\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
142
+ "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
143
+ "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe")) { if (Test-Path $b) { return $b } }
144
+ }
145
+
146
+ function Place-Popup($h) {
147
+ # Above the bubble, lined up with whichever edge of the screen the bubble is nearer, or in the
148
+ # bottom-right corner when opened from the tray; kept on screen.
149
+ $wa = [System.Windows.SystemParameters]::WorkArea
150
+ $scale = [System.Windows.PresentationSource]::FromVisual($win).CompositionTarget.TransformToDevice.M11
151
+ if ($script:anchor -eq "tray") { $left = $wa.Right - 8 - $PopupWidth; $top = $wa.Bottom - 8 - $PopupHeight }
152
+ else {
153
+ $left = if ($win.Left + $Size / 2 -lt $wa.Left + $wa.Width / 2) { $win.Left + 4 } else { $win.Left + $Size - 4 - $PopupWidth }
154
+ $top = $win.Top - 6 - $PopupHeight
155
+ }
156
+ if ($top -lt $wa.Top) { $top = $wa.Top }
157
+ $left = [Math]::Max($wa.Left, [Math]::Min($left, $wa.Right - $PopupWidth))
158
+ [U]::SetWindowPos($h, [IntPtr]::Zero, [int]($left * $scale), [int]($top * $scale),
159
+ [int]($PopupWidth * $scale), [int]($PopupHeight * $scale), 0x14) | Out-Null # no z-order change, no activate
160
+ }
161
+
162
+ function Show-Popup($from = "bubble") {
163
+ $script:anchor = $from
164
+ $h = Popup-Handle
165
+ if ($h -eq [IntPtr]::Zero) {
166
+ $b = Browser
167
+ if (-not $b) { [System.Windows.MessageBox]::Show("The pop-up needs Chrome or Edge.", "chatdeck") | Out-Null; return }
168
+ Start-Process $b -ArgumentList "--app=$Base/?popup=1", "--window-size=$PopupWidth,$PopupHeight"
169
+ for ($i = 0; $i -lt 60 -and $h -eq [IntPtr]::Zero; $i++) { Start-Sleep -Milliseconds 100; $h = Popup-Handle }
170
+ if ($h -eq [IntPtr]::Zero) { return }
171
+ }
172
+ Place-Popup $h
173
+ [U]::ShowWindow($h, $(if ([U]::IsIconic($h)) { 9 } else { 5 })) | Out-Null
174
+ [U]::Front($h)
175
+ $script:shown = $true; $script:shownAt = Get-Date
176
+ }
177
+
178
+ function Hide-Popup {
179
+ $h = Popup-Handle
180
+ if ($h -ne [IntPtr]::Zero) { [U]::ShowWindow($h, 0) | Out-Null }
181
+ $script:shown = $false
182
+ }
183
+
184
+ function Toggle-Popup($from = "bubble") { if ($script:shown) { Hide-Popup } else { Show-Popup $from } }
185
+
186
+ function Hide-Bubble($until) { Hide-Popup; $win.Hide(); $script:hiddenUntil = $until }
187
+ function Show-Bubble { $script:hiddenUntil = $null; $win.Show(); [U]::ShowWindow($script:self, 4) | Out-Null }
188
+
189
+ # Click toggles; dragging moves the bubble (and the pop-up with it).
190
+ $win.Add_MouseLeftButtonDown({
191
+ $l = $win.Left; $t = $win.Top
192
+ $win.DragMove()
193
+ if ([Math]::Abs($win.Left - $l) -lt 3 -and [Math]::Abs($win.Top - $t) -lt 3) { Toggle-Popup }
194
+ else { Save-Position; if ($script:shown -and $script:anchor -eq "bubble") { $h = Popup-Handle; if ($h -ne [IntPtr]::Zero) { Place-Popup $h } } }
195
+ })
196
+
197
+ $menu = New-Object System.Windows.Controls.ContextMenu
198
+ function Add-Item($text, $action) { $i = New-Object System.Windows.Controls.MenuItem; $i.Header = $text; $i.Add_Click($action); $menu.Items.Add($i) | Out-Null }
199
+ Add-Item "Show chats" { Show-Popup }
200
+ Add-Item "Open the full dashboard" { $b = Browser; if ($b) { Start-Process $b -ArgumentList "--app=$Base/" } }
201
+ Add-Item "Move back to the corner" { Home-Position; Save-Position }
202
+ $menu.Items.Add((New-Object System.Windows.Controls.Separator)) | Out-Null
203
+ Add-Item "Hide for an hour" { Hide-Bubble ((Get-Date).AddHours(1)) }
204
+ Add-Item "Hide (the tray icon brings it back)" { Hide-Bubble ([DateTime]::MaxValue) }
205
+ Add-Item "Quit" { $win.Close() }
206
+ $win.ContextMenu = $menu
207
+
208
+ # --- the tray icon ------------------------------------------------------------------------------
209
+ $tray = New-Object System.Windows.Forms.NotifyIcon
210
+ $tray.Text = "chatdeck"
211
+ $tray.Add_MouseClick({ param($s, $e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Toggle-Popup "tray" } })
212
+ $trayMenu = New-Object System.Windows.Forms.ContextMenuStrip
213
+ function Add-TrayItem($text, $action) { $i = $trayMenu.Items.Add($text); $i.Add_Click($action); $i }
214
+ Add-TrayItem "Show chats" { Show-Popup "tray" } | Out-Null
215
+ Add-TrayItem "Open the full dashboard" { $b = Browser; if ($b) { Start-Process $b -ArgumentList "--app=$Base/" } } | Out-Null
216
+ $bubbleItem = Add-TrayItem "Show the corner button" { if ($script:hiddenUntil) { Show-Bubble } else { Hide-Bubble ([DateTime]::MaxValue) } }
217
+ $trayMenu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
218
+ Add-TrayItem "Quit" { $win.Close() } | Out-Null
219
+ $trayMenu.Add_Opening({ $bubbleItem.Text = $(if ($script:hiddenUntil) { "Show the corner button" } else { "Hide the corner button" }) })
220
+ $tray.ContextMenuStrip = $trayMenu
221
+
222
+ # The tray icon: the logo, with a count badge in its corner.
223
+ $script:trayKey = $null; $script:trayIcon = $null; $script:trayHandle = [IntPtr]::Zero
224
+ function Set-Tray($label, $argb, $tip) {
225
+ $key = "$label|$argb"
226
+ if ($key -ne $script:trayKey) {
227
+ $bmp = Logo-Bitmap 32
228
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
229
+ $g.SmoothingMode = "AntiAlias"; $g.TextRenderingHint = "AntiAliasGridFit"
230
+ $center = New-Object System.Drawing.StringFormat; $center.Alignment = "Center"; $center.LineAlignment = "Center"
231
+ if ($label) {
232
+ $g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 15, 17, 21))), 11, 11, 23, 23) # dark ring keeps it apart from the logo
233
+ $g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb($argb))), 13, 13, 19, 19)
234
+ $badgeFont = New-Object System.Drawing.Font("Segoe UI", 13, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
235
+ $g.DrawString($label, $badgeFont, (New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 15, 17, 21))),
236
+ (New-Object System.Drawing.RectangleF(13, 13, 19, 19)), $center)
237
+ }
238
+ $g.Dispose()
239
+ $h = $bmp.GetHicon(); $tray.Icon = [System.Drawing.Icon]::FromHandle($h); $bmp.Dispose()
240
+ if ($script:trayHandle -ne [IntPtr]::Zero) { [U]::DestroyIcon($script:trayHandle) | Out-Null }
241
+ $script:trayHandle = $h; $script:trayKey = $key
242
+ }
243
+ $tray.Text = $(if ($tip.Length -gt 63) { $tip.Substring(0, 60) + "..." } else { $tip }) # Windows caps tray tips at 63 characters
244
+ $tray.Visible = $true
245
+ }
246
+
247
+ $win.Add_Closed({
248
+ $tray.Visible = $false; $tray.Dispose()
249
+ $h = Popup-Handle
250
+ if ($h -ne [IntPtr]::Zero) { [U]::PostMessage($h, 0x10, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null } # close the pop-up too
251
+ })
252
+
253
+ # --- timers: tuck the pop-up away when focus moves elsewhere, and keep the badge current -------
254
+ $script:self = [IntPtr]::Zero
255
+ $focusTimer = New-Object System.Windows.Threading.DispatcherTimer
256
+ $focusTimer.Interval = [TimeSpan]::FromMilliseconds(300)
257
+ $focusTimer.Add_Tick({
258
+ if ($script:showSignal.WaitOne(0) -or ($script:hiddenUntil -and (Get-Date) -ge $script:hiddenUntil)) { Show-Bubble }
259
+ if (-not $script:shown) { return }
260
+ $h = Popup-Handle
261
+ if ($h -eq [IntPtr]::Zero -or -not [U]::IsWindowVisible($h) -or [U]::IsIconic($h)) { $script:shown = $false; return }
262
+ if (((Get-Date) - $script:shownAt).TotalMilliseconds -lt 800) { return }
263
+ $fg = [U]::GetAncestor([U]::GetForegroundWindow(), 3) # root owner, so the pop-up's own dialogs count as the pop-up
264
+ # The taskbar and tray take focus while you click the tray icon, which toggles the pop-up itself.
265
+ $taskbar = @("Shell_TrayWnd", "Shell_SecondaryTrayWnd", "NotifyIconOverflowWindow", "TopLevelWindowForOverflowXamlIsland") -contains [U]::ClassOf($fg)
266
+ if ($fg -ne $h -and $fg -ne $script:self -and -not $taskbar) { Hide-Popup }
267
+ })
268
+
269
+ $script:wasWaiting = $false
270
+ function Update-Badge {
271
+ try {
272
+ $s = Get-Summary
273
+ $disc.Opacity = 1
274
+ $waiting = @($s.waiting)
275
+ if ($waiting.Count) {
276
+ $badge.Background = "#F5B942"; $count.Text = $waiting.Count; $badge.Visibility = "Visible"
277
+ $win.ToolTip = "Waiting for you: " + ($waiting -join ", ")
278
+ if (-not $script:wasWaiting) { $ring.BeginAnimation([System.Windows.UIElement]::OpacityProperty, $pulse) }
279
+ Set-Tray "$($waiting.Count)" 0xFFF5B942 $win.ToolTip
280
+ } else {
281
+ if ($script:wasWaiting) { $ring.BeginAnimation([System.Windows.UIElement]::OpacityProperty, $null); $ring.Opacity = 0 }
282
+ $badge.Background = "#3ECF8E"; $count.Text = $s.running
283
+ $badge.Visibility = $(if ($s.running) { "Visible" } else { "Collapsed" })
284
+ $win.ToolTip = $(if ($s.running) { "$($s.running) running, $($s.busy) working" } else { "No chats running" })
285
+ Set-Tray $(if ($s.running) { "$($s.running)" } else { "" }) 0xFF3ECF8E "chatdeck: $($win.ToolTip)"
286
+ }
287
+ $script:wasWaiting = [bool]$waiting.Count
288
+ } catch {
289
+ $disc.Opacity = 0.45; $badge.Visibility = "Collapsed"; $win.ToolTip = "Dashboard server isn't running"
290
+ Set-Tray "" 0 "chatdeck: the dashboard server isn't running"
291
+ }
292
+ }
293
+ $pollTimer = New-Object System.Windows.Threading.DispatcherTimer
294
+ $pollTimer.Interval = [TimeSpan]::FromSeconds(3)
295
+ $pollTimer.Add_Tick({ Update-Badge })
296
+
297
+ $win.Add_SourceInitialized({ $script:self = (New-Object System.Windows.Interop.WindowInteropHelper($win)).Handle })
298
+ # Started with -WindowStyle Hidden, Windows applies "hidden" to the first window the process shows, so show it explicitly.
299
+ $win.Add_Loaded({ [U]::ShowWindow($script:self, 4) | Out-Null; Update-Badge; $pollTimer.Start(); $focusTimer.Start() })
300
+ # Run a message loop rather than ShowDialog: hiding a dialog ends it, which took the tray icon down with the bubble.
301
+ $win.Add_Closed({ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown() })
302
+ $win.Show()
303
+ [System.Windows.Threading.Dispatcher]::Run()