SLOPSHOPPER

dashboard

Bento dashboard in the docked side pane: clock, context and compaction forecast, todo, git, Hacker News. Widgets on a pure contract.

newpanebandguardcommandtoast
v0.1.0GPL-3.0-or-laterupdated 2026-10-02shabier/claude-plugins/plugins/dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─ TIME ──────────────────────────────────── │ ┃ │ ⣶⠶⣶⠀⣀⣶⠀⠀⠀⣀⠀⠀⣶⠶⠶⠀⠶⠶⣶⠀ ⏺ Read(src/auth.ts) │ ┃ │ ⣿⠀⣿⠀⠉⣿⠀⠀⠀⣉⠀⠀⠿⠶⣶⠀⠀⠶⣿⠀ ⎿ Read 6 lines │ ┃ │ ⠿⠶⠿⠀⠶⠿⠶⠀⠀⠉⠀⠀⠶⠶⠿⠀⠶⠶⠿⠀ ⏺ Update(src/auth.ts) │ ┃ │ THU 09 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /dash │ ┃ ⎿ dashboard: Dashboard opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
╭─ TIME ───────────────────────────────────────────────╮ │ ⣶⠶⣶⠀⣀⣶⠀⠀⠀⣀⠀⠀⣶⠶⠶⠀⠶⠶⣶⠀ │ │ ⣿⠀⣿⠀⠉⣿⠀⠀⠀⣉⠀⠀⠿⠶⣶⠀⠀⠶⣿⠀ │ │ ⠿⠶⠿⠀⠶⠿⠶⠀⠀⠉⠀⠀⠶⠶⠿⠀⠶⠶⠿⠀ │ │ THU 09 j: down k: up x: done d: delete
README

dashboard

A bento grid in the side pane: clock, todo, context forecast, git, Hacker News. /dash opens it.

Commands

CommandDoes
/dashopen or close
/dash listevery widget, its size, the sizes it takes
/dash add <widget> [WxH]add one, default size unless given
/dash rm <widget>remove one; its data stays
/dash size <widget> <WxH>resize
/dash resetback to the default layout
`/dash autostart on\off`open at session start. Off by default

Keys

Click the pane or ctrl+x tab to give it the keys, Esc to hand them back.

The todo tile owns them: j k move, x done, d delete. Its last row says so too. n moves focus once a second tile has keys.

+ buy milk in the prompt adds a todo and never reaches Claude. Single lines only, so a pasted diff still does.

Widgets

WidgetSizesShows
clock2x1, 1x1time in dot-matrix, date, seconds
todo2x2, 2x1, 4x1the list
context2x1, 2x2share of the compaction point, tokens left, turns left at the current pace. Cost on API keys; hidden on subscriptions, where it means nothing
git2x2, 4x2, 2x1branch, ahead/behind, recent commits, changed files with +/- bars
hn2x2, 4x2, 2x1top 10 stories, every 5 min. Click a title for the thread

Layout order is packing order. Tiles stretch to fill the pane.

Git is read-only by construction: the host runs 4 fixed read commands and nothing else, after every edit Claude makes and every 10s. The tile follows the folder Claude runs in.

Writing a widget

  1. hooks/widgets/<id>.ts, built with defineWidget from hooks/contract.ts.
  2. Add it to hooks/widgets/index.ts.
  3. claude plugin test plugins/dashboard.

Widgets are pure: init, update, draw, persist. No $, no timers. The host owns every effect (ticks, session numbers, git, fetches, storage) and hands the data in. Colours are tones, never hex: fg dim faint accent add mod del info link. Links go through tile.link, https only.

The suite runs every registered widget through 19 inputs at every size: no throws, under 50ms a draw, nothing outside its tile, state through a JSON round trip. A widget that throws at runtime gets an error tile; the rest carry on.

contract: 1. Additions only. A break would be v2, and the host refuses versions it doesn't know.

Limits

  • Height-only window resizes don't refit; Claude Code redraws on width. Toggle /dash.
  • A fresh session shows context after its first response.
  • Turns left needs 2 answered turns to have a pace.

Credits: CREDITS.md.

