SLOPSHOPPER

sidebar

A docked sidebar for widget mods (checklist, questions, processes, and any other): one tab per widget, or one stacked pane

newpanecommandtoast
★ 1v0.1.0no licenseupdated 2026-10-10hunterbrewer04/claude-toolkit/plugins/sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidebar
│ ┃ sidebar ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing going on. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /sidebar │ ⎿ sidebar: Sidebar: nothing going on. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sidebar
Nothing going on.
README

BrewKit

My personal Claude Code setup, packaged as a plugin marketplace so I can install exactly the pieces a given machine needs.

This is a public snapshot of how I extend Claude Code. It ships 11 plugins covering 20 skills, 1 sub-agent, 4 hooks, 5 mods, and a custom status line.

Most pieces follow my own paths and conventions, so treat them as reference patterns to borrow from rather than drop-in installs.

Install

/plugin marketplace add hunterbrewer04/claude-toolkit
/plugin install school@brewkit

Install only the plugins that machine actually needs. Nothing here assumes the others are present.

Plugins

school

Coursework tooling.

SkillWhat it does
course-setupScaffold a new course folder and clean Canvas file dumps; owns the school.json registry
grade-calcExact grade math from syllabus weights: current grade, what-ifs, target scores
rubric-checkGrade a draft against its assignment spec before submitting
study-guideTurn lecture PDFs into a self-contained interactive HTML study package
notebooklm-course-syncKeep a course's NotebookLM notebook in sync with local files
sapling-ai-detectorScan text for AI-generated content with a per-sentence report

work

Client and project delivery.

SkillWhat it does
linear-assistantCreate, update, and query Linear issues, projects, milestones, and cycles

personal

Personal-life tooling.

SkillWhat it does
apple-calendarRead and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server

misc

Everything cross-cutting.

SkillWhat it does
claude-toolkitAdd, sync, and set up this repo's components across machines
claude-documentationGenerate consistent README docs for skills, hooks, and sub-agents
skill-builderBuild a new skill through a structured, validated process
docxCreate, read, and edit Word documents, including tracked changes and comments
notebooklmFull programmatic NotebookLM API: notebooks, sources, artifacts, downloads
tailnetMove files to tailnet servers, serve files over Tailscale, Taildrop to a phone

Also ships three hooks that apply everywhere: a PreToolUse guard against committing .env files, a SessionStart agent-state tracker, and a Stop desktop notification.

dev-flow

My development workflow chain, split across two sessions with a context clear in between.

SkillWhat it does
specTurn an idea into an approved specification
planBreak an approved spec into waves of file-disjoint tasks
implementExecute the plan across persistent subagent slots in git worktrees
reviewWhole-branch review pass with specialist fan-out
testRun the plan's verification section, then commit and open the PR

Includes the code-reviewer sub-agent used by the review step, and a SessionStart resume hook.

meta-builders

SkillWhat it does
skill-creatorCreate and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric

sidebar

A mod: a docked sidebar for the widget mods. Widgets publish their section and handle their own buttons; the sidebar only draws, so a new widget needs no change here. The layout option picks how: tabs (the default) gives each widget its own pane, shown as tabs titled with the widget's badge (Checklist 93%), opened when the widget has something, focused when it asks for attention, and closed when it empties; stacked draws every section in one pane that opens itself on attention. The order option sets the tab order or the stacking. /sidebar opens it, /sidebar close hides it.

checklist

A mod: a live checklist of the current plan, drawn as a section in the sidebar. Claude posts the steps through its own checklist tool and checks each one off as it finishes, subagents working a step show under it with their status and tool-call count, and the status line carries done/total. /checklist prints the list, /checklist clear empties it.

questions

A mod: questions Claude asks without stopping the work. Each one comes with the default Claude goes with meanwhile and gets an answer field in the sidebar. An answer reaches Claude on its next step, with a note to redo anything built on the default. Questions still open at the end of a turn get a toast.

processes

A mod: the background commands and dev servers Claude started this session, shown in the sidebar with their ports and uptime and a button that kills one. Nothing is ever killed automatically; anything still running when the session ends gets a toast.

review-pair

