Workbench side pane: greg watches your PRs (the pass), birdy counts down to your next meeting.

The mods I run in Claude Code, each in its own folder. Clone this repo and run one script to get the same setup on any machine.
Mods are small plugins that add live panes, status bands and slash commands to Claude Code. They hot-reload when you edit them.
git clone https://github.com/wpvip-awesomeface/claude-mods.git ~/.claude/mods
~/.claude/mods/install.sh
Then start a new Claude Code session. Add --all to also turn on the personal mods in fun/ (that's the full backup restore).
The script:
CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json)CLAUDE_CODE_PLUGIN_DIR_WATCH=1)mods command into ~/.local/binmods # list mods and whether they're on
mods on bento # load one in every new session
mods off workbench # stop loading it
mods on all # the core mods
mods on fun # the personal mods in fun/
| Mod | What you see | Saves tokens? |
|---|---|---|
| bento | A band above the prompt showing context use, turns and subagents | Yes, directly |
| turn-timer | "Speed Radar": how long each reply took | Indirectly |
| recipe-card | A card under each answer showing the route line, with /recipe to save a solve | Yes, over time |
workbench (in fun/) | A side pane with your open PRs, next meeting, pizza and a dino | No, it's for focus and fun |
What: a lunchbox band above the prompt. The box fills as context grows and shows context %, tokens, turn count and how many subagents ("helpers") the session has started.
Why: cost is roughly turns × context size, and both are invisible by default. Bento makes them visible, and warns you once at 70% context or 60 turns that it's time to start fresh.
Commands:
/doggybag or /handoff: writes a 5–10 line handoff (goal, state, links, next step) and copies it to your clipboard. It runs as one tool-less fork over the cached transcript, so it's cheap. Paste it into a new session./hush: hides every mod at once, for screenshares and recordings./bento: shows or hides the band.What: shows how long each reply took (Speed Radar: ⏱ 2m 15s), drawn above the route line.
Why: slow replies are usually long, tool-heavy turns. Seeing the time makes it easy to spot when a session is dragging and should be split up. It stays visible during /hush, since it's safe to show in demos.
What: reads the route line at the end of each answer (◎ shape · † graveyard · ◫ library · ≡ rung · ✦ extract) and draws it as a card. When an answer was a fresh solve worth keeping, it offers /recipe to save it as a reusable tool.
Why: the cheapest task is the one you've already solved. Saving solves to a library, and dead ends to a graveyard, keeps future sessions from paying to re-solve them.
Needs: a skills library that uses the route line. Without one, the card has nothing to read.
Heads up: this is a personal mod, built around the author's own workflow (their demo sites, calendar and sense of humor). Most people won't need or want it. It's here so the author's full setup is backed up, and as an example if you want to build your own.
It lives in fun/, and the installer leaves it off unless you pass --all or run mods on workbench.
What: a side pane with:
/prep to get ready for it/fun, off by default): a quote of the day, pizza and mochi the dinoWhy: keeps PR status and your calendar in view, so you don't spend turns asking Claude to check them.
Needs: the gh CLI signed in, and a Google Calendar connector for birdy. The demo links and the company email domain are set at the top of fun/workbench/hooks/register.tsx, so change those for your own setup. Edit the quote list with /quotes.
Commands: /workbench, /greg-check, /prep, /quotes, /fun.
Not a mod, but it lives here too: token-kit/ has the CLAUDE.md rules and a read guard hook that blocks whole-file reads over 400 lines once and returns an outline. To have an agent set all of it up, point it at AGENT_SETUP.md.
<mod>/hooks/register.tsx.*/.claude-plugin/types/ is generated by Claude Code on each machine and is not committed. Your editor may show type errors until a session has loaded the mods once.hooks/register.tsx 887 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Dollar, Register } from 'claude-code'
3
4import type { Day, Meeting, Pass, Ticket } from '../types'
5
6// Muted Dracula accents for the names, greys for everything else (matches bento).
7const GREG = '#A393C7'
8const LINK = '#C3B3EC' // a step brighter than greg's name, for PR titles
9const BIRDY = '#7FB2C0'
10const TEXT = '#D4D4D4'
11const DATA = '#A3A3A3'
12const MUTED = '#6B6B6B' // quiet footnotes: the quote toggle, "checked …"
13const LINKY = '#CFC98A' // muted Dracula yellow, for the linkydoos header
14
15// ── linkydoos: one-click links to each demo's site, repo and open PRs ─────────
16const GH = 'https://github.com/wpcomvip/'
17const LINKYDOOS = [
18 { name: 'vip demo', site: 'https://wpvipdemo.dev/', repo: 'vip-soleng-vip-demo' },
19 { name: 'federal gov', site: 'https://wpvipgov.com/', repo: 'vip-soleng-us-federal-government-showme' },
20 { name: 'publisher', site: 'https://vip-news.digital/', repo: 'vip-soleng-publisher-demo' },
21]
22
23const PANE = 'workbench'
24const TITLE = 'Workbench'
25
26const pass = atom({ plugin: 'workbench', key: 'pass' } as const, { tickets: [], checkedAt: 0 })
27const day = atom({ plugin: 'workbench', key: 'day' } as const, { meetings: [], ahead: [], aheadDay: 0, checkedAt: 0, now: 0 })
28const nudged = atom({ plugin: 'workbench', key: 'nudged' } as const, [])
29const quotes = atom({ plugin: 'workbench', key: 'quotes' } as const, [])
30// Hide the quote for recordings and screenshares; sticks until you show it again.
31const quoteHidden = atom({ plugin: 'workbench', key: 'quoteHidden' } as const, false)
32// A colored notice above the prompt, in place of plain toasts (toasts can't take color).
33const flash = atom({ plugin: 'workbench', key: 'flash' } as const, null)
34// Survives hot reloads, so hiding the Workbench sticks until you show it again.
35const isHidden = atom({ plugin: 'workbench', key: 'isHidden' } as const, false)
36// Fun mode: the quote, pizza and mochi. Off by default; /fun flips it and the
37// choice is kept in $.store, so it stays put across sessions until flipped again.
38const fun = atom({ plugin: 'workbench', key: 'fun' } as const, false)
39const FUN_KEY = 'funMode'
40
41const FLASH_MS = 10_000
42
43// Shows a notice in the speaker's color for a few seconds; a newer one replaces it.
44const say = async ($: Dollar, who: 'greg' | 'birdy', text: string) => {
45 const id = await $.clock.now()
46 const voice = who === 'greg' ? { glyph: '▤', name: 'greg', color: GREG } : { glyph: '◷', name: 'birdy', color: BIRDY }
47 await update($, flash, () => ({ ...voice, text, id }))
48 $.clock.after(FLASH_MS, () => void update($, flash, f => (f && f.id === id ? null : f)))
49}
50
51// ── greg: the pass ────────────────────────────────────────────────────────────
52
53const GREG_EVERY_MS = 3 * 60 * 1000
54const MERGED_WINDOW_MS = 3 * 60 * 60 * 1000
55
56const QUERY = `query($open: String!, $merged: String!) {
57 open: search(query: $open, type: ISSUE, first: 20) { nodes { ...pr } }
58 merged: search(query: $merged, type: ISSUE, first: 10) { nodes { ...pr } }
59}
60fragment pr on PullRequest {
61 number title url isDraft mergedAt
62 repository { nameWithOwner }
63 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
64}`
65
66type PrNode = {
67 number: number
68 title: string
69 url: string
70 isDraft: boolean
71 mergedAt: string | null
72 repository: { nameWithOwner: string }
73 commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }
74}
75
76const toTicket = (n: PrNode, merged: boolean): Ticket => {
77 const checks = n.commits.nodes[0]?.commit.statusCheckRollup?.state
78 const state: Ticket['state'] = merged ? 'merged'
79 : checks === 'FAILURE' || checks === 'ERROR' ? 'failing'
80 : n.isDraft || checks === 'PENDING' || checks === 'EXPECTED' ? 'cooking'
81 : 'waiting'
82
83 return { number: n.number, title: n.title, url: n.url, repo: n.repository.nameWithOwner, state, mergedAt: n.mergedAt ?? undefined }
84}
85
86const checkPass = async ($: Dollar) => {
87 const now = await $.clock.now()
88 const since = new Date(now - MERGED_WINDOW_MS).toISOString().slice(0, 19)
89
90 try {
91 const ran = await $.process.run([
92 'gh', 'api', 'graphql',
93 '-f', `query=${QUERY}`,
94 '-f', 'open=is:pr is:open author:@me archived:false',
95 '-f', `merged=is:pr is:merged author:@me merged:>=${since}`,
96 ], { timeoutMs: 20000 })
97
98 if (ran.exitCode !== 0) {
99 await update($, pass, p => ({ ...p, checkedAt: now, error: 'gh could not reach GitHub' }))
100 return
101 }
102
103 const data = JSON.parse(ran.stdout).data
104 const tickets: Ticket[] = [
105 ...data.open.nodes.filter((n: PrNode) => n.number).map((n: PrNode) => toTicket(n, false)),
106 ...data.merged.nodes.filter((n: PrNode) => n.number).map((n: PrNode) => toTicket(n, true)),
107 ]
108
109 const before = await read($, pass)
110 const was = new Map(before.tickets.map(t => [t.url, t.state]))
111
112 // Speak up only when something changes state, and only after the first look.
113 if (before.checkedAt > 0) {
114 for (const t of tickets) {
115 const old = was.get(t.url)
116 if (t.state === 'merged' && old !== 'merged') {
117 await say($, 'greg', `#${t.number} merged. Deploy runs from GitHub; content changes next.`)
118 } else if (t.state === 'failing' && old !== 'failing') {
119 await say($, 'greg', `checks failing on #${t.number} ${t.title}`)
120 } else if (t.state === 'waiting' && old && old !== 'waiting') {
121 await say($, 'greg', `#${t.number} is green and waiting on your merge`)
122 }
123 }
124 }
125
126 await update($, pass, () => ({ tickets, checkedAt: now }))
127 } catch {
128 await update($, pass, p => ({ ...p, checkedAt: now, error: 'gh is not available' }))
129 }
130}
131
132// ── birdy: mise en place ──────────────────────────────────────────────────────
133
134// The Google Calendar connector's list tool as last seen; findCalendarTool looks up the live name.
135const CALENDAR_TOOL = 'mcp__b181ff40-d5f0-44e8-b7fc-66208918b06e__list_events'
136const HOME_DOMAIN = 'a8c.com'
137const BIRDY_EVERY_MS = 5 * 60 * 1000
138const NUDGE_MS = 10 * 60 * 1000
139
140type Attendee = { email?: string; self?: boolean; responseStatus?: string }
141type CalEvent = {
142 id: string
143 summary?: string
144 status?: string
145 eventType?: string
146 start?: { dateTime?: string }
147 end?: { dateTime?: string }
148 htmlLink?: string
149 attendees?: Attendee[]
150}
151
152const toMeeting = (ev: CalEvent): Meeting | null => {
153 if (ev.status === 'cancelled' || ev.eventType !== 'DEFAULT') return null
154 if (!ev.start?.dateTime || !ev.end?.dateTime) return null // all-day
155
156 const people = ev.attendees ?? []
157 if (people.some(a => a.self && a.responseStatus === 'declined')) return null
158 if (people.length < 2) return null // solo blocks are focus time, not meetings
159
160 const outside = [...new Set(
161 people
162 .map(a => a.email?.split('@')[1]?.toLowerCase())
163 .filter((d): d is string => !!d && d !== HOME_DOMAIN && !d.endsWith('calendar.google.com')),
164 )]
165
166 return {
167 id: ev.id,
168 title: ev.summary ?? 'Untitled meeting',
169 start: Date.parse(ev.start.dateTime),
170 end: Date.parse(ev.end.dateTime),
171 people: people.length,
172 outside,
173 // Google Calendar's own page for the event; only kept when it is a plain https link.
174 link: ev.htmlLink?.startsWith('https://') ? ev.htmlLink : undefined,
175 }
176}
177
178// The connector's tool name carries an id that can differ between sessions, so look it up:
179// any MCP tool named ..._list_events that describes itself as a calendar.
180// Any connected calendar's list_events: same schema, so typed as the one we know.
181type McpToolName = typeof CALENDAR_TOOL
182
183const findCalendarTool = async ($: Dollar): Promise<McpToolName | null> => {
184 const tools = await $.tool.list()
185 const hit = tools.find(t => t.mcp && t.name.endsWith('__list_events') && /calendar/i.test(t.description))
186 return (hit?.name as McpToolName | undefined) ?? (tools.some(t => t.name === CALENDAR_TOOL) ? CALENDAR_TOOL : null)
187}
188
189// Local midnight at the start of the given day.
190const startOfDay = (ms: number) => {
191 const d = new Date(ms)
192 d.setHours(0, 0, 0, 0)
193 return d.getTime()
194}
195
196// Midnight of the next weekday (Fri, Sat, Sun all look ahead to Monday). Holidays are not skipped.
197const nextBusinessDay = (ms: number) => {
198 const d = new Date(startOfDay(ms))
199 do d.setDate(d.getDate() + 1)
200 while (d.getDay() === 0 || d.getDay() === 6)
201 return d.getTime()
202}
203
204// Midnight at the end of the given day (DST-safe: steps the calendar date, not 24h).
205const endOfDay = (ms: number) => {
206 const d = new Date(startOfDay(ms))
207 d.setDate(d.getDate() + 1)
208 return d.getTime()
209}
210
211const atHour = (dayStart: number, hour: number) => {
212 const d = new Date(dayStart)
213 d.setHours(hour)
214 return d.getTime()
215}
216
217const RETRY_MS = 30_000
218const RETRIES = 6
219let retriesLeft = RETRIES
220
221const checkDay = async ($: Dollar) => {
222 const now = await $.clock.now()
223 const fail = async (error: string) => {
224 await update($, day, d => ({ ...d, checkedAt: now, now, error }))
225 // Connectors often finish connecting after the session starts: try again soon, a few times.
226 if (retriesLeft > 0) {
227 retriesLeft -= 1
228 $.clock.after(RETRY_MS, () => void checkDay($))
229 }
230 }
231
232 try {
233 const tool = await findCalendarTool($)
234 if (!tool) return fail('calendar connector not connected yet')
235
236 const list = async (from: number, to: number) => {
237 const ran = await $.tool.call({
238 tool,
239 startTime: new Date(from).toISOString(),
240 endTime: new Date(to).toISOString(),
241 orderBy: 'startTime',
242 pageSize: 25,
243 })
244 if (ran.deny) throw new Error('calendar access was blocked by a permission rule')
245 if (ran.isError || !ran.text) throw new Error('calendar connector returned an error')
246 const events: CalEvent[] = JSON.parse(ran.text).events ?? []
247 return events.map(toMeeting).filter((m): m is Meeting => !!m)
248 }
249
250 // The rest of today, then the next business morning (6am-noon) as a look ahead.
251 const aheadDay = nextBusinessDay(now)
252 const meetings = await list(now - 60 * 60 * 1000, endOfDay(now))
253 const ahead = (await list(atHour(aheadDay, 6), atHour(aheadDay, 12))).filter(m => m.start >= atHour(aheadDay, 6))
254 retriesLeft = RETRIES
255 await update($, day, () => ({ meetings, ahead, aheadDay, checkedAt: now, now }))
256 } catch (err) {
257 await fail(err instanceof Error && err.message.startsWith('calendar') ? err.message : 'calendar is not reachable')
258 }
259}
260
261// Each minute: move the clock (redraws the countdown) and nudge once before a meeting.
262const tick = async ($: Dollar) => {
263 const now = await $.clock.now()
264 const d = await update($, day, x => ({ ...x, now }))
265 const soon = d.meetings.find(m => m.start > now && m.start - now <= NUDGE_MS)
266 if (!soon) return
267
268 const done = await read($, nudged)
269 if (done.includes(soon.id)) return
270
271 await update($, nudged, list => [...list, soon.id].slice(-50))
272 const mins = Math.max(1, Math.round((soon.start - now) / 60000))
273 await say($, 'birdy', `${soon.title} in ${mins}m. /prep to get ready.`)
274}
275
276const pick = (d: Day) => ({
277 current: d.meetings.find(m => m.start <= d.now && m.end > d.now),
278 upcoming: d.meetings.filter(m => m.start > d.now),
279})
280
281const prepPrompt = (m: Meeting, now = Date.now()) => [
282 `Prep me for my next meeting: "${m.title}" at ${clock(m.start)}${m.start >= endOfDay(now) ? ` on ${weekday(m.start)}` : ''}`,
283 `with ${m.people} people${m.outside.length ? ` (outside: ${m.outside.join(', ')})` : ' (internal)'}.`,
284 'Look up the event description and any linked docs, related PRs I have open or merged recently,',
285 'and demo pages on wpvipdemo.dev that fit. Give me a short checklist: what to open, what to show, what to ask.',
286 'Do not change anything, just prep.',
287].join(' ')
288
289// Opens a PR in the browser. Only GitHub PR URLs from greg's own query get through.
290const openPr = async ($: Dollar, url: string) => {
291 if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(url)) return
292 await $.process.run(['open', url], { timeoutMs: 5000 })
293}
294
295const show = async ($: Dollar) => {
296 await update($, isHidden, () => false)
297 await $.ui.open({ id: PANE, title: TITLE })
298}
299
300// ── quote of the day ──────────────────────────────────────────────────────────
301
302// The one quote list every session reads: paste lines or a whole paragraph, # for comments.
303// Relative to $HOME, so the mod works on any machine.
304const QUOTES_PATH = '.claude/mods/fun/workbench/assets/quotes.txt'
305const QUOTE_COLOR = '#F2F2F0'
306const QUOTE_FONT = "Helvetica, 'Helvetica Neue', Arial, sans-serif"
307
308// Built-in list, used when assets/quotes.txt is missing or unreadable.
309const DEFAULT_QUOTES = [
310 "Ship the ugly first draft.",
311 "Make the fucking thing.",
312 "Show it, don't explain it.",
313 "Cut it in half. Then again.",
314 "Done beats perfect.",
315 "Ask the dumb question.",
316 "Talk less. Ship more.",
317 "Test it your damn self.",
318 "Steal the pattern, not the work.",
319 "Clarity is a kindness.",
320 "Defaults are decisions.",
321 "If it needs a manual, fix it.",
322 "Name things like you mean it.",
323 "Make it fucking obvious.",
324 "Delete more than you add.",
325 "Prototype before you pitch.",
326 "Every click costs something.",
327 "The demo is the argument.",
328 "Sweat the first five seconds.",
329 "Small, sharp, shipped.",
330 "Write it down or it didn't happen.",
331 "Make the next step obvious.",
332 "Leave it better than you found it.",
333 "Trust the user. Test the assumption.",
334 "Good enough, then great.",
335]
336
337const loadQuotes = async ($: Dollar) => {
338 try {
339 // Read through the shell: the mod's own file access is limited to the session's folder.
340 const ran = await $.process.run(['sh', '-c', `cat "$HOME/${QUOTES_PATH}"`], { timeoutMs: 5000 })
341 if (ran.exitCode !== 0) throw new Error('unreadable')
342 const text = ran.stdout
343 // Paste-friendly: lines are joined, then split into sentences, so a pasted paragraph works.
344 const list = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
345 .join(' ').split(/(?<=[.!?])\s+/).map(q => q.trim()).filter(Boolean)
346 await update($, quotes, () => list)
347 } catch {
348 await update($, quotes, () => [])
349 }
350}
351
352const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
353
354// Today's pick. Stepping by a number coprime with the list length walks every quote
355// before repeating, and two days in a row never land on the same one.
356const quoteFor = (list: string[], now: number) => {
357 if (list.length === 0) return null
358 const dayNumber = Math.floor((now - new Date(now).getTimezoneOffset() * 60000) / 86400000)
359 let step = 7
360 while (gcd(step, list.length) !== 1) step += 1
361 return list[(dayNumber * step) % list.length]
362}
363
364const escapeXml = (t: string) =>
365 t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
366
367// Breaks a quote into lines of at most `max` characters, on word boundaries.
368const wrap = (t: string, max: number) => {
369 const lines: string[] = []
370 let line = ''
371 for (const word of t.split(/\s+/)) {
372 if (line && (line + ' ' + word).length > max) {
373 lines.push(line)
374 line = word
375 } else {
376 line = line ? `${line} ${word}` : word
377 }
378 }
379 if (line) lines.push(line)
380 return lines
381}
382
383// Big, bold, nearly-white type in the poster's spirit, drawn as SVG text so it scales.
384const quoteSvg = (t: string) => {
385 // Twice the old 30px. Long words shrink the type just enough to fit the 300-wide box
386 // (bold Helvetica runs about 0.52em per character).
387 const lines = wrap(t, 10)
388 const longest = Math.max(...lines.map(l => l.length))
389 const size = Math.min(60, Math.floor(300 / (longest * 0.52)))
390 const lead = Math.round(size * 1.08)
391 const height = lines.length * lead + 8
392 const tspans = lines.map((l, i) => `<tspan x="0" y="${size + i * lead}">${escapeXml(l)}</tspan>`).join('')
393 return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="${height}" viewBox="0 0 300 ${height}"><text font-family="${QUOTE_FONT}" font-weight="700" font-size="${size}" letter-spacing="-1" fill="${QUOTE_COLOR}">${tspans}</text></svg>`
394}
395
396// ── mochi: a chunky 8-bit dino, standing tall and grinning ────────────────────
397
398// One character per pixel: . empty, G body, D brow/shade, L belly, S spikes, W teeth, K mouth, E eyes.
399// No outline: bright shapes straight on the pane.
400const MOCHI = [
401 '....DDDDDDD.............',
402 '...DGGGGGGGD............',
403 '..GGEGGGGEGGG...........',
404 '.GGGGGGGGGGGGG..........',
405 '.GKGGGGGGGGKGGS.........',
406 '.GGKWWWWWWKGGGG.........',
407 '..GGKKKKKKGGGGS.........',
408 '...GGGGGGGGGGGG.........',
409 '....DGGGGGGGGS..........',
410 '....GGLLLLGGGGS.........',
411 '...GGGLLLLGGGGGS........',
412 '.GGGGGLLLLLGGGGGS.......',
413 'GG..GGLLLLLGGGGGGS..G...',
414 '....GGLLLLLGGGGGGGS.G...',
415 '....GGLLLLLLGGGGGGGGG...',
416 '....GGLLLLLLGGGGGGGS....',
417 '....GGGLLLLLGGGGGGGGS...',
418 '....GGGGGGGGGGGGGGGGGS..',
419 '....GGGG....GGGGGGGGGGS.',
420 '....GGG......GGG...GGGGS',
421 '....GGG......GGG.....GGG',
422 '...GGGG.....GGGG........',
423]
424
425// Bright retro palette: mint body, lime belly, aqua spikes, a big cheesy grin, tiny black eyes.
426const MOCHI_COLORS: Record<string, string> = {
427 G: '#4FD08A',
428 D: '#2E9E68',
429 L: '#B6F28C',
430 S: '#7FE3E0',
431 W: '#FFFFFF',
432 K: '#1B2420',
433 E: '#111111',
434}
435
436const mochiSvg = () => {
437 const px = 4
438 const w = (MOCHI[0] ?? '').length * px
439 const h = MOCHI.length * px
440 const cells = (keep: (c: string) => boolean) =>
441 MOCHI.flatMap((row, y) =>
442 [...row].map((c, x) => (c !== '.' && keep(c)
443 ? `<rect x="${x * px}" y="${y * px}" width="${px}" height="${px}" fill="${MOCHI_COLORS[c]}"/>`
444 : '')),
445 ).join('')
446
447 const body = cells(c => c !== 'S')
448 const spikes = cells(c => c === 'S')
449 // A blink is a body-colored lid over the eye for a moment, every few seconds.
450 const blink = '<animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.92;0.96;1" dur="4s" repeatCount="indefinite"/>'
451 const lid = [4, 9].map(x => `<rect x="${x * px}" y="${2 * px}" width="${px}" height="${px}" fill="${MOCHI_COLORS.G}" opacity="0">${blink}</rect>`).join('')
452
453 // Room around mochi to jump, spin and flip without clipping.
454 const padX = 32
455 const padTop = 48
456 const W = w + padX * 2
457 const H = padTop + h + px
458 const cx = padX + w / 2
459 const cy = padTop + h / 2
460 const feet = H
461 const { tx, ty, r, s, dur } = mochiDance()
462
463 const anim = (type: string, values: string) =>
464 `<animateTransform attributeName="transform" type="${type}" values="${values}" keyTimes="${tx.keyTimes}" dur="${dur}s" repeatCount="indefinite"/>`
465
466 // Nested groups: hop/slide, then spin around the middle, then squash/flip from the feet.
467 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" shape-rendering="crispEdges">`
468 + `<g>${anim('translate', tx.values.map((x, i) => `${x} ${ty.values[i]}`).join(';'))}`
469 + `<g>${anim('rotate', r.values.map(a => `${a} ${cx} ${cy}`).join(';'))}`
470 + `<g transform="translate(${cx} ${feet})"><g>${anim('scale', s.values.join(';'))}<g transform="translate(${-cx} ${-feet})">`
471 + `<g transform="translate(${padX} ${padTop})">`
472 + `<g><animateTransform attributeName="transform" type="translate" values="0 ${px};0 0;0 ${px}" dur="1.6s" calcMode="discrete" repeatCount="indefinite"/>`
473 + body + lid
474 + `<g>${spikes}<animate attributeName="opacity" values="1;0.55;1" dur="2.4s" calcMode="discrete" repeatCount="indefinite"/></g>`
475 + '</g></g></g></g></g></g></g></svg>'
476}
477
478// ── mochi's dance moves: a random routine, picked fresh each time the mod loads ──
479
480type Pose = { tx: number; ty: number; r: number; sx: number; sy: number }
481// Each step: seconds to get there, and the pose reached (unset fields are at rest; r is relative to the move's start).
482type Move = Array<[number, Partial<Pose>]>
483
484const MOVES: Record<string, Move> = {
485 bounce: [
486 [0.12, { sx: 1.15, sy: 0.85 }], [0.25, { ty: -28, sx: 0.92, sy: 1.08 }], [0.25, { sx: 1.12, sy: 0.88 }], [0.1, {}],
487 [0.22, { ty: -18, sx: 0.94, sy: 1.06 }], [0.22, { sx: 1.1, sy: 0.9 }], [0.12, {}],
488 ],
489 dance: [
490 [0.22, { tx: -8, ty: -4, r: -12 }], [0.22, { tx: 8, ty: -4, r: 12 }], [0.22, { tx: -8, ty: -4, r: -12 }],
491 [0.22, { tx: 8, ty: -4, r: 12 }], [0.22, { tx: -8, ty: -4, r: -12 }], [0.22, { tx: 8, ty: -4, r: 12 }], [0.2, {}],
492 ],
493 spin: [
494 [0.12, { sx: 1.1, sy: 0.9 }], [0.6, { ty: -12, r: 360 }], [0.14, { r: 360, sx: 1.1, sy: 0.9 }], [0.1, { r: 360 }],
495 ],
496 backflip: [
497 [0.15, { sx: 1.15, sy: 0.85 }], [0.32, { ty: -38, r: -180, sx: 0.95, sy: 1.05 }], [0.32, { r: -360 }],
498 [0.12, { r: -360, sx: 1.14, sy: 0.86 }], [0.12, { r: -360 }],
499 ],
500 turnaround: [
501 [0.18, { sx: -1 }], [0.15, { sx: -1, ty: -8 }], [0.15, { sx: -1 }], [0.15, { sx: -1, ty: -8 }], [0.15, { sx: -1 }], [0.18, {}],
502 ],
503 shimmy: [
504 [0.07, { tx: -4 }], [0.07, { tx: 4 }], [0.07, { tx: -4 }], [0.07, { tx: 4 }],
505 [0.07, { tx: -4 }], [0.07, { tx: 4 }], [0.07, { tx: -4 }], [0.07, { tx: 4 }], [0.07, {}],
506 ],
507}
508
509const mochiDance = () => {
510 const names = Object.keys(MOVES)
511 const rest: Pose = { tx: 0, ty: 0, r: 0, sx: 1, sy: 1 }
512 const frames: Array<[number, Pose]> = [[0, rest]]
513 let t = 0
514 let base = 0
515 let last = ''
516 const hold = (secs: number) => {
517 t += secs
518 frames.push([t, { ...rest, r: base }])
519 }
520
521 hold(0.8 + Math.random())
522 for (let i = 0; i < 12; i++) {
523 let name: string
524 do name = names[Math.floor(Math.random() * names.length)]!
525 while (name === last)
526 last = name
527 for (const [dt, pose] of MOVES[name]!) {
528 t += dt
529 frames.push([t, { ...rest, ...pose, r: base + (pose.r ?? 0) }])
530 }
531 base = frames[frames.length - 1]![1].r
532 hold(0.6 + Math.random() * 2)
533 }
534
535 // Whole turns look the same, so the loop can jump back to 0° unseen.
536 const keyTimes = frames.map(([at], i) => (i === frames.length - 1 ? '1' : (at / t).toFixed(4))).join(';')
537 const pick = (f: (p: Pose) => string | number) => ({ keyTimes, values: frames.map(([, p]) => f(p)) })
538 return {
539 dur: t.toFixed(2),
540 tx: pick(p => p.tx),
541 ty: pick(p => p.ty),
542 r: pick(p => p.r),
543 s: pick(p => `${p.sx} ${p.sy}`),
544 }
545}
546
547// Built once per load, so redraws don't restart the routine.
548const MOCHI_SVG = mochiSvg()
549
550// ── pizza: a dripping pepperoni slice, side view, ringed by muted code glyphs ───────────────────────────
551
552// Bright slice, slate-grey glyphs, one blinking text cursor. No background.
553// The slice floats; its cheese drips stretch a touch as it rises. The code symbols stay put.
554const PIZZA_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="220" viewBox="0 0 300 220"><g font-family="Menlo, Monaco, monospace" font-weight="700"><text x="14" y="38" font-size="24" fill="#555E73">{ }</text><text x="232" y="30" font-size="20" fill="#4B5366"></></text><text x="16" y="110" font-size="18" fill="#606A80">#</text><text x="250" y="140" font-size="24" fill="#555E73">;</text><text x="226" y="196" font-size="18" fill="#4B5366">$_</text><text x="120" y="212" font-size="16" fill="#606A80">=></text><text x="18" y="200" font-size="16" fill="#555E73">[ ]</text></g><rect x="276" y="96" width="3" height="18" fill="#606A80"><animate attributeName="opacity" values="1;1;0;0" dur="1.1s" repeatCount="indefinite"/></rect><path d="M86 26 l0 24 l6 -5 l5 10 l4 -2 l-5 -10 l8 0 z" fill="#606A80"/><g><path d="M248 98 L60 158 L60 168 L250 110 Z" fill="#E3A35A"/><path d="M138 44 Q200 58 250 96 L58 158 Q92 98 138 44 Z" fill="#FFD23F"/><path d="M120 70 q30 6 50 18 M90 118 q40 -8 70 -4" stroke="#F4B728" stroke-width="5" fill="none" stroke-linecap="round" opacity="0.7"/><g fill="#FFC93C"><path d="M58 152 L250 92 L250 100 Q200 116 150 128 Q100 142 60 160 Z"/><path d="M76 140 h10 v48 a5.0 5.0 0 0 1 -10 0 "><animate attributeName="d" values="M76 140 h10 v48.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v48.27 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v49.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v50.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v51.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v51.73 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v52.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v51.73 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v51.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v50.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v49.00 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v48.27 a5.0 5.0 0 0 1 -10 0 ;M76 140 h10 v48.00 a5.0 5.0 0 0 1 -10 0 " dur="3.6s" repeatCount="indefinite" keyTimes="0;0.0833;0.1667;0.25;0.3333;0.4167;0.5;0.5833;0.6667;0.75;0.8333;0.9167;1"/></path><path d="M96 133 h9 v61 a4.5 4.5 0 0 1 -9 0 "><animate attributeName="d" values="M96 133 h9 v61.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v61.27 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v62.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v63.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v64.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v64.73 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v65.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v64.73 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v64.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v63.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v62.00 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v61.27 a4.5 4.5 0 0 1 -9 0 ;M96 133 h9 v61.00 a4.5 4.5 0 0 1 -9 0 " dur="3.6s" repeatCount="indefinite" keyTimes="0;0.0833;0.1667;0.25;0.3333;0.4167;0.5;0.5833;0.6667;0.75;0.8333;0.9167;1"/></path><path d="M140 119 h8 v39 a4.0 4.0 0 0 1 -8 0 "><animate attributeName="d" values="M140 119 h8 v39.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v39.27 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v40.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v41.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v42.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v42.73 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v43.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v42.73 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v42.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v41.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v40.00 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v39.27 a4.0 4.0 0 0 1 -8 0 ;M140 119 h8 v39.00 a4.0 4.0 0 0 1 -8 0 " dur="3.6s" repeatCount="indefinite" keyTimes="0;0.0833;0.1667;0.25;0.3333;0.4167;0.5;0.5833;0.6667;0.75;0.8333;0.9167;1"/></path><path d="M186 104 h9 v54 a4.5 4.5 0 0 1 -9 0 "><animate attributeName="d" values="M186 104 h9 v54.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v54.27 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v55.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v56.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v57.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v57.73 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v58.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v57.73 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v57.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v56.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v55.00 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v54.27 a4.5 4.5 0 0 1 -9 0 ;M186 104 h9 v54.00 a4.5 4.5 0 0 1 -9 0 " dur="3.6s" repeatCount="indefinite" keyTimes="0;0.0833;0.1667;0.25;0.3333;0.4167;0.5;0.5833;0.6667;0.75;0.8333;0.9167;1"/></path><path d="M228 91 h7 v35 a3.5 3.5 0 0 1 -7 0 "><animate attributeName="d" values="M228 91 h7 v35.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v35.27 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v36.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v37.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v38.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v38.73 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v39.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v38.73 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v38.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v37.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v36.00 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v35.27 a3.5 3.5 0 0 1 -7 0 ;M228 91 h7 v35.00 a3.5 3.5 0 0 1 -7 0 " dur="3.6s" repeatCount="indefinite" keyTimes="0;0.0833;0.1667;0.25;0.3333;0.4167;0.5;0.5833;0.6667;0.75;0.8333;0.9167;1"/></path></g><g fill="#E8404F"><ellipse cx="160" cy="74" rx="13" ry="8" transform="rotate(20 160 74)"/><ellipse cx="205" cy="92" rx="12" ry="7.5" transform="rotate(20 205 92)"/><ellipse cx="128" cy="96" rx="12" ry="7.5" transform="rotate(20 128 96)"/><ellipse cx="160" cy="114" rx="11" ry="7" transform="rotate(20 160 114)"/><ellipse cx="96" cy="128" rx="10" ry="6.5" transform="rotate(20 96 128)"/></g><g fill="#FF8A93"><ellipse cx="156" cy="72" rx="4" ry="2.5" transform="rotate(20 156 72)"/><ellipse cx="201" cy="90" rx="4" ry="2.5" transform="rotate(20 201 90)"/><ellipse cx="124" cy="94" rx="4" ry="2.5" transform="rotate(20 124 94)"/></g><path d="M136 46 Q200 56 252 98" stroke="#D98A3D" stroke-width="22" fill="none" stroke-linecap="round"/><path d="M138 40 Q198 50 250 90" stroke="#F0AE5E" stroke-width="12" fill="none" stroke-linecap="round"/><path d="M170 46 q6 6 0 12 M206 60 q6 6 0 12 M234 78 q6 6 -1 12" stroke="#C97A30" stroke-width="2.5" fill="none" stroke-linecap="round"/><animateTransform attributeName="transform" type="translate" values="0 2.00;0 1.46;0 0.00;0 -2.00;0 -4.00;0 -5.46;0 -6.00;0 -5.46;0 -4.00;0 -2.00;0 0.00;0 1.46;0 2.00" dur="3.6s" repeatCount="indefinite" keyTimes="0;0.0833;0.1667;0.25;0.3333;0.4167;0.5;0.5833;0.6667;0.75;0.8333;0.9167;1"/></g></svg>'
555
556// A line of small UI type, drawn as SVG since Text has no size.
557const smallText = (t: string, color: string) =>
558 `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="16" viewBox="0 0 240 16"><text x="0" y="12" font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif" font-size="11" fill="${color}">${escapeXml(t)}</text></svg>`
559
560// ── shared formatting ─────────────────────────────────────────────────────────
561
562const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
563const weekday = (ms: number) => new Date(ms).toLocaleDateString([], { weekday: 'long' })
564
565const until = (ms: number) => {
566 const m = Math.max(0, Math.round(ms / 60000))
567 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
568}
569
570const ago = (ms: number) => {
571 const m = Math.max(0, Math.round(ms / 60000))
572 return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`
573}
574
575const cut = (t: string, n: number) => (t.length > n ? `${t.slice(0, Math.max(1, n - 1))}…` : t)
576
577const GROUPS: { state: Ticket['state']; label: string }[] = [
578 { state: 'failing', label: 'checks failing' },
579 { state: 'waiting', label: 'waiting on your merge' },
580 { state: 'cooking', label: 'cooking' },
581]
582
583// ── wiring ────────────────────────────────────────────────────────────────────
584
585export const register: Register = on => {
586 on('session.start', async ($, e, next) => {
587 await $.command.register({ name: 'workbench', description: 'Show or hide the Workbench side pane (greg & birdy)' })
588 await $.command.register({ name: 'greg', description: 'Show the Workbench side pane' })
589 await $.command.register({ name: 'birdy', description: 'Show the Workbench side pane' })
590 await $.command.register({ name: 'greg-check', description: 'Have greg and birdy check right now' })
591 await $.command.register({ name: 'quotes', description: 'Open the Workbench quote list to edit (paste text, one sentence each)' })
592 await $.command.register({ name: 'prep', description: 'Birdy preps you for your next meeting' })
593 await $.command.register({ name: 'fun', description: 'Fun mode on or off: quote, pizza and mochi (stays until you flip it)' })
594
595 const savedFun = (await $.store.get(FUN_KEY)) === true
596 await update($, fun, () => savedFun)
597
598 void loadQuotes($)
599 void checkPass($)
600 void checkDay($)
601 $.clock.every(GREG_EVERY_MS, () => void checkPass($))
602 $.clock.every(BIRDY_EVERY_MS, () => void checkDay($))
603 $.clock.every(60 * 1000, () => void tick($))
604 if (!(await read($, isHidden))) void $.ui.open({ id: PANE, title: TITLE })
605
606 return next(e)
607 })
608
609 // /hush (owned by bento) hides the Workbench; /hush again brings it back.
610 on('state.set', { plugin: 'bento', key: 'hushed' }, async ($, e, next) => {
611 const result = await next(e)
612 if (e.value) {
613 await update($, isHidden, () => true)
614 await $.ui.close({ id: PANE })
615 } else {
616 await show($)
617 }
618 return result
619 }).catch(($, e, next) => next(e)) // a failed hide never blocks /hush itself
620
621 // Closing the pane yourself counts as hiding it; greg and birdy keep checking either way.
622 on('ui.close', async ($, e, next) => {
623 if (e.id === PANE && e.origin.kind === 'person') await update($, isHidden, () => true)
624 return next(e)
625 }).catch(($, e, next) => next(e)) // the pane still closes if remembering that fails
626
627 for (const command of ['greg', 'birdy']) {
628 on('command.run', { command }, async $ => {
629 await show($)
630 return { text: 'Workbench is open.' }
631 })
632 }
633
634 on('command.run', { command: 'workbench' }, async $ => {
635 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
636 if (!isOpen) {
637 await show($)
638 return { text: 'Workbench is open.' }
639 }
640
641 await update($, isHidden, () => true)
642 await $.ui.close({ id: PANE })
643 return { text: 'Workbench tucked away. /workbench brings it back.' }
644 })
645
646 // /fun flips it; /fun on and /fun off set it outright.
647 on('command.run', { command: 'fun' }, async ($, e) => {
648 const arg = e.args.trim().toLowerCase()
649 const isFun = await update($, fun, was => (arg === 'on' ? true : arg === 'off' ? false : !was))
650 await $.store.set(FUN_KEY, isFun)
651 return { text: isFun ? 'Fun mode on: quote, pizza and mochi are back. Stays on until /fun again.' : 'Fun mode off: quote, pizza and mochi hidden. Stays off until /fun again.' }
652 })
653
654 on('command.run', { command: 'greg-check' }, async $ => {
655 await Promise.all([checkPass($), checkDay($)])
656 return { text: 'Greg and birdy checked in.' }
657 })
658
659 on('command.run', { command: 'quotes' }, async $ => {
660 const opened = await $.process.run(['sh', '-c', `open -t "$HOME/${QUOTES_PATH}"`], { timeoutMs: 5000 })
661 await loadQuotes($)
662 return { text: opened.exitCode === 0 ? 'Opened your quote list in TextEdit. Save it, then run /quotes again to reload.' : `Could not open ~/${QUOTES_PATH}` }
663 })
664
665 on('command.run', { command: 'prep' }, async $ => {
666 await checkDay($)
667 const d = await read($, day)
668 const { current, upcoming } = pick(d)
669 const m = upcoming[0] ?? current ?? (d.ahead ?? [])[0]
670 if (!m) return { text: 'Birdy sees no more meetings today or next morning.' }
671
672 void $.prompt.submit({ text: prepPrompt(m, d.now) })
673 return { text: `Birdy is prepping you for ${m.title}…` }
674 })
675
676 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
677 const f = await read($, flash)
678 if (e.props.hasSurvey || !f) return next(e)
679
680 const { Box, Text, Button } = $.ui.resolve(e)
681 const below = await next(e)
682
683 return (
684 <Box flexDirection="column">
685 <Box gap={1}>
686 <Text>
687 <Text bold color={f.color}>{f.glyph} {f.name} </Text>
688 <Text color={TEXT}>{f.text}</Text>
689 </Text>
690 <Button key="flash-close" plain dimColor label="×" onPress={() => update($, flash, () => null)} />
691 </Box>
692 {below}
693 </Box>
694 )
695 })
696
697 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
698 const { Box, Text, Button, Link } = $.ui.resolve(e)
699 const p = await read($, pass)
700 const d = await read($, day)
701 const loaded = await read($, quotes)
702 const list = loaded.length ? loaded : DEFAULT_QUOTES
703 const isQuoteHidden = await read($, quoteHidden)
704 const isFun = await read($, fun)
705 const quote = quoteFor(list, d.now || p.checkedAt || Date.now())
706 const width = Math.max(24, (e.props.bodyColumns ?? e.viewport?.columns ?? 48) - 2)
707 const dot = <Text color={DATA}> · </Text>
708
709 // birdy
710 const { current, upcoming } = pick(d)
711 const next = upcoming[0]
712 const later = upcoming.slice(1, 6)
713 // Older saved state from before the look ahead existed has no ahead list.
714 const ahead = d.ahead ?? []
715 const firstAhead = ahead[0]
716 const who = (m: Meeting) => (m.outside.length ? m.outside.slice(0, 2).join(', ') : 'internal')
717
718 const birdy = (
719 <Box flexDirection="column">
720 <Text bold color={BIRDY}>◷ birdy</Text>
721 {d.checkedAt === 0 && <Text color={DATA}>looking at your calendar…</Text>}
722 {d.error && <Text color={TEXT}>{d.error}</Text>}
723 {!d.error && current && (
724 <Text>
725 <Text color={TEXT}>now </Text>
726 {current.link ? <Link href={current.link}><Text color={TEXT}>{cut(current.title, width - 18)}</Text></Link> : <Text color={TEXT}>{cut(current.title, width - 18)}</Text>}
727 {dot}<Text color={DATA}>ends in {until(current.end - d.now)}</Text>
728 </Text>
729 )}
730 {!d.error && next && (
731 <Box flexDirection="column">
732 <Text>
733 <Text color={TEXT}>next </Text>
734 {next.link ? <Link href={next.link}><Text bold color={TEXT}>{cut(next.title, width - 16)}</Text></Link> : <Text bold color={TEXT}>{cut(next.title, width - 16)}</Text>}
735 </Text>
736 <Text>
737 <Text color={DATA}>in {until(next.start - d.now)}</Text>
738 {dot}<Text color={DATA}>{clock(next.start)}</Text>
739 {dot}<Text color={DATA}>{next.people}</Text><Text color={TEXT}> people</Text>
740 {dot}<Text color={DATA}>{who(next)}</Text>
741 </Text>
742 <Box>
743 <Button key="prep" label="Prep me" onPress={() => void $.prompt.submit({ text: prepPrompt(next, d.now) })} />
744 </Box>
745 </Box>
746 )}
747 {!d.error && d.checkedAt > 0 && !current && !next && (
748 <Box flexDirection="column">
749 <Text bold color={TEXT}>No More Meetings</Text>
750 <Text color={DATA}> </Text>
751 <Text color={DATA}>look ahead · {weekday(d.aheadDay)} morning</Text>
752 {ahead.length === 0 && <Text color={TEXT}>nothing before noon, cook away</Text>}
753 {ahead.map(m => (
754 <Text>
755 <Text color={DATA}>{clock(m.start).padEnd(8)} </Text>
756 {m.link ? <Link href={m.link}><Text color={TEXT}>{cut(m.title, width - 10)}</Text></Link> : <Text color={TEXT}>{cut(m.title, width - 10)}</Text>}
757 {dot}<Text color={DATA}>{who(m)}</Text>
758 </Text>
759 ))}
760 {firstAhead && (
761 <Box>
762 <Button key="prep-ahead" label="Prep me" onPress={() => void $.prompt.submit({ text: prepPrompt(firstAhead, d.now) })} />
763 </Box>
764 )}
765 </Box>
766 )}
767 {later.length > 0 && <Text color={DATA}> </Text>}
768 {later.map(m => (
769 <Text>
770 <Text color={DATA}>{clock(m.start).padEnd(8)} </Text>
771 {m.link ? <Link href={m.link}><Text color={TEXT}>{cut(m.title, width - 10)}</Text></Link> : <Text color={TEXT}>{cut(m.title, width - 10)}</Text>}
772 </Text>
773 ))}
774 </Box>
775 )
776
777 // greg
778 const greg = (
779 <Box flexDirection="column">
780 <Text bold color={GREG}>▤ greg</Text>
781 {p.checkedAt === 0 && <Text color={DATA}>checking the pass…</Text>}
782 {p.error && <Text color={TEXT}>{p.error}</Text>}
783 {!p.error && p.checkedAt > 0 && p.tickets.every(t => t.state === 'merged') && <Text color={TEXT}>everything is merged and good!</Text>}
784 {!p.error && GROUPS.map(g => {
785 const list = p.tickets.filter(t => t.state === g.state)
786 if (list.length === 0) return null
787
788 return (
789 <Box flexDirection="column" marginTop={1}>
790 <Text><Text color={TEXT}>{g.label} </Text><Text color={DATA}>{list.length}</Text></Text>
791 {list.slice(0, 6).map(t => (
792 <Box flexDirection="column">
793 <Box key={`pr-${t.repo}-${t.number}`} gap={1}>
794 {/* A Link takes styled children, so the lavender title itself opens the PR.
795 The app draws its own (blue) hover underline; mods can't restyle it. */}
796 <Link href={t.url}><Text color={LINK}>#{t.number} {cut(t.title, width - 10)}</Text></Link>
797 <Button plain label="↗" hover={{ color: LINK }} onPress={() => void openPr($, t.url)} />
798 </Box>
799 <Text color={DATA}> {t.repo.split('/')[1]}{t.mergedAt ? ` · ${ago(d.now - Date.parse(t.mergedAt))}` : ''}</Text>
800 </Box>
801 ))}
802 </Box>
803 )
804 })}
805 {p.checkedAt > 0 && (e.surface === 'terminal'
806 ? <Text color={MUTED}>✓ checked {ago((d.now || p.checkedAt) - p.checkedAt)}</Text>
807 : (() => {
808 // Small type sits snug under the list, so it reads as part of greg.
809 const { Svg } = $.ui.resolve(e)
810 const label = `✓ checked ${ago((d.now || p.checkedAt) - p.checkedAt)}`
811 return <Svg source={smallText(label, MUTED)} alt={label} />
812 })())}
813 </Box>
814 )
815
816 const toggleQuote = (
817 // Buttons can't take a text color, so the words are muted Text and the glyph is the click target.
818 <Box key="quote-toggle" gap={1}>
819 <Button
820 plain
821 dimColor
822 label={isQuoteHidden ? '+' : '−'}
823 hover={{ color: DATA }}
824 onPress={() => update($, quoteHidden, was => !was)}
825 />
826 <Text color={MUTED}>{isQuoteHidden ? 'show quote' : 'hide quote'}</Text>
827 </Box>
828 )
829
830 const quoteBlock = !quote || !isFun ? null : (
831 <Box flexDirection="column">
832 {!isQuoteHidden && (e.surface === 'terminal'
833 ? <Text bold color={QUOTE_COLOR}>{quote}</Text>
834 : (() => {
835 const { Svg } = $.ui.resolve(e)
836 return <Svg source={quoteSvg(quote)} alt={quote} />
837 })())}
838 <Box>{toggleQuote}</Box>
839 </Box>
840 )
841
842 return (
843 <Box flexDirection="column" paddingX={1}>
844 {birdy}
845 <Text color={DATA}> </Text>
846 {greg}
847 {isFun && e.surface !== 'terminal' && (() => {
848 const { Svg } = $.ui.resolve(e)
849 // Between greg and the quote, with 6 rows of space below it.
850 return (
851 <Box marginTop={2} marginBottom={6} justifyContent="center">
852 <Svg source={PIZZA_SVG} alt="a pizza slice surrounded by code symbols" />
853 </Box>
854 )
855 })()}
856 {e.surface === 'terminal' && quoteBlock && <Text color={DATA}> </Text>}
857 {quoteBlock}
858 {isFun && e.surface !== 'terminal' && (() => {
859 const { Svg } = $.ui.resolve(e)
860 // Mochi sits centered under the quote, 8 rows down.
861 return (
862 <Box marginTop={8} justifyContent="center">
863 <Svg source={MOCHI_SVG} alt="mochi, a pixel dino" />
864 </Box>
865 )
866 })()}
867 {/* linkydoos sits under mochi. */}
868 <Box flexDirection="column" marginTop={2}>
869 <Text bold color={LINKY}>linkydoos ↗</Text>
870 {LINKYDOOS.map(l => (
871 <Box flexDirection="column" marginTop={1}>
872 <Text color={TEXT}>{l.name}</Text>
873 <Text>
874 <Link href={l.site}><Text color={DATA}>site</Text></Link>
875 {dot}
876 <Link href={`${GH}${l.repo}`}><Text color={DATA}>repo</Text></Link>
877 {dot}
878 <Link href={`${GH}${l.repo}/pulls`}><Text color={DATA}>PRs</Text></Link>
879 </Text>
880 </Box>
881 ))}
882 </Box>
883 </Box>
884 )
885 })
886}
887types/index.d.ts 37 lines1export type Ticket = {
2 number: number
3 title: string
4 url: string
5 repo: string
6 // waiting: ready for your merge · failing: checks red · cooking: checks running or draft · merged: landed recently
7 state: 'waiting' | 'failing' | 'cooking' | 'merged'
8 mergedAt?: string
9}
10
11export type Pass = { tickets: Ticket[]; checkedAt: number; error?: string }
12
13export type Meeting = {
14 id: string
15 title: string
16 start: number
17 end: number
18 people: number
19 // Attendee domains outside a8c.com: who the customer or partner is. Domains only, never emails.
20 outside: string[]
21 // Opens the event in Google Calendar.
22 link?: string
23}
24
25export type Flash = { glyph: string; name: string; color: string; text: string; id: number }
26
27// meetings: the rest of today. ahead: next business day 6am-noon, shown once today is done.
28export type Day = { meetings: Meeting[]; ahead: Meeting[]; aheadDay: number; checkedAt: number; now: number; error?: string }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'workbench': { pass: Pass; day: Day; nudged: string[]; isHidden: boolean; quotes: string[]; quoteHidden: boolean; fun: boolean; flash: Flash | null }
33 // Owned by bento; watched here so /hush hides the Workbench too.
34 'bento': { hushed: boolean }
35 }
36}
37