The plan above the prompt: a page's dot block, one node a packet, its class the state, its tooltip the done-when

The plan above the prompt. A Claude Code mod: a band that reads one page's
- A node's state is its `class`: `done`, `hand` (yours), `open` (a word
not said), `end` (where it ends), `next`. Colours are the fallback:
blue fill done, thick border hand, diamond open, octagon end.
- A node's `tooltip` is its done-when. Each packet is a button; a press
toasts it. Hotkeys 1–9 while the band has focus (ctrl+x tab), `h` hides.
- `/week` prints the packets as a list with their done-whens.
- The page is a setting, `file`. The band re-reads it when it changes.
Install, from the cyberware.sh shelf:
/plugin marketplace add Sureffi/cyberware.sh
/plugin install week-band@cyberware
`file` has no default; the install asks for it. From the shell it is set
at install with `--config`, and changed later through `configure`:
claude plugin install week-band@cyberware --config file=/path/to/plan.md
echo '{"file":"/path/to/plan.md"}' | claude plugin configure week-band@cyberware --values-stdin
`claude plugin configure week-band@cyberware` alone shows the value.
The same dot block draws as a graph anywhere graphviz draws, so the plan
is one file with two projections.
hooks/register.tsx 101 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3
4import type { Packet, State } from '../types'
5
6const packets = atom({ plugin: 'week-band', key: 'packets' } as const, [] as Packet[])
7const mtime = atom({ plugin: 'week-band', key: 'mtimeMs' } as const, 0)
8const isHidden = atom({ plugin: 'week-band', key: 'isHidden' } as const, false)
9
10const STATES: State[] = ['done', 'hand', 'open', 'end', 'next']
11
12const unquote = (s: string): string => s.replace(/\\n/g, ' · ').replace(/\\"/g, '"')
13
14const attr = (attrs: string, name: string): string | undefined =>
15 attrs.match(new RegExp(`\\b${name}="((?:[^"\\\\]|\\\\.)*)"`))?.[1]
16
17const stateOf = (attrs: string): State => {
18 const cls = attr(attrs, 'class')
19 if (cls && (STATES as string[]).includes(cls)) return cls as State
20 if (/fillcolor="#233d8c"/.test(attrs)) return 'done'
21 if (/penwidth=2/.test(attrs)) return 'hand'
22 if (/shape=diamond/.test(attrs)) return 'open'
23 if (/shape=octagon/.test(attrs)) return 'end'
24 return 'next'
25}
26
27const parse = (text: string): Packet[] => {
28 const fence = text.match(/```dot\n([\s\S]*?)```/)
29 if (!fence) return []
30 const out: Packet[] = []
31 for (const line of fence[1].split('\n')) {
32 const m = line.match(/^\s*(\w+)\s*\[(.*)\]\s*$/)
33 if (!m || m[1] === 'node' || m[1] === 'edge' || m[1] === 'graph') continue
34 const attrs = m[2]
35 out.push({
36 id: m[1],
37 label: unquote(attr(attrs, 'label') ?? m[1]),
38 state: stateOf(attrs),
39 note: unquote(attr(attrs, 'tooltip') ?? ''),
40 })
41 }
42 return out
43}
44
45const GLYPH: Record<State, string> = { done: '●', hand: '▶', open: '◇', end: '⬢', next: '○' }
46const COLOR: Record<State, 'success' | 'warning' | 'suggestion' | 'error' | 'inactive'> = {
47 done: 'success', hand: 'warning', open: 'suggestion', end: 'error', next: 'inactive',
48}
49
50async function refresh($: EngineInterface, file: string): Promise<void> {
51 const stat = await $.fs.stat(file).catch(() => undefined)
52 if (!stat) return
53 const seen = await read($, mtime)
54 if (stat.mtimeMs === seen) return
55 const text = await $.fs.read(file)
56 await update($, packets, () => parse(text))
57 await update($, mtime, () => stat.mtimeMs)
58}
59
60export const register: Register = (on, options) => {
61 const file = String(options.file)
62
63 on('session.start', async ($, e, next) => {
64 await refresh($, file)
65 $.clock.every(3000, () => { void refresh($, file) })
66 $.command.register({ name: 'week', description: 'The plan as a list, each packet with its state and its done-when' })
67 return next(e)
68 })
69
70 on('command.run', { name: 'week' }, async ($) => {
71 const list = await read($, packets)
72 if (list.length === 0) return { text: `no plan in ${file}` }
73 const lines = list.map(p => `${GLYPH[p.state]} ${p.label}${p.note ? `\n ${p.note}` : ''}`)
74 return { text: lines.join('\n') }
75 })
76
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 const list = await read($, packets)
79 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return next(e)
80 const { Box, Button, Text } = $.ui.resolve(e)
81 return (
82 <Box flexDirection="row" flexWrap="wrap">
83 {list.map((p, i) => (
84 <Box key={p.id} marginRight={2}>
85 <Button
86 key={p.id}
87 hotkey={i < 9 ? String(i + 1) : undefined}
88 onPress={() => { $.ui.toast(p.note ? `${p.label}: ${p.note}` : p.label) }}
89 >
90 <Text color={COLOR[p.state]} bold={p.state === 'hand'}>{GLYPH[p.state]} </Text>
91 <Text color={COLOR[p.state]} dimColor={p.state === 'next'}>{p.label}</Text>
92 </Button>
93 {i < list.length - 1 ? <Text dimColor> →</Text> : null}
94 </Box>
95 ))}
96 <Button key="hide" hotkey="h" label="hide" onPress={() => update($, isHidden, () => true)} />
97 </Box>
98 )
99 })
100}
101types/index.d.ts 9 lines1export type State = 'done' | 'hand' | 'open' | 'end' | 'next'
2export type Packet = { id: string; label: string; state: State; note: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'week-band': { packets: Packet[]; mtimeMs: number; isHidden: boolean }
7 }
8}
9