A mod: a second Claude session that reviews every commit the session you are working in makes. The first commit in a repo asks once (always, not now, never). On always, a Sonnet reviewer opens in a herdr pane beside you (or as a background session outside herdr) and gets each new commit range. Blockers go back to the builder, which fixes them, for up to two rounds; nits and clean reviews go to a Review tab in the sidebar, and nits are posted as one PR comment when the builder opens a PR. The reviewer cannot use the Edit or Write tools and closes when the last builder in that repo ends. /review-pair shows the status; on, off, forget and stop change it.

Status line

statusline/ holds an agnoster-inspired three-row status line: where you are, what you are running, and what you are burning. Point statusLine.command in settings.json at statusline-command.sh.

Machine-local configuration

Nothing machine-specific is committed here. Two files live outside the repo:

FileUsed byNotes
~/.claude/tailnet-servers.jsontailnetServer registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema
~/.claude/settings.jsoneverythingPermissions, env, enabled plugins, status line wiring
Source 2 files
hooks/register.tsx 536 lines
1import { update } from 'claude-code'
2import type { Elements, EngineInterface, Register, UiPane } from 'claude-code'
3
4import type { SidebarRow, SidebarSection, SidebarTone } from '../types'
5
6const PANE = 'sidebar'
7const TAB = 'sidebar-'
8const TITLE = 'Sidebar'
9const DEFAULT_ORDER = 'checklist,questions,processes'
10const ANSWER_PLACEHOLDER = 'type an answer…'
11
12const THEME = {
13  accent: '#60A5FA',
14  done: '#4ADE80',
15  track: '#2A3F66',
16  activeRow: '#1E3A8A',
17  activeText: '#FFFFFF',
18  activeMuted: '#BFD4FF',
19  warn: '#FBBF24',
20}
21const TONE: Record<SidebarTone, string> = {
22  accent: THEME.accent,
23  done: THEME.done,
24  warn: THEME.warn,
25  error: 'error',
26  muted: 'inactive',
27  text: 'text',
28}
29const TONES = Object.keys(TONE)
30const ITEM_STATES = ['normal', 'active', 'done']
31
32const sections = { plugin: 'sidebar', key: 'sections' } as const
33const seen = { plugin: 'sidebar', key: 'seen' } as const
34const seenAttention = { plugin: 'sidebar', key: 'seenAttention' } as const
35const hasHinted = { plugin: 'sidebar', key: 'hasHinted' } as const
36
37type Layout = 'tabs' | 'stacked'
38
39type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & {
40  Input: Elements['terminal']['Input'] | undefined
41}
42
43type Entry = { name: string; section: SidebarSection | undefined }
44
45// The sidebar only draws: each widget hooks ui.press / ui.input on its own keys.
46const noop = (): void => undefined
47
48export const register: Register = (on, options) => {
49  const order = parseOrder(typeof options.order === 'string' ? options.order : DEFAULT_ORDER)
50  const layout: Layout = options.layout === 'stacked' ? 'stacked' : 'tabs'
51
52  on('session.start', async ($, e, next) => {
53    await $.command.register({
54      name: 'sidebar',
55      description: 'Open the sidebar (close hides it)',
56      argumentHint: '[close]',
57    })
58    // A layout change reloads the module: swap the old layout's panes for this one's.
59    await swapLayout($, layout, order).catch(noop)
60
61    return next(e)
62  })
63
64  on('command.run', { command: 'sidebar' }, async ($, e) => {
65    if (e.args.trim() === 'close') {
66      for (const pane of await ownPanes($)) await $.ui.close({ id: pane.id })
67      return { text: 'Sidebar closed.' }
68    }
69    if (layout === 'stacked') {
70      await $.ui.open({ id: PANE, title: TITLE })
71      return { text: 'Sidebar opened.' }
72    }
73    const shown = (await entries($, order)).filter(isShownEntry)
74    if (shown.length === 0) return { text: 'Sidebar: nothing going on.' }
75    for (const entry of shown) {
76      await $.ui.open({ id: tabId(entry.name), title: tabTitle(entry.name, entry.section) })
77    }
78
79    return { text: 'Sidebar opened.' }
80  })
81
82  on('state.set', async ($, e, next) => {
83    const ran = await next(e)
84    const write = e as unknown as { plugin: string; key: string; value: unknown }
85    // A write that lost its ifVersion race changed nothing, so the winner's value must stay.
86    const landed = ran.value?.isSet === true
87    if (landed && write.plugin !== 'sidebar' && write.key === 'section' && isVersionOne(write.value)) {
88      await mirror($, layout, write.plugin, write.value as SidebarSection)
89    }
90
91    return ran
92  }).catch(($, e, next) => next(e))
93
94  // Draws the stacked pane and every widget's tab, whichever layout is set, so a
95  // pane left up by the other layout still draws until it is closed.
96  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
97    const isStacked = e.requestId === PANE
98    if (!isStacked && !e.requestId.startsWith(TAB)) return next(e)
99
100    const table = $.ui.resolve(e)
101    const kit: Kit = {
102      Box: table.Box,
103      Text: table.Text,
104      Button: table.Button,
105      // Mobile draws no fields yet, so an input row shows as dim text there.
106      Input: e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input,
107    }
108    const { Box, Text } = kit
109    const all = await entries($, order)
110    const visible = isStacked
111      ? all.filter(isShownEntry)
112      : all.filter(entry => tabId(entry.name) === e.requestId && isShownEntry(entry))
113    const inner = Math.max(12, e.props.bodyColumns - 4)
114
115    if (visible.length === 0) {
116      return (
117        <Box paddingX={1}>
118          <Text dimColor>Nothing going on.</Text>
119        </Box>
120      )
121    }
122
123    return (
124      <Box flexDirection="column">
125        {visible.map((entry, index) => (
126          <Box key={entry.name} flexDirection="column" {...(index > 0 ? { marginTop: 1 } : {})}>
127            {drawEntry(kit, entry, inner)}
128          </Box>
129        ))}
130      </Box>
131    )
132  }).catch(($, e, next) => next(e))
133}
134
135async function entries($: EngineInterface, order: string[]): Promise<Entry[]> {
136  const { value: names = [] } = await $.state.get(seen)
137  const found: Entry[] = []
138  for (const name of drawOrder(order, names)) {
139    const { value } = await $.state.get({ ...sections, id: name })
140    found.push({ name, section: isSection(value) ? value : undefined })
141  }
142
143  return found
144}
145
146async function mirror(
147  $: EngineInterface,
148  layout: Layout,
149  name: string,
150  section: SidebarSection,
151): Promise<void> {
152  const member = { ...sections, id: name }
153  const { value: before } = await $.state.get(member)
154  await $.state.set(member, section)
155  await update($, seen, (names = []) => (names.includes(name) ? names : [...names, name]))
156  const rose = await noteAttention($, name, section)
157
158  if (layout === 'stacked') {
159    if (rose) await reveal($, PANE, TITLE, false)
160    return
161  }
162
163  const id = tabId(name)
164  const pane = (await ownPanes($)).find(open => open.id === id)
165  if (!isShown(section)) {
166    if (pane !== undefined) await $.ui.close({ id }).catch(noop)
167    return
168  }
169  const title = tabTitle(name, isSection(section) ? section : undefined)
170  const wasShown = before !== undefined && isShown(before)
171  if (rose || !wasShown) {
172    await reveal($, id, title, rose)
173    return
174  }
175  // Retitle only a tab that is up: a tab the person closed stays closed.
176  if (pane !== undefined && pane.title !== title) {
177    await $.ui.open({ id, title }).catch(noop)
178  }
179}
180
181// Records the widget's attention and says whether it rose. A drop means the widget
182// started its count over (a /clear); only a rise asks to be seen.
183async function noteAttention($: EngineInterface, name: string, section: SidebarSection): Promise<boolean> {
184  const attention = section.attention
185  if (typeof attention !== 'number' || !Number.isFinite(attention)) return false
186  const member = { ...seenAttention, id: name }
187  const { value: last = 0 } = await $.state.get(member)
188  if (attention === last) return false
189  await $.state.set(member, attention)
190
191  return attention > last
192}
193
194async function reveal($: EngineInterface, id: string, title: string, focus: boolean): Promise<void> {
195  const opened = await $.ui
196    .open({ id, title, ...(focus ? { focus: true as const } : {}) })
197    .catch(() => undefined)
198  if (opened === undefined || opened.isPlaced) return
199  const { value: isHinted = false } = await $.state.get(hasHinted)
200  if (isHinted) return
201  await $.state.set(hasHinted, true)
202  $.ui.toast('Sidebar: run /sidebar to open')
203}
204
205async function swapLayout($: EngineInterface, layout: Layout, order: string[]): Promise<void> {
206  const stale = (await ownPanes($)).filter(pane => (layout === 'tabs') === (pane.id === PANE))
207  if (stale.length === 0) return
208  for (const pane of stale) await $.ui.close({ id: pane.id })
209  // The sidebar was up in the old layout, so put it up in the new one.
210  if (layout === 'stacked') {
211    await $.ui.open({ id: PANE, title: TITLE })
212    return
213  }
214  for (const entry of (await entries($, order)).filter(isShownEntry)) {
215    await $.ui.open({ id: tabId(entry.name), title: tabTitle(entry.name, entry.section) })
216  }
217}
218
219// A surface with no pane record lists none, so a write still opens what it should.
220async function ownPanes($: EngineInterface): Promise<UiPane[]> {
221  const panes = await $.ui.panes().catch((): readonly UiPane[] => [])
222  return panes.filter(pane => pane.id === PANE || pane.id.startsWith(TAB))
223}
224
225function tabId(name: string): string {
226  return `${TAB}${name.replace(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 64)
227}
228
229function tabTitle(name: string, section: SidebarSection | undefined): string {
230  const label = name.charAt(0).toUpperCase() + name.slice(1)
231  const badge = section?.badge ? ` ${section.badge}` : ''
232  // A control character in a title is refused, so a badge never carries one.
233  return `${label}${badge}`.replace(/[\u0000-\u001f\u007f]/g, ' ')
234}
235
236// A bad section still shows (as its error line); only a good one can be empty.
237function isShown(value: unknown): boolean {
238  return !isSection(value) || !value.isEmpty
239}
240
241function isShownEntry(entry: Entry): boolean {
242  return entry.section === undefined || !entry.section.isEmpty
243}
244
245function drawEntry(kit: Kit, entry: Entry, inner: number) {
246  const { Box, Text } = kit
247  if (entry.section === undefined) {
248    return (
249      <Box paddingX={1}>
250        <Text dimColor>{`${entry.name}: bad section`}</Text>
251      </Box>
252    )
253  }
254
255  return drawSection(kit, entry.section, inner)
256}
257
258function drawSection(kit: Kit, section: SidebarSection, inner: number) {
259  const { Box, Text } = kit
260  const color = TONE[section.tone ?? 'accent']
261  const lead = section.rows.findIndex(row => row.kind !== 'progress')
262  const headed = lead === -1 ? section.rows : section.rows.slice(0, lead)
263  const rest = lead === -1 ? [] : section.rows.slice(lead)
264  const numberWidth = Math.max(
265    0,
266    ...section.rows.map(row =>
267      row.kind === 'item' && row.number !== undefined ? String(row.number).length : 0,
268    ),
269  )
270
271  return (
272    <Box flexDirection="column">
273      <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
274        <Box justifyContent="space-between">
275          <Box flexShrink={1}>
276            <Text bold color="text" wrap="truncate-end">
277              {section.title}
278            </Text>
279          </Box>
280          {section.badge ? <Text bold color={color}>{` ${section.badge}`}</Text> : null}
281        </Box>
282        {headed.map((row, index) =>
283          row.kind === 'progress' ? (
284            <Box key={`progress-${index}`}>{drawProgress(kit, row, color, inner)}</Box>
285          ) : null,
286        )}
287      </Box>
288      {rest.map((row, index) => {
289        const previous = index === 0 ? undefined : rest[index - 1]
290        const isSpaced =
291          index === 0 ||
292          row.kind === 'divider' ||
293          (row.kind === 'text' && previous?.kind !== 'text' && previous?.kind !== 'divider')
294        return (
295          <Box key={`row-${index}`} flexDirection="column" {...(isSpaced ? { marginTop: 1 } : {})}>
296            {drawRow(kit, row, color, inner, numberWidth)}
297          </Box>
298        )
299      })}
300    </Box>
301  )
302}
303
304function drawRow(kit: Kit, row: SidebarRow, color: string, inner: number, numberWidth: number) {
305  const { Box, Text, Button, Input } = kit
306
307  if (row.kind === 'progress') {
308    return <Box paddingX={1}>{drawProgress(kit, row, color, inner)}</Box>
309  }
310  if (row.kind === 'divider') {
311    return (
312      <Box paddingX={1}>
313        <Text color={THEME.accent}>{rule(row.label, inner)}</Text>
314      </Box>
315    )
316  }
317  if (row.kind === 'text') {
318    const tone = row.tone ?? 'text'
319    return (
320      <Box paddingX={1}>
321        <Text
322          bold={row.bold === true}
323          {...(tone === 'muted' ? { dimColor: true } : { color: TONE[tone] })}
324        >
325          {row.text}
326        </Text>
327      </Box>
328    )
329  }
330  if (row.kind === 'button') {
331    return (
332      <Box paddingX={1}>
333        <Button
334          key={row.key}
335          label={row.label}
336          {...(row.primary ? { variant: 'primary' as const } : {})}
337          onPress={noop}
338        />
339      </Box>
340    )
341  }
342  if (row.kind === 'input') {
343    // Lines up under the item text: past the icon column, and the number column when shown.
344    const indent = numberWidth > 0 ? numberWidth + 4 : 2
345    return (
346      <Box flexDirection="column" paddingX={1}>
347        {Input === undefined ? (
348          <Text dimColor>{row.placeholder ?? row.key}</Text>
349        ) : (
350          <Box paddingLeft={indent}>
351            <Box flexGrow={1} borderStyle="round" borderColor={THEME.track} paddingX={1}>
352              <Input
353                key={row.key}
354                label="›"
355                placeholder={row.placeholder ?? ANSWER_PLACEHOLDER}
356                submitLabel="send"
357                onSubmit={noop}
358              />
359            </Box>
360          </Box>
361        )}
362        {row.hint ? (
363          <Box paddingLeft={Input === undefined ? 0 : indent + 2}>
364            <Text dimColor>{row.hint}</Text>
365          </Box>
366        ) : null}
367      </Box>
368    )
369  }
370
371  const isDone = row.state === 'done'
372  const isActive = row.state === 'active'
373  const hasNumber = row.number !== undefined
374  const muted = isActive ? THEME.activeMuted : 'inactive'
375  return (
376    <Box
377      flexDirection="column"
378      paddingX={1}
379      {...(isActive ? { backgroundColor: THEME.activeRow } : {})}
380    >
381      <Box>
382        <Text color={isActive ? THEME.activeText : TONE[row.tone ?? 'text']}>{`${row.icon} `}</Text>
383        {hasNumber ? (
384          <Text color={muted}>{`${String(row.number).padStart(numberWidth)}  `}</Text>
385        ) : null}
386        <Box flexShrink={1} {...(row.action ? { flexGrow: 1 } : {})}>
387          <Text
388            strikethrough={isDone}
389            dimColor={isDone}
390            bold={isActive}
391            color={isActive ? THEME.activeText : 'text'}
392          >
393            {row.text}
394          </Text>
395        </Box>
396        {row.action ? (
397          <Box marginLeft={1}>
398            <Button key={row.action.key} plain label={row.action.label} onPress={noop} />
399          </Box>
400        ) : null}
401      </Box>
402      {row.sub ? (
403        <Box paddingLeft={hasNumber ? numberWidth + 4 : 2}>
404          <Text color={muted}>{'└ '}</Text>
405          {row.sub.icon ? (
406            <Text color={TONE[row.sub.tone ?? 'muted']}>{`${row.sub.icon} `}</Text>
407          ) : null}
408          <Box flexShrink={1}>
409            <Text color={muted}>{row.sub.text}</Text>
410          </Box>
411        </Box>
412      ) : null}
413    </Box>
414  )
415}
416
417function drawProgress(
418  kit: Kit,
419  row: Extract<SidebarRow, { kind: 'progress' }>,
420  color: string,
421  inner: number,
422) {
423  const { Box, Text } = kit
424  const tally = row.label ? ` ${row.label}` : ''
425  const bar = progressBar(row.fraction, Math.max(4, inner - tally.length))
426
427  return (
428    <Box>
429      <Text color={color}>{bar.filled}</Text>
430      <Text color={THEME.track}>{bar.empty}</Text>
431      {tally ? <Text dimColor>{tally}</Text> : null}
432    </Box>
433  )
434}
435
436function parseOrder(text: string): string[] {
437  return text
438    .split(',')
439    .map(name => name.trim())
440    .filter(name => name.length > 0)
441}
442
443function drawOrder(order: string[], names: string[]): string[] {
444  return [...order.filter(name => names.includes(name)), ...names.filter(name => !order.includes(name))]
445}
446
447function isVersionOne(value: unknown): boolean {
448  return typeof value === 'object' && value !== null && (value as { version?: unknown }).version === 1
449}
450
451function isSection(value: unknown): value is SidebarSection {
452  if (!isVersionOne(value)) return false
453  const section = value as Record<string, unknown>
454  return (
455    typeof section.title === 'string' &&
456    isOptional(section.badge, 'string') &&
457    isTone(section.tone) &&
458    typeof section.isEmpty === 'boolean' &&
459    typeof section.attention === 'number' &&
460    Array.isArray(section.rows) &&
461    section.rows.every(isRow)
462  )
463}
464
465function isRow(value: unknown): boolean {
466  if (typeof value !== 'object' || value === null) return false
467  const row = value as Record<string, unknown>
468  switch (row.kind) {
469    case 'progress':
470      return typeof row.fraction === 'number' && isOptional(row.label, 'string')
471    case 'item':
472      return (
473        typeof row.icon === 'string' &&
474        typeof row.text === 'string' &&
475        isTone(row.tone) &&
476        isOptional(row.number, 'number') &&
477        (row.state === undefined || ITEM_STATES.includes(row.state as string)) &&
478        (row.sub === undefined || isSub(row.sub)) &&
479        (row.action === undefined || isAction(row.action))
480      )
481    case 'input':
482      return (
483        typeof row.key === 'string' &&
484        isOptional(row.placeholder, 'string') &&
485        isOptional(row.hint, 'string')
486      )
487    case 'button':
488      return (
489        typeof row.key === 'string' &&
490        typeof row.label === 'string' &&
491        isOptional(row.primary, 'boolean')
492      )
493    case 'text':
494      return typeof row.text === 'string' && isTone(row.tone) && isOptional(row.bold, 'boolean')
495    case 'divider':
496      return typeof row.label === 'string'
497    default:
498      return false
499  }
500}
501
502function isSub(value: unknown): boolean {
503  if (typeof value !== 'object' || value === null) return false
504  const sub = value as Record<string, unknown>
505  return typeof sub.text === 'string' && isOptional(sub.icon, 'string') && isTone(sub.tone)
506}
507
508function isAction(value: unknown): boolean {
509  if (typeof value !== 'object' || value === null) return false
510  const action = value as Record<string, unknown>
511  return typeof action.key === 'string' && typeof action.label === 'string'
512}
513
514function isTone(value: unknown): boolean {
515  return value === undefined || TONES.includes(value as string)
516}
517
518function isOptional(value: unknown, type: 'string' | 'number' | 'boolean'): boolean {
519  return value === undefined || typeof value === type
520}
521
522function progressBar(fraction: number, width: number): { filled: string; empty: string } {
523  const halves = Math.round(Math.min(1, Math.max(0, fraction)) * width * 2)
524  const full = Math.floor(halves / 2)
525  const hasHalf = halves % 2 === 1
526
527  return {
528    filled: '━'.repeat(full) + (hasHalf ? '╸' : ''),
529    empty: '─'.repeat(width - full - (hasHalf ? 1 : 0)),
530  }
531}
532
533function rule(label: string, width: number): string {
534  return `── ${label} ${'─'.repeat(Math.max(0, width - label.length - 4))}`
535}
536
types/index.d.ts 38 lines
1export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
2
3export type SidebarRow =
4  | { kind: 'progress'; fraction: number; label?: string }
5  | { kind: 'item'; icon: string; tone?: SidebarTone; number?: number; text: string;
6      state?: 'normal' | 'active' | 'done';
7      sub?: { icon?: string; tone?: SidebarTone; text: string };
8      action?: { key: string; label: string } }
9  | { kind: 'input'; key: string; placeholder?: string; hint?: string }
10  | { kind: 'button'; key: string; label: string; primary?: boolean }
11  | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
12  | { kind: 'divider'; label: string }
13
14export type SidebarSection = {
15  version: 1
16  title: string
17  badge?: string
18  tone?: SidebarTone
19  isEmpty: boolean
20  attention: number
21  rows: SidebarRow[]
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    sidebar: {
27      /** Each widget's latest section, keyed by the widget's plugin name. */
28      sections: StateFamily<SidebarSection>
29      /** Widget names in the order their first section arrived. */
30      seen: string[]
31      /** The highest `attention` acted on per widget, so a reload never reopens the pane. */
32      seenAttention: StateFamily<number>
33      /** Whether the "run /sidebar" toast has shown this session. */
34      hasHinted: boolean
35    }
36  }
37}
38