SLOPSHOPPER

week-band

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

newbandcommandtoasttimer
v0.2.1no licenseupdated 2026-10-14Sureffi/week-band
A shopper browsing a rack in a slop shop
README

week-band

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.
Source 2 files
hooks/register.tsx 101 lines
1import { 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}
101
types/index.d.ts 9 lines
1export 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