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

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.
/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.
Coursework tooling.
| Skill | What it does |
|---|---|
| course-setup | Scaffold a new course folder and clean Canvas file dumps; owns the school.json registry |
| grade-calc | Exact grade math from syllabus weights: current grade, what-ifs, target scores |
| rubric-check | Grade a draft against its assignment spec before submitting |
| study-guide | Turn lecture PDFs into a self-contained interactive HTML study package |
| notebooklm-course-sync | Keep a course's NotebookLM notebook in sync with local files |
| sapling-ai-detector | Scan text for AI-generated content with a per-sentence report |
Client and project delivery.
| Skill | What it does |
|---|---|
| linear-assistant | Create, update, and query Linear issues, projects, milestones, and cycles |
Personal-life tooling.
| Skill | What it does |
|---|---|
| apple-calendar | Read and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server |
Everything cross-cutting.
| Skill | What it does |
|---|---|
| claude-toolkit | Add, sync, and set up this repo's components across machines |
| claude-documentation | Generate consistent README docs for skills, hooks, and sub-agents |
| skill-builder | Build a new skill through a structured, validated process |
| docx | Create, read, and edit Word documents, including tracked changes and comments |
| notebooklm | Full programmatic NotebookLM API: notebooks, sources, artifacts, downloads |
| tailnet | Move 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.
My development workflow chain, split across two sessions with a context clear in between.
| Skill | What it does |
|---|---|
| spec | Turn an idea into an approved specification |
| plan | Break an approved spec into waves of file-disjoint tasks |
| implement | Execute the plan across persistent subagent slots in git worktrees |
| review | Whole-branch review pass with specialist fan-out |
| test | Run 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.
| Skill | What it does |
|---|---|
| skill-creator | Create and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric |
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.
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.
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.
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.
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.
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.
Nothing machine-specific is committed here. Two files live outside the repo:
| File | Used by | Notes |
|---|---|---|
~/.claude/tailnet-servers.json | tailnet | Server registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema |
~/.claude/settings.json | everything | Permissions, env, enabled plugins, status line wiring |
hooks/register.tsx 536 lines1import { 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}
536types/index.d.ts 38 lines1export 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