A project context pane: roadmap progress, decisions, errors and releases from your DOCS, drawn with pixel visuals.

docket is a Claude Code mod that opens a project context pane. It reads the roadmap, decisions, errors, and changelog from your project's docs and draws each one with a small pixel visual on top.
I keep DOCS/ROADMAP.md, DOCS/MEMORY.md, DOCS/ERRORS.md, and CHANGELOG.md in pretty much every project, and I wanted to see where a project stands without opening four files.
Run /docket and a pane opens with four tabs:
| Tab | File | Visual |
|---|---|---|
| Roadmap | DOCS/ROADMAP.md | Overall percent done, plus a progress bar for each ## section |
| Decisions | DOCS/MEMORY.md | A timeline with a dot for each dated entry |
| Errors | DOCS/ERRORS.md | The same timeline, in amber |
| Changelog | CHANGELOG.md | A GitHub-style calendar of releases over recent weeks |
The Decisions, Errors, and Changelog tabs list every entry newest first. Select one to expand its full text right in the pane.
If a file isn't in DOCS/, docket checks the project root next. For the changelog it checks the root first.
| Key | What it does |
|---|---|
1 to 4 | Switch tabs |
r | Read the files again |
| Tab, Enter | Move between entries and expand one |
| Page Up, Page Down | Scroll a long tab |
| Esc | Close the pane |
You usually won't need r. The pane reloads by itself when Claude edits one of the files it shows.
- [x] and - [ ] checkboxes. A section with plain bullets and no checkboxes counts every item as done if its heading says something like "Done", "Completed", or "Shipped", and as open otherwise.## headings. Dates can look like 2026-09-21 or September 22, 2026. If a heading is only a date, docket uses the first ### under it as the title.So a roadmap written as plain bullets under a heading like "Phase 1 - Current" shows up as all open, even if the work is finished. Checkboxes give the most accurate bars.
You need Claude Code v2.1.287 or later.
This repo is also a small plugin marketplace called docket, so you can install it straight from GitHub. Inside Claude Code:
/plugin marketplace add pinkpixel-dev/docket
/plugin install docket@docket
Or from your shell:
claude plugin marketplace add pinkpixel-dev/docket
claude plugin install docket@docket
If a session is already open, run /reload-plugins to load it. Then run /docket from inside any project.
Run /plugin and you should see docket in the mods active line under the tabs. To pick up new versions later, run claude plugin marketplace update docket.
If you've cloned the repo and want to hack on it, you can load it from the folder for one session instead:
git clone https://github.com/pinkpixel-dev/docket.git
claude --plugin-dir ./docket
Claude Code watches that folder, so saving a file reloads the mod.
The pixel visuals are Raster elements, and only the terminal draws those. In the Claude Desktop app the pane shows a plain text list of the current tab instead.
claude plugin validate .
claude plugin test .
The code lives in hooks/:
register.tsx registers the command, the pane, and the tool hook that refreshes on editsdocs.ts finds and loads the filesparse.ts turns Markdown into sections and entriescells.ts packs bars, timelines, and the calendar into Raster cellsview.tsx draws each tabApache 2.0. See LICENSE.
Made with 💖 by Pink Pixel
hooks/register.tsx 107 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Docs, Tab } from './docs'
4import { BG_HEX } from './cells'
5import { FILE_NAMES, loadDocs } from './docs'
6import { shortDate } from './parse'
7import { body } from './view'
8
9const PANE = 'docket'
10const TABS: { tab: Tab; label: string; hotkey: string }[] = [
11 { tab: 'roadmap', label: 'Roadmap', hotkey: '1' },
12 { tab: 'decisions', label: 'Decisions', hotkey: '2' },
13 { tab: 'errors', label: 'Errors', hotkey: '3' },
14 { tab: 'changelog', label: 'Changelog', hotkey: '4' },
15]
16const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
17
18let tab: Tab = 'roadmap'
19let expanded: Partial<Record<Tab, number>> = {}
20let docs: Docs | undefined
21
22// A plain-text version of the open tab, for apps that can't draw a Raster.
23function summary(d: Docs): string {
24 switch (tab) {
25 case 'roadmap':
26 return (d.roadmap ?? []).map(s => `- **${s.title}**: ${s.done}/${s.done + s.open}`).join('\n') || 'No roadmap.'
27 default: {
28 const list = d[tab] ?? []
29 return list.map(e => `- ${e.date === undefined ? '' : shortDate(e.date) + ' '}${e.title}`).join('\n') || 'Nothing logged.'
30 }
31 }
32}
33
34async function reload($: EngineInterface) {
35 docs = await loadDocs(path => $.fs.read(path), await $.session.cwd())
36 $.ui.invalidate('ui.render')
37}
38
39export const register: Register = on => {
40 on('session.start', async ($, e, next) => {
41 await $.command.register({ name: 'docket', description: 'Open the project context pane' })
42 return next(e)
43 })
44
45 on('command.run', { command: 'docket' }, async $ => {
46 await reload($)
47 await $.ui.open({ id: PANE, title: `docket · ${docs!.project}`, focus: true, closeOnEscape: true })
48 return {}
49 })
50
51 // Keep the pane current when Claude edits one of the files it shows.
52 on('tool.call', async ($, e, next) => {
53 const res = await next(e)
54 if (!docs || !EDIT_TOOLS.has(String(e.tool))) return res
55 const path = 'file_path' in e ? String(e.file_path) : ''
56 if (FILE_NAMES.has(path.split(/[\\/]/).at(-1) ?? '')) await reload($).catch(() => undefined)
57 return res
58 })
59
60 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
61 if (!docs) docs = await loadDocs(path => $.fs.read(path), await $.session.cwd())
62 const redraw = () => $.ui.invalidate('ui.render')
63 const pick = (next: Tab) => {
64 tab = next
65 redraw()
66 }
67
68 if (e.surface !== 'terminal') {
69 const { Markdown } = $.ui.resolve(e)
70 return <Markdown text={`**${TABS.find(t => t.tab === tab)!.label}**\n\n${summary(docs)}`} />
71 }
72
73 const el = $.ui.resolve(e)
74 const { Box, Button, Text } = el
75 // The pane body's own width, inside the frame and the padding below.
76 const cols = Math.max(20, e.props.bodyColumns - 2)
77 const view = {
78 tab,
79 expanded,
80 onExpand: (t: Tab, i: number) => {
81 expanded = { ...expanded, [t]: expanded[t] === i ? undefined : i }
82 redraw()
83 },
84 }
85
86 return (
87 <Box flexDirection="column" gap={1} paddingX={1} backgroundColor={BG_HEX} minHeight={e.props.scroll.bodyRows}>
88 <Box key="tabs" flexDirection="row" columnGap={3}>
89 {TABS.map(t => (
90 <Button
91 key={`tab-${t.tab}`}
92 label={t.label}
93 hotkey={t.hotkey}
94 plain
95 dimColor={t.tab !== tab}
96 onPress={() => pick(t.tab)}
97 />
98 ))}
99 <Button key="refresh" label="Refresh" hotkey="r" plain dimColor onPress={() => reload($)} />
100 </Box>
101 {body(el, docs, view, cols, Date.now())}
102 {docs.paths[tab] ? <Text key="src" dimColor>{docs.paths[tab]}</Text> : null}
103 </Box>
104 )
105 })
106}
107hooks/docs.ts 51 lines1// Finds and reads the project's context files from the session's directory.
2
3import type { Entry, Section } from './parse'
4import { parseEntries, parseRoadmap } from './parse'
5
6export type Tab = 'roadmap' | 'decisions' | 'errors' | 'changelog'
7
8export type Docs = {
9 project: string
10 roadmap?: Section[]
11 decisions?: Entry[]
12 errors?: Entry[]
13 changelog?: Entry[]
14 // Which file each tab came from, to show and to match edits against.
15 paths: Partial<Record<Tab, string>>
16}
17
18// Where each file usually lives, first match wins.
19export const CANDIDATES: Record<Tab, string[]> = {
20 roadmap: ['DOCS/ROADMAP.md', 'ROADMAP.md'],
21 decisions: ['DOCS/MEMORY.md', 'MEMORY.md'],
22 errors: ['DOCS/ERRORS.md', 'ERRORS.md'],
23 changelog: ['CHANGELOG.md', 'DOCS/CHANGELOG.md'],
24}
25
26export const FILE_NAMES = new Set(Object.values(CANDIDATES).flat().map(p => p.split('/').at(-1)!))
27
28type Read = (path: string) => Promise<string>
29
30async function first(read: Read, paths: string[]): Promise<{ path: string; text: string } | undefined> {
31 for (const path of paths) {
32 const text = await read(path).catch(() => undefined)
33 if (text !== undefined) return { path, text }
34 }
35 return undefined
36}
37
38export async function loadDocs(read: Read, cwd: string): Promise<Docs> {
39 const [roadmap, decisions, errors, changelog] = await Promise.all(
40 (['roadmap', 'decisions', 'errors', 'changelog'] as const).map(tab => first(read, CANDIDATES[tab])),
41 )
42 return {
43 project: cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? cwd,
44 roadmap: roadmap && parseRoadmap(roadmap.text),
45 decisions: decisions && parseEntries(decisions.text),
46 errors: errors && parseEntries(errors.text),
47 changelog: changelog && parseEntries(changelog.text),
48 paths: { roadmap: roadmap?.path, decisions: decisions?.path, errors: errors?.path, changelog: changelog?.path },
49 }
50}
51hooks/cells.ts 88 lines1// Builds the packed cell strings the pane's Raster elements draw.
2
3// The runtime has the ES2026 Uint8Array base64 methods; es2023's lib doesn't declare them.
4declare global {
5 interface Uint8Array {
6 toBase64(): string
7 }
8}
9
10export const DEFAULT = 0x01000000
11// The pane's own background, so Raster cells match the Box behind them.
12export const BG = 0x18181b
13export const BG_HEX = '#18181b'
14export const PINK = 0xf472b6
15export const AMBER = 0xf5b041
16export const TRACK = 0x3a3a42
17export const AXIS = 0x4a4a52
18// Heat steps from an empty day to a busy one.
19export const HEAT = [0x2c2c33, 0x6e2f52, 0xa83f78, 0xe0609f, 0xf9b4d6]
20
21const DAY = 86_400_000
22
23export type Cell = [char: string, color: number, background?: number]
24
25export type Grid = { columns: number; rows: number; cells: string }
26
27/** Packs rows of cells into a Raster's size and base64 `cells`. */
28export function pack(rows: Cell[][]): Grid {
29 const columns = rows[0]?.length ?? 0
30 const words = rows.flat().flatMap(([char, color, bg = BG]) => [char.codePointAt(0)!, color, bg])
31 return { columns, rows: rows.length, cells: new Uint8Array(Uint32Array.from(words).buffer).toBase64() }
32}
33
34/** A one-row bar, `width` cells, filled in `color` for done out of total. */
35export function bar(done: number, total: number, width: number, color = PINK): Grid {
36 const filled = total > 0 ? Math.round((done / total) * width) : 0
37 return pack([Array.from({ length: width }, (_, i): Cell => ['━', i < filled ? color : TRACK])])
38}
39
40/**
41 * A one-row time axis from the first date to the last, with a marker where
42 * each date falls. Two or more on one cell draw as a bigger marker.
43 */
44export function timeline(dates: number[], width: number, color: number): Grid {
45 const row: Cell[] = Array.from({ length: width }, (): Cell => ['─', AXIS])
46 if (dates.length === 0) return pack([row])
47 const first = Math.min(...dates)
48 const span = Math.max(...dates) - first
49 const hits = new Array<number>(width).fill(0)
50 for (const d of dates) hits[span === 0 ? width - 1 : Math.round(((d - first) / span) * (width - 1))]! += 1
51 hits.forEach((n, i) => {
52 if (n > 0) row[i] = [n > 1 ? '◆' : '●', color]
53 })
54 return pack([row])
55}
56
57/** Sunday 00:00 UTC of the week holding `ms`. */
58function weekStart(ms: number): number {
59 const day = Math.floor(ms / DAY) * DAY
60 return day - new Date(day).getUTCDay() * DAY
61}
62
63/**
64 * A GitHub-style calendar: seven rows, Sunday first, one column per week
65 * ending with the week of `today`, each day shaded by how many dates land on it.
66 * Each day is a block and a gap, so the grid is `weeks * 2` cells wide.
67 */
68export function heatmap(dates: number[], weeks: number, today: number): Grid {
69 const start = weekStart(today) - (weeks - 1) * 7 * DAY
70 const counts = new Map<number, number>()
71 for (const d of dates) {
72 const day = Math.floor(d / DAY) * DAY
73 counts.set(day, (counts.get(day) ?? 0) + 1)
74 }
75 const last = Math.floor(today / DAY) * DAY
76 const rows: Cell[][] = []
77 for (let weekday = 0; weekday < 7; weekday++) {
78 const row: Cell[] = []
79 for (let w = 0; w < weeks; w++) {
80 const day = start + (w * 7 + weekday) * DAY
81 const n = counts.get(day) ?? 0
82 row.push(day > last ? [' ', DEFAULT] : ['■', HEAT[Math.min(n, HEAT.length - 1)]!], [' ', DEFAULT])
83 }
84 rows.push(row)
85 }
86 return pack(rows)
87}
88hooks/parse.ts 86 lines1// Turns the Markdown in a project's DOCS into the small shapes the pane draws.
2
3export type Section = { title: string; done: number; open: number }
4
5export type Entry = { title: string; date?: number; body: string }
6
7const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
8const ISO = /(\d{4})-(\d{2})-(\d{2})/
9const LONG = /\b([A-Z][a-z]+)\.? (\d{1,2}),? (\d{4})\b/
10const DONE_HEADING = /\b(done|complete[d]?|shipped|released|finished)\b|[✅✔✓]/i
11const CHECKED = /^\s*[-*+] \[[xX]\]/
12const UNCHECKED = /^\s*[-*+] \[ \]/
13// Top-level items only, so sub-bullets don't inflate a plain list.
14const TOP_ITEM = /^(?:[-*+]|\d+\.) \S/
15
16/** The UTC midnight of the first date in `text`, as `2026-09-21` or `September 22, 2026`. */
17export function parseDate(text: string): number | undefined {
18 const iso = text.match(ISO)
19 if (iso) return Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
20 const long = text.match(LONG)
21 if (!long) return undefined
22 const month = MONTHS.indexOf(long[1]!.slice(0, 3).toLowerCase())
23 return month < 0 ? undefined : Date.UTC(Number(long[3]), month, Number(long[2]))
24}
25
26/** Splits Markdown at its `##` headings, skipping anything above the first one. */
27function chunks(md: string): { heading: string; lines: string[] }[] {
28 const out: { heading: string; lines: string[] }[] = []
29 for (const line of md.split('\n')) {
30 const h = line.match(/^## (.+)/)
31 if (h) out.push({ heading: h[1]!.trim(), lines: [] })
32 else out.at(-1)?.lines.push(line)
33 }
34 return out
35}
36
37/**
38 * Counts the roadmap items in each `##` section. Checkboxes count as done or
39 * open; a section of plain bullets counts as done when its heading says so.
40 */
41export function parseRoadmap(md: string): Section[] {
42 return chunks(md)
43 .map(({ heading, lines }) => {
44 const done = lines.filter(l => CHECKED.test(l)).length
45 const open = lines.filter(l => UNCHECKED.test(l)).length
46 if (done + open > 0) return { title: heading, done, open }
47 const items = lines.filter(l => TOP_ITEM.test(l)).length
48 return DONE_HEADING.test(heading) ? { title: heading, done: items, open: 0 } : { title: heading, done: 0, open: items }
49 })
50 .filter(s => s.done + s.open > 0)
51}
52
53/**
54 * Drops the date and the separator around it, so `2026-09-21: Pick a db`
55 * reads `Pick a db`. A heading that is only a date takes its first `###`
56 * instead, with a count of the rest.
57 */
58function titleOf(heading: string, lines: string[]): string {
59 const bare = heading
60 .replace(ISO, '')
61 .replace(LONG, '')
62 .replace(/^[\s:\-–|·]+|[\s:\-–|·]+$/g, '')
63 if (bare) return bare
64 const subs = lines.flatMap(l => l.match(/^### (.+)/)?.[1]?.trim() ?? [])
65 if (subs.length === 0) return heading
66 return subs.length > 1 ? `${subs[0]} +${subs.length - 1}` : subs[0]!
67}
68
69/** One entry per `##` heading, newest first when dated, its body without trailing rules. */
70export function parseEntries(md: string): Entry[] {
71 const entries = chunks(md).map(({ heading, lines }) => ({
72 title: titleOf(heading, lines),
73 date: parseDate(heading),
74 body: lines.join('\n').replace(/(\s*\n---\s*)+$/, '').trim(),
75 }))
76 // Stable sort keeps file order among undated entries, which sink to the end.
77 return entries
78 .map((entry, i) => ({ entry, i }))
79 .sort((a, b) => (b.entry.date ?? -Infinity) - (a.entry.date ?? -Infinity) || a.i - b.i)
80 .map(({ entry }) => entry)
81}
82
83export function shortDate(ms: number): string {
84 return new Date(ms).toISOString().slice(0, 10)
85}
86hooks/view.tsx 144 lines1// The pane's tab bodies: a pixel visual on top, the file's content under it.
2import type { Elements } from 'claude-code'
3
4import { AMBER, PINK, bar, heatmap, timeline } from './cells'
5import type { Docs, Tab } from './docs'
6import type { Entry, Section } from './parse'
7import { shortDate } from './parse'
8
9type El = Elements['terminal']
10
11export const TEXT = { dim: '#8a8a94', pink: '#f472b6', amber: '#f5b041' }
12
13export type View = {
14 tab: Tab
15 // The open entry in each list tab, by its index.
16 expanded: Partial<Record<Tab, number>>
17 onExpand: (tab: Tab, index: number) => void
18}
19
20const TITLE_MAX = 30
21const COUNT_W = 9
22const MAX_WEEKS = 52
23
24function clip(text: string, max: number): string {
25 return text.length > max ? text.slice(0, Math.max(1, max - 1)) + '…' : text
26}
27
28function missing({ Text }: El, tab: Tab, cols: number) {
29 const where = { roadmap: 'DOCS/ROADMAP.md', decisions: 'DOCS/MEMORY.md', errors: 'DOCS/ERRORS.md', changelog: 'CHANGELOG.md' }[tab]
30 return <Text dimColor wrap="truncate">{clip(`No ${where} in this project.`, cols)}</Text>
31}
32
33function roadmap(el: El, sections: Section[], cols: number) {
34 const { Box, Text, Raster } = el
35 const done = sections.reduce((n, s) => n + s.done, 0)
36 const total = sections.reduce((n, s) => n + s.done + s.open, 0)
37 const pct = total > 0 ? Math.round((done / total) * 100) : 0
38 const titleW = Math.min(TITLE_MAX, Math.max(10, Math.floor(cols * 0.35)))
39 const barW = Math.max(6, cols - titleW - COUNT_W - 2)
40
41 return (
42 <Box key="roadmap" flexDirection="column" gap={1}>
43 <Box key="overall" flexDirection="column">
44 <Box key="head" flexDirection="row" gap={1}>
45 <Text key="pct" bold color={TEXT.pink}>{`${pct}%`}</Text>
46 <Text key="of" dimColor>{`${done} of ${total} items done`}</Text>
47 </Box>
48 <Raster key="overall-bar" {...bar(done, total, Math.max(6, cols - 1))} />
49 </Box>
50 <Box key="sections" flexDirection="column">
51 {sections.map((s, i) => (
52 <Box key={`s${i}`} flexDirection="row" gap={1}>
53 <Box key="t" width={titleW}>
54 <Text wrap="truncate" dimColor={s.open === 0}>{clip(s.title, titleW)}</Text>
55 </Box>
56 <Raster key={`bar${i}`} {...bar(s.done, s.done + s.open, barW)} />
57 <Text key="n" dimColor>{`${s.done}/${s.done + s.open}`}</Text>
58 </Box>
59 ))}
60 </Box>
61 </Box>
62 )
63}
64
65function entryList(el: El, tab: Tab, entries: Entry[], view: View, cols: number) {
66 const { Box, Text, Button, Markdown } = el
67 const open = view.expanded[tab]
68 return (
69 <Box key="entries" flexDirection="column">
70 {entries.map((entry, i) => (
71 <Box key={`e${i}`} flexDirection="column">
72 <Box key="row" flexDirection="row" gap={1}>
73 <Text key="d" dimColor>{entry.date === undefined ? ' ' : shortDate(entry.date)}</Text>
74 <Button
75 key={`${tab}-${i}`}
76 plain
77 label={clip(`${open === i ? '▾' : '▸'} ${entry.title}`, Math.max(8, cols - 12))}
78 onPress={() => view.onExpand(tab, i)}
79 />
80 </Box>
81 {open === i && entry.body ? (
82 <Box key="body" paddingLeft={2} paddingY={1}>
83 <Markdown text={entry.body} />
84 </Box>
85 ) : null}
86 </Box>
87 ))}
88 </Box>
89 )
90}
91
92function timelineTab(el: El, tab: Tab, entries: Entry[], view: View, cols: number, color: number, noun: string) {
93 const { Box, Text, Raster } = el
94 const dates = entries.flatMap(e => (e.date === undefined ? [] : [e.date]))
95 const width = Math.max(6, cols - 1)
96 return (
97 <Box key={tab} flexDirection="column" gap={1}>
98 <Box key="viz" flexDirection="column">
99 <Text key="count" bold color={color === AMBER ? TEXT.amber : TEXT.pink}>{`${entries.length} ${noun}`}</Text>
100 <Raster key={`${tab}-line`} {...timeline(dates, width, color)} />
101 {dates.length > 0 ? (
102 <Box key="ends" flexDirection="row" justifyContent="space-between" width={width}>
103 <Text key="a" dimColor>{shortDate(Math.min(...dates))}</Text>
104 <Text key="b" dimColor>{shortDate(Math.max(...dates))}</Text>
105 </Box>
106 ) : null}
107 </Box>
108 {entryList(el, tab, entries, view, cols)}
109 </Box>
110 )
111}
112
113function changelogTab(el: El, entries: Entry[], view: View, cols: number, now: number) {
114 const { Box, Text, Raster } = el
115 const dates = entries.flatMap(e => (e.date === undefined ? [] : [e.date]))
116 const weeks = Math.max(4, Math.min(MAX_WEEKS, Math.floor((cols - 1) / 2)))
117 const latest = entries[0]
118 return (
119 <Box key="changelog" flexDirection="column" gap={1}>
120 <Box key="viz" flexDirection="column">
121 <Box key="head" flexDirection="row" gap={1}>
122 <Text key="v" bold color={TEXT.pink}>{latest ? latest.title : 'No releases'}</Text>
123 <Text key="n" dimColor>{`${entries.length} releases · last ${weeks} weeks`}</Text>
124 </Box>
125 <Raster key="changelog-heat" {...heatmap(dates, weeks, now)} />
126 </Box>
127 {entryList(el, 'changelog', entries, view, cols)}
128 </Box>
129 )
130}
131
132export function body(el: El, docs: Docs, view: View, cols: number, now: number) {
133 switch (view.tab) {
134 case 'roadmap':
135 return docs.roadmap?.length ? roadmap(el, docs.roadmap, cols) : missing(el, 'roadmap', cols)
136 case 'decisions':
137 return docs.decisions ? timelineTab(el, 'decisions', docs.decisions, view, cols, PINK, 'decisions') : missing(el, 'decisions', cols)
138 case 'errors':
139 return docs.errors ? timelineTab(el, 'errors', docs.errors, view, cols, AMBER, 'logged errors') : missing(el, 'errors', cols)
140 case 'changelog':
141 return docs.changelog ? changelogTab(el, docs.changelog, view, cols, now) : missing(el, 'changelog', cols)
142 }
143}
144