SLOPSHOPPER

squad-chat

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

newpanebandguardcommandtoast
★ 1v0.9.1MITupdated 2026-10-09chrisluo5311/squad-chat/plugins/squad-chat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · squad-chat
│ ┃ Squad Chat ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ squad-chat ● unavailable │ ┃ ◔ Usage ⎇ Git ⟡ Agents │ # chat ● squad-chat: could not run `node --version` │ ┃ ● squad-chat: could not run `node --version` │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ CAN'T START │ ⎿ Read 6 lines │ ┃ │ could not run `node --version` │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /chat │ ┃ ● squad-chat: Sign in with your email (/chat-login you@example.com) o │ ┃ ● squad-chat: could not run `node --version` │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Squad Chat
◆ squad-chat ● unavailable ◔ Usage ⎇ Git ⟡ Agents │ # chat ╭──────────────────────────────────────────────────────────╮ │ CAN'T START │ │ could not run `node --version` │ ╰──────────────────────────────────────────────────────────╯
README

squad-chat

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.

squad-chat demo: sign in, join a room, chat, switch rooms

Install

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:

  • Claude Code 2.1.287 or newer, in the terminal. The desktop app can't run the chat's background process yet.
  • Node 22 or newer on your PATH (node --version). squad-chat tells you if it's missing or too old.

Connect to a server

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

Get started

  1. /chat opens the pane.
  2. Sign in: type a name and press Enter (where the server allows it), or type your email, then the code from the email.
  3. Create a room and give the passcode to your friends: type /room our-team some-passcode in the pane. Friends join with the same line.
  4. Chat: type in the pane and press Enter. Esc goes back to the prompt.

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.

Commands

CommandWhat it does
/chatOpen the pane
/say <message>Send a message to the current room from the prompt
/roomList 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.
/whoWho'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 #roomSend 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-logoutSign out on this computer
/chat notify on / offToast when someone writes @yourname (off by default)
/chat dnd on / off / autoDo 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 / agentsOpen 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.

Privacy

  • What the model sees:
  • Nothing typed in the pane enters the conversation.
  • For /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.
  • Claude Code still keeps a slash command's raw text in one bookkeeping line of the local transcript on your own disk, so type passcodes in the pane if that matters to you.
  • Where your data lives:
  • Messages live in your squad's own Supabase database and are deleted after 30 days. Whoever hosts it can read it, like any database admin.
  • Only members of a room can read it. You become a member only with the room's passcode, and access is enforced in the database.
  • The built-in rooms read this session's figures from Claude Code, and 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.
  • On your computer: your sign-in session is kept in ~/.config/squad-chat/session.json, readable only by you. /chat-logout removes it.
  • What others see: your display name: the name you picked, or the part of your email before the @ (ann@example.com → ann). Friends see it, plus whether you're online. They never see your email.

How it works

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.

License

MIT

Source 11 files
hooks/squad-chat.mjs 681 lines
1// 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}
681
hooks/state.mjs 231 lines
1// 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}
231
hooks/commands.mjs 318 lines
1// 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}
318
hooks/views.mjs 658 lines
1// 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}
658
hooks/metrics.mjs 294 lines
1// 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}
294
hooks/github.mjs 263 lines
1// 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}
263
hooks/sessions.mjs 72 lines
1// 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}
72
hooks/share.mjs 112 lines
1// 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}
112
hooks/sysviews.mjs 710 lines
1// 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}
710
hooks/theme.mjs 58 lines
1// 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};
58
hooks/widgets.mjs 237 lines
1// 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