Claude's cooking. Chat with your squad. Friends online, right beside your Claude Code session. Zero tokens, zero leaks to Claude.

See which friends are online and chat with them in a side pane while you work in Claude Code. Chat never reaches Claude: it isn't in the conversation and uses no model tokens.

In Claude Code:
/plugin install squad-chat --marketplace chrisluo5311/squad-chat
Then /reload-plugins (or restart Claude Code), and connect to your squad's server (below). Or from a shell:
claude plugin marketplace add chrisluo5311/squad-chat
claude plugin install squad-chat@squad-chat
Requires:
PATH (node --version). squad-chat tells you if it's missing or too old.Each group of friends shares its own free Supabase project. Get its URL and publishable key from whoever hosts it, or host one yourself (instructions). Then set them in /config under squad-chat, or:
echo '{"supabase_url":"https://abcd1234.supabase.co","supabase_key":"sb_publishable_..."}' \
| claude plugin configure squad-chat@squad-chat --values-stdin
/chat opens the pane./room our-team some-passcode in the pane. Friends join with the same line.Before your chat rooms, three built-in tabs need no server or sign-in: Usage (context, rate limits, cost, cache hits, tool timings, subagents), Git (your branch, pull requests, reviews, Actions, issues, through the gh CLI) and Agents (every Claude Code session on this computer, its subagents and a live tool feed).
In a terminal 110 columns wide or more (fullscreen), the pane docks on the right. In a narrower one it sits above the prompt. While the pane is closed, a line above the prompt shows the room, who's online and what's unread, with an Open button.
| Command | What it does |
|---|---|
/chat | Open the pane |
/say <message> | Send a message to the current room from the prompt |
/room | List your rooms |
/room <name> | Switch to a room you're in |
/room <name> <passcode> | Create a room, or join a friend's |
/room leave <name> | Leave a room (rejoin any time with its passcode) |
/room delete <name> | Delete a room you created, with all its messages, for everyone. Asks you to run it twice. |
/who | Who's online, across all your rooms |
/chat-login <email>, then /chat-login <code> | Sign in by email from the prompt instead of the pane |
/chat-login <name> | Sign in with just a name, where the server allows it |
/chat-share [#room] | Share the text you selected, or else the last code block in Claude's reply, to the current room or the one you name. You see it first, then /chat-share send posts it (or /chat-share cancel). |
/chat-share diff [path] [#room] | Share your uncommitted changes (git diff HEAD), all of them or one file's |
/chat-share to #room | Send what's waiting in the preview to another of your rooms instead. /chat-share send #room picks the room and posts in one go. |
/chat-logout | Sign out on this computer |
/chat notify on / off | Toast when someone writes @yourname (off by default) |
/chat dnd on / off / auto | Do not disturb: no toasts, a quiet band and status line, and friends see you as busy. auto turns it on while Claude works on something longer than 30 seconds, then sums up what you missed. |
/chat usage / git / agents | Open the pane on a built-in room |
/chat rooms <list> | Which built-in rooms have tabs (usage, git, agents, all or none) |
/chat-share usage / git / agents [#room] | Share a snapshot of a built-in room to a chat room |
The pane's input box takes /room, /who, /dnd, /share, /usage, /git, /agents, /chat and /logout too.
/say, /room and /chat-login, the arguments are replaced with a placeholder before Claude Code saves the command, and command answers are notices the model never reads.git and gh in its folder as you. Nothing is sent anywhere. Each session keeps a small heartbeat in /tmp/squad-chat-<uid>/sessions/, readable only by you (folder name, branch, model, recent tool names with a few words each, secrets masked, never prompts or output), so the Agents room can show every session.~/.config/squad-chat/session.json, readable only by you. /chat-logout removes it.@ (ann@example.com → ann). Friends see it, plus whether you're online. They never see your email.The mod starts a small Node process (bridge/dist/bridge.mjs) that holds the connection to Supabase: sign-in, Realtime presence and messages, and catching up after network drops. The mod talks to it over a private Unix socket. Source and tests: github.com/chrisluo5311/squad-chat.
MIT
hooks/squad-chat.mjs 681 lines1// squad-chat: friends' presence and a group chat in a side pane. Chat text
2// never reaches the model.
3//
4// Everything that touches the engine ($) lives in this file, since $ is never
5// followed across an import. The other files are plain logic:
6// state.mjs the state built from the bridge's events
7// commands.mjs slash commands and the pane's input box
8// views.mjs the pane
9// sysviews.mjs the built-in rooms (Usage, Git, Agents), with widgets.mjs
10// metrics.mjs this session's numbers, github.mjs the Git room's data,
11// sessions.mjs heartbeats shared with the other sessions on this computer
12
13import { state, applyEvent, resetBridgeState, currentRoom, roomMessages, statusText, isQuiet, missedText, activeView, SYS_ROOMS } from "./state.mjs";
14import { PRIVATE_ARGS, login, logout, rename, room, who, dnd, share, retargetShare, sendMessage, paneInput, sysRooms } from "./commands.mjs";
15import { paneView, bandView } from "./views.mjs";
16import { applyMeasure, applyTurnUsage, recordTurnContext, toolStarted, toolEnded, turnStarted, turnEnded, agentSpawned, applyAgentList, agentCounts, runningCalls } from "./metrics.mjs";
17import { fetchGit, diffGit } from "./github.mjs";
18import { heartbeat, beatKey } from "./sessions.mjs";
19
20const PANE_ID = "squad-chat";
21const MIN_NODE_MAJOR = 22;
22const MAX_BACKOFF_MS = 30_000;
23const TYPING_PING_MS = 2_000;
24const LONG_TURN_MS = 30_000; // a Claude turn this long turns "auto" do not disturb on
25const GIT_SHOWN_MS = 60_000; // how often the Git room refreshes while it's on show
26const GIT_HIDDEN_MS = 5 * 60_000; // and otherwise (for its tab's badge and the toasts)
27const BEAT_IDLE_MS = 10_000; // a heartbeat at least this often, so others don't drop us
28const PUSHED = /\b(git\s+push|gh\s+pr\s+(create|merge|ready|close|review))\b/;
29
30// ---------------------------------------------------------------- the bridge
31
32let bridgeStarted = false;
33
34function randomToken() {
35 const bytes = new Uint8Array(24);
36 crypto.getRandomValues(bytes);
37 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
38}
39
40function unavailable($, detail) {
41 state.bridge = "unavailable";
42 state.detail = detail;
43 $.ui.invalidate("ui.render");
44}
45
46async function nodeProblem($) {
47 try {
48 const { exitCode, stdout } = await $.process.run(["node", "--version"], { timeoutMs: 10_000 });
49 const major = Number(/^v(\d+)/.exec(stdout.trim())?.[1]);
50 if (exitCode !== 0 || !major) return "could not run `node --version`";
51 if (major < MIN_NODE_MAJOR) return `squad-chat needs Node ${MIN_NODE_MAJOR} or newer (found ${stdout.trim()})`;
52 return null;
53 } catch (err) {
54 return `squad-chat needs Node ${MIN_NODE_MAJOR}+ on PATH (${err?.message ?? err})`;
55 }
56}
57
58// Runs bridge/dist/bridge.mjs for the life of the module, restarting it with
59// backoff. Leaving the loop, or the module unloading, kills the child.
60// The squad's server comes from the plugin's options (userConfig). An unset
61// one is left out, so a SQUAD_SUPABASE_* variable in the environment (local
62// development) still applies.
63function serverEnv(options) {
64 const env = {};
65 if (options?.supabase_url) env.SQUAD_SUPABASE_URL = String(options.supabase_url).trim();
66 if (options?.supabase_key) env.SQUAD_SUPABASE_KEY = String(options.supabase_key).trim();
67 return env;
68}
69
70async function runBridge($, options) {
71 const problem = await nodeProblem($);
72 if (problem) return unavailable($, problem);
73
74 let backoff = 1000;
75 while (!state.ended) {
76 state.token = randomToken();
77 resetBridgeState();
78 const startedAt = await $.clock.now();
79 let buffered = "";
80 try {
81 const child = $.process.spawn({
82 argv: ["node", `${$.plugin.root}/bridge/dist/bridge.mjs`],
83 env: { SQUAD_BRIDGE_TOKEN: state.token, ...serverEnv(options) },
84 });
85 for await (const { stream, text } of child) {
86 if (stream === "stderr") { $.ui.log(`squad-chat bridge: ${text.trimEnd()}`, { to: "debug" }); continue; }
87 buffered += text;
88 let nl;
89 while ((nl = buffered.indexOf("\n")) >= 0) {
90 const line = buffered.slice(0, nl).trim();
91 buffered = buffered.slice(nl + 1);
92 if (!line) continue;
93 let event;
94 try { event = JSON.parse(line); }
95 catch { $.ui.log(`squad-chat bridge: unparsable line: ${line.slice(0, 120)}`, { to: "debug" }); continue; }
96 if (applyEvent(event)) afterChange($);
97 // A new or restarted bridge starts out available: tell it again.
98 if (event.type === "auth" && event.state === "signed_in" && isQuiet()) sendStatus($);
99 }
100 }
101 } catch (err) {
102 // Cannot start: no process noun on this surface (desktop), or no node.
103 return unavailable($, `cannot start the chat bridge: ${err?.message ?? err}`);
104 }
105 if (state.ended || state.bridge === "unconfigured") return; // nothing to retry until options change
106 if ((await $.clock.now()) - startedAt > 60_000) backoff = 1000;
107 resetBridgeState();
108 state.bridge = "restarting";
109 state.detail = `bridge stopped; retrying in ${Math.round(backoff / 1000)}s`;
110 $.ui.invalidate("ui.render");
111 await $.clock.sleep(backoff);
112 backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
113 }
114}
115
116// One control request; resolves the bridge's JSON answer or throws its error.
117async function callBridge($, path, body) {
118 if (!state.socket) {
119 throw new Error(state.bridge === "unavailable" ? state.detail : "chat is still connecting, try again in a moment");
120 }
121 const res = await $.http.fetch(`http://bridge${path}`, {
122 method: body === undefined ? "GET" : "POST",
123 headers: { "content-type": "application/json", "x-squad-token": state.token },
124 body: body === undefined ? undefined : JSON.stringify(body),
125 socketPath: state.socket,
126 });
127 let data = {};
128 try { data = JSON.parse(res.text || "{}"); } catch { /* keep {} */ }
129 if (!res.ok) throw new Error(data.error || `bridge answered ${res.status}`);
130 return data;
131}
132
133// ---------------------------------------------------------------- keeping chat out of the model
134
135// Claude Code records a slash command as a user row the model reads on later
136// turns ("<command-name>/say</command-name> ... <command-args>TEXT</command-args>").
137// For commands that carry messages, emails, codes or passcodes, the args are
138// replaced before the row is stored or sent.
139const ARGS = /<command-args>[\s\S]*?<\/command-args>/g;
140const REDACTED = "<command-args>[squad-chat input, hidden from Claude]</command-args>";
141const NAME = /<command-name>\/(?:squad-chat:)?([\w-]+)<\/command-name>/;
142
143function blockTexts(content) {
144 if (typeof content === "string") return [content];
145 return (content ?? []).filter((b) => b?.type === "text").map((b) => b.text ?? "");
146}
147
148function privateCommand(message) {
149 return blockTexts(message?.content).some((t) => PRIVATE_ARGS.has(NAME.exec(t)?.[1]));
150}
151
152function redact(content) {
153 if (typeof content === "string") return content.replace(ARGS, REDACTED);
154 return content.map((b) => (b?.type === "text" ? { ...b, text: String(b.text ?? "").replace(ARGS, REDACTED) } : b));
155}
156
157// ---------------------------------------------------------------- pane and commands
158
159async function openPane($) {
160 const opened = await $.ui.open({ id: PANE_ID, title: "Squad Chat", focus: true, rows: 12, columns: 52 });
161 if (opened?.isPlaced === false) $.ui.toast("squad-chat: widen the terminal to see the chat pane");
162}
163
164// After any state change: redraw, refresh the status line, and toast an
165// @mention when the person asked for that (/chat notify on).
166function afterChange($) {
167 $.ui.invalidate("ui.render");
168 $.ui.status(statusText());
169 if (state.mention) {
170 const m = state.mention;
171 state.mention = null;
172 if (state.notify) $.ui.toast(`💬 ${m.user} in #${m.slug}: ${m.body.slice(0, 80)}`);
173 }
174}
175
176// ---------------------------------------------------------------- do not disturb
177
178function sendStatus($) {
179 callBridge($, "/status", { status: isQuiet() ? "busy" : "available" })
180 .catch((err) => $.ui.log(`squad-chat: could not set status: ${err?.message ?? err}`, { to: "debug" }));
181}
182
183// Do not disturb may have started or ended: tell the room, and on the way
184// out sum up what came in meanwhile (a toast, for people who asked for them).
185let wasQuiet = false;
186function quietChanged($) {
187 const quiet = isQuiet();
188 if (quiet === wasQuiet) return;
189 wasQuiet = quiet;
190 if (state.auth === "signed_in") sendStatus($);
191 if (!quiet) {
192 const summary = missedText();
193 if (summary && state.notify) $.ui.toast(summary);
194 state.missed = { messages: 0, mentions: 0 };
195 }
196 afterChange($);
197}
198
199async function setDnd($, mode) {
200 state.dnd = mode;
201 await $.store.set("dnd", mode);
202 quietChanged($);
203}
204
205// ---------------------------------------------------------------- sharing snippets
206
207// What /chat-share reads from the session: the mouse selection, Claude's replies,
208// and `git diff` in the session's folder. Reading only: nothing is added to
209// the conversation.
210function shareSources($, { inPane = false } = {}) {
211 return {
212 inPane,
213 // Claude Code 2.1.287 has no selection to read: share the last code block.
214 selection: async () => { try { return (await $.ui.selection())?.text; } catch { return undefined; } },
215 messages: () => $.session.messages(),
216 diff: async (path) => {
217 const cwd = await $.session.cwd();
218 const argv = ["git", "diff", "HEAD", "--no-color", "--no-ext-diff", "--", ...(path ? [path] : [])];
219 const r = await $.process.run(argv, { cwd, timeoutMs: 15_000 });
220 if (r.exitCode !== 0) throw new Error(`git diff: ${r.stderr.trim().split("\n")[0] || `exit ${r.exitCode}`}`);
221 if (r.isStdoutTruncated) throw new Error(`Too big to share. Share one file with ${inPane ? "/share" : "/chat-share"} diff <path>.`);
222 return r.stdout;
223 },
224 };
225}
226
227// The preview card's Send and Cancel: like typing "/share send" in the box,
228// without touching what's typed there.
229async function shareFromPane($, verb) {
230 const say = (text) => { state.notice = text; $.ui.invalidate("ui.render"); };
231 try {
232 await share((path, body) => callBridge($, path, body), verb, say, shareSources($, { inPane: true }));
233 } catch (err) {
234 say(err?.message ?? String(err));
235 }
236 $.ui.invalidate("ui.render");
237}
238
239async function copySnippet($, text, surface) {
240 const r = await $.ui.copy({ text, ...(surface ? { surface } : {}) });
241 state.notice = r?.isCopied ? "Copied." : `Couldn't copy${r?.reason ? `: ${r.reason}` : ""}.`;
242 $.ui.invalidate("ui.render");
243}
244
245// Tell the bridge the current room is read up to its newest message. Runs
246// when the pane has the keyboard and when the person sends from it.
247let marking = false;
248async function markRead($) {
249 const room = currentRoom();
250 const newest = roomMessages().at(-1)?.id ?? 0;
251 if (marking || !room || (!room.unread && newest <= room.last_read_id)) return;
252 marking = true;
253 // Remember where they'd read up to, so the pane can draw a "new" line there.
254 if (!state.dividerAt.has(room.id) && room.last_read_id > 0) state.dividerAt.set(room.id, room.last_read_id);
255 try {
256 await callBridge($, "/read", { room: room.id, last_id: newest });
257 room.last_read_id = Math.max(room.last_read_id, newest);
258 room.unread = 0;
259 afterChange($);
260 } catch (err) {
261 $.ui.log(`squad-chat: could not mark read: ${err?.message ?? err}`, { to: "debug" });
262 } finally {
263 marking = false;
264 }
265}
266
267// When the tabs change (join, leave, switch a room or a built-in room), the
268// pane's focus ring leaves the input box, and what the person types next
269// falls through to the prompt: it would go to Claude. After a change they
270// started in the pane, wait for the new tabs to be drawn, then put the ring
271// back on the box.
272async function keepFocusAcrossRoomChange($, before) {
273 for (let waited = 0; waited < 2000; waited += 50) {
274 if (state.current !== before.current || state.rooms.length !== before.count || state.view !== before.view) break;
275 await $.clock.sleep(50);
276 }
277 await $.clock.sleep(80); // let the new tree draw first
278 await $.ui.open({ id: PANE_ID, title: "Squad Chat", focus: true });
279 await $.ui.focus({ requestId: PANE_ID, key: "compose" });
280}
281
282// While a message is being typed in the pane, tell the room every couple of
283// seconds. Commands and sign-in steps aren't messages, so they stay quiet.
284let lastTypingPing = 0;
285function typingPing($, value) {
286 const text = String(value ?? "").trimStart();
287 if (!text || text.startsWith("/") || state.auth !== "signed_in" || !currentRoom()) return;
288 const now = Date.now();
289 if (now - lastTypingPing < TYPING_PING_MS) return;
290 lastTypingPing = now;
291 callBridge($, "/typing", { room: state.current }).catch(() => { /* a missed ping only hides a hint */ });
292}
293
294async function submitFromPane($, value) {
295 const say = (text) => { state.notice = text; $.ui.invalidate("ui.render"); };
296 const before = { current: state.current, count: state.rooms.length, view: state.view };
297 state.draft = "";
298 state.notice = "";
299 lastTypingPing = 0;
300 $.ui.invalidate("ui.render");
301 try {
302 await paneInput((path, body) => callBridge($, path, body), value, say, {
303 setDnd: (mode) => setDnd($, mode),
304 sources: shareSources($, { inPane: true }),
305 setView: (view) => setView($, view),
306 refreshGit: () => refreshGit($),
307 });
308 state.dividerAt.clear(); // they've replied: everything above is read
309 await markRead($); // they're looking at the room they just wrote in
310 } catch (err) {
311 say(err?.message ?? String(err));
312 }
313 if (/^\/room\b/.test(String(value).trim()) || state.view !== before.view) {
314 try { await keepFocusAcrossRoomChange($, before); } catch { /* the box is one click away */ }
315 }
316}
317
318async function selectRoom($, id) {
319 const before = { current: state.current, count: state.rooms.length, view: state.view };
320 try {
321 await callBridge($, "/room/select", { room: id });
322 } catch (err) {
323 state.notice = err?.message ?? String(err);
324 $.ui.invalidate("ui.render");
325 return;
326 }
327 try { await keepFocusAcrossRoomChange($, before); } catch { /* the box is one click away */ }
328}
329
330// ---------------------------------------------------------------- the built-in rooms
331
332async function setView($, view) {
333 state.view = view;
334 state.notice = "";
335 await $.store.set("view", view);
336 if (view === "git") void refreshGit($, { ifOlderThan: 15_000 });
337 if (view === "usage") void refreshUsage($);
338 afterChange($);
339}
340
341async function saveSysRooms($, list) {
342 state.sysRooms = list;
343 await $.store.set("sysRooms", list);
344 afterChange($);
345}
346
347// The engine's figures, asked for directly: on opening the room, and for
348// the context breakdown (estimated locally, so it costs nothing).
349async function refreshUsage($) {
350 try {
351 const u = await $.session.usage(state.usage.showBreakdown ? { breakdown: "summary" } : {});
352 applyMeasure(state.usage, u, Date.now());
353 if (u?.context?.breakdown) state.usage.breakdown = u.context.breakdown;
354 afterChange($);
355 } catch (err) {
356 $.ui.log(`squad-chat: could not read usage: ${err?.message ?? err}`, { to: "debug" });
357 }
358}
359
360function toggleBreakdown($) {
361 state.usage.showBreakdown = !state.usage.showBreakdown;
362 $.ui.invalidate("ui.render");
363 if (state.usage.showBreakdown) void refreshUsage($);
364}
365
366let gitBusy = false;
367async function refreshGit($, { ifOlderThan = 0 } = {}) {
368 if (gitBusy || !state.sysRooms.includes("git")) return;
369 if (ifOlderThan && Date.now() - state.git.fetchedAt < ifOlderThan) return;
370 gitBusy = true;
371 const prev = state.git;
372 if (!prev.fetchedAt) { state.git = { ...prev, status: "loading" }; $.ui.invalidate("ui.render"); }
373 try {
374 const cwd = await $.session.cwd();
375 const run = (argv) => $.process.run(argv, { cwd, timeoutMs: 15_000 });
376 const next = await fetchGit(run, prev);
377 state.git = next;
378 for (const text of diffGit(prev, next)) if (!isQuiet()) $.ui.toast(text);
379 } catch (err) {
380 state.git = { ...prev, status: "error", error: err?.message ?? String(err), stale: prev.status === "ok" };
381 } finally {
382 gitBusy = false;
383 }
384 afterChange($);
385}
386
387// Something in this session moved: redraw while a built-in room is on show
388// (the tabs' badges change in the chat too), and tell the other sessions.
389function sysChanged($) {
390 $.ui.invalidate("ui.render");
391 if (state.sysRooms.includes("agents")) beat($, false);
392}
393
394let lastBeat = { key: "", at: 0 };
395let cwdCache = null;
396function beat($, force) {
397 if (!state.sessionId) return;
398 const now = Date.now();
399 state.self = heartbeat(state.usage, { id: state.sessionId, cwd: cwdCache, branch: state.git.local?.branch, now });
400 const key = beatKey(state.self);
401 if (!force && key === lastBeat.key && now - lastBeat.at < BEAT_IDLE_MS) return;
402 if (!force && key !== lastBeat.key && now - lastBeat.at < 1000) return; // the next tick sends it
403 if (!state.socket) return;
404 lastBeat = { key, at: now };
405 callBridge($, "/sessions/beat", state.self).catch((err) => $.ui.log(`squad-chat: heartbeat: ${err?.message ?? err}`, { to: "debug" }));
406}
407
408function liveNow() {
409 const u = state.usage;
410 if (u.activity.state !== "idle" || runningCalls(u).length || agentCounts(u).running) return true;
411 if (activeView() === "agents") return state.sessions.some((s) => (s.activity?.state && s.activity.state !== "idle") || (s.agents ?? []).length);
412 if (activeView() === "git") return state.git.runs.some((r) => r.status !== "completed");
413 return false;
414}
415
416// What a built-in room's drawing changes with as time passes. Usage moves by
417// the second only while a subagent runs (its spinner and timer); otherwise
418// the session's span and the spend buckets move by the minute. A redraw a
419// second for nothing made the desktop's input box flicker all through a turn.
420let lastFrame = "";
421function frameKey(view, now) {
422 if (view === "usage" && !agentCounts(state.usage).running) return `usage:${Math.floor(now / 60_000)}`;
423 return `${view}:${Math.floor(now / 1000)}`;
424}
425
426// One loop for the built-in rooms: a redraw a second while something runs
427// (spinners, timers), the agents' statuses, the heartbeat, and the Git poll.
428async function tick($) {
429 for (;;) {
430 await $.clock.sleep(1000);
431 if (state.ended) return;
432 if (!state.sysRooms.length) continue;
433 const view = activeView();
434 if (agentCounts(state.usage).running) {
435 try { if (applyAgentList(state.usage, await $.agent.list(), Date.now())) sysChanged($); } catch { /* the next tick */ }
436 }
437 if (view !== "chat" && liveNow()) {
438 const key = frameKey(view, Date.now());
439 if (key !== lastFrame) { lastFrame = key; $.ui.invalidate("ui.render"); }
440 }
441 if (state.sysRooms.includes("agents")) beat($, false);
442 if (state.sysRooms.includes("git")) {
443 const shown = view === "git" && state.paneShown;
444 void refreshGit($, { ifOlderThan: shown ? GIT_SHOWN_MS : GIT_HIDDEN_MS });
445 }
446 }
447}
448
449// Runs a command body. Its answer and any error go to the transcript as a
450// notice the model never sees; the command itself returns nothing.
451async function answer($, fn) {
452 // One notice per line: a single notice draws newlines as junk.
453 const say = (text) => { for (const line of String(text).split("\n")) $.ui.log(line); };
454 try {
455 await fn((path, body) => callBridge($, path, body), say);
456 } catch (err) {
457 say(err?.message ?? String(err)); // the engine already labels notices "squad-chat:"
458 }
459 return {};
460}
461
462const COMMANDS = [
463 { name: "chat", description: "squad-chat: open the pane: chat, or the Usage, Git and Agents rooms (notify, dnd, rooms: settings)", argumentHint: "[usage|git|agents | rooms <list> | notify on|off | dnd on|off|auto]" },
464 { name: "say", description: "squad-chat: send a message to the current room", argumentHint: "<message>" },
465 { name: "room", description: "squad-chat: list, switch, join/create, leave or delete rooms", argumentHint: "[name] [passcode] | leave <name> | delete <name>" },
466 { name: "who", description: "squad-chat: who's online" },
467 { name: "chat-login", description: "squad-chat: sign in with an emailed code", argumentHint: "<email> | <code>" },
468 { name: "chat-share", description: "squad-chat: share the selected text, Claude's last code block, your diff, or a Usage/Git/Agents snapshot to the room", argumentHint: "[diff [path] | usage | git | agents] [#room] | to #room | send [#room] | cancel" },
469 { name: "chat-name", description: "squad-chat: change your display name", argumentHint: "<new name>" },
470 { name: "chat-logout", description: "squad-chat: sign out on this computer" },
471];
472
473export function register(on, options) {
474 on("session.start", async ($, e, next) => {
475 const r = await next(e);
476 // One by one: a name Claude Code refuses (one of its own, say) costs that
477 // command, not the chat.
478 for (const command of COMMANDS) {
479 try { await $.command.register({ ...command, immediate: true }); }
480 catch (err) { $.ui.log(`squad-chat: /${command.name} unavailable: ${err?.message ?? err}`, { to: "debug" }); }
481 }
482 state.notify = (await $.store.get("notify")) === true;
483 const savedDnd = await $.store.get("dnd");
484 state.dnd = ["on", "off", "auto"].includes(savedDnd) ? savedDnd : "off";
485 wasQuiet = isQuiet(); // already in effect: no summary for a reload
486 const savedRooms = await $.store.get("sysRooms");
487 if (Array.isArray(savedRooms)) state.sysRooms = SYS_ROOMS.filter((x) => savedRooms.includes(x));
488 const savedView = await $.store.get("view");
489 if (savedView === "chat" || SYS_ROOMS.includes(savedView)) state.view = savedView;
490 try { state.sessionId = String(await $.session.id()); } catch { state.sessionId = null; }
491 try { cwdCache = await $.session.cwd(); } catch { cwdCache = null; }
492 try { state.usage.model = (await $.session.model()) || state.usage.model; } catch { /* the first turn tells */ }
493 beat($, false); // this session's own row in the Agents room, before any heartbeat goes out
494 if (state.sysRooms.includes("usage")) void refreshUsage($); // numbers right away after a reload
495 if (!bridgeStarted) {
496 bridgeStarted = true;
497 void runBridge($, options);
498 tick($).catch(() => { /* unloaded mid-sleep */ });
499 }
500 return r;
501 });
502
503 on("command.run", { command: "chat" }, ($, e) => answer($, async (call, say) => {
504 const args = String(e.args ?? "").trim();
505 const v = /^(usage|git|agents|chat)$/i.exec(args);
506 if (v) {
507 const view = v[1].toLowerCase();
508 if (view !== "chat" && !state.sysRooms.includes(view)) return say(`The ${view} room is hidden. Bring it back with /chat rooms all.`);
509 await setView($, view);
510 return openPane($);
511 }
512 const rm = /^rooms\b\s*(.*)$/i.exec(args);
513 if (rm) return sysRooms(rm[1], say, (list) => saveSysRooms($, list));
514 const d = /^dnd\b\s*(.*)$/i.exec(args);
515 if (d) return dnd(d[1], say, (mode) => setDnd($, mode));
516 const m = /^notify\s+(on|off)$/i.exec(args);
517 if (!m) return openPane($);
518 state.notify = m[1].toLowerCase() === "on";
519 await $.store.set("notify", state.notify);
520 say(state.notify ? "Will toast when someone @mentions you." : "Mention toasts off.");
521 }));
522 on("command.run", { command: "say" }, ($, e) => answer($, (call, say) => sendMessage(call, e.args, say)));
523 on("command.run", { command: "room" }, ($, e) => answer($, (call, say) => room(call, e.args, say)));
524 on("command.run", { command: "who" }, ($) => answer($, (call, say) => who(say)));
525 on("command.run", { command: "chat-login" }, ($, e) => answer($, async (call, say) => {
526 if (!String(e.args ?? "").trim()) await openPane($);
527 await login(call, e.args, say);
528 }));
529 on("command.run", { command: "chat-share" }, ($, e) => answer($, (call, say) => share(call, e.args, say, shareSources($))));
530 on("command.run", { command: "chat-name" }, ($, e) => answer($, (call, say) => rename(call, e.args, say)));
531 on("command.run", { command: "chat-logout" }, ($) => answer($, (call, say) => logout(call, say)));
532
533 on("ui.render", { component: "Pane" }, ($, e, next) => {
534 if (e.requestId !== PANE_ID) return next(e);
535 const props = e.props ?? {};
536 state.paneFocused = props.isFocused === true;
537 if (state.paneFocused) void markRead($);
538 state.paneShown = true;
539 return paneView($.ui.resolve(e), { ...props, surface: e.surface }, {
540 onInput: (value) => { state.draft = value; typingPing($, value); },
541 onSubmit: (value) => { void submitFromPane($, value); },
542 onSelectRoom: (id) => { void (async () => { if (state.view !== "chat") await setView($, "chat"); await selectRoom($, id); })(); },
543 onSelectView: (view) => {
544 void (async () => {
545 const before = { current: state.current, count: state.rooms.length, view: state.view };
546 await setView($, view);
547 try { await keepFocusAcrossRoomChange($, before); } catch { /* the box is one click away */ }
548 })();
549 },
550 onShare: (verb) => { void shareFromPane($, verb); },
551 onShareTarget: (id) => {
552 const r = retargetShare(id);
553 state.notice = typeof r === "string" ? r : "";
554 $.ui.invalidate("ui.render");
555 },
556 onCopy: (text, surface) => { void copySnippet($, text, surface); },
557 onRefresh: () => { void refreshGit($); },
558 onToggleBreakdown: () => toggleBreakdown($),
559 onToggleSession: (id) => { if (!state.collapsed.delete(id)) state.collapsed.add(id); $.ui.invalidate("ui.render"); },
560 onCycleFilter: () => { state.feedFilter = { all: "here", here: "errors", errors: "all" }[state.feedFilter]; $.ui.invalidate("ui.render"); },
561 });
562 });
563
564 // While the pane can't be seen (too narrow to place, or closed), a one-line
565 // band above the prompt keeps the room in view. It yields to surveys.
566 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
567 const chatShown = state.auth === "signed_in" && currentRoom();
568 if (e.props?.hasSurvey || (activeView() === "chat" && !chatShown)) return next(e);
569 const panes = await $.ui.panes();
570 if (panes.some((p) => p.id === PANE_ID && p.isPlaced && p.isShown)) return next(e);
571 return bandView($.ui.resolve(e), e.props ?? {}, { onOpen: () => { void openPane($); } });
572 });
573
574 // "auto" do not disturb: a turn still running after LONG_TURN_MS goes quiet
575 // until it completes. Short back-and-forth with Claude never flips it.
576 let turn = null;
577 on("turn.start", ($, e, next) => {
578 const id = e.turnId;
579 turn = id;
580 if (!e.agentId) { turnStarted(state.usage, Date.now()); sysChanged($); }
581 void (async () => {
582 await $.clock.sleep(LONG_TURN_MS);
583 if (turn !== id || state.dnd !== "auto") return;
584 state.working = true;
585 quietChanged($);
586 })();
587 return next(e);
588 });
589
590 on("turn.complete", async ($, e, next) => {
591 const r = await next(e);
592 applyTurnUsage(state.usage, r?.usage); // the main loop's and each subagent's
593 if (e.agentId) { // a subagent finished: the main turn goes on
594 sysChanged($);
595 return r;
596 }
597 turn = null;
598 turnEnded(state.usage, Date.now());
599 sysChanged($);
600 // The context after the turn, for the band's forecast chart: read fresh,
601 // as the status line has it once the turn is done.
602 (async () => {
603 await refreshUsage($);
604 if (recordTurnContext(state.usage)) $.ui.invalidate("ui.render");
605 })().catch(() => {});
606 if (state.working) {
607 state.working = false;
608 quietChanged($);
609 }
610 return r;
611 });
612
613 // The Usage room's figures, as the engine measures them.
614 on("session.measure", async ($, e, next) => {
615 const r = await next(e);
616 applyMeasure(state.usage, e, Date.now());
617 afterChange($);
618 return r;
619 });
620
621 // Each tool call, timed. Passed through untouched: never denied, never changed.
622 on("tool.call", async ($, e, next) => {
623 let key = null;
624 try { key = toolStarted(state.usage, { tool: e.tool, agentId: e.agentId, input: e, at: Date.now() }); sysChanged($); }
625 catch { /* the call matters more than its timing */ }
626 let r;
627 try {
628 r = await next(e);
629 return r;
630 } finally {
631 try {
632 toolEnded(state.usage, key, { at: Date.now(), isError: !r || !!r.isError || !!r.deny });
633 sysChanged($);
634 // A push or a new PR: the Git room catches up once GitHub has.
635 if (e.tool === "Bash" && PUSHED.test(String(e.command ?? ""))) {
636 (async () => { await $.clock.sleep(8_000); await refreshGit($); })().catch(() => {});
637 }
638 } catch { /* as above */ }
639 }
640 }).catch(($, e, next) => next(e)); // pass through: replays the call's own result
641
642 on("agent.spawn", async ($, e, next) => {
643 const r = await next(e);
644 if (r?.agentId) {
645 agentSpawned(state.usage, { id: r.agentId, type: e.subagentType || (e.isTeammate ? "teammate" : "agent"), description: e.description, parentId: e.parentAgentId, at: Date.now() });
646 sysChanged($);
647 }
648 return r;
649 }).catch(($, e, next) => next(e));
650
651 on("ui.close", ($, e, next) => {
652 if (e.id === PANE_ID) {
653 state.paneFocused = false;
654 state.paneShown = false;
655 state.dividerAt.clear();
656 $.ui.invalidate("ui.render");
657 }
658 return next(e);
659 });
660
661 on("session.append", { door: "command" }, ($, e, next) => {
662 if (!privateCommand(e.message)) return next(e);
663 return next({ ...e, message: { ...e.message, content: redact(e.message.content) } });
664 }).catch(($, e, next) => {
665 // Fail closed: if the rewrite threw, store a row with no args at all.
666 if (next.called || !privateCommand(e.message)) return next(e);
667 const name = blockTexts(e.message?.content).map((t) => NAME.exec(t)?.[1]).find(Boolean) ?? "say";
668 return next({ ...e, message: { ...e.message, content: [{ type: "text", text: `<command-name>/${name}</command-name>\n${REDACTED}` }] } });
669 });
670
671 // Best effort: the end chain has one short time bound. Presence never
672 // depends on this; the bridge's dropped connection is what takes us offline.
673 on("session.end", async ($, e, next) => {
674 if (e.reason !== "clear" && e.reason !== "resume") {
675 state.ended = true;
676 try { await callBridge($, "/shutdown", {}); } catch { /* exiting anyway */ }
677 }
678 return next(e);
679 });
680}
681hooks/state.mjs 231 lines1// The mod's one state object, built from the bridge's events. The module's
2// variables start over on every reload; the bridge restarts with it and
3// replays what the state needs (auth, rooms, recent history).
4
5import { createUsage } from "./metrics.mjs";
6import { emptyGit } from "./github.mjs";
7
8export const MAX_MESSAGES = 100; // per room
9
10// The built-in rooms, in tab order. They need no server and no sign-in.
11export const SYS_ROOMS = ["usage", "git", "agents"];
12
13export const state = {
14 bridge: "starting", // starting | ready | restarting | unavailable | unconfigured
15 detail: "", // why the bridge isn't ready
16 socket: null,
17 token: null,
18 auth: "starting", // starting | signed_out | code_sent | signed_in
19 email: null, // where the code went
20 user: null, // { id, name, email }
21 rooms: [], // [{ id, slug, last_read_id, unread }]
22 current: null, // room id
23 messages: new Map(), // room id → [{ id, user, user_id, mine, body, at }] sorted by id
24 online: new Map(), // room id → [{ user_id, name }]
25 typing: new Map(), // room id → [{ user_id, name }] typing there now
26 roomStatus: new Map(), // room id → SUBSCRIBED | CHANNEL_ERROR | ...
27 friends: [], // [{ user_id, name, online, rooms }]
28 notice: "", // last error or hint, shown in the pane
29 draft: "", // what's typed in the pane's input box, kept across redraws
30 paneFocused: false, // the pane holds the keyboard: messages there count as read
31 paneShown: false, // the pane is drawn (the Git room polls faster then)
32 notify: false, // toast @mentions (/chat notify on), kept in $.store
33 mention: null, // newest unseen message that @mentions me, until toasted
34 dnd: "off", // do not disturb: off | on | auto (while Claude works), kept in $.store
35 working: false, // a long Claude turn is running (what "auto" waits for)
36 missed: { messages: 0, mentions: 0 }, // what came in while quiet, for the summary
37 pendingShare: null, // a snippet shown for a look before /share send
38 dividerAt: new Map(), // room id → read marker when the pane last caught up: the "new" line
39 // The built-in rooms.
40 view: "chat", // chat | usage | git | agents: what the pane shows, kept in $.store
41 sysRooms: [...SYS_ROOMS], // which built-in rooms have tabs (/chat rooms), kept in $.store
42 usage: createUsage(), // this session's numbers (metrics.mjs)
43 git: emptyGit(), // the Git room's snapshot (github.mjs)
44 sessionId: null,
45 self: null, // this session's latest heartbeat (sessions.mjs)
46 sessions: [], // other sessions' heartbeats, from the bridge
47 feedFilter: "all", // the Agents room's feed: all | here | errors
48 collapsed: new Set(), // sessions whose agent trees are folded in the Agents room
49 ended: false,
50};
51
52// What the pane shows: a built-in room, or the chat (a hidden room's tab
53// falls back to the chat).
54export function activeView() {
55 return state.view !== "chat" && state.sysRooms.includes(state.view) ? state.view : "chat";
56}
57
58export function currentRoom() {
59 return state.rooms.find((r) => r.id === state.current) ?? null;
60}
61
62export function roomMessages(roomId = state.current) {
63 return state.messages.get(roomId) ?? [];
64}
65
66export function totalUnread() {
67 return state.rooms.reduce((n, r) => n + (r.unread ?? 0), 0);
68}
69
70// "@ann" or "@Ann," mentions ann, not "@anna".
71export function mentions(body, name) {
72 if (!name) return false;
73 const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74 return new RegExp(`(^|[^\\w@])@${escaped}(?![\\w-])`, "i").test(body);
75}
76
77// Do not disturb holds right now: switched on, or "auto" and Claude is busy.
78export function isQuiet() {
79 return state.dnd === "on" || (state.dnd === "auto" && state.working);
80}
81
82// The status line: unread counts, or nothing. While quiet, a 🔕 says so.
83// The built-in rooms add a word only when something needs a look: context
84// nearly full, or this branch's checks failing.
85export function statusText() {
86 const extra = sysStatus();
87 if (state.auth !== "signed_in") return extra || undefined;
88 const unread = state.rooms.filter((r) => r.unread > 0);
89 const counts = unread.map((r) => `#${r.slug} ${r.unread}`).join(" · ");
90 if (isQuiet()) return [`🔕 ${counts || "do not disturb"}`, extra].filter(Boolean).join(" · ");
91 if (!unread.length) return extra || undefined;
92 return [`💬 ${counts}`, extra].filter(Boolean).join(" · ");
93}
94
95function sysStatus() {
96 const bits = [];
97 const ctx = state.usage.context?.percent;
98 if (state.sysRooms.includes("usage") && ctx >= 80) bits.push(`◔ ${Math.round(ctx)}%`);
99 if (state.sysRooms.includes("git") && state.git.status === "ok" && state.git.pr?.checks.fail) bits.push("✗ CI");
100 return bits.join(" · ");
101}
102
103// "💬 Missed 5 messages · 1 @mention", or "" for nothing. Short enough for
104// one line of a toast.
105export function missedText() {
106 const { messages, mentions: at } = state.missed;
107 if (!messages) return "";
108 const s = (n) => (n === 1 ? "" : "s");
109 return `💬 Missed ${messages} message${s(messages)}${at ? ` · ${at} @mention${s(at)}` : ""}`;
110}
111
112// "sam is typing…" for a room, or "".
113export function typingText(roomId = state.current) {
114 const names = (state.typing.get(roomId) ?? []).filter((u) => u.user_id !== state.user?.id).map((u) => u.name);
115 if (!names.length) return "";
116 if (names.length === 1) return `${names[0]} is typing…`;
117 if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
118 return "several people are typing…";
119}
120
121// The latest message in the current room, for the band.
122export function lastMessage() {
123 return roomMessages().at(-1) ?? null;
124}
125
126function addMessage(m) {
127 const list = state.messages.get(m.room) ?? [];
128 if (list.some((x) => x.id === m.id)) return false;
129 // Live and catch-up copies can interleave: keep the list in id order.
130 let i = list.length;
131 while (i > 0 && list[i - 1].id > m.id) i--;
132 list.splice(i, 0, m);
133 if (list.length > MAX_MESSAGES) list.splice(0, list.length - MAX_MESSAGES);
134 state.messages.set(m.room, list);
135 return true;
136}
137
138// Applies one bridge event; returns true when the pane should redraw.
139export function applyEvent(event) {
140 switch (event.type) {
141 case "ready":
142 state.socket = event.socket;
143 // Without a server the bridge runs for the Agents room's heartbeats alone.
144 if (event.chat === false) return true;
145 state.bridge = "ready";
146 state.detail = "";
147 return true;
148 case "auth":
149 state.auth = event.state;
150 state.user = event.user ?? null;
151 if (event.state === "code_sent") state.email = event.email;
152 if (event.state === "signed_in") state.notice = "";
153 if (event.state === "signed_out") {
154 state.email = null;
155 state.messages.clear();
156 state.online.clear();
157 state.typing.clear();
158 state.friends = [];
159 if (event.reason) state.notice = event.reason;
160 }
161 return true;
162 case "rooms": {
163 const known = new Set(event.rooms.map((r) => r.id));
164 for (const id of [...state.messages.keys()]) if (!known.has(id)) state.messages.delete(id);
165 if (event.current !== state.current) {
166 state.notice = ""; // it was about the old room
167 state.dividerAt.clear();
168 }
169 state.rooms = event.rooms;
170 state.current = event.current;
171 return true;
172 }
173 case "message": {
174 const m = event.message;
175 if (!addMessage(m)) return false;
176 // The bridge keeps the unread count (it knows the read marker and what
177 // it has already counted); a focused pane marks the room read at once.
178 const room = state.rooms.find((r) => r.id === m.room);
179 if (room && typeof event.unread === "number") room.unread = event.unread;
180 const seen = m.room === state.current && state.paneFocused;
181 const mentioned = event.counted && !seen && (!m.kind || m.kind === "text") && mentions(m.body, state.user?.name);
182 if (isQuiet()) {
183 // Nothing pops up now: count it for the summary instead.
184 if (event.counted && !seen) state.missed.messages++;
185 if (mentioned) state.missed.mentions++;
186 } else if (mentioned) state.mention = m;
187 return true;
188 }
189 case "presence":
190 state.online.set(event.room, event.online);
191 return true;
192 case "typing":
193 state.typing.set(event.room, event.users);
194 return true;
195 case "name":
196 // Someone changed their name: messages already here show the new one.
197 for (const list of state.messages.values()) {
198 for (const m of list) if (m.user_id === event.user_id) m.user = event.name;
199 }
200 return true;
201 case "friends":
202 state.friends = event.friends;
203 return true;
204 case "status":
205 state.roomStatus.set(event.room, event.status);
206 return true;
207 case "sessions":
208 state.sessions = (event.sessions ?? []).filter((x) => x?.id && x.id !== state.sessionId);
209 return true;
210 case "error":
211 if (event.code === "unconfigured") {
212 state.bridge = "unconfigured";
213 state.detail = event.message;
214 return true;
215 }
216 state.notice = event.message;
217 return true;
218 default:
219 return false;
220 }
221}
222
223// Forget what only a live bridge can vouch for (it is gone or restarting).
224export function resetBridgeState() {
225 state.socket = null;
226 state.auth = "starting";
227 state.roomStatus.clear();
228 state.online.clear();
229 state.typing.clear();
230}
231hooks/commands.mjs 318 lines1// What the person types: slash commands, and the pane's input box (which
2// also takes the same commands, so a passcode or sign-in code typed there
3// never reaches the transcript at all).
4//
5// The hooks module hands in `call(path, body)`, a bridge request, and
6// `say(text)`, the answer: $.ui.log for slash commands (shown, never sent to
7// the model), the pane's notice line for the input box.
8
9import { state, currentRoom, activeView, SYS_ROOMS } from "./state.mjs";
10import { lastCodeBlock, looksLikeDiff, snippetTitle, findSecret, tooBig, displayLines } from "./share.mjs";
11import { snapshotText } from "./sysviews.mjs";
12
13// Commands whose arguments must never reach the model: messages, emails,
14// sign-in codes, room passcodes.
15export const PRIVATE_ARGS = new Set(["say", "room", "chat-login", "chat-share"]);
16
17const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18const CODE = /^\d[\d\s]{4,12}\d$/;
19
20const NAME = /^[A-Za-z0-9_-]{1,24}$/;
21
22// Sign in with an email (a code follows by email) or, where the server
23// allows it, with just a name (an anonymous account).
24export async function login(call, arg, say) {
25 arg = String(arg ?? "").trim();
26 if (state.auth === "signed_in") return say(`Already signed in as ${state.user?.name}. /chat-logout first to switch accounts.`);
27 if (EMAIL.test(arg)) {
28 await call("/login/start", { email: arg });
29 return say(`Code sent to ${arg}. Type it here (or /chat-login <code>).`);
30 }
31 if (CODE.test(arg)) {
32 if (state.auth !== "code_sent") return say("Ask for a code first: /chat-login <email>");
33 // The answer carries the user: the auth event on stdout may land later.
34 const r = await call("/login/verify", { code: arg.replace(/\s+/g, "") });
35 return say(`Signed in as ${r.user?.name ?? "you"}.`);
36 }
37 if (NAME.test(arg) && state.auth !== "code_sent") {
38 const r = await call("/login/name", { name: arg });
39 return say(`Signed in as ${r.user?.name ?? arg}.`);
40 }
41 return say(state.auth === "code_sent"
42 ? `Enter the code emailed to ${state.email}, or another email address.`
43 : "Sign in with your email (/chat-login you@example.com) or a name of 1-24 letters, digits, - or _.");
44}
45
46// A new display name for this account, unique on the server.
47export async function rename(call, arg, say) {
48 arg = String(arg ?? "").trim().replace(/^@/, "");
49 requireSignedIn();
50 if (!arg) return say(`You're ${state.user?.name}. Change it with /chat-name <new name>.`);
51 if (!NAME.test(arg)) return say("A name is 1-24 letters, digits, - or _.");
52 const r = await call("/name", { name: arg });
53 return say(`You're now ${r.name}.`);
54}
55
56let pendingLogout = 0; // until when a second /chat-logout confirms
57
58export async function logout(call, say) {
59 if (state.auth !== "signed_in") return say("Not signed in.");
60 // An account without an email can't be signed back into: ask twice.
61 if (state.user?.anonymous && Date.now() > pendingLogout) {
62 pendingLogout = Date.now() + 30_000;
63 return say(`You signed in with just a name, so signing out loses "${state.user.name}" and your rooms for good. Run /chat-logout again within 30 seconds to confirm.`);
64 }
65 pendingLogout = 0;
66 await call("/logout", {});
67 return say("Signed out.");
68}
69
70export async function room(call, args, say) {
71 const [slug, ...rest] = String(args ?? "").trim().split(/\s+/).filter(Boolean);
72 const passcode = rest.join(" ") || undefined;
73 requireSignedIn();
74 if (!slug) {
75 if (!state.rooms.length) return say("No rooms yet. Create or join one: /room <name> <passcode>");
76 const list = state.rooms.map((r) => `${r.id === state.current ? "▸" : " "} #${r.slug}${r.unread ? ` (${r.unread} unread)` : ""}`);
77 return say(`Rooms:\n${list.join("\n")}`);
78 }
79 // "/room leave <name>" and "/room delete <name>". A room literally called
80 // "leave" or "delete" is still reachable as "/room #leave".
81 if ((slug === "leave" || slug === "delete") && rest.length) return manageRoom(call, slug, rest[0], say);
82
83 const name = slug.replace(/^#/, "").toLowerCase();
84 const joined = state.rooms.find((r) => r.slug === name);
85 if (joined && !passcode) {
86 await call("/room/select", { room: joined.id });
87 return say(`Now in #${name}.`);
88 }
89 const r = await call("/room", { slug: name, passcode });
90 return say(`Now in #${r.slug}.`);
91}
92
93const CONFIRM_MS = 30_000;
94let pendingDelete = null; // { slug, until }: a delete waiting for its second ask
95
96async function manageRoom(call, action, ref, say) {
97 const name = ref.replace(/^#/, "").toLowerCase();
98 const target = state.rooms.find((r) => r.slug === name);
99 if (!target) return say(`You're not in a room called #${name}.`);
100 if (action === "leave") {
101 await call("/room/leave", { room: target.id });
102 return say(`Left #${name}. Rejoin any time with its passcode.`);
103 }
104 // Deleting is for everyone and can't be undone: ask twice.
105 const now = Date.now();
106 if (!pendingDelete || pendingDelete.slug !== name || now > pendingDelete.until) {
107 pendingDelete = { slug: name, until: now + CONFIRM_MS };
108 return say(`This deletes #${name} and all its messages for everyone in it. Run /room delete ${name} again within 30 seconds to confirm.`);
109 }
110 pendingDelete = null;
111 await call("/room/delete", { room: target.id });
112 return say(`Deleted #${name}.`);
113}
114
115export function who(say) {
116 requireSignedIn();
117 if (!state.friends.length) return say("No friends yet: people show up here once you share a room.");
118 const lines = state.friends.map((f) => `${f.busy ? "◐" : f.online ? "●" : "○"} ${f.name}${f.busy ? " (busy)" : ""} ${f.rooms.map((s) => `#${s}`).join(" ")}`);
119 const n = state.friends.filter((f) => f.online).length;
120 return say(`${n} of ${state.friends.length} online\n${lines.join("\n")}`);
121}
122
123const DND_MODES = {
124 on: "Do not disturb is on: no toasts, and your friends see you as busy. /chat dnd off to end it.",
125 off: "Do not disturb is off.",
126 auto: "Do not disturb turns on by itself while Claude works on something longer than 30 seconds.",
127};
128
129// "/chat dnd [on|off|auto]". `setDnd(mode)` keeps the choice and tells the room.
130export async function dnd(args, say, setDnd) {
131 const mode = String(args ?? "").trim().toLowerCase();
132 if (!mode) return say(`Do not disturb: ${state.dnd}. Change it with /chat dnd on, off or auto.`);
133 if (!(mode in DND_MODES)) return say("Use /chat dnd on, off or auto.");
134 await setDnd(mode);
135 return say(DND_MODES[mode]);
136}
137
138// "/chat rooms usage,git": which built-in rooms have tabs. "all" or "none" too.
139// `save(list)` keeps the choice.
140export async function sysRooms(args, say, save) {
141 const arg = String(args ?? "").trim().toLowerCase();
142 if (!arg) return say(`Built-in rooms: ${state.sysRooms.join(", ") || "none"}. Change with /chat rooms usage,git,agents (or all, or none).`);
143 const list = arg === "all" ? [...SYS_ROOMS] : arg === "none" ? [] : arg.split(/[\s,]+/).filter(Boolean);
144 const unknown = list.filter((x) => !SYS_ROOMS.includes(x));
145 if (unknown.length) return say(`No built-in room called ${unknown.join(", ")}. They are usage, git and agents.`);
146 await save(SYS_ROOMS.filter((x) => list.includes(x)));
147 return say(state.sysRooms.length ? `Built-in rooms: ${state.sysRooms.join(", ")}.` : "Built-in rooms hidden.");
148}
149
150const SHARE_HOLD_MS = 120_000; // a preview waits this long for a send
151
152// "/chat-share" (what's selected, or the last code block in Claude's reply),
153// "/chat-share diff [path]", then "/chat-share send" or "/chat-share cancel".
154// A "#room" anywhere picks one of your other rooms; the current one otherwise.
155// (A path that starts with "#" can be written "./#name".)
156// In the pane's box it's "/share" (Claude Code's own /share is taken).
157// Nothing goes out without a look first. `sources` reads the session: selection(),
158// messages() and diff(path), each from the hooks module.
159export async function share(call, args, say, sources) {
160 const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
161 const targets = words.filter((w) => w.startsWith("#"));
162 const [verb, ...rest] = words.filter((w) => !w.startsWith("#"));
163 const c = sources.inPane ? "/share" : "/chat-share";
164 requireSignedIn();
165 if (targets.length > 1) return say("Pick one room to share to.");
166 if (targets.length && verb === "cancel") return say(`${c} cancel takes no room.`);
167 // "/share to #room" points what's waiting at another room, "/share send #room" sends it there.
168 if (verb === "to" || (verb === "send" && targets.length)) {
169 if (!targets.length) return say(`Name the room: ${c} to #room.`);
170 const r = retargetShare(targets[0].slice(1));
171 if (typeof r === "string") return say(r);
172 if (verb === "to") return say(`It will go to #${r.slug}. ${c} send to post it.`);
173 }
174 if (verb === "cancel") {
175 const had = state.pendingShare;
176 state.pendingShare = null;
177 return say(had ? "Dropped it." : "Nothing waiting to share.");
178 }
179 if (verb === "send") return sendShare(call, say, c);
180 let room = currentRoom();
181 if (targets.length) {
182 const name = targets[0].slice(1).toLowerCase();
183 room = state.rooms.find((r) => r.slug === name);
184 if (!room) return say(`You're not in #${name}. Join it first: /room ${name} <passcode>`);
185 }
186 if (!room) return say("Join a room first: /room <name> <passcode>");
187
188 let snippet;
189 if (SYS_ROOMS.includes(verb)) {
190 snippet = { kind: "code", lang: verb, body: snapshotText(verb) };
191 } else if (verb === "diff") {
192 const body = String(await sources.diff(rest.join(" ")) ?? "").replace(/\n$/, "");
193 if (!body.trim()) return say(rest.length ? `No uncommitted changes in ${rest.join(" ")}.` : "No uncommitted changes to share.");
194 snippet = { kind: "diff", lang: "diff", body };
195 } else if (verb) {
196 return say(`Use ${c} [#room], ${c} diff [path] [#room], ${c} usage|git|agents [#room], ${c} to #room, ${c} send [#room] or ${c} cancel.`);
197 } else {
198 const selected = String(await sources.selection() ?? "").replace(/^(\s*\n)+/, "").trimEnd();
199 if (selected.trim()) snippet = looksLikeDiff(selected) ? { kind: "diff", lang: "diff", body: selected } : { kind: "code", lang: null, body: selected };
200 else {
201 const block = lastCodeBlock(await sources.messages());
202 if (!block?.body.trim()) return say(`Select some text first, or ask Claude for some code. ${c} diff shares your uncommitted changes.`);
203 snippet = { kind: "code", ...block };
204 }
205 }
206 const big = tooBig(snippet.body);
207 if (big) return say(`Too big to share: ${big}.${snippet.kind === "diff" ? ` Share one file with ${c} diff <path>.` : " Select a smaller part."}`);
208
209 const secret = findSecret(snippet.body);
210 state.pendingShare = { ...snippet, room: room.id, slug: room.slug, secret, confirmed: false, until: Date.now() + SHARE_HOLD_MS };
211 // The pane draws its own preview card: say only what needs saying.
212 if (sources.inPane) return secret ? say(`⚠ It looks like it has ${secret}. Check it before you send.`) : undefined;
213 const preview = displayLines(snippet.body, snippet.kind).slice(0, 3).map((l) => ` │ ${l.slice(0, 100)}`);
214 return say([
215 `Ready to share to #${room.slug}: ${snippetTitle(snippet)}`,
216 ...preview,
217 ...(secret ? [`⚠ It looks like it has ${secret}. Check it before you send.`] : []),
218 `${c} send to post it, ${c} to #room to pick another room, ${c} cancel to drop it.`,
219 ].join("\n"));
220}
221
222// Points the waiting snippet at another of your rooms, by slug or id.
223// Returns the room, or what went wrong.
224export function retargetShare(nameOrId) {
225 const p = state.pendingShare;
226 if (!p || Date.now() > p.until) return "Nothing waiting to share.";
227 const name = String(nameOrId).toLowerCase();
228 const room = state.rooms.find((r) => r.id === nameOrId || r.slug === name);
229 if (!room) return `You're not in #${name}. Join it first: /room ${name} <passcode>`;
230 if (room.id !== p.room) Object.assign(p, { room: room.id, slug: room.slug, confirmed: false });
231 return room;
232}
233
234async function sendShare(call, say, c) {
235 const p = state.pendingShare;
236 if (!p || Date.now() > p.until) {
237 state.pendingShare = null;
238 return say(`Nothing waiting to share. Start with ${c} or ${c} diff.`);
239 }
240 if (!state.rooms.some((r) => r.id === p.room)) {
241 state.pendingShare = null;
242 return say(`You're no longer in #${p.slug}.`);
243 }
244 // A likely secret takes a second send.
245 if (p.secret && !p.confirmed) {
246 p.confirmed = true;
247 return say(`This looks like it has ${p.secret}. Run ${c} send again to post it anyway, or ${c} cancel.`);
248 }
249 await call("/send", { text: p.body, room: p.room, kind: p.kind, ...(p.lang ? { lang: p.lang } : {}) });
250 state.pendingShare = null;
251 return say(`Shared to #${p.slug}.`);
252}
253
254export async function sendMessage(call, text, say) {
255 const body = String(text ?? "").trim();
256 if (!body) return;
257 requireSignedIn();
258 if (!currentRoom()) return say("Join a room first: /room <name> <passcode>");
259 await call("/send", { text: body, room: state.current });
260}
261
262function requireSignedIn() {
263 if (state.auth === "starting" || state.bridge !== "ready") {
264 throw new Error(state.bridge === "unavailable" ? state.detail : "Chat is still connecting, try again in a moment.");
265 }
266 if (state.auth !== "signed_in") {
267 throw new Error(state.auth === "code_sent"
268 ? `Enter the code emailed to ${state.email} first.`
269 : "Sign in first: /chat-login you@example.com");
270 }
271}
272
273function showView(view, say, setView) {
274 if (!state.sysRooms.includes(view)) return say(`The ${view} room is hidden. Bring it back with /chat rooms all.`);
275 return setView?.(view);
276}
277
278const HELP = "/room [name] [passcode] · /room leave|delete <name> · /who · /name <new name> · /dnd on|off|auto · /share [diff|usage|git|agents] [#room] · /share to #room · /usage · /git · /agents · /chat · /logout · anything else is a message";
279
280// The pane's input box: commands, the sign-in steps, or a message.
281// `setView(id)` shows a built-in room ("chat" for the chat), `refreshGit()`
282// fetches the Git room again.
283export async function paneInput(call, value, say, { setDnd, sources, setView, refreshGit } = {}) {
284 const text = String(value ?? "").trim();
285 if (!text) return;
286 const view = activeView();
287 if (view === "git" && /^r(efresh)?$/i.test(text)) return refreshGit?.();
288 const m = /^\/([\w-]+)\s*([\s\S]*)$/.exec(text);
289 if (m) {
290 const [, cmd, args] = m;
291 switch (cmd) {
292 case "usage": case "git": case "agents": return showView(cmd, say, setView);
293 case "chat": {
294 // "/chat git" typed here does what it does at the prompt.
295 const [sub, ...more] = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
296 if (!sub || sub === "chat") return setView?.("chat");
297 if (SYS_ROOMS.includes(sub)) return showView(sub, say, setView);
298 if (sub === "dnd") return dnd(more.join(" "), say, setDnd);
299 return say("Here, /chat takes usage, git, agents or dnd. Type it at the prompt for the rest.");
300 }
301 case "room": return room(call, args, say);
302 case "who": return who(say);
303 case "login": case "chat-login": return login(call, args, say);
304 case "logout": case "chat-logout": return logout(call, say);
305 case "name": case "chat-name": return rename(call, args, say);
306 case "say": return sendMessage(call, args, say);
307 case "dnd": return dnd(args, say, setDnd);
308 case "share": return share(call, args, say, sources);
309 case "help": return say(HELP);
310 default: return say(`Unknown command /${cmd}. Try /help.`);
311 }
312 }
313 if (view !== "chat") return say(`This is the ${view} room: type a command (/help), or pick a chat room to talk.`);
314 // The pane itself shows each sign-in step, so only errors (thrown) need words.
315 if (state.auth === "signed_out" || state.auth === "code_sent") return login(call, text, () => {});
316 return sendMessage(call, text, say);
317}
318hooks/views.mjs 658 lines1// The chat pane and the band, drawn from state alone (no $ here: the engine
2// follows $ only within the hooks module's own file).
3//
4// Docked (fullscreen, wide terminal):
5// title bar · tabs (built-in rooms, then chat rooms) · FRIENDS card ·
6// room card with grouped messages · input box · key hints
7// A built-in room (Usage, Git, Agents) takes the space under the tabs with
8// its own cards (sysviews.mjs).
9// Inline (above the prompt): one header line, the last few messages, input.
10// Band (pane not up): one line with an Open button.
11
12import { state, currentRoom, roomMessages, lastMessage, totalUnread, typingText, isQuiet, activeView } from "./state.mjs";
13import { theme, nameColor, glyph } from "./theme.mjs";
14import { snippetTitle, oneLine, displayLines } from "./share.mjs";
15import { cells, rowsFor } from "./widgets.mjs";
16import { SYS, sysDock, sysInline, sysBandPieces, sysBadge } from "./sysviews.mjs";
17
18const GROUP_GAP_MS = 5 * 60_000; // same sender within 5 minutes: one group
19
20// ---------------------------------------------------------------- small helpers
21
22function formatTime(iso) {
23 const d = new Date(iso);
24 return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
25}
26
27function dayKey(iso) {
28 const d = new Date(iso);
29 return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
30}
31
32function dayLabel(iso, now = new Date()) {
33 const d = new Date(iso);
34 const start = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
35 const days = Math.round((start(now) - start(d)) / 86_400_000);
36 if (days === 0) return "Today";
37 if (days === 1) return "Yesterday";
38 return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
39}
40
41function connection() {
42 if (state.bridge === "unavailable") return { color: theme.error, text: `${glyph.on} unavailable` };
43 if (state.bridge === "unconfigured") return { color: theme.warn, text: `${glyph.off} no server` };
44 if (state.bridge !== "ready") return { color: theme.warn, text: `${glyph.off} ${state.bridge}` };
45 if (state.auth === "code_sent") return { color: theme.warn, text: `${glyph.off} code sent` };
46 if (state.auth !== "signed_in") return { color: theme.muted, text: `${glyph.off} ${state.auth === "starting" ? "connecting" : "signed out"}` };
47 const room = currentRoom();
48 if (!room) return { color: theme.online, text: `${glyph.on} connected` };
49 const status = state.roomStatus.get(room.id);
50 if (status === "SUBSCRIBED" && isQuiet()) return { color: theme.amber, text: `${glyph.busy} busy` };
51 if (status === "SUBSCRIBED") return { color: theme.online, text: `${glyph.on} live` };
52 return { color: theme.warn, text: `${glyph.off} ${status ? "reconnecting" : "joining"}` };
53}
54
55function displayName(m) {
56 return m.mine ? "you" : m.user;
57}
58
59// ---------------------------------------------------------------- messages
60
61// The room's messages as rows: date separators, the "new" divider, a header
62// per group of messages from one sender, and the bodies. Each row knows how
63// many terminal rows it takes at `width`, so the newest ones can be fitted.
64// A bubble is as wide as its text (plus a cell of padding each side), up to
65// about three quarters of the room's width; longer text wraps inside it.
66function bubbleWidth(body, width) {
67 const max = Math.max(12, Math.floor(width * 0.78));
68 const longest = Math.max(...String(body).split("\n").map(cells));
69 return Math.min(max, longest + 2);
70}
71
72function messageRows(list, width, dividerAt) {
73 const rows = [];
74 let prev = null;
75 let dividerShown = false;
76 for (const m of list) {
77 const newDay = !prev || dayKey(prev.at) !== dayKey(m.at);
78 if (newDay) rows.push({ kind: "day", key: `d${m.id}`, text: dayLabel(m.at), height: 1 });
79 const isNew = dividerAt > 0 && !dividerShown && m.id > dividerAt && !m.mine;
80 if (isNew) {
81 rows.push({ kind: "new", key: `n${m.id}`, height: 1 });
82 dividerShown = true;
83 }
84 const sameGroup = prev && !newDay && !isNew && prev.user_id === m.user_id
85 && Date.parse(m.at) - Date.parse(prev.at) < GROUP_GAP_MS;
86 if (!sameGroup) rows.push({ kind: "head", key: `h${m.id}`, m, height: 2 }); // a blank row, then the header
87 if (m.kind === "code" || m.kind === "diff") {
88 const lines = snippetLines(m.body, m.kind);
89 const gap = sameGroup ? 1 : 0; // a blank row from the message above
90 rows.push({ kind: "snippet", key: `m${m.id}`, m, ...lines, gap, height: gap + 1 + lines.shown.length + (lines.more ? 1 : 0) });
91 } else {
92 const bubble = bubbleWidth(m.body, width);
93 rows.push({ kind: "body", key: `m${m.id}`, m, bubble, height: rowsFor(m.body, bubble - 2) });
94 }
95 prev = m;
96 }
97 return rows;
98}
99
100// A snippet shows its first lines, one terminal row each (never wrapped, so
101// its height is known), and how many more there are. Copy takes it all.
102const SNIPPET_ROWS = 8;
103function snippetLines(body, kind = "code", max = SNIPPET_ROWS) {
104 const all = displayLines(body, kind);
105 return { shown: all.slice(0, max), more: Math.max(0, all.length - max) };
106}
107
108function diffColor(line) {
109 if (line.startsWith("▸ ")) return theme.muted;
110 if (line.startsWith("@@")) return theme.sky;
111 if (line.startsWith("+")) return theme.online;
112 if (line.startsWith("-")) return theme.error;
113 return theme.theirText;
114}
115
116// The snippet's lines on a dark fill, diffs colored line by line.
117function snippetBody(els, { kind, shown, more }) {
118 const { Text } = els;
119 return [
120 ...shown.map((line, i) => Text({ key: `l${i}`, wrap: "truncate-end", color: kind === "diff" ? diffColor(line) : theme.theirText, children: line || " " })),
121 ...(more ? [Text({ key: "more", color: theme.muted, children: `… ${more} more line${more === 1 ? "" : "s"}` })] : []),
122 ];
123}
124
125// The newest rows that fit in `capacity` terminal rows, never starting
126// with message bodies whose sender header didn't fit.
127function fitRows(rows, capacity) {
128 const shown = [];
129 let used = 0;
130 let i = rows.length - 1;
131 for (; i >= 0; i--) {
132 if (used + rows[i].height > capacity && shown.length) break;
133 used += rows[i].height;
134 shown.unshift(rows[i]);
135 }
136 // A snippet that would open the view without its sender's header (it, or
137 // the header, didn't fit) shows fewer lines under a header instead of
138 // leaving the space empty. First the snippet that didn't fit, else the one
139 // that lost its header.
140 const shrink = (at) => {
141 const row = rows[at];
142 let room = capacity - used;
143 if (shown[0] === row) room += row.height;
144 const own = rows[at - 1]?.kind === "head";
145 const head = own ? rows[at - 1] : { kind: "head", key: `h${row.m.id}-cut`, m: row.m, height: 2 };
146 const gap = own ? row.gap : 0; // a header of its own sits right above it
147 const all = displayLines(row.m.body, row.m.kind);
148 const lines = room - head.height - gap - 1; // below its title
149 const fit = all.length <= lines ? all.length : lines - 1; // keep a row for "… more"
150 if (fit < 2) return false;
151 if (shown[0] === row) { shown.shift(); used -= row.height; }
152 const more = all.length - fit;
153 const cut = { ...row, gap, shown: all.slice(0, fit), more, height: gap + 1 + fit + (more ? 1 : 0) };
154 shown.unshift(head, cut);
155 used += head.height + cut.height;
156 return true;
157 };
158 if (!(i >= 0 && rows[i].kind === "snippet" && shrink(i)) && shown[0]?.kind === "snippet" && shown.length > 1) shrink(i + 1);
159 // Don't open on a group that has lost its header: start at the next one.
160 while (shown.length > 1 && (shown[0].kind === "body" || shown[0].kind === "snippet")) shown.shift();
161 return shown;
162}
163
164function drawRow(els, row, width, onCopy) {
165 const { Box, Text, Button } = els;
166 switch (row.kind) {
167 case "snippet":
168 // Full width whoever sent it: code reads left to right.
169 return Box({ key: row.key, flexShrink: 0, flexDirection: "column", paddingX: 1, marginTop: row.gap, backgroundColor: theme.theirBubble, children: [
170 Box({ key: "head", flexDirection: "row", justifyContent: "space-between", gap: 1, children: [
171 Text({ key: "t", bold: true, color: theme.amber, wrap: "truncate-end", children: `📎 ${snippetTitle(row.m)}` }),
172 onCopy ? Button({ key: "copy", plain: true, label: "Copy", onPress: (press) => onCopy(row.m.body, press?.surface) }) : null,
173 ].filter(Boolean) }),
174 ...snippetBody(els, { kind: row.m.kind, shown: row.shown, more: row.more }),
175 ] });
176 case "day":
177 return Box({ key: row.key, flexShrink: 0, justifyContent: "center", children: [
178 Text({ key: "t", color: theme.muted, children: `${glyph.rule.repeat(3)} ${row.text} ${glyph.rule.repeat(3)}` }),
179 ] });
180 case "new":
181 return Box({ key: row.key, flexShrink: 0, flexDirection: "row", children: [
182 Text({ key: "t", color: theme.accent, bold: true, children: "new " }),
183 Text({ key: "r", color: theme.accent, children: glyph.rule.repeat(Math.max(4, width - 4)) }),
184 ] });
185 case "head":
186 // Theirs: "name 12:04" on the left. Yours: "12:04 you" on the right.
187 return Box({ key: row.key, flexShrink: 0, flexDirection: "row", gap: 1, marginTop: 1,
188 justifyContent: row.m.mine ? "flex-end" : "flex-start", children: row.m.mine ? [
189 Text({ key: "t", color: theme.muted, children: formatTime(row.m.at) }),
190 Text({ key: "n", bold: true, color: theme.you, children: "you" }),
191 ] : [
192 Text({ key: "n", bold: true, color: nameColor(row.m.user_id), wrap: "truncate-end", children: displayName(row.m) }),
193 Text({ key: "t", color: theme.muted, children: formatTime(row.m.at) }),
194 ] });
195 default: {
196 // A bubble: yours on the right in the accent, theirs on the left in grey.
197 const mine = row.m.mine;
198 return Box({ key: row.key, flexShrink: 0, flexDirection: "row", justifyContent: mine ? "flex-end" : "flex-start", children: [
199 Box({ key: "bubble", flexDirection: "column", width: row.bubble, paddingX: 1,
200 backgroundColor: mine ? theme.mineBubble : theme.theirBubble, children: [
201 Text({ key: "b", wrap: "wrap", color: mine ? theme.mineText : theme.theirText, children: row.m.body }),
202 ] }),
203 ] });
204 }
205 }
206}
207
208// ---------------------------------------------------------------- building blocks
209
210// A rounded card, full width: a bold label and right-aligned meta on its
211// first row. `grow` makes it take the pane's spare height, its content kept
212// at the bottom (a chat reads upward from the input).
213function card(els, { key, title, titleColor, meta, metaColor, children, grow = false, marginTop = 0 }) {
214 const { Box, Text } = els;
215 const head = Box({ key: "head", flexDirection: "row", justifyContent: "space-between", gap: 1, children: [
216 Text({ key: "title", bold: true, color: titleColor, wrap: "truncate-end", children: title }),
217 meta ? Text({ key: "meta", color: metaColor ?? theme.muted, children: meta }) : null,
218 ].filter(Boolean) });
219 const body = grow
220 ? [Box({ key: "body", flexDirection: "column", flexGrow: 1, justifyContent: "flex-end", overflow: "hidden", children })]
221 : children;
222 return Box({
223 key, flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop,
224 flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, children: [head, ...body],
225 });
226}
227
228function titleBar(els) {
229 const { Box, Text } = els;
230 const conn = connection();
231 const who = state.auth === "signed_in" && state.user ? state.user.name : null;
232 return Box({ key: "titlebar", flexDirection: "row", justifyContent: "space-between", children: [
233 Box({ key: "brand", flexDirection: "row", gap: 1, children: [
234 Text({ key: "logo", color: theme.accent, bold: true, children: `${glyph.brand} squad-chat` }),
235 who ? Text({ key: "me", color: theme.muted, wrap: "truncate-end", children: who }) : null,
236 ].filter(Boolean) }),
237 Text({ key: "conn", color: conn.color, children: conn.text }),
238 ] });
239}
240
241// The tabs: built-in rooms first, each in its own color, then a thin rule
242// and the chat rooms. The tab on show is filled with its color; the others
243// are pressable and carry a badge when something there is worth a glance.
244function roomTabs(els, handlers) {
245 const { Box, Text, Button } = els;
246 const view = activeView();
247 const tabs = [];
248 for (const id of state.sysRooms) {
249 const meta = SYS[id];
250 const badge = view === id ? null : sysBadge(id);
251 if (view === id) {
252 tabs.push(Text({ key: `sys-${id}`, bold: true, color: theme.onAccent, backgroundColor: meta.color, children: ` ${meta.icon} ${meta.label} ` }));
253 continue;
254 }
255 const tab = Button({ key: `sys-${id}`, plain: true, label: ` ${meta.icon} ${meta.label}`, dimColor: !badge, onPress: () => handlers.onSelectView?.(id) });
256 tabs.push(badge ? Box({ key: `sysbox-${id}`, flexDirection: "row", gap: 1, children: [tab, Text({ key: "badge", bold: true, color: badge.color, children: badge.text })] }) : tab);
257 }
258 const chatTabs = [];
259 if (state.auth === "signed_in" && state.rooms.length) {
260 for (const r of state.rooms) {
261 if (r.id === state.current && view === "chat") {
262 chatTabs.push(Text({ key: `tab-${r.id}`, bold: true, color: theme.onAccent, backgroundColor: theme.accent, children: ` #${r.slug} ` }));
263 continue;
264 }
265 const tab = Button({ key: `tab-${r.id}`, plain: true, label: ` #${r.slug}`, dimColor: !r.unread, onPress: () => handlers.onSelectRoom(r.id) });
266 chatTabs.push(r.unread
267 ? Box({ key: `tabbox-${r.id}`, flexDirection: "row", gap: 1, children: [tab, Text({ key: "badge", bold: true, color: theme.amber, children: String(r.unread) })] })
268 : tab);
269 }
270 } else if (state.sysRooms.length) {
271 // Signed out or no room yet: one tab back to the chat's own screens.
272 chatTabs.push(view === "chat"
273 ? Text({ key: "tab-chat", bold: true, color: theme.onAccent, backgroundColor: theme.accent, children: " # chat " })
274 : Button({ key: "tab-chat", plain: true, dimColor: true, label: " # chat", onPress: () => handlers.onSelectView?.("chat") }));
275 }
276 const rule = tabs.length && chatTabs.length ? [Text({ key: "rule", color: theme.muted, children: glyph.bar })] : [];
277 return Box({ key: "tabs", flexDirection: "row", flexWrap: "wrap", columnGap: 1, children: [...tabs, ...rule, ...chatTabs] });
278}
279
280// How many rows the tabs take at `width`: they wrap like words.
281function tabRows(width) {
282 const view = activeView();
283 const widths = state.sysRooms.map((id) => {
284 const badge = view === id ? null : sysBadge(id);
285 return cells(` ${SYS[id].icon} ${SYS[id].label}${view === id ? " " : ""}`) + (badge ? 1 + cells(badge.text) : 0);
286 });
287 if (widths.length) widths.push(1); // the rule
288 if (state.auth === "signed_in" && state.rooms.length) {
289 for (const r of state.rooms) widths.push(cells(` #${r.slug} `) + (r.unread ? 1 + String(r.unread).length : 0));
290 } else if (state.sysRooms.length) widths.push(8);
291 let rows = 1;
292 let used = 0;
293 for (const w of widths) {
294 if (used && used + 1 + w > width) { rows++; used = w; } else used += (used ? 1 : 0) + w;
295 }
296 return rows;
297}
298
299function friendsCard(els, width) {
300 const { Box, Text } = els;
301 const friends = state.friends;
302 const online = friends.filter((f) => f.online).length;
303 const chips = friends.map((f) => Box({ key: f.user_id, flexDirection: "row", children: [
304 Text({ key: "dot", color: f.busy ? theme.amber : f.online ? theme.online : theme.muted, children: `${f.busy ? glyph.busy : f.online ? glyph.on : glyph.off} ` }),
305 Text({ key: "name", color: f.online ? undefined : theme.muted, children: f.busy ? `${f.name} (busy)` : f.name }),
306 ] }));
307 const body = friends.length
308 ? Box({ key: "list", flexDirection: "row", flexWrap: "wrap", columnGap: 2, children: chips })
309 : Text({ key: "none", color: theme.muted, children: "Share a room's name and passcode to add friends." });
310 const textLen = friends.reduce((n, f) => n + f.name.length + (f.busy ? 7 : 0) + 4, 0);
311 const height = 3 + (friends.length ? rowsFor("x".repeat(textLen), width - 4) : rowsFor("Share a room's name and passcode to add friends.", width - 4));
312 return { node: card(els, { key: "friends", title: "FRIENDS", meta: `${online}/${friends.length} online`, marginTop: 1, children: [body] }), height };
313}
314
315// The terminal lights the frame while the pane holds the keyboard. The
316// desktop's field draws its own focus, and a tree that changed with focus
317// there made the box blink as focus moved, so it stays one color and the
318// field waits for a click instead of taking the focus on every draw.
319function inputBox(els, mode, { onSubmit, onInput }, focused, surface) {
320 const { Box, Text, Input } = els;
321 const terminal = surface !== "desktop";
322 return Box({ key: "compose-box", flexDirection: "row", marginTop: 1, flexShrink: 0, borderStyle: "round", borderColor: terminal && !focused ? theme.border : theme.accent, paddingX: 1, children: [
323 Text({ key: "prompt", color: theme.accent, bold: true, children: `${glyph.prompt} ` }),
324 Box({ key: "field", flexGrow: 1, minWidth: 0, children: [
325 Input({ key: "compose", placeholder: mode.placeholder, submitLabel: mode.label, value: state.draft ?? "", ...(terminal ? { autoFocus: true } : {}), onInput, onSubmit }),
326 ] }),
327 ] });
328}
329
330function hints(els, text) {
331 const { Text } = els;
332 return Text({ key: "hints", color: theme.muted, wrap: "truncate-end", children: text });
333}
334
335// "sam is typing…", in the row the key hints use, so nothing moves.
336function typingLine(els, text) {
337 const { Text } = els;
338 return Text({ key: "hints", color: theme.muted, italic: true, wrap: "truncate-end", children: `${glyph.typing} ${text}` });
339}
340
341function notice(els) {
342 const { Text } = els;
343 return state.notice ? Text({ key: "notice", color: theme.warn, wrap: "wrap", children: state.notice }) : null;
344}
345
346// What the input box is for right now.
347const SYS_INPUT = {
348 usage: { placeholder: "/git · /agents · /share usage [#room] · /help", label: "run" },
349 git: { placeholder: "r to refresh · /share git [#room] · /help", label: "run" },
350 agents: { placeholder: "/usage · /git · /share agents [#room] · /help", label: "run" },
351};
352function inputMode() {
353 const view = activeView();
354 if (view !== "chat") return SYS_INPUT[view];
355 if (state.bridge !== "ready") return null;
356 if (state.auth === "signed_out") return { placeholder: "you@example.com, or a name", label: "continue" };
357 if (state.auth === "code_sent") return { placeholder: "8-digit code from the email", label: "sign in" };
358 if (state.auth !== "signed_in") return null;
359 if (!currentRoom()) return { placeholder: "/room <name> <passcode>", label: "join" };
360 return { placeholder: "Message", label: "send" };
361}
362
363// Screens before there's a room to show: sign-in steps, errors, waiting.
364function setupCard(els, width) {
365 const { Text } = els;
366 const line = (key, children, extra = {}) => Text({ key, wrap: "wrap", children, ...extra });
367 if (state.bridge === "unavailable") {
368 return card(els, { key: "setup", marginTop: 1, title: "CAN'T START", titleColor: theme.error, children: [line("why", state.detail)] });
369 }
370 if (state.bridge === "unconfigured") {
371 return card(els, { key: "setup", marginTop: 1, title: "CONNECT A SERVER", titleColor: theme.accent, children: [
372 line("a", "squad-chat talks to your squad's own Supabase server."),
373 line("b", "Ask whoever runs it for the URL and publishable key, or host one yourself (see the README)."),
374 line("c", "Then set them in /config under squad-chat, or run:", { color: theme.muted }),
375 line("d", "claude plugin configure squad-chat@squad-chat", { color: theme.accent }),
376 ] });
377 }
378 if (state.bridge !== "ready" || state.auth === "starting") {
379 return card(els, { key: "setup", marginTop: 1, title: "CONNECTING", meta: "…", children: [line("wait", state.detail || "Starting the chat in the background.", { color: theme.muted })] });
380 }
381 if (state.auth === "signed_out") {
382 return card(els, { key: "setup", marginTop: 1, title: "SIGN IN", meta: "step 1 of 2", children: [
383 line("a", "Chat with your friends while you code."),
384 line("b", "Enter your email to get a sign-in code, or just pick a name if your server allows it.", { color: theme.muted }),
385 ] });
386 }
387 if (state.auth === "code_sent") {
388 return card(els, { key: "setup", marginTop: 1, title: "SIGN IN", meta: "step 2 of 2", children: [
389 line("a", `Code sent to ${state.email}.`),
390 line("b", "Type it below. Wrong address? Enter another email.", { color: theme.muted }),
391 ] });
392 }
393 return card(els, { key: "setup", marginTop: 1, title: "JOIN A ROOM", children: [
394 line("a", "Create a room, or join a friend's with its passcode:"),
395 line("b", "/room <name> <passcode>", { color: theme.accent }),
396 line("c", "Share the name and passcode with whoever you want in it.", { color: theme.muted }),
397 ] });
398}
399
400// ---------------------------------------------------------------- the pane
401
402// `els` is the surface's element table ($.ui.resolve(e)), `props` the Pane's.
403export function paneView(els, props, handlers) {
404 return props.placement === "inline" ? inlineView(els, props, handlers) : dockView(els, props, handlers);
405}
406
407function dockView(els, props, handlers) {
408 const { Box, Text } = els;
409 const width = Math.max(24, (props.bodyColumns || 48));
410 const bodyRows = props.scroll?.bodyRows || 30;
411 const room = currentRoom();
412 const mode = inputMode();
413 const view = activeView();
414 const parts = [titleBar(els)];
415 let used = 1;
416
417 if (state.sysRooms.length || (state.auth === "signed_in" && state.rooms.length)) {
418 parts.push(roomTabs(els, handlers));
419 used += tabRows(width);
420 }
421
422 if (view !== "chat") {
423 const n = notice(els);
424 const pending = shareCard(els, handlers, width);
425 const tail = 1 + 3 + 1 + (n ? rowsFor(state.notice, width) : 0) + (pending?.height ?? 0); // gap + input box + hints + notice + preview
426 const capacity = Math.max(4, bodyRows - used - tail - 1); // one row the dock reserves
427 parts.push(...sysDock(els, view, width, capacity, handlers));
428 parts.push(Box({ key: "spacer", flexGrow: 1 }));
429 if (pending) parts.push(pending.node);
430 if (n) parts.push(n);
431 parts.push(inputBox(els, mode, handlers, props.isFocused, props.surface));
432 parts.push(sysHints(els, view, handlers));
433 } else if (state.auth === "signed_in" && room) {
434 const friends = friendsCard(els, width);
435 parts.push(friends.node);
436 used += 1 + friends.height;
437
438 const n = notice(els);
439 const pending = shareCard(els, handlers, width);
440 const tail = 1 + 3 + 1 + (n ? rowsFor(state.notice, width) : 0) + (pending?.height ?? 0); // gap + input box + hints + notice + preview
441 // The gap above the card, its border and head, and one row the dock reserves (its close mark).
442 const capacity = Math.max(3, bodyRows - used - tail - 1 - 3 - 1);
443 const here = (state.online.get(room.id) ?? []).filter((u) => u.user_id !== state.user?.id).length;
444 const list = roomMessages();
445 const rows = fitRows(messageRows(list, width - 4, state.dividerAt?.get(room.id) ?? 0), capacity);
446 const body = list.length
447 ? rows.map((r) => drawRow(els, r, width - 4, handlers.onCopy)) // inside the card's border and padding
448 : [Text({ key: "empty", color: theme.muted, children: "No messages yet. Say hi!" })];
449 parts.push(card(els, {
450 key: "room", title: `#${room.slug}`, titleColor: theme.accent, marginTop: 1,
451 meta: here ? `${here} here` : "just you", metaColor: here ? theme.online : theme.muted,
452 children: body, grow: true,
453 }));
454 if (pending) parts.push(pending.node);
455 if (n) parts.push(n);
456 if (mode) parts.push(inputBox(els, mode, handlers, props.isFocused, props.surface));
457 const typing = typingText(room.id);
458 parts.push(typing ? typingLine(els, typing) : hints(els, "enter send · esc back · /room · /who · /help"));
459 } else {
460 parts.push(setupCard(els, width));
461 const n = notice(els);
462 if (n) parts.push(n);
463 if (mode) parts.push(inputBox(els, mode, handlers, props.isFocused, props.surface));
464 if (mode) parts.push(hints(els, state.auth === "signed_in" ? "you can type /room right here" : "enter to continue · esc back"));
465 }
466
467 // Fill the dock's height so the input sits at the bottom.
468 return Box({ flexDirection: "column", height: bodyRows, children: parts });
469}
470
471const SYS_HINTS = {
472 usage: "Context ▸ breaks it down",
473 git: "↻ or r refreshes · ⧉ copies a link",
474 agents: "▾ folds a session · all ▾ filters the feed",
475};
476
477// A built-in room's hints, after a Share button that posts its snapshot
478// (the preview card picks the room).
479function sysHints(els, view, handlers) {
480 const { Box, Text, Button } = els;
481 return Box({ key: "hints", flexDirection: "row", gap: 1, children: [
482 Box({ key: "share", flexShrink: 0, children: [
483 Button({ key: "share-snapshot", plain: true, label: "⇪ Share", onPress: () => handlers.onShare?.(view) }),
484 ] }),
485 Text({ key: "t", color: theme.muted, wrap: "truncate-end", children: `· ${SYS_HINTS[view]}` }),
486 ] });
487}
488
489// The rooms a waiting snippet can go to, the chosen one lit. Rooms that
490// don't fit the width are left out, except the chosen one.
491function shareTargets(els, p, width, handlers) {
492 const { Box, Text, Button } = els;
493 if (state.rooms.length < 2) return null;
494 let left = width - 4 - 4; // inside the card, after "to: "
495 const picks = [];
496 for (const r of [...state.rooms].sort((a, b) => (b.id === p.room) - (a.id === p.room))) {
497 const w = r.slug.length + 2;
498 if (w > left && r.id !== p.room) continue;
499 left -= w;
500 picks.push(r);
501 }
502 picks.sort((a, b) => state.rooms.indexOf(a) - state.rooms.indexOf(b));
503 return Box({ key: "to", flexDirection: "row", children: [
504 Text({ key: "label", color: theme.muted, children: "to: " }),
505 ...picks.map((r) => Box({ key: `pick-${r.id}`, flexShrink: 0, children: [
506 Button({ key: `to-${r.id}`, plain: true, label: `#${r.slug} `, dimColor: r.id !== p.room, onPress: () => handlers.onShareTarget?.(r.id) }),
507 ] })),
508 ] });
509}
510
511// A snippet waiting for a look before it goes out: where it goes, Send and Cancel.
512const PREVIEW_ROWS = 5;
513function shareCard(els, handlers, width = 48) {
514 const p = state.pendingShare;
515 if (!p || Date.now() > p.until) return null;
516 const { Box, Text, Button } = els;
517 const lines = snippetLines(p.body, p.kind, PREVIEW_ROWS);
518 const targets = shareTargets(els, p, width, handlers);
519 const children = [
520 Box({ key: "code", flexDirection: "column", paddingX: 1, backgroundColor: theme.theirBubble, children: snippetBody(els, { kind: p.kind, ...lines }) }),
521 p.secret ? Text({ key: "secret", color: theme.warn, wrap: "truncate-end", children: `⚠ looks like it has ${p.secret}` }) : null,
522 targets,
523 Box({ key: "actions", flexDirection: "row", gap: 2, children: [
524 Button({ key: "send", label: `Send to #${p.slug}`, variant: "primary", onPress: () => handlers.onShare?.("send") }),
525 Button({ key: "cancel", label: "Cancel", onPress: () => handlers.onShare?.("cancel") }),
526 ] }),
527 ].filter(Boolean);
528 const height = 1 + 3 + lines.shown.length + (lines.more ? 1 : 0) + (p.secret ? 1 : 0) + (targets ? 1 : 0) + 1; // the gap above it first
529 return { node: card(els, { key: "share", marginTop: 1, title: "SHARE?", titleColor: theme.amber, meta: snippetTitle(p), children }), height };
530}
531
532// The compact pane's one line for a waiting snippet.
533function pendingLine(els) {
534 const p = state.pendingShare;
535 if (!p || Date.now() > p.until) return null;
536 const to = state.rooms.length > 1 ? ", /share to #room" : "";
537 return els.Text({ key: "share", color: theme.amber, wrap: "truncate-end", children: `📎 ${snippetTitle(p)} for #${p.slug}: /share send${to} or /share cancel` });
538}
539
540// Above the prompt: as little as reads well.
541function inlineView(els, props, handlers) {
542 const { Box, Text } = els;
543 const width = Math.max(24, (props.bodyColumns || 48));
544 const room = currentRoom();
545 const mode = inputMode();
546 const conn = connection();
547 const view = activeView();
548 const parts = [];
549
550 if (view !== "chat") {
551 parts.push(...sysInline(els, view, width, handlers));
552 const share = pendingLine(els);
553 if (share) parts.push(share);
554 } else if (state.auth === "signed_in" && room) {
555 const names = (state.online.get(room.id) ?? []).filter((u) => u.user_id !== state.user?.id).map((u) => u.name);
556 parts.push(Box({ key: "header", flexDirection: "row", justifyContent: "space-between", children: [
557 Box({ key: "left", flexDirection: "row", gap: 1, flexShrink: 1, children: [
558 Text({ key: "room", bold: true, color: theme.accent, children: `${glyph.brand} #${room.slug}` }),
559 Text({ key: "online", color: names.length ? theme.online : theme.muted, wrap: "truncate-end", children: names.length ? `${glyph.on} ${names.join(", ")}` : "just you" }),
560 ] }),
561 Text({ key: "conn", color: conn.color, children: conn.text }),
562 ] }));
563 const list = roomMessages().slice(-5);
564 if (!list.length) parts.push(Text({ key: "empty", color: theme.muted, children: "No messages yet. Say hi!" }));
565 // A name column, filled only where the sender changes.
566 const nameWidth = Math.min(12, Math.max(...list.map((m) => displayName(m).length), 3));
567 let prevUser = null;
568 for (const m of list) {
569 const name = m.user_id === prevUser ? "" : displayName(m).slice(0, nameWidth);
570 prevUser = m.user_id;
571 parts.push(Box({ key: `m${m.id}`, flexDirection: "row", gap: 1, children: [
572 Box({ key: "n", width: nameWidth, flexShrink: 0, children: [
573 Text({ key: "t", bold: true, color: m.mine ? theme.you : nameColor(m.user_id), children: name }),
574 ] }),
575 Text({ key: "b", wrap: "truncate-end", children: oneLine(m) }),
576 ] }));
577 }
578 const typing = typingText(room.id);
579 if (typing) parts.push(typingLine(els, typing));
580 const share = pendingLine(els);
581 if (share) parts.push(share);
582 } else {
583 parts.push(titleBar(els));
584 parts.push(setupCard(els, width));
585 }
586 const n = notice(els);
587 if (n) parts.push(n);
588 if (mode) {
589 const { Input } = els;
590 parts.push(Box({ key: "compose-row", flexDirection: "row", marginTop: 0, children: [
591 Text({ key: "prompt", color: theme.accent, bold: true, children: `${glyph.prompt} ` }),
592 Box({ key: "field", flexGrow: 1, children: [
593 Input({ key: "compose", placeholder: mode.placeholder, submitLabel: mode.label, value: state.draft ?? "", autoFocus: true, onInput: handlers.onInput, onSubmit: handlers.onSubmit }),
594 ] }),
595 ] }));
596 }
597 return Box({ flexDirection: "column", children: parts });
598}
599
600// ---------------------------------------------------------------- the band
601
602// One line above the prompt while the pane can't be seen (terminal too
603// narrow, or the pane closed): room, who's online, unread, the latest message.
604export function bandView(els, props, { onOpen }) {
605 const { Box, Text, Button } = els;
606 const view = activeView();
607 if (view !== "chat") {
608 const meta = SYS[view];
609 return Box({ flexDirection: "row", children: [
610 Box({ key: "line", flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1, children: [
611 Box({ key: "room", flexShrink: 0, children: [Text({ key: "t", bold: true, color: meta.color, children: `${meta.icon} ${meta.label}` })] }),
612 Box({ key: "sep", flexShrink: 0, children: [Text({ key: "t", color: theme.muted, children: glyph.bar })] }),
613 Box({ key: "msg", flexDirection: "row", flexShrink: 1, flexGrow: 1, minWidth: 0, overflow: "hidden", children: sysBandPieces(view, Math.max(20, (props.bodyColumns || 100) - cells(`${meta.icon} ${meta.label}`) - 16)).map((p, i) => (
614 Box({ key: `p${i}`, flexShrink: 0, children: [Text({ key: "t", color: p.color, bold: p.bold, children: p.text })] }))) }),
615 ] }),
616 Box({ key: "open-box", flexShrink: 0, marginLeft: 1, children: [
617 Button({ key: "open", label: "Open", variant: "primary", onPress: onOpen }),
618 ] }),
619 ] });
620 }
621 const room = currentRoom();
622 const here = (state.online.get(room?.id) ?? []).filter((u) => u.user_id !== state.user?.id).length;
623 const unreadHere = room.unread ?? 0;
624 const unreadElsewhere = totalUnread() - unreadHere;
625 const live = state.bridge === "ready" && state.roomStatus.get(room.id) === "SUBSCRIBED";
626 const last = lastMessage();
627 const typing = typingText(room.id);
628 // Everything but the message keeps its size; the message gives way.
629 const fixed = (key, children, style = {}) => Box({ key, flexShrink: 0, children: [Text({ key: "t", children, ...style })] });
630 const bits = [
631 fixed("room", `${glyph.brand} #${room.slug}`, { bold: true, color: theme.accent }),
632 fixed("online", live ? `${glyph.on} ${here}` : `${glyph.off} reconnecting`, { color: live ? (here ? theme.online : theme.muted) : theme.warn }),
633 ];
634 if (isQuiet()) {
635 // Do not disturb: counts stay, in grey, and no message text pulls the eye.
636 const n = unreadHere + unreadElsewhere;
637 bits.push(fixed("quiet", `${glyph.quiet} ${n ? `${n} new · ` : ""}do not disturb`, { color: theme.muted }));
638 bits.push(Box({ key: "msg", flexGrow: 1, children: [] }));
639 } else {
640 if (unreadHere) bits.push(fixed("unread", ` ${unreadHere} new `, { bold: true, color: theme.onAccent, backgroundColor: theme.accent }));
641 if (unreadElsewhere) bits.push(fixed("elsewhere", `+${unreadElsewhere}`, { color: theme.muted }));
642 if (typing) {
643 bits.push(fixed("sep", glyph.bar, { color: theme.muted }));
644 bits.push(Box({ key: "msg", flexShrink: 1, flexGrow: 1, minWidth: 0, children: [Text({ key: "t", color: theme.muted, italic: true, wrap: "truncate-end", children: typing })] }));
645 } else if (last) {
646 bits.push(fixed("sep", glyph.bar, { color: theme.muted }));
647 bits.push(fixed("who", displayName(last), { bold: true, color: last.mine ? theme.you : nameColor(last.user_id) }));
648 bits.push(Box({ key: "msg", flexShrink: 1, flexGrow: 1, minWidth: 0, children: [Text({ key: "t", wrap: "truncate-end", children: oneLine(last) })] }));
649 }
650 }
651 return Box({ flexDirection: "row", children: [
652 Box({ key: "line", flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1, children: bits }),
653 Box({ key: "open-box", flexShrink: 0, marginLeft: 1, children: [
654 Button({ key: "open", label: "Open", variant: "primary", onPress: onOpen }),
655 ] }),
656 ] });
657}
658hooks/metrics.mjs 294 lines1// This session's numbers for the Usage and Agents rooms: what the engine
2// measures (context, rate limits, cost), what each turn used (tokens, cache),
3// how long each tool call took, and which subagents ran. Plain logic: the
4// hooks module feeds it from session.measure, turn.complete, tool.call and
5// agent.spawn.
6
7import { findSecret } from "./share.mjs";
8
9const COST_SAMPLES = 240; // about two hours at one sample per 30 s
10const SAMPLE_EVERY_MS = 30_000;
11const DURATIONS = 200; // per tool, for the percentiles
12const FEED = 40; // newest tool calls kept for the live feed
13const BURN_WINDOW_MS = 15 * 60_000;
14
15export function createUsage() {
16 return {
17 measured: false,
18 context: null, // { tokens, window, percent }
19 breakdown: null, // the /context breakdown, fetched when asked for
20 showBreakdown: false,
21 rateLimits: [], // [{ kind, percentUsed, resetsAt }]
22 hasLimits: false, // ever reported a window (a subscription, not an API key)
23 ctxHistory: [], // context tokens after each main-loop turn, oldest first (the band's chart)
24 cost: null, // { usd }
25 startedAt: null,
26 model: null,
27 costSamples: [], // [{ at, usd }]
28 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
29 tools: new Map(), // tool name → { count, errors, durations }
30 calls: new Map(), // call key → feed entry, while it runs
31 feed: [], // [{ key, tool, agentId, summary, at, ms, isError, done }], oldest first
32 agents: new Map(), // agent id → { id, type, description, status, parentId, startedAt, endedAt, tools }
33 activity: { state: "idle", tool: null, since: 0 }, // idle | thinking | tool
34 };
35}
36
37// ---------------------------------------------------------------- engine figures
38
39// A session.measure event or a $.session.usage() answer.
40export function applyMeasure(u, m, now) {
41 if (!m) return false;
42 u.measured = true;
43 if (m.context) u.context = { tokens: m.context.tokens, window: m.context.window, percent: m.context.percent };
44 if (Array.isArray(m.rateLimits)) {
45 u.rateLimits = m.rateLimits;
46 if (m.rateLimits.length) u.hasLimits = true; // a subscription: its windows always show
47 }
48 if (typeof m.startedAt === "number") u.startedAt = m.startedAt;
49 if (m.cost && typeof m.cost.usd === "number") {
50 u.cost = { usd: m.cost.usd };
51 const last = u.costSamples.at(-1);
52 if (!last || last.usd !== m.cost.usd || now - last.at >= SAMPLE_EVERY_MS) {
53 u.costSamples.push({ at: now, usd: m.cost.usd });
54 if (u.costSamples.length > COST_SAMPLES) u.costSamples.splice(0, u.costSamples.length - COST_SAMPLES);
55 }
56 }
57 return true;
58}
59
60// The context's fill after a main-loop turn, for the band's chart.
61const CTX_HISTORY = 12;
62export function recordTurnContext(u) {
63 const t = u.context?.tokens;
64 if (t == null) return false;
65 u.ctxHistory.push(t);
66 if (u.ctxHistory.length > CTX_HISTORY) u.ctxHistory.splice(0, u.ctxHistory.length - CTX_HISTORY);
67 return true;
68}
69
70// What one turn used (turn.complete's `usage`), the main loop's or a subagent's.
71export function applyTurnUsage(u, usage) {
72 if (!usage) return false;
73 u.tokens.input += usage.input_tokens ?? 0;
74 u.tokens.output += usage.output_tokens ?? 0;
75 u.tokens.cacheRead += usage.cache_read_input_tokens ?? 0;
76 u.tokens.cacheWrite += usage.cache_creation_input_tokens ?? 0;
77 if (usage.model) u.model = usage.model;
78 return true;
79}
80
81export function totalTokens(u) {
82 const t = u.tokens;
83 return t.input + t.output + t.cacheRead + t.cacheWrite;
84}
85
86// The windows to show, 5-hour and 7-day first and always (a window that just
87// reset has no reading until the next reply: its percent is null), then any
88// others the account has. None at all for an account without windows.
89const MAIN_WINDOWS = ["five_hour", "seven_day"];
90export function limitWindows(u) {
91 if (!u.hasLimits && !u.rateLimits.length) return [];
92 const main = MAIN_WINDOWS.map((kind) => u.rateLimits.find((l) => l.kind === kind) ?? { kind, percentUsed: null });
93 return [...main, ...u.rateLimits.filter((l) => !MAIN_WINDOWS.includes(l.kind))];
94}
95
96// The share of input read from the prompt cache, 0-100, or null before any.
97export function cacheRatio(u) {
98 const t = u.tokens;
99 const input = t.input + t.cacheRead + t.cacheWrite;
100 return input ? (t.cacheRead / input) * 100 : null;
101}
102
103// Dollars per hour over the last quarter hour, or null with too little to go on.
104export function burnRate(u, now) {
105 const recent = u.costSamples.filter((s) => now - s.at <= BURN_WINDOW_MS);
106 if (recent.length < 2) return null;
107 const first = recent[0];
108 const last = recent.at(-1);
109 const hours = (last.at - first.at) / 3_600_000;
110 if (hours < 5 / 60) return null; // a few minutes say little about an hour
111 return (last.usd - first.usd) / hours;
112}
113
114// Spend per bucket over the last `n` buckets, oldest first: the sparkline.
115export function spendSeries(u, now, n = 12, bucketMs = 5 * 60_000) {
116 const out = new Array(n).fill(0);
117 const s = u.costSamples;
118 for (let i = 1; i < s.length; i++) {
119 const back = Math.floor((now - s[i].at) / bucketMs);
120 if (back < 0 || back >= n) continue;
121 out[n - 1 - back] += Math.max(0, s[i].usd - s[i - 1].usd);
122 }
123 return out;
124}
125
126// ---------------------------------------------------------------- tool calls
127
128// A few words on what a call is about, with anything that looks like a
129// secret masked: the command for Bash, a file's name, a search pattern.
130export function toolSummary(tool, input = {}) {
131 const base = (p) => String(p ?? "").split("/").filter(Boolean).at(-1) ?? "";
132 let s;
133 switch (tool) {
134 case "Bash": s = input.command; break;
135 case "Read": case "Write": case "Edit": case "NotebookEdit": s = base(input.file_path ?? input.notebook_path); break;
136 case "Grep": s = input.pattern ? `"${input.pattern}"` : ""; break;
137 case "Glob": s = input.pattern; break;
138 case "WebFetch": s = input.url; break;
139 case "WebSearch": s = input.query; break;
140 case "Agent": case "Task": s = input.description; break;
141 default: s = input.description ?? input.command ?? input.query ?? "";
142 }
143 s = String(s ?? "").replace(/\s+/g, " ").trim();
144 if (findSecret(s)) return "(hidden: looks secret)";
145 return s.slice(0, 80);
146}
147
148let seq = 0;
149
150export function toolStarted(u, { tool, agentId, input, at }) {
151 const key = `c${++seq}`;
152 const entry = { key, tool, agentId: agentId ?? null, summary: toolSummary(tool, input), at, ms: null, isError: false, done: false };
153 u.calls.set(key, entry);
154 u.feed.push(entry);
155 if (u.feed.length > FEED) u.feed.splice(0, u.feed.length - FEED);
156 if (agentId) {
157 // Only agents the session spawned or lists: the engine's own loops
158 // (compaction, memory) carry ids nothing else names.
159 const a = u.agents.get(agentId);
160 if (a) a.tools.set(tool, (a.tools.get(tool) ?? 0) + 1);
161 } else {
162 u.activity = { state: "tool", tool, since: at };
163 }
164 return key;
165}
166
167export function toolEnded(u, key, { at, isError = false }) {
168 const entry = u.calls.get(key);
169 if (!entry) return false;
170 u.calls.delete(key);
171 entry.ms = Math.max(0, at - entry.at);
172 entry.isError = isError;
173 entry.done = true;
174 const t = u.tools.get(entry.tool) ?? { count: 0, errors: 0, durations: [] };
175 t.count++;
176 if (isError) t.errors++;
177 t.durations.push(entry.ms);
178 if (t.durations.length > DURATIONS) t.durations.shift();
179 u.tools.set(entry.tool, t);
180 if (!entry.agentId && u.activity.state === "tool") u.activity = { state: "thinking", tool: null, since: at };
181 return true;
182}
183
184export function percentile(values, p) {
185 if (!values.length) return null;
186 const sorted = [...values].sort((a, b) => a - b);
187 const i = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
188 return sorted[i];
189}
190
191// One row per tool, the most time spent first.
192export function toolRows(u) {
193 return [...u.tools.entries()].map(([name, t]) => ({
194 name,
195 count: t.count,
196 errors: t.errors,
197 p50: percentile(t.durations, 50),
198 p95: percentile(t.durations, 95),
199 total: t.durations.reduce((a, b) => a + b, 0),
200 })).sort((a, b) => b.total - a.total || b.count - a.count);
201}
202
203export function runningCalls(u) {
204 return [...u.calls.values()];
205}
206
207// ---------------------------------------------------------------- turns
208
209export function turnStarted(u, at) {
210 u.activity = { state: "thinking", tool: null, since: at };
211}
212
213export function turnEnded(u, at) {
214 u.activity = { state: "idle", tool: null, since: at };
215}
216
217// ---------------------------------------------------------------- subagents
218
219function agentEntry(u, id, at) {
220 let a = u.agents.get(id);
221 if (!a) {
222 a = { id, type: "agent", description: "", status: "running", parentId: null, startedAt: at, endedAt: null, tools: new Map() };
223 u.agents.set(id, a);
224 }
225 return a;
226}
227
228export function agentSpawned(u, { id, type, description, parentId, at }) {
229 if (!id) return false;
230 const a = agentEntry(u, id, at);
231 a.type = type || a.type;
232 a.description = description || a.description;
233 a.parentId = parentId ?? a.parentId;
234 a.startedAt = Math.min(a.startedAt, at);
235 return true;
236}
237
238const ENDED = new Set(["completed", "failed", "killed"]);
239
240// $.agent.list(): the engine's word on each agent's status.
241export function applyAgentList(u, list, now) {
242 let changed = false;
243 for (const info of list ?? []) {
244 const a = agentEntry(u, info.id, now);
245 const was = a.status;
246 a.type = info.type || a.type;
247 a.description = info.description || a.description;
248 a.parentId = info.parentId ?? a.parentId;
249 a.status = info.status;
250 if (ENDED.has(info.status) && !a.endedAt) a.endedAt = now;
251 if (was !== a.status) changed = true;
252 }
253 return changed;
254}
255
256// Agents in tree order: each one followed by those it spawned, newest first
257// at each level. Each row carries its depth and whether it is the last child.
258export function agentTree(u) {
259 const all = [...u.agents.values()];
260 // An agent whose parent isn't known is drawn at the top level.
261 const parentOf = (a) => (a.parentId && u.agents.has(a.parentId) ? a.parentId : null);
262 const out = [];
263 const walk = (pid, depth) => {
264 const list = all.filter((a) => parentOf(a) === pid).sort((a, b) => b.startedAt - a.startedAt);
265 list.forEach((a, i) => {
266 out.push({ agent: a, depth, last: i === list.length - 1 });
267 walk(a.id, depth + 1);
268 });
269 };
270 walk(null, 0);
271 return out;
272}
273
274export function agentCounts(u) {
275 let running = 0;
276 let done = 0;
277 for (const a of u.agents.values()) {
278 if (ENDED.has(a.status)) done++;
279 else running++;
280 }
281 return { running, done };
282}
283
284export function isEnded(status) {
285 return ENDED.has(status);
286}
287
288// The tool an agent used most: "Read ×14".
289export function topTool(agent) {
290 let best = null;
291 for (const [name, n] of agent.tools) if (!best || n > best[1]) best = [name, n];
292 return best ? `${best[0]} ×${best[1]}` : "";
293}
294hooks/github.mjs 263 lines1// The Git room's data: the local branch from `git status`, and pull requests,
2// reviews, Actions runs, issues and Dependabot alerts from the `gh` CLI,
3// signed in as the person already is. Nothing is stored and no token passes
4// through here. `run(argv)` comes from the hooks module ($.process.run).
5
6const PR_FIELDS = "number,title,state,isDraft,url,author,headRefName,reviewDecision,statusCheckRollup,updatedAt";
7const PR_VIEW_FIELDS = `${PR_FIELDS},mergeable,mergeStateStatus,reviews,reviewRequests`;
8
9export function emptyGit() {
10 return {
11 status: "idle", // idle | loading | ok | no-gh | no-auth | no-repo | error
12 error: "",
13 fetchedAt: 0,
14 stale: false, // the last refresh failed: what's shown is older
15 me: null, // the gh login
16 repo: null, // { name, url }
17 local: null, // { branch, ahead, behind, changed }
18 pr: null, // this branch's pull request
19 prs: [], // open pull requests
20 reviewMe: [], // numbers of open PRs that ask for my review
21 runs: [], // recent Actions runs
22 issues: [], // open issues assigned to me
23 alerts: null, // Dependabot alerts, or null when not readable
24 };
25}
26
27// ---------------------------------------------------------------- parsing
28
29// `git status --porcelain=v2 --branch`
30export function parseGitStatus(out) {
31 const local = { branch: null, ahead: 0, behind: 0, changed: 0, upstream: null, unpushed: null };
32 for (const line of String(out).split("\n")) {
33 if (line.startsWith("# branch.head ")) local.branch = line.slice(14).trim();
34 else if (line.startsWith("# branch.upstream ")) local.upstream = line.slice(18).trim();
35 else if (line.startsWith("# branch.ab ")) {
36 const m = /\+(\d+) -(\d+)/.exec(line);
37 if (m) { local.ahead = Number(m[1]); local.behind = Number(m[2]); }
38 } else if (/^[12u?] /.test(line)) local.changed++;
39 }
40 if (local.branch === "(detached)") local.branch = "detached";
41 return local;
42}
43
44const FAILED = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
45
46// A PR's checks as counts: passed, failed, still running.
47export function checks(rollup) {
48 const c = { pass: 0, fail: 0, pending: 0, total: 0 };
49 for (const item of rollup ?? []) {
50 c.total++;
51 if (item.__typename === "StatusContext" || item.state) {
52 if (item.state === "SUCCESS") c.pass++;
53 else if (FAILED.has(item.state)) c.fail++;
54 else c.pending++;
55 continue;
56 }
57 if (item.status !== "COMPLETED") c.pending++;
58 else if (FAILED.has(item.conclusion)) c.fail++;
59 else c.pass++;
60 }
61 return c;
62}
63
64function prModel(p) {
65 return {
66 number: p.number,
67 title: p.title ?? "",
68 state: p.state ?? "OPEN",
69 draft: !!p.isDraft,
70 url: p.url,
71 author: p.author?.login ?? "",
72 branch: p.headRefName ?? "",
73 decision: p.reviewDecision || null, // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED
74 checks: checks(p.statusCheckRollup),
75 updatedAt: Date.parse(p.updatedAt) || 0,
76 };
77}
78
79// `gh pr view` adds who reviewed (each person's latest word) and who's asked.
80export function parsePrView(p) {
81 const pr = prModel(p);
82 const latest = new Map();
83 for (const r of p.reviews ?? []) {
84 const who = r.author?.login;
85 if (!who || r.state === "COMMENTED" || r.state === "PENDING") continue;
86 latest.set(who, r.state);
87 }
88 pr.reviews = [...latest].map(([login, state]) => ({ login, state }));
89 pr.requested = (p.reviewRequests ?? []).map((r) => r.login ?? r.name ?? r.slug).filter(Boolean);
90 pr.mergeable = p.mergeable ?? "UNKNOWN"; // MERGEABLE | CONFLICTING | UNKNOWN
91 pr.mergeState = p.mergeStateStatus ?? "UNKNOWN"; // CLEAN | BLOCKED | BEHIND | DIRTY | UNSTABLE | ...
92 return pr;
93}
94
95export function parsePrList(list) {
96 return (list ?? []).map(prModel);
97}
98
99// Recent runs, the newest of each workflow on each branch only: an older
100// run that a newer one replaced (a failure since fixed) no longer counts.
101export function parseRuns(list) {
102 const seen = new Set();
103 return (list ?? []).filter((r) => {
104 const key = `${r.workflowName || r.name}\u0000${r.headBranch}`;
105 if (seen.has(key)) return false;
106 seen.add(key);
107 return true;
108 }).map((r) => ({
109 id: r.databaseId,
110 name: r.workflowName || r.name || "workflow",
111 branch: r.headBranch ?? "",
112 status: r.status, // queued | in_progress | completed
113 conclusion: r.conclusion || null, // success | failure | cancelled | skipped
114 startedAt: Date.parse(r.createdAt) || 0,
115 updatedAt: Date.parse(r.updatedAt) || 0,
116 url: r.url,
117 }));
118}
119
120export function parseIssues(list) {
121 return (list ?? []).map((i) => ({
122 number: i.number,
123 title: i.title ?? "",
124 url: i.url,
125 labels: (i.labels ?? []).map((l) => l.name),
126 updatedAt: Date.parse(i.updatedAt) || 0,
127 }));
128}
129
130export function parseAlerts(list) {
131 return (list ?? []).map((a) => ({
132 number: a.number,
133 pkg: a.dependency?.package?.name ?? a.security_vulnerability?.package?.name ?? "dependency",
134 severity: a.security_advisory?.severity ?? a.security_vulnerability?.severity ?? "unknown",
135 url: a.html_url,
136 }));
137}
138
139// ---------------------------------------------------------------- fetching
140
141const NO_AUTH = /gh auth login|not logged in|authentication|HTTP 401/i;
142const NO_PR = /no pull requests found|no open pull requests/i;
143const NO_REMOTE = /no git remotes|none of the git remotes|not a github|could not determine/i;
144
145function json(out) {
146 try { return JSON.parse(out || "null"); } catch { return null; }
147}
148
149// Everything the room shows, fetched in parallel. `prev` is the last good
150// snapshot: on a failed refresh it stays, marked stale.
151export async function fetchGit(run, prev = emptyGit()) {
152 const next = { ...emptyGit(), me: prev.me };
153 let status;
154 try {
155 status = await run(["git", "status", "--porcelain=v2", "--branch"]);
156 } catch (err) {
157 return { ...prev, status: "error", error: `git: ${err?.message ?? err}`, stale: prev.status === "ok" };
158 }
159 if (status.exitCode !== 0) {
160 return /not a git repository/i.test(status.stderr)
161 ? { ...emptyGit(), status: "no-repo" }
162 : { ...prev, status: "error", error: firstLine(status.stderr) || "git status failed", stale: prev.status === "ok" };
163 }
164 next.local = parseGitStatus(status.stdout);
165 // A branch that was never pushed has nothing to be ahead of: count its
166 // commits past the main branch instead.
167 if (!next.local.upstream && next.local.branch && next.local.branch !== "detached") {
168 next.local.unpushed = await unpushed(run);
169 }
170
171 const gh = (args) => run(["gh", ...args]).then((r) => r, (err) => ({ thrown: err }));
172 const [repo, me] = await Promise.all([
173 gh(["repo", "view", "--json", "nameWithOwner,url"]),
174 prev.me ? Promise.resolve(null) : gh(["api", "user", "--jq", ".login"]),
175 ]);
176 if (repo.thrown) return { ...emptyGit(), local: next.local, status: "no-gh" };
177 if (repo.exitCode !== 0) {
178 if (NO_AUTH.test(repo.stderr)) return { ...emptyGit(), local: next.local, status: "no-auth" };
179 if (NO_REMOTE.test(repo.stderr)) return { ...emptyGit(), local: next.local, status: "ok", fetchedAt: Date.now(), error: "no GitHub remote" };
180 return { ...prev, local: next.local, status: "error", error: firstLine(repo.stderr) || "gh failed", stale: prev.status === "ok" };
181 }
182 const r = json(repo.stdout);
183 next.repo = { name: r?.nameWithOwner ?? "", url: r?.url ?? "" };
184 if (me && me.exitCode === 0) next.me = me.stdout.trim() || null;
185
186 const [view, list, mine, runs, issues, alerts] = await Promise.all([
187 gh(["pr", "view", "--json", PR_VIEW_FIELDS]),
188 gh(["pr", "list", "--state", "open", "--limit", "8", "--json", PR_FIELDS]),
189 gh(["pr", "list", "--state", "open", "--search", "review-requested:@me", "--json", "number"]),
190 gh(["run", "list", "--limit", "12", "--json", "databaseId,name,workflowName,headBranch,status,conclusion,createdAt,updatedAt,url"]),
191 gh(["issue", "list", "--state", "open", "--assignee", "@me", "--limit", "6", "--json", "number,title,url,labels,updatedAt"]),
192 gh(["api", "repos/{owner}/{repo}/dependabot/alerts?state=open&per_page=5"]),
193 ]);
194 const ok = (x) => !x.thrown && x.exitCode === 0;
195 if (!ok(list)) {
196 return { ...prev, local: next.local, repo: next.repo, status: "error", error: firstLine(list.stderr ?? list.thrown?.message) || "gh pr list failed", stale: prev.status === "ok" };
197 }
198 next.pr = ok(view) ? parsePrView(json(view.stdout) ?? {}) : null;
199 if (!ok(view) && !NO_PR.test(view.stderr ?? "")) next.pr = prev.pr; // a hiccup: keep what we had
200 next.prs = parsePrList(json(list.stdout));
201 next.reviewMe = ok(mine) ? (json(mine.stdout) ?? []).map((p) => p.number) : prev.reviewMe;
202 next.runs = ok(runs) ? parseRuns(json(runs.stdout)) : prev.runs;
203 next.issues = ok(issues) ? parseIssues(json(issues.stdout)) : prev.issues;
204 // No access to alerts (not an admin, or turned off): leave the section out.
205 next.alerts = ok(alerts) ? parseAlerts(json(alerts.stdout)) : null;
206 next.status = "ok";
207 next.fetchedAt = Date.now();
208 return next;
209}
210
211async function unpushed(run) {
212 for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
213 try {
214 const r = await run(["git", "rev-list", "--count", `${base}..HEAD`]);
215 if (r.exitCode === 0) return Number(r.stdout.trim()) || 0;
216 } catch { return null; }
217 }
218 return null;
219}
220
221function firstLine(text) {
222 return String(text ?? "").trim().split("\n")[0].slice(0, 120);
223}
224
225// ---------------------------------------------------------------- what changed
226
227// Toast-worthy changes between two good snapshots, as short lines.
228export function diffGit(prev, next) {
229 if (prev?.status !== "ok" || next?.status !== "ok" || !prev.fetchedAt) return [];
230 const out = [];
231 const a = prev.pr;
232 const b = next.pr;
233 if (a && b && a.number === b.number) {
234 if (b.checks.fail > a.checks.fail) out.push(`✗ Checks failed on #${b.number}`);
235 else if (a.checks.pending > 0 && b.checks.pending === 0 && b.checks.fail === 0 && b.checks.total) out.push(`✓ All checks passed on #${b.number}`);
236 if (b.decision !== a.decision && b.decision === "APPROVED") out.push(`✓ #${b.number} was approved`);
237 if (b.decision !== a.decision && b.decision === "CHANGES_REQUESTED") out.push(`● Changes requested on #${b.number}`);
238 if (b.state !== a.state && b.state === "MERGED") out.push(`✓ #${b.number} was merged`);
239 if (b.mergeable !== a.mergeable && b.mergeable === "CONFLICTING") out.push(`✗ #${b.number} has a merge conflict`);
240 }
241 for (const n of next.reviewMe) {
242 if (prev.reviewMe.includes(n)) continue;
243 const pr = next.prs.find((p) => p.number === n);
244 out.push(`● ${pr?.author || "Someone"} asked for your review on #${n}${pr ? ` ${pr.title}` : ""}`);
245 }
246 return out;
247}
248
249// Needs a look: this branch's checks failed, or someone wants your review.
250export function gitAttention(g) {
251 if (g?.status !== "ok") return false;
252 return (g.pr?.checks.fail ?? 0) > 0 || g.reviewMe.length > 0 || g.pr?.mergeable === "CONFLICTING";
253}
254
255export function ghStatusText(g) {
256 switch (g.status) {
257 case "no-gh": return "The Git room reads GitHub through the gh CLI. Install it (brew install gh), then gh auth login.";
258 case "no-auth": return "gh isn't signed in. Run gh auth login in a terminal, then press r here.";
259 case "no-repo": return "This session's folder isn't a git repository.";
260 default: return "";
261 }
262}
263hooks/sessions.mjs 72 lines1// Every Claude Code session on this computer, for the Agents room. Each
2// session's mod sends a small heartbeat to its bridge, the bridge keeps it as
3// a file next to the others', and every bridge reports the set back (a
4// "sessions" event). Only what the room draws goes in: no prompts, no output.
5
6import { agentTree, topTool } from "./metrics.mjs";
7
8const FEED_SHARED = 12;
9
10// This session's heartbeat, from its own figures.
11export function heartbeat(u, { id, cwd, branch, now }) {
12 const project = String(cwd ?? "").split("/").filter(Boolean).at(-1) ?? "session";
13 return {
14 v: 1,
15 id,
16 project,
17 cwd,
18 branch: branch ?? null,
19 model: u.model,
20 activity: u.activity,
21 cost: u.cost?.usd ?? null,
22 context: u.context?.percent ?? null,
23 agents: agentTree(u).map(({ agent: a, depth, last }) => ({
24 id: a.id, type: a.type, description: a.description, status: a.status,
25 startedAt: a.startedAt, endedAt: a.endedAt, top: topTool(a), depth, last,
26 })),
27 feed: u.feed.slice(-FEED_SHARED).map((f) => ({
28 key: f.key, tool: f.tool, agent: f.agentId ? (u.agents.get(f.agentId)?.type ?? "agent") : null,
29 summary: f.summary, at: f.at, ms: f.ms, isError: f.isError, done: f.done,
30 })),
31 updatedAt: now,
32 };
33}
34
35// What changed enough to send now (rather than at the next idle beat).
36export function beatKey(hb) {
37 return JSON.stringify([hb.activity, hb.agents.map((a) => a.status), hb.feed.at(-1)?.key, hb.feed.at(-1)?.done, Math.round((hb.cost ?? 0) * 100)]);
38}
39
40// Sessions in the order the room lists them: this one, then busy ones, then
41// the rest by when they last did something.
42export function orderSessions(own, others) {
43 const list = [own, ...others.filter((s) => s && s.id !== own?.id)].filter(Boolean);
44 const busy = (s) => ((s.activity?.state && s.activity.state !== "idle") || (s.agents ?? []).some((a) => !["completed", "failed", "killed"].includes(a.status)) ? 1 : 0);
45 return list.sort((a, b) => (a === own ? -1 : b === own ? 1 : 0) || busy(b) - busy(a) || (b.activity?.since ?? 0) - (a.activity?.since ?? 0));
46}
47
48// One feed across sessions, newest last, each row naming its session.
49export function mergedFeed(sessions, { filter = "all", ownId, limit = 12 } = {}) {
50 const rows = [];
51 for (const s of sessions) {
52 if (filter === "here" && s.id !== ownId) continue;
53 for (const f of s.feed ?? []) rows.push({ ...f, session: s.project, sessionId: s.id });
54 }
55 const kept = (filter === "errors" ? rows.filter((r) => r.isError) : rows).sort((a, b) => a.at - b.at);
56 // A run of the same finished call (a subagent reading file after file
57 // with one name) reads as one row with a count.
58 const out = [];
59 for (const r of kept) {
60 const prev = out.at(-1);
61 if (prev && prev.done && r.done && !r.isError && !prev.isError && prev.sessionId === r.sessionId
62 && prev.agent === r.agent && prev.tool === r.tool && prev.summary === r.summary) {
63 prev.times = (prev.times ?? 1) + 1;
64 prev.at = r.at;
65 prev.ms = r.ms;
66 continue;
67 }
68 out.push({ ...r });
69 }
70 return out.slice(-limit);
71}
72hooks/share.mjs 112 lines1// Snippets: code or a diff from the session, shared to a room as one
2// message. Plain logic: what to share, how it's titled, and whether it looks
3// like it holds a secret.
4
5export const MAX_SNIPPET = 8000;
6export const MAX_SNIPPET_LINES = 200;
7const LANG = /^[a-z0-9+#._-]{1,20}$/;
8
9// The last fenced code block in Claude's latest reply that has one.
10export function lastCodeBlock(messages) {
11 for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) {
12 const m = messages[i];
13 if (m?.role !== "assistant") continue;
14 const blocks = [...String(m.text ?? "").matchAll(/```([^\n`]*)\n([\s\S]*?)```/g)];
15 if (!blocks.length) continue;
16 const [, info, body] = blocks.at(-1);
17 return { body: body.replace(/\n$/, ""), lang: tag(info) };
18 }
19 return null;
20}
21
22// "ts" from "ts title=x.ts", lowercased; null for anything that isn't a short word.
23export function tag(info) {
24 const word = String(info ?? "").trim().split(/\s+/)[0].toLowerCase();
25 return LANG.test(word) ? word : null;
26}
27
28export function looksLikeDiff(text) {
29 return /^(diff --git |@@ -\d|--- a\/|\+\+\+ b\/)/m.test(String(text));
30}
31
32// "--- a/x", "+++ b/x", "--- /dev/null": git's file headers, not a removed
33// line that happens to start with "--".
34const FILE_HEADER = /^(---|\+\+\+) (a\/|b\/|\/dev\/null)/;
35
36// Files touched and lines added and removed, read from a unified diff.
37export function diffStats(body) {
38 let files = 0, added = 0, removed = 0;
39 for (const line of String(body).split("\n")) {
40 if (line.startsWith("diff --git ")) files++;
41 else if (FILE_HEADER.test(line)) continue;
42 else if (line.startsWith("+")) added++;
43 else if (line.startsWith("-")) removed++;
44 }
45 return { files, added, removed };
46}
47
48// The lines a person reads: tabs as two spaces, and for a diff each file as
49// "▸ path", its hunks, and its changes with one unchanged line on each side
50// (git gives three), without git's header lines.
51export function displayLines(body, kind = "code") {
52 const all = String(body).replace(/\t/g, " ").split("\n");
53 if (kind !== "diff") return all;
54 const lines = all.flatMap((line) => {
55 const file = /^diff --git a\/(.+?) b\//.exec(line);
56 if (file) return [`▸ ${file[1]}`];
57 return FILE_HEADER.test(line) || /^(index |new file mode|deleted file mode|similarity index|rename (from|to) )/.test(line) ? [] : [line];
58 });
59 const changed = (l) => l !== undefined && /^[+-]/.test(l);
60 const marker = (l) => l.startsWith("▸ ") || l.startsWith("@@");
61 return lines.filter((l, i) => marker(l) || changed(l) || changed(lines[i - 1]) || changed(lines[i + 1]));
62}
63
64export function lineCount(body) {
65 return String(body).split("\n").length;
66}
67
68// A built-in room's snapshot is a code snippet tagged with the room's name.
69export const SNAPSHOT_ICONS = { usage: "📊", git: "⎇", agents: "⟡" };
70
71// "diff · 3 files +42 −7", "code · ts · 12 lines", "📊 Usage · opus-5.5 · 1h12m".
72export function snippetTitle({ kind, lang, body }) {
73 if (kind === "code" && SNAPSHOT_ICONS[lang]) return `${SNAPSHOT_ICONS[lang]} ${String(body).split("\n")[0].slice(0, 60)}`;
74 if (kind === "diff") {
75 const { files, added, removed } = diffStats(body);
76 const where = files ? `${files} file${files === 1 ? "" : "s"} ` : "";
77 return `diff · ${where}+${added} −${removed}`;
78 }
79 const n = lineCount(body);
80 return ["code", lang, `${n} line${n === 1 ? "" : "s"}`].filter(Boolean).join(" · ");
81}
82
83// What a one-line view shows for a message: its text, or the snippet's title.
84export function oneLine(m) {
85 return !m.kind || m.kind === "text" ? m.body : `📎 ${snippetTitle(m)}`;
86}
87
88// Things that shouldn't leave the computer. Each match names what it looks
89// like, for the warning.
90const SECRETS = [
91 [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, "an API key"],
92 [/\bsb_secret_[A-Za-z0-9_-]{10,}/, "a Supabase secret key"],
93 [/\b(?:ghp|gho|ghs|github_pat)_[A-Za-z0-9_]{20,}/, "a GitHub token"],
94 [/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key"],
95 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
96 [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"],
97 [/^\+?\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*\S{8,}/m, "a secret in an env file"],
98];
99
100export function findSecret(body) {
101 for (const [pattern, what] of SECRETS) if (pattern.test(body)) return what;
102 return null;
103}
104
105// Why a snippet can't be shared as it is, or null.
106export function tooBig(body) {
107 const lines = lineCount(body);
108 if (lines > MAX_SNIPPET_LINES) return `that's ${lines} lines, more than ${MAX_SNIPPET_LINES}`;
109 if (body.length > MAX_SNIPPET) return `that's ${body.length} characters, more than ${MAX_SNIPPET}`;
110 return null;
111}
112hooks/sysviews.mjs 710 lines1// The built-in rooms, drawn from state alone: Usage (this session's context,
2// limits, spend, tools and subagents), Git (the branch, pull requests,
3// Actions, issues) and Agents (every session on this computer, and a live
4// feed of tool calls). Each has a docked layout of cards, a compact one for
5// the inline pane, a one-line summary for the band, and a plain-text
6// snapshot for sharing to a chat room.
7
8import { state } from "./state.mjs";
9import { theme, level, nameColor } from "./theme.mjs";
10import {
11 cells, clip, fit, fmtTokens, fmtUsd, fmtDur, fmtSpan, relTime, age, resetLabel,
12 bar, sparkline, meter, tiles, line, col, stackCards, CARD_GAP, LABEL_W,
13} from "./widgets.mjs";
14import {
15 totalTokens, cacheRatio, burnRate, limitWindows, spendSeries, toolRows, agentTree, agentCounts, isEnded, topTool,
16} from "./metrics.mjs";
17import { gitAttention, ghStatusText } from "./github.mjs";
18import { orderSessions, mergedFeed } from "./sessions.mjs";
19
20export const SYS = {
21 usage: { icon: "◔", label: "Usage", color: theme.usage },
22 git: { icon: "⎇", label: "Git", color: theme.git },
23 agents: { icon: "⟡", label: "Agents", color: theme.agents },
24};
25
26const SPIN = ["◐", "◓", "◑", "◒"];
27const spin = (now) => SPIN[Math.floor(now / 1000) % SPIN.length];
28
29// "claude-opus-5-5" → "opus-5.5"
30export function shortModel(id) {
31 if (!id) return "";
32 return String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "").replace(/-(\d+)-(\d+)$/, "-$1.$2");
33}
34
35// One rate-limit window's meter. Without a reading (it just reset) it says so.
36function limitMeter(els, l, w) {
37 const right = l.percentUsed == null ? "no data yet" : l.resetsAt ? `↻ ${resetLabel(l.resetsAt)}` : "";
38 return meter(els, { key: `rl-${l.kind}`, label: limitName(l.kind), pct: l.percentUsed ?? undefined, width: w, right });
39}
40
41const LIMIT_NAMES = { five_hour: "5-hour", seven_day: "7-day", seven_day_opus: "7d opus", seven_day_sonnet: "7d sonnet", spend_limit: "Spend" };
42function limitName(kind) {
43 return LIMIT_NAMES[kind] ?? String(kind).replace(/_/g, " ");
44}
45
46// A rounded card like the chat's: a bold title, right-aligned meta (text or
47// an element), the rows. Returns the node and the rows it takes.
48function card(els, { key, title, color, meta, metaColor, metaNode, rows, grow = false }) {
49 const { Box, Text } = els;
50 const head = Box({ key: "head", flexDirection: "row", justifyContent: "space-between", gap: 1, children: [
51 Text({ key: "title", bold: true, color, wrap: "truncate-end", children: title }),
52 metaNode ?? (meta ? Text({ key: "meta", color: metaColor ?? theme.muted, wrap: "truncate-start", children: meta }) : null),
53 ].filter(Boolean) });
54 const body = grow
55 ? [Box({ key: "body", flexDirection: "column", flexGrow: 1, overflow: "hidden", children: rows })]
56 : rows;
57 return Box({
58 key, flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1,
59 flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, children: [head, ...body],
60 });
61}
62
63const sized = (name, node, rowCount) => ({ name, node, height: 3 + rowCount });
64const muted = (els, key, text) => els.Text({ key, color: theme.muted, wrap: "truncate-end", children: text });
65const copyButton = (els, key, url, handlers) => (url ? { node: els.Button({ key, plain: true, dimColor: true, label: "⧉", onPress: () => handlers.onCopy?.(url) }) } : null);
66
67// ---------------------------------------------------------------- Usage
68
69function usageCard(els, w, handlers, now) {
70 const u = state.usage;
71 const rows = [];
72 if (!u.measured) {
73 rows.push(muted(els, "wait", "Waiting for the first reply from Claude…"));
74 } else {
75 const c = u.context ?? {};
76 const right = c.tokens != null ? `${fmtTokens(c.tokens)}/${fmtTokens(c.window)}` : `${fmtTokens(c.window)} window`;
77 rows.push(meter(els, { key: "ctx", label: u.showBreakdown ? "Context ▾" : "Context ▸", pct: c.percent, width: w, right, onPress: handlers.onToggleBreakdown }));
78 if (u.showBreakdown) {
79 const cats = (u.breakdown?.categories ?? []).filter((x) => !x.isDeferred && x.tokens > 0 && !/free space/i.test(x.name));
80 if (!u.breakdown) rows.push(muted(els, "bd-wait", " counting…"));
81 for (const [i, x] of cats.slice(0, 6).entries()) {
82 const pct = u.breakdown.maxTokens ? (x.tokens / u.breakdown.maxTokens) * 100 : 0;
83 rows.push(meter(els, { key: `bd${i}`, label: ` ${x.name}`, pct, width: w, right: fmtTokens(x.tokens), color: theme.usage }));
84 }
85 }
86 const windows = limitWindows(u);
87 if (windows.length) {
88 for (const l of windows) rows.push(limitMeter(els, l, w));
89 } else {
90 rows.push(muted(els, "nolimits", "No rate-limit windows on this account."));
91 }
92 }
93 const meta = [shortModel(u.model), u.startedAt ? fmtSpan(now - u.startedAt) : null].filter(Boolean).join(" · ");
94 return sized("Usage", card(els, { key: "usage", title: "USAGE", color: theme.usage, meta, rows }), rows.length);
95}
96
97function spendCard(els, w, now) {
98 const u = state.usage;
99 const series = spendSeries(u, now);
100 const rate = burnRate(u, now);
101 const cache = cacheRatio(u);
102 const spark = series.some((v) => v > 0) ? `${sparkline(series)} last 1h` : "";
103 const list = [
104 { value: u.cost ? fmtUsd(u.cost.usd) : "–", sub: rate != null ? `+${fmtUsd(rate)}/h` : "this session", color: theme.usage },
105 { value: fmtTokens(totalTokens(u)), sub: `${fmtTokens(u.tokens.output)} out`, color: undefined },
106 { value: cache == null ? "–" : `${Math.round(cache)}%`, sub: "cache hits", color: cache == null ? theme.muted : cache >= 60 ? theme.online : cache >= 30 ? theme.amber : theme.error },
107 ];
108 return sized("Spend", card(els, { key: "spend", title: "SPEND", color: theme.usage, meta: spark, metaColor: theme.usage, rows: [tiles(els, list, w)] }), 2);
109}
110
111// The number columns, shared by the header and every row so they line up.
112const TOOL_COLS = [["p50", 6], ["p95", 6], ["calls", 7], ["fail", 6]];
113const TOOL_NUMS = TOOL_COLS.reduce((n, [, w]) => n + w, 0);
114const toolHeader = () => TOOL_COLS.map(([h, w]) => fit(h, w, { right: true })).join("");
115
116function toolsCard(els, w, max) {
117 const { Box } = els;
118 const list = toolRows(state.usage).slice(0, max);
119 const rows = [];
120 if (!list.length) rows.push(muted(els, "none", "No tool calls yet."));
121 const NAME = Math.min(14, Math.max(8, ...list.map((t) => cells(t.name) + 1)));
122 const barW = Math.max(3, w - NAME - TOOL_NUMS);
123 const top = Math.max(...list.map((t) => t.total), 1);
124 const [[, w50], [, w95], [, wCalls], [, wFail]] = TOOL_COLS;
125 for (const t of list) {
126 const fill = bar((t.total / top) * 100, barW).fill || "╸"; // even a quick tool gets a mark
127 rows.push(Box({ key: `t-${t.name}`, flexDirection: "row", children: [
128 col(els, "n", clip(t.name, NAME - 1), NAME),
129 Box({ key: "b", width: barW, flexShrink: 1, minWidth: 0, overflow: "hidden", children: [
130 els.Text({ key: "t", color: theme.usage, wrap: "truncate-end", children: fill }),
131 ] }),
132 col(els, "p50", fmtDur(t.p50), w50, { right: true }),
133 col(els, "p95", fmtDur(t.p95), w95, { right: true, color: theme.muted }),
134 col(els, "c", String(t.count), wCalls, { right: true }),
135 col(els, "e", String(t.errors), wFail, { right: true, bold: t.errors > 0, color: t.errors ? theme.error : theme.muted }),
136 ] }));
137 }
138 // The header ends at the card's right edge, as the rows do: each label sits over its column.
139 const header = list.length
140 ? Box({ key: "meta", flexDirection: "row", flexShrink: 0, children: TOOL_COLS.map(([h, cw]) => col(els, h, h, cw, { right: true, color: theme.muted })) })
141 : null;
142 return sized("Tools", card(els, { key: "tools", title: "TOOLS", color: theme.usage, metaNode: header, rows }), rows.length);
143}
144
145function agentGlyph(status, now) {
146 if (status === "completed") return { g: "✓", color: theme.online };
147 if (status === "failed") return { g: "✗", color: theme.error };
148 if (status === "killed") return { g: "⊘", color: theme.muted };
149 if (status === "idle" || status === "waiting" || status === "pending") return { g: "○", color: theme.agents };
150 return { g: spin(now), color: theme.agents };
151}
152
153function agentRow(els, key, a, { indent = "", now }) {
154 const st = agentGlyph(a.status, now);
155 const took = (a.endedAt ?? now) - a.startedAt;
156 return line(els, key, [
157 indent ? { text: indent, color: theme.muted } : null,
158 { text: `${st.g} `, color: st.color },
159 { text: clip(a.type, 9), width: 10, bold: true },
160 { text: a.description, grow: true },
161 { text: ` ${isEnded(a.status) ? "done" : fmtDur(took)}`, color: theme.muted },
162 a.top ? { text: ` ${a.top}`, color: theme.muted } : null,
163 ]);
164}
165
166function subagentsCard(els, max, now) {
167 const tree = agentTree(state.usage);
168 const { running, done } = agentCounts(state.usage);
169 const rows = tree.slice(0, max).map(({ agent: a, depth, last }) => agentRow(els, `a-${a.id}`, { ...a, top: topTool(a) }, {
170 indent: depth ? `${" ".repeat(depth - 1)}${last ? "└─ " : "├─ "}` : "", now,
171 }));
172 if (tree.length > max) rows.push(muted(els, "more", `+${tree.length - max} more`));
173 if (!rows.length) rows.push(muted(els, "none", "None yet. They show up here as Claude starts them."));
174 const meta = running || done ? `${running} running · ${done} done` : "";
175 return sized("Subagents", card(els, { key: "subagents", title: "SUBAGENTS", color: theme.usage, meta, metaColor: running ? theme.agents : theme.muted, rows }), rows.length);
176}
177
178function usageDock(els, w, capacity, handlers, now) {
179 return stackCards(els, [
180 usageCard(els, w, handlers, now),
181 spendCard(els, w, now),
182 toolsCard(els, w, 5),
183 subagentsCard(els, 4, now),
184 ], capacity);
185}
186
187// ---------------------------------------------------------------- Git
188
189function ago(g, now) {
190 if (g.status === "loading" && !g.fetchedAt) return { text: "loading…", color: theme.muted };
191 if (!g.fetchedAt) return { text: "", color: theme.muted };
192 return { text: `${g.stale ? "⚠ " : ""}updated ${relTime(g.fetchedAt, now)}`, color: g.stale ? theme.amber : theme.muted };
193}
194
195// Where the branch stands against its remote: "↑2 ↓1", or "local" (with its
196// commits past main, "↑3 local") when it has no remote branch yet. Then the
197// working tree: "● 23 changed" or "clean".
198function syncPieces(l) {
199 if (!l) return [];
200 const sync = l.upstream
201 ? [
202 { text: `↑${l.ahead}`, color: l.ahead ? theme.sky : theme.muted, bold: l.ahead > 0 },
203 { text: " " },
204 { text: `↓${l.behind}`, color: l.behind ? theme.amber : theme.muted, bold: l.behind > 0 },
205 ]
206 : [{ text: l.unpushed ? `↑${l.unpushed} local` : "local", color: l.unpushed ? theme.amber : theme.muted }];
207 const tree = l.changed
208 ? { text: `● ${l.changed} changed`, color: theme.amber }
209 : { text: "clean", color: theme.muted };
210 return { sync, tree };
211}
212
213function checksPieces(c) {
214 if (!c.total) return [{ text: "no checks", color: theme.muted }];
215 return [
216 { text: `✓ ${c.pass}`, color: c.pass ? theme.online : theme.muted },
217 { text: ` ● ${c.pending}${c.pending ? " running" : ""}`, color: c.pending ? theme.amber : theme.muted },
218 { text: ` ✗ ${c.fail}`, color: c.fail ? theme.error : theme.muted },
219 ];
220}
221
222function mergePiece(pr) {
223 if (pr.state === "MERGED") return { text: "merged ✓", color: theme.agents };
224 if (pr.state === "CLOSED") return { text: "closed", color: theme.muted };
225 if (pr.mergeable === "CONFLICTING" || pr.mergeState === "DIRTY") return { text: "conflict ✗", color: theme.error };
226 if (pr.mergeState === "BEHIND") return { text: "behind base", color: theme.amber };
227 if (pr.mergeState === "BLOCKED") return { text: "blocked", color: theme.amber };
228 if (pr.mergeState === "CLEAN" || pr.mergeable === "MERGEABLE") return { text: "mergeable ✓", color: theme.online };
229 return { text: "merge ?", color: theme.muted };
230}
231
232function prState(pr) {
233 if (pr.state === "MERGED") return { text: "merged", color: theme.agents };
234 if (pr.state === "CLOSED") return { text: "closed", color: theme.muted };
235 if (pr.draft) return { text: "draft", color: theme.muted };
236 return { text: "open", color: theme.git };
237}
238
239// The short status a PR row ends in, most urgent first.
240function prPill(pr, g) {
241 if (g.reviewMe.includes(pr.number)) return { text: "● you", color: theme.amber };
242 if (pr.checks.fail) return { text: "✗ CI", color: theme.error };
243 if (pr.decision === "CHANGES_REQUESTED") return { text: "● changes", color: theme.amber };
244 if (pr.checks.pending) return { text: "● CI", color: theme.amber };
245 if (pr.draft) return { text: "draft", color: theme.muted };
246 if (pr.decision === "APPROVED") return { text: "✓ approved", color: theme.online };
247 if (pr.checks.total) return { text: "✓ CI", color: theme.online };
248 return { text: "", color: theme.muted };
249}
250
251function repoCard(els, handlers, now) {
252 const g = state.git;
253 const l = g.local;
254 const rows = [];
255 if (l) {
256 const { sync, tree } = syncPieces(l);
257 rows.push(line(els, "branch", [
258 { text: `⎇ ${l.branch ?? "?"}`, bold: true, color: theme.git },
259 { text: " " },
260 ...sync,
261 { text: " " },
262 { ...tree, grow: true },
263 { node: els.Button({ key: "refresh", plain: true, dimColor: true, label: "↻", onPress: () => handlers.onRefresh?.() }) },
264 ]));
265 }
266 const help = ghStatusText(g);
267 if (help) rows.push(els.Text({ key: "help", color: theme.warn, wrap: "wrap", children: help }));
268 else if (g.error && (g.stale || !g.fetchedAt || g.error === "no GitHub remote")) rows.push(muted(els, "err", g.error === "no GitHub remote" ? "No GitHub remote: only the local branch shows here." : `⚠ ${g.error}`));
269 if (!rows.length) rows.push(muted(els, "wait", "Reading the repository…"));
270 const when = ago(g, now);
271 const helpRows = help ? Math.ceil(cells(help) / 44) : 0;
272 return sized("Repo", card(els, { key: "repo", title: g.repo?.name || "REPOSITORY", color: theme.git, meta: when.text, metaColor: when.color, rows }), rows.length + Math.max(0, helpRows - 1));
273}
274
275function branchCard(els, handlers) {
276 const g = state.git;
277 if (g.status !== "ok" || !g.repo) return null;
278 const pr = g.pr;
279 if (!pr) {
280 return sized("This branch", card(els, { key: "branch", title: "THIS BRANCH", color: theme.git, meta: "no pull request", rows: [
281 muted(els, "none", g.local?.ahead ? "Pushed commits, no PR yet. Open one with gh pr create." : "No pull request for this branch."),
282 ] }), 1);
283 }
284 const st = prState(pr);
285 const reviewers = [
286 ...pr.reviews.map((r) => ({ text: `${r.state === "APPROVED" ? "✓" : r.state === "CHANGES_REQUESTED" ? "✗" : "●"} ${r.login}`, color: r.state === "APPROVED" ? theme.online : r.state === "CHANGES_REQUESTED" ? theme.error : theme.muted })),
287 ...pr.requested.filter((x) => !pr.reviews.some((r) => r.login === x)).map((x) => ({ text: `● ${x}`, color: theme.amber })),
288 ];
289 const rows = [
290 line(els, "title", [{ text: pr.title, grow: true, bold: true }]),
291 line(els, "checks", [...checksPieces(pr.checks), { text: "", grow: true }, mergePiece(pr)]),
292 line(els, "reviews", [
293 { text: "reviews ", color: theme.muted },
294 ...(reviewers.length ? reviewers.flatMap((r, i) => [i ? { text: " " } : null, r]) : [{ text: pr.decision === "REVIEW_REQUIRED" ? "needed, none asked yet" : "none", color: theme.muted }]),
295 ]),
296 els.Box({ key: "actions", flexDirection: "row", gap: 2, children: [
297 els.Button({ key: "copy-pr", label: "Copy link", onPress: () => handlers.onCopy?.(pr.url) }),
298 ] }),
299 ];
300 return sized("This branch", card(els, { key: "branch", title: "THIS BRANCH", color: theme.git, meta: `PR #${pr.number} · ${st.text}`, metaColor: st.color, rows }), rows.length);
301}
302
303function prsCard(els, handlers, max, now) {
304 const g = state.git;
305 if (g.status !== "ok" || !g.repo) return null;
306 const list = g.prs.filter((p) => p.number !== g.pr?.number)
307 .sort((a, b) => Number(g.reviewMe.includes(b.number)) - Number(g.reviewMe.includes(a.number)) || b.updatedAt - a.updatedAt);
308 const rows = list.slice(0, max).map((p) => {
309 const pill = prPill(p, g);
310 return line(els, `pr-${p.number}`, [
311 { text: `#${p.number}`, width: 6, color: theme.muted },
312 { text: p.title, grow: true },
313 { text: clip(pill.text, 10), width: 11, right: true, color: pill.color },
314 { text: clip(age(p.updatedAt, now), 4), width: 5, right: true, color: theme.muted },
315 { text: " " },
316 copyButton(els, "copy", p.url, handlers),
317 ]);
318 });
319 if (list.length > max) rows.push(muted(els, "more", `+${list.length - max} more`));
320 if (!rows.length) rows.push(muted(els, "none", "No other open pull requests."));
321 const meta = `${g.prs.length} open${g.reviewMe.length ? ` · ${g.reviewMe.length} for you` : ""}`;
322 return sized("Pull requests", card(els, { key: "prs", title: "PULL REQUESTS", color: theme.git, meta, metaColor: g.reviewMe.length ? theme.amber : theme.muted, rows }), rows.length);
323}
324
325function runGlyph(r, now) {
326 if (r.status !== "completed") return { g: spin(now), color: theme.amber };
327 if (r.conclusion === "success") return { g: "✓", color: theme.online };
328 if (r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure") return { g: "✗", color: theme.error };
329 return { g: "⊘", color: theme.muted };
330}
331
332function runsCard(els, handlers, max, now) {
333 const g = state.git;
334 if (g.status !== "ok" || !g.repo) return null;
335 const rows = g.runs.slice(0, max).map((r) => {
336 const st = runGlyph(r, now);
337 const when = r.status === "completed" ? age(r.updatedAt, now) : `${r.status === "queued" ? "queued" : "running"} ${fmtDur(now - r.startedAt)}`;
338 return line(els, `run-${r.id}`, [
339 { text: `${st.g} `, color: st.color },
340 { text: clip(r.name, 12), width: 13, bold: true },
341 { text: r.branch, color: theme.muted, grow: true },
342 { text: ` ${when} `, color: r.status === "completed" ? theme.muted : theme.amber },
343 copyButton(els, "copy", r.url, handlers),
344 ]);
345 });
346 if (!rows.length) rows.push(muted(els, "none", "No workflow runs."));
347 const failing = g.runs.filter((r) => r.status === "completed" && runGlyph(r, now).g === "✗").length;
348 const running = g.runs.filter((r) => r.status !== "completed").length;
349 const meta = [running ? `${running} running` : null, failing ? `${failing} failed` : null].filter(Boolean).join(" · ");
350 return sized("Actions", card(els, { key: "runs", title: "ACTIONS", color: theme.git, meta, metaColor: failing ? theme.error : running ? theme.amber : theme.muted, rows }), rows.length);
351}
352
353const SEVERITY = { critical: theme.error, high: theme.error, medium: theme.amber, moderate: theme.amber, low: theme.muted };
354
355function issuesCard(els, handlers, max, now) {
356 const g = state.git;
357 if (g.status !== "ok" || !g.repo) return null;
358 const rows = [];
359 for (const i of g.issues.slice(0, max)) {
360 rows.push(line(els, `is-${i.number}`, [
361 { text: `#${i.number}`, width: 6, color: theme.muted },
362 { text: i.title, grow: true },
363 i.labels[0] ? { text: ` ${clip(i.labels[0], 12)}`, color: theme.agents } : null,
364 { text: clip(age(i.updatedAt, now), 4), width: 5, right: true, color: theme.muted },
365 { text: " " },
366 copyButton(els, "copy", i.url, handlers),
367 ]));
368 }
369 for (const a of (g.alerts ?? []).slice(0, Math.max(1, max - rows.length))) {
370 rows.push(line(els, `al-${a.number}`, [
371 { text: "⚠ ", color: SEVERITY[a.severity] ?? theme.amber },
372 { text: a.pkg, bold: true, grow: true },
373 { text: ` ${a.severity} `, color: SEVERITY[a.severity] ?? theme.amber },
374 copyButton(els, "copy", a.url, handlers),
375 ]));
376 }
377 if (!rows.length) rows.push(muted(els, "none", "No issues assigned to you."));
378 const nAlerts = g.alerts?.length ?? 0;
379 const meta = `${g.issues.length} assigned${nAlerts ? ` · ${nAlerts} ⚠` : ""}`;
380 return sized("Issues", card(els, { key: "issues", title: "ISSUES & ALERTS", color: theme.git, meta, metaColor: nAlerts ? theme.amber : theme.muted, rows }), rows.length);
381}
382
383function gitDock(els, w, capacity, handlers, now) {
384 return stackCards(els, [
385 repoCard(els, handlers, now),
386 branchCard(els, handlers),
387 prsCard(els, handlers, 4, now),
388 runsCard(els, handlers, 3, now),
389 issuesCard(els, handlers, 3, now),
390 ], capacity);
391}
392
393// ---------------------------------------------------------------- Agents
394
395function allSessions() {
396 return orderSessions(state.self, state.sessions);
397}
398
399// Subagents of a session still at work.
400const liveAgents = (s) => (s.agents ?? []).filter((a) => !isEnded(a.status)).length;
401// A session is busy while its turn runs or any of its agents does: a turn
402// can end with a background agent still at work.
403const sessionBusy = (s) => (s.activity?.state && s.activity.state !== "idle") || liveAgents(s) > 0;
404
405function activityPieces(s, now) {
406 const a = s.activity ?? { state: "idle" };
407 const live = liveAgents(s);
408 const since = a.since ? now - a.since : 0;
409 const tail = [s.cost != null ? fmtUsd(s.cost) : null, s.context != null ? `ctx ${Math.round(s.context)}%` : null].filter(Boolean).join(" · ");
410 const head = a.state === "tool" ? { text: `running ${a.tool} · ${fmtDur(since)}`, color: theme.agents }
411 : a.state === "thinking" ? { text: `thinking · ${fmtDur(since)}`, color: theme.agents }
412 : live ? { text: `waiting on ${live} agent${live === 1 ? "" : "s"}`, color: theme.agents }
413 : { text: `idle${a.since ? ` ${age(a.since, now)}` : ""}`, color: theme.muted };
414 return [{ text: " " }, head, tail ? { text: ` · ${tail}`, color: theme.muted, grow: true } : { text: "", grow: true }];
415}
416
417function sessionRows(els, s, handlers, now, maxAgents) {
418 const here = s.id === state.sessionId;
419 const busy = sessionBusy(s);
420 const color = nameColor(s.id);
421 const folded = state.collapsed.has(s.id);
422 const agents = s.agents ?? [];
423 const live = agents.filter((a) => !isEnded(a.status)).length;
424 const rows = [line(els, `s-${s.id}`, [
425 { text: `${busy ? "●" : "○"} `, color: busy ? color : theme.muted },
426 { text: s.project, bold: true, color },
427 here ? { text: " (here)", color: theme.muted } : null,
428 { text: s.branch ? ` ${s.branch}` : "", color: theme.muted, grow: true },
429 { text: ` ${shortModel(s.model)} `, color: theme.muted },
430 agents.length
431 ? { node: els.Button({ key: "fold", plain: true, dimColor: true, label: folded ? `▸${live || agents.length}` : "▾", onPress: () => handlers.onToggleSession?.(s.id) }) }
432 : { text: " " }, // keeps the model column in line with rows that have the fold mark
433 ]), line(els, `sa-${s.id}`, activityPieces(s, now))];
434 if (!folded) {
435 const shown = agents.slice(0, maxAgents);
436 for (const a of shown) {
437 const indent = ` ${" ".repeat(a.depth ?? 0)}${a.last || a === shown.at(-1) ? "└─ " : "├─ "}`;
438 rows.push(agentRow(els, `ag-${s.id}-${a.id}`, a, { indent, now }));
439 }
440 if (agents.length > maxAgents) rows.push(muted(els, `agm-${s.id}`, ` +${agents.length - maxAgents} more`));
441 }
442 return rows;
443}
444
445function sessionsCard(els, handlers, maxRows, now) {
446 const list = allSessions();
447 const rows = [];
448 for (const s of list) {
449 const next = sessionRows(els, s, handlers, now, s.id === state.sessionId ? 4 : 2);
450 if (rows.length && rows.length + next.length > maxRows) {
451 rows.push(muted(els, "more", `+${list.length - list.indexOf(s)} more sessions`));
452 break;
453 }
454 rows.push(...next);
455 }
456 if (!rows.length) rows.push(muted(els, "none", "Starting…"));
457 const meta = `${list.length} on this computer`;
458 return sized("Sessions", card(els, { key: "sessions", title: "SESSIONS", color: theme.agents, meta, rows }), rows.length);
459}
460
461const FILTERS = { all: "all", here: "here", errors: "errors" };
462
463function feedRow(els, f, w, now) {
464 const t = new Date(f.at);
465 const clock = `${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}:${String(t.getSeconds()).padStart(2, "0")}`;
466 const who = f.agent ? ` ↳ ${f.agent}` : f.session;
467 const end = f.done ? (f.isError ? { text: " ✗", color: theme.error } : { text: " ✓", color: theme.online }) : { text: ` ${spin(now)}`, color: theme.agents };
468 return line(els, `f-${f.sessionId}-${f.key}`, [
469 w >= 46 ? { text: `${clock} `, color: theme.muted } : null,
470 { text: clip(who, w >= 46 ? 11 : 9), width: w >= 46 ? 12 : 10, color: f.agent ? theme.muted : nameColor(f.sessionId) },
471 { text: clip(f.tool, 6), width: 7, bold: true },
472 { text: f.summary || "", color: theme.muted, grow: true },
473 f.times > 1 ? { text: ` ×${f.times}`, color: theme.agents } : null,
474 { text: ` ${f.done ? fmtDur(f.ms) : fmtDur(now - f.at)}`, color: f.done ? theme.muted : theme.agents },
475 end,
476 ]);
477}
478
479function feedCard(els, w, rowsLeft, handlers, now) {
480 const rows = mergedFeed(allSessions(), { filter: state.feedFilter, ownId: state.sessionId, limit: Math.max(1, rowsLeft) })
481 .map((f) => feedRow(els, f, w, now));
482 if (!rows.length) rows.push(muted(els, "none", state.feedFilter === "errors" ? "No failed tool calls." : "Tool calls show up here as they run."));
483 const metaNode = els.Button({ key: "filter", plain: true, dimColor: true, label: `${FILTERS[state.feedFilter]} ▾`, onPress: () => handlers.onCycleFilter?.() });
484 return { name: "Live", node: card(els, { key: "feed", title: "LIVE", color: theme.agents, metaNode, rows, grow: true }), height: 3 + Math.min(rows.length, 1) };
485}
486
487function agentsDock(els, w, capacity, handlers, now) {
488 const sessions = sessionsCard(els, handlers, Math.max(3, Math.floor((capacity - 4) * 0.55)), now);
489 const left = capacity - sessions.height - CARD_GAP - 3;
490 return stackCards(els, [sessions, feedCard(els, w, left, handlers, now)], capacity);
491}
492
493// ---------------------------------------------------------------- entry points
494
495// The docked pane's body for a built-in room: cards within `capacity` rows.
496export function sysDock(els, view, width, capacity, handlers, now = Date.now()) {
497 const w = Math.max(20, width - 4); // inside a card's border and padding
498 if (view === "usage") return usageDock(els, w, capacity, handlers, now);
499 if (view === "git") return gitDock(els, w, capacity, handlers, now);
500 return agentsDock(els, w, capacity, handlers, now);
501}
502
503// The inline pane: a few lines, no cards.
504export function sysInline(els, view, width, handlers, now = Date.now()) {
505 const { Text } = els;
506 const w = Math.max(20, width);
507 const meta = SYS[view];
508 const head = (text, right) => line(els, "head", [
509 { text: `${meta.icon} ${meta.label}`, bold: true, color: meta.color },
510 { text: text ? ` ${text}` : "", color: theme.muted, grow: true },
511 right ? { text: right, color: theme.muted } : null,
512 ]);
513 if (view === "usage") {
514 const u = state.usage;
515 const parts = [head([shortModel(u.model), u.startedAt ? fmtSpan(now - u.startedAt) : null].filter(Boolean).join(" · "))];
516 if (!u.measured) return [...parts, muted(els, "wait", "Waiting for the first reply from Claude…")];
517 const c = u.context ?? {};
518 parts.push(meter(els, { key: "ctx", label: "Context", pct: c.percent, width: w, right: c.tokens != null ? `${fmtTokens(c.tokens)}/${fmtTokens(c.window)}` : c.window ? `${fmtTokens(c.window)} window` : "" }));
519 for (const l of limitWindows(u).slice(0, 2)) parts.push(limitMeter(els, l, w));
520 const cache = cacheRatio(u);
521 const running = agentCounts(u).running;
522 parts.push(line(els, "spend", [
523 { text: "Spend", width: LABEL_W, color: theme.muted },
524 { text: u.cost ? fmtUsd(u.cost.usd) : "–", bold: true, color: theme.usage },
525 { text: " · ", color: theme.muted },
526 { text: fmtTokens(totalTokens(u)), bold: true },
527 { text: " tokens · ", color: theme.muted },
528 { text: cache == null ? "–" : `${Math.round(cache)}%`, bold: true, color: cache == null ? theme.muted : cache >= 60 ? theme.online : cache >= 30 ? theme.amber : theme.error },
529 { text: " cache hits", color: theme.muted },
530 running ? { text: ` · ⟡ ${running} running`, color: theme.agents } : null,
531 ]));
532 return parts;
533 }
534 if (view === "git") {
535 const g = state.git;
536 const l = g.local;
537 const parts = [head("", ago(g, now).text)];
538 if (l) {
539 const { sync, tree } = syncPieces(l);
540 parts.push(line(els, "branch", [{ text: `⎇ ${l.branch ?? "?"}`, bold: true, color: theme.git }, { text: " " }, ...sync, { text: " " }, { ...tree, grow: true }]));
541 }
542 const help = ghStatusText(g);
543 if (help) return [...parts, Text({ key: "help", color: theme.warn, wrap: "wrap", children: help })];
544 if (g.pr) {
545 const c = g.pr.checks;
546 parts.push(line(els, "pr", [{ text: `#${g.pr.number} `, color: theme.git, bold: true }, { text: g.pr.title, grow: true }, { text: ` ✓${c.pass} ●${c.pending} ✗${c.fail}`, color: c.fail ? theme.error : c.pending ? theme.amber : theme.online }]));
547 }
548 for (const p of g.prs.filter((p) => p.number !== g.pr?.number).slice(0, 3)) {
549 const pill = prPill(p, g);
550 parts.push(line(els, `pr-${p.number}`, [{ text: `#${p.number}`, width: 6, color: theme.muted }, { text: p.title, grow: true }, { text: ` ${pill.text}`, color: pill.color }]));
551 }
552 return parts;
553 }
554 const list = allSessions();
555 const parts = [head(`${list.length} session${list.length === 1 ? "" : "s"}`)];
556 for (const s of list.slice(0, 4)) {
557 const live = (s.agents ?? []).filter((a) => !isEnded(a.status)).length;
558 parts.push(line(els, `s-${s.id}`, [
559 { text: `${sessionBusy(s) ? "●" : "○"} `, color: sessionBusy(s) ? nameColor(s.id) : theme.muted },
560 { text: clip(s.project, 14), width: 15, bold: true, color: nameColor(s.id) },
561 ...activityPieces(s, now).slice(1),
562 live ? { text: ` ⟡${live}`, color: theme.agents } : null,
563 ]));
564 }
565 return parts;
566}
567
568// How full the context is, as weather (from token-weather, the
569// claude-code-playground mod): single-width symbols that line up in any font.
570const FORECAST = [
571 { upTo: 25, icon: "☀", word: "Clear", color: theme.amber },
572 { upTo: 50, icon: "☁", word: "Cloudy", color: theme.usage },
573 { upTo: 75, icon: "☂", word: "Showers", color: "#6FC2B5" },
574 { upTo: 90, icon: "☇", word: "Storm", color: theme.agents },
575 { upTo: Infinity, icon: "↯", word: "Compact soon", color: theme.error },
576];
577export function forecast(pct) {
578 return FORECAST.find((f) => pct < f.upTo) ?? FORECAST.at(-1);
579}
580
581// The band's line for a built-in room, as colored pieces, fitted to `width`
582// cells. For Usage: "☂ Showers 67% 134k/200k ▁▂▅█ ▲+98k · 5-hour 12% ·
583// spent $1.21": the context as a forecast once measured, the 5-hour window,
584// and the spend once there is some.
585export function sysBandPieces(view, width = 0) {
586 const sep = { text: " · ", color: theme.muted };
587 const join = (list) => list.filter(Boolean).flatMap((p, i) => (i ? [sep, ...p] : p));
588 const figure = (label, pct) => [{ text: `${label} `, color: theme.muted }, { text: `${Math.round(pct)}%`, color: level(pct), bold: true }];
589 if (view === "usage") {
590 const u = state.usage;
591 const c = u.context;
592 // The forecast, then the 5-hour window and the spend. On a narrow band
593 // the turn's delta goes first, then the chart, then the token count.
594 const weather = (detail) => {
595 if (c?.percent == null) return null;
596 const f = forecast(c.percent);
597 const hist = u.ctxHistory;
598 const delta = hist.length >= 2 ? hist.at(-1) - hist.at(-2) : 0;
599 return [
600 { text: `${f.icon} ${f.word} `, color: f.color, bold: true },
601 { text: `${Math.round(c.percent)}%`, color: f.color, bold: true },
602 detail >= 1 && c.tokens != null ? { text: ` ${fmtTokens(c.tokens)}/${fmtTokens(c.window)}`, color: theme.muted } : null,
603 detail >= 2 && hist.length >= 2 ? { text: ` ${sparkline(hist, 12)}`, color: f.color } : null,
604 detail >= 3 && delta ? { text: delta > 0 ? ` ▲+${fmtTokens(delta)}` : ` ▼${fmtTokens(-delta)}`, color: theme.muted } : null,
605 ].filter(Boolean);
606 };
607 const five = limitWindows(u).find((l) => l.kind === "five_hour");
608 const tail = [
609 five ? (five.percentUsed == null
610 ? [{ text: "5-hour ", color: theme.muted }, { text: "–", color: theme.muted }]
611 : figure("5-hour", five.percentUsed)) : null,
612 u.cost?.usd > 0 ? [{ text: "spent ", color: theme.muted }, { text: fmtUsd(u.cost.usd), color: theme.usage, bold: true }] : null,
613 ];
614 let pieces = [];
615 for (let detail = 3; detail >= 0; detail--) {
616 pieces = join([weather(detail), ...tail]);
617 if (!width || cells(pieces.map((p) => p.text).join("")) <= width) break;
618 }
619 return pieces.length ? pieces : [{ text: "waiting for the first reply", color: theme.muted }];
620 }
621 if (view === "git") {
622 const g = state.git;
623 if (g.status === "no-repo") return [{ text: "not a git repository", color: theme.warn }];
624 const pr = g.pr;
625 // git works without gh: the branch shows either way.
626 return join([
627 g.local?.branch ? [{ text: g.local.branch, color: theme.git, bold: true }] : null,
628 g.local ? syncPieces(g.local).sync : null,
629 g.local ? [syncPieces(g.local).tree] : null,
630 ghStatusText(g) ? [{ text: "gh isn't set up", color: theme.warn }] : null,
631 pr ? [{ text: `#${pr.number} `, color: theme.muted }, ...checksPieces(pr.checks).map((p) => ({ ...p, text: p.text.replace(" running", "") }))] : null,
632 g.reviewMe.length ? [{ text: `${g.reviewMe.length} review${g.reviewMe.length === 1 ? "" : "s"} for you`, color: theme.amber }] : null,
633 ]);
634 }
635 const list = allSessions();
636 const running = list.reduce((n, s) => n + (s.agents ?? []).filter((a) => !isEnded(a.status)).length, 0);
637 return join([
638 [{ text: `${list.length} session${list.length === 1 ? "" : "s"}`, color: theme.muted }],
639 [{ text: `${running} agent${running === 1 ? "" : "s"} running`, color: running ? theme.agents : theme.muted }],
640 ]);
641}
642
643// The same as plain text.
644export function sysBandText(view) {
645 return sysBandPieces(view).map((p) => p.text).join("");
646}
647
648// A tab's badge: what's worth a glance from another room.
649export function sysBadge(view) {
650 if (view === "git" && gitAttention(state.git)) return { text: "●", color: theme.error };
651 if (view === "agents") {
652 const n = allSessions().reduce((k, s) => k + (s.agents ?? []).filter((a) => !isEnded(a.status)).length, 0);
653 if (n) return { text: String(n), color: theme.agents };
654 }
655 if (view === "usage" && (state.usage.context?.percent ?? 0) >= 80) return { text: `${Math.round(state.usage.context.percent)}%`, color: level(state.usage.context.percent) };
656 return null;
657}
658
659// ---------------------------------------------------------------- snapshots
660
661const textBar = (pct, width) => {
662 const { fill, track } = bar(pct, width);
663 return fill + track;
664};
665
666// A built-in room as plain text, for a snippet card in a chat room.
667export function snapshotText(view, now = Date.now()) {
668 const W = 20;
669 const out = [];
670 if (view === "usage") {
671 const u = state.usage;
672 out.push(`Usage · ${[shortModel(u.model), u.startedAt ? fmtSpan(now - u.startedAt) : null].filter(Boolean).join(" · ")}`);
673 if (u.context) out.push(`${fit("Context", LABEL_W)}${textBar(u.context.percent ?? 0, W)} ${fit(`${Math.round(u.context.percent ?? 0)}%`, 4, { right: true })} ${fmtTokens(u.context.tokens)}/${fmtTokens(u.context.window)}`);
674 for (const l of limitWindows(u)) {
675 out.push(`${fit(limitName(l.kind), LABEL_W)}${textBar(l.percentUsed ?? 0, W)} ${fit(l.percentUsed == null ? "–" : `${Math.round(l.percentUsed)}%`, 4, { right: true })}`);
676 }
677 const cache = cacheRatio(u);
678 const rate = burnRate(u, now);
679 out.push(`Spend ${u.cost ? fmtUsd(u.cost.usd) : "–"}${rate != null ? ` (+${fmtUsd(rate)}/h)` : ""} · ${fmtTokens(totalTokens(u))} tokens · ${cache == null ? "–" : `${Math.round(cache)}%`} cache hits`);
680 const tools = toolRows(u).slice(0, 5);
681 if (tools.length) {
682 out.push(`${fit("Tools", 10)}${toolHeader()}`);
683 for (const t of tools) out.push(` ${fit(t.name, 8)}${[t.p50, t.p95].map((v) => fit(fmtDur(v), 6, { right: true })).join("")}${fit(String(t.count), 7, { right: true })}${fit(String(t.errors), 6, { right: true })}`);
684 }
685 const { running, done } = agentCounts(u);
686 if (running || done) out.push(`Agents ${running} running · ${done} done`);
687 } else if (view === "git") {
688 const g = state.git;
689 const l = g.local;
690 const st = l ? syncPieces(l) : null;
691 out.push(`${g.repo?.name || "repository"}${l ? ` · ⎇ ${l.branch} ${st.sync.map((p) => p.text).join("")} · ${st.tree.text}` : ""}`);
692 if (g.pr) {
693 const c = g.pr.checks;
694 out.push(`PR #${g.pr.number} ${prState(g.pr).text}: ${g.pr.title}`);
695 out.push(` checks ✓${c.pass} ●${c.pending} ✗${c.fail} · ${mergePiece(g.pr).text}`);
696 const revs = g.pr.reviews.map((r) => `${r.state === "APPROVED" ? "✓" : "✗"} ${r.login}`).concat(g.pr.requested.map((x) => `● ${x}`));
697 if (revs.length) out.push(` reviews ${revs.join(" ")}`);
698 out.push(` ${g.pr.url}`);
699 }
700 for (const p of g.prs.filter((p) => p.number !== g.pr?.number).slice(0, 5)) out.push(`#${p.number} ${clip(p.title, 50)} ${prPill(p, g).text}`);
701 for (const r of g.runs.slice(0, 3)) out.push(`${runGlyph(r, now).g} ${r.name} · ${r.branch} · ${r.status === "completed" ? age(r.updatedAt, now) : "running"}`);
702 } else {
703 for (const s of allSessions()) {
704 out.push(`${sessionBusy(s) ? "●" : "○"} ${s.project}${s.branch ? ` (${s.branch})` : ""} · ${shortModel(s.model)} · ${s.activity?.state ?? "idle"}${s.cost != null ? ` · ${fmtUsd(s.cost)}` : ""}`);
705 for (const a of s.agents ?? []) out.push(` ${isEnded(a.status) ? (a.status === "completed" ? "✓" : "✗") : "◐"} ${a.type}: ${clip(a.description, 40)}${a.top ? ` (${a.top})` : ""}`);
706 }
707 }
708 return out.join("\n");
709}
710hooks/theme.mjs 58 lines1// squad-chat's look: a warm "classic" palette (coral accent, amber, sky, leaf
2// green on the terminal's own background), with grey card borders and meta
3// text from the person's theme. Fixed hex colors, so the accents read the
4// same in every theme.
5
6export const theme = {
7 accent: "#D97757", // coral: title, active tab, you, focused input, "new"
8 onAccent: "#1F1E1D", // text drawn on the accent
9 amber: "#E2B86B", // secondary highlight: unread badges, the band's "new"
10 sky: "#7FB6E2", // informational dots
11 you: "#7FB6E2", // your name: set apart from your coral bubbles
12 online: "#6CC070", // online, live
13 // Message bubbles: fixed text and fill, so they read in light and dark themes.
14 mineBubble: "#D97757",
15 mineText: "#1F1E1D",
16 theirBubble: "#3A3836",
17 theirText: "#ECE7E1",
18 border: "inactive", // card borders
19 muted: "subtle", // meta text: times, counts, hints
20 warn: "#E2B86B",
21 error: "#E06C75",
22 // The built-in rooms, each with its own color from the same warm palette:
23 // its tab, its card titles and its meters' fill.
24 usage: "#7FB6E2", // sky
25 git: "#6CC070", // leaf
26 agents: "#C8A2E0", // lilac
27};
28
29// Green, amber or red for how full something is (a percent, 0-100).
30export function level(pct) {
31 if (pct >= 85) return theme.error;
32 if (pct >= 60) return theme.amber;
33 return theme.online;
34}
35
36// Warm, distinct name colors for friends. Sky blue is kept for "you", and
37// coral for your bubbles, so neither appears here.
38const NAME_COLORS = ["#E2B86B", "#6CC070", "#C8A2E0", "#E58FA8", "#6FC2B5", "#D9A877", "#B8C46A", "#D4A5A5"];
39
40// The same person always gets the same color, in every pane.
41export function nameColor(userId = "") {
42 let h = 0;
43 for (const ch of String(userId)) h = (h * 31 + ch.codePointAt(0)) >>> 0;
44 return NAME_COLORS[h % NAME_COLORS.length];
45}
46
47export const glyph = {
48 brand: "◆",
49 on: "●",
50 off: "○",
51 busy: "◐",
52 quiet: "🔕",
53 prompt: "›",
54 rule: "─",
55 bar: "│",
56 typing: "✎",
57};
58hooks/widgets.mjs 237 lines1// Small drawing pieces the built-in rooms share: text measuring, number
2// formats, meters, sparklines, stat tiles and a height-aware card stack.
3// Plain logic and element trees, no $ (see squad-chat.mjs).
4
5import { theme, level } from "./theme.mjs";
6
7// ---------------------------------------------------------------- measuring text
8
9const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]|\p{Extended_Pictographic}/u;
10
11// Terminal cells a string takes: wide characters (CJK, most emoji) take two.
12export function cells(str) {
13 let n = 0;
14 for (const ch of String(str)) n += WIDE.test(ch) ? 2 : 1;
15 return n;
16}
17
18// Rows a text takes when word-wrapped at `width`, the way the terminal wraps.
19export function rowsFor(text, width) {
20 width = Math.max(10, width);
21 let rows = 0;
22 for (const para of String(text).split("\n")) {
23 let line = 0;
24 rows++;
25 for (const word of para.split(/(\s+)/)) {
26 const w = cells(word);
27 if (!w) continue;
28 if (line + w <= width) { line += w; continue; }
29 if (/^\s+$/.test(word)) { line = 0; rows++; continue; }
30 rows += line > 0 ? 1 : 0;
31 line = w % width || (w > 0 ? width : 0);
32 rows += Math.max(0, Math.ceil(w / width) - 1);
33 }
34 }
35 return Math.max(1, rows);
36}
37
38// Cut to `width` cells, with "…" when anything was cut.
39export function clip(str, width) {
40 str = String(str ?? "");
41 if (width <= 0) return "";
42 if (cells(str) <= width) return str;
43 let out = "";
44 let n = 0;
45 for (const ch of str) {
46 const w = WIDE.test(ch) ? 2 : 1;
47 if (n + w > width - 1) break;
48 out += ch;
49 n += w;
50 }
51 return `${out}…`;
52}
53
54// Clip, then pad with spaces to exactly `width` cells (on the left with `right`).
55export function fit(str, width, { right = false } = {}) {
56 const s = clip(str, width);
57 const pad = " ".repeat(Math.max(0, width - cells(s)));
58 return right ? pad + s : s + pad;
59}
60
61// ---------------------------------------------------------------- numbers
62
63export function fmtTokens(n) {
64 n = Number(n) || 0;
65 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 9_950_000 ? 0 : 1)}M`;
66 if (n >= 1000) return `${Math.round(n / 1000)}k`;
67 return String(Math.round(n));
68}
69
70export function fmtUsd(n) {
71 n = Number(n) || 0;
72 if (n >= 100) return `$${Math.round(n)}`;
73 return `$${n.toFixed(2)}`;
74}
75
76// 0.2s, 1.8s, 42s, 1:12, 1:02:03
77export function fmtDur(ms) {
78 ms = Math.max(0, Number(ms) || 0);
79 if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
80 const s = Math.round(ms / 1000);
81 if (s < 60) return `${s}s`;
82 const h = Math.floor(s / 3600);
83 const m = Math.floor((s % 3600) / 60);
84 const ss = String(s % 60).padStart(2, "0");
85 return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
86}
87
88// How long a session has run: 12m, 1h12m.
89export function fmtSpan(ms) {
90 const m = Math.max(0, Math.floor((Number(ms) || 0) / 60_000));
91 if (m < 60) return `${m}m`;
92 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
93}
94
95// "now", "12s ago", "3m ago", "2h ago", "4d ago"
96export function relTime(ms, now = Date.now()) {
97 const s = Math.max(0, Math.round((now - ms) / 1000));
98 if (s < 5) return "now";
99 if (s < 60) return `${s}s ago`;
100 if (s < 3600) return `${Math.floor(s / 60)}m ago`;
101 if (s < 86_400) return `${Math.floor(s / 3600)}h ago`;
102 return `${Math.floor(s / 86_400)}d ago`;
103}
104
105// The same, without "ago": for columns.
106export function age(ms, now = Date.now()) {
107 const r = relTime(ms, now);
108 return r === "now" ? "now" : r.replace(" ago", "");
109}
110
111// When a rate-limit window resets: "2:40pm" within a day (past midnight
112// too), "Fri" further out.
113export function resetLabel(iso, now = new Date()) {
114 const d = new Date(iso);
115 if (Number.isNaN(d.getTime())) return "";
116 if (d.getTime() - now.getTime() < 24 * 3_600_000) {
117 const h = d.getHours();
118 return `${h % 12 || 12}:${String(d.getMinutes()).padStart(2, "0")}${h < 12 ? "am" : "pm"}`;
119 }
120 return d.toLocaleDateString("en-US", { weekday: "short" });
121}
122
123// ---------------------------------------------------------------- bars and sparks
124
125const SPARKS = "▁▂▃▄▅▆▇█";
126
127// A bar `width` cells wide, `pct` full, to half a cell: a heavy line, the
128// filled part in the meter's color and the rest in grey, both one weight.
129export function bar(pct, width) {
130 width = Math.max(1, Math.floor(width));
131 const halves = Math.round((Math.min(100, Math.max(0, Number(pct) || 0)) / 100) * width * 2);
132 const whole = Math.floor(halves / 2);
133 const half = halves % 2 ? "╸" : "";
134 return { fill: "━".repeat(whole) + half, track: "━".repeat(Math.max(0, width - whole - (half ? 1 : 0))) };
135}
136
137// Values as a row of rising blocks, scaled from zero to the largest.
138export function sparkline(values, width = values.length) {
139 const vals = values.slice(-width);
140 const max = Math.max(...vals, 0);
141 if (!vals.length) return "";
142 return vals.map((v) => (max > 0 && v > 0 ? SPARKS[Math.min(7, Math.max(1, Math.round((v / max) * 7)))] : SPARKS[0])).join("");
143}
144
145// ---------------------------------------------------------------- elements
146
147// A column `width` cells wide, the text cut to fit and, with `right`, set
148// against the column's right edge. The Box holds the width, not padding
149// spaces, so columns line up where text is drawn in a proportional font (the
150// desktop) as they do in the terminal.
151export function col(els, key, text, width, { right = false, box = {}, ...style } = {}) {
152 const { Box, Text } = els;
153 return Box({ key, width, flexShrink: 0, justifyContent: right ? "flex-end" : "flex-start", ...box, children: [
154 Text({ key: "t", wrap: "truncate-end", ...style, children: clip(text, width) }),
155 ] });
156}
157
158// One labeled meter row: `Context ━━━━━━━╸━━━━━ 62% 124k/200k`.
159// `width` is the row's width in cells. The bar gives way first, so the
160// numbers at the end always show.
161export const LABEL_W = 10;
162export function meter(els, { key, label, pct, width, right = "", color, rightColor, onPress }) {
163 const { Box, Text, Button } = els;
164 const rightW = Math.min(11, Math.max(0, width - LABEL_W - 16));
165 const barW = Math.min(28, Math.max(4, width - LABEL_W - 1 - 4 - (rightW ? 1 + rightW : 0)));
166 const { fill, track } = bar(pct, barW);
167 const tint = color ?? level(pct);
168 const known = Number.isFinite(pct);
169 return Box({ key, flexDirection: "row", children: [
170 onPress
171 ? Box({ key: "label", width: LABEL_W, flexShrink: 0, children: [Button({ key: "press", plain: true, label: clip(label, LABEL_W - 1), onPress })] })
172 : col(els, "label", label, LABEL_W, { color: theme.muted }),
173 Box({ key: "bar", flexDirection: "row", width: barW, flexShrink: 1, minWidth: 0, overflow: "hidden", children: [
174 Text({ key: "fill", color: tint, wrap: "truncate-end", children: fill }),
175 Text({ key: "track", color: theme.muted, wrap: "truncate-end", children: track }),
176 ] }),
177 col(els, "pct", known ? `${Math.round(pct)}%` : "–", 5, { right: true, bold: true, color: known ? tint : theme.muted }),
178 rightW ? col(els, "right", right, rightW, { color: rightColor ?? theme.muted, box: { marginLeft: 1 } }) : null,
179 ].filter(Boolean) });
180}
181
182// A big number over a small caption, side by side with others in a row.
183export function statTile(els, { key, value, sub, color, width }) {
184 const { Box, Text } = els;
185 return Box({ key, flexDirection: "column", width, flexShrink: 0, children: [
186 Text({ key: "v", bold: true, color, wrap: "truncate-end", children: clip(value, width - 1) }),
187 Text({ key: "s", color: theme.muted, wrap: "truncate-end", children: clip(sub, width - 1) }),
188 ] });
189}
190
191export function tiles(els, list, width) {
192 const { Box } = els;
193 const w = Math.floor(width / list.length);
194 return Box({ key: "tiles", flexDirection: "row", children: list.map((t, i) => statTile(els, { key: `t${i}`, width: w, ...t })) });
195}
196
197// A short piece of colored status: "✓ 4", "● review", "draft".
198export function pill(els, key, text, color, extra = {}) {
199 const { Text } = els;
200 return Text({ key, color, children: text, ...extra });
201}
202
203// Pieces of one row, left to right, each { text, color, bold, grow } or
204// { node }. The piece marked `grow` takes the space left and is cut to fit;
205// the others keep their size, so a status at the end of a row always shows.
206// One with `width` is a column of that many cells (`right` to right-align).
207export function line(els, key, pieces) {
208 const { Box, Text } = els;
209 return Box({ key, flexDirection: "row", children: pieces.filter(Boolean).map((p, i) => (p.width != null
210 ? col(els, `p${i}`, p.text, p.width, { right: p.right, color: p.color, bold: p.bold, dimColor: p.dim, italic: p.italic })
211 : p.grow
212 ? Box({ key: `p${i}`, flexGrow: 1, flexShrink: 1, minWidth: 0, children: [Text({ key: "t", wrap: "truncate-end", color: p.color, bold: p.bold, dimColor: p.dim, italic: p.italic, children: p.text })] })
213 : Box({ key: `p${i}`, flexShrink: 0, children: [p.node ?? Text({ key: "t", color: p.color, bold: p.bold, dimColor: p.dim, italic: p.italic, backgroundColor: p.bg, children: p.text })] }))) });
214}
215
216// Cards in priority order, as many as the height allows, a row apart so
217// their frames don't run together. What doesn't fit is named on one muted
218// line, so the person knows a taller pane shows more.
219// Each card is { node, height, name }; `capacity` is the rows there are.
220export const CARD_GAP = 1;
221export function stackCards(els, cards, capacity) {
222 const { Box, Text } = els;
223 const shown = [];
224 const hidden = [];
225 let used = 0;
226 for (const c of cards.filter(Boolean)) {
227 const need = c.height + (shown.length ? CARD_GAP : 0);
228 if (!hidden.length && used + need <= capacity) { shown.push(c); used += need; }
229 else hidden.push(c.name);
230 }
231 // The footer needs a row: give up the last card shown if there isn't one.
232 if (hidden.length && used + 1 > capacity && shown.length > 1) hidden.unshift(shown.pop().name);
233 const nodes = shown.flatMap((c, i) => (i ? [Box({ key: `gap-${c.name}`, height: CARD_GAP, flexShrink: 0 }), c.node] : [c.node]));
234 if (hidden.length) nodes.push(Text({ key: "more", color: theme.muted, wrap: "truncate-end", children: `+ ${hidden.join(" · ")} (a taller pane shows them)` }));
235 return nodes;
236}
237