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

A bento grid in the side pane: clock, todo, context forecast, git, Hacker News. /dash opens it.
| Command | Does | |
|---|---|---|
/dash | open or close | |
/dash list | every 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 reset | back to the default layout | |
| `/dash autostart on\ | off` | open at session start. Off by default |
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.
| Widget | Sizes | Shows |
|---|---|---|
clock | 2x1, 1x1 | time in dot-matrix, date, seconds |
todo | 2x2, 2x1, 4x1 | the list |
context | 2x1, 2x2 | share of the compaction point, tokens left, turns left at the current pace. Cost on API keys; hidden on subscriptions, where it means nothing |
git | 2x2, 4x2, 2x1 | branch, ahead/behind, recent commits, changed files with +/- bars |
hn | 2x2, 4x2, 2x1 | top 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.
hooks/widgets/<id>.ts, built with defineWidget from hooks/contract.ts.hooks/widgets/index.ts.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.
/dash.Credits: CREDITS.md.
hooks/register.tsx 518 lines1// 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}
518hooks/contract.ts 115 lines1// 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)
115hooks/gitparse.ts 81 lines1// 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}
81hooks/grid.ts 50 lines1// 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}`
50hooks/href.ts 18 lines1// 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}
18hooks/kit.ts 198 lines1// 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}
198hooks/widgets/index.ts 13 lines1// 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)
13hooks/widgets/clock.ts 48 lines1// 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})
48hooks/widgets/context.ts 66 lines1// 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})
66hooks/widgets/git.ts 115 lines1// 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})
115hooks/widgets/hn.ts 83 lines1// 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})
83hooks/widgets/todo.ts 85 lines1// 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