A pixel-art office of your running Claude Code sessions: one desk per session, animated by what it is doing, with speech bubbles, layouts, characters and drag…

A pixel-art office of every busy terminal in herdr. /office opens a pane (a sidebar when the terminal is wide enough) with a desk for every agent pane, and for every plain pane running something. Each worker's animation shows what the pane is doing, so you can read the room at a glance.
By default only Claude sessions get a desk (show: claude). Other coding agents, terminals running something (as robots) and idle shells are opt-in: agents, busy, all. At most 8 desks show at once (desks, key d: 4, 6, 8, 12 or all), the most relevant first: needs you, working, done, idle. The header says +N more, and m pages on.
Fewer desks are drawn bigger. The office and war room use the largest whole-number scale at which every desk fits, so 8 desks in a full-screen pane are twice the size. Name tags widen with them, and the whole workstation is the drag target. The grid uses its large cards wherever they fit, sidebar included.
Each desk's bubble says what its session is doing:
A session running this mod records its activity in its registry entry, from the model's own stream (thinking or text) and its tool calls. For other sessions it comes from the tail of the transcript: the last assistant block. That is at most one tail -c 32768 per working session whose transcript changed, and at most 3 a poll. The words are text cells in every view (and the grid tag's second line).
render (key g) is hybrid or cells. In hybrid the cell office or war room stays a Raster, and each person is a small real picture (the detailed Kenney people, or the mod's robots, critters and animals) in its own keyed Image. Its background is cropped from the very cells beneath it, pixel for pixel, so no box shows. cells never draws pictures.
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. herdr then passes kitty graphics through to Ghostty.pictures off: this terminal draws no images, desks too small here, the pane is too small for the room or the character art did not load.Where pictures do not draw (or render is cells), the office is all block cells. Its people are critters (characters), and they are drawn bold (style, key b). Bold characters are chunky blocks with a thick outline, eyes two pixels tall and one feature each: hair for humans, a visor for robots, ears for cats and dogs, tufts for owls. They are made for cell resolution rather than shrunk from detailed art. detailed brings back the finer v3 to v6 characters.
The tiles layout is the grid's cards, each with a small real picture of its character (16 x 24 pixels, sent as a 4 KB PNG enlarged as in hybrid) in its own keyed Image over the card. The rest of the card stays cells.
When herdr is missing or fails, the office still works. herdr is asked again a minute later, and the header says which source is in use.
Claude Code's own records come first. These are <config>/sessions/<pid>.json under $CLAUDE_CONFIG_DIR, ~/.claude-work and ~/.claude. Only the *.json files are read: the .key files beside them are secrets and are never opened.
ps -o pid= -p a,b,c per poll) or its record was updated in the last two minutes.busy is working, idle is idle, and anything waiting on permission or input needs you. A session that also runs this mod adds its richer state and activity from its registry entry.Only when there are no such records does the office fall back to:
<config>/plugins/data/office-registry/<session id>.json, holding its id, cwd, project, state (working, idle, needs-you or done), subagent count and last heartbeat. It is written on start and on each state change: turn start, turn end, AskUserQuestion and permission prompts. A heartbeat rewrites it every 30 s, and it is left at session end. An entry not heard from for 2 minutes has expired. It is a file per session rather than $.store because the store is one JSON file per plugin, and sessions writing their own keys into it at once would overwrite each other's copies.<config>/projects/*/*.jsonl written in the last 30 minutes: idle if written in the last 5, done before that. A few project folders are listed per poll, in turn.Outside herdr there is no pane to jump to. Clicking a desk, or pressing its key, shows the session's cwd and id and copies claude --resume <id>. bin/office-monitor reads the same registry and transcripts straight from disk, and shows the resume command in its footer.
| Layout | What it is |
|---|---|
grid (default) | One tile per desk, side view, the v2 look. The tile shrinks so everyone fits. |
office | A room: a back wall with windows whose sky follows the time of day, a bookshelf, a wall clock showing the real time, a server rack with blinking LEDs, a water cooler, plants and a poster. Plank floor in perspective, rugs, light pools from the ceiling lamps. Desks are a long bench for 1 to 6 sessions, then pods of four or rows, in a regular or a small size, so 1 to about 40 sessions fit. |
war-room | Everyone round one big table: the far side faces you, the near side has its back to you with the laptop screen showing. A second table opens when one is full. |
strip | One compact row for a narrow pane. When even that is too wide, each session becomes a head over a coloured state bar. |
/office full (or f) asks Claude Code for as much of the screen as a pane can have: the terminal width minus 30 columns, and up to 200 rows.
Each state has its own pose and accent colour, and the accent repeats on the desk stripe, the name tag and the header strip.
| State | At the desk | Accent |
|---|---|---|
| needs you | Standing and waving both arms, a red and amber beacon flashing, red light on the room, ! on the screen | red |
| working | Typing with the hands alternating, code scrolling, key flashes and sparks, the screen glow lighting the face | green |
| running (plain terminal) | A robot at a green terminal, named after the process (npm run dev) | orange |
| idle | Feet up on the desk, coffee in hand with steam, a sip now and then, the chair swivelling, a screensaver | blue |
| done | Head down on the desk, monitor off, zZ floating up | grey |
| just finished | Cheering with confetti for 4 seconds, then a stretch for 3, round again, until you look at it (its pane is focused in herdr, or it starts working again). Then it sleeps. The "not looked at yet" mark is kept in $.store, so a reload goes on celebrating. | |
| helpers | One minion per active subagent around the desk, and x<count> | |
| this session | A small gold crown |
Press s in the office, or run /office settings. Each option has a key that cycles it, and the choice is saved in the mod's $.store for every session. The userConfig values in plugin.json (/config, or /plugin configure) are the defaults the store overrides.
| Key | Option | Values |
|---|---|---|
l | layout | grid, tiles, office, war-room, strip |
c | characters | critters (the default: small orange blocky mascots), humans (varied skin, hair and clothes, the same per session every time), robots, animals (cats, dogs, owls), mixed by project |
b | style | bold (the default in cells), detailed |
t | lighting | clock, day, night |
p | palette | nightshade (from the Nightshade Ghostty theme), warm, cool, mono |
w | which panes | claude (the default), agents, busy (adds busy terminals), all (adds idle shells as empty desks) |
d | desks at a time | 4, 6, 8 (the default), 12, all; never more than fit the pane |
h | hide done after | 0 (never), 5, 15, 30, 60 minutes |
o | seating | state (the default: needs you, working, done, idle, then busy terminals), stable (a session keeps its place), project, workspace (one rug and label per group) |
m | animation speed | slow, normal, fast |
r | reduced motion | slower frames, no particles, confetti or flashing |
n | toasts | needs-you, changes, off |
g | render | hybrid (the default: people as pictures where they draw), cells |
u | sound | a short chime (sounds/needs-you.wav) when a session starts needing you. Off by default |
Under the options you can pick a session and give it a label, a character (human, robot, critter, cat, dog or owl) or a place in the order (1 is the first desk). A pinned desk is put back at that place in every seating mode; with fewer desks than that it goes last. z forgets that session's settings. Per-session settings are keyed by the Claude session id, or by the pane for a plain terminal.
Seats are packed. Desks fill from the top left in order, one per session, with no gaps and no rows of empty desks. Stable seating (an option; state order is the default) keeps each session's place in that order while it runs; when one leaves, the others close up. The order is remembered in $.store. Before v10 a drag pinned an absolute seat number (25, 49) and every seat up to it was drawn; those old numbers are dropped once on load, names and characters kept.
Press a desk with the mouse, drag it onto another desk, and let go: the desk you carried takes that place in the order and the ones between move up one. It is pinned to that place in $.store, in every seating mode. While you drag, a ghost with the desk's name follows the pointer, the desk you picked up is outlined in grey and the one under the pointer in yellow. A press and release without moving jumps to that desk's pane. The drag layer is a Client laid over the picture (hooks/office-drag.mjs). A Client holds the pointer from press to release and can't draw a Raster, so the picture stays a Raster underneath it. The layer names the desk under the pointer itself, from the same rectangles it was drawn with, and the floor never scrolls (see Keys), so the outlines land where the pointer is. Pins can also be set by number in the settings.
Busy terminals sort after idle sessions: they are long-running servers and watchers, rarely news.
In the office view, letters control the view: l layout, d desks at a time, c characters, b style, t lighting, p palette, g pictures or cells (render), m next page, s settings, f full size or sidebar, x close. The header strip shows them all; in a sidebar they show their values alone, to keep it short.
The desks always fit. desks (key d, the settings view, or the plugin's config) caps how many are drawn in every layout and render mode, pane or full. On top of that the floor gets only the rows the header leaves, and a page holds no more desks than fit them, so the pane never scrolls: the rest are +N more, a page away with m. A room too short for even one desk is drawn as the grid. A value changed in the plugin's config wins over an older one saved from the settings view. bin/office-monitor uses the same letters, plus q to quit.
Each tag reads <key>: <name>. Desks take digits only: 1-9, then 0 for the tenth. Any other desk is reached by clicking it or dragging it. Pressing the key, or clicking the tag, jumps to that pane. For an agent pane the jump is herdr workspace focus, then tab focus, then agent focus. x closes the office.
For a screen recording: OFFICE_DEMO=1 in the environment, or /office demo (again to leave), or bin/office-monitor --demo. The header says demo . Eight scripted sessions replace herdr and the session records, on a 60-second loop that starts at 0 each time demo mode turns on or the office opens, so takes repeat exactly. Nothing is asked of herdr, and nothing of the demo is saved to the store.
| Second | What happens |
|---|---|
| 0 | api-refactor (crowned, this session) coding, docs-site writing, bug-triage searching, release-notes writing, test-suite thinking, security-review delegating to 3 helpers; db-migration and onboarding idle |
| 8 to 14 | api-refactor reading, then thinking; docs-site searching (12) |
| 15 | bug-triage needs you: "Help please", the beacon, a toast |
| 20 | db-migration wakes up and thinks, then codes (28) |
| 27 | bug-triage back to work |
| 30 | release-notes finishes: "Done!" and the celebration loop; security-review down to 2 helpers |
| 40 | release-notes looked at: it settles to "Done" |
| 45 | security-review's helpers finish; it writes |
| 46 | db-migration idle again |
| 60 | the loop starts over |
A desk's key or click toasts demo: would jump to <name>. Dragging reorders the demo desks. Layouts, characters (mixed gives one of each kind), desks at a time, pictures and settings work as usual.
mods/office/bin/office-monitor --help # the flags and keys
mods/office/bin/office-monitor --layout office # q quits; m pages; l d c b t p cycle the view
mods/office/bin/office-monitor --pictures # the people as pictures (kitty graphics), as in hybrid
mods/office/bin/office-monitor --once --size 200x56 # print one frame and exit
mods/office/bin/office-monitor --demo # the scripted demo loop, for a recording
The monitor draws half blocks by default. With --pictures (or g) the office and war room put each person in as a sharp PNG over the cells, as the pane's hybrid view does, for terminals that draw kitty graphics (Ghostty, kitty).
It runs on node (or bun) and uses the same painter as the pane: hooks/office-model.mjs and hooks/office-scene.mjs. Every frame is one synchronized update (CSI ? 2026), drawn over the last with no clear. With pictures, each desk has two kitty image ids used in turn: the new picture goes to the spare one, is placed over the box, and only then is the old one freed, so a person is never missing for a frame. A picture that did not change is not sent.
hooks/office-pictures.mjs, hooks/office-art-hd.mjs, assets/: the character pictures and their PNG encoder, the mod's own characters in the same style, and the atlas cut from Kenney's CC0 packs by tools/build-atlas.py (see assets/CREDITS.txt). The atlas is one byte per pixel with a palette of 61 colours, 36 KB. Run python3 tools/build-atlas.py <folder of unzipped packs> to rebuild it.hooks/office-art.mjs: every sprite as a palette-indexed pixel map, edited as text. This covers heads and backs of heads per character, hair styles, torsos, plants, the cooler, the mug and minions, plus skin, hair, clothes and fur colours, room palettes and the sky for each hour.hooks/office-scene.mjs: the room and the layouts. The static background is built once per size, seating, palette and quarter-hour of light. Each frame copies it and paints the sprites, the clock hands, the rack LEDs and the stars on top.hooks/office-model.mjs: herdr parsing, desks, options, seating, state changes, and the grid painter.hooks/office.mjs: the hooks. One Raster is repainted with $.ui.blit at 90-260 ms per frame. The render hook runs again only when a desk, a state, a count or an option changes. Nothing polls or animates while the pane is closed.Data comes from one herdr pane list per poll (every 3 s, every 4 s at full size), and nothing else is spawned. A plain pane counts as busy when its terminal title is a command rather than a prompt (user@host:path, a bare path or a shell's name). Idle shells drawn as empty desks with "which panes: all" are counted as idle shells, never as done. Subagent counts come from the transcripts under <config>/projects/<folder>/<session>/subagents/*.jsonl written to in the last 60 s. The folder is found with $.fs alone: the pane's cwd or a folder above it, checked against one listing of each config folder (every 10 minutes). Both found and missing lookups are cached.
claude plugin validate --strict mods/office
claude plugin test mods/office
The tests cover parsing, busy-shell detection and subagent counts. They mount every layout on the terminal and check the tag buttons and the herdr argv a press sends. They also cover:
Tested on Claude Code 2.1.287 with herdr API protocol 22.
hooks/office.mjs 1418 lines1// Office: a pixel-art office of every busy terminal in herdr. One desk per
2// agent pane (and per plain pane running something), each worker animated by
3// its pane's state, minions for a session's active subagents. Name tags are
4// buttons: a desk's hotkey jumps to its pane. `/office` opens it, `/office
5// full` makes it as big as a pane can be, `s` opens the settings, `/office
6// close` closes it.
7//
8// herdr is polled, subagent folders listed and the animation run only while
9// the pane is on screen; every timer stops when it closes.
10
11import {
12 ACCENT,
13 CHARACTER_KINDS,
14 FRAME_MS,
15 HOTKEYS,
16 OPTIONS,
17 STATUS_WORD,
18 STATUSES,
19 applyOverrides,
20 VIEW_KEYS,
21 activityFromTail,
22 activityOfTool,
23 bubbleFor,
24 buildDesks,
25 pageOf,
26 busyFromTitle,
27 countActiveSubagents,
28 cycleOption,
29 deskKey,
30 hideStaleDone,
31 parsePaneList,
32 projectDirName,
33 resolveOptions,
34 seatDesks,
35 tally,
36 toBase64,
37 trackChanges,
38} from "./office-model.mjs";
39import { characterBox, loadAtlas, portraitMoves, sharpPng } from "./office-pictures.mjs";
40import { demoPanes } from "./office-demo.mjs";
41import { hitRects, paintPlan, paintRoomCanvas, planScene } from "./office-scene.mjs";
42import {
43 HEARTBEAT_MS,
44 REGISTRY_TTL_MS,
45 alivePids,
46 claudeSessionPanes,
47 isSessionRecord,
48 liveEntries,
49 recentTranscripts,
50 recordPids,
51 registryDir,
52 registryEntry,
53 resumeHint,
54 sessionPanes,
55} from "./office-sessions.mjs";
56
57const PANE = "office";
58const RASTER = "office-floor";
59// One `herdr pane list` per poll and nothing else spawned: every 3 s, every 4 s full size.
60const POLL_MS = 3000;
61const POLL_FULL_MS = 4000;
62const HELPERS_EVERY = 3; // polls between subagent scans
63const ACTIVE_MS = 60000;
64const DIR_RETRY_MS = 600000;
65const SOUND = "sounds/needs-you.wav";
66const PROBE_MS = 400;
67// Outside herdr: project folders listed for recent transcripts per poll.
68const SCAN_PER_POLL = 25;
69const HERDR_RETRY_MS = 60000;
70const PORTRAIT_MS = 1000;
71// Transcript tails read per poll, at most, to tell thinking from coding.
72const TAILS_PER_POLL = 3;
73
74// Module state. A reload starts it over and drops the timers with it; the
75// pane's next draw starts them again. What must outlive a reload is in $.store.
76const state = {
77 userConfig: {},
78 stored: {},
79 options: resolveOptions(),
80 overrides: {},
81 seatMemory: {},
82 changeMemory: {},
83 unseen: {},
84 isLoaded: false,
85 panes: [],
86 procs: {},
87 helpers: {},
88 sessionDirs: new Map(),
89 projectNames: new Map(),
90 roots: null,
91 desks: [],
92 seats: [],
93 idleShells: 0,
94 error: null,
95 hasData: false,
96 isFull: false,
97 view: "office",
98 picked: null,
99 polls: 0,
100 selfPaneId: undefined,
101 isPolling: false,
102 poll: null,
103 pollMs: 0,
104 frame: null,
105 isBlitting: false,
106 frameMs: 0,
107 tick: 0,
108 minute: "",
109 lastColumns: 0,
110 plan: null,
111 atlas: null,
112 isAtlasTried: false,
113 // Where the desks come from: herdr's panes, or the sessions registry.
114 source: "herdr",
115 sourceNote: "",
116 herdrMissingUntil: 0,
117 scans: new Map(),
118 recent: new Map(),
119 // This session's own registry entry, and its heartbeat.
120 reg: null,
121 heartbeat: null,
122 // Tiles: what each tile's picture last showed, and whether pictures draw.
123 portraitSent: new Map(),
124 portraitTimer: null,
125 tileSupport: "unknown",
126 // Paging through the desks, a few at a time.
127 page: 0,
128 pageInfo: { page: 0, pages: 1, total: 0 },
129 // How many desks fit the pane without scrolling (0: all of a page), and the
130 // header's height as last measured.
131 // Demo mode: a scripted minute instead of herdr (`OFFICE_DEMO=1`, `/office
132 // demo`): when it started, and the real memories it set aside.
133 demo: null,
134 isDemoEnvRead: false,
135 fitCap: 0,
136 ordered: [],
137 pageSize: 0,
138 headerRows: 2,
139 // Why the people are cells where pictures were asked for, shown in the header.
140 pictureNote: "",
141 pictureDeny: "",
142 // What each session is doing (thinking, coding...), and the transcripts read for it.
143 activity: new Map(),
144 transcripts: new Map(),
145};
146
147const hex = (n) => `#${n.toString(16).padStart(6, "0")}`;
148
149async function load($) {
150 if (state.isLoaded) return;
151 state.isLoaded = true;
152 const [stored, overrides, seats, unseen, seenConfig] = await Promise.all([
153 $.store.get("options").catch(() => undefined),
154 $.store.get("sessions").catch(() => undefined),
155 $.store.get("seats").catch(() => undefined),
156 $.store.get("unseen").catch(() => undefined),
157 $.store.get("optionsConfig").catch(() => undefined),
158 ]);
159 state.unseen = unseen && typeof unseen === "object" ? unseen : {};
160 state.stored = stored && typeof stored === "object" ? stored : {};
161 // A setting changed in the plugin's config since the settings view saved
162 // its own value wins: the stored one is dropped. Without a record of the
163 // config (before v10), the config in force now is taken as the one seen.
164 const was = seenConfig && typeof seenConfig === "object" ? seenConfig : configSnapshot();
165 const now = configSnapshot();
166 const kept = { ...state.stored };
167 for (const o of OPTIONS) if (String(was[o.field] ?? "") !== String(now[o.field] ?? "")) delete kept[o.key];
168 if (Object.keys(kept).length !== Object.keys(state.stored).length) {
169 state.stored = kept;
170 await $.store.set("options", kept).catch(() => {});
171 }
172 if (JSON.stringify(was) !== JSON.stringify(now) || !seenConfig) await $.store.set("optionsConfig", now).catch(() => {});
173 state.overrides = overrides && typeof overrides === "object" ? overrides : {};
174 state.seatMemory = seats && typeof seats === "object" ? seats : {};
175 state.options = resolveOptions(state.userConfig, state.stored);
176 await migrateSeats($);
177}
178
179// The plugin's config values for the options, as they are now.
180function configSnapshot() {
181 const out = {};
182 for (const o of OPTIONS) if (state.userConfig?.[o.field] !== undefined) out[o.field] = state.userConfig[o.field];
183 return out;
184}
185
186// Before v10 a dragged desk was pinned to an absolute seat number (25, 49),
187// and the office drew every seat up to it, mostly empty. Seats are packed
188// now and a pin is a place in that order: the old numbers are dropped once,
189// names and characters kept.
190async function migrateSeats($) {
191 const version = await $.store.get("seatingVersion").catch(() => undefined);
192 if (version === 2) return;
193 const next = {};
194 for (const [k, o] of Object.entries(state.overrides)) {
195 if (!o || typeof o !== "object") continue;
196 const { seat, ...rest } = o;
197 if (Object.keys(rest).length) next[k] = rest;
198 }
199 state.overrides = next;
200 await $.store.set("sessions", next).catch(() => {});
201 await $.store.set("seatingVersion", 2).catch(() => {});
202}
203
204function signature() {
205 if (state.error) return state.error;
206 if (state.source !== "herdr" && !state.seats.length) return `none:${state.sourceNote}`;
207 const seats = state.seats.map((d) => (d ? `${d.paneId}:${d.kind}:${d.status}:${d.name}:${d.helpers}:${d.character ?? ""}:${d.unseenDone ? 1 : 0}` : "-")).join("|");
208 return `${seats}#${state.idleShells}#${state.isFull ? state.minute : ""}`;
209}
210
211async function runQuiet($, argv) {
212 try {
213 return await $.process.run(argv, { timeoutMs: 5000 });
214 } catch {
215 return null;
216 }
217}
218
219// The Claude config folders to look in for session transcripts.
220async function configRoots($) {
221 if (state.roots) return state.roots;
222 const home = (await $.env.get("HOME")) ?? "";
223 const custom = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? "";
224 const roots = [custom, home && `${home}/.claude-work`, home && `${home}/.claude`].filter(Boolean);
225 state.roots = [...new Set(roots.map((r) => r.replace(/\/+$/, "")))];
226 return state.roots;
227}
228
229// A session's transcript folder. Claude Code names it after the folder the
230// session started in: the pane's cwd, or one above it. Looked up with $.fs
231// alone, and remembered either way (a miss is tried again after ten minutes).
232async function sessionDir($, sessionId, cwd, now) {
233 const cached = state.sessionDirs.get(sessionId);
234 if (cached && (cached.dir || now - cached.at < DIR_RETRY_MS)) return cached.dir;
235 let dir = null;
236 const roots = await configRoots($);
237 const parts = String(cwd ?? "").split("/").filter(Boolean);
238 const names = [];
239 for (let i = parts.length; i >= 1 && names.length < 6; i--) names.push(projectDirName(`/${parts.slice(0, i).join("/")}`));
240 search: for (const root of roots) {
241 // Only the folders that exist: one listing per config folder, kept a while.
242 const known = await projectNames($, root, now);
243 for (const name of names.filter((n) => known.has(n))) {
244 const candidate = `${root}/projects/${name}/${sessionId}`;
245 if (await $.fs.exists(candidate).catch(() => false)) {
246 dir = candidate;
247 break search;
248 }
249 }
250 }
251 state.sessionDirs.set(sessionId, { dir, at: now });
252 return dir;
253}
254
255// The project folder names under a config folder, listed once every ten minutes.
256async function projectNames($, root, now) {
257 const cached = state.projectNames.get(root);
258 if (cached && now - cached.at < DIR_RETRY_MS) return cached.names;
259 const entries = await $.fs.list(`${root}/projects`).catch(() => []);
260 const names = new Set(entries.filter((e) => e.kind === "dir").map((e) => e.name));
261 state.projectNames.set(root, { names, at: now });
262 return names;
263}
264
265// Subagent transcripts written to in the last minute, per session.
266async function scanHelpers($, panes, now) {
267 const agents = panes.filter((p) => p.agent === "claude" && p.sessionId);
268 const counts = await Promise.all(
269 agents.map(async (p) => {
270 const dir = await sessionDir($, p.sessionId, p.cwd, now);
271 if (!dir) return [p.sessionId, 0];
272 const entries = await $.fs.list(`${dir}/subagents`).catch(() => []);
273 return [p.sessionId, countActiveSubagents(entries, now, ACTIVE_MS)];
274 }),
275 );
276 state.helpers = Object.fromEntries(counts);
277}
278
279// From the last poll to the seats on screen: which panes, overrides, state
280// changes, hiding, seating. Returns the state changes since last time.
281async function recompute($, now) {
282 const o = state.options;
283 const built = buildDesks(state.panes, state.procs, state.helpers, o.show);
284 const tracked = trackChanges(applyOverrides(built.desks, state.overrides), state.changeMemory, now, state.unseen);
285 state.changeMemory = tracked.memory;
286 // Finished sessions not yet looked at, kept so a reload goes on celebrating them.
287 if (JSON.stringify(tracked.unseen) !== JSON.stringify(state.unseen)) {
288 state.unseen = tracked.unseen;
289 if (!state.demo) await $.store.set("unseen", tracked.unseen).catch(() => {});
290 }
291 const shown = hideStaleDone(tracked.desks, Number(o.hideDoneMinutes) || 0, now).map((d) => ({ ...d, activity: d.activity ?? state.activity.get(d.sessionId)?.activity ?? null }));
292 // Packed in order from the first desk, then the page on show: at most
293 // `desks` at a time, and no more than fit the pane without scrolling.
294 const seated = seatDesks(shown, o.seating, state.seatMemory, now);
295 const cap = Number(o.desks) || Infinity;
296 const size = Math.min(cap, state.fitCap || Infinity);
297 const page = pageOf(seated.seats, Number.isFinite(size) ? size : "all", state.page, true);
298 state.page = page.page;
299 state.pageInfo = page;
300 state.desks = shown;
301 state.ordered = seated.seats;
302 state.pageSize = Number.isFinite(size) ? size : seated.seats.length;
303 state.seats = page.desks;
304 state.idleShells = built.idleShells;
305 if (JSON.stringify(seated.memory) !== JSON.stringify(state.seatMemory)) {
306 state.seatMemory = seated.memory;
307 if (!state.demo) await $.store.set("seats", seated.memory).catch(() => {});
308 }
309 return tracked.changes;
310}
311
312// Toasts and the chime for state changes, as the options say. The chime is
313// not awaited, so the poll never waits on a sound.
314function announce($, changes) {
315 const o = state.options;
316 for (const ch of changes) {
317 const needsYou = ch.to === "blocked";
318 if (needsYou && o.notify !== "off") $.ui.toast(`${ch.desk.name} needs you`);
319 else if (o.notify === "changes") $.ui.toast(`${ch.desk.name}: ${STATUS_WORD[ch.from] ?? ch.from} to ${STATUS_WORD[ch.to] ?? ch.to}`);
320 if (needsYou && o.sound) $.audio.play({ asset: SOUND }).catch(() => {});
321 }
322}
323
324// One poll. Returns true when what the office shows changed.
325async function refresh($) {
326 if (state.isPolling) return false;
327 state.isPolling = true;
328 const before = state.hasData ? signature() : null;
329 try {
330 await load($);
331 if (state.selfPaneId === undefined) state.selfPaneId = (await $.env.get("HERDR_PANE_ID")) ?? null;
332 const now = await $.clock.now();
333 if (!state.isDemoEnvRead) {
334 state.isDemoEnvRead = true;
335 if ((await $.env.get("OFFICE_DEMO")) === "1") startDemo(now);
336 }
337 // Demo mode: the scripted minute, and nothing asked of herdr or the disk.
338 if (state.demo) {
339 const demo = demoPanes(now - state.demo.start, state.options.characters);
340 state.source = "demo";
341 state.sourceNote = "";
342 state.error = null;
343 state.procs = {};
344 state.helpers = demo.helpers;
345 state.panes = demo.panes;
346 state.polls++;
347 const changes = await recompute($, now);
348 if (state.hasData) announce($, changes);
349 const d = new Date(now);
350 state.minute = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
351 return signature() !== before;
352 }
353 // Without herdr (missing, or failing), the sessions registry and recent
354 // transcripts stand in; herdr is asked again a minute later.
355 const run = now < state.herdrMissingUntil ? null : await runQuiet($, ["herdr", "pane", "list"]);
356 let parsed = null;
357 let why = "";
358 if (!run) why = "herdr is not available";
359 else if (run.exitCode !== 0) why = `herdr pane list failed: ${String(run.stderr || run.stdout || "").trim().split("\n")[0].slice(0, 80)}`;
360 else {
361 parsed = parsePaneList(run.stdout, state.selfPaneId);
362 if (parsed.error) why = parsed.error;
363 }
364 if (why) {
365 if (run !== null || now >= state.herdrMissingUntil) state.herdrMissingUntil = now + HERDR_RETRY_MS;
366 state.source = "sessions";
367 state.sourceNote = why;
368 const fallback = await readSessions($, now);
369 state.error = null;
370 state.helpers = fallback.helpers;
371 state.procs = {};
372 state.panes = fallback.panes;
373 state.polls++;
374 const changes = await recompute($, now);
375 if (state.hasData) announce($, changes);
376 return signature() !== before;
377 }
378 state.source = "herdr";
379 state.sourceNote = "";
380 state.error = null;
381 // Busy terminals from their titles: no process spawned per pane.
382 state.procs = Object.fromEntries(parsed.panes.filter((p) => !p.agent).map((p) => [p.paneId, busyFromTitle(p.terminalTitle)]));
383 // Not on the first poll: the first draw waits for it.
384 if (state.polls % HELPERS_EVERY === 1) await scanHelpers($, parsed.panes, now).catch(() => {});
385 await readActivities($, parsed.panes, now).catch(() => {});
386 state.polls++;
387 state.panes = parsed.panes;
388 const changes = await recompute($, now);
389 if (state.hasData) announce($, changes);
390 const d = new Date(now);
391 state.minute = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
392 } finally {
393 state.isPolling = false;
394 state.hasData = true;
395 }
396 return signature() !== before;
397}
398
399// ---- sessions without herdr ---------------------------------------------------
400
401// The registry's live entries and the recent transcripts, read with $.fs.
402async function readSessions($, now) {
403 const roots = await configRoots($);
404 const ownEntries = await readRegistry($, roots, now);
405 const selfId = state.reg?.id ?? null;
406 // Claude Code's own records of its running sessions, when there are any.
407 const records = await readClaudeSessions($, roots);
408 if (records.length) {
409 const pids = recordPids(records);
410 const ps = pids.length ? await runQuiet($, ["ps", "-o", "pid=", "-p", pids.join(",")]) : null;
411 const result = claudeSessionPanes(records, alivePids(ps?.stdout), now, selfId, ownEntries);
412 await addTailActivities($, result.panes, now);
413 state.sourceKind = "claude";
414 return result;
415 }
416 state.sourceKind = "registry";
417 const texts = [];
418 for (const root of roots) {
419 const dir = registryDir(root);
420 const entries = await $.fs.list(dir).catch(() => []);
421 for (const e of entries) {
422 if (e.kind !== "file" || !e.name.endsWith(".json") || now - Number(e.mtimeMs || 0) > REGISTRY_TTL_MS) continue;
423 const text = await $.fs.read(`${dir}/${e.name}`).catch(() => null);
424 if (typeof text === "string") texts.push(text);
425 }
426 }
427 await scanTranscripts($, roots, now);
428 return sessionPanes(liveEntries(texts, now), state.recent.values(), now, selfId);
429}
430
431// `<config>/sessions/*.json`: Claude Code's record of each running session.
432// The folder also holds .key files, which are secrets: only *.json is read.
433async function readClaudeSessions($, roots) {
434 const records = [];
435 for (const root of roots) {
436 const dir = `${root}/sessions`;
437 const entries = await $.fs.list(dir).catch(() => []);
438 for (const e of entries) {
439 if (e.kind !== "file" || !isSessionRecord(e.name)) continue;
440 const text = await $.fs.read(`${dir}/${e.name}`).catch(() => null);
441 try {
442 if (typeof text === "string") records.push(JSON.parse(text));
443 } catch {
444 // a record being rewritten: next poll
445 }
446 }
447 }
448 return records;
449}
450
451// This mod's registry entries: richer state, and what each session is doing.
452async function readRegistry($, roots, now) {
453 const texts = [];
454 for (const root of roots) {
455 const dir = registryDir(root);
456 const entries = await $.fs.list(dir).catch(() => []);
457 for (const e of entries) {
458 if (e.kind !== "file" || !e.name.endsWith(".json") || now - Number(e.mtimeMs || 0) > REGISTRY_TTL_MS) continue;
459 const text = await $.fs.read(`${dir}/${e.name}`).catch(() => null);
460 if (typeof text === "string") texts.push(text);
461 }
462 }
463 return liveEntries(texts, now);
464}
465
466// In herdr: what each working session is doing, from its registry entry when
467// it runs this mod, else from the tail of its transcript.
468async function readActivities($, panes, now) {
469 const roots = await configRoots($);
470 const entries = await readRegistry($, roots, now);
471 for (const e of entries) if (e.activity) state.activity.set(e.id, { activity: e.activity, at: now });
472 await addTailActivities($, panes.filter((p) => p.agent === "claude" && p.sessionId && !entries.some((e) => e.id === p.sessionId && e.activity)), now);
473}
474
475// One `tail` of a transcript per working session whose transcript changed,
476// a few per poll at most: the last assistant block says thinking, writing or a tool.
477async function addTailActivities($, panes, now) {
478 let budget = TAILS_PER_POLL;
479 for (const p of panes) {
480 if (p.status !== "working" || !p.sessionId || p.activity) continue;
481 const path = await transcriptPath($, p.sessionId, p.cwd, now);
482 if (!path) continue;
483 const stat = await $.fs.stat(path).catch(() => null);
484 if (!stat) continue;
485 const known = state.transcripts.get(p.sessionId);
486 if (known && known.mtimeMs === stat.mtimeMs) {
487 p.activity = known.activity;
488 continue;
489 }
490 if (budget-- <= 0) continue;
491 const run = await runQuiet($, ["tail", "-c", "32768", path]);
492 const activity = run?.exitCode === 0 ? activityFromTail(run.stdout) : null;
493 state.transcripts.set(p.sessionId, { mtimeMs: stat?.mtimeMs ?? 0, activity, path });
494 if (activity) state.activity.set(p.sessionId, { activity, at: now });
495 p.activity = activity;
496 }
497}
498
499// A session's transcript: `<config>/projects/<folder>/<id>.jsonl`, the folder
500// named after the cwd or a folder above it. Cached, misses included.
501async function transcriptPath($, sessionId, cwd, now) {
502 const cached = state.transcripts.get(`path:${sessionId}`);
503 if (cached && (cached.path || now - cached.at < DIR_RETRY_MS)) return cached.path;
504 let path = null;
505 const roots = await configRoots($);
506 const parts = String(cwd ?? "").split("/").filter(Boolean);
507 const names = [];
508 for (let i = parts.length; i >= 1 && names.length < 6; i--) names.push(projectDirName(`/${parts.slice(0, i).join("/")}`));
509 search: for (const root of roots) {
510 const known = await projectNames($, root, now);
511 for (const name of names.filter((n) => known.has(n))) {
512 const candidate = `${root}/projects/${name}/${sessionId}.jsonl`;
513 if (await $.fs.exists(candidate).catch(() => false)) {
514 path = candidate;
515 break search;
516 }
517 }
518 }
519 state.transcripts.set(`path:${sessionId}`, { path, at: now });
520 return path;
521}
522
523// A few project folders per poll, round the list, for transcripts written in
524// the last half hour: all of them every few minutes, never all at once.
525async function scanTranscripts($, roots, now) {
526 for (const root of roots) {
527 let scan = state.scans.get(root);
528 let perPoll = SCAN_PER_POLL;
529 if (!scan || now - scan.at > DIR_RETRY_MS) {
530 // Newest folders first (a new transcript touches its folder), so recent
531 // sessions show on the first poll; one stat per folder, every ten minutes.
532 const entries = await $.fs.list(`${root}/projects`).catch(() => []);
533 const dirs = entries.filter((e) => e.kind === "dir").map((e) => e.name);
534 const stamped = await Promise.all(dirs.map(async (name) => [name, (await $.fs.stat(`${root}/projects/${name}`).catch(() => null))?.mtimeMs ?? 0]));
535 stamped.sort((a, b) => b[1] - a[1]);
536 scan = { names: stamped.map(([name]) => name), at: now, i: 0 };
537 state.scans.set(root, scan);
538 perPoll = SCAN_PER_POLL * 2;
539 }
540 for (let k = 0; k < Math.min(perPoll, scan.names.length); k++) {
541 const name = scan.names[scan.i % scan.names.length];
542 scan.i++;
543 const files = await $.fs.list(`${root}/projects/${name}`).catch(() => []);
544 for (const r of recentTranscripts(name, files, now)) state.recent.set(r.id, r);
545 }
546 }
547 for (const [id, r] of state.recent) if (now - r.mtimeMs > 30 * 60000) state.recent.delete(id);
548}
549
550// This session's own entry in the registry, so other sessions' offices see it.
551async function registryRoot($) {
552 const custom = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? "";
553 const home = (await $.env.get("HOME")) ?? "";
554 return custom || (home ? `${home}/.claude` : "");
555}
556
557// Writes the entry as it stands when called: a state that moves on while the
558// write waits does not change what this write says.
559// Writes go out one after another, so the last word written is the last said.
560let regWrites = Promise.resolve();
561function writeEntry($, isLeaving = false) {
562 const reg = state.reg ? { ...state.reg } : null;
563 if (!reg?.root) return regWrites;
564 regWrites = regWrites
565 .then(async () => {
566 const now = isLeaving ? 0 : await $.clock.now();
567 await $.fs.write(`${registryDir(reg.root)}/${reg.id}.json`, JSON.stringify(registryEntry({ id: reg.id, cwd: reg.cwd, state: reg.state, helpers: reg.helpers, activity: reg.activity ?? null, now })));
568 })
569 .catch(() => {});
570 return regWrites;
571}
572
573function setRegState($, next, activity) {
574 if (!state.reg) return;
575 const nextActivity = next === "working" ? activity ?? state.reg.activity ?? null : null;
576 if (state.reg.state === next && state.reg.activity === nextActivity) return;
577 state.reg.state = next;
578 state.reg.activity = nextActivity;
579 writeEntry($).catch(() => {});
580}
581
582// ---- pictures -------------------------------------------------------------------
583
584async function ensureAtlas($) {
585 if (state.atlas || state.isAtlasTried) return state.atlas;
586 state.isAtlasTried = true;
587 try {
588 const [json, bin] = await Promise.all([
589 $.fs.read(`${$.plugin.root}/assets/office-atlas.json`),
590 $.fs.read(`${$.plugin.root}/assets/office-atlas.bin`, { as: "bytes" }),
591 ]);
592 state.atlas = loadAtlas(json, Uint8Array.fromBase64(bin.base64));
593 } catch {
594 state.atlas = null;
595 }
596 return state.atlas;
597}
598
599// Sends one frame of the current plan, never two at once: a slow terminal
600// skips frames instead of piling them up. A plan replaced meanwhile (a new
601// layout, size or seating) is not sent.
602async function pushFrame($) {
603 const plan = state.plan;
604 if (!plan || state.view !== "office" || state.isBlitting) return;
605 state.isBlitting = true;
606 try {
607 const now = await $.clock.now();
608 if (state.plan !== plan) return;
609 state.tick++;
610 await $.ui.blit({ requestId: PANE, key: RASTER, columns: plan.columns, rows: plan.rows, cells: toBase64(new Uint8Array(paintPlan(plan, state.tick, now).buffer)) });
611 } catch {
612 // a frame lost: the next one follows
613 } finally {
614 state.isBlitting = false;
615 }
616}
617
618function seatIn(plan, x, y) {
619 const h = hitRects(plan).find((r) => x >= r.left && x < r.left + r.width && y >= r.top && y < r.top + r.height);
620 return h ? h.index : -1;
621}
622
623// ---- tile pictures -----------------------------------------------------------
624
625// A tile's picture, kept as last sent while it shows the same thing: a redraw
626// then sends nothing, and only a changed or moving tile is sent again.
627function portraitSource(desk, p, now, isForTree, bg) {
628 const key = `${p.isHybrid ? "h" : "t"}:${p.index}:${desk.paneId}:${p.columns}x${p.rows}`;
629 const moves = portraitMoves(desk, state.options.reducedMotion);
630 const sig = `${desk.status}:${desk.unseenDone ? 1 : 0}:${desk.isSelf ? 1 : 0}:${state.options.characters}:${desk.character ?? ""}:${moves ? state.tick : 0}`;
631 const last = state.portraitSent.get(key);
632 if (last && (isForTree || last.sig === sig)) return last.source;
633 // The hybrid view's picture carries the room's own pixels under it; a
634 // tile's, the card's colour. Enlarged pixel for pixel, so it stays sharp.
635 const room = p.isHybrid ? (bg ?? paintRoomCanvas(state.plan, state.tick, now)) : null;
636 const pic = characterBox(state.atlas, desk, state.options.characters, state.tick, now, state.options.reducedMotion, p, room);
637 const source = { png: toBase64(sharpPng(pic)) };
638 state.portraitSent.set(key, { sig, source });
639 return source;
640}
641
642// Once a second at most, each tile whose picture changed or moves; none when still.
643function startPortraits($) {
644 if (state.portraitTimer) return;
645 state.portraitTimer = $.clock.every(PORTRAIT_MS, () => {
646 pushPortraits($).catch(() => {});
647 });
648}
649
650async function pushPortraits($) {
651 const plan = state.plan;
652 // One blit in flight at a time, the office's frames and the tiles' together.
653 if (!plan?.portraits?.length || state.view !== "office" || state.isBlitting || state.tileSupport === "no") return;
654 state.isBlitting = true;
655 try {
656 const now = await $.clock.now();
657 let bg = null;
658 for (const p of plan.portraits) {
659 if (state.plan !== plan) return;
660 const desk = plan.isHybrid ? plan.desks[p.index]?.seat : plan.seats[p.index];
661 if (!desk) continue;
662 const key = `${p.isHybrid ? "h" : "t"}:${p.index}:${desk.paneId}:${p.columns}x${p.rows}`;
663 const before = state.portraitSent.get(key)?.source;
664 if (p.isHybrid && !bg) bg = paintRoomCanvas(plan, state.tick, now);
665 const source = portraitSource(desk, p, now, false, bg);
666 if (source === before) continue;
667 await $.ui.blit({ requestId: PANE, key: `portrait-${p.index + 1}`, source });
668 }
669 } finally {
670 state.isBlitting = false;
671 }
672}
673
674// The first tile picture tells whether pictures draw here.
675async function probeTiles($) {
676 const plan = state.plan;
677 const p = plan?.portraits?.[0];
678 if (!p || state.tileSupport !== "unknown") return;
679 if (state.isBlitting) {
680 $.clock.after(PROBE_MS, () => probeTiles($).catch(() => {}));
681 return;
682 }
683 const desk = plan.isHybrid ? plan.desks[p.index]?.seat : plan.seats[p.index];
684 state.isBlitting = true;
685 let r;
686 try {
687 r = await $.ui.blit({ requestId: PANE, key: `portrait-${p.index + 1}`, source: portraitSource(desk, p, await $.clock.now(), true) });
688 } catch (err) {
689 r = { deny: String(err?.message ?? err) };
690 } finally {
691 state.isBlitting = false;
692 }
693 if (!r?.deny) state.tileSupport = "yes";
694 else if (/alt/i.test(r.deny)) {
695 state.tileSupport = "no";
696 state.pictureDeny = String(r.deny).slice(0, 60);
697 $.ui.invalidate("ui.render");
698 }
699}
700
701function startTimers($) {
702 // The demo's script moves by the second: it is read every second.
703 const pollMs = state.demo ? 1000 : state.isFull ? POLL_FULL_MS : POLL_MS;
704 if (state.poll && state.pollMs !== pollMs) {
705 state.poll.cancel();
706 state.poll = null;
707 }
708 if (!state.poll) {
709 state.pollMs = pollMs;
710 state.poll = $.clock.every(pollMs, () => {
711 refresh($)
712 .then((changed) => {
713 if (changed) $.ui.invalidate("ui.render");
714 })
715 .catch(() => {});
716 });
717 }
718 const ms = FRAME_MS[state.options.speed] ?? FRAME_MS.normal;
719 if (state.frame && state.frameMs !== ms) {
720 state.frame.cancel();
721 state.frame = null;
722 }
723 if (!state.frame) {
724 state.frameMs = ms;
725 state.frame = $.clock.every(ms, () => {
726 const plan = state.plan;
727 if (!plan || state.view !== "office") return;
728 pushFrame($).catch(() => {});
729 });
730 }
731}
732
733function stopTimers() {
734 state.portraitTimer?.cancel();
735 state.portraitTimer = null;
736 state.poll?.cancel();
737 state.frame?.cancel();
738 state.poll = null;
739 state.frame = null;
740 state.plan = null;
741}
742
743// Focus a desk's pane: its workspace, its tab, then the pane itself.
744async function jumpTo($, desk) {
745 if (state.demo) {
746 $.ui.toast(`demo: would jump to ${desk.name}`);
747 return;
748 }
749 // Outside herdr there is no pane to focus: copy the command that resumes it.
750 if (desk.source) {
751 const hint = resumeHint(desk);
752 await $.ui.copy({ text: hint.text }).catch(() => {});
753 $.ui.toast(`${desk.name} (${desk.sessionId}) in ${hint.where}: copied "${hint.text}"`);
754 return;
755 }
756 const steps = [];
757 if (desk.workspaceId) steps.push(["herdr", "workspace", "focus", desk.workspaceId]);
758 if (desk.tabId) steps.push(["herdr", "tab", "focus", desk.tabId]);
759 // An agent pane is focused as the agent; a plain pane has no agent to name.
760 if (desk.kind === "agent") steps.push(["herdr", "agent", "focus", desk.paneId]);
761 for (const argv of steps) {
762 const run = await runQuiet($, argv);
763 if (!run || run.exitCode !== 0) {
764 const why = run ? String(run.stderr || "").trim().split("\n")[0].slice(0, 100) : "herdr is not available";
765 await $.ui.toast(`Could not jump to ${desk.name}${why ? `: ${why}` : "."}`);
766 return;
767 }
768 }
769}
770
771// Demo mode on: the loop starts at 0 now, from a clean slate (no pins,
772// no remembered seats or finishes), the real ones set aside until it ends.
773function startDemo(now) {
774 if (!state.demo) state.demo = { saved: { overrides: state.overrides, seatMemory: state.seatMemory, changeMemory: state.changeMemory, unseen: state.unseen } };
775 state.demo.start = now;
776 state.overrides = {};
777 state.seatMemory = {};
778 state.changeMemory = {};
779 state.unseen = {};
780 state.page = 0;
781 state.plan = null;
782}
783
784function stopDemo() {
785 if (!state.demo) return;
786 Object.assign(state, state.demo.saved);
787 state.demo = null;
788 state.page = 0;
789 state.plan = null;
790}
791
792async function openOffice($, isFull, columns) {
793 state.isFull = isFull;
794 // A take starts at second 0 each time the office opens.
795 if (state.demo) {
796 startDemo(await $.clock.now());
797 await refresh($).catch(() => {});
798 }
799 const wide = Math.max(60, (columns || 120) - 30);
800 const placed = await $.ui.open(
801 isFull
802 ? { id: PANE, title: "Office", focus: true, columns: wide, rows: 200 }
803 : { id: PANE, title: "Office", focus: true, columns: 66, rows: 28 },
804 );
805 $.ui.invalidate("ui.render");
806 return placed;
807}
808
809async function closeOffice($) {
810 stopTimers();
811 state.view = "office";
812 await $.ui.close({ id: PANE });
813}
814
815// ---- arranging by drag ----------------------------------------------------------
816
817// Moves the desk at seat `from` to the place of seat `to` (both on the page
818// on show): the desks between shift up one, nothing is left empty. The moved
819// desk is pinned to that place in the packed order, so it stays there while
820// the others reorder by state; nobody else keeps a pin on the same place.
821async function moveDesk($, from, to) {
822 const a = state.seats[from];
823 if (!a || from === to || !state.seats[to]) return;
824 const place = state.page * (state.pageSize || state.seats.length) + to + 1;
825 const keyA = deskKey(a);
826 const next = { ...state.overrides };
827 for (const [k, o] of Object.entries(next)) {
828 if (k !== keyA && o.seat === place) {
829 const { seat, ...rest } = o;
830 if (Object.keys(rest).length) next[k] = rest;
831 else delete next[k];
832 }
833 }
834 next[keyA] = { ...(next[keyA] ?? {}), seat: place };
835 state.overrides = next;
836 if (!state.demo) await $.store.set("sessions", next);
837 await recompute($, await $.clock.now());
838 $.ui.invalidate("ui.render");
839}
840
841const cell = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isInteger(n) && Math.abs(n) < 2000);
842
843// What the drag layer posted: a hover to light up, a drop to act on, a click.
844// The drag layer names the seats itself, from the very hits it was drawn
845// with; the cells are the fallback.
846const seatOf = (plan, seat, at) => (Number.isInteger(seat) && seat >= -1 && seat < state.seats.length ? seat : cell(at) ? seatIn(plan, at[0], at[1]) : -1);
847
848async function onDragMessage($, data) {
849 const plan = state.plan;
850 if (!plan || !data || typeof data !== "object") return;
851 if (data.type === "hover" && cell(data.from) && cell(data.at)) {
852 const next = { from: seatOf(plan, data.fromSeat, data.from), over: seatOf(plan, data.atSeat, data.at) };
853 if (plan.drag?.from !== next.from || plan.drag?.over !== next.over) {
854 plan.drag = next;
855 plan.isDirty = true;
856 await pushFrame($);
857 }
858 } else if (data.type === "drop" && cell(data.from) && cell(data.to)) {
859 plan.drag = null;
860 plan.isDirty = true;
861 const from = seatOf(plan, data.fromSeat, data.from);
862 const to = seatOf(plan, data.toSeat, data.to);
863 if (from >= 0 && to >= 0) await moveDesk($, from, to);
864 else $.ui.invalidate("ui.render");
865 } else if (data.type === "click" && cell(data.at)) {
866 const desk = state.seats[seatOf(plan, data.atSeat, data.at)];
867 if (desk) await jumpTo($, desk);
868 }
869}
870
871async function nextPage($) {
872 state.page = (state.page + 1) % Math.max(1, state.pageInfo.pages);
873 state.plan = null;
874 await recompute($, await $.clock.now());
875 $.ui.invalidate("ui.render");
876}
877
878// ---- settings -----------------------------------------------------------------
879
880async function setOption($, key) {
881 // A frame of the old scene may be in flight: it is dropped, not sent.
882 state.plan = null;
883 if (key === "render") {
884 state.tileSupport = "unknown";
885 state.pictureDeny = "";
886 }
887 state.options = cycleOption(state.options, key);
888 state.stored = { ...state.stored, [key]: state.options[key] };
889 await $.store.set("options", state.stored);
890 await $.store.set("optionsConfig", configSnapshot());
891 await recompute($, await $.clock.now());
892 state.plan = null;
893 if (key === "speed") startTimers($);
894 $.ui.invalidate("ui.render");
895}
896
897async function setOverride($, key, patch) {
898 const next = { ...(state.overrides[key] ?? {}), ...patch };
899 for (const k of Object.keys(next)) if (next[k] === undefined || next[k] === "") delete next[k];
900 state.overrides = { ...state.overrides };
901 if (Object.keys(next).length) state.overrides[key] = next;
902 else delete state.overrides[key];
903 if (!state.demo) await $.store.set("sessions", state.overrides);
904 await recompute($, await $.clock.now());
905 $.ui.invalidate("ui.render");
906}
907
908function settingsView($, e) {
909 const { Box, Text, Button, Select, Input } = $.ui.resolve(e);
910 const o = state.options;
911 const rows = OPTIONS.map((opt) =>
912 Box({
913 key: `opt-${opt.key}`,
914 flexDirection: "row",
915 columnGap: 1,
916 children: [
917 Button({ key: `set-${opt.key}`, label: opt.title, hotkey: opt.hotkey, plain: true, onPress: () => setOption($, opt.key) }),
918 Text({ color: "#76ea6a", children: String(o[opt.key]) }),
919 ],
920 }),
921 );
922 const desks = state.desks;
923 const pickedKey = state.picked && desks.some((d) => deskKey(d) === state.picked) ? state.picked : desks[0] ? deskKey(desks[0]) : null;
924 const picked = desks.find((d) => deskKey(d) === pickedKey);
925 const own = (pickedKey && state.overrides[pickedKey]) || {};
926 const perSession = picked
927 ? [
928 Text({ bold: true, children: "This session" }),
929 Select && Select({
930 key: "session",
931 label: "Session",
932 value: pickedKey,
933 options: desks.map((d) => ({ value: deskKey(d), label: `${d.name} (${STATUS_WORD[d.status]})` })),
934 onSelect: (value) => {
935 state.picked = value;
936 $.ui.invalidate("ui.render");
937 },
938 }),
939 Select && Select({
940 key: "character",
941 label: "Character",
942 value: own.character ?? "default",
943 options: [{ value: "default", label: `default (${o.characters})` }, ...CHARACTER_KINDS.map((k) => ({ value: k, label: k }))],
944 onSelect: (value) => setOverride($, pickedKey, { character: value === "default" ? undefined : value }),
945 }),
946 Input && Input({
947 key: "label",
948 label: "Label",
949 placeholder: picked.name,
950 value: own.label ?? "",
951 onSubmit: (value) => setOverride($, pickedKey, { label: value.trim() || undefined }),
952 }),
953 Input && Input({
954 key: "seat",
955 label: "Pin to a place in the order, 1 first (empty to unpin)",
956 value: own.seat ? String(own.seat) : "",
957 onSubmit: (value) => {
958 const n = Number.parseInt(value, 10);
959 return setOverride($, pickedKey, { seat: Number.isInteger(n) && n > 0 ? n : undefined });
960 },
961 }),
962 Button({ key: "reset-session", label: "Forget this session's settings", hotkey: "z", plain: true, dimColor: true, onPress: () => setOverride($, pickedKey, { label: undefined, character: undefined, seat: undefined }) }),
963 ].filter(Boolean)
964 : [Text({ dimColor: true, children: "No sessions to set up yet." })];
965 return Box({
966 flexDirection: "column",
967 children: [
968 Box({
969 flexDirection: "row",
970 columnGap: 2,
971 children: [
972 Text({ bold: true, children: "Office settings" }),
973 Button({ key: "back", label: "Back to the office", hotkey: "s", plain: true, onPress: () => showView("office", $) }),
974 ],
975 }),
976 Text({ dimColor: true, children: "Press a key to cycle an option. Saved for every session." }),
977 ...rows,
978 Text({ children: " " }),
979 ...perSession,
980 ],
981 });
982}
983
984function showView(view, $) {
985 state.view = view;
986 state.plan = null;
987 $.ui.invalidate("ui.render");
988}
989
990// ---- the office ---------------------------------------------------------------
991
992// The header strip: a coloured count per state, idle shells, the clock in full mode.
993function headerStrip(Box, Text, Button, $, columns = 0) {
994 const t = tally(state.desks);
995 const isNarrow = columns > 0 && columns < 100;
996 const parts = STATUSES.filter((s) => t[s] > 0 && s !== "unknown").map((s) =>
997 Text({ color: hex(ACCENT[s]), bold: s === "blocked", children: `■ ${t[s] + (s === "idle" ? t.unknown : 0)} ${STATUS_WORD[s]}` }),
998 );
999 if (t.unknown && !t.idle) parts.push(Text({ color: hex(ACCENT.unknown), children: `■ ${t.unknown} idle` }));
1000 if (state.idleShells) parts.push(Text({ dimColor: true, children: `${state.idleShells} idle shell${state.idleShells === 1 ? "" : "s"}` }));
1001 if (!parts.length) parts.push(Text({ dimColor: true, children: "Nobody is in yet." }));
1002 if (state.isFull && state.minute) parts.push(Text({ bold: true, children: state.minute }));
1003 if (state.demo) parts.unshift(Text({ bold: true, inverse: true, children: " demo " }));
1004 if (state.source === "demo") {
1005 // Said once, at the front.
1006 } else if (state.source !== "herdr" && state.sourceKind === "claude") {
1007 parts.push(Text({ dimColor: true, children: `no herdr: Claude Code sessions (${state.desks.length} running)` }));
1008 } else if (state.source !== "herdr") {
1009 const live = state.desks.filter((d) => d.source === "registry").length;
1010 const recent = state.desks.filter((d) => d.source === "transcript").length;
1011 parts.push(Text({ dimColor: true, children: `no herdr: ${live} live session${live === 1 ? "" : "s"} running this mod, ${recent} recent transcript${recent === 1 ? "" : "s"}` }));
1012 }
1013 if (state.pictureNote) parts.push(Text({ color: "#e0a040", children: `pictures off: ${state.pictureNote}` }));
1014 // More desks than a page: say how many, and page on with m.
1015 const info = state.pageInfo;
1016 const pageButton =
1017 info.pages > 1
1018 ? [Button({ key: "page", label: `+${info.total - state.seats.filter(Boolean).length} more (page ${info.page + 1}/${info.pages})`, hotkey: "m", plain: true, onPress: () => nextPage($) })]
1019 : [];
1020 return Box({
1021 key: "header",
1022 flexDirection: "row",
1023 flexWrap: "wrap",
1024 columnGap: 2,
1025 children: [
1026 ...parts,
1027 ...pageButton,
1028 // The view's own keys: letters, never a desk's.
1029 // In a sidebar the keys show their values alone, to keep the header short.
1030 ...Object.entries(VIEW_KEYS).map(([key, option]) =>
1031 Button({ key: `view-${option}`, label: isNarrow ? String(state.options[option]) : `${option} ${state.options[option]}`, hotkey: key, plain: true, dimColor: true, onPress: () => setOption($, option) }),
1032 ),
1033 Button({ key: "full", label: state.isFull ? "Sidebar" : "Full", hotkey: "f", plain: true, dimColor: true, onPress: () => openOffice($, !state.isFull, state.lastColumns) }),
1034 Button({ key: "settings", label: "Settings", hotkey: "s", plain: true, dimColor: true, onPress: () => showView("settings", $) }),
1035 Button({ key: "close", label: "Close", hotkey: "x", plain: true, dimColor: true, onPress: () => closeOffice($) }),
1036 ],
1037 });
1038}
1039
1040/**
1041 * How many rows the header wraps to in `columns`, from its items' widths
1042 * and the 2-column gap. A plain Button reads `k: label`. Wide glyphs (the
1043 * state squares) count as two, so the guess errs tall, never short.
1044 */
1045function headerRowsFor(widths, columns) {
1046 if (!columns) return 2;
1047 let rows = 1;
1048 let used = 0;
1049 for (const w of widths) {
1050 const need = used ? used + 2 + w : w;
1051 if (need > columns && used) {
1052 rows++;
1053 used = Math.min(w, columns);
1054 } else used = need;
1055 }
1056 return rows;
1057}
1058
1059function textWidth(text) {
1060 let n = 0;
1061 for (const ch of String(text)) {
1062 const c = ch.codePointAt(0) ?? 0;
1063 n += (c >= 0x2580 && c <= 0x25ff) || c >= 0x2e80 ? 2 : 1;
1064 }
1065 return n;
1066}
1067
1068// The widths of a header tree's items, for headerRowsFor. An element keeps
1069// its children beside its props (a Text's strings, a Box's elements).
1070function headerWidths(header) {
1071 const textOf = (el) => {
1072 if (typeof el === "string" || typeof el === "number") return String(el);
1073 const kids = el?.children ?? el?.props?.children ?? [];
1074 return Array.isArray(kids) ? kids.map(textOf).join("") : String(kids);
1075 };
1076 return (header?.children ?? header?.props?.children ?? []).map((el) => {
1077 const p = el?.props ?? {};
1078 if (typeof p.label === "string") return textWidth(p.hotkey ? `${p.hotkey}: ${p.label}` : p.label);
1079 return textWidth(textOf(el));
1080 });
1081}
1082
1083export function register(on, options) {
1084 state.userConfig = options ?? {};
1085 state.options = resolveOptions(state.userConfig, state.stored);
1086
1087 on("session.start", async ($, e, next) => {
1088 const r = await next(e);
1089 await $.command.register({
1090 name: "office",
1091 description: "Open a pixel-art office of every busy herdr pane (\"full\" for the big view, \"settings\", \"demo\" for a scripted loop, \"close\")",
1092 argumentHint: "[full|settings|demo|close]",
1093 });
1094 // Into the sessions registry, so offices outside herdr see this session.
1095 try {
1096 const root = await registryRoot($);
1097 if (root) {
1098 state.reg = { root, id: await $.session.id(), cwd: await $.session.cwd(), state: "idle", helpers: 0 };
1099 await writeEntry($);
1100 state.heartbeat?.cancel();
1101 state.heartbeat = $.clock.every(HEARTBEAT_MS, () => {
1102 $.agent
1103 .list()
1104 .then((agents) => {
1105 if (state.reg) state.reg.helpers = agents.length;
1106 })
1107 .catch(() => {})
1108 .finally(() => writeEntry($).catch(() => {}));
1109 });
1110 }
1111 } catch {
1112 state.reg = null;
1113 }
1114 return r;
1115 });
1116
1117 // The registry follows this session's own state. Every hook here passes its
1118 // event straight on, and none waits on a write.
1119 on("turn.start", ($, e, next) => {
1120 if (!e.agentId) setRegState($, "working");
1121 return next(e);
1122 });
1123
1124 on("turn.complete", async ($, e, next) => {
1125 const r = await next(e);
1126 if (!e.agentId) setRegState($, "done");
1127 return r;
1128 });
1129
1130 on("tool.call", async ($, e, next) => {
1131 if (e.agentId || !state.reg) return next(e);
1132 const asks = e.tool === "AskUserQuestion";
1133 setRegState($, asks ? "needs-you" : "working", activityOfTool(e.tool));
1134 const r = await next(e);
1135 if (asks) setRegState($, "working");
1136 return r;
1137 });
1138
1139 // Thinking or writing, from the model's own stream; every chunk passed on as it came.
1140 on("turn.step", async function* ($, e, next) {
1141 const stream = next(e);
1142 if (e.agentId || !state.reg) return yield* stream;
1143 let step = await stream.next();
1144 while (!step.done) {
1145 const kind = step.value?.kind;
1146 if (kind === "thinking") setRegState($, "working", "thinking");
1147 else if (kind === "text") setRegState($, "working", "writing");
1148 yield step.value;
1149 step = await stream.next();
1150 }
1151 return step.value;
1152 });
1153
1154 on("classic.Notification", ($, e, next) => {
1155 if (e.notification_type === "permission_prompt") setRegState($, "needs-you");
1156 return next(e);
1157 });
1158
1159 on("command.run", { command: "office" }, async ($, e) => {
1160 const arg = String(e.args ?? "").trim().toLowerCase();
1161 if (arg === "close") {
1162 await closeOffice($);
1163 return { text: "Office closed." };
1164 }
1165 if (arg === "demo") {
1166 await load($);
1167 state.isDemoEnvRead = true;
1168 if (state.demo) stopDemo();
1169 else startDemo(await $.clock.now());
1170 state.hasData = false;
1171 state.lastColumns = e.presentation?.columns ?? 0;
1172 state.view = "office";
1173 // Opening restarts the loop at 0; closing demo mode goes back to herdr.
1174 if (state.demo) await openOffice($, state.isFull, e.presentation?.columns);
1175 else {
1176 await refresh($).catch(() => {});
1177 $.ui.invalidate("ui.render");
1178 }
1179 return { text: state.demo ? "Office demo mode on: a scripted 60-second loop, starting now." : "Office demo mode off." };
1180 }
1181 state.lastColumns = e.presentation?.columns ?? 0;
1182 state.view = arg === "settings" ? "settings" : "office";
1183 const placed = await openOffice($, arg === "full", e.presentation?.columns);
1184 if (placed?.isPlaced === false) return { text: "The office pane is waiting for room on screen." };
1185 if (arg === "settings") return { text: "Office settings opened." };
1186 return { text: arg === "full" ? "Office opened full size. Press a desk's key to jump to it." : "Office opened. Press a desk's key to jump to it." };
1187 });
1188
1189 on("ui.message", async ($, e, next) => {
1190 if (e.requestId !== PANE || e.element !== "drag") return next(e);
1191 await onDragMessage($, e.data);
1192 return {};
1193 });
1194
1195 // The person's close only: the office's own $.ui.close stops the timers
1196 // itself, so this hook is never raised from inside its own close.
1197 on("ui.close", { id: PANE, origin: { kind: "person" } }, ($, e, next) => {
1198 stopTimers();
1199 return next(e);
1200 });hooks/office-model.mjs 1211 lines1// Office: the pure half. Parses herdr's JSON, builds the desk list, lays out
2// the grid and paints each frame as the packed cells a Raster takes. Nothing
3// here touches `$`, so the tests and bin/office-monitor import it directly.
4
5import { BOLD_SMALL_HEADS, FURS, HAIR_COLOURS, SKIN_TONES, SMALL_HEADS } from "./office-art.mjs";
6
7export const GAP = 1;
8export const MAX_PER_ROW = 12;
9
10// Three sizes of desk. `pw` x `ph` is the sprite in pixels, `k` how many
11// terminal cells across (and half-cells down) one pixel takes.
12export const SCALES = {
13 large: { name: "large", pw: 20, ph: 18, k: 2, tagRows: 2 },
14 normal: { name: "normal", pw: 20, ph: 18, k: 1, tagRows: 2 },
15 compact: { name: "compact", pw: 10, ph: 8, k: 1, tagRows: 2 },
16};
17
18// Most interesting first.
19// The order desks sort in: needs you, working, done (not yet looked at, or
20// resting), idle, then busy terminals, which are long-running and rarely news.
21export const STATUSES = ["blocked", "working", "done", "idle", "unknown", "running"];
22const RANK = Object.fromEntries(STATUSES.map((s, i) => [s, i]));
23
24export const STATUS_WORD = {
25 blocked: "needs you",
26 working: "working",
27 running: "running",
28 idle: "idle",
29 unknown: "idle?",
30 done: "done",
31};
32
33// Each state's accent: the desk stripe, the header dot, the name tag.
34export const ACCENT = {
35 blocked: 0xff4d4d,
36 working: 0x4ade80,
37 running: 0xf59e0b,
38 idle: 0x60a5fa,
39 unknown: 0x94a3b8,
40 done: 0x8b8fa8,
41};
42export const TAG_BG = {
43 blocked: 0x6b1515,
44 working: 0x14532d,
45 running: 0x5a3a06,
46 idle: 0x1e3a64,
47 unknown: 0x2a2f3a,
48 done: 0x26262f,
49};
50const TAG_FG = { done: 0xa1a1aa };
51
52const DEFAULT_COLOR = 0x01000000;
53const UPPER_HALF = 0x2580; // '▀': the top pixel is the fg, the bottom the bg
54
55const C = {
56 gap: 0x0d0e14,
57 wall: 0x1a1c29,
58 wallDark: 0x13141d,
59 floor: 0x121219,
60 floorEdge: 0x1d1d27,
61 lampWarm: 0xffc874,
62 lampShade: 0xd9973a,
63 bulbOn: 0xfff4cf,
64 bulbOff: 0x5b4a2e,
65 frame: 0x2d3240,
66 frameHi: 0x3b4152,
67 screenBg: 0x0b1020,
68 screenOff: 0x05060a,
69 screenGlare: 0x1c2030,
70 termBg: 0x050a06,
71 code: [0x60a5fa, 0x93c5fd, 0xc4b5fd, 0x4ade80, 0xfbbf24, 0xf472b6],
72 termGreen: [0x4ade80, 0x22c55e, 0x166534],
73 saver: 0x0d1b36,
74 saverDot: 0x7dd3fc,
75 keys: 0x4b5060,
76 keysHi: 0x6b7184,
77 spark: 0xfff3a0,
78 mug: 0xeeeae2,
79 mugShade: 0xb9b2a5,
80 coffee: 0x5b3a22,
81 steam: 0xc9cedb,
82 deskTop: 0x9a6640,
83 deskTopHi: 0xb57a4d,
84 deskFront: 0x6b4429,
85 deskShadow: 0x3e2717,
86 chair: 0x394050,
87 chairDark: 0x232833,
88 skin: 0xf1c27d,
89 skinShade: 0xc99a5b,
90 eye: 0x1b1b22,
91 pants: 0x2b3245,
92 shoe: 0x111116,
93 crown: 0xfacc15,
94 zzz: 0xc4b5fd,
95 alertRed: 0xff3b3b,
96 alertAmber: 0xffb020,
97 alertWall: 0x3a1418,
98 white: 0xf8fafc,
99 metal: 0xa3aab8,
100 metalDark: 0x6b7280,
101 metalDeep: 0x4b5160,
102 led: 0x22d3ee,
103 minion: 0xfacc15,
104 minionShade: 0xd4a90f,
105 goggle: 0xe5e7eb,
106 overalls: 0x2563eb,
107};
108const CONFETTI_GRID = [0xff5c8a, 0xffd23f, 0x4ade80, 0x60a5fa, 0xc084fc, 0xff8c42];
109const SHIRTS = [0x4f7cac, 0xa05a7c, 0x5f9e6e, 0xc07a3e, 0x7a6fc0, 0x3fa0a0, 0xb0524f, 0x8a8f3c];
110const HAIRS = [0x3b2a20, 0x1f1a17, 0x8c5a2b, 0xc9a25a, 0x6b3a2a, 0x9a9a9a, 0x5a2e5e];
111const SHELLS = new Set(["zsh", "-zsh", "bash", "-bash", "fish", "-fish", "sh", "-sh", "dash", "login", "nu", "tmux"]);
112const RUNTIMES = new Set(["node", "python", "python3", "Python", "bun", "deno", "ruby", "perl", "java", "npx", "uv", "npm", "pnpm", "yarn", "make", "cargo", "go"]);
113
114// ---- herdr data -----------------------------------------------------------
115
116/** The status a desk animates: herdr's own, anything new read as unknown. */
117export function statusOf(raw) {
118 return Object.prototype.hasOwnProperty.call(RANK, raw) && raw !== "running" ? raw : "unknown";
119}
120
121function printable(s) {
122 return String(s ?? "")
123 .replace(/[^\x20-\x7e]/g, "")
124 .replace(/\s+/g, " ")
125 .trim();
126}
127
128function baseName(path) {
129 return String(path ?? "").split("/").filter(Boolean).pop() ?? "";
130}
131
132/** A printable name: herdr's name or title, else the cwd's last folder. */
133export function shortName(pane) {
134 return printable(pane.name) || printable(pane.title) || printable(baseName(pane.cwd)) || String(pane.pane_id ?? "?");
135}
136
137export function hash(text) {
138 let h = 2166136261;
139 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
140 return h >>> 0;
141}
142
143/**
144 * Reads `herdr pane list` into plain panes, in herdr's order.
145 * Returns `{ panes }` or `{ error }`.
146 */
147export function parsePaneList(stdout, selfPaneId) {
148 let doc;
149 try {
150 doc = JSON.parse(stdout);
151 } catch {
152 return { error: "herdr pane list returned something that is not JSON." };
153 }
154 const raw = doc?.result?.panes;
155 if (!Array.isArray(raw)) return { error: "herdr pane list returned no panes." };
156 const panes = raw
157 .filter((p) => p && typeof p.pane_id === "string")
158 .map((p, order) => ({
159 paneId: p.pane_id,
160 tabId: typeof p.tab_id === "string" ? p.tab_id : "",
161 workspaceId: typeof p.workspace_id === "string" ? p.workspace_id : "",
162 agent: typeof p.agent === "string" && p.agent ? p.agent : null,
163 status: statusOf(p.agent_status),
164 name: shortName(p),
165 terminalTitle: printable(p.terminal_title_stripped ?? p.terminal_title ?? ""),
166 cwd: typeof p.cwd === "string" ? p.cwd : "",
167 sessionId: typeof p.agent_session?.value === "string" ? p.agent_session.value : null,
168 isSelf: Boolean(selfPaneId) && p.pane_id === selfPaneId,
169 isFocused: p.focused === true,
170 order,
171 }));
172 return { panes };
173}
174
175/**
176 * Reads `herdr pane process-info --pane <id>`: whether the pane's shell is
177 * running something in the foreground, and a short name for it.
178 */
179export function parseProcessInfo(stdout) {
180 let info;
181 try {
182 info = JSON.parse(stdout)?.result?.process_info;
183 } catch {
184 return { isRunning: false, name: "" };
185 }
186 const procs = Array.isArray(info?.foreground_processes) ? info.foreground_processes : [];
187 if (!procs.length || info.foreground_process_group_id === info.shell_pid) return { isRunning: false, name: "" };
188 const busy = procs
189 .filter((p) => !SHELLS.has(String(p.name ?? p.argv0 ?? "")))
190 .sort((a, b) => (a.pid ?? 0) - (b.pid ?? 0));
191 if (!busy.length) return { isRunning: false, name: "" };
192 const lead = busy.find((p) => p.pid === info.foreground_process_group_id) ?? busy[0];
193 const argv = Array.isArray(lead.argv) ? lead.argv : String(lead.cmdline ?? lead.argv0 ?? "").split(" ");
194 let name = baseName(argv[0] || lead.argv0 || lead.name);
195 // `node vite`, `npm run dev`: the arguments say more than the runtime.
196 if (RUNTIMES.has(name)) {
197 const rest = argv.slice(1).filter((a) => a && !a.startsWith("-"));
198 name = [name, ...rest.slice(0, 2).map(baseName)].join(" ").slice(0, 32);
199 }
200 return { isRunning: true, name: printable(name) || printable(lead.name) || "process" };
201}
202
203/**
204 * Whether a plain pane's shell is running something, read from its terminal
205 * title alone (no process spawned): shells title an idle prompt as
206 * `user@host:path`, a path, or the shell's name, and a running command as
207 * the command. Returns `{ isRunning, name }` as parseProcessInfo does.
208 */
209export function busyFromTitle(title) {
210 const t = printable(title);
211 if (!t) return { isRunning: false, name: "" };
212 const idle =
213 /^[\w.-]+@[\w.-]+(:|\s|$)/.test(t) || // user@host:~/path
214 /^(~|\/)[^\s]*$/.test(t) || // a bare path
215 SHELLS.has(t) ||
216 SHELLS.has(t.split(/\s/)[0]) && t.split(/\s/).length === 1;
217 if (idle) return { isRunning: false, name: "" };
218 return { isRunning: true, name: t.slice(0, 32) };
219}
220
221/** Claude Code's folder name for a project: every non-alphanumeric is a dash. */
222export function projectDirName(cwd) {
223 return String(cwd ?? "").replace(/[^a-zA-Z0-9]/g, "-");
224}
225
226/** Subagent transcripts written to within `windowMs` of `now`. */
227export function countActiveSubagents(entries, now, windowMs = 60000) {
228 let n = 0;
229 for (const e of entries ?? []) {
230 if (e && e.kind !== "dir" && /\.jsonl$/.test(String(e.name)) && now - Number(e.mtimeMs || 0) <= windowMs) n++;
231 }
232 return n;
233}
234
235/**
236 * The desks, most interesting first. `show` says which panes get one:
237 * `claude` (the default) only Claude sessions; `agents` any coding agent;
238 * `busy` also plain panes whose shell runs something (`procs` by pane id);
239 * `all` also idle shells, as empty desks. Idle shells are counted in
240 * `idleShells` either way.
241 */
242export function buildDesks(panes, procs = {}, helpers = {}, show = "claude") {
243 const desks = [];
244 let idleShells = 0;
245 for (const p of panes) {
246 const look = hash(p.paneId);
247 const project = baseName(p.cwd);
248 if (p.agent && show === "claude" && p.agent !== "claude") continue;
249 if (p.agent) {
250 desks.push({
251 ...p,
252 project,
253 kind: "agent",
254 detail: p.agent === "claude" ? "" : p.agent,
255 helpers: helpers[p.sessionId] ?? 0,
256 look,
257 });
258 } else if (procs[p.paneId]?.isRunning) {
259 if (show !== "busy" && show !== "all") continue;
260 desks.push({
261 ...p,
262 project,
263 kind: "process",
264 status: "running",
265 name: procs[p.paneId].name,
266 detail: baseName(p.cwd),
267 helpers: 0,
268 look,
269 });
270 } else {
271 idleShells++;
272 // Shown as an empty desk only when asked for.
273 if (show === "all" && !procs[p.paneId]?.isRunning) {
274 desks.push({ ...p, project, kind: "shell", status: "done", name: project || p.name, detail: "idle shell", helpers: 0, look });
275 }
276 }
277 }
278 desks.sort((a, b) => rankOf(a) - rankOf(b) || a.order - b.order);
279 return { desks, idleShells };
280}
281
282/** Counts per status, for the header strip. */
283export function tally(desks) {
284 const out = Object.fromEntries(STATUSES.map((s) => [s, 0]));
285 // An idle shell drawn as an empty desk is no session: not counted as done.
286 for (const d of desks) if (d.kind !== "shell") out[d.status]++;
287 return out;
288}
289
290// Desk hotkeys: digits only, 0 for the tenth. Letters are the view's own keys.
291export const HOTKEYS = [..."1234567890"];
292
293// The view's keys, the same in the pane and in bin/office-monitor.
294export const VIEW_KEYS = { l: "layout", d: "desks", c: "characters", b: "style", t: "lighting", p: "palette", g: "render" };
295
296// What a desk's bubble says: what the session is doing, in words.
297export const ACTIVITY_WORDS = {
298 thinking: "Thinking...",
299 writing: "Writing...",
300 coding: "Coding...",
301 reading: "Reading...",
302 searching: "Searching...",
303 running: "Running...",
304 delegating: "Delegating...",
305};
306
307/** The bubble's words for a desk, and its kind (for the colours), or null. */
308export function bubbleFor(desk) {
309 if (!desk || desk.kind === "shell") return null;
310 if (desk.status === "blocked") return { text: "Help please", tone: "alert" };
311 if (desk.status === "done") return { text: desk.unseenDone ? "Done!" : "Done", tone: desk.unseenDone ? "party" : "quiet" };
312 if (desk.status === "working") return { text: ACTIVITY_WORDS[desk.activity] ?? "Working...", tone: "work" };
313 return null;
314}
315
316/** An activity from a tool's name: what the bubble says while it runs. */
317export function activityOfTool(tool) {
318 const t = String(tool ?? "");
319 if (/^(Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell)$/.test(t)) return "coding";
320 if (/^(Read|NotebookRead)$/.test(t)) return "reading";
321 if (/^(Grep|Glob|WebFetch|WebSearch|LS)$/.test(t)) return "searching";
322 if (/^(Agent|Task)$/.test(t)) return "delegating";
323 return t ? "running" : null;
324}
325
326/**
327 * What a session was doing, from the tail of its transcript: the last
328 * assistant block (thinking, text, or a tool by name). Lines that do not
329 * parse (the first, cut mid-way) are skipped.
330 */
331export function activityFromTail(text) {
332 const lines = String(text ?? "").split("\n").filter(Boolean).reverse();
333 for (const line of lines) {
334 let row;
335 try {
336 row = JSON.parse(line);
337 } catch {
338 continue;
339 }
340 const content = row?.message?.content;
341 if (row?.type !== "assistant" || !Array.isArray(content) || !content.length) continue;
342 const last = content[content.length - 1];
343 if (last?.type === "thinking" || last?.type === "redacted_thinking") return "thinking";
344 if (last?.type === "text") return "writing";
345 if (last?.type === "tool_use") return activityOfTool(last.name);
346 }
347 return null;
348}
349
350/**
351 * The page of desks on show, `size` at a time: in the order given when
352 * `isOrdered` (seatDesks's), else the most relevant first (needs you,
353 * working, done, idle). Returns the desks, the page and how many pages.
354 */
355export function pageOf(desks, size, page = 0, isOrdered = false) {
356 const sorted = isOrdered ? [...desks] : [...desks].sort((a, b) => rankOf(a) - rankOf(b) || (a.order ?? 0) - (b.order ?? 0));
357 if (size === "all" || !Number(size)) return { desks: sorted, page: 0, pages: 1, total: sorted.length };
358 const n = Number(size);
359 const pages = Math.max(1, Math.ceil(sorted.length / n));
360 const p = ((page % pages) + pages) % pages;
361 return { desks: sorted.slice(p * n, p * n + n), page: p, pages, total: sorted.length };
362}
363
364/** Where a desk sorts: by its state, empty shell desks last. */
365export function rankOf(d) {
366 return d.kind === "shell" ? STATUSES.length : RANK[d.status] ?? STATUSES.length;
367}
368
369// ---- layout ---------------------------------------------------------------
370
371function tileOf(scale) {
372 const artRows = (scale.ph * scale.k) / 2;
373 return { tileW: scale.pw * scale.k, artRows, tileH: artRows + scale.tagRows };
374}
375
376/**
377 * Picks the largest desk size at which every desk fits the body, and cuts the
378 * grid. Nothing is left out: when even compact desks overflow the height, the
379 * pane scrolls.
380 */
381export function layoutFor(count, bodyColumns, bodyRows, options = {}) {
382 const width = Math.max(10, Math.floor(bodyColumns || 0));
383 const height = Math.max(6, Math.floor(bodyRows || 30));
384 // The biggest desks that fit, in a sidebar too.
385 const order = ["large", "normal", "compact"];
386 let pick = null;
387 for (const name of order) {
388 const scale = SCALES[name];
389 const t = tileOf(scale);
390 const perRow = Math.max(1, Math.min(MAX_PER_ROW, Math.floor((width + GAP) / (t.tileW + GAP))));
391 const deskRows = Math.max(1, Math.ceil(count / perRow));
392 pick = { scale, ...t, perRow, deskRows };
393 if (deskRows * t.tileH <= height && (name !== "large" || t.tileW <= width)) break;
394 }
395 // A Raster is at most 512 x 256 cells; past that (hundreds of desks) the rest are cut.
396 const maxRows = Math.floor(256 / pick.tileH);
397 const shown = Math.min(count, pick.perRow * maxRows);
398 const perRow = Math.max(1, Math.min(pick.perRow, shown || 1));
399 const deskRows = Math.max(1, Math.ceil(shown / perRow));
400 return {
401 scale: pick.scale,
402 tileW: pick.tileW,
403 tileH: pick.tileH,
404 artRows: pick.artRows,
405 shown,
406 perRow,
407 deskRows,
408 columns: Math.min(512, perRow * pick.tileW + (perRow - 1) * GAP),
409 rows: deskRows * pick.tileH,
410 };
411}
412
413/** Where desk `i`'s name tag sits in the grid, in cells. */
414export function tagPlace(layout, i) {
415 return {
416 left: (i % layout.perRow) * (layout.tileW + GAP),
417 top: Math.floor(i / layout.perRow) * layout.tileH + layout.artRows,
418 };
419}
420
421/**
422 * The two lines of a desk's name tag. The first starts `1: ` for a desk with
423 * a hotkey, as a plain Button draws its label; a long name runs on into the
424 * second line, else the second line says what the desk is doing.
425 */
426export function tagLines(desk, i, width) {
427 const key = HOTKEYS[i];
428 const prefix = key ? `${key}: ` : "";
429 const room = Math.max(1, width - prefix.length);
430 const name = desk.name;
431 const first = name.slice(0, room);
432 let second;
433 if (name.length > room) {
434 const rest = name.slice(room).trimStart();
435 second = rest.length > width ? `${rest.slice(0, width - 1)}~` : rest;
436 } else {
437 const bits = [bubbleFor(desk)?.text ?? STATUS_WORD[desk.status]];
438 if (desk.kind === "process" && desk.detail) bits.push(desk.detail);
439 else if (desk.detail) bits.push(desk.detail);
440 if (desk.helpers) bits.push(`${desk.helpers} helper${desk.helpers === 1 ? "" : "s"}`);
441 if (desk.isSelf) bits.push("you");
442 second = bits.join(" / ").slice(0, width);
443 }
444 return { hotkey: key, label: first, first: `${prefix}${first}`, second };
445}
446
447// ---- painting -------------------------------------------------------------
448
449function blend(a, b, t) {
450 const k = Math.max(0, Math.min(1, t));
451 const ch = (s) => {
452 const x = (a >> s) & 255;
453 const y = (b >> s) & 255;
454 return Math.round(x + (y - x) * k) << s;
455 };
456 return ch(16) | ch(8) | ch(0);
457}
458
459// A deterministic flicker, so a frame is a function of the tick.
460function noise(a, b) {
461 return hash(`${a}:${b}`) & 255;
462}
463
464/**
465 * Paints one frame: `[codePoint, fg, bg]` per cell, row-major, as a
466 * Uint32Array. `desks` are in grid order; `tick` advances the animation.
467 */
468export function paintWords(layout, desks, tick, opts = {}) {
469 const W = layout.columns;
470 const H = layout.rows * 2;
471 const px = new Uint32Array(W * H).fill(C.gap);
472 const glyphs = new Map();
473
474 desks.slice(0, layout.shown).forEach((desk, i) => {
475 if (!desk) return;
476 const ox = (i % layout.perRow) * (layout.tileW + GAP);
477 const oyRow = Math.floor(i / layout.perRow) * layout.tileH;
478 if (ox + layout.tileW > W) return;
479 paintDesk(px, glyphs, W, layout, ox, oyRow, desk, opts.reducedMotion ? Math.floor(tick / 4) : tick, opts);
480 paintTag(glyphs, W, layout, ox, oyRow + layout.artRows, desk, i);
481 // The bubble's words along the card's top, over the wall.
482 const bubble = bubbleFor(desk);
483 if (bubble && layout.tileW >= 10) {
484 const text = ` ${bubble.text} `.slice(0, layout.tileW - 1);
485 const [fg, bg] = { alert: [0xffffff, 0xd8352a], work: [0x1e1a26, 0xf4f2ee], party: [0x1e1a26, 0xffd23f], quiet: [0x4a4658, 0xc8c4d4] }[bubble.tone];
486 const col = ox + layout.tileW - text.length - 1;
487 for (let x = 0; x < text.length; x++) glyphs.set(oyRow * W + col + x, { ch: text[x], fg, bg });
488 }
489 });
490
491 const words = new Uint32Array(W * layout.rows * 3);
492 for (let r = 0; r < layout.rows; r++) {
493 for (let c = 0; c < W; c++) {
494 const at = (r * W + c) * 3;
495 const g = glyphs.get(r * W + c);
496 if (g) {
497 words[at] = g.ch.codePointAt(0);
498 words[at + 1] = g.fg;
499 words[at + 2] = g.bg;
500 } else {
501 words[at] = UPPER_HALF;
502 words[at + 1] = px[2 * r * W + c];
503 words[at + 2] = px[(2 * r + 1) * W + c];
504 }
505 }
506 }
507 return words;
508}
509
510/** paintWords packed as RasterProps `cells`. */
511export function paintFrame(layout, desks, tick, opts) {
512 return toBase64(new Uint8Array(paintWords(layout, desks, tick, opts).buffer));
513}
514
515export function toBase64(bytes) {
516 if (typeof bytes.toBase64 === "function") return bytes.toBase64();
517 let s = "";
518 for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
519 return btoa(s);
520}
521
522function paintTag(glyphs, W, layout, ox, row, desk, i) {
523 const { first, second } = tagLines(desk, i, layout.tileW);
524 const bg = TAG_BG[desk.status];
525 const fg = TAG_FG[desk.status] ?? C.white;
526 const key = HOTKEYS[i];
527 const lines = [first, second];
528 for (let l = 0; l < layout.scale.tagRows; l++) {
529 const text = (lines[l] ?? "").padEnd(layout.tileW, " ");
530 for (let x = 0; x < layout.tileW; x++) {
531 const isKey = l === 0 && key && x < key.length;
532 const color = isKey ? ACCENT[desk.status] : l === 1 ? blend(fg, bg, 0.3) : fg;
533 glyphs.set((row + l) * W + ox + x, { ch: text[x], fg: color, bg });
534 }
535 }
536}
537
538// One desk's art. `set` and `glyph` take sprite pixels; the scale maps them.
539function paintDesk(px, glyphs, W, layout, ox, oyRow, desk, tick, opts = {}) {
540 const { pw, ph, k } = layout.scale;
541 const oy = oyRow * 2;
542 const set = (x, y, color) => {
543 if (x < 0 || x >= pw || y < 0 || y >= ph) return;
544 for (let dy = 0; dy < k; dy++) for (let dx = 0; dx < k; dx++) px[(oy + y * k + dy) * W + ox + x * k + dx] = color;
545 };
546 const get = (x, y) => px[(oy + Math.max(0, Math.min(ph - 1, y)) * k) * W + ox + Math.max(0, Math.min(pw - 1, x)) * k];
547 // A character over the cell holding sprite pixel (x, y).
548 const glyph = (x, y, ch, fg) => {
549 if (x < 0 || x >= pw || y < 0 || y >= ph) return;
550 const col = ox + x * k;
551 const row = oyRow + Math.floor((y * k) / 2);
552 const bg = px[row * 2 * W + col];
553 glyphs.set(row * W + col, { ch, fg, bg: bg ?? DEFAULT_COLOR });
554 };
555 const line = (x0, y0, x1, y1, color) => {
556 const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
557 for (let s = 0; s <= n; s++) set(Math.round(x0 + ((x1 - x0) * s) / n), Math.round(y0 + ((y1 - y0) * s) / n), color);
558 };
559 const look = {
560 shirt: SHIRTS[desk.look % SHIRTS.length],
561 hair: HAIRS[(desk.look >>> 8) % HAIRS.length],
562 };
563 // A tile whose character is a picture leaves the chair empty for it.
564 const kind = desk.kind === "shell" ? "empty" : desk.hasPortrait ? "none" : kindFor(desk, opts.characters);
565 const api = { set, get, glyph, line, look, tick, desk, kind, isBold: opts.style !== "detailed" };
566 if (pw <= 10) paintCompact(api);
567 else paintFull(api);
568}
569
570/** Which creature sits at a desk: its override, the character set, or a robot for a terminal. */
571export const CHARACTER_KINDS = ["human", "robot", "critter", "cat", "dog", "owl"];
572export function kindFor(desk, characters) {
573 if (desk.character && CHARACTER_KINDS.includes(desk.character)) return desk.character;
574 if (desk.kind === "process") return "robot";
575 const h = hash(desk.sessionId || desk.paneId || desk.name || "");
576 if (characters === "robots") return "robot";
577 if (characters === "critters") return "critter";
578 if (characters === "animals") return ["cat", "dog", "owl"][h % 3];
579 if (characters === "mixed") {
580 const p = hash(desk.project || desk.cwd || "");
581 return ["human", "robot", "critter", ["cat", "dog", "owl"][h % 3]][p % 4];
582 }
583 return "human";
584}
585
586// A 6 x 6 front head for the grid's workers: every kind in bold, the
587// non-human ones in detailed.
588function smallHead(set, x, y, kind, desk, isBold) {
589 const map = (isBold ? BOLD_SMALL_HEADS : SMALL_HEADS)[kind];
590 if (!map) return;
591 const furs = FURS[kind];
592 const [f, d] = furs ? furs[desk.look % furs.length] : [0xde7a4a, 0xb35a30];
593 const skin = SKIN_TONES[desk.look % SKIN_TONES.length];
594 const hair = HAIR_COLOURS[(desk.look >>> 4) % HAIR_COLOURS.length];
595 const pal = {
596 o: 0x1a1420, e: 0x17131c, F: f, D: d, W: f, y: 0xf2c94c, b: 0xe0a030, n: 0x3a2a2a, m: 0x5a3030, p: 0xf0a0b4, q: 0xde7a4a, Q: 0xb35a30,
597 s: skin[0], S: skin[1], r: hair[0], R: hair[1],
598 ...(kind === "robot" ? { m: 0xa9b0be, M: 0x6e7584, v: desk.kind === "process" ? 0xffa53a : 0x5ee7ff, V: 0xffffff, a: 0xff5c5c } : {}),
599 };
600 map.forEach((row, j) => [...row].forEach((k, i) => k !== "." && pal[k] != null && set(x + i, y + j, pal[k])));
601}
602
603// ---- the 20 x 18 desk -------------------------------------------------------
604
605function paintFull({ set, get, glyph, line, look, tick, desk, kind, isBold }) {
606 const s = desk.status;
607 const isRobot = kind === "robot";
608 const isEmpty = kind === "empty";
609 const noWorker = kind === "none";
610 const blink = tick % 4 < 2;
611 const fast = tick % 2 === 0;
612 const lampOn = s !== "done";
613
614 // Wall, warmed by the desk lamp on the right; floor below.
615 for (let y = 0; y < 18; y++) {
616 for (let x = 0; x < 20; x++) {
617 if (y >= 13) {
618 set(x, y, y === 13 ? C.floorEdge : C.floor);
619 continue;
620 }
621 const d = Math.hypot(x - 9, (y - 1) * 1.2);
622 let c = blend(C.wall, C.wallDark, y / 26);
623 if (lampOn && d < 9) c = blend(c, C.lampWarm, (1 - d / 9) * 0.28);
624 set(x, y, c);
625 }
626 }
627
628 // Needs you: the alert light on the monitor throws red and amber on the wall.
629 if (s === "blocked") {
630 const hue = blink ? C.alertRed : C.alertAmber;
631 for (let y = 0; y < 13; y++) {
632 for (let x = 0; x < 20; x++) {
633 const d = Math.hypot(x - 13, (y - 2) * 1.1);
634 if (d < 10) set(x, y, blend(get(x, y), hue, (1 - d / 10) * (blink ? 0.55 : 0.3)));
635 }
636 }
637 for (let x = 0; x < 20; x++) set(x, 17, blink ? C.alertWall : C.floor);
638 }
639
640 // Desk: top, the state's accent stripe, a front panel, two legs.
641 for (let x = 8; x < 20; x++) {
642 set(x, 12, x === 8 ? C.deskTopHi : C.deskTop);
643 set(x, 13, x % 4 === 1 ? blend(C.deskFront, ACCENT[s], 0.15) : ACCENT[s]);
644 set(x, 14, C.deskFront);
645 }
646 for (let y = 15; y <= 17; y++) {
647 set(9, y, C.deskShadow);
648 set(19, y, C.deskShadow);
649 }
650
651 // A pendant lamp over the keyboard.
652 set(8, 0, C.lampShade);
653 set(9, 0, C.lampShade);
654 set(10, 0, C.lampShade);
655 set(9, 1, lampOn ? C.bulbOn : C.bulbOff);
656
657 // Monitor: frame, stand, keyboard.
658 for (let y = 4; y <= 10; y++) for (let x = 10; x <= 16; x++) set(x, y, y === 4 ? C.frameHi : C.frame);
659 set(13, 11, C.frame);
660 set(12, 11, C.frameHi);
661 set(14, 11, C.frameHi);
662 for (let x = 8; x <= 11; x++) set(x, 11, (x + tick) % 5 === 0 && s === "working" ? C.keysHi : C.keys);
663 paintScreen(set, isEmpty ? "done" : s === "done" && desk.unseenDone ? "celebrate" : s, isRobot && desk.kind === "process", tick, desk);
664
665 // Mug on the right of the monitor (in hand while idle).
666 if (s !== "idle" && s !== "unknown") {
667 set(18, 10, C.mug);
668 set(18, 11, C.mugShade);
669 set(19, 10, C.mugShade);
670 set(18, 9, C.coffee);
671 }
672
673 // The worker.
674 if (isEmpty) chairUpright(set);
675 else if (noWorker) {
676 // The picture sits here.
677 } else if (isRobot) paintRobot({ set, line, tick, s });
678 else if (s === "working") paintTyping({ set, line, look, tick });
679 else if (s === "idle" || s === "unknown") paintLeaning({ set, line, look, tick, s });
680 else if (s === "done" && desk.unseenDone) paintWaving({ set, line, look, blink, fast });
681 else if (s === "done") paintAsleep({ set, look });
682 else if (s === "blocked") paintWaving({ set, line, look, blink, fast });
683 // Critters and animals (and in bold, everyone): their own head on the same body.
684 if (!isEmpty && !noWorker && (isBold || (!isRobot && kind !== "human"))) {
685 const at = isRobot ? [3, 0] : desk.status === "done" && desk.unseenDone ? [4, 0] : ({ working: [3, 1], idle: [1, 1], unknown: [1, 1], done: [6, 6], blocked: [4, 0] }[s] ?? [3, 1]);
686 smallHead(set, at[0], at[1], kind, desk, isBold);
687 }
688
689 // Steam curling off the mug in hand while idle.
690 if ((s === "idle" || s === "unknown") && !isRobot && !isEmpty) {
691 const sip = tick % 24 >= 20;
692 const [mx, my] = sip ? [6, 4] : [7, 8];
693 for (let p = 0; p < 4; p++) {
694 const phase = (tick + p * 3) % 12;
695 const y = my - 1 - Math.floor(phase * 0.5);
696 const x = mx + Math.round(Math.sin((tick + p * 5) / 2.5));
697 if (y >= 1 && x >= 6) set(x, y, blend(C.steam, get(x, y), phase / 14));
698 }
699 }
700
701 // Confetti over a session that finished and has not been looked at yet.
702 if (s === "done" && desk.unseenDone && !isEmpty) {
703 for (let k = 0; k < 10; k++) {
704 const h = hash(`cf${desk.paneId}${k}`);
705 set((h % 20) + Math.round(Math.sin((tick + k) / 2)), ((h >> 5) + tick * (1 + (k % 3))) % 13, CONFETTI_GRID[k % CONFETTI_GRID.length]);
706 }
707 }
708 // zZ floating up off a sleeper.
709 if (s === "done" && !isEmpty && !desk.unseenDone) {
710 for (let p = 0; p < 3; p++) {
711 const phase = (Math.floor(tick / 2) + p * 4) % 12;
712 const y = 7 - Math.floor(phase * 0.6);
713 const x = 7 - Math.floor(phase / 4);
714 if (y >= 0) glyph(x, y, phase < 5 ? "z" : "Z", blend(C.zzz, C.wall, phase / 13));
715 }
716 }
717
718 // The alert light, and a "!" over the worker's head.
719 if (s === "blocked") {
720 set(12, 3, C.metalDark);
721 set(13, 3, blink ? C.alertRed : C.alertAmber);
722 set(14, 3, C.metalDark);
723 set(13, 2, blink ? C.white : C.alertRed);
724 glyph(3, 0, "!", blink ? C.alertAmber : C.alertRed);
725 }
726
727 if (desk.isSelf && !isRobot && !noWorker) paintCrown(set, s);
728
729 // Helpers: a minion per active subagent in front of the desk, and a count.
730 const n = desk.helpers || 0;
731 if (n > 0) {
732 // Two on the floor in front, a third up on the monitor.
733 const spots = [
734 [11, 17],
735 [15, 17],
736 [15, 3],
737 ];
738 spots.slice(0, Math.min(3, n)).forEach(([x, foot], i) => paintMinion(set, x, foot - ((tick + i) % 3 === 0 ? 1 : 0), tick + i));
739 const label = `x${n}`;
740 for (let i = 0; i < label.length; i++) glyph(20 - label.length + i, 0, label[i], C.minion);
741 }
742}
743
744function paintScreen(set, s, isRobot, tick, desk) {
745 for (let y = 5; y <= 9; y++) {
746 for (let x = 11; x <= 15; x++) {
747 let c = C.screenOff;
748 if (s === "celebrate") {
749 c = CONFETTI_GRID[(x + y + tick) % CONFETTI_GRID.length];
750 } else if (isRobot) {
751 // A terminal: green output scrolling up.
752 const ln = y - 5 + Math.floor(tick / 2);
753 const h = hash(`${desk.paneId}:${ln}`);
754 const len = 1 + (h % 5);
755 c = x - 11 < len ? C.termGreen[(h >> 4) % 3] : C.termBg;
756 if (y === 9 && x === 11 + (len % 5) && tick % 2) c = C.termGreen[0];
757 } else if (s === "working") {
758 // Code scrolling up a line a frame.
759 const ln = y - 5 + tick;
760 const h = hash(`${desk.paneId}:${ln}`);
761 const indent = h % 3;
762 const len = 1 + ((h >> 3) % (5 - indent));
763 const col = x - 11;
764 c = col >= indent && col < indent + len ? C.code[(h >> 7) % C.code.length] : C.screenBg;
765 } else if (s === "idle" || s === "unknown") {
766 // A screensaver: one dot drifting round the screen.
767 const t = Math.floor(tick / 2);
768 const dx = Math.abs((t % 8) - 4);
769 const dy = Math.abs(((t + 2) % 8) - 4);
770 c = x - 11 === dx && y - 5 === dy ? C.saverDot : C.saver;
771 } else if (s === "blocked") {
772 const on = tick % 4 < 2;
773 const bang = x === 13 && (y <= 7 || y === 9);
774 c = bang ? C.white : on ? 0x8f1d1d : 0x5a1414;
775 } else {
776 c = x === 15 && y === 5 ? C.screenGlare : C.screenOff;
777 }
778 set(x, y, c);
779 }
780 }
781 // A working screen lights the wall above it, flickering.
782 if (s === "working" || isRobot) {
783 const glow = isRobot ? C.termGreen[0] : C.code[0];
784 const amount = noise(tick, 31) & 1 ? 0.22 : 0.12;
785 for (let x = 10; x <= 16; x++) {
786 set(x, 3, blend(C.wall, glow, amount));
787 set(x, 2, blend(C.wall, glow, amount / 2));
788 }
789 }
790}
791
792function chairUpright(set) {
793 for (let y = 5; y <= 11; y++) set(1, y, C.chair);
794 for (let y = 6; y <= 10; y++) set(2, y, C.chairDark);
795 for (let x = 1; x <= 6; x++) set(x, 12, C.chair);
796 set(4, 13, C.chairDark);
797 set(4, 14, C.chairDark);
798 set(4, 15, C.chairDark);
799 for (let x = 2; x <= 6; x++) set(x, 16, C.chairDark);
800 set(2, 17, C.shoe);
801 set(6, 17, C.shoe);
802}
803
804function seatedLegs(set) {
805 for (let x = 3; x <= 7; x++) set(x, 11, C.pants);
806 for (let y = 12; y <= 15; y++) set(7, y, C.pants);
807 set(7, 16, C.shoe);
808 set(8, 16, C.shoe);
809}
810
811function paintTyping({ set, line, look, tick }) {
812 chairUpright(set);
813 seatedLegs(set);
814 // Head leaning in to the screen.
815 set(4, 2, look.hair);
816 set(5, 2, look.hair);
817 set(6, 2, look.hair);
818 set(4, 3, look.hair);
819 set(4, 4, look.hair);
820 for (let y = 3; y <= 5; y++) for (let x = 5; x <= 7; x++) set(x, y, C.skin);
821 set(7, 4, C.eye);
822 set(6, 5, C.skinShade);
823 for (let y = 6; y <= 10; y++) for (let x = 3; x <= 6; x++) set(x, y, look.shirt);
824 // Two arms, hands alternating on the keys every frame.
825 const nearDown = tick % 2 === 0;
826 const far = blend(look.shirt, 0x000000, 0.25);
827 const farHand = nearDown ? [11, 7] : [11, 10];
828 const nearHand = nearDown ? [9, 10] : [9, 7];
829 line(6, 7, farHand[0] - 1, farHand[1], far);
830 set(farHand[0], farHand[1], C.skinShade);
831 line(5, 8, nearHand[0] - 1, nearHand[1], look.shirt);
832 set(nearHand[0], nearHand[1], C.skin);
833 // A key flash under whichever hand is down.
834 const down = nearDown ? nearHand : farHand;
835 if (down[1] === 10) set(down[0], 11, C.spark);
836}
837
838function paintLeaning({ set, line, look, tick, s }) {
839 // Chair tipped back.
840 set(0, 4, C.chair);
841 set(0, 5, C.chair);
842 set(0, 6, C.chair);
843 set(1, 7, C.chair);
844 set(1, 8, C.chair);
845 set(1, 9, C.chair);
846 set(2, 10, C.chair);
847 set(2, 11, C.chair);
848 for (let x = 2; x <= 6; x++) set(x, 12, C.chair);
849 set(4, 13, C.chairDark);
850 set(4, 14, C.chairDark);
851 set(4, 15, C.chairDark);
852 for (let x = 2; x <= 6; x++) set(x, 16, C.chairDark);
853 seatedLegs(set);
854 // Head back, hands behind it, a slow blink.
855 set(2, 2, look.hair);
856 set(3, 2, look.hair);
857 set(4, 2, look.hair);
858 set(2, 3, look.hair);
859 for (let y = 3; y <= 5; y++) for (let x = 3; x <= 5; x++) set(x, y, C.skin);
860 const isBlinking = tick % 18 >= 16;
861 set(5, 4, isBlinking ? C.skinShade : C.eye);
862 set(5, 5, C.skinShade);
863 for (let y = 6; y <= 10; y++) for (let x = 2; x <= 5; x++) set(x, y, look.shirt);
864 // Coffee in hand, lifted for a sip now and then.
865 const sip = tick % 24 >= 20;
866 if (sip) {
867 line(4, 7, 5, 5, look.shirt);
868 set(5, 5, C.skin);
869 set(6, 4, C.mug);
870 set(6, 5, C.mugShade);
871 } else {
872 line(4, 7, 6, 9, look.shirt);
873 set(6, 9, C.skin);
874 set(7, 8, C.mug);
875 set(7, 9, C.mugShade);
876 set(8, 8, C.mugShade);
877 }
878 if (s === "unknown") set(5, 4, C.skinShade);
879}
880
881function paintAsleep({ set, look }) {
882 chairUpright(set);
883 seatedLegs(set);
884 // Slumped forward, head on folded arms.
885 for (let y = 7; y <= 10; y++) for (let x = 3; x <= 6; x++) set(x, y, look.shirt);
886 for (let x = 6; x <= 10; x++) set(x, 11, look.shirt);
887 set(7, 8, look.hair);
888 set(8, 8, look.hair);
889 set(9, 8, look.hair);
890 set(7, 9, look.hair);
891 set(8, 9, C.skin);
892 set(9, 9, C.skin);
893 set(7, 10, C.skin);
894 set(8, 10, C.skin);
895 set(9, 10, C.skinShade);
896}
897
898function paintWaving({ set, line, look, blink, fast }) {
899 // Chair pushed back, standing, both arms up and waving.
900 for (let y = 7; y <= 12; y++) set(0, y, C.chair);
901 set(1, 12, C.chair);
902 set(2, 12, C.chair);
903 set(1, 16, C.chairDark);
904 for (let y = 13; y <= 15; y++) set(1, y, C.chairDark);
905 set(5, 1, look.hair);
906 set(6, 1, look.hair);
907 set(7, 1, look.hair);
908 for (let y = 2; y <= 4; y++) for (let x = 5; x <= 7; x++) set(x, y, C.skin);
909 set(5, 2, look.hair);
910 set(6, 3, C.eye);
911 set(7, 3, C.eye);
912 set(6, 4, blink ? C.eye : C.skinShade);
913 for (let y = 5; y <= 9; y++) for (let x = 4; x <= 7; x++) set(x, y, look.shirt);
914 for (let y = 10; y <= 15; y++) {
915 set(5, y, C.pants);
916 set(6, y, C.pants);
917 }
918 set(4, 16, C.shoe);
919 set(5, 16, C.shoe);
920 set(6, 16, C.shoe);
921 set(7, 16, C.shoe);
922 const left = fast ? [2, 1] : [1, 3];
923 const right = fast ? [10, 4] : [9, 2];
924 line(4, 5, left[0], left[1] + 1, look.shirt);
925 set(left[0], left[1], C.skin);
926 line(7, 5, right[0], right[1] + 1, look.shirt);
927 set(right[0], right[1], C.skin);
928}
929
930function paintRobot({ set, line, tick, s }) {
931 chairUpright(set);
932 // Legs: metal struts.
933 for (let x = 3; x <= 7; x++) set(x, 11, C.metalDeep);
934 for (let y = 12; y <= 15; y++) set(7, y, C.metalDeep);
935 set(7, 16, C.metalDark);
936 set(8, 16, C.metalDark);
937 // Antenna with a blinking tip.
938 set(5, 0, tick % 6 < 3 ? 0xef4444 : 0x22c55e);
939 set(5, 1, C.metalDark);
940 // Square head, scanning eye.
941 for (let y = 2; y <= 5; y++) for (let x = 3; x <= 7; x++) set(x, y, y === 2 ? C.metal : C.metalDark);
942 const eye = 5 + (Math.floor(tick / 2) % 3);
943 set(eye, 3, C.led);
944 set(4, 5, C.metalDeep);
945 set(6, 5, C.metalDeep);
946 // Body with a chest light.
947 for (let y = 6; y <= 10; y++) for (let x = 3; x <= 6; x++) set(x, y, C.metalDark);
948 set(4, 8, tick % 2 ? C.led : 0x0e7490);
949 set(5, 8, s === "running" ? 0xf59e0b : C.metalDeep);
950 // Piston arms, typing.
951 const down = tick % 2 === 0;
952 line(6, 7, 9, down ? 10 : 9, C.metal);
953 line(6, 8, 11, down ? 9 : 10, C.metalDeep);
954 set(9, down ? 10 : 9, C.led);
955}
956
957function paintCrown(set, s) {
958 // This session: a small gold crown on the worker's head.
959 const spots = {
960 working: [4, 1],
961 idle: [2, 1],
962 unknown: [2, 1],
963 done: [7, 7],
964 blocked: [5, 0],
965 }[s] ?? [4, 1];
966 const [x, y] = spots;
967 set(x, y, C.crown);
968 set(x + 2, y, C.crown);
969 set(x + 1, y, blend(C.crown, C.wall, 0.4));
970}
971
972function paintMinion(set, x, footY, t) {
973 // 3 wide, 4 tall: yellow head with a goggle, blue overalls, feet.
974 const y = footY - 3;
975 set(x - 1, y, C.minion);
976 set(x, y, C.minion);
977 set(x + 1, y, C.minion);
978 set(x - 1, y + 1, C.minionShade);
979 set(x, y + 1, t % 8 === 0 ? C.minion : C.goggle);
980 set(x + 1, y + 1, C.minionShade);
981 set(x - 1, y + 2, C.overalls);
982 set(x, y + 2, C.overalls);
983 set(x + 1, y + 2, C.overalls);
984 set(x - 1, y + 3, C.shoe);
985 set(x + 1, y + 3, C.shoe);
986}
987
988// ---- the 10 x 8 desk --------------------------------------------------------
989
990function paintCompact({ set, glyph, look, tick, desk, kind }) {
991 const s = kind === "empty" ? "done" : desk.status;
992 const isRobot = kind === "robot";
993 const blink = tick % 4 < 2;
994 for (let y = 0; y < 8; y++) for (let x = 0; x < 10; x++) set(x, y, y >= 6 ? C.floor : C.wall);
995 if (s === "blocked") for (let x = 0; x < 10; x++) set(x, 0, blink ? C.alertRed : C.alertAmber);
996 // Desk with its accent stripe, monitor.
997 for (let x = 4; x < 10; x++) {
998 set(x, 5, C.deskTop);
999 set(x, 6, ACCENT[s]);
1000 }
1001 for (let y = 1; y <= 4; y++) for (let x = 5; x <= 8; x++) set(x, y, C.frame);
1002 for (let y = 2; y <= 3; y++) {
1003 for (let x = 6; x <= 7; x++) {
1004 let c = C.screenOff;
1005 if (isRobot) c = (x + y + tick) % 2 ? C.termGreen[0] : C.termBg;
1006 else if (s === "working") c = C.code[noise(tick, x * 5 + y) % C.code.length];
1007 else if (s === "idle" || s === "unknown") c = (Math.floor(tick / 2) + x + y) % 4 === 0 ? C.saverDot : C.saver;
1008 else if (s === "blocked") c = blink ? C.alertRed : 0x5a1414;
1009 set(x, y, c);
1010 }
1011 }
1012 // Worker or robot; a tile's picture takes the worker's place.
1013 if (kind === "none") return;
1014 if (kind === "empty") {
1015 set(0, 4, C.chair);
1016 set(1, 4, C.chair);
1017 set(0, 5, C.chair);
1018 return;
1019 }
1020 if (kind !== "human" && !isRobot) {
1021 const furs = FURS[kind];
1022 look = { ...look, hair: furs ? furs[desk.look % furs.length][1] : 0xb35a30 };
1023 }
1024 set(0, 2, C.chair);
1025 set(0, 3, C.chair);
1026 set(0, 4, C.chair);
1027 set(0, 5, C.chair);
1028 const head = isRobot ? C.metal : kind === "human" ? C.skin : kind === "critter" ? 0xde7a4a : (FURS[kind]?.[desk.look % FURS[kind].length]?.[0] ?? C.skin);
1029 const body = isRobot ? C.metalDark : look.shirt;
1030 if (s === "done" && desk.unseenDone && !isRobot) {
1031 // Finished and not looked at: arms up, the screen a party.
1032 set(2, 1, look.hair);
1033 set(2, 2, head);
1034 set(2, 3, body);
1035 set(2, 4, body);
1036 set(blink ? 1 : 3, 0, C.skin);
1037 set(blink ? 3 : 1, 1, C.skin);
1038 set(6, 2, CONFETTI_GRID[tick % 6]);
1039 set(7, 3, CONFETTI_GRID[(tick + 2) % 6]);
1040 set(6 + (tick % 3), 0, CONFETTI_GRID[(tick + 4) % 6]);
1041 } else if (s === "done" && !isRobot) {
1042 set(1, 3, body);
1043 set(2, 4, body);
1044 set(3, 4, look.hair);
1045 set(4, 4, look.hair);
1046 set(1, 4, body);
1047 if (Math.floor(tick / 3) % 2) glyph(4, 0, "z", C.zzz);
1048 } else if (s === "blocked" && !isRobot) {
1049 set(2, 1, look.hair);
1050 set(2, 2, head);
1051 set(2, 3, body);
1052 set(2, 4, body);
1053 set(blink ? 1 : 3, 0, C.skin);
1054 set(blink ? 3 : 1, 1, C.skin);
1055 } else {
1056 const back = s === "idle" || s === "unknown" ? 1 : 2;
1057 set(back, 1, isRobot ? C.metal : look.hair);
1058 set(back, 2, head);
1059 if (isRobot) set(back + 1, 2, C.led);
1060 set(back, 3, body);
1061 set(back, 4, body);
1062 set(back + 1, 3, body);
1063 if (s === "working" || isRobot) set(tick % 2 ? 3 : 4, 4, isRobot ? C.metal : C.skin);
1064 if (s === "idle" || s === "unknown") {
1065 set(9, 4, C.mug);
1066 set(9, 3 - (tick % 3), blend(C.steam, C.wall, (tick % 3) / 3));
1067 }
1068 }
1069 if (desk.isSelf && !isRobot) set(2, 0, C.crown);
1070 const n = desk.helpers || 0;
1071 for (let i = 0; i < Math.min(5, n); i++) set(5 + i, 7, (tick + i) % 4 === 0 ? C.minionShade : C.minion);
1072 if (n > 5) glyph(9, 0, n > 9 ? "+" : String(n), C.minion);
1073}
1074
1075// ---- options ----------------------------------------------------------------
1076
1077// Every option: its manifest field, the values the settings view cycles
1078// through, the default, and the key that cycles it there.
1079export const OPTIONS = [
1080 { key: "layout", field: "layout", title: "Layout", values: ["grid", "tiles", "office", "war-room", "strip"], fallback: "grid", hotkey: "l" },
1081 { key: "characters", field: "characters", title: "Characters", values: ["critters", "humans", "robots", "animals", "mixed"], fallback: "critters", hotkey: "c" },
1082 { key: "style", field: "style", title: "Character style in cells", values: ["bold", "detailed"], fallback: "bold", hotkey: "b" },
1083 { key: "lighting", field: "lighting", title: "Lighting", values: ["clock", "day", "night"], fallback: "clock", hotkey: "t" },
1084 { key: "palette", field: "palette", title: "Palette", values: ["nightshade", "warm", "cool", "mono"], fallback: "nightshade", hotkey: "p" },
1085 { key: "show", field: "show", title: "Show", values: ["claude", "agents", "busy", "all"], fallback: "claude", hotkey: "w" },
1086 { key: "desks", field: "desks", title: "Desks at a time", values: [4, 6, 8, 12, "all"], fallback: 8, hotkey: "d" },
1087 { key: "hideDoneMinutes", field: "hide_done_minutes", title: "Hide done after (min)", values: [0, 5, 15, 30, 60], fallback: 0, hotkey: "h" },
1088 { key: "seating", field: "seating", title: "Seating", values: ["state", "stable", "project", "workspace"], fallback: "state", hotkey: "o" },
1089 { key: "speed", field: "speed", title: "Animation speed", values: ["slow", "normal", "fast"], fallback: "normal", hotkey: "m" },
1090 { key: "reducedMotion", field: "reduced_motion", title: "Reduced motion", values: [false, true], fallback: false, hotkey: "r" },
1091 { key: "notify", field: "notify", title: "Toasts", values: ["needs-you", "changes", "off"], fallback: "needs-you", hotkey: "n" },
1092 { key: "render", field: "render", title: "Characters as pictures (hybrid) or cells", values: ["hybrid", "cells"], fallback: "hybrid", hotkey: "g" },
1093 { key: "sound", field: "sound", title: "Sound when someone needs you", values: [false, true], fallback: false, hotkey: "u" },
1094];
1095
1096export const FRAME_MS = { slow: 260, normal: 150, fast: 90 };
1097
1098/**
1099 * The options in force: what the settings view stored, else the manifest's
1100 * userConfig value, else the built-in default. A value outside the list is ignored.
1101 */
1102export function resolveOptions(userConfig = {}, stored = {}) {
1103 const out = {};
1104 for (const o of OPTIONS) {
1105 const pick = [stored?.[o.key], userConfig?.[o.field]].find((v) => o.values.some((x) => String(x) === String(v)));
1106 const value = pick === undefined ? o.fallback : o.values.find((x) => String(x) === String(pick));
1107 out[o.key] = value;
1108 }
1109 return out;
1110}
1111
1112/** The next value of an option, as its settings key cycles it. */
1113export function cycleOption(options, key) {
1114 const o = OPTIONS.find((x) => x.key === key);
1115 if (!o) return options;
1116 const i = o.values.findIndex((v) => String(v) === String(options[key]));
1117 return { ...options, [key]: o.values[(i + 1) % o.values.length] };
1118}
1119
1120// ---- sessions: keys, overrides, changes, seats --------------------------------
1121
1122/** What a desk is remembered by: its Claude session, else its pane. */
1123export function deskKey(desk) {
1124 return desk.sessionId ? `s:${desk.sessionId}` : `p:${desk.paneId}`;
1125}
1126
1127/** Applies per-session settings: a label, a character, a pinned seat. */
1128export function applyOverrides(desks, overrides = {}) {
1129 return desks.map((d) => {
1130 const o = overrides[deskKey(d)];
1131 if (!o) return d;
1132 return {
1133 ...d,
1134 name: typeof o.label === "string" && o.label.trim() ? o.label.trim().slice(0, 40) : d.name,
1135 character: CHARACTER_KINDS.includes(o.character) ? o.character : d.character,
1136 pin: Number.isInteger(o.seat) && o.seat > 0 ? o.seat : undefined,
1137 };
1138 });
1139}
1140
1141/**
1142 * Notes when each desk's state last changed, and whether a finished session
1143 * has been looked at. Returns the desks with `changedAt`, `prevStatus` and
1144 * `unseenDone`, the memory to keep, the changes since last time (a desk seen
1145 * for the first time is not a change), and `unseen`: the finished sessions
1146 * nobody has looked at yet, by key, to keep across reloads.
1147 *
1148 * A session becomes unseen-done when it moves to done; it is seen once herdr
1149 * reports its pane focused, or it moves on from done. A session first met
1150 * already done counts as seen, unless `unseen` (from before a reload) says not.
1151 */
1152export function trackChanges(desks, memory = {}, now = 0, unseen = {}) {
1153 const next = {};
1154 const changes = [];
1155 const stillUnseen = {};
1156 const out = desks.map((d) => {
1157 const key = deskKey(d);
1158 const was = memory[key];
1159 let entry;
1160 if (!was) {
1161 const wasUnseen = d.status === "done" && unseen[key] != null;
1162 entry = { status: d.status, at: wasUnseen ? Number(unseen[key]) : now, prev: wasUnseen ? "working" : null, unseen: wasUnseen };
1163 } else if (was.status !== d.status) {
1164 entry = { status: d.status, at: now, prev: was.status, unseen: d.status === "done" };
1165 changes.push({ desk: d, from: was.status, to: d.status });
1166 } else {
1167 entry = { ...was };
1168 }
1169 if (entry.unseen && (d.status !== "done" || d.isFocused)) entry.unseen = false;
1170 next[key] = entry;
1171 if (entry.unseen) stillUnseen[key] = entry.at;
1172 return { ...d, changedAt: entry.at, prevStatus: entry.prev, unseenDone: entry.unseen };
1173 });
1174 return { desks: out, memory: next, changes, unseen: stillUnseen };
1175}
1176
1177/** Leaves out sessions that have been done for longer than `minutes`. */
1178export function hideStaleDone(desks, minutes, now) {
1179 if (!minutes) return desks;
1180 return desks.filter((d) => d.kind === "shell" || d.status !== "done" || now - (d.changedAt ?? now) < minutes * 60000);
1181}
1182
1183const FORGET_MS = 24 * 3600000;
1184
1185/**
1186 * Puts the desks in order, packed from the first seat with no gaps: the
1187 * returned `seats` has exactly one entry per desk. `state` (the default):
1188 * needs you, working, done, idle. `project`, `workspace`: grouped, then by
1189 * state. `stable`: the order each session last had (remembered in `seats`),
1190 * newcomers after, by state. A desk dragged to a place (`pin`, from 1) is
1191 * put back at that place in the packed order, or last when fewer desks are
1192 * left, so a pin never opens a gap. Returns the order and the memory to keep.
1193 */
1194export function seatDesks(desks, mode = "state", seats = {}, now = 0) {
1195 const memory = {};
1196 for (const [k, v] of Object.entries(seats ?? {})) if (v && now - (v.seen ?? 0) < FORGET_MS) memory[k] = v;
1197 const keyOf = mode === "project" ? (d) => d.project || "" : mode === "workspace" ? (d) => d.workspaceId || "" : () => "";
1198 const was = (d) => {
1199 const m = memory[deskKey(d)];
1200 return mode === "stable" && m && Number.isFinite(m.seat) ? m.seat : Infinity;hooks/office-pictures.mjs 321 lines1// Office: the picture characters. Kenney's CC0 people (assets/office-atlas.*)
2// and the mod's own robots, critters and animals, each drawn as a small
3// picture for one box of cells: the hybrid view's people and the tiles'
4// portraits. Pure: the pane and bin/office-monitor both call it.
5//
6// Sharpness: a mod cannot learn a cell's size in pixels, so a picture is
7// composed at one pixel per half cell, then enlarged pixel for pixel by a
8// large whole number and sent as a PNG. The terminal then only ever shrinks
9// a crisp picture, never smooths up a tiny one; enlarged pixels compress to
10// almost nothing.
11
12import { CAT, CONFETTI_HD, CRITTER, CROWN, DOG, HD_FURS, OWL, ROBOT } from "./office-art-hd.mjs";
13import { mix } from "./office-art.mjs";
14import { hash, kindFor } from "./office-model.mjs";
15import { poseFor } from "./office-scene.mjs";
16
17// ---- atlas ------------------------------------------------------------------
18
19/**
20 * The sprite atlas from its JSON (text or parsed) and its index bytes: every
21 * tile by name (`chr:0,0`), each pixel a palette index, 0 clear.
22 */
23export function loadAtlas(json, bytes) {
24 const meta = typeof json === "string" ? JSON.parse(json) : json;
25 if (!meta || !Array.isArray(meta.palette) || !meta.sprites) throw new Error("not an office atlas");
26 if (!bytes || bytes.length !== meta.width * meta.height) throw new Error("atlas size does not match its bytes");
27 const rgb = meta.palette.map(([r, g, b, a]) => (a ? (r << 16) | (g << 8) | b : -1));
28 return { width: meta.width, height: meta.height, idx: bytes, rgb, sprites: meta.sprites };
29}
30
31// ---- a small picture ----------------------------------------------------------------
32
33// Pixels as 0xRRGGBB, -1 for clear.
34class Pic {
35 constructor(w, h) {
36 this.w = w;
37 this.h = h;
38 this.px = new Int32Array(w * h).fill(-1);
39 }
40 set(x, y, c) {
41 x |= 0;
42 y |= 0;
43 if (x < 0 || y < 0 || x >= this.w || y >= this.h || c == null || c < 0) return;
44 this.px[y * this.w + x] = c;
45 }
46 rect(x, y, w, h, c) {
47 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c);
48 }
49 tile(atlas, name, x, y, rows = 16) {
50 const r = atlas.sprites[name];
51 if (!r) return;
52 const [sx, sy, w, h] = r;
53 for (let j = 0; j < Math.min(h, rows); j++) {
54 for (let i = 0; i < w; i++) {
55 const k = atlas.idx[(sy + j) * atlas.width + sx + i];
56 if (k) this.set(x + i, y + j, atlas.rgb[k]);
57 }
58 }
59 }
60 map(rows, pal, x, y) {
61 rows.forEach((row, j) => [...row].forEach((k, i) => k !== "." && pal[k] != null && this.set(x + i, y + j, pal[k])));
62 }
63}
64
65// ---- looks ------------------------------------------------------------------------
66
67/**
68 * A person's layers, picked from the session's hash so a session always
69 * looks the same: body (skin), trousers, shoes, top, hair, maybe a beard.
70 */
71export function pictureLook(desk, characters) {
72 const seed = hash(`hd:${desk.sessionId || desk.paneId || desk.name}`);
73 const kind = kindFor(desk, characters);
74 const pick = (n, shift) => (seed >>> shift) % n;
75 const leg = 1 + pick(9, 3);
76 const hairCol = [19, 20, 23, 24][pick(4, 14)];
77 const hairRow = [0, 1, 4, 5, 8, 9][pick(6, 17)];
78 const beard = pick(5, 21) === 0 ? `chr:${hairCol < 23 ? 21 : 25},${hairRow - (hairRow % 4)}` : null;
79 const furs = HD_FURS[kind];
80 return {
81 kind,
82 layers: [`chr:${pick(2, 0)},${pick(3, 1)}`, `chr:3,${leg}`, `chr:4,${leg}`, `chr:${6 + pick(12, 7)},${[0, 1, 2, 5, 6, 7][pick(6, 11)]}`, `chr:${hairCol},${hairRow}`, ...(beard ? [beard] : [])],
83 fur: furs ? furs[pick(furs.length, 24)] : null,
84 };
85}
86
87function creaturePal(look, desk) {
88 const isProcess = desk.kind === "process";
89 switch (look.kind) {
90 case "robot": {
91 const visor = isProcess ? 0xffa53a : desk.status === "blocked" ? 0xff4d4d : 0x5ee7ff;
92 return isProcess
93 ? { m: 0xd98a4a, M: 0xa8622c, v: visor, V: 0xffffff, c: 0xe8a060, L: 0xfff1a0, a: 0xff5c5c }
94 : { m: 0xb4bcc8, M: 0x7d8594, v: visor, V: 0xffffff, c: 0x96a0b0, L: 0x5fcf55, a: 0xff5c5c };
95 }
96 case "critter":
97 return { q: 0xd97757, Q: 0xa8553a, e: 0x2a1a14, h: 0xf09a78 };
98 default: {
99 const [f, d] = look.fur ?? [0xd2a676, 0x8a5c38];
100 return { F: f, D: d, E: mix(d, 0x000000, 0.2), w: 0xf6f0e6, e: 0x1e1a1c, n: 0x3a2a2a, m: 0x8a4a4a, p: 0xf0a0b4, y: 0xf2c94c, b: 0xe0a030 };
101 }
102 }
103}
104
105const CREATURES = { robot: ROBOT, critter: CRITTER, cat: CAT, dog: DOG, owl: OWL };
106
107// One person, 16 x 20, chair behind, in the pose of the moment.
108function personPic(atlas, desk, characters, tick, nowMs, reducedMotion) {
109 const one = new Pic(16, 20);
110 const look = pictureLook(desk, characters);
111 const pose = poseFor(desk, nowMs, reducedMotion);
112 const t = reducedMotion ? 0 : tick;
113 const dy = { type: t % 2, wave: [0, -1, -2, -1][t % 4], celebrate: [0, -2, -1][t % 3], sleep: 3 }[pose] ?? 0;
114 one.tile(atlas, "ind:0,8", 0, 5);
115 if (look.kind === "human") for (const name of look.layers) one.tile(atlas, name, 0, 4 + dy);
116 else one.map(CREATURES[look.kind] ?? CRITTER, creaturePal(look, desk), 0, 4 + dy);
117 if (pose === "stretch" || pose === "celebrate" || pose === "wave") {
118 const sk = look.kind === "human" ? 0xe5c49d : creaturePal(look, desk).F ?? 0xd97757;
119 const up = t % 2;
120 one.rect(0, 1 + dy + up, 2, 7, sk);
121 one.rect(14, 1 + dy + (1 - up), 2, 7, sk);
122 }
123 if (desk.isSelf) one.map(CROWN, { y: 0xffd23f, Y: 0xe0a810 }, 5, 1 + dy);
124 if (pose === "celebrate" && t) {
125 for (let k = 0; k < 6; k++) {
126 const h = hash(`pc${desk.paneId}${k}`);
127 one.set(h % 16, ((h >>> 5) + t * (1 + (k % 2))) % 20, CONFETTI_HD[k % CONFETTI_HD.length]);
128 }
129 }
130 return one;
131}
132
133/**
134 * A character for one box of cells, at one pixel per half cell (finer for a
135 * box smaller than a person): the box's
136 * background (the room's own pixels beneath it, from `bg`, or a plain
137 * `fill`), the person laid in at the largest whole-number scale that fits,
138 * standing on the box's bottom edge (the desk top). Returns the small picture.
139 */
140export function characterBox(atlas, desk, characters, tick, nowMs, reducedMotion, box, bg, fill = 0x1d1f2c) {
141 // A box smaller than a person (a sidebar's small desks, a tile) is drawn
142 // a whole number of times finer, keeping its shape: `m` pixels per half
143 // cell. The background is the room's pixel beneath, repeated m x m, so it
144 // still lines up exactly with the cells around the box.
145 const m = Math.max(1, Math.ceil(Math.max(16 / box.columns, 16 / (box.rows * 2))));
146 const W = box.columns * m;
147 const H = box.rows * 2 * m;
148 const pic = new Pic(W, H);
149 for (let y = 0; y < H; y++) {
150 for (let x = 0; x < W; x++) {
151 const by = Math.min(bg ? bg.h - 1 : 0, box.top * 2 + Math.floor(y / m));
152 const bx = Math.min(bg ? bg.w - 1 : 0, box.left + Math.floor(x / m));
153 pic.px[y * W + x] = bg ? bg.px[by * bg.w + bx] & 0xffffff : fill;
154 }
155 }
156 const one = personPic(atlas, desk, characters, tick, nowMs, reducedMotion);
157 const scale = Math.max(1, Math.floor(Math.min(W / 16, (H + 4) / 20)));
158 const ox = Math.floor((W - 16 * scale) / 2);
159 // The last four rows (legs) go below the box: the desk hides them.
160 const oy = H - 16 * scale;
161 for (let y = 0; y < 20; y++) {
162 for (let x = 0; x < 16; x++) {
163 const c = one.px[y * 16 + x];
164 if (c < 0) continue;
165 for (let j = 0; j < scale; j++) for (let i = 0; i < scale; i++) pic.set(ox + x * scale + i, oy + y * scale + j, c);
166 }
167 }
168 return pic;
169}
170
171/** Whether a desk's picture moves (and so is sent again now and then). */
172export function portraitMoves(desk, reducedMotion) {
173 if (reducedMotion || !desk) return false;
174 return desk.status === "working" || desk.status === "blocked" || desk.status === "running" || Boolean(desk.unseenDone);
175}
176
177// ---- sharp PNGs -----------------------------------------------------------------------
178
179/**
180 * How much to enlarge a box's picture. A mod cannot know a cell's size in
181 * pixels; a half cell on a Retina screen is about 16 by 17, so 16 times keeps
182 * the terminal shrinking the picture, never stretching it, at most 640 wide.
183 */
184export function sharpFactor(pic) {
185 return Math.max(4, Math.min(16, Math.floor(640 / Math.max(pic.w, pic.h))));
186}
187
188/**
189 * The picture enlarged `factor` times pixel for pixel, as a whole PNG. Each
190 * row is filtered against its left neighbour, or the row above when equal,
191 * so enlarged pixels become runs of zero bytes, which the deflate below
192 * stores as back-references: 3 to 11 KB for a character.
193 */
194export function sharpPng(pic, factor = sharpFactor(pic)) {
195 const W = pic.w * factor;
196 const H = pic.h * factor;
197 const stride = W * 4 + 1;
198 const raw = new Uint8Array(stride * H);
199 const row = new Uint8Array(W * 4);
200 for (let y = 0; y < pic.h; y++) {
201 for (let x = 0; x < pic.w; x++) {
202 const c = pic.px[y * pic.w + x];
203 const r = c < 0 ? 0 : (c >> 16) & 255;
204 const g = c < 0 ? 0 : (c >> 8) & 255;
205 const b = c < 0 ? 0 : c & 255;
206 const a = c < 0 ? 0 : 255;
207 for (let i = 0; i < factor; i++) {
208 const at = (x * factor + i) * 4;
209 row[at] = r;
210 row[at + 1] = g;
211 row[at + 2] = b;
212 row[at + 3] = a;
213 }
214 }
215 // First copy of the row: Sub filter (each byte less the one four before).
216 const first = y * factor * stride;
217 raw[first] = 1;
218 for (let i = 0; i < row.length; i++) raw[first + 1 + i] = (row[i] - (i >= 4 ? row[i - 4] : 0)) & 255;
219 // The repeats: Up filter, all zeros.
220 for (let j = 1; j < factor; j++) raw[first + j * stride] = 2;
221 }
222 return png(W, H, deflate(raw));
223}
224
225// zlib, one fixed-Huffman block, literals and distance-1 runs.
226function deflate(data) {
227 const out = [];
228 let bits = 0;
229 let nbits = 0;
230 const put = (value, n) => {
231 bits |= value << nbits;
232 nbits += n;
233 while (nbits >= 8) {
234 out.push(bits & 255);
235 bits >>>= 8;
236 nbits -= 8;
237 }
238 };
239 // Huffman codes go most significant bit first.
240 const code = (c, n) => {
241 let r = 0;
242 for (let i = 0; i < n; i++) r |= ((c >> i) & 1) << (n - 1 - i);
243 put(r, n);
244 };
245 const literal = (v) => (v < 144 ? code(0x30 + v, 8) : code(0x190 + v - 144, 9));
246 const symbol = (s) => (s < 280 ? code(s - 256, 7) : code(0xc0 + s - 280, 8));
247 const LBASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
248 const LEXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
249 const length = (len) => {
250 let i = LBASE.length - 1;
251 while (LBASE[i] > len) i--;
252 symbol(257 + i);
253 if (LEXTRA[i]) put(len - LBASE[i], LEXTRA[i]);
254 code(0, 5); // distance 1
255 };
256 out.push(0x78, 0x01);
257 put(1, 1);
258 put(1, 2);
259 let i = 0;
260 while (i < data.length) {
261 if (i > 0) {
262 let run = 0;
263 while (run < 258 && i + run < data.length && data[i + run] === data[i - 1]) run++;
264 if (run >= 3) {
265 length(run);
266 i += run;
267 continue;
268 }
269 }
270 literal(data[i]);
271 i++;
272 }
273 symbol(256);
274 if (nbits) out.push(bits & 255);
275 let a = 1;
276 let b = 0;
277 for (let k = 0; k < data.length; k++) {
278 a = (a + data[k]) % 65521;
279 b = (b + a) % 65521;
280 }
281 out.push((b >>> 8) & 255, b & 255, (a >>> 8) & 255, a & 255);
282 return Uint8Array.from(out);
283}
284
285const CRC = (() => {
286 const t = new Int32Array(256);
287 for (let n = 0; n < 256; n++) {
288 let c = n;
289 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
290 t[n] = c;
291 }
292 return t;
293})();
294
295function png(W, H, idat) {
296 const chunk = (type, data) => {
297 const out = new Uint8Array(12 + data.length);
298 const dv = new DataView(out.buffer);
299 dv.setUint32(0, data.length);
300 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
301 out.set(data, 8);
302 let c = -1;
303 for (let i = 4; i < 8 + data.length; i++) c = CRC[(c ^ out[i]) & 255] ^ (c >>> 8);
304 dv.setUint32(8 + data.length, (c ^ -1) >>> 0);
305 return out;
306 };
307 const ihdr = new Uint8Array(13);
308 const dv = new DataView(ihdr.buffer);
309 dv.setUint32(0, W);
310 dv.setUint32(4, H);
311 ihdr.set([8, 6, 0, 0, 0], 8);
312 const parts = [Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
313 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
314 let at = 0;
315 for (const p of parts) {
316 out.set(p, at);
317 at += p.length;
318 }
319 return out;
320}
321hooks/office-demo.mjs 62 lines1// Office: demo mode. A scripted minute of eight sessions, in place of herdr
2// and the session records, for a screen recording: every state, bubble and
3// helper shows within one 60-second loop, the same on every take.
4//
5// Each session's script is a list of steps [fromSecond, status, activity,
6// helpers]; a step lasts until the next one. `seen` is the second the person
7// "looks at" a finished session, which ends its celebration.
8
9export const DEMO_LOOP_MS = 60_000;
10
11const SCRIPT = [
12 { name: "api-refactor", isSelf: true, steps: [[0, "working", "coding"], [8, "working", "reading"], [14, "working", "thinking"], [20, "working", "coding"], [30, "working", "writing"], [38, "working", "coding"]] },
13 { name: "docs-site", steps: [[0, "working", "writing"], [12, "working", "searching"], [18, "working", "writing"], [44, "working", "reading"], [52, "working", "writing"]] },
14 { name: "bug-triage", steps: [[0, "working", "searching"], [10, "working", "reading"], [15, "blocked", null], [27, "working", "thinking"], [35, "working", "coding"]] },
15 { name: "release-notes", seen: 40, steps: [[0, "working", "writing"], [30, "done", null]] },
16 { name: "test-suite", steps: [[0, "working", "thinking"], [6, "working", "coding"], [20, "working", "reading"], [26, "working", "coding"], [44, "working", "thinking"], [50, "working", "coding"]] },
17 { name: "db-migration", steps: [[0, "idle", null], [20, "working", "thinking"], [28, "working", "coding"], [46, "idle", null]] },
18 { name: "security-review", steps: [[0, "working", "delegating", 3], [30, "working", "delegating", 2], [45, "working", "writing", 0]] },
19 { name: "onboarding", steps: [[0, "idle", null]] },
20];
21
22// Under `characters: mixed`, one of each kind, so the cast shows the range.
23const MIXED = ["human", "robot", "critter", "cat", "human", "dog", "owl", "robot"];
24
25/** The demo's names, in script order. */
26export const DEMO_NAMES = SCRIPT.map((s) => s.name);
27
28/**
29 * The demo sessions at `elapsedMs` since demo mode turned on, as panes in
30 * the shape herdr's give (plus `activity`), and the helper counts by session.
31 * The loop repeats every 60 s. With `characters` "mixed" each gets a kind of
32 * its own; any other value is left to the option, as for real sessions.
33 */
34export function demoPanes(elapsedMs, characters = "") {
35 const t = ((Math.max(0, elapsedMs) % DEMO_LOOP_MS) + DEMO_LOOP_MS) % DEMO_LOOP_MS / 1000;
36 const helpers = {};
37 const panes = SCRIPT.map((s, i) => {
38 let step = s.steps[0];
39 for (const st of s.steps) if (st[0] <= t) step = st;
40 const [, status, activity, n = 0] = step;
41 const sessionId = `demo-${i + 1}-${s.name}`;
42 if (n) helpers[sessionId] = n;
43 return {
44 paneId: `demo:p${i + 1}`,
45 tabId: `demo:t${i + 1}`,
46 workspaceId: "demo",
47 agent: "claude",
48 status,
49 name: s.name,
50 cwd: `/demo/${s.name}`,
51 sessionId,
52 isSelf: Boolean(s.isSelf),
53 // Looked at: a finished session's celebration ends.
54 isFocused: s.seen !== undefined && t >= s.seen,
55 order: i,
56 activity: status === "working" ? activity : null,
57 ...(characters === "mixed" ? { character: MIXED[i] } : {}),
58 };
59 });
60 return { panes, helpers, second: Math.floor(t) };
61}
62hooks/office-scene.mjs 1501 lines1// Office: the scene painter. Lays out a room for every layout but `grid`
2// (which office-model.mjs paints), builds its static background once per
3// size, seating and time of day, and paints the animated sprites over it
4// each frame. Pure: the pane and bin/office-monitor both call it.
5
6import {
7 BOLD_HEADS,
8 BOLD_SMALL_HEADS,
9 CLOTHES,
10 CONFETTI,
11 COOLER,
12 FURS,
13 HAIR_COLOURS,
14 HAIRS,
15 HEAD_BACKS,
16 HEADS,
17 MINION,
18 MUG,
19 PALETTES,
20 PLANT,
21 SKIN_TONES,
22 SMALL_HAIRS,
23 SMALL_HEADS,
24 SMALL_MINION,
25 SMALL_TORSO,
26 TORSO,
27 mix,
28 skyAt,
29} from "./office-art.mjs";
30import { CHARACTER_KINDS, HOTKEYS, bubbleFor, hash, kindFor, layoutFor, paintWords, tagLines } from "./office-model.mjs";
31
32const UPPER_HALF = 0x2580;
33const OUTLINE = 0x1a1420;
34const EYE = 0x17131c;
35export { CHARACTER_KINDS, kindFor };
36
37// ---- canvas ---------------------------------------------------------------
38
39/** A pixel canvas two pixels per cell high, with text cells laid over it. */
40export class Canvas {
41 constructor(w, h, fill = 0) {
42 this.w = w;
43 this.h = h;
44 this.px = new Uint32Array(w * h).fill(fill);
45 this.glyphs = new Map();
46 // 0 lit, towards 1 dark: what night does to everything not emissive.
47 this.dark = 0;
48 this.nightTint = 0x0a0c1e;
49 }
50 shade(c) {
51 return this.dark > 0 ? mix(c, this.nightTint, this.dark) : c;
52 }
53 set(x, y, c, isEmissive = false) {
54 x |= 0;
55 y |= 0;
56 if (x < 0 || y < 0 || x >= this.w || y >= this.h || c == null) return;
57 this.px[y * this.w + x] = isEmissive ? c : this.shade(c);
58 }
59 get(x, y) {
60 x = Math.max(0, Math.min(this.w - 1, x | 0));
61 y = Math.max(0, Math.min(this.h - 1, y | 0));
62 return this.px[y * this.w + x];
63 }
64 // Mixes `c` into what is there: light, glow, shadow.
65 tint(x, y, c, a) {
66 x |= 0;
67 y |= 0;
68 if (x < 0 || y < 0 || x >= this.w || y >= this.h || a <= 0) return;
69 const i = y * this.w + x;
70 this.px[i] = mix(this.px[i], c, a);
71 }
72 rect(x, y, w, h, c, isEmissive = false) {
73 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c, isEmissive);
74 }
75 line(x0, y0, x1, y1, c, isEmissive = false) {
76 const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
77 for (let s = 0; s <= n; s++) this.set(Math.round(x0 + ((x1 - x0) * s) / n), Math.round(y0 + ((y1 - y0) * s) / n), c, isEmissive);
78 }
79 glow(cx, cy, r, c, a) {
80 for (let y = Math.floor(cy - r); y <= cy + r; y++) {
81 for (let x = Math.floor(cx - r * 1.4); x <= cx + r * 1.4; x++) {
82 const d = Math.hypot((x - cx) / 1.4, y - cy) / r;
83 if (d < 1) this.tint(x, y, c, a * (1 - d) * (1 - d));
84 }
85 }
86 }
87 /** Draws a palette-indexed map; `pal` maps a key to a colour. */
88 sprite(map, x, y, pal, opts = {}) {
89 for (let j = 0; j < map.length; j++) {
90 const row = map[j];
91 for (let i = 0; i < row.length; i++) {
92 const k = row[opts.flip ? row.length - 1 - i : i];
93 if (k === "." || k === " ") continue;
94 const c = pal[k];
95 if (c == null) continue;
96 this.set(x + i, y + j, c, opts.isEmissive);
97 }
98 }
99 }
100 /** A character over a cell; `bg` absent takes the pixel beneath. */
101 glyph(col, row, ch, fg, bg) {
102 if (col < 0 || row < 0 || col >= this.w || row * 2 >= this.h) return;
103 this.glyphs.set(row * this.w + col, { ch, fg, bg });
104 }
105 text(col, row, str, fg, bg) {
106 for (let i = 0; i < str.length; i++) this.glyph(col + i, row, str[i], fg, bg);
107 }
108 copyFrom(other) {
109 this.px.set(other.px);
110 this.glyphs = new Map(other.glyphs);
111 }
112 /** `[codePoint, fg, bg]` per cell, row-major. */
113 pack() {
114 const rows = this.h >> 1;
115 const W = this.w;
116 const words = new Uint32Array(W * rows * 3);
117 for (let r = 0; r < rows; r++) {
118 for (let c = 0; c < W; c++) {
119 const at = (r * W + c) * 3;
120 const g = this.glyphs.get(r * W + c);
121 const top = this.px[2 * r * W + c];
122 if (g) {
123 words[at] = g.ch.codePointAt(0);
124 words[at + 1] = g.fg;
125 words[at + 2] = g.bg ?? mix(top, this.px[(2 * r + 1) * W + c], 0.5);
126 } else {
127 words[at] = UPPER_HALF;
128 words[at + 1] = top;
129 words[at + 2] = this.px[(2 * r + 1) * W + c];
130 }
131 }
132 }
133 return words;
134 }
135}
136
137// ---- looks ------------------------------------------------------------------
138
139/** A stable look per session: the same seed always draws the same person. */
140export function lookFor(desk, characters, style) {
141 const seed = hash(`look:${desk.sessionId || desk.paneId || desk.name}`);
142 const kind = kindFor(desk, characters);
143 const isBold = style !== "detailed";
144 const pick = (list, shift) => list[(seed >>> shift) % list.length];
145 return {
146 kind,
147 skin: pick(SKIN_TONES, 0),
148 hair: pick(HAIR_COLOURS, 4),
149 hairStyle: pick(Object.keys(HAIRS), 8),
150 clothes: pick(CLOTHES, 12),
151 fur: kind in FURS ? pick(FURS[kind], 16) : null,
152 isBold,
153 };
154}
155
156function palFor(look, status, isProcess) {
157 const [cl, cs, ck] = look.clothes;
158 const base = { o: OUTLINE, e: EYE, c: cl, C: cs, k: ck };
159 switch (look.kind) {
160 case "robot": {
161 const visor = isProcess ? 0xffa53a : status === "blocked" ? 0xff4d4d : 0x5ee7ff;
162 const body = isProcess ? [0xc0703a, 0x8f4f26, 0xdd925a] : [0x7d8494, 0x5b6170, 0x9aa1b0];
163 return { ...base, m: 0xa9b0be, M: 0x6e7584, h: 0xd5dbe5, v: visor, V: 0xffffff, a: 0xff5c5c, s: 0xa9b0be, S: 0x6e7584, c: body[0], C: body[1], k: body[2] };
164 }
165 case "critter":
166 return { ...base, q: 0xde7a4a, Q: 0xb35a30, s: 0xde7a4a, S: 0xb35a30, h: 0xf09a6a, c: 0xde7a4a, C: 0xb35a30, k: 0xf09a6a };
167 case "cat":
168 case "dog":
169 case "owl": {
170 const [f, d] = look.fur;
171 return { ...base, F: f, D: d, W: f, p: 0xf0a0b4, n: 0x3a2a2a, m: 0x5a3030, y: 0xf2c94c, b: 0xe0a030, s: f, S: d, h: mix(f, 0xffffff, 0.25) };
172 }
173 default:
174 return { ...base, s: look.skin[0], S: look.skin[1], h: look.skin[2], m: 0x9a4a4a, r: look.hair[0], R: look.hair[1] };
175 }
176}
177
178// ---- time and light -----------------------------------------------------------
179
180/** The hour the room is lit for: the clock, or noon/midnight when fixed. */
181export function lightingHour(lighting, nowMs) {
182 if (lighting === "day") return 12;
183 if (lighting === "night") return 23;
184 const d = new Date(nowMs);
185 return d.getHours() + d.getMinutes() / 60;
186}
187
188function darknessAt(hour) {
189 const sky = skyAt(hour);
190 if (hour >= 7.5 && hour <= 17.5) return 0;
191 if (sky.isNight) return 0.55;
192 return 0.25;
193}
194
195// ---- plans --------------------------------------------------------------------
196
197/**
198 * Lays out a scene: where every desk sits, where its name tag goes, and the
199 * key its background is cached under. `seats` is the desk list in seat order;
200 * a `null` is an empty seat. `cols` x `rows` is the drawing size in cells.
201 */
202export function planScene(layout, seats, cols, rows, opts = {}) {
203 const W = Math.max(20, Math.min(512, Math.floor(cols)));
204 const Hrows = Math.max(6, Math.min(256, Math.floor(rows)));
205 // A room needs some width; a narrow pane gets the grid instead.
206 if (layout === "tiles") return planTiles(seats, W, Hrows, opts);
207 if (layout === "grid" || ((layout === "office" || layout === "war-room") && W < 48)) return planGrid(seats, W, Hrows, opts);
208 if (layout === "strip") return planStrip(seats, W, opts);
209 const plan = planRoomScaled(layout === "war-room" ? planWarRoom : planOffice, seats, W, Hrows, opts);
210 if (opts.hybrid) markHybrid(plan);
211 // A Raster is at most 256 rows: past that the room is cut, tags with it.
212 if (plan.rows > 256) {
213 plan.rows = 256;
214 plan.tags = plan.tags.filter((t) => t.top < 256);
215 }
216 return plan;
217}
218
219/**
220 * The hybrid view: which desks get their person as a picture, and the box of
221 * cells each picture covers, from the top of the chair to the desk top. A
222 * box smaller than a 16-pixel person gets a finer picture (office-pictures).
223 */
224function markHybrid(plan) {
225 const k = plan.k ?? 1;
226 const portraits = [];
227 plan.desks.forEach((d, i) => {
228 if (!d.seat || d.seat.kind === "shell" || portraits.length >= MAX_PORTRAITS) return;
229 let box;
230 if (plan.layout === "war-room") {
231 if (d.side !== "far") return;
232 box = plan.size === "small" ? { x: d.x - 1, y: d.y, w: 14, h: 12 } : { x: d.x - 1, y: d.y - 2, w: 18, h: 20 };
233 } else {
234 box = plan.size === "small" ? { x: d.x + 6, y: d.y, w: 12, h: 10 } : { x: d.x + 11, y: d.y, w: 18, h: 18 };
235 }
236 // Too small even for a finer picture (a 16-pixel person in under 6 x 3 cells).
237 if (box.w * k < 6 || box.h * k < 6) return;
238 d.isHybrid = true;
239 portraits.push({ index: i, left: box.x * k, top: (box.y * k) >> 1, columns: Math.min(255, box.w * k), rows: Math.min(255, (box.h * k) >> 1), isHybrid: true });
240 });
241 plan.portraits = portraits;
242 plan.isHybrid = portraits.length > 0;
243}
244
245// The room at the largest whole-number scale at which every desk fits: few
246// desks in a big pane get big desks, big names and big drag targets.
247function planRoomScaled(build, seats, W, Hrows, opts) {
248 let best = null;
249 for (const k of [3, 2, 1]) {
250 const w = Math.floor(W / k);
251 const h = Math.floor(Hrows / k);
252 if (k > 1 && (w < 60 || h < 14)) continue;
253 const inner = build(seats, w, h, opts);
254 const fits = inner.rows <= h;
255 const size = (inner.size === "small" ? 18 : 30) * k;
256 if ((fits || k === 1) && (!best || size > best.size)) best = { inner, k, size };
257 }
258 const { inner, k } = best;
259 if (k === 1) return { ...inner, k: 1 };
260 const tags = inner.tags.map((t) => {
261 const desk = inner.seats[t.index];
262 const width = t.width * k;
263 return { ...t, left: t.left * k, top: t.top * k + k - 1, width, ...tagLines(desk, t.index, width), second: undefined };
264 });
265 return { ...inner, k, logicalColumns: inner.columns, logicalRows: inner.rows, columns: Math.min(512, inner.columns * k), rows: inner.rows * k, tags };
266}
267
268function planGrid(seats, W, Hrows, opts) {
269 const g = layoutFor(seats.length, W, Hrows, { isFull: opts.isFull });
270 const tags = [];
271 seats.slice(0, g.shown).forEach((desk, i) => {
272 if (!desk) return;
273 const lines = tagLines(desk, i, g.tileW);
274 tags.push({
275 index: i,
276 left: (i % g.perRow) * (g.tileW + 1),
277 top: Math.floor(i / g.perRow) * g.tileH + g.artRows,
278 width: g.tileW,
279 ...lines,
280 });
281 });
282 return { layout: "grid", grid: g, columns: g.columns, rows: g.rows, seats, tags, opts };
283}
284
285// At most this many tile pictures in one drawing; past it, cells characters.
286export const MAX_PORTRAITS = 24;
287
288/**
289 * Tiles: the grid's cards, each with a box where the character's own little
290 * picture goes (`portraits`), in cells. Where pictures cannot be drawn
291 * (`opts.portraits === false`) it is the grid with bold characters.
292 */
293function planTiles(seats, W, Hrows, opts) {
294 const base = planGrid(seats, W, Hrows, opts);
295 if (opts.portraits === false) return { ...base, layout: "grid", isTiles: true };
296 const g = base.grid;
297 const box = { compact: [0, 0, 4, 3], normal: [1, 1, 8, 6], large: [2, 2, 16, 12] }[g.scale.name];
298 const portraits = [];
299 const marked = seats.map((desk, i) => {
300 if (!desk || desk.kind === "shell" || i >= g.shown || portraits.length >= MAX_PORTRAITS) return desk;
301 const [dx, dy, cols, rows] = box;
302 portraits.push({ index: i, left: (i % g.perRow) * (g.tileW + 1) + dx, top: Math.floor(i / g.perRow) * g.tileH + dy, columns: Math.min(255, cols), rows: Math.min(255, rows) });
303 return { ...desk, hasPortrait: true };
304 });
305 return { ...base, seats: marked, portraits, isTiles: true };
306}
307
308function planStrip(seats, W, opts) {
309 const n = Math.max(1, seats.length);
310 if (n * 11 - 1 <= W) {
311 const tile = 10;
312 const grid = { scale: { name: "compact", pw: 10, ph: 8, k: 1, tagRows: 2 }, tileW: tile, tileH: 6, artRows: 4, shown: n, perRow: n, deskRows: 1, columns: n * 11 - 1, rows: 6 };
313 const tags = seats.map((desk, i) => (desk ? { index: i, left: i * 11, top: 4, width: tile, ...tagLines(desk, i, tile) } : null)).filter(Boolean);
314 return { layout: "grid", grid, columns: grid.columns, rows: 6, seats, tags, opts };
315 }
316 // Too many for desks: a pip per session, a head over its state bar.
317 const per = Math.max(1, Math.min(n, Math.floor((W + 1) / 7)));
318 const lines = Math.ceil(n / per);
319 const tags = seats
320 .map((desk, i) => (desk ? { index: i, left: (i % per) * 7, top: Math.floor(i / per) * 5 + 4, width: 6, hotkey: HOTKEYS[i], label: desk.name.slice(0, 3), first: `${HOTKEYS[i] ?? " "}:${desk.name.slice(0, 4)}` } : null))
321 .filter(Boolean);
322 return { layout: "pips", per, columns: per * 7 - 1, rows: lines * 5, seats, tags, opts };
323}
324
325// Workstation sizes, in pixels: the desk sprite and the slot it sits in.
326const WS = {
327 regular: { w: 30, h: 28, slotW: 32, slotH: 32 },
328 small: { w: 18, h: 16, slotW: 21, slotH: 20 },
329};
330
331function planOffice(seats, W, Hrows, opts) {
332 const H = Hrows * 2;
333 const wallH = Math.max(18, Math.min(46, Math.round(H * 0.3))) & ~1;
334 const floorTop = wallH + 6;
335 const floorH = H - floorTop - 2;
336 const n = Math.max(1, seats.length);
337 const tries = [];
338 for (const size of ["regular", "small"]) {
339 for (const style of n <= 6 ? ["bench", "rows"] : ["pods", "rows"]) tries.push([size, style]);
340 }
341 let pick = null;
342 for (const [size, style] of tries) {
343 const p = arrange(n, size, style, W, floorH);
344 if (p.fits) {
345 pick = p;
346 break;
347 }
348 pick = pick ?? p;
349 }
350 if (!pick.fits) pick = arrange(n, "small", "rows", W, floorH);
351 const rowsNeeded = Math.max(Hrows, Math.ceil((floorTop + pick.height + 2) / 2));
352 // Centre the arrangement in the floor, leaving the aisle at the back.
353 const drop = Math.max(0, Math.floor((floorH - pick.height) / 2)) & ~1;
354 const desks = pick.spots.map((s, i) => ({ ...s, y: s.y + floorTop + drop, seat: seats[i] ?? null }));
355 const tags = [];
356 desks.forEach((d, i) => {
357 if (!d.seat) return;
358 const width = Math.max(6, pick.step - 1);
359 const left = Math.max(0, Math.min(W - width, d.x - Math.floor((pick.step - pick.ws.w) / 2)));
360 const lines = tagLines(d.seat, i, width);
361 tags.push({ index: i, left, top: (d.y + pick.ws.h) >> 1, width, ...lines, second: undefined });
362 });
363 const zones = zonesFor(desks, pick.ws, opts);
364 return {
365 layout: "office",
366 size: pick.size,
367 style: pick.style,
368 ws: pick.ws,
369 columns: W,
370 rows: rowsNeeded,
371 wallH,
372 desks,
373 zones,
374 tags,
375 seats,
376 opts,
377 };
378}
379
380// Places `n` workstations in a floor `W` x `floorH` pixels: a long bench, rows,
381// or pods of four with an aisle between pods.
382function arrange(n, size, style, W, floorH) {
383 const ws = WS[size];
384 const spots = [];
385 if (style === "pods") {
386 const podW = ws.slotW * 2 + 8;
387 const podH = ws.slotH * 2;
388 const podsPerRow = Math.max(1, Math.floor((W - 4) / podW));
389 const pods = Math.ceil(n / 4);
390 const podRows = Math.ceil(pods / podsPerRow);
391 const podGap = podRows > 1 ? Math.max(4, Math.min(12, Math.floor((floorH - podRows * podH) / (podRows - 1)))) & ~1 : 4;
392 const usedW = Math.min(pods, podsPerRow) * podW;
393 const x0 = Math.floor((W - usedW) / 2) + 4;
394 for (let i = 0; i < n; i++) {
395 const p = Math.floor(i / 4);
396 const q = i % 4;
397 const px = x0 + (p % podsPerRow) * podW + (q % 2) * ws.slotW;
398 const py = Math.floor(p / podsPerRow) * (podH + podGap) + Math.floor(q / 2) * ws.slotH;
399 spots.push({ x: px, y: py & ~1, pod: p });
400 }
401 const height = podRows * podH + (podRows - 1) * podGap;
402 return { size, style, ws, step: ws.slotW, spots, height, fits: height <= floorH && podW <= W };
403 }
404 const gap = style === "bench" ? 0 : ws.slotW - ws.w;
405 const step = ws.w + gap;
406 const perRow = Math.max(1, Math.floor((W - 4 + gap) / step));
407 const rowsN = Math.ceil(n / perRow);
408 const inRow = Math.min(n, perRow);
409 const x0 = Math.floor((W - (inRow * step - gap)) / 2);
410 const vGap = rowsN > 1 ? Math.max(0, Math.min(10, Math.floor((floorH - rowsN * ws.slotH) / (rowsN - 1)))) & ~1 : 0;
411 for (let i = 0; i < n; i++) {
412 spots.push({ x: x0 + (i % perRow) * step, y: (Math.floor(i / perRow) * (ws.slotH + vGap)) & ~1, pod: Math.floor(i / perRow) });
413 }
414 const height = rowsN * ws.slotH + (rowsN - 1) * vGap;
415 return { size, style, ws, step, spots, height, fits: height <= floorH };
416}
417
418// Rugs: one per pod or bench row; with seating by project or workspace, one
419// per group, labelled.
420function zonesFor(desks, ws, opts) {
421 const groups = new Map();
422 desks.forEach((d) => {
423 const key = opts.zoneBy && d.seat ? String(d.seat[opts.zoneBy] ?? "") : `pod${d.pod}`;
424 if (!groups.has(key)) groups.set(key, []);
425 groups.get(key).push(d);
426 });
427 let i = 0;
428 return [...groups.entries()].map(([key, list]) => {
429 const x0 = Math.min(...list.map((d) => d.x)) - 3;
430 const y0 = Math.min(...list.map((d) => d.y)) + Math.floor(ws.h * 0.45);
431 const x1 = Math.max(...list.map((d) => d.x + ws.w)) + 3;
432 const y1 = Math.max(...list.map((d) => d.y + ws.h)) + 1;
433 return { key, label: opts.zoneBy ? key : "", x: x0, y: y0, w: x1 - x0, h: y1 - y0, colour: i++ };
434 });
435}
436
437function planWarRoom(seats, W, Hrows, opts) {
438 const H = Hrows * 2;
439 const wallH = Math.max(18, Math.min(46, Math.round(H * 0.3))) & ~1;
440 const n = Math.max(1, seats.length);
441 const size = n > 16 || W < 120 ? "small" : "regular";
442 const seatW = size === "regular" ? 24 : 14;
443 const perSide = Math.max(1, Math.min(Math.ceil(n / 2), Math.floor((W - 16) / seatW)));
444 const tables = Math.ceil(n / (perSide * 2));
445 const tableW = perSide * seatW + 10;
446 const farH = size === "regular" ? 18 : 12;
447 const nearH = size === "regular" ? 20 : 12;
448 const tableH = size === "regular" ? 12 : 8;
449 const blockH = farH + tableH + nearH + 6;
450 const top = wallH + 6;
451 const tx = Math.floor((W - tableW) / 2);
452 const desks = [];
453 for (let i = 0; i < n; i++) {
454 const t = Math.floor(i / (perSide * 2));
455 const j = i % (perSide * 2);
456 const isFar = j < perSide;
457 const k = isFar ? j : j - perSide;
458 const by = top + t * blockH;
459 desks.push({
460 x: tx + 5 + k * seatW,
461 y: (isFar ? by : by + farH + tableH) & ~1,
462 side: isFar ? "far" : "near",
463 table: t,
464 seat: seats[i] ?? null,
465 });
466 }
467 const tags = [];
468 desks.forEach((d, i) => {
469 if (!d.seat) return;
470 const width = seatW - 1;
471 const y = d.side === "far" ? d.y - 2 : d.y + nearH;
472 tags.push({ index: i, left: Math.max(0, d.x - 1), top: Math.max(0, y >> 1), width, ...tagLines(d.seat, i, width), second: undefined });
473 });
474 const rowsNeeded = Math.max(Hrows, Math.ceil((top + tables * blockH + 4) / 2));
475 return {
476 layout: "war-room",
477 size,
478 columns: W,
479 rows: rowsNeeded,
480 wallH,
481 seatW,
482 farH,
483 nearH,
484 tableH,
485 tables: Array.from({ length: tables }, (_, t) => ({ x: tx, y: top + t * blockH + farH, w: tableW, h: tableH })),
486 desks,
487 tags,
488 seats,
489 opts,
490 };
491}
492
493// ---- painting ---------------------------------------------------------------
494
495/**
496 * One frame of a plan as `[codePoint, fg, bg]` words. `nowMs` drives the
497 * clock, the sky and the after-a-change animations; `tick` the rest.
498 */
499export function paintPlan(plan, tick, nowMs = Date.now()) {
500 const words = paintBase(plan, tick, nowMs);
501 // While a desk is dragged: its own place dimmed, the place under the pointer lit.
502 if (plan.drag) {
503 const hits = hitRects(plan);
504 const from = hits.find((h) => h.index === plan.drag.from);
505 const over = hits.find((h) => h.index === plan.drag.over);
506 if (from) outline(words, plan.columns, plan.rows, from, 0x8a8494);
507 if (over && over !== from) outline(words, plan.columns, plan.rows, over, 0xffd23f);
508 }
509 return words;
510}
511
512/**
513 * Where each seat sits, in cells: `{ index, left, top, width, height }`,
514 * art and name tag together, empty seats included. What a pointer is
515 * tested against.
516 */
517export function hitRects(plan) {
518 if (plan.hits) return plan.hits;
519 const out = [];
520 if (plan.layout === "grid") {
521 const g = plan.grid;
522 for (let i = 0; i < Math.min(plan.seats.length, g.shown); i++) {
523 out.push({ index: i, left: (i % g.perRow) * (g.tileW + 1), top: Math.floor(i / g.perRow) * g.tileH, width: g.tileW, height: g.tileH });
524 }
525 } else if (plan.layout === "pips") {
526 plan.seats.forEach((_, i) => out.push({ index: i, left: (i % plan.per) * 7, top: Math.floor(i / plan.per) * 5, width: 6, height: 5 }));
527 } else if (plan.layout === "war-room") {
528 const k = plan.k ?? 1;
529 plan.desks.forEach((d, i) => {
530 const top = d.side === "far" ? Math.max(0, (d.y >> 1) - 2) : (d.y >> 1);
531 out.push({ index: i, left: Math.max(0, d.x - 1) * k, top: top * k, width: (plan.seatW - 1) * k, height: (plan.size === "small" ? 8 : 12) * k });
532 });
533 } else {
534 const k = plan.k ?? 1;
535 // The whole workstation and its tag: a big target to drag.
536 plan.desks.forEach((d, i) => out.push({ index: i, left: d.x * k, top: (d.y >> 1) * k, width: plan.ws.w * k, height: ((plan.ws.h >> 1) + 1) * k }));
537 }
538 plan.hits = out.map((h) => ({ ...h, width: Math.max(1, Math.min(h.width, plan.columns - h.left)), height: Math.max(1, Math.min(h.height, plan.rows - h.top)) }));
539 return plan.hits;
540}
541
542/** The seat under a cell, or -1. */
543export function seatAt(plan, x, y) {
544 const h = hitRects(plan).find((r) => x >= r.left && x < r.left + r.width && y >= r.top && y < r.top + r.height);
545 return h ? h.index : -1;
546}
547
548// A one-pixel frame round a rect of cells, drawn into packed words.
549function outline(words, cols, rows, r, colour) {
550 const put = (c, row, part) => {
551 if (c < 0 || row < 0 || c >= cols || row >= rows) return;
552 const at = (row * cols + c) * 3;
553 if (words[at] !== UPPER_HALF) {
554 words[at + 2] = colour;
555 return;
556 }
557 if (part !== "bottom") words[at + 1] = colour;
558 if (part !== "top") words[at + 2] = colour;
559 };
560 for (let c = r.left; c < r.left + r.width; c++) {
561 put(c, r.top, "top");
562 put(c, r.top + r.height - 1, "bottom");
563 }
564 for (let row = r.top; row < r.top + r.height; row++) {
565 put(r.left, row, "both");
566 put(r.left + r.width - 1, row, "both");
567 }
568}
569
570function paintBase(plan, tick, nowMs) {
571 if (plan.layout === "grid") return paintWords(plan.grid, plan.seats.slice(0, plan.grid.shown), tick, plan.opts);
572 const canvas = new Canvas(plan.columns, plan.rows * 2, 0);
573 if (plan.layout === "pips") {
574 paintPips(canvas, plan, tick);
575 return canvas.pack();
576 }
577 const final = paintRoomCanvas(plan, tick, nowMs);
578 paintBubbles(final, plan);
579 paintTags(final, plan);
580 return final.pack();
581}
582
583/**
584 * The room's picture without its words, at the plan's full size: drawn at
585 * the logical size, then each pixel made a k x k block. What the hybrid
586 * view crops a character's background from.
587 */
588export function paintRoomCanvas(plan, tick, nowMs) {
589 const k = plan.k ?? 1;
590 const lw = plan.logicalColumns ?? plan.columns;
591 const lh = plan.logicalRows ?? plan.rows;
592 const canvas = new Canvas(lw, lh * 2, 0);
593 const hour = lightingHour(plan.opts.lighting, nowMs);
594 const bgKey = `${plan.layout}:${lw}x${lh}:${Math.floor(hour * 4)}:${plan.opts.palette}:${plan.desks.map((d) => (d.seat ? `${d.x},${d.y}` : "-")).join(";")}`;
595 if (!plan.bg || plan.bgKey !== bgKey) {
596 plan.bg = new Canvas(lw, lh * 2, 0);
597 paintRoom(plan.bg, plan, hour);
598 plan.bgKey = bgKey;
599 }
600 canvas.copyFrom(plan.bg);
601 canvas.dark = darknessAt(hour) * 0.8;
602 const t = plan.opts.reducedMotion ? Math.floor(tick / 4) : tick;
603 paintRoomLife(canvas, plan, t, nowMs, hour);
604 if (plan.layout === "war-room") paintWarRoomPeople(canvas, plan, t, nowMs);
605 else for (const d of plan.desks) paintWorkstation(canvas, plan, d, t, nowMs);
606 return k === 1 ? canvas : upscaleCanvas(canvas, k, plan.columns, plan.rows);
607}
608
609// Each pixel a k x k block; a character over a cell lands in the middle of its block.
610function upscaleCanvas(src, k, columns, rows) {
611 const out = new Canvas(columns, rows * 2, 0);
612 for (let y = 0; y < out.h; y++) {
613 const sy = Math.min(src.h - 1, Math.floor(y / k));
614 for (let x = 0; x < out.w; x++) out.px[y * out.w + x] = src.px[sy * src.w + Math.min(src.w - 1, Math.floor(x / k))];
615 }
616 for (const [i, g] of src.glyphs) {
617 const col = (i % src.w) * k + Math.floor(k / 2);
618 const row = Math.floor(i / src.w) * k + Math.floor(k / 2);
619 out.glyph(col, row, g.ch, g.fg, g.bg);
620 }
621 return out;
622}
623
624// The bubbles' words, in cells over the picture: what each session is doing.
625const BUBBLE_TONES = {
626 alert: [0xffffff, 0xd8352a],
627 work: [0x1e1a26, 0xf4f2ee],
628 party: [0x1e1a26, 0xffd23f],
629 quiet: [0x4a4658, 0xc8c4d4],
630};
631
632export function bubbleSpots(plan) {
633 const k = plan.k ?? 1;
634 const out = [];
635 if (plan.layout === "pips" || plan.layout === "grid") return out;
636 plan.desks.forEach((d, i) => {
637 const bubble = d.seat && bubbleFor(d.seat);
638 if (!bubble) return;
639 if (plan.layout === "war-room" && d.side === "near" && d.seat.status !== "blocked") return;
640 const isSmall = plan.size === "small";
641 const headX = plan.layout === "war-room" ? d.x + 2 : d.x + (isSmall ? 8 : 13);
642 const headY = plan.layout === "war-room" ? (d.side === "far" ? d.y - 4 : d.y - 2) : d.y - 1;
643 // Above a hybrid picture, never under it.
644 const box = plan.portraits?.find((p) => p.index === i);
645 const row = Math.floor((headY * k) / 2);
646 out.push({ index: i, col: Math.max(0, headX * k), row: Math.max(0, box ? Math.min(row, box.top - 1) : row), ...bubble });
647 });
648 return out;
649}
650
651function paintBubbles(c, plan) {
652 for (const b of bubbleSpots(plan)) {
653 const [fg, bg] = BUBBLE_TONES[b.tone];
654 const text = ` ${b.text} `;
655 const col = Math.max(0, Math.min(c.w - text.length, b.col));
656 c.text(col, b.row, text, fg, bg);
657 }
658}
659
660function pal(plan) {
661 return PALETTES[plan.opts.palette] ?? PALETTES.nightshade;
662}
663
664// ---- the room ---------------------------------------------------------------
665
666function paintRoom(c, plan, hour) {
667 const P = pal(plan);
668 const W = c.w;
669 const H = c.h;
670 const wallH = plan.wallH;
671 const sky = skyAt(hour);
672 c.dark = darknessAt(hour);
673
674 // Back wall: a soft vertical gradient, a dado rail, the baseboard.
675 for (let y = 0; y < wallH; y++) {
676 const col = mix(P.wallShade, P.wall, Math.min(1, y / (wallH * 0.7)));
677 for (let x = 0; x < W; x++) c.set(x, y, (x + y * 3) % 17 === 0 ? mix(col, P.wallShade, 0.3) : col);
678 }
679 for (let x = 0; x < W; x++) {
680 c.set(x, Math.floor(wallH * 0.62), P.trim);
681 c.set(x, wallH - 3, P.trim);
682 c.set(x, wallH - 2, P.baseboard);
683 c.set(x, wallH - 1, P.baseboard);
684 }
685
686 // Floor: wooden planks in perspective toward a point above the room.
687 const vx = W / 2;
688 const vy = wallH - (H - wallH) * 1.6;
689 let y = wallH;
690 let row = 0;
691 while (y < H) {
692 const depth = (y - wallH) / Math.max(1, H - wallH);
693 const ph = 2 + Math.round(depth * 3);
694 for (let j = 0; j < ph && y + j < H; j++) {
695 const yy = y + j;
696 // Plank seams: lines from the vanishing point, staggered per row.
697 for (let x = 0; x < W; x++) {
698 const fx = vx + ((x - vx) * (wallH - vy)) / (yy - vy);
699 const seamW = 9;
700 const lane = Math.floor((fx + (row % 2) * (seamW / 2)) / seamW);
701 const tone = hash(`${lane}:${row}`) % 3;
702 let col = tone === 0 ? P.floorA : tone === 1 ? P.floorB : mix(P.floorA, P.floorB, 0.5);
703 const onSeam = Math.abs(((fx + (row % 2) * (seamW / 2)) % seamW)) < 0.6;
704 if (onSeam || j === ph - 1) col = P.floorSeam;
705 else if (j === 0) col = mix(col, 0xffffff, 0.06);
706 c.set(x, yy, mix(col, P.dark, 0.25 * (1 - depth)));
707 }
708 }
709 y += ph;
710 row++;
711 }
712 // Skirting shadow where floor meets wall.
713 for (let x = 0; x < W; x++) {
714 c.tint(x, wallH, P.dark, 0.5);
715 c.tint(x, wallH + 1, P.dark, 0.25);
716 }
717
718 // Windows, with the sky of the hour.
719 const props = wallProps(plan);
720 for (const w of props.windows) paintWindow(c, w, sky, hour, P);
721 // Sun patches below the windows by day.
722 if (!sky.isNight && hour > 7 && hour < 18) {
723 const slant = (hour - 12.5) * 1.2;
724 for (const w of props.windows) {
725 for (let j = 0; j < Math.min(14, H - wallH); j++) {
726 for (let i = 0; i < w.w; i++) c.tint(Math.round(w.x + i + slant * j * 0.4), wallH + 2 + j, 0xfff1c8, 0.16 * (1 - j / 14));
727 }
728 }
729 }
730
731 // Wall furniture.
732 if (props.shelf) paintBookshelf(c, props.shelf, P);
733 if (props.poster) paintPoster(c, props.poster, P);
734 if (props.clock) paintClockFace(c, props.clock, P);
735 if (props.rack) paintRack(c, props.rack, P);
736 if (props.cooler) c.sprite(COOLER, props.cooler.x, props.cooler.y, { o: OUTLINE, w: 0x8fd3ff, W: 0xc8ecff, x: 0xdedede, X: 0xb4b4b4, b: 0x3b82f6, r: 0xef4444 });
737 for (const p of props.plants) c.sprite(PLANT, p.x, p.y, { o: OUTLINE, g: 0x4f9a4f, G: 0x3a7a3e, L: 0x7cc46c, P: 0xb8693e, p: 0xd88a5a });
738
739 // Rugs (pods, benches, or one per workspace or project).
740 if (plan.layout === "office") {
741 for (const z of plan.zones) {
742 const col = P.rugs[z.colour % P.rugs.length];
743 for (let j = 0; j < z.h; j++) {
744 for (let i = 0; i < z.w; i++) {
745 const edge = i === 0 || j === 0 || i === z.w - 1 || j === z.h - 1;
746 const inner = i === 2 || j === 2 || i === z.w - 3 || j === z.h - 3;
747 c.set(z.x + i, z.y + j, edge ? mix(col, P.dark, 0.4) : inner ? mix(col, 0xffffff, 0.15) : col);
748 }
749 }
750 if (z.label) c.text(Math.max(0, z.x + 2), (z.y + z.h + 1) >> 1, ` ${z.label.slice(0, Math.max(1, z.w - 4))} `, 0xe8e6ef, mix(col, P.dark, 0.4));
751 }
752 // Desks' static parts and their floor shadows.
753 for (const d of plan.desks) paintDeskStatic(c, plan, d, P);
754 } else {
755 for (const t of plan.tables) paintTable(c, t, P);
756 }
757
758 // Ceiling lights: warm pools over the desks, stronger at night.
759 const pools = plan.layout === "office" ? plan.zones.map((z) => [z.x + z.w / 2, z.y + z.h / 2, Math.max(z.w, z.h) * 0.55]) : plan.tables.map((t) => [t.x + t.w / 2, t.y + t.h / 2, t.w * 0.45]);
760 const strength = sky.isNight ? 0.3 : 0.08;
761 for (const [x, yc, r] of pools) c.glow(x, yc, r, 0xffd9a0, strength);
762 c.dark = 0;
763}
764
765// Where the windows and the wall furniture go for this width.
766function wallProps(plan) {
767 const W = plan.columns;
768 const wallH = plan.wallH;
769 const out = { windows: [], plants: [] };
770 const winH = Math.max(8, Math.floor(wallH * 0.5));
771 const winY = Math.max(2, Math.floor(wallH * 0.12));
772 // Left: bookshelf; right: server rack; middle: windows around a clock.
773 const shelfW = W >= 70 ? 22 : 0;
774 const rackW = W >= 50 ? 11 : 0;
775 if (shelfW) out.shelf = { x: 4, y: Math.max(1, wallH - 3 - Math.floor(wallH * 0.75)), w: shelfW, h: Math.floor(wallH * 0.75) };
776 if (rackW) out.rack = { x: W - rackW - 3, y: Math.max(1, wallH - Math.floor(wallH * 0.9) + 2), w: rackW, h: Math.floor(wallH * 0.9) + 2 };
777 const left = 4 + shelfW + 6;
778 const right = W - rackW - 8 - (W >= 90 ? 14 : 0);
779 const span = right - left;
780 const nWin = Math.max(1, Math.min(4, Math.floor(span / 46)));
781 const winW = Math.min(34, Math.floor(span / nWin) - 10);
782 const clockAt = W >= 60 ? Math.floor(nWin / 2) : -1;
783 for (let i = 0; i < nWin; i++) {
784 const slot = left + Math.floor((span * (i + 0.5)) / nWin);
785 out.windows.push({ x: slot - Math.floor(winW / 2) + (clockAt >= 0 && i >= clockAt ? 6 : clockAt >= 0 ? -6 : 0), y: winY, w: Math.max(8, winW), h: winH });
786 }
787 if (clockAt >= 0) out.clock = { x: left + Math.floor((span * clockAt) / nWin), y: winY + Math.floor(winH / 2), r: 4 };
788 if (W >= 90) out.poster = { x: right + 3, y: winY + 1, w: 10, h: 13 };
789 if (W >= 60) out.cooler = { x: 4 + shelfW + 1, y: wallH - 9 };
790 out.plants.push({ x: 0, y: wallH - 8 });
791 if (W >= 50) out.plants.push({ x: W - rackW - 15, y: wallH - 8 });
792 plan.props = out;
793 return out;
794}
795
796function paintWindow(c, w, sky, hour, P) {
797 c.rect(w.x - 1, w.y - 1, w.w + 2, w.h + 3, P.trim);
798 for (let j = 0; j < w.h; j++) {
799 const col = mix(sky.top, sky.bottom, j / w.h);
800 for (let i = 0; i < w.w; i++) c.set(w.x + i, w.y + j, col, true);
801 }
802 // City skyline at the bottom of the window.
803 for (let i = 0; i < w.w; i++) {
804 const bh = 2 + (hash(`b${Math.floor((w.x + i) / 3)}`) % Math.max(2, Math.floor(w.h / 3)));
805 for (let j = 0; j < bh; j++) {
806 const y = w.y + w.h - 1 - j;
807 const lit = sky.isNight && hash(`l${w.x + i},${j}`) % 7 === 0;
808 c.set(w.x + i, y, lit ? 0xffd27a : mix(sky.bottom, 0x1a1a2e, sky.isNight ? 0.75 : 0.45), true);
809 }
810 }
811 if (sky.isNight) {
812 for (let k = 0; k < Math.floor(w.w / 4); k++) {
813 const h = hash(`s${w.x},${k}`);
814 c.set(w.x + (h % w.w), w.y + ((h >> 8) % Math.max(1, w.h - 5)), 0xf2f0ff, true);
815 }
816 if (w.x % 3 === 0) {
817 c.rect(w.x + w.w - 6, w.y + 2, 3, 3, 0xf4efd0, true);
818 c.set(w.x + w.w - 6, w.y + 2, mix(sky.top, 0xf4efd0, 0.3), true);
819 }
820 } else if (hour >= 6 && hour < 19) {
821 // Clouds.
822 for (let k = 0; k < 2; k++) {
823 const h = hash(`c${w.x},${k}`);
824 const cx = w.x + (h % Math.max(1, w.w - 8));
825 const cy = w.y + 2 + ((h >> 6) % Math.max(1, Math.floor(w.h / 3)));
826 c.rect(cx + 1, cy, 4, 1, 0xffffff, true);
827 c.rect(cx, cy + 1, 7, 1, 0xf2f6ff, true);
828 }
829 }
830 // Mullions and a sill.
831 const mid = w.x + Math.floor(w.w / 2);
832 for (let j = 0; j < w.h; j++) c.set(mid, w.y + j, P.trim);
833 for (let i = 0; i < w.w; i++) c.set(w.x + i, w.y + Math.floor(w.h / 2), P.trim);
834 for (let i = -2; i < w.w + 2; i++) {
835 c.set(w.x + i, w.y + w.h + 1, mix(P.trim, 0xffffff, 0.2));
836 c.set(w.x + i, w.y + w.h + 2, P.baseboard);
837 }
838}
839
840function paintBookshelf(c, s, P) {
841 c.rect(s.x, s.y, s.w, s.h, P.woodDark);
842 c.rect(s.x + 1, s.y + 1, s.w - 2, s.h - 2, mix(P.woodDark, 0x000000, 0.35));
843 const shelves = Math.max(2, Math.floor(s.h / 6));
844 const sh = Math.floor((s.h - 2) / shelves);
845 const spines = [0xb5442b, 0x2f6f9a, 0xd9b25e, 0x4f8a4f, 0x7a4f9a, 0xe0e0d0, 0x9a6a3a, 0x3a3a4a];
846 for (let k = 0; k < shelves; k++) {
847 const by = s.y + 1 + (k + 1) * sh - 1;
848 c.rect(s.x + 1, by, s.w - 2, 1, P.wood);
849 let x = s.x + 2;
850 while (x < s.x + s.w - 3) {
851 const h = hash(`book${s.x},${k},${x}`);
852 const bw = 1 + (h % 2);
853 const bh = Math.max(2, sh - 1 - ((h >> 3) % 3));
854 if (h % 11 === 0) {
855 x += 2;
856 continue;
857 }
858 const col = spines[(h >> 5) % spines.length];
859 c.rect(x, by - bh, bw, bh, col);
860 c.set(x, by - bh, mix(col, 0xffffff, 0.3));
861 x += bw;
862 }
863 }
864}
865
866function paintPoster(c, p, P) {
867 c.rect(p.x, p.y, p.w, p.h, P.trim);
868 c.rect(p.x + 1, p.y + 1, p.w - 2, p.h - 2, 0x24222c);
869 // A small pixel mascot: the orange critter.
870 c.sprite(HEADS.critter, p.x, p.y + 2, { o: 0x24222c, q: 0xde7a4a, Q: 0xb35a30, e: 0x1a1420 });
871}
872
873function paintClockFace(c, k, P) {
874 for (let y = -k.r - 1; y <= k.r + 1; y++) {
875 for (let x = -k.r - 1; x <= k.r + 1; x++) {
876 const d = Math.hypot(x, y);
877 if (d <= k.r + 0.6) c.set(k.x + x, k.y + y, d > k.r - 0.6 ? P.dark : 0xf2efe6);
878 }
879 }
880}
881
882function paintRack(c, r, P) {
883 c.rect(r.x, r.y, r.w, r.h, 0x1b1c22);
884 c.rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, 0x26272f);
885 for (let y = r.y + 2; y < r.y + r.h - 2; y += 3) {
886 c.rect(r.x + 2, y, r.w - 4, 2, 0x31333d);
887 c.set(r.x + 2, y, 0x454857);
888 }
889 c.rect(r.x, r.y + r.h, r.w, 1, P.dark);
890}
891
892function paintTable(c, t, P) {
893 c.rect(t.x + 2, t.y + t.h, t.w - 4, 3, mix(P.dark, 0x000000, 0.2));
894 c.rect(t.x, t.y, t.w, t.h, P.woodTop);
895 for (let i = 0; i < t.w; i++) {
896 c.set(t.x + i, t.y, P.woodLight);
897 c.set(t.x + i, t.y + t.h - 1, P.woodDark);
898 }
899 for (let j = 1; j < t.h - 1; j++) {
900 c.set(t.x, t.y + j, P.woodDark);
901 c.set(t.x + t.w - 1, t.y + j, P.woodDark);
902 for (let i = 4; i < t.w - 4; i += 11) c.set(t.x + i + (j % 3), t.y + j, mix(P.woodTop, P.woodDark, 0.25));
903 }
904 // Papers, a pen pot and a plant on the table.
905 for (let k = 0; k < Math.floor(t.w / 30); k++) {
906 const h = hash(`paper${t.x},${t.y},${k}`);
907 const px = t.x + 6 + (h % Math.max(1, t.w - 14));
908 const py = t.y + 4 + ((h >> 6) % Math.max(1, t.h - 7));
909 c.rect(px, py, 3, 2, 0xece8de);
910 c.set(px + 2, py + 1, 0xc9c3b4);
911 }
912 const mid = t.x + Math.floor(t.w / 2);
913 c.rect(mid - 1, t.y + Math.floor(t.h / 2) - 1, 3, 2, 0xb8693e);
914 c.set(mid, t.y + Math.floor(t.h / 2) - 2, 0x4f9a4f);
915 c.set(mid - 1, t.y + Math.floor(t.h / 2) - 3, 0x7cc46c);
916 c.set(mid + 1, t.y + Math.floor(t.h / 2) - 3, 0x3a7a3e);
917}
918
919function paintDeskStatic(c, plan, d, P) {
920 const s = plan.size === "small";
921 const ws = plan.ws;
922 // Shadow under the desk.
923 for (let i = 1; i < ws.w - 1; i++) {
924 c.tint(d.x + i, d.y + ws.h, P.dark, 0.45);
925 c.tint(d.x + i, d.y + ws.h + 1, P.dark, 0.2);
926 }
927 if (!d.seat) {
928 // An empty seat: the desk alone, chair tucked in, screen off.
929 paintDesk(c, d.x, d.y, s, P, 0x555566);
930 paintMonitor(c, d.x, d.y, s, null, 0);
931 }
932}
933
934// ---- workstations -------------------------------------------------------------
935
936// The desk: a top in light wood seen a little from above, the front panel,
937// the state's accent stripe, legs.
938function paintDesk(c, x, y, isSmall, P, accent) {
939 const w = isSmall ? 18 : 30;
940 const top = isSmall ? 10 : 18;
941 const depth = isSmall ? 2 : 4;
942 const front = isSmall ? 3 : 5;
943 for (let i = 0; i < w; i++) {
944 for (let j = 0; j < depth; j++) c.set(x + i, y + top + j, j === depth - 1 ? P.woodLight : i === 0 || i === w - 1 ? P.wood : P.woodTop);
945 for (let j = 0; j < front; j++) c.set(x + i, y + top + depth + j, j === 1 ? accent : i === 0 || i === w - 1 ? P.woodDark : j === front - 1 ? P.woodDark : P.wood);
946 }
947 c.set(x, y + top, OUTLINE);
948 c.set(x + w - 1, y + top, OUTLINE);
949 const legY = y + top + depth + front;
950 for (let j = 0; j < (isSmall ? 1 : 2); j++) {
951 c.set(x + 1, legY + j, P.woodDark);
952 c.set(x + w - 2, legY + j, P.woodDark);
953 }
954}
955
956// A monitor three-quarters on, its screen showing the state.
957function paintMonitor(c, x, y, isSmall, desk, t, status) {
958 const sx = isSmall ? 1 : 2;
959 const sy = isSmall ? 3 : 7;
960 const sw = isSmall ? 5 : 8;
961 const sh = isSmall ? 6 : 9;
962 c.rect(x + sx - 1, y + sy - 1, sw + 2, sh + 2, 0x23252e);
963 for (let j = 0; j < sh + 1; j++) c.set(x + sx + sw + 1, y + sy + j, 0x15161c);
964 c.set(x + sx - 1, y + sy - 1, 0x3a3d4a);
965 c.rect(x + sx + Math.floor(sw / 2) - 1, y + sy + sh + 1, 2, 1, 0x2b2d36);
966 c.rect(x + sx + Math.floor(sw / 2) - 2, y + sy + sh + 2, 4, 1, 0x3a3d4a);
967 if (!desk) {
968 c.rect(x + sx, y + sy, sw, sh, 0x0b0c10);
969 c.set(x + sx + sw - 2, y + sy + 1, 0x262a36);
970 return;
971 }
972 screenPixels(c, x + sx, y + sy, sw, sh, desk, t, status);
973}
974
975export function screenPixels(c, x, y, w, h, desk, t, status) {
976 const isRobot = desk.kind === "process";
977 for (let j = 0; j < h; j++) {
978 for (let i = 0; i < w; i++) {
979 let col = 0x07080c;
980 if (isRobot) {
981 const ln = j + Math.floor(t / 2);
982 const hh = hash(`${desk.paneId}:${ln}`);
983 col = i < 1 + (hh % (w - 1)) ? [0x4ade80, 0x22c55e, 0x86efac][(hh >> 4) % 3] : 0x041008;
984 } else if (status === "working") {
985 const ln = j + t;
986 const hh = hash(`${desk.paneId}:${ln}`);
987 const indent = hh % 3;
988 const len = 1 + ((hh >> 3) % Math.max(1, w - indent - 1));
989 col = i >= indent && i < indent + len ? [0x7ab8ff, 0xb3d4ff, 0xc4a8ff, 0x7ef0a0, 0xffd27a, 0xff9ac8][(hh >> 7) % 6] : 0x0d1428;
990 } else if (status === "idle" || status === "unknown") {
991 const k = Math.floor(t / 3);
992 const dx = Math.abs((k % (2 * (w - 1))) - (w - 1));
993 const dy = Math.abs(((k + 3) % (2 * (h - 1))) - (h - 1));
994 col = i === dx && j === dy ? 0x9be7ff : 0x13254a;
995 } else if (status === "blocked") {
996 const on = t % 4 < 2;
997 const midX = Math.floor(w / 2);
998 const bang = i === midX && (j < h - 3 || j === h - 2);
999 col = bang ? 0xffffff : on ? 0xb91c1c : 0x6b1414;
1000 } else if (status === "celebrate") {
1001 col = CONFETTI[(i + j + t) % CONFETTI.length];
1002 }
1003 c.set(x + i, y + j, col, true);
1004 }
1005 }
1006}
1007
1008function screenColour(desk, status) {
1009 if (desk.kind === "process") return 0x4ade80;
1010 return { working: 0x6aa8ff, idle: 0x3a6fd0, unknown: 0x3a6fd0, blocked: 0xff3b3b, celebrate: 0xffd23f }[status] ?? null;
1011}
1012
1013// What a desk is doing this frame, with the after-a-change moments.
1014// A finished session nobody has looked at yet keeps celebrating: 4 s of
1015// cheering, 3 s of stretching, round again, until its pane is focused.
1016export function poseFor(desk, nowMs, reducedMotion) {
1017 if (desk.status === "done" && desk.unseenDone) {
1018 if (reducedMotion) return "stretch";
1019 const since = Math.max(0, nowMs - (desk.changedAt ?? nowMs));
1020 return since % 7000 < 4000 ? "celebrate" : "stretch";
1021 }
1022 if (desk.kind === "process") return "type";
1023 return { working: "type", idle: "lean", unknown: "lean", done: "sleep", blocked: "wave" }[desk.status] ?? "lean";
1024}
1025
1026function paintWorkstation(c, plan, d, t, nowMs) {
1027 const desk = d.seat;
1028 if (!desk) return;
1029 const P = pal(plan);
1030 const isSmall = plan.size === "small";
1031 const status = desk.status;
1032 const pose = poseFor(desk, nowMs, plan.opts.reducedMotion);
1033 const look = lookFor(desk, plan.opts.characters, plan.opts.style);
1034 const palette = palFor(look, status, desk.kind === "process");
1035 const accent = P.accent[status] ?? P.accent.unknown;
1036 const x = d.x;
1037 const y = d.y;
1038 const blink = t % 4 < 2;
1039 const screenStatus = pose === "celebrate" ? "celebrate" : status === "done" ? "done" : status;
1040
1041 // Red light thrown round a desk that needs you.
1042 if (status === "blocked") c.glow(x + (isSmall ? 9 : 15), y + (isSmall ? 8 : 12), isSmall ? 12 : 20, blink ? 0xff2a2a : 0xff9a1a, blink ? 0.4 : 0.22);
1043
1044 // In the hybrid view the person is a picture laid over this spot.
1045 const drawsPerson = !d.isHybrid;
1046 if (drawsPerson) paintChair(c, x, y, isSmall, pose, t, plan.opts.reducedMotion);
1047 if (drawsPerson && pose !== "sleep") paintPerson(c, x, y, isSmall, pose, look, palette, t, desk);
1048 paintDesk(c, x, y, isSmall, P, accent);
1049 if (drawsPerson && pose === "sleep") paintSleeper(c, x, y, isSmall, look, palette);
1050 if (!isSmall) {
1051 if (pose !== "lean" || !drawsPerson) c.sprite(MUG, x + 25, y + 15, { o: OUTLINE, w: 0xeeeae2, c: 0x5b3a22 });
1052 paintKeyboard(c, x, y, pose, t);
1053 if (drawsPerson && pose === "lean") paintFeetUp(c, x, y, look, t, plan.opts.reducedMotion);
1054 }
1055 if (drawsPerson && (pose === "type" || pose === "lean" || pose === "stretch" || pose === "celebrate")) paintArms(c, x, y, isSmall, pose, palette, t, plan.opts.reducedMotion);
1056 paintMonitor(c, x, y, isSmall, desk, t, screenStatus);
1057
1058 // Screen light on the face and the desk.
1059 const glowC = screenColour(desk, screenStatus);
1060 if (glowC && pose !== "sleep") {
1061 const fx = x + (isSmall ? 9 : 15);
1062 const fy = y + (isSmall ? 4 : 8);
1063 c.glow(fx, fy, isSmall ? 4 : 7, glowC, 0.22 + (t % 3 === 0 && status === "working" ? 0.08 : 0));
1064 c.glow(x + (isSmall ? 4 : 7), y + (isSmall ? 11 : 19), isSmall ? 4 : 7, glowC, 0.18);
1065 }
1066
1067 // Life: typing sparks, steam, zZ, beacon, confetti.
1068 const reduced = plan.opts.reducedMotion;
1069 if (pose === "type" && !reduced && !isSmall) {
1070 for (let k = 0; k < 2; k++) {
1071 const h = hash(`spark${desk.paneId}${t}${k}`);
1072 if (h % 3 === 0) c.set(x + 13 + (h % 10), y + 17 - ((h >> 4) % 3), [0xfff3a0, 0x9be7ff, 0xffffff][(h >> 8) % 3], true);
1073 }
1074 }
1075 if (pose === "lean" && !reduced) {
1076 const sip = t % 24 >= 20;
1077 const mx = isSmall ? x + 13 : sip ? x + 19 : x + 23;
1078 const my = isSmall ? y + 6 : sip ? y + 9 : y + 13;
1079 for (let k = 0; k < 4; k++) {
1080 const ph = (t + k * 3) % 12;
1081 const sy = my - 1 - Math.floor(ph * 0.6);
1082 const sx = mx + Math.round(Math.sin((t + k * 5) / 2.5));
1083 if (sy > y - 2) c.tint(sx, sy, 0xe8ecf5, 0.75 * (1 - ph / 12));
1084 }
1085 }
1086 if (pose === "sleep") {
1087 const cols = [x + (isSmall ? 12 : 22), x + (isSmall ? 13 : 24), x + (isSmall ? 14 : 26)];
1088 for (let k = 0; k < 3; k++) {
1089 const ph = (Math.floor(t / 2) + k * 4) % 12;
1090 const gy = y + (isSmall ? 6 : 12) - Math.floor(ph * 0.9);
1091 if (gy >= 0 && !reduced) c.glyph(cols[k], gy >> 1, ph < 5 ? "z" : "Z", mix(0xd8ccff, 0x6a6a88, ph / 13));
1092 }
1093 if (reduced) c.glyph(cols[1], (y + (isSmall ? 2 : 6)) >> 1, "z", 0xd8ccff);
1094 }
1095 if (status === "blocked") {
1096 const bx = x + (isSmall ? 3 : 6);
1097 const by = y + (isSmall ? 0 : 2);
1098 c.rect(bx - 1, by + 2, 4, 1, 0x3a3d4a);
1099 c.rect(bx, by, 2, 2, blink || reduced ? 0xff3b3b : 0xffb020, true);
1100 c.set(bx, by, 0xffffff, true);
1101 c.glyph(x + (isSmall ? 15 : 26), (y + (isSmall ? 0 : 1)) >> 1, "!", blink ? 0xffb020 : 0xff3b3b);
1102 }
1103 if (pose === "celebrate" && !reduced) {
1104 for (let k = 0; k < (isSmall ? 6 : 14); k++) {
1105 const h = hash(`conf${desk.paneId}${k}`);
1106 const cx = x + (h % (isSmall ? 18 : 30));
1107 const cy = y - 4 + ((h >> 5) + t * (1 + (k % 3))) % (isSmall ? 18 : 28);
1108 c.set(cx + Math.round(Math.sin((t + k) / 2)), cy, CONFETTI[k % CONFETTI.length], true);
1109 }
1110 }
1111
1112 // Helpers: minions around the desk, and a count.
1113 const n = desk.helpers || 0;
1114 if (n > 0) {
1115 const spots = isSmall ? [[-3, 12], [19, 12], [-3, 7]] : [[-5, 21], [30, 21], [-5, 14], [30, 14]];
1116 spots.slice(0, Math.min(spots.length, n)).forEach(([dx, dy], i) => {
1117 const hop = !reduced && (t + i * 2) % 6 === 0 ? 1 : 0;
1118 if (isSmall) c.sprite(SMALL_MINION, x + dx, y + dy - hop, { y: 0xfacc15, u: 0x2563eb });
1119 else c.sprite(MINION, x + dx, y + dy - hop, { y: 0xfacc15, G: 0xe5e7eb, g: (t + i) % 9 === 0 ? 0xfacc15 : 0x1f2937, u: 0x2563eb, o: OUTLINE });
1120 });
1121 const label = `x${n}`;
1122 c.text(x + (isSmall ? 18 : 29) - label.length, y >> 1, label, 0xfacc15, null);
1123 }
1124 if (desk.isSelf && drawsPerson) {
1125 const cx = x + (isSmall ? 11 : 18);
1126 const cy = y + (pose === "wave" ? 0 : isSmall ? 1 : 3);
1127 c.set(cx, cy, 0xfacc15, true);
1128 c.set(cx + 2, cy, 0xfacc15, true);
1129 c.set(cx + 4, cy, 0xfacc15, true);
1130 c.rect(cx, cy + 1, 5, 1, 0xe0b010, true);
1131 }
1132}
1133
1134function paintChair(c, x, y, isSmall, pose, t, reduced) {
1135 const swivel = pose === "lean" && !reduced ? Math.round(Math.sin(t / 6)) : 0;
1136 const pushed = pose === "wave" ? 3 : 0;
1137 const cx = x + (isSmall ? 8 : 13) + swivel + pushed;
1138 const cy = y + (isSmall ? 4 : 8) + pushed;
1139 const w = isSmall ? 9 : 13;
1140 const h = isSmall ? 7 : 11;
1141 for (let j = 0; j < h; j++) {
1142 for (let i = 0; i < w; i++) {
1143 const corner = (j === 0 || j === h - 1) && (i === 0 || i === w - 1);
1144 if (corner) continue;
1145 const edge = i === 0 || i === w - 1 || j === 0;
1146 c.set(cx + i, cy + j, edge ? 0x1e2129 : i < 2 ? 0x454b5c : j < 2 ? 0x4c5366 : 0x363b49);
1147 }
1148 }
1149}
1150
1151function paintPerson(c, x, y, isSmall, pose, look, palette, t, desk) {
1152 const isLean = pose === "lean";
1153 const isWave = pose === "wave";
1154 const lift = isWave ? (isSmall ? 3 : 5) : 0;
1155 if (isSmall) {
1156 const hx = x + 9 + (isLean ? 1 : 0);
1157 const hy = y + 2 - lift + (isLean ? -1 : 0);
1158 c.sprite(SMALL_TORSO, x + 8, y + 7 - lift, palette);
1159 paintHead(c, hx, hy, look, palette, pose, t, true, desk, look.isBold);
1160 if (isWave) {
1161 const up = t % 2 === 0;
1162 c.line(x + 8, y + 7 - lift, x + (up ? 6 : 7), y + (up ? 0 : 2) - lift, palette.c);
1163 c.line(x + 15, y + 7 - lift, x + (up ? 17 : 16), y + (up ? 2 : 0) - lift, palette.c);
1164 c.set(x + (up ? 6 : 7), y + (up ? -1 : 1) - lift, palette.s);
1165 c.set(x + (up ? 17 : 16), y + (up ? 1 : -1) - lift, palette.s);
1166 }
1167 return;
1168 }
1169 const hx = x + 14 + (isLean ? 1 : 0);
1170 const hy = y + 3 - lift + (isLean ? -1 : 0);
1171 c.sprite(TORSO, x + 13, y + 12 - lift, palette);
1172 paintHead(c, hx, hy, look, palette, pose, t, false, desk, look.isBold);
1173 if (isWave) {
1174 // Both arms up, waving out of step.
1175 const a = t % 2 === 0;
1176 const L = a ? [10, -1] : [8, 2];
1177 const R = a ? [28, 2] : [27, -1];
1178 for (const [sx, [hx2, hy2]] of [[14, L], [23, R]]) {
1179 c.line(x + sx, y + 13 - lift, x + hx2, y + hy2 + 2 - lift, OUTLINE);
1180 c.line(x + sx, y + 12 - lift, x + hx2, y + hy2 + 1 - lift, palette.c);
1181 c.rect(x + hx2 - 1, y + hy2 - lift, 2, 2, palette.s);
1182 }
1183 }
1184}
1185
1186function paintHead(c, x, y, look, palette, pose, t, isSmall, desk, isBold) {
1187 const kind = look.kind;
1188 const p = { ...palette };
1189 if (isBold) {
1190 // Bold: one chunky block per kind, the hair or ears part of it.
1191 if (pose === "stretch" || pose === "celebrate" || (pose === "lean" && t % 18 >= 16)) p.e = p.S ?? p.e;
1192 if (kind === "robot") p.v = t % 6 < 3 ? p.v : mix(p.v, 0xffffff, 0.4);
1193 c.sprite((isSmall ? BOLD_SMALL_HEADS : BOLD_HEADS)[kind] ?? BOLD_HEADS.critter, x, y, p);
1194 return;
1195 }
1196 const blinkNow = (t + (desk.look ?? 0)) % 23 === 0;
1197 if (pose === "stretch" || pose === "celebrate" || blinkNow || (pose === "lean" && t % 18 >= 16)) p.e = p.S ?? p.e;
1198 if (kind === "robot") p.v = t % 6 < 3 ? p.v : mix(p.v, 0xffffff, 0.4);
1199 if (isSmall) {
1200 c.sprite(SMALL_HEADS[kind] ?? SMALL_HEADS.human, x, y, p);hooks/office-sessions.mjs 190 lines1// Office: sessions without herdr. Every session running this mod keeps a
2// small file in a shared registry folder (its id, cwd, project, state,
3// helpers, last heartbeat); recently written transcripts fill in sessions
4// that do not run the mod. Pure: the pane and bin/office-monitor both use it.
5//
6// A file per session, not $.store: the store is one JSON file per plugin,
7// and sessions writing their own keys into it at once would overwrite each
8// other's copies.
9
10export const REGISTRY_TTL_MS = 2 * 60000;
11export const HEARTBEAT_MS = 30000;
12export const RECENT_MS = 30 * 60000;
13const IDLE_MS = 5 * 60000;
14
15// The registry's states, as the office's statuses.
16const STATUS_OF = { working: "working", idle: "idle", "needs-you": "blocked", done: "done" };
17
18/** Where the registry lives under a Claude config folder. */
19export function registryDir(root) {
20 return `${String(root).replace(/\/+$/, "")}/plugins/data/office-registry`;
21}
22
23/** A project's name from Claude Code's folder for it (`-Users-me-Projects-qube` is qube). */
24export function projectFromDir(dir) {
25 const parts = String(dir ?? "").split("-").filter(Boolean);
26 return parts[parts.length - 1] ?? String(dir ?? "");
27}
28
29function baseName(path) {
30 return String(path ?? "").split("/").filter(Boolean).pop() ?? "";
31}
32
33/** One registry entry, the session's own word, as a file's text. */
34export function registryEntry({ id, cwd, state, helpers = 0, activity = null, now }) {
35 return { id, cwd, project: baseName(cwd), state, helpers, activity, lastSeen: now };
36}
37
38/**
39 * Parses registry files (text) into live entries: well formed, heard from in
40 * the last two minutes, not ended.
41 */
42export function liveEntries(texts, now) {
43 const out = new Map();
44 for (const text of texts) {
45 let e;
46 try {
47 e = JSON.parse(text);
48 } catch {
49 continue;
50 }
51 if (!e || typeof e.id !== "string" || !STATUS_OF[e.state]) continue;
52 if (!(now - Number(e.lastSeen || 0) < REGISTRY_TTL_MS)) continue;
53 const was = out.get(e.id);
54 if (!was || was.lastSeen < e.lastSeen) out.set(e.id, e);
55 }
56 return [...out.values()];
57}
58
59/**
60 * The panes the office draws when herdr is not there: the registry's live
61 * sessions, then recent transcripts of sessions not in it (idle when written
62 * in the last five minutes, done before that). `recent` holds
63 * `{ id, dir, mtimeMs }` per transcript.
64 */
65export function sessionPanes(entries, recent, now, selfId) {
66 const panes = [];
67 const helpers = {};
68 const seen = new Set();
69 for (const e of entries) {
70 seen.add(e.id);
71 helpers[e.id] = Math.max(0, Number(e.helpers) || 0);
72 panes.push({
73 paneId: `session:${e.id}`,
74 tabId: "",
75 workspaceId: "",
76 agent: "claude",
77 status: STATUS_OF[e.state],
78 name: e.project || baseName(e.cwd) || e.id.slice(0, 8),
79 cwd: e.cwd ?? "",
80 sessionId: e.id,
81 isSelf: e.id === selfId,
82 isFocused: false,
83 order: panes.length,
84 source: "registry",
85 activity: e.activity ?? null,
86 terminalTitle: "",
87 });
88 }
89 for (const r of [...recent].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
90 if (seen.has(r.id) || now - r.mtimeMs > RECENT_MS) continue;
91 seen.add(r.id);
92 panes.push({
93 paneId: `session:${r.id}`,
94 tabId: "",
95 workspaceId: "",
96 agent: "claude",
97 status: now - r.mtimeMs < IDLE_MS ? "idle" : "done",
98 name: projectFromDir(r.dir),
99 cwd: "",
100 sessionId: r.id,
101 isSelf: r.id === selfId,
102 isFocused: false,
103 order: panes.length,
104 source: "transcript",
105 projectDir: r.dir,
106 terminalTitle: "",
107 });
108 }
109 return { panes, helpers };
110}
111
112/** The transcripts in a project folder's listing written in the last half hour. */
113export function recentTranscripts(dir, entries, now) {
114 return (entries ?? [])
115 .filter((e) => e && e.kind === "file" && /^[0-9a-f-]{20,}\.jsonl$/i.test(String(e.name)) && now - Number(e.mtimeMs || 0) < RECENT_MS)
116 .map((e) => ({ id: String(e.name).replace(/\.jsonl$/, ""), dir, mtimeMs: Number(e.mtimeMs) }));
117}
118
119/** What to say about a session outside herdr, and the command that resumes it. */
120export function resumeHint(desk) {
121 const where = desk.cwd || (desk.projectDir ? `project ${projectFromDir(desk.projectDir)}` : "an unknown folder");
122 return { text: `claude --resume ${desk.sessionId}`, where };
123}
124
125// ---- Claude Code's own registry of running sessions ------------------------------
126// `<config>/sessions/<pid>.json`, one per running session. Only the .json
127// files are read: the .key files beside them are secrets, never opened.
128
129/** Whether a file in a sessions folder may be read: a .json record, nothing else. */
130export function isSessionRecord(name) {
131 return /^[0-9]+\.json$/.test(String(name));
132}
133
134/** The pids to ask `ps` about, from the records. */
135export function recordPids(records) {
136 return [...new Set(records.map((r) => Number(r?.pid)).filter((n) => Number.isInteger(n) && n > 0))];
137}
138
139/** Which of the asked pids `ps -o pid= -p a,b,c` says are running. */
140export function alivePids(stdout) {
141 return new Set(String(stdout ?? "").split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger));
142}
143
144// Claude Code's statuses, as the office's: busy is working; anything waiting
145// on the person (permission, input, a question) needs you.
146export function statusOfRecord(status) {
147 const s = String(status ?? "").toLowerCase();
148 if (s === "busy" || s === "working" || s === "running") return "working";
149 if (/wait|permission|input|ask|block|approv/.test(s)) return "blocked";
150 return "idle";
151}
152
153/**
154 * The panes the office draws from Claude Code's session records: live when
155 * the pid runs or the record was updated in the last two minutes. A session
156 * that also runs this mod adds its richer state and activity from `entries`.
157 */
158export function claudeSessionPanes(records, alive, now, selfId, entries = []) {
159 const byId = new Map(entries.map((e) => [e.id, e]));
160 const panes = [];
161 const helpers = {};
162 const seen = new Set();
163 for (const r of records) {
164 if (!r || typeof r.sessionId !== "string" || seen.has(r.sessionId)) continue;
165 const isLive = alive.has(Number(r.pid)) || now - Number(r.updatedAt || 0) < REGISTRY_TTL_MS;
166 if (!isLive) continue;
167 seen.add(r.sessionId);
168 const own = byId.get(r.sessionId);
169 helpers[r.sessionId] = Math.max(0, Number(own?.helpers) || 0);
170 const named = r.nameSource && r.nameSource !== "derived" && typeof r.name === "string" ? r.name : "";
171 panes.push({
172 paneId: `session:${r.sessionId}`,
173 tabId: "",
174 workspaceId: "",
175 agent: "claude",
176 status: own ? STATUS_OF[own.state] ?? statusOfRecord(r.status) : statusOfRecord(r.status),
177 name: named || baseName(r.cwd) || r.sessionId.slice(0, 8),
178 cwd: typeof r.cwd === "string" ? r.cwd : "",
179 sessionId: r.sessionId,
180 isSelf: r.sessionId === selfId,
181 isFocused: false,
182 order: panes.length,
183 source: "claude",
184 activity: own?.activity ?? null,
185 terminalTitle: "",
186 });
187 }
188 return { panes, helpers };
189}
190hooks/office-drag.mjs 55 lines1// Office: the drag layer. A Client laid over the office picture: it holds
2// the pointer from a press to its release, draws a ghost of the desk being
3// moved next to the pointer, and posts what happened to the hooks module,
4// which owns the seats. A press and release without moving is a click.
5//
6// props.hits: [left, top, width, height, label, seat] per seat, in cells.
7
8export default function OfficeDrag(props, surface) {
9 const { Box, Text } = surface.elements;
10 const hits = Array.isArray(props?.hits) ? props.hits : [];
11 const s = surface.state ?? {};
12 // The seat under a cell, from the hits this drawing was made with: the
13 // same rectangles the floor shows, so the hooks never guess from a plan
14 // drawn since. -1 off every desk.
15 const seatAt = (x, y) => {
16 const hit = hits.find((h) => x >= h[0] && x < h[0] + h[2] && y >= h[1] && y < h[1] + h[3]);
17 return hit && Number.isInteger(hit[5]) ? hit[5] : -1;
18 };
19
20 surface.onPointer((e) => {
21 const cur = surface.state ?? {};
22 if (e.type === "down" && e.button === "left") {
23 surface.setState({ x0: e.x, y0: e.y, x: e.x, y: e.y, isDown: true, isMoved: false });
24 } else if (e.type === "move" && cur.isDown) {
25 const isMoved = cur.isMoved || Math.abs(e.x - cur.x0) + Math.abs(e.y - cur.y0) > 1;
26 surface.setState({ ...cur, x: e.x, y: e.y, isMoved });
27 if (isMoved) surface.post({ type: "hover", from: [cur.x0, cur.y0], at: [e.x, e.y], fromSeat: seatAt(cur.x0, cur.y0), atSeat: seatAt(e.x, e.y) });
28 } else if (e.type === "up" && cur.isDown) {
29 surface.post(
30 cur.isMoved
31 ? { type: "drop", from: [cur.x0, cur.y0], to: [e.x, e.y], fromSeat: seatAt(cur.x0, cur.y0), toSeat: seatAt(e.x, e.y) }
32 : { type: "click", at: [cur.x0, cur.y0], atSeat: seatAt(cur.x0, cur.y0) },
33 );
34 surface.setState({});
35 }
36 });
37
38 const children = [];
39 if (s.isDown && s.isMoved) {
40 const hit = hits.find((h) => s.x0 >= h[0] && s.x0 < h[0] + h[2] && s.y0 >= h[1] && s.y0 < h[1] + h[3]);
41 const label = ` ${hit ? String(hit[4]).slice(0, 24) : "desk"} `;
42 const left = Math.max(0, Math.min(s.x + 1, (surface.columns || 80) - label.length));
43 children.push(
44 Box({
45 key: "ghost",
46 position: "absolute",
47 top: Math.max(0, s.y),
48 left,
49 children: [Text({ inverse: true, bold: true, children: label })],
50 }),
51 );
52 }
53 return Box({ flexDirection: "column", children });
54}
55hooks/office-art.mjs 499 lines1// Office: the artwork. Every sprite is a palette-indexed pixel map: one string
2// per row, one character per pixel, `.` transparent. The key letters name a
3// colour role, which a look (skin, hair, clothes) or a theme fills in, so a
4// map is edited here without touching the painter.
5//
6// Roles used across the maps:
7// o outline s skin/limb S limb shade h limb highlight
8// e eye m mouth c clothes C clothes shade k clothes light
9// r hair R hair shade q critter Q critter shade
10// m metal (robots use M for shade), v visor, a antenna
11// F fur D fur dark p pink n nose W feather
12// y owl eye ring b beak
13
14// ---- heads, front view, 10 x 10 (the face sits in rows 2-9) -------------------
15
16export const HEADS = {
17 human: [
18 "..........",
19 "..........",
20 "..oooooo..",
21 ".osssssso.",
22 ".osshssso.",
23 ".osesseso.",
24 ".osssssso.",
25 ".oSssmsSo.",
26 "..oSSSSo..",
27 "...oooo...",
28 ],
29 robot: [
30 "....a.....",
31 "....o.....",
32 "..oooooo..",
33 ".ohmmmmmo.",
34 ".omvvvvMo.",
35 ".omvvvvMo.",
36 ".ommmmmMo.",
37 ".oMmMMmMo.",
38 "..oMMMMo..",
39 "...oooo...",
40 ],
41 critter: [
42 "..........",
43 "..........",
44 ".oooooooo.",
45 "oqqqqqqqqo",
46 "oqeqqqqeqo",
47 "oqeqqqqeqo",
48 "oqqqqqqqqo",
49 "oqqqqqqqQo",
50 "oQQQQQQQQo",
51 ".oooooooo.",
52 ],
53 cat: [
54 ".o......o.",
55 "oFo....oFo",
56 "oFpoooopFo",
57 "oFFFFFFFFo",
58 "oFeFFFFeFo",
59 "oFFFFFFFFo",
60 "oFFFnnFFFo",
61 ".oFFFFFFo.",
62 "..oDDDDo..",
63 "...oooo...",
64 ],
65 dog: [
66 "..........",
67 "..........",
68 "..oooooo..",
69 ".DFFFFFFD.",
70 "DDFFFFFFDD",
71 "DDFeFFeFDD",
72 "DDFFFFFFDD",
73 ".DFFnnFFD.",
74 "..FFmmFF..",
75 "...oooo...",
76 ],
77 owl: [
78 ".W......W.",
79 ".WW....WW.",
80 ".WWWWWWWW.",
81 "WWyyWWyyWW",
82 "WyeyWWyeyW",
83 "WWyyWWyyWW",
84 "WWWWbbWWWW",
85 ".WWWWbWWW.",
86 "..WWWWWW..",
87 "...oooo...",
88 ],
89};
90
91// The back of each head (war-room seats facing the table, and a head laid
92// down on the desk).
93export const HEAD_BACKS = {
94 human: [
95 "..........",
96 "..........",
97 "..oooooo..",
98 ".orrrrrro.",
99 ".orRrrrro.",
100 ".orrrrRro.",
101 ".orrrrrro.",
102 ".oRrrrrRo.",
103 "..oSSSSo..",
104 "...oooo...",
105 ],
106 robot: [
107 "....a.....",
108 "....o.....",
109 "..oooooo..",
110 ".ohmmmmmo.",
111 ".ommmmmMo.",
112 ".omMvMmMo.",
113 ".ommmmmMo.",
114 ".oMmmmmMo.",
115 "..oMMMMo..",
116 "...oooo...",
117 ],
118 critter: [
119 "..........",
120 "..........",
121 ".oooooooo.",
122 "oqqqqqqqqo",
123 "oqqqqqqqqo",
124 "oqqqqqqqQo",
125 "oqqqqqqqQo",
126 "oqqqqqqqQo",
127 "oQQQQQQQQo",
128 ".oooooooo.",
129 ],
130 cat: [
131 ".o......o.",
132 "oFo....oFo",
133 "oFFooooFFo",
134 "oFFFFFFFFo",
135 "oFDFFFFDFo",
136 "oFFFFFFFFo",
137 "oFFDFFDFFo",
138 ".oFFFFFFo.",
139 "..oDDDDo..",
140 "...oooo...",
141 ],
142 dog: [
143 "..........",
144 "..........",
145 "..oooooo..",
146 ".DFFFFFFD.",
147 "DDFFFFFFDD",
148 "DDFFFFFFDD",
149 "DDFFFFFFDD",
150 ".DFFFFFFD.",
151 "..FFFFFF..",
152 "...oooo...",
153 ],
154 owl: [
155 ".W......W.",
156 ".WW....WW.",
157 ".WWWWWWWW.",
158 "WWWWWWWWWW",
159 "WWDWWWWDWW",
160 "WWWWWWWWWW",
161 "WWDWWWWDWW",
162 ".WWWWWWWW.",
163 "..WWWWWW..",
164 "...oooo...",
165 ],
166};
167
168// Hair styles drawn over the human front head, same 10 x 10 frame.
169export const HAIRS = {
170 short: [
171 "..........",
172 "..........",
173 "..RRRRRR..",
174 ".RrrrrrrR.",
175 ".Rr....rR.",
176 ".R......R.",
177 ],
178 long: [
179 "..........",
180 "..........",
181 "..RRRRRR..",
182 ".RrrrrrrR.",
183 ".Rrr..rrR.",
184 ".Rr....rR.",
185 "Rr......rR",
186 "Rr......rR",
187 "Rr......rR",
188 ".R......R.",
189 ],
190 bun: [
191 "....RR....",
192 "...RrrR...",
193 "..RRRRRR..",
194 ".RrrrrrrR.",
195 ".R......R.",
196 ],
197 curly: [
198 "..RrRrRr..",
199 ".RrRrRrRr.",
200 "RrRrRrRrRr",
201 "Rr......rR",
202 "R........R",
203 "R........R",
204 ],
205 ponytail: [
206 "..........",
207 "..........",
208 "..RRRRRR..",
209 ".RrrrrrrRR",
210 ".Rr....rRr",
211 ".R......Rr",
212 ".........r",
213 ".........R",
214 ],
215 bald: [
216 "..........",
217 "..........",
218 "..........",
219 "....hh....",
220 ],
221};
222
223// ---- small heads for crowded rooms, 6 x 6 ---------------------------------
224
225export const SMALL_HEADS = {
226 human: [".oooo.", "osssso", "oesseo", "osssso", ".oSSo.", "..oo.."],
227 robot: ["..a...", ".oooo.", "omvvMo", "ommmMo", ".oMMo.", "..oo.."],
228 critter: ["oooooo", "oqqqqo", "oeqqeo", "oqqqqo", "oQQQQo", ".oooo."],
229 cat: ["o....o", "oFooFo", "oeFFeo", "oFnnFo", ".oDDo.", "..oo.."],
230 dog: [".oooo.", "DFFFFD", "DeFFeD", "DFnnFD", ".FmmF.", "..oo.."],
231 owl: ["W....W", "WWWWWW", "yeWWey", "WWbbWW", ".WWWW.", "..oo.."],
232};
233export const SMALL_HAIRS = {
234 short: ["RRRRRR", "R....R"],
235 long: ["RRRRRR", "R....R", "R....R", "R....R"],
236 bun: [".RRRR.", "R....R"],
237 curly: ["RrRrRr", "R....R", "R....R"],
238 ponytail: ["RRRRRR", "R....RR", ".....R"],
239 bald: ["......"],
240};
241
242// ---- torsos, front view: 12 wide; arms are drawn by the painter -------------
243
244export const TORSO = [
245 "..oooooooo..",
246 ".okkcccccCo.",
247 "okccccccccCo",
248 "okccccccccCo",
249 "occcccccccCo",
250 "occcccccccCo",
251 "occcccccccCo",
252 "oCCCCCCCCCCo",
253];
254export const SMALL_TORSO = [".oooooo.", "okcccCCo", "occccCCo", "oCCCCCCo"];
255
256// ---- props ----------------------------------------------------------------
257
258export const PLANT = [
259 "....g.G.....",
260 "..gGg.gGg...",
261 ".gGLgGgLGg..",
262 "gGLggGLggGg.",
263 ".gGgLgGgLGg.",
264 "..gGgGLgGg..",
265 "...gGgGgg...",
266 "....oPPo....",
267 "...oPpPPo...",
268 "...oPPPPo...",
269 "....oooo....",
270];
271
272export const COOLER = [
273 "..owwwwo..",
274 ".owWwwwWo.",
275 ".owwwwwwo.",
276 ".owWwwwWo.",
277 "..owwwwo..",
278 "..oxxxxo..",
279 ".oxXxxxxo.",
280 ".oxxbxrxo.",
281 ".oxxxxxxo.",
282 ".oxXxxxxo.",
283 ".oxxxxxxo.",
284 ".oXXXXXXo.",
285];
286
287export const RACK_W = 9;
288
289export const MUG = ["occo.", "owwoo", "owwoo", ".oo.."];
290export const MINION = [".yyy.", "yGgGy", ".yyy.", ".uuu.", "o...o"];
291export const SMALL_MINION = ["yy", "uu"];
292
293// Confetti and sparks colours, cycled.
294export const CONFETTI = [0xff5c8a, 0xffd23f, 0x4ade80, 0x60a5fa, 0xc084fc, 0xff8c42];
295
296// ---- character looks ------------------------------------------------------
297
298export const SKIN_TONES = [
299 [0xf6d3b3, 0xd9ab88, 0xffe6d0],
300 [0xeec39a, 0xcc9a6e, 0xfbd9b8],
301 [0xd6a175, 0xb07c52, 0xe8bb93],
302 [0xb57d55, 0x8d5a37, 0xc99670],
303 [0x8d5a3b, 0x6b3f26, 0xa36e4c],
304 [0x5e3a26, 0x452818, 0x734a33],
305];
306export const HAIR_COLOURS = [
307 [0x2b1d16, 0x1a110c],
308 [0x4a2f1d, 0x2f1d12],
309 [0x8c5a2b, 0x6b4220],
310 [0xd9b25e, 0xb08a3f],
311 [0xb5442b, 0x8a321f],
312 [0x9aa0a8, 0x737880],
313 [0x5c3d8c, 0x432c66],
314 [0x2f8a7a, 0x226657],
315];
316export const CLOTHES = [
317 [0x4f7cac, 0x3b5f85, 0x6d97c4],
318 [0xa05a7c, 0x7d4561, 0xbd7898],
319 [0x5f9e6e, 0x497a55, 0x7fbb8d],
320 [0xc07a3e, 0x9a6030, 0xd99a62],
321 [0x7a6fc0, 0x5d5499, 0x968cd8],
322 [0x3fa0a0, 0x2f7d7d, 0x62bcbc],
323 [0xb0524f, 0x8a3d3b, 0xc9716e],
324 [0x8a8f3c, 0x6b6f2c, 0xa6ab58],
325 [0x4b4f5c, 0x363944, 0x666b7a],
326];
327export const FURS = {
328 cat: [
329 [0xe39a4f, 0xb87635],
330 [0x8a8f98, 0x60656d],
331 [0x2e2b30, 0x1c1a1e],
332 [0xf0e6da, 0xc9bcae],
333 ],
334 dog: [
335 [0xc89b6d, 0x7a5233],
336 [0xf2e8d8, 0x8a6a48],
337 [0x6b4a32, 0x3d2a1c],
338 [0xd9b47a, 0x9c7a46],
339 ],
340 owl: [
341 [0x9a7b5a, 0x6b5238],
342 [0xd8d2c4, 0xa69f8f],
343 [0x6b5a4a, 0x483b2f],
344 ],
345};
346
347// ---- room palettes --------------------------------------------------------
348
349// `nightshade` follows the Nightshade Ghostty theme (violet charcoal, moss
350// green and lilac); the others are presets.
351export const PALETTES = {
352 nightshade: {
353 wall: 0x3a3546, wallShade: 0x2b2833, trim: 0x524a63, baseboard: 0x211f28,
354 floorA: 0x5a4a5e, floorB: 0x50425a, floorSeam: 0x3a3043,
355 rugs: [0x4a3a6b, 0x2f5246, 0x5e3a4a, 0x2f4466, 0x5a4a2a],
356 wood: 0x8a6548, woodTop: 0xa77c58, woodLight: 0xc49670, woodDark: 0x5e4231,
357 metal: 0x6b6577, dark: 0x1d1b23,
358 accent: { blocked: 0xf0655f, working: 0x76ea6a, running: 0xf5a524, idle: 0x8bbcff, unknown: 0xa8a2b8, done: 0x8c8699 },
359 },
360 warm: {
361 wall: 0x6b5444, wallShade: 0x55412f, trim: 0x8a6a4a, baseboard: 0x3a2a1e,
362 floorA: 0x9a6c45, floorB: 0x8c613e, floorSeam: 0x6b4a2c,
363 rugs: [0x8a3a2e, 0x2e5a4a, 0x7a5a2a, 0x3a4a7a, 0x6a3a5a],
364 wood: 0x7a5232, woodTop: 0x96683f, woodLight: 0xb8865a, woodDark: 0x4e331f,
365 metal: 0x6e6a66, dark: 0x241a14,
366 accent: { blocked: 0xff4d4d, working: 0x4ade80, running: 0xf59e0b, idle: 0x60a5fa, unknown: 0x94a3b8, done: 0x8b8fa8 },
367 },
368 cool: {
369 wall: 0x3d4c5e, wallShade: 0x2e3a49, trim: 0x55677d, baseboard: 0x1f2832,
370 floorA: 0x5b6878, floorB: 0x52606f, floorSeam: 0x3c4756,
371 rugs: [0x2f4f6f, 0x3f6a5a, 0x5a4a7a, 0x6a4a4a, 0x4a5a3a],
372 wood: 0x7c6a5a, woodTop: 0x96826f, woodLight: 0xb3a08c, woodDark: 0x4f4337,
373 metal: 0x6f7c8a, dark: 0x161c24,
374 accent: { blocked: 0xff4d4d, working: 0x4ade80, running: 0xf59e0b, idle: 0x60a5fa, unknown: 0x94a3b8, done: 0x8b8fa8 },
375 },
376 mono: {
377 wall: 0x4a4a4a, wallShade: 0x383838, trim: 0x5e5e5e, baseboard: 0x222222,
378 floorA: 0x5a5a5a, floorB: 0x525252, floorSeam: 0x3c3c3c,
379 rugs: [0x444444, 0x4c4c4c, 0x3e3e3e, 0x484848, 0x404040],
380 wood: 0x707070, woodTop: 0x888888, woodLight: 0xa0a0a0, woodDark: 0x4a4a4a,
381 metal: 0x6a6a6a, dark: 0x1a1a1a,
382 accent: { blocked: 0xff4d4d, working: 0x4ade80, running: 0xf59e0b, idle: 0x60a5fa, unknown: 0x94a3b8, done: 0x9a9a9a },
383 },
384};
385
386// ---- skies by time of day --------------------------------------------------
387
388/** Sky colours for an hour (0-24, fractional): top, bottom, and whether it is night. */
389export function skyAt(hour) {
390 const stops = [
391 [0, 0x0b1026, 0x1a2147, true],
392 [5, 0x1a2147, 0x3b3366, true],
393 [6, 0x5b5a9a, 0xf2a07a, false],
394 [7.5, 0x6fa8e8, 0xf6d6a8, false],
395 [10, 0x4f94e0, 0xa8d4f5, false],
396 [16, 0x5a9be0, 0xb8dcf5, false],
397 [18, 0x4a5aa0, 0xf09a6a, false],
398 [19.5, 0x2a2a66, 0xc0607a, false],
399 [21, 0x101536, 0x2a2a5a, true],
400 [24, 0x0b1026, 0x1a2147, true],
401 ];
402 for (let i = 0; i < stops.length - 1; i++) {
403 const [h0, t0, b0, n0] = stops[i];
404 const [h1, t1, b1] = stops[i + 1];
405 if (hour >= h0 && hour <= h1) {
406 const k = (hour - h0) / (h1 - h0 || 1);
407 return { top: mix(t0, t1, k), bottom: mix(b0, b1, k), isNight: n0 && k < 0.5 ? true : hour >= 20.5 || hour < 5.5 };
408 }
409 }
410 return { top: stops[0][1], bottom: stops[0][2], isNight: true };
411}
412
413export function mix(a, b, t) {
414 const k = Math.max(0, Math.min(1, t));
415 const ch = (s) => {
416 const x = (a >> s) & 255;
417 const y = (b >> s) & 255;
418 return Math.round(x + (y - x) * k) << s;
419 };
420 return ch(16) | ch(8) | ch(0);
421}
422
423// ---- bold: chunky, high-contrast characters made for cells ---------------------
424// Every kind drawn as the critter is: one block with a thick outline, eyes
425// two pixels tall, a feature or two (hair, ears, visor, beak) and nothing finer.
426
427export const BOLD_HEADS = {
428 critter: HEADS.critter,
429 human: [
430 "..........",
431 ".oooooooo.",
432 "orrrrrrrro",
433 "orrsssssro",
434 "osesssseso",
435 "osesssseso",
436 "osssssssso",
437 "osssmmsSso",
438 "oSSSSSSSSo",
439 ".oooooooo.",
440 ],
441 robot: [
442 "....a.....",
443 ".oooooooo.",
444 "ommmmmmmmo",
445 "omvvvvvvmo",
446 "omvVvvVvmo",
447 "omvvvvvvmo",
448 "ommmmmmmmo",
449 "omMmMMmMmo",
450 "oMMMMMMMMo",
451 ".oooooooo.",
452 ],
453 cat: [
454 ".oo....oo.",
455 "oFFo..oFFo",
456 "oFFFooFFFo",
457 "oFFFFFFFFo",
458 "oFeFFFFeFo",
459 "oFeFFFFeFo",
460 "oFFFnnFFFo",
461 "oFFFFFFFFo",
462 "oDDDDDDDDo",
463 ".oooooooo.",
464 ],
465 dog: [
466 "..........",
467 ".oooooooo.",
468 "oDFFFFFFDo",
469 "oDFFFFFFDo",
470 "oDeFFFFeDo",
471 "oDeFFFFeDo",
472 "oDFFnnFFDo",
473 "oDFFmmFFDo",
474 "oDDDDDDDDo",
475 ".oooooooo.",
476 ],
477 owl: [
478 "oo......oo",
479 "oWo....oWo",
480 "oWWooooWWo",
481 "oyyyWWyyyo",
482 "oyeyWWyeyo",
483 "oyyyWWyyyo",
484 "oWWWbbWWWo",
485 "oWWWWbWWWo",
486 "oDDDDDDDDo",
487 ".oooooooo.",
488 ],
489};
490
491export const BOLD_SMALL_HEADS = {
492 critter: SMALL_HEADS.critter,
493 human: ["oooooo", "orrrro", "oesseo", "oesseo", "oSmmSo", ".oooo."],
494 robot: ["..a...", "oooooo", "ovVVvo", "ommmmo", "oMMMMo", ".oooo."],
495 cat: ["o....o", "oFooFo", "oeFFeo", "oeFFeo", "oDnnDo", ".oooo."],
496 dog: ["oooooo", "DFFFFD", "DeFFeD", "DeFFeD", "DFnnFD", ".oooo."],
497 owl: ["W....W", "oWWWWo", "yeyyey", "yyWWyy", "oWbbWo", ".oooo."],
498};
499hooks/office-art-hd.mjs 129 lines1// Office pictures: the characters drawn for this mod, in the flat, outline-free style
2// of Kenney's Roguelike packs (16 x 16, a darker shade for edges, no black).
3// Each map is one string per row, one key per pixel, `.` clear; a palette
4// names the colours, and a look (fur, metal) fills the rest in.
5
6// ---- characters, front view, 16 x 16 like Kenney's people ---------------------
7
8export const ROBOT = [
9 ".......a........",
10 ".......m........",
11 ".....MMMMMM.....",
12 "....MmmmmmmM....",
13 "....MvvvvvvM....",
14 "....MvVvvVvM....",
15 ".MMMMmmmmmmMMMM.",
16 "MmmmMmmmmmmMmmmM",
17 "MmmmmccccccmmmmM",
18 "MmmmmcLccLcmmmmM",
19 "MmmmccccccccmmmM",
20 "MmmMMMMMMMMMMmmM",
21 "MmmMmmmmmmmmMmmM",
22 "MmmMmmmmmmmmMmmM",
23 ".MMMmmMMMMmmMMM.",
24 "...MmmM..MmmM...",
25];
26
27export const CRITTER = [
28 "................",
29 "................",
30 "..QQQQQQQQQQQQ..",
31 ".QqqqqqqqqqqqqQ.",
32 ".QqqeeqqqqeeqqQ.",
33 ".QqqeeqqqqeeqqQ.",
34 ".QqqqqqqqqqqqqQ.",
35 "QqqqqqqqqqqqqqqQ",
36 "QqhqqqqqqqqqqhqQ",
37 ".QqqqqqqqqqqqqQ.",
38 ".QqqqqqqqqqqqqQ.",
39 "..QQQQQQQQQQQQ..",
40 "..Q.Q.Q..Q.Q.Q..",
41 "..Q.Q.Q..Q.Q.Q..",
42 "................",
43 "................",
44];
45
46export const CAT = [
47 "....D......D....",
48 "...DpD....DpD...",
49 "...DFFDDDDFFD...",
50 "..DFFFFFFFFFFD..",
51 "..DFeFFFFFFeFD..",
52 "..DFFFFnnFFFFD..",
53 "...DFFFmmFFFD...",
54 "..DFFFFFFFFFFD..",
55 ".DFFFwwwwwwFFFD.",
56 ".DFFwwwwwwwwFFD.",
57 ".DFFwwwwwwwwFFD.",
58 ".DFFFwwwwwwFFFD.",
59 ".DDFFFFFFFFFFDD.",
60 "..DDFFDDDDFFDD..",
61 "...wwF....Fww...",
62 "................",
63];
64
65export const DOG = [
66 "................",
67 "....DFFFFFFD....",
68 "...DFFFFFFFFD...",
69 "..EEFFFFFFFFEE..",
70 "..EEFeFFFFeFEE..",
71 "..EEFFFwwFFFEE..",
72 "...EFFwnnwFFE...",
73 "....FFwmmwFF....",
74 "..DFFFFFFFFFFD..",
75 ".DFFFwwwwwwFFFD.",
76 ".DFFwwwwwwwwFFD.",
77 ".DFFwwwwwwwwFFD.",
78 ".DFFFwwwwwwFFFD.",
79 "..DDFFFFFFFFDD..",
80 "...FFF....FFF...",
81 "................",
82];
83
84export const OWL = [
85 "...D........D...",
86 "...DD......DD...",
87 "...DFFFFFFFFD...",
88 "..DFyyyFFyyyFD..",
89 "..DyyeyFFyeyyD..",
90 "..DFyyyFFyyyFD..",
91 "..DFFFFbbFFFFD..",
92 ".DFFFFFFbFFFFFD.",
93 ".DDFFwwwwwwFFDD.",
94 ".DDFwwDwwDwwFDD.",
95 ".DDFwwwwwwwwFDD.",
96 ".DDFwwDwwDwwFDD.",
97 "..DDFwwwwwwFDD..",
98 "...DDDDDDDDDD...",
99 "....b......b....",
100 "................",
101];
102
103// ---- details -----------------------------------------------------------
104
105export const CROWN = ["y.y.y", "yyyyy", "YYYYY"];
106
107// Fur and feather colours, the pairs a look picks from.
108export const HD_FURS = {
109 cat: [
110 [0xe8a35a, 0xb8763a],
111 [0x9a9fa8, 0x6e737c],
112 [0x4a454f, 0x2e2b33],
113 [0xf2ebe0, 0xc9bfb0],
114 ],
115 dog: [
116 [0xd2a676, 0x8a5c38],
117 [0xf2e8d8, 0x9a7a58],
118 [0x7a5438, 0x4a3222],
119 [0xe0bb80, 0xa07c48],
120 ],
121 owl: [
122 [0xa88660, 0x6e5438],
123 [0xd8d2c4, 0x9a9384],
124 [0x7a6a58, 0x4e4236],
125 ],
126};
127
128export const CONFETTI_HD = [0xff5c8a, 0xffd23f, 0x5fcf55, 0x6fa8f5, 0xc084fc, 0xff8c42];
129