SLOPSHOPPER

workbench

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

newpanebandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-07wpvip-awesomeface/claude-mods/fun/workbench
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench
│ ┃ Workbench ✕ › fix the failing auth test and add an audit log call │ ┃ ◷ birdy │ ┃ calendar connector not connected yet ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▤ greg ⏺ Update(src/auth.ts) │ ┃ gh is not available ⎿ Added 2 lines, removed 1 line │ ┃ ✓ checked just now ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Prototype before you pitch. │ ┃ − hide quote ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ linkydoos ↗ │ ┃ › /workbench │ ┃ vip demo ⎿ workbench: Workbench tucked away. /workbench brings it back. │ ┃ https://wpvipdemo.dev/ · │ ┃ https://github.com/wpcomvip/vip-soleng-vip │ ┃ -demo · │ ┃ https://github.com/wpcomvip/vip-soleng-vip │ ┃ -demo/pulls │ ┃ │ ┃ federal gov │ ┃ https://wpvipgov.com/ · │ ┃ https://github.com/wpcomvip/vip-soleng-us- │ ┃ federal-government-showme · │ ┃ https://github.com/wpcomvip/vip-soleng-us- ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workbench
◷ birdy calendar connector not connected yet ▤ greg gh is not available ✓ checked just now Prototype before you pitch. − hide quote linkydoos ↗ vip demo https://wpvipdemo.dev/ · https://github.com/wpcomvip/vip-soleng-vip-demo · https://github.com/wpcomvip/vip-soleng-vip-demo/pulls federal gov https://wpvipgov.com/ · https://github.com/wpcomvip/vip-soleng-us-federal-governme nt-showme · https://github.com/wpcomvip/vip-soleng-us-federal-governme nt-showme/pulls publisher https://vip-news.digital/ · https://github.com/wpcomvip/vip-soleng-publisher-demo · https://github.com/wpcomvip/vip-soleng-publisher-demo/pull s
README

Claude Code mods

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.

Install

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:

  • turns the core mods on for new sessions (via CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json)
  • turns on hot reload (CLAUDE_CODE_PLUGIN_DIR_WATCH=1)
  • links the mods command into ~/.local/bin

Turn mods on or off

mods                 # 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/

The mods

ModWhat you seeSaves tokens?
bentoA band above the prompt showing context use, turns and subagentsYes, directly
turn-timer"Speed Radar": how long each reply tookIndirectly
recipe-cardA card under each answer showing the route line, with /recipe to save a solveYes, over time
workbench (in fun/)A side pane with your open PRs, next meeting, pizza and a dinoNo, it's for focus and fun

bento

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.

turn-timer (Speed Radar)

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.

recipe-card

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.

workbench (fun)

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:

  • greg: your open PRs, plus PRs merged in the last 3 hours, checked every 3 minutes
  • birdy: a countdown to your next meeting, a nudge 10 minutes before, and /prep to get ready for it
  • linkydoos: one-click links to each demo site, repo and its open PRs
  • fun mode (/fun, off by default): a quote of the day, pizza and mochi the dino

Why: 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.

Token kit

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.

Notes

  • Each mod's code is in <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.
  • More on cutting token spend: Token Spend Playbook.
Source 2 files
hooks/register.tsx 887 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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">&lt;/&gt;</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">=&gt;</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}
887
types/index.d.ts 37 lines
1export 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