Source 14 files
hooks/register.tsx 518 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, SessionUsage } from 'claude-code'
6
7import type { DashSlot } from '../types'
8import type { GitSnapshot, Input, Json, SessionSnapshot, Step, Widget } from './contract'
9import { GIT_READS, NOT_A_REPO, snapshot } from './gitparse'
10import { pack, parseSize, showSize, UNIT_ROWS, unitsFor } from './grid'
11import { safeHref } from './href'
12import { Board, encode, PALETTE } from './kit'
13import { WIDGETS } from './widgets'
14
15const PANE = 'dashboard'
16const TITLE = 'Dashboard'
17// Dock floor. Below it the engine seats panes inline, above the prompt.
18const DOCK_COLUMNS = 110
19// A request, not a grant: 72 fits 4 grid units. A hand-dragged width wins.
20const WANT_COLUMNS = 72
21const TICK_MS = 1000
22const WATCH_MS = 250
23const GIT_MS = 10_000
24// Fallback only, before the pane reports its body height.
25const CHROME_ROWS = 6
26const GIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
27
28// The list needs the height; the context bar does not.
29const DEFAULT_LAYOUT: DashSlot[] = [
30  { id: 'clock', size: { w: 2, h: 1 } },
31  { id: 'todo', size: { w: 2, h: 2 } },
32  { id: 'context', size: { w: 2, h: 1 } },
33  { id: 'git', size: { w: 4, h: 2 } },
34  { id: 'hn', size: { w: 4, h: 2 } },
35]
36
37const layoutRef = atom({ plugin: 'dashboard', key: 'layout' } as const, DEFAULT_LAYOUT)
38const focusRef = atom({ plugin: 'dashboard', key: 'focus' } as const, '')
39
40// The host's mirror of the layout, for work outside a render.
41let slots: DashSlot[] = DEFAULT_LAYOUT
42const states = new Map<string, unknown>()
43const failures = new Map<string, string>()
44const ticked = new Map<string, number>()
45let session: SessionSnapshot = { window: 0, turns: 0, history: [] }
46let lastGit: GitSnapshot | undefined
47let gitTimer: { cancel: () => void } | undefined
48let breakdownAt = 0
49// Where git runs, for the "not a git repo" line.
50let where: string | undefined
51let size = { columns: 0, rows: 0 }
52let isVisible = false
53let placement: 'dock' | 'inline' | undefined
54let isHiddenByWidth = false
55let viewport = { columns: 0, isFullscreen: false }
56
57const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, Math.floor(n)))
58const widgetOf = (id: string) => WIDGETS.find(w => w.id === id)
59const active = () => slots.flatMap(s => widgetOf(s.id) ?? [])
60// Only tiles with keys take focus: focusing a clock does nothing.
61const focusable = () => active().filter(w => (w.keys ?? []).length > 0).map(w => w.id)
62
63// Stored layouts come from an older build or a hand edit: check every field.
64const isSlot = (v: unknown): v is DashSlot => {
65  if (typeof v !== 'object' || v === null) return false
66  const s = v as { id?: unknown; size?: { w?: unknown; h?: unknown } }
67  return typeof s.id === 'string' && widgetOf(s.id) !== undefined && parseSize(`${String(s.size?.w)}x${String(s.size?.h)}`) !== undefined
68}
69
70// Tiles stretch to fill `rows` exactly, rows shared out evenly per grid unit.
71// Below UNIT_ROWS a unit the grid keeps UNIT_ROWS and the pane scrolls.
72const geometry = (columns: number, rows: number) => {
73  const units = unitsFor(columns)
74  const placed = pack(slots, units)
75  const unitCols = Math.floor(columns / units)
76  const high = Math.max(1, ...placed.map(p => p.y + p.h))
77  const unitRows = Math.max(UNIT_ROWS, rows / high)
78  const at = (u: number) => Math.round(u * unitRows)
79
80  return {
81    rows: Math.max(rows, at(high)),
82    tiles: placed.map(p => {
83      const x = p.x * unitCols
84      // The last column takes the remainder; others leave a 1-cell gutter.
85      const w = p.x + p.w === units ? columns - x : p.w * unitCols - 1
86      return { id: p.id, x, y: at(p.y), w, h: at(p.y + p.h) - at(p.y) }
87    }),
88  }
89}
90
91type Span = Board['links'][number]
92
93const spansKey = (spans: readonly Span[]) => spans.map(l => `${l.x},${l.y},${l.url},${l.text}`).join('|')
94// The spans the last render laid Link elements over.
95let drawnSpans = ''
96
97const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
98
99const frame = (columns: number, rows: number, focus: string, now: number): { cells: string; links: Span[] } => {
100  const board = new Board(columns, rows)
101  // A lit frame says "focus is here, not there". With one focusable tile
102  // there is no "there": every frame stays the same.
103  const canMove = focusable().length > 1
104  for (const t of geometry(columns, rows).tiles) {
105    const widget = widgetOf(t.id)
106    if (widget === undefined) continue
107    board.frame(t.x, t.y, t.w, t.h, widget.title, canMove && t.id === focus)
108    const tile = board.tile(t.x, t.y, t.w, t.h, t.id === focus)
109    const failure = failures.get(widget.id)
110    // Contract guarantee: draw never sees a widget that has not started.
111    if (!states.has(widget.id) && failure === undefined) {
112      tile.text(0, 0, 'starting...', 'faint')
113      continue
114    }
115    if (failure !== undefined) {
116      tile.text(0, 0, 'widget failed', 'del')
117      tile.text(0, 1, failure.slice(0, tile.cols * 2), 'dim')
118      continue
119    }
120    try {
121      widget.draw(states.get(widget.id), tile, now)
122    } catch (error) {
123      failures.set(widget.id, error instanceof Error ? error.message : String(error))
124    }
125  }
126
127  return { cells: encode(board.cells), links: board.links }
128}
129
130// Timer, press and fetch callbacks have no caller to throw to. Failures go to
131// `claude --debug`.
132function report($: EngineInterface, error: unknown) {
133  $.ui.log(`dashboard: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
134}
135
136async function paint($: EngineInterface) {
137  if (!isVisible || size.columns === 0) return
138  const focus = await read($, focusRef)
139  const { cells, links } = frame(size.columns, size.rows, focus, await $.clock.now())
140  await $.ui.blit({ requestId: PANE, key: 'view', columns: size.columns, rows: size.rows, cells })
141  // Links are elements over the Raster, not cells: new spans need a render.
142  if (spansKey(links) !== drawnSpans) $.ui.invalidate('ui.render')
143}
144
145// Applies one step: new state, then its effects, then persistence. A widget
146// that throws is parked with its message; the others keep running.
147async function apply($: EngineInterface, widget: Widget<unknown>, step: Step<unknown>) {
148  states.set(widget.id, step.state)
149  for (const effect of step.effects ?? []) {
150    if (effect.kind === 'toast') $.ui.toast(effect.text)
151    if (effect.kind === 'fetch') {
152      // Network failure becomes ok: false; a throw inside feed is reported, never re-fed.
153      void $.http
154        .fetch(effect.url)
155        .then(
156          res => ({ ok: res.ok, status: res.status, text: res.text }),
157          () => ({ ok: false, status: 0, text: '' }),
158        )
159        .then(r => feed($, widget, { kind: 'fetched', tag: effect.tag, ...r }))
160        .catch(error => report($, error))
161    }
162  }
163  if (widget.persist !== undefined) await $.store.set(`state:${widget.id}`, widget.persist(step.state))
164}
165
166async function feed($: EngineInterface, widget: Widget<unknown>, input: Input) {
167  if (failures.has(widget.id) || !states.has(widget.id)) return
168  let step: Step<unknown>
169  try {
170    step = widget.update(states.get(widget.id), input)
171  } catch (error) {
172    failures.set(widget.id, error instanceof Error ? error.message : String(error))
173    return
174  }
175  await apply($, widget, step)
176}
177
178async function start($: EngineInterface, widget: Widget<unknown>) {
179  failures.delete(widget.id)
180  const saved = (await $.store.get(`state:${widget.id}`)) as Json | undefined
181  try {
182    await apply($, widget, widget.init(saved, await $.clock.now()))
183  } catch (error) {
184    failures.set(widget.id, error instanceof Error ? error.message : String(error))
185    return
186  }
187  ticked.set(widget.id, await $.clock.now())
188  if (widget.needs?.includes('session')) await feed($, widget, { kind: 'session', session })
189  if (widget.needs?.includes('git') && lastGit !== undefined) await feed($, widget, { kind: 'git', git: lastGit })
190}
191
192async function setLayout($: EngineInterface, next: DashSlot[]) {
193  const added = next.filter(s => !slots.some(old => old.id === s.id))
194  slots = next
195  await update($, layoutRef, () => next)
196  await $.store.set('layout', next)
197  for (const s of added) {
198    const widget = widgetOf(s.id)
199    if (widget !== undefined) await start($, widget)
200  }
201}
202
203async function tick($: EngineInterface) {
204  const now = await $.clock.now()
205  for (const widget of active()) {
206    if (widget.tickMs === undefined) continue
207    if (now - (ticked.get(widget.id) ?? 0) < widget.tickMs) continue
208    ticked.set(widget.id, now)
209    await feed($, widget, { kind: 'tick', now })
210  }
211  await paint($)
212}
213
214// The fixed read list in gitparse.ts is all the git the host ever runs.
215async function refreshGit($: EngineInterface) {
216  const [status, log, unstaged, staged] = await Promise.all([
217    $.process.run(GIT_READS.status),
218    $.process.run(GIT_READS.log),
219    $.process.run(GIT_READS.unstaged),
220    $.process.run(GIT_READS.staged),
221  ])
222  const found = status.exitCode !== 0 ? NOT_A_REPO : snapshot(status.stdout, log.exitCode === 0 ? log.stdout : '', unstaged.stdout, staged.stdout)
223  lastGit = { ...found, cwd: where }
224  for (const widget of active()) {
225    if (widget.needs?.includes('git')) await feed($, widget, { kind: 'git', git: lastGit })
226  }
227  await paint($)
228}
229
230// Edits land in bursts: one refresh 400ms after the last one.
231function scheduleGit($: EngineInterface) {
232  gitTimer?.cancel()
233  gitTimer = $.clock.after(400, () => void refreshGit($).catch(error => report($, error)))
234}
235
236// One mapping from the engine's usage shape to the contract's: for the read at
237// start (a resumed session already has context) and every measure after.
238async function takeUsage($: EngineInterface, usage: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>) {
239  const now = await $.clock.now()
240  session = {
241    ...session,
242    window: usage.context.window,
243    contextTokens: usage.context.tokens ?? session.contextTokens,
244    usd: usage.cost?.usd ?? session.usd,
245    // Usage windows mean a subscription; a gateway's spend_limit is money.
246    plan: usage.rateLimits.some(r => r.kind === 'five_hour' || r.kind === 'seven_day')
247      ? 'subscription'
248      : usage.context.tokens !== undefined
249        ? 'api'
250        : session.plan,
251  }
252  // The compaction point needs a breakdown, which costs a count: once per 15s.
253  if (now - breakdownAt > 15_000) {
254    breakdownAt = now
255    const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
256    session = { ...session, compactAt: b?.isAutoCompactEnabled === true ? b.autoCompactThreshold : undefined }
257  }
258  await pushSession($)
259}
260
261async function pushSession($: EngineInterface) {
262  for (const widget of active()) {
263    if (widget.needs?.includes('session')) await feed($, widget, { kind: 'session', session })
264  }
265  await paint($)
266}
267
268// Below DOCK_COLUMNS the engine moves the pane above the prompt instead of
269// hiding it. Close it there, reopen once the window is wide again.
270async function watch($: EngineInterface) {
271  const pane = (await $.ui.panes()).find(one => one.id === PANE)
272  isVisible = pane !== undefined && pane.isShown && pane.isPlaced
273
274  if (pane !== undefined && placement === 'inline') {
275    isHiddenByWidth = true
276    placement = undefined
277    size = { columns: 0, rows: 0 }
278    await $.ui.close({ id: PANE })
279    return
280  }
281
282  const isWide = viewport.isFullscreen && viewport.columns >= DOCK_COLUMNS
283  if (pane === undefined && isHiddenByWidth && isWide) {
284    isHiddenByWidth = false
285    await $.ui.open({ id: PANE, title: TITLE, columns: WANT_COLUMNS })
286  }
287}
288
289async function press($: EngineInterface, action: string) {
290  const focus = await read($, focusRef)
291  const widget = widgetOf(focus)
292  if (widget !== undefined) await feed($, widget, { kind: 'key', action })
293  await paint($)
294}
295
296async function cycleFocus($: EngineInterface) {
297  const ids = focusable()
298  const focus = await read($, focusRef)
299  await update($, focusRef, () => ids[(ids.indexOf(focus) + 1) % ids.length] ?? '')
300}
301
302const HELP = 'Usage: /dash, /dash list, /dash add <widget> [WxH], /dash rm <widget>, /dash size <widget> <WxH>, /dash reset, /dash autostart on|off'
303
304// Off by default: no pane opens by itself unless the person turned this on.
305async function autostart($: EngineInterface, value: string): Promise<string> {
306  if (value === 'on' || value === 'off') await $.store.set('autostart', value === 'on')
307  else if (value !== '') return 'Usage: /dash autostart on|off'
308  return (await $.store.get('autostart')) === true
309    ? 'The dashboard opens at session start. /dash autostart off undoes it.'
310    : 'The dashboard opens on /dash only. /dash autostart on opens it at session start.'
311}
312
313export const register: Register = on => {
314  on('session.start', async ($, e, next) => {
315    const home = await $.env.get('HOME')
316    where = home !== undefined && e.cwd.startsWith(home) ? `~${e.cwd.slice(home.length)}` : e.cwd
317    const stored = await $.store.get('layout')
318    slots = Array.isArray(stored) && stored.every(isSlot) ? (stored as DashSlot[]) : DEFAULT_LAYOUT
319    await update($, layoutRef, () => slots)
320    states.clear()
321    failures.clear()
322    for (const widget of active()) await start($, widget)
323    const focus = await read($, focusRef)
324    if (!focusable().includes(focus)) await update($, focusRef, () => focusable()[0] ?? '')
325
326    await $.command.register({
327      name: 'dash',
328      description: 'Open or close the dashboard; /dash add|rm|size|list|reset edits its widgets; /dash autostart on|off',
329      argumentHint: '[list|add|rm|size|reset|autostart]',
330    })
331    $.clock.every(TICK_MS, () => void tick($).catch(error => report($, error)))
332    $.clock.every(WATCH_MS, () => void watch($).catch(error => report($, error)))
333    $.clock.every(GIT_MS, () => void refreshGit($).catch(error => report($, error)))
334    void refreshGit($).catch(error => report($, error))
335    void $.session
336      .usage()
337      .then(usage => takeUsage($, usage))
338      .catch(error => report($, error))
339    // Opt-in only. Unasked, the engine seats it from 144 columns, 110 once asked for.
340    if ((await $.store.get('autostart')) === true) void $.ui.open({ id: PANE, title: TITLE, columns: WANT_COLUMNS })
341
342    return next(e)
343  })
344
345  on('session.measure', async ($, e, next) => {
346    await takeUsage($, e)
347
348    return next(e)
349  })
350
351  on('turn.complete', async ($, e, next) => {
352    const result = await next(e)
353    const tokens = session.contextTokens
354    if (tokens !== undefined) {
355      const history = [...session.history, tokens].slice(-40)
356      const deltas = history.slice(1).map((v, i) => v - (history[i] ?? v)).filter(d => d > 0).slice(-5)
357      const tokensPerTurn = deltas.length === 0 ? undefined : Math.round(deltas.reduce((a, b) => a + b, 0) / deltas.length)
358      session = { ...session, turns: session.turns + 1, history, tokensPerTurn }
359    } else {
360      session = { ...session, turns: session.turns + 1 }
361    }
362    await pushSession($)
363
364    return result
365  })
366
367  on('tool.call', async ($, e, next) => {
368    const result = await next(e)
369    if (GIT_TOOLS.has(String(e.tool))) scheduleGit($)
370
371    return result
372  })
373
374  // `+ milk` goes to the todo widget and never reaches the model.
375  on('prompt.submit', async ($, e, next) => {
376    // Single lines only: a pasted diff starts with "+ " too, and belongs to Claude.
377    if (e.origin.kind !== 'composer' || e.text.includes('\n')) return next(e)
378    const widget = active().find(w => w.prefix !== undefined && e.text.startsWith(`${w.prefix} `))
379    if (widget?.prefix === undefined) return next(e)
380
381    await feed($, widget, { kind: 'prompt', text: e.text.slice(widget.prefix.length + 1) })
382    await paint($)
383    $.ui.toast(`${widget.title}: added`)
384
385    return { drop: `dashboard: handled by ${widget.id}` }
386  })
387
388  on('command.run', { command: 'dash' }, async ($, e) => {
389    const [verb = '', id = '', arg = ''] = e.args.trim().split(/\s+/)
390    const known = WIDGETS.map(w => w.id).join(', ')
391    if (verb === 'autostart') return { text: await autostart($, id) }
392
393    if (verb === 'list') {
394      const lines = WIDGETS.map(w => {
395        const slot = slots.find(s => s.id === w.id)
396        return `${w.id.padEnd(8)} ${slot === undefined ? 'off' : showSize(slot.size)}  sizes ${w.sizes.map(showSize).join(' ')}`
397      })
398      return { text: lines.join('\n') }
399    }
400    if (verb === 'add' || verb === 'size') {
401      const widget = widgetOf(id)
402      if (widget === undefined) return { text: `No widget "${id}". Known: ${known}.` }
403      const want = arg === '' ? widget.sizes[0] : parseSize(arg)
404      if (want === undefined || !widget.sizes.some(s => s.w === want.w && s.h === want.h)) {
405        return { text: `${widget.id} draws at ${widget.sizes.map(showSize).join(', ')}.` }
406      }
407      const exists = slots.some(s => s.id === id)
408      if (verb === 'size' && !exists) return { text: `${id} is not on the dashboard. /dash add ${id}` }
409      await setLayout($, exists ? slots.map(s => (s.id === id ? { id, size: want } : s)) : [...slots, { id, size: want }])
410      return { text: `${id} at ${showSize(want)}.` }
411    }
412    if (verb === 'rm') {
413      if (!slots.some(s => s.id === id)) return { text: `${id} is not on the dashboard.` }
414      await setLayout($, slots.filter(s => s.id !== id))
415      states.delete(id)
416      return { text: `${id} removed.` }
417    }
418    if (verb === 'reset') {
419      await setLayout($, DEFAULT_LAYOUT)
420      return { text: 'Dashboard reset.' }
421    }
422    if (verb !== '') return { text: HELP }
423
424    const isOpen = (await $.ui.panes()).some(one => one.id === PANE)
425    if (isOpen) {
426      isHiddenByWidth = false
427      await $.ui.close({ id: PANE })
428      return { text: 'Dashboard closed.' }
429    }
430    if (!e.presentation.isFullscreen) return { text: 'The dashboard needs the fullscreen layout.' }
431    if (e.presentation.columns < DOCK_COLUMNS) {
432      return { text: `The dashboard needs ${DOCK_COLUMNS} columns or more. This window has ${e.presentation.columns}.` }
433    }
434    isHiddenByWidth = false
435    await $.ui.open({ id: PANE, title: TITLE, columns: WANT_COLUMNS })
436
437    return { text: 'Dashboard opened.' }
438  })
439
440  on('ui.close', ($, e, next) => {
441    // Closed by hand: stays closed until /dash.
442    if (e.id === PANE && e.origin.kind === 'person') isHiddenByWidth = false
443
444    return next(e)
445  })
446
447  // Width probe. The band re-renders on every resize, even with the pane
448  // closed. Returns the engine's band untouched.
449  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
450    if (e.surface === 'terminal' && e.viewport !== undefined) {
451      viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen === true }
452    }
453
454    return next(e)
455  })
456
457  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
458    if (e.surface !== 'terminal') {
459      const { Text } = $.ui.resolve(e)
460      return <Text dimColor>The dashboard draws in the terminal.</Text>
461    }
462
463    const { Box, Button, Link, Raster, Text } = $.ui.resolve(e)
464    placement = e.props.placement
465    if (e.viewport !== undefined) {
466      viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen === true }
467    }
468    if (e.props.placement === 'inline') {
469      return <Text dimColor>The dashboard hides: the window is too narrow for the dock.</Text>
470    }
471
472    // Subscribes this render to layout and focus: either change redraws it.
473    await read($, layoutRef)
474    const focus = await read($, focusRef)
475    const columns = clamp(e.props.bodyColumns, 1, 512)
476    const focused = widgetOf(focus)
477    const hasKeyRow = (focused?.keys ?? []).length > 0 || focusable().length > 1
478    // The pane reports its body height; the key row under the grid takes one.
479    const body = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 30) - CHROME_ROWS
480    const grid = geometry(columns, clamp(body - (hasKeyRow ? 1 : 0), 8, 256))
481    const rows = clamp(grid.rows, 8, 256)
482    size = { columns, rows }
483    const view = frame(columns, rows, focus, await $.clock.now())
484    drawnSpans = spansKey(view.links)
485
486    return (
487      <Box flexDirection="column" backgroundColor={`#${PALETTE.bg.toString(16).padStart(6, '0')}`}>
488        <Box key="grid">
489          <Raster key="view" columns={columns} rows={rows} cells={view.cells} />
490          {view.links.flatMap(span => {
491            const href = safeHref(span.url)
492            if (href === undefined) return []
493            // Laid exactly over the cells the widget drew, same colours, so
494            // the only visible change is the underline on hover.
495            return [
496              <Box key={`link-${span.x}-${span.y}`} position="absolute" top={span.y} left={span.x}>
497                <Link href={href}>
498                  <Text color={hex(PALETTE[span.tone])} backgroundColor={hex(PALETTE.surface)} hover={{ underline: true }}>
499                    {span.text}
500                  </Text>
501                </Link>
502              </Box>,
503            ]
504          })}
505        </Box>
506        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
507          {focusable().length > 1 && (
508            <Button key="next" hotkey="n" label={`next (${focused?.title ?? 'none'})`} plain onPress={() => void cycleFocus($).catch(error => report($, error))} />
509          )}
510          {(focused?.keys ?? []).map(k => (
511            <Button key={`key-${k.action}`} hotkey={k.hotkey} label={k.label} plain onPress={() => void press($, k.action).catch(error => report($, error))} />
512          ))}
513        </Box>
514      </Box>
515    )
516  })
517}
518
hooks/contract.ts 115 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Widget contract v1. Widgets are pure: data in, cells out, no `$`. The host
5// owns every side effect. Within v1 changes are additive only; a breaking
6// change is v2 and the host refuses widgets it does not know.
7
8export const CONTRACT = 1
9
10export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
11
12// Grid units. The host clamps w to the columns the pane has.
13export type Size = { w: 1 | 2 | 3 | 4; h: 1 | 2 | 3 }
14
15// Semantic tones, never hex. One palette change restyles every widget.
16export type Tone = 'fg' | 'dim' | 'faint' | 'accent' | 'add' | 'mod' | 'del' | 'info' | 'link'
17
18export type Need = 'session' | 'git'
19
20// Contract-owned shapes, mapped by the host from the engine's own. Engine API
21// changes land in the host's mapping, not in every widget.
22export type SessionSnapshot = {
23  window: number
24  contextTokens?: number
25  compactAt?: number
26  usd?: number
27  turns: number
28  // Context growth per answered turn, moving average.
29  tokensPerTurn?: number
30  history: readonly number[]
31  // Subscription plans report usage windows; API keys report money.
32  // Absent until the first response says which. (v1 addition.)
33  plan?: 'subscription' | 'api'
34}
35
36export type GitFile = {
37  path: string
38  status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
39  adds: number
40  dels: number
41  isStaged: boolean
42}
43
44export type GitCommit = { hash: string; subject: string; author: string; at: number }
45
46export type GitSnapshot = {
47  isRepo: boolean
48  // The folder git ran in, home shortened to ~. (v1 addition.)
49  cwd?: string
50  branch: string
51  upstream?: string
52  ahead: number
53  behind: number
54  commits: readonly GitCommit[]
55  files: readonly GitFile[]
56}
57
58export type Input =
59  | { kind: 'tick'; now: number }
60  | { kind: 'session'; session: SessionSnapshot }
61  | { kind: 'git'; git: GitSnapshot }
62  | { kind: 'key'; action: string }
63  | { kind: 'prompt'; text: string }
64  | { kind: 'fetched'; tag: string; ok: boolean; status: number; text: string }
65
66export type Effect = { kind: 'fetch'; tag: string; url: string } | { kind: 'toast'; text: string }
67
68export type Step<S> = { state: S; effects?: readonly Effect[] }
69
70// `n` is the host's: it moves focus to the next tile.
71export const RESERVED_HOTKEYS = ['n'] as const
72export type Key = { hotkey: string; label: string; action: string }
73
74// The drawing surface of one tile, inside its border. Clips everything.
75export type Tile = {
76  readonly cols: number
77  readonly rows: number
78  // True when this tile's keys are the live ones. (v1 addition.)
79  readonly isFocused: boolean
80  text(x: number, y: number, s: string, tone?: Tone): void
81  // Dot-matrix: 0-9 : % . - $ k M. Scale 1: 2 cells wide, 2 rows tall per
82  // glyph. Scale 2: 4 wide, 3 tall. (v1 addition: `scale`.)
83  digits(x: number, y: number, s: string, tone?: Tone, scale?: 1 | 2): void
84  // Segments in order, each a share of the width; the rest drawn faint.
85  meter(x: number, y: number, width: number, parts: readonly { share: number; tone: Tone }[]): void
86  spark(x: number, y: number, width: number, values: readonly number[], tone?: Tone): void
87  // Text that opens `url` on click. https only; anything else draws as plain
88  // text. (v1 addition.)
89  link(x: number, y: number, s: string, url: string, tone?: Tone): void
90}
91
92export type Widget<S> = {
93  contract: typeof CONTRACT
94  // Kebab-case, stable forever: keys layouts and saved state.
95  id: string
96  title: string
97  sizes: readonly Size[]
98  tickMs?: number
99  needs?: readonly Need[]
100  // A prompt starting with this and a space goes to the widget, never the model.
101  prefix?: string
102  keys?: readonly Key[]
103  init(saved: Json | undefined, now: number): Step<S>
104  update(state: S, input: Input): Step<S>
105  draw(state: S, tile: Tile, now: number): void
106  persist?(state: S): Json
107}
108
109// Typed at the definition, erased in the registry.
110export const defineWidget = <S>(widget: Widget<S>): Widget<unknown> => widget as unknown as Widget<unknown>
111
112// Dot-matrix size of `s` in cells.
113export const digitsWidth = (s: string, scale: 1 | 2 = 1): number => s.length * 2 * scale
114export const digitsHeight = (scale: 1 | 2 = 1): number => Math.ceil((5 * scale + 1) / 4)
115
hooks/gitparse.ts 81 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import type { GitCommit, GitFile, GitSnapshot } from './contract'
5
6// Read-only, by construction: the only git the host ever runs. No widget can
7// add to this list.
8export const GIT_READS = {
9  status: ['git', 'status', '--porcelain=v2', '--branch'],
10  log: ['git', 'log', '-n', '12', '--format=%h%x1f%an%x1f%at%x1f%s'],
11  unstaged: ['git', 'diff', '--numstat'],
12  staged: ['git', 'diff', '--cached', '--numstat'],
13} as const
14
15export const NOT_A_REPO: GitSnapshot = { isRepo: false, branch: '', ahead: 0, behind: 0, commits: [], files: [] }
16
17type Status = { branch: string; upstream?: string; ahead: number; behind: number; files: GitFile[] }
18
19const kindOf = (xy: string): GitFile['status'] => {
20  if (xy.includes('A')) return 'added'
21  if (xy.includes('D')) return 'deleted'
22  if (xy.includes('R') || xy.includes('C')) return 'renamed'
23  return 'modified'
24}
25
26// `git status --porcelain=v2 --branch`.
27export const parseStatus = (text: string): Status => {
28  const out: Status = { branch: '', ahead: 0, behind: 0, files: [] }
29  for (const line of text.split('\n')) {
30    if (line.startsWith('# branch.head ')) out.branch = line.slice(14)
31    else if (line.startsWith('# branch.upstream ')) out.upstream = line.slice(18)
32    else if (line.startsWith('# branch.ab ')) {
33      const m = /\+(\d+) -(\d+)/.exec(line)
34      out.ahead = Number(m?.[1] ?? 0)
35      out.behind = Number(m?.[2] ?? 0)
36    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
37      const parts = line.split(' ')
38      const xy = parts[1] ?? '..'
39      // Ordinary entries have 8 fields before the path; renames 9, then "path\torig".
40      const path = parts.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0] ?? ''
41      out.files.push({ path, status: kindOf(xy), adds: 0, dels: 0, isStaged: xy[0] !== '.' })
42    } else if (line.startsWith('? ')) {
43      out.files.push({ path: line.slice(2), status: 'untracked', adds: 0, dels: 0, isStaged: false })
44    }
45  }
46
47  return out
48}
49
50// `git diff --numstat`: adds, dels, path. Binary files report "-".
51export const parseNumstat = (text: string): Map<string, { adds: number; dels: number }> => {
52  const out = new Map<string, { adds: number; dels: number }>()
53  for (const line of text.split('\n')) {
54    const [adds, dels, ...rest] = line.split('\t')
55    if (rest.length === 0) continue
56    // Renames print "old => new" or "dir/{old => new}"; key on the new path.
57    const path = rest.join('\t').replace(/\{[^}]* => ([^}]*)\}/, '$1').replace(/^.* => /, '')
58    const prev = out.get(path) ?? { adds: 0, dels: 0 }
59    out.set(path, { adds: prev.adds + (Number(adds) || 0), dels: prev.dels + (Number(dels) || 0) })
60  }
61
62  return out
63}
64
65export const parseLog = (text: string): GitCommit[] =>
66  text
67    .split('\n')
68    .filter(line => line.includes('\x1f'))
69    .map(line => {
70      const [hash = '', author = '', at = '0', ...subject] = line.split('\x1f')
71      return { hash, author, at: Number(at) * 1000, subject: subject.join('\x1f') }
72    })
73
74export const snapshot = (status: string, log: string, unstaged: string, staged: string): GitSnapshot => {
75  const s = parseStatus(status)
76  const counts = parseNumstat(`${unstaged}\n${staged}`)
77  const files = s.files.map(f => ({ ...f, ...(counts.get(f.path) ?? {}) }))
78
79  return { isRepo: true, branch: s.branch, upstream: s.upstream, ahead: s.ahead, behind: s.behind, commits: parseLog(log), files }
80}
81
hooks/grid.ts 50 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import type { Size } from './contract'
5
6export type Slot = { id: string; size: Size }
7export type Placed = { id: string; x: number; y: number; w: number; h: number }
8
9// Rows per grid unit, border included: 4 inner rows on a 1-high tile.
10export const UNIT_ROWS = 6
11
12export const unitsFor = (columns: number): number => (columns >= 64 ? 4 : columns >= 30 ? 2 : 1)
13
14// First fit, row-major, in layout order. A tile wider than the grid shrinks
15// to the grid instead of being dropped.
16export const pack = (slots: readonly Slot[], units: number): Placed[] => {
17  const taken: boolean[][] = []
18  const isFree = (x: number, y: number, w: number, h: number) => {
19    for (let dy = 0; dy < h; dy++) {
20      for (let dx = 0; dx < w; dx++) if (taken[y + dy]?.[x + dx]) return false
21    }
22    return true
23  }
24  const placed: Placed[] = []
25  for (const slot of slots) {
26    const w = Math.min(slot.size.w, units)
27    const h = slot.size.h
28    for (let y = 0; ; y++) {
29      const x = [...Array(units - w + 1).keys()].find(x0 => isFree(x0, y, w, h))
30      if (x === undefined) continue
31      for (let dy = 0; dy < h; dy++) {
32        taken[y + dy] ??= []
33        for (let dx = 0; dx < w; dx++) taken[y + dy]![x + dx] = true
34      }
35      placed.push({ id: slot.id, x, y, w, h })
36      break
37    }
38  }
39
40  return placed
41}
42
43export const parseSize = (s: string): Size | undefined => {
44  const m = /^([1-4])x([1-3])$/.exec(s.trim())
45  if (m === null) return undefined
46  return { w: Number(m[1]) as Size['w'], h: Number(m[2]) as Size['h'] }
47}
48
49export const showSize = (size: Size): string => `${size.w}x${size.h}`
50
hooks/href.ts 18 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Link refuses the whole tree over one bad href. Anything that is not a clean
5// https URL stays plain text: no credentials, `@` encoded (Medium paths have
6// one), printable ASCII only, spelled the way `new URL` spells it.
7export const safeHref = (url: string): string | undefined => {
8  let u: URL
9  try {
10    u = new URL(url)
11  } catch {
12    return undefined
13  }
14  if (u.protocol !== 'https:' || u.username !== '' || u.password !== '') return undefined
15  const href = u.href.replace(/@/g, '%40')
16  return href.length <= 2048 && /^[\x21-\x7e]+$/.test(href) && new URL(href).href === href ? href : undefined
17}
18
hooks/kit.ts 198 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import type { Tile, Tone } from './contract'
5
6// Palette from sem's site (Ataraxy Labs, MIT). See CREDITS.md.
7export const PALETTE: Record<Tone | 'bg' | 'surface' | 'border', number> = {
8  bg: 0x0a0a0a,
9  surface: 0x111111,
10  border: 0x222222,
11  fg: 0xe0e0e0,
12  dim: 0x666666,
13  faint: 0x444444,
14  accent: 0xffffff,
15  add: 0x4ade80,
16  mod: 0xfacc15,
17  del: 0xf87171,
18  info: 0x60a5fa,
19  link: 0x22d3ee,
20}
21
22const SPACE = 0x20
23const SPARK = [0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588] as const
24const RULE = 0x2501
25
26// 3x5 dot font, drawn as Braille: 2 cells wide, 2 rows tall per glyph.
27const FONT: Readonly<Record<string, readonly string[]>> = {
28  '0': ['###', '#.#', '#.#', '#.#', '###'],
29  '1': ['.#.', '##.', '.#.', '.#.', '###'],
30  '2': ['###', '..#', '###', '#..', '###'],
31  '3': ['###', '..#', '.##', '..#', '###'],
32  '4': ['#.#', '#.#', '###', '..#', '..#'],
33  '5': ['###', '#..', '###', '..#', '###'],
34  '6': ['###', '#..', '###', '#.#', '###'],
35  '7': ['###', '..#', '..#', '.#.', '.#.'],
36  '8': ['###', '#.#', '###', '#.#', '###'],
37  '9': ['###', '#.#', '###', '..#', '###'],
38  ':': ['...', '.#.', '...', '.#.', '...'],
39  '%': ['#.#', '..#', '.#.', '#..', '#.#'],
40  '.': ['...', '...', '...', '...', '.#.'],
41  '-': ['...', '...', '###', '...', '...'],
42  $: ['.##', '#..', '.#.', '..#', '##.'],
43  k: ['#..', '#.#', '##.', '#.#', '#.#'],
44  M: ['#.#', '###', '###', '#.#', '#.#'],
45  ' ': ['...', '...', '...', '...', '...'],
46}
47
48// Braille dot bits by [column][row] inside one 2x4 cell.
49const DOT = [
50  [0x01, 0x02, 0x04, 0x40],
51  [0x08, 0x10, 0x20, 0x80],
52] as const
53
54// Raster takes printable width-1 BMP characters only. Anything else becomes
55// a space rather than failing the whole frame.
56const safe = (cp: number): number => (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || cp > 0xffff ? SPACE : cp)
57
58export class Board {
59  readonly cells: Uint32Array
60  // Link spans in board cells, for the host to lay Link elements over.
61  readonly links: { x: number; y: number; text: string; url: string; tone: Tone }[] = []
62
63  constructor(
64    readonly columns: number,
65    readonly rows: number,
66  ) {
67    this.cells = new Uint32Array(columns * rows * 3)
68    for (let i = 0; i < columns * rows; i++) {
69      this.cells[i * 3] = SPACE
70      this.cells[i * 3 + 1] = PALETTE.fg
71      this.cells[i * 3 + 2] = PALETTE.bg
72    }
73  }
74
75  put(x: number, y: number, cp: number, fg: number, bg?: number): void {
76    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return
77    const at = (y * this.columns + x) * 3
78    this.cells[at] = safe(cp)
79    this.cells[at + 1] = fg
80    if (bg !== undefined) this.cells[at + 2] = bg
81  }
82
83  // Rounded frame, title set into the top edge, inside filled with surface.
84  frame(x: number, y: number, w: number, h: number, title: string, isFocused: boolean): void {
85    const edge = isFocused ? PALETTE.dim : PALETTE.border
86    for (let dy = 0; dy < h; dy++) {
87      for (let dx = 0; dx < w; dx++) {
88        const isTop = dy === 0
89        const isBottom = dy === h - 1
90        const isLeft = dx === 0
91        const isRight = dx === w - 1
92        const cp =
93          isTop && isLeft ? 0x256d : isTop && isRight ? 0x256e : isBottom && isLeft ? 0x2570 : isBottom && isRight ? 0x256f : isTop || isBottom ? 0x2500 : isLeft || isRight ? 0x2502 : SPACE
94        this.put(x + dx, y + dy, cp, edge, isTop || isBottom || isLeft || isRight ? PALETTE.bg : PALETTE.surface)
95      }
96    }
97    const label = ` ${title.toUpperCase()} `.slice(0, Math.max(0, w - 4))
98    for (let i = 0; i < label.length; i++) {
99      this.put(x + 2 + i, y, label.charCodeAt(i), isFocused ? PALETTE.accent : PALETTE.dim, PALETTE.bg)
100    }
101  }
102
103  tile(x: number, y: number, w: number, h: number, isFocused = false): Tile {
104    const left = x + 1
105    const top = y + 1
106    const cols = Math.max(0, w - 2)
107    const rows = Math.max(0, h - 2)
108    const put = (cx: number, cy: number, cp: number, tone: Tone) => {
109      if (cx < 0 || cy < 0 || cx >= cols || cy >= rows) return
110      this.put(left + cx, top + cy, cp, PALETTE[tone], PALETTE.surface)
111    }
112
113    return {
114      cols,
115      rows,
116      isFocused,
117      text: (tx, ty, s, tone = 'fg') => {
118        let i = 0
119        for (const ch of s) {
120          put(Math.floor(tx) + i, Math.floor(ty), ch.codePointAt(0) ?? SPACE, tone)
121          i += 1
122        }
123      },
124      link: (lx, ly, s, url, tone = 'fg') => {
125        const x0 = Math.floor(lx)
126        const y0 = Math.floor(ly)
127        const chars = [...s]
128        chars.forEach((ch, i) => put(x0 + i, y0, ch.codePointAt(0) ?? SPACE, tone))
129        // Only the part the tile shows becomes a link, so the overlay matches the cells.
130        if (y0 < 0 || y0 >= rows || x0 < 0 || x0 >= cols || !url.startsWith('https://')) return
131        const shown = chars.slice(0, cols - x0).join('')
132        if (shown !== '') this.links.push({ x: left + x0, y: top + y0, text: shown, url, tone })
133      },
134      digits: (tx, ty, s, tone = 'fg', scale = 1) => {
135        const glyphs = [...s].map(ch => FONT[ch] ?? FONT[' ']!)
136        // Each font dot becomes scale x scale Braille dots; one blank dot row
137        // on top keeps glyphs off the cell above.
138        const advance = 4 * scale
139        const tall = Math.ceil((5 * scale + 1) / 4)
140        for (let cell = 0; cell < (glyphs.length * advance) / 2; cell++) {
141          for (let row = 0; row < tall; row++) {
142            let bits = 0
143            for (let dc = 0; dc < 2; dc++) {
144              const dotX = cell * 2 + dc
145              const glyph = glyphs[Math.floor(dotX / advance)]!
146              const gx = Math.floor((dotX % advance) / scale)
147              for (let dr = 0; dr < 4; dr++) {
148                const gy = Math.floor((row * 4 + dr - 1) / scale)
149                if (row * 4 + dr >= 1 && gx < 3 && gy < 5 && glyph[gy]?.[gx] === '#') bits |= DOT[dc]![dr]!
150              }
151            }
152            put(Math.floor(tx) + cell, Math.floor(ty) + row, 0x2800 + bits, tone)
153          }
154        }
155      },
156      meter: (mx, my, width, parts) => {
157        let at = 0
158        for (const part of parts) {
159          const span = Math.round(Math.max(0, part.share) * width)
160          for (let i = 0; i < span && at < width; i++, at++) put(Math.floor(mx) + at, Math.floor(my), RULE, part.tone)
161        }
162        for (; at < width; at++) put(Math.floor(mx) + at, Math.floor(my), RULE, 'faint')
163      },
164      spark: (sx, sy, width, values, tone = 'dim') => {
165        const shown = values.slice(-width)
166        const high = Math.max(1, ...shown)
167        shown.forEach((v, i) => {
168          const level = Math.max(0, Math.min(7, Math.round((v / high) * 7)))
169          put(Math.floor(sx) + width - shown.length + i, Math.floor(sy), SPARK[level]!, tone)
170        })
171      },
172    }
173  }
174}
175
176const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
177
178// Native toBase64 in the mod sandbox (2.1.287). Manual path is for Node 20.
179export const encode = (words: Uint32Array): string => {
180  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
181  const native = (bytes as unknown as { toBase64?: () => string }).toBase64
182  if (typeof native === 'function') return native.call(bytes)
183
184  const out: string[] = []
185  let i = 0
186  for (; i + 2 < bytes.length; i += 3) {
187    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
188    out.push(B64.charAt((n >> 18) & 63), B64.charAt((n >> 12) & 63), B64.charAt((n >> 6) & 63), B64.charAt(n & 63))
189  }
190  const rest = bytes.length - i
191  if (rest > 0) {
192    const n = ((bytes[i] ?? 0) << 16) | (rest > 1 ? (bytes[i + 1] ?? 0) << 8 : 0)
193    out.push(B64.charAt((n >> 18) & 63), B64.charAt((n >> 12) & 63), rest > 1 ? B64.charAt((n >> 6) & 63) : '=', '=')
194  }
195
196  return out.join('')
197}
198
hooks/widgets/index.ts 13 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { CONTRACT, type Widget } from '../contract'
5import { clock } from './clock'
6import { context } from './context'
7import { git } from './git'
8import { hn } from './hn'
9import { todo } from './todo'
10
11// The registry. A widget built against another contract version is refused.
12export const WIDGETS: readonly Widget<unknown>[] = [clock, context, git, todo, hn].filter(w => w.contract === CONTRACT)
13
hooks/widgets/clock.ts 48 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { defineWidget, digitsHeight, digitsWidth } from '../contract'
5
6type Clock = { now: number }
7
8const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const
9const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] as const
10
11const two = (n: number) => String(n).padStart(2, '0')
12
13export const clock = defineWidget<Clock>({
14  contract: 1,
15  id: 'clock',
16  title: 'Time',
17  sizes: [
18    { w: 2, h: 1 },
19    { w: 1, h: 1 },
20  ],
21  tickMs: 1000,
22  init: (_saved, now) => ({ state: { now } }),
23  update: (state, input) => (input.kind === 'tick' ? { state: { now: input.now } } : { state }),
24  draw: (state, tile) => {
25    const d = new Date(state.now)
26    // The colon blinks on odd seconds, so a frozen clock is obvious.
27    const time = `${two(d.getHours())}${d.getSeconds() % 2 === 0 ? ':' : ' '}${two(d.getMinutes())}`
28    const date = `${DAYS[d.getDay()]} ${two(d.getDate())} ${MONTHS[d.getMonth()]}`
29    // Big digits when the tile has the room, small ones otherwise.
30    const scale = tile.rows >= digitsHeight(2) + 1 && tile.cols >= digitsWidth(time, 2) ? 2 : 1
31    // Each glyph carries a blank dot column on its right; the last one would
32    // push the time left of centre. At scale 2 that column is a whole cell.
33    const wide = digitsWidth(time, scale) - (scale === 2 ? 1 : 0)
34    const tall = digitsHeight(scale)
35    const line = `${date}  ${two(d.getSeconds())}s`
36    // Digits, a blank row, the date: centred as one block, both ways.
37    const hasDate = tile.rows > tall
38    const gap = hasDate && tile.rows >= tall + 3 ? 1 : 0
39    const top = Math.max(0, Math.floor((tile.rows - (tall + (hasDate ? gap + 1 : 0))) / 2))
40    tile.digits(Math.max(0, Math.floor((tile.cols - wide) / 2)), top, time, 'accent', scale)
41    if (hasDate) {
42      const x = Math.max(0, Math.floor((tile.cols - line.length) / 2))
43      tile.text(x, top + tall + gap, date, 'dim')
44      tile.text(x + date.length + 2, top + tall + gap, `${two(d.getSeconds())}s`, 'faint')
45    }
46  },
47})
48
hooks/widgets/context.ts 66 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { defineWidget, type SessionSnapshot, type Tone } from '../contract'
5import { compact } from '../format'
6
7type Context = { session: SessionSnapshot | null }
8
9const toneFor = (share: number): Tone => (share >= 0.8 ? 'del' : share >= 0.6 ? 'mod' : 'add')
10
11export const context = defineWidget<Context>({
12  contract: 1,
13  id: 'context',
14  title: 'Context',
15  sizes: [
16    { w: 2, h: 1 },
17    { w: 2, h: 2 },
18  ],
19  needs: ['session'],
20  init: () => ({ state: { session: null } }),
21  update: (state, input) => (input.kind === 'session' ? { state: { session: input.session } } : { state }),
22  draw: (state, tile) => {
23    const s = state.session
24    if (s === null || s.contextTokens === undefined) {
25      const wait = 'waiting for the first response'
26      tile.text(Math.max(0, Math.floor((tile.cols - wait.length) / 2)), Math.floor((tile.rows - 1) / 2), wait, 'faint')
27      return
28    }
29
30    // Share of the compaction point when auto-compact is on, else of the window.
31    const limit = s.compactAt ?? s.window
32    const share = Math.min(1, s.contextTokens / Math.max(1, limit))
33    const pct = `${Math.round(share * 100)}%`
34    const left = `${compact(Math.max(0, limit - s.contextTokens))} left`
35    const perTurn = s.tokensPerTurn ?? 0
36    const turnsLeft = perTurn > 0 ? Math.floor(Math.max(0, limit - s.contextTokens) / perTurn) : undefined
37    // Money only for API keys: on a subscription the number means nothing.
38    const hasCost = s.plan === 'api' && s.usd !== undefined
39    const hasSpark = s.history.length > 1 && tile.rows >= 3 + (hasCost ? 1 : 0) + 3
40
41    // Fixed content, so it sits centred as one block, like the clock.
42    const height = 3 + (hasCost ? 1 : 0) + (hasSpark ? 3 : 0)
43    let y = Math.max(0, Math.floor((tile.rows - height) / 2))
44
45    tile.meter(0, y, tile.cols, [{ share, tone: toneFor(share) }])
46    tile.text(0, y + 1, `${compact(s.contextTokens)} / ${compact(limit)} ${s.compactAt === undefined ? 'window' : 'to compaction'}`, 'dim')
47    tile.text(tile.cols - pct.length, y + 1, pct, toneFor(share))
48    tile.text(0, y + 2, left, 'fg')
49    tile.text(
50      left.length + 2,
51      y + 2,
52      turnsLeft === undefined ? `${s.turns} turns so far` : `~${turnsLeft} turns at ${compact(perTurn)}/turn`,
53      turnsLeft !== undefined && turnsLeft <= 3 ? 'mod' : 'dim',
54    )
55    y += 3
56    if (hasCost) {
57      tile.text(0, y, `$${(s.usd ?? 0).toFixed(2)} this session`, 'dim')
58      y += 1
59    }
60    if (hasSpark) {
61      tile.text(0, y + 1, 'context per turn', 'faint')
62      tile.spark(0, y + 2, tile.cols, s.history, 'info')
63    }
64  },
65})
66
hooks/widgets/git.ts 115 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { defineWidget, type GitFile, type GitSnapshot, type Tile, type Tone } from '../contract'
5import { ago, fit, padStart } from '../format'
6
7type Git = { git: GitSnapshot | null }
8
9// Powerline branch symbol, in every Nerd Font and Powerline font; Ghostty
10// ships it built in. A font without it shows a box.
11const BRANCH = '\ue0a0'
12
13// sem's semantic marks: added, modified, deleted.
14const MARK: Record<GitFile['status'], { glyph: string; tone: Tone }> = {
15  added: { glyph: '⊕', tone: 'add' },
16  modified: { glyph: '∆', tone: 'mod' },
17  deleted: { glyph: '⊖', tone: 'del' },
18  renamed: { glyph: '→', tone: 'info' },
19  untracked: { glyph: '?', tone: 'faint' },
20}
21
22const header = (tile: Tile, git: GitSnapshot) => {
23  let x = 0
24  const put = (s: string, tone: Tone) => {
25    tile.text(x, 0, s, tone)
26    x += [...s].length + 2
27  }
28  put(`${BRANCH} ${git.branch === '(detached)' ? 'detached' : fit(git.branch, Math.max(8, tile.cols - 26))}`, 'info')
29  if (git.ahead > 0) put(`↑${git.ahead}`, 'accent')
30  if (git.behind > 0) put(`↓${git.behind}`, 'mod')
31  const adds = git.files.reduce((n, f) => n + f.adds, 0)
32  const dels = git.files.reduce((n, f) => n + f.dels, 0)
33  if (git.files.length === 0) {
34    put('clean', 'faint')
35    return
36  }
37  put(`${git.files.length} changed`, 'dim')
38  if (adds > 0) put(`+${adds}`, 'add')
39  if (dels > 0) put(`-${dels}`, 'del')
40}
41
42const commits = (tile: Tile, git: GitSnapshot, x: number, y: number, width: number, rows: number, now: number) => {
43  git.commits.slice(0, rows).forEach((c, i) => {
44    const age = ago(c.at, now)
45    tile.text(x, y + i, '●', i === 0 ? 'accent' : 'faint')
46    tile.text(x + 2, y + i, c.hash.slice(0, 7), 'faint')
47    tile.text(x + 10, y + i, fit(c.subject, width - 10 - age.length - 1), i === 0 ? 'fg' : 'dim')
48    tile.text(x + width - age.length, y + i, age, 'faint')
49  })
50}
51
52const files = (tile: Tile, git: GitSnapshot, x: number, y: number, width: number, rows: number) => {
53  const shown = [...git.files].sort((a, b) => b.adds + b.dels - (a.adds + a.dels)).slice(0, rows)
54  const most = Math.max(1, ...shown.map(f => f.adds + f.dels))
55  const bar = Math.max(4, Math.min(10, Math.floor(width / 4)))
56  shown.forEach((f, i) => {
57    const mark = MARK[f.status]
58    const counts = `${f.adds > 0 ? `+${f.adds}` : ''}${f.dels > 0 ? ` -${f.dels}` : ''}`.trim()
59    // Untracked folders end in a slash: name them by their last segment.
60    const name = f.path.replace(/\/$/, '').split('/').pop() || f.path
61    const nameWidth = width - 2 - bar - 1 - 9
62    tile.text(x, y + i, mark.glyph, mark.tone)
63    tile.text(x + 2, y + i, fit(name, nameWidth), f.isStaged ? 'fg' : 'dim')
64    const total = f.adds + f.dels
65    const scale = total / most
66    if (total > 0) {
67      tile.meter(x + 2 + nameWidth + 1, y + i, bar, [
68        { share: (f.adds / total) * scale, tone: 'add' },
69        { share: (f.dels / total) * scale, tone: 'del' },
70      ])
71    }
72    tile.text(x + width - 8, y + i, padStart(counts, 8), 'dim')
73  })
74  if (git.files.length > rows) tile.text(x + 2, y + rows, `+${git.files.length - rows} more`, 'faint')
75}
76
77export const git = defineWidget<Git>({
78  contract: 1,
79  id: 'git',
80  title: 'Git',
81  sizes: [
82    { w: 2, h: 2 },
83    { w: 4, h: 2 },
84    { w: 2, h: 1 },
85  ],
86  needs: ['git'],
87  init: () => ({ state: { git: null } }),
88  update: (state, input) => (input.kind === 'git' ? { state: { git: input.git } } : { state }),
89  draw: (state, tile, now) => {
90    const g = state.git
91    if (g === null) {
92      tile.text(0, 0, 'reading git...', 'faint')
93      return
94    }
95    if (!g.isRepo) {
96      tile.text(0, 0, fit(`${g.cwd ?? 'this folder'} is not a git repo`, tile.cols), 'faint')
97      return
98    }
99    header(tile, g)
100    const body = tile.rows - 2
101    if (body <= 0) return
102
103    // Wide tiles: commits left, files right. Narrow: stacked.
104    if (tile.cols >= 70) {
105      const left = Math.floor((tile.cols - 2) * 0.58)
106      commits(tile, g, 0, 2, left, body, now)
107      files(tile, g, left + 2, 2, tile.cols - left - 2, body - 1)
108      return
109    }
110    const commitRows = Math.min(g.commits.length, g.files.length === 0 ? body : Math.ceil(body / 2) - 1)
111    commits(tile, g, 0, 2, tile.cols, commitRows, now)
112    files(tile, g, 0, 2 + commitRows + 1, tile.cols, body - commitRows - 2)
113  },
114})
115
hooks/widgets/hn.ts 83 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { defineWidget, type Effect, type Json } from '../contract'
5import { fit, padStart } from '../format'
6
7type Story = { id: number; title: string; score: number; comments: number }
8type Hn = { ids: number[]; stories: Story[]; error?: string }
9
10const API = 'https://hacker-news.firebaseio.com/v0'
11const COUNT = 10
12
13const top: Effect = { kind: 'fetch', tag: 'top', url: `${API}/topstories.json` }
14
15const parse = (text: string): unknown => {
16  try {
17    return JSON.parse(text)
18  } catch {
19    return undefined
20  }
21}
22
23const isStory = (v: unknown): v is { id: number; title: string; score?: number; descendants?: number } =>
24  typeof v === 'object' && v !== null && typeof (v as { id?: unknown }).id === 'number' && typeof (v as { title?: unknown }).title === 'string'
25
26const toStory = (s: { id: number; title: string; score?: number; descendants?: number }): Story => ({
27  id: s.id,
28  title: s.title,
29  score: s.score ?? 0,
30  comments: s.descendants ?? 0,
31})
32
33const thread = (id: number) => `https://news.ycombinator.com/item?id=${id}`
34
35const restore = (saved: Json | undefined): Story[] =>
36  Array.isArray(saved) ? saved.flatMap(s => (isStory(s) ? [toStory(s)] : [])) : []
37
38export const hn = defineWidget<Hn>({
39  contract: 1,
40  id: 'hn',
41  title: 'Hacker News',
42  sizes: [
43    { w: 2, h: 2 },
44    { w: 4, h: 2 },
45    { w: 2, h: 1 },
46  ],
47  tickMs: 300_000,
48  // Last fetch shows at once on start; the fresh one replaces it.
49  init: saved => ({ state: { ids: [], stories: restore(saved) }, effects: [top] }),
50  update: (state, input) => {
51    if (input.kind === 'tick') return { state, effects: [top] }
52    if (input.kind !== 'fetched') return { state }
53    if (!input.ok) return { state: { ...state, error: `HN unreachable (${input.status})` } }
54
55    const data = parse(input.text)
56    if (input.tag === 'top') {
57      const ids = Array.isArray(data) ? data.filter((n): n is number => typeof n === 'number').slice(0, COUNT) : []
58      return { state: { ...state, ids, error: undefined }, effects: ids.map(id => ({ kind: 'fetch', tag: `item:${id}`, url: `${API}/item/${id}.json` })) }
59    }
60    if (!isStory(data)) return { state }
61    const story = toStory(data)
62    // Keep top-list order; drop stories that fell off the list.
63    const byId = new Map([...state.stories, story].map(s => [s.id, s]))
64    const stories = state.ids.length === 0 ? [...byId.values()] : state.ids.flatMap(id => byId.get(id) ?? [])
65
66    return { state: { ...state, stories } }
67  },
68  draw: (state, tile) => {
69    if (state.stories.length === 0) {
70      tile.text(0, 0, state.error ?? 'fetching...', state.error === undefined ? 'faint' : 'del')
71      return
72    }
73    state.stories.slice(0, tile.rows).forEach((s, i) => {
74      const comments = `${s.comments}`
75      tile.text(0, i, padStart(`${s.score}`, 4), s.score >= 300 ? 'mod' : 'dim')
76      // Everything goes to the thread; the story's own link is one click on from there.
77      tile.link(5, i, fit(s.title, tile.cols - 5 - comments.length - 1), thread(s.id), 'fg')
78      tile.link(tile.cols - comments.length, i, comments, thread(s.id), 'faint')
79    })
80  },
81  persist: state => state.stories.map(s => ({ id: s.id, title: s.title, score: s.score, descendants: s.comments })),
82})
83
hooks/widgets/todo.ts 85 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { defineWidget, type Json } from '../contract'
5import { fit } from '../format'
6
7type Item = { text: string; isDone: boolean }
8type Todo = { items: Item[]; cursor: number }
9
10const restore = (saved: Json | undefined): Item[] => {
11  if (!Array.isArray(saved)) return []
12  return saved.flatMap(one =>
13    typeof one === 'object' && one !== null && !Array.isArray(one) && typeof one.text === 'string'
14      ? [{ text: one.text, isDone: one.isDone === true }]
15      : [],
16  )
17}
18
19const clampCursor = (items: readonly Item[], cursor: number) => Math.max(0, Math.min(items.length - 1, cursor))
20
21export const todo = defineWidget<Todo>({
22  contract: 1,
23  id: 'todo',
24  title: 'Todo',
25  sizes: [
26    { w: 2, h: 2 },
27    { w: 2, h: 1 },
28    { w: 4, h: 1 },
29  ],
30  prefix: '+',
31  keys: [
32    { hotkey: 'j', label: 'down', action: 'down' },
33    { hotkey: 'k', label: 'up', action: 'up' },
34    { hotkey: 'x', label: 'done', action: 'toggle' },
35    { hotkey: 'd', label: 'delete', action: 'delete' },
36  ],
37  init: saved => ({ state: { items: restore(saved), cursor: 0 } }),
38  update: (state, input) => {
39    if (input.kind === 'prompt') {
40      const text = input.text.trim()
41      if (text === '') return { state }
42      const items = [...state.items, { text, isDone: false }]
43      return { state: { items, cursor: items.length - 1 } }
44    }
45    if (input.kind !== 'key' || state.items.length === 0) return { state }
46
47    const { items, cursor } = state
48    switch (input.action) {
49      case 'down':
50        return { state: { items, cursor: clampCursor(items, cursor + 1) } }
51      case 'up':
52        return { state: { items, cursor: clampCursor(items, cursor - 1) } }
53      case 'toggle':
54        return { state: { items: items.map((it, i) => (i === cursor ? { ...it, isDone: !it.isDone } : it)), cursor } }
55      case 'delete': {
56        const next = items.filter((_, i) => i !== cursor)
57        return { state: { items: next, cursor: clampCursor(next, cursor) } }
58      }
59      default:
60        return { state }
61    }
62  },
63  draw: (state, tile) => {
64    if (state.items.length === 0) {
65      tile.text(0, 0, 'type "+ something" in the prompt', 'faint')
66      return
67    }
68    // The last row says how to work the list: the focus model is not obvious.
69    const long = 'click pane, then x done  j k move  d delete'
70    const hint = !tile.isFocused ? 'n: focus this list' : long.length <= tile.cols ? long : 'x done  j k move  d delete'
71    tile.text(0, tile.rows - 1, fit(hint, tile.cols), 'faint')
72    const rows = tile.rows - 1
73    // Keep the cursor in view on long lists.
74    const first = Math.max(0, Math.min(state.cursor - rows + 1, state.items.length - rows))
75    state.items.slice(first, first + rows).forEach((it, i) => {
76      const at = first + i
77      const isCursor = at === state.cursor
78      tile.text(0, i, isCursor ? '›' : ' ', 'accent')
79      tile.text(2, i, it.isDone ? '●' : '○', it.isDone ? 'add' : 'dim')
80      tile.text(4, i, fit(it.text, tile.cols - 4), it.isDone ? 'faint' : isCursor ? 'accent' : 'fg')
81    })
82  },
83  persist: state => state.items.map(it => ({ text: it.text, isDone: it.isDone })),
84})
85