Keeps Claude's open questions, confirmations and hand-offs in numbered lists in a pane beside the chat, answerable by label or by button

Claude produces more than a person can keep up with. Questions get buried in long replies, things it's waiting on you for scroll away, and you miss stuff. Docket keeps every open thread between you and Claude in numbered lists in a pane beside the chat, so you can answer by label (q10 yes, c3-c5 confirmed, accept all other leans) or with one click.
At the prompt of a Claude Code session, add docket's marketplace, then install it from there:
/plugin marketplace add StoneCypher/docket
/plugin install docket@docket
Pick a scope when asked. From a shell instead, claude plugin marketplace add StoneCypher/docket then claude plugin install docket@docket.
docket is also listed in StoneCypher/claude-plugins, alongside StoneCypher's other plugins: /plugin marketplace add StoneCypher/claude-plugins, then /plugin install docket@stonecypher.
In the pane, the most important list comes first:
| Letter | List | What's in it |
|---|---|---|
| R | Requests | Things Claude wants to do on its own initiative. It waits for your ✔ or ✗. |
| Q | Questions | Decisions only you can make. Each has a lean: the answer Claude would pick. |
| C | Confirm | Claude checking that it read you right, or making sure you know something risky is coming. |
| N | Needs you | Things only you can do: consoles, accounts, hardware. |
| V | Verify | Things for you to check: that a change works, reads right or looks right. |
| U | Underway | Claude's work in progress. |
| L | Later | Claude's work that's decided but not started, in order. |
| W | Waiting | Things stalled on something other than you. |
| P | Pinned | Parked for later, drawn on one line. |
Numbers only ever go up, so Q12 always means the same question. Unchanged items are dimmed and new ones are marked.
/docket reopens the pane if you close it.
Settings live in ~/.claude/docket/config.json, a versioned JSON file with every setting written out. A project can have its own .claude/docket/config.json, whose settings win. The first time docket runs without one, the pane offers to create yours with the defaults; when docket adds settings, it updates the file in place and tells you in Verify. Edit the file directly, or use ⚙ at the bottom of the pane:
language: auto (the default) follows Claude Code's language setting, then VS Code's display language, then the terminal's locale, then the language Claude hears you write in; or a language code with a file in locales/, such as es, ja or deturnReview: whether the Deep dive switch, at the pane's bottom left, starts each session on (it does by default); the switch decides from then on. With it on, before each turn ends, Claude goes back over it once for decisions it made on your behalf and threads it left open, and adds them to the docket. It costs one short extra pass per turn.updateCheck: on by default. Once a day docket reads its plugin.json on GitHub, its only outside request, and says in Verify when a newer version is out. Off stops the request too.display: pane beside the chat (the default), or band above the promptitemColor, itemDimColor, leanColor, leanDimColor: hex colors or theme keysemoji, headingEmoji, noItemEmojiLists: emoji on or off, overall or per liststakesDisplay: emoji, text or noneinlineLists, targetWords: which lists draw on one line, and soft word limits per listansweredLingerSeconds, strikeLingerMinutes: how long answered and auto-closed items stay updocket speaks English, Spanish, Japanese and German: the headings, buttons, settings, and the messages its buttons send. Each language names its lists so each starts with its own letter, and items are labeled with those letters, so a Spanish question is P12 under Preguntas, and a Japanese one is 質12 under 質問. Claude is told to write items in the same language. The config file keeps docket's internal English letters, whatever the language.
Every language but English is one JSON file in locales/, mapping each English string to its translation, so translating docket needs no code. Each translation, and the file as a whole, says how ready it is: false (not translated, so English shows), "ai translated", or true (checked by a person):
{
"ready": "ai translated",
"version": 1,
"Questions": { "text": "Preguntas", "ready": true },
"Accept the lean on {id}.": { "text": "Acepto la propuesta de {id}.", "ready": "ai translated" }
}
docket uses any file not marked false. A list's letter is the first character of its translated name, so name the lists to start with different letters, and keep anything in {braces} as it is. A regional file, such as es-MX.json, holds only what differs from es.json.
Every language docket recognizes already has a file, so helping means editing one: open it on GitHub, press the pencil, fill in or fix each text, set its ready, and commit. GitHub copies docket to your account and offers to open a pull request. When Claude Code is set to a language whose file isn't ready, machine-translated, or behind English, docket asks once, in Requests, whether you'd like to help, and its ✔ opens that file in GitHub's editor.
A language can also end in a dialect, as BCP 47 tags end in a variant (en-US-something): a file in locales/dialects/ with its own "name" and "tag", laying its words over English and listed below the real Englishes in the picker. docket ships none; the novelty lives in voices.
Voices are their own setting, in any language: a character Claude writes the docket's items as. The casts live in locales/voices/ (Star Trek, Samurai Jack, Yinzer, Hackerman), each a "voice" for every item and "voices" per list by English name, and each may bring its own ✔ and ✗, as text or a picture: Star Trek has Uhura on Verify, Chekov on Underway and Sulu on Questions, with 👉 and 🤦; Samurai Jack has Aku gloating over Waiting, with a katana and Aku. Pick one in ⚙, or write a character into the config's "voice", such as "drunk Optimus Prime". When the language or voice changes, Claude rewrites the open items to match.
When English changes, STRINGS_VERSION in hooks/i18n.ts goes up and npm run locales brings every file in line: new strings arrive blank, and strings English no longer says are dropped. npm test checks that each file is in line, keeps every placeholder, is no readier than its strings, and gives each list its own letter.
npm test runs the unit tests for the pure logic (hooks/lists.ts, hooks/config.ts, hooks/i18n.ts, hooks/languages.ts) and the language files under Node's own test runner, with coverage: it fails below 80% of lines, branches or functions. Needs Node 23.6 or later, which runs TypeScript directly.npm run test:mod runs the hooks and pane tests (tests/*.test.tsx) inside Claude Code's engine, on every surface.npm run validate checks the manifest, the marketplace file and the hooks module the way Claude Code loads them.Docket is a Claude Code mod. It adds its rules to Claude's system prompt, gives Claude one tool for adding, changing and closing items, sends Claude the current docket with each of your prompts (with any labels you mention expanded), and draws the lists in a pane. If Claude's reply talks about pending work without updating the docket, your next prompt reminds it to reconcile.
hooks/register.tsx 1690 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Counts, Item, Letter, Stakes } from '../types'
5import {
6 LETTERS,
7 LISTS,
8 arrange,
9 counterParts,
10 instructions,
11 isOpen,
12 openCounts,
13 RECONCILE,
14 REVIEW,
15 nowNote,
16 references,
17 rewriteNote,
18 settingsOf,
19 snapshot,
20 speaksOfPending,
21} from './lists'
22import type { Settings } from './lists'
23import { CONFIG_VERSION, FIELDS, defaults, merged, migrate, parse, serialize } from './config'
24import type { ConfigFile } from './config'
25import { AI, EN, STRINGS_VERSION, fill, idFor, labelOf, labelOfId, letterFor, localeOf, markImage, resolveLanguage, stateOf, translate, voiceOf } from './i18n'
26import type { FileState, Voice } from './i18n'
27import { codeOf, englishName, nativeName } from './languages'
28import { appDirOf, exeDirsOf, exeIn, launchOf, scriptDirsOf, scriptIn } from './editor'
29import { BUILT } from './release'
30import { DIVIDER, dayOf, defaultVariant, groupsOf, isChoosing, labelFor, pickedOf, variantsOf } from './picker'
31import type { Dialect } from './picker'
32import type { Language, Strings } from './i18n'
33
34const TOOL = 'mcp__docket__update'
35const PANE = 'docket'
36const CLOSED_KEPT = 200
37// How long an item Claude just added or changed stays out of a bulk button's answer, so one that lands
38// under the person's finger isn't swept up with the rest.
39const BULK_SETTLE_MS = 5_000
40
41type Dollar = EngineInterface
42type Kit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text' | 'Link'>
43// Every surface but the terminal draws SVG.
44type Svg = Elements['vscode']['Svg']
45// Every surface but the terminal hands docket a press on a Markdown link without opening it.
46type MarkdownElement = Elements['vscode']['Markdown']
47const markdownOf = (kit: object, surface: string): MarkdownElement | undefined =>
48 surface === 'terminal' ? undefined : (kit as { Markdown?: MarkdownElement }).Markdown
49const svgOf = (kit: object, surface: string): Svg | undefined => (surface === 'terminal' ? undefined : (kit as { Svg?: Svg }).Svg)
50type FieldKit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text' | 'Input' | 'Select'>
51
52const items = atom({ plugin: 'docket', key: 'items' } as const, [] as Item[])
53const counters = atom({ plugin: 'docket', key: 'counters' } as const, {} as Counts)
54const turn = atom({ plugin: 'docket', key: 'turn' } as const, 0)
55const baseline = atom({ plugin: 'docket', key: 'baseline' } as const, {} as Counts)
56const dismissed = atom({ plugin: 'docket', key: 'dismissed' } as const, [] as string[])
57const isSettings = atom({ plugin: 'docket', key: 'isSettings' } as const, false)
58const expanded = atom({ plugin: 'docket', key: 'expanded' } as const, [] as string[])
59const updatedTurn = atom({ plugin: 'docket', key: 'updatedTurn' } as const, 0)
60const isUnreconciled = atom({ plugin: 'docket', key: 'isUnreconciled' } as const, false)
61const languageGroup = atom({ plugin: 'docket', key: 'languageGroup' } as const, '')
62const spokenAs = atom({ plugin: 'docket', key: 'spokenAs' } as const, '')
63const deepDive = atom({ plugin: 'docket', key: 'deepDive' } as const, null as boolean | null)
64
65// The language and voice items are written in, as one comparable key.
66const speechOf = (text: Strings, voice: Voice) => JSON.stringify({ language: text.language, voice })
67
68type Fields = { text?: string; emoji?: string; lean?: string; stakes?: Stakes; rank?: number; options?: string[]; open?: string }
69type UpdateInput = {
70 add?: (Fields & { list: Letter; text: string })[]
71 change?: (Fields & { id: string })[]
72 close?: { id: string; unasked?: boolean }[]
73 // The language the user writes in, as Claude hears it: the last hint "auto" has before English.
74 language?: string
75}
76
77const STAKES = ['emergency', 'high', 'med']
78
79// A button label: one letter, or one emoji (a pictograph with its modifiers, at most 8 code units).
80const isOptionLabel = (label: unknown): label is string =>
81 typeof label === 'string' && (/^\p{L}$/u.test(label) || (/^\p{Extended_Pictographic}/u.test(label) && label.length <= 8))
82
83const fieldsOf = (spec: Fields): Fields => {
84 const out: Fields = {}
85 if (typeof spec.text === 'string') out.text = spec.text
86 if (typeof spec.emoji === 'string' && spec.emoji) out.emoji = spec.emoji
87 if (typeof spec.lean === 'string' && spec.lean) out.lean = spec.lean
88 if (spec.stakes && STAKES.includes(spec.stakes)) out.stakes = spec.stakes
89 if (Array.isArray(spec.options)) {
90 const options = spec.options.filter(isOptionLabel).slice(0, 3)
91 if (options.length) out.options = options
92 }
93 if (typeof spec.rank === 'number') out.rank = spec.rank
94 if (typeof spec.open === 'string' && spec.open.trim()) out.open = spec.open.trim()
95
96 return out
97}
98
99// The tool's fields, in the user's language's letters and messages.
100const itemFields = (text: Strings) => ({
101 text: { type: 'string', description: 'The item, one to three lines' },
102 emoji: { type: 'string', description: 'One emoji for the item' },
103 lean: { type: 'string', description: 'Questions only: the answer you would pick' },
104 stakes: { type: 'string', enum: STAKES, description: 'Omit for low stakes' },
105 rank: {
106 type: 'number',
107 description: `${(['V', 'N', 'C', 'Q', 'R'] as const).map(letter => text.lists[letter].letter).join(', ')} only: 1 is the most urgent`,
108 },
109 open: { type: 'string', description: 'A local file path the item is about. The item gets an Open button that opens it in the editor.' },
110 options: {
111 type: 'array',
112 items: { type: 'string' },
113 maxItems: 3,
114 description: `Questions only: one to three button labels, each a single letter or a single emoji, such as ["a", "b", "c"] or ["🌐", "📄"]. Each label must appear in the text beside what it picks. Their buttons replace the lean’s ✔ and ✗ and send "${fill(text.send.pick, { id: `${text.lists.Q.letter}4`, option: 'b' })}"`,
115 },
116})
117
118const schemaOf = (text: Strings) => ({
119 type: 'object',
120 properties: {
121 add: {
122 type: 'array',
123 items: {
124 type: 'object',
125 properties: { list: { type: 'string', enum: LETTERS.map(letter => text.lists[letter].letter) }, ...itemFields(text) },
126 required: ['list', 'text'],
127 },
128 },
129 change: {
130 type: 'array',
131 items: {
132 type: 'object',
133 properties: { id: { type: 'string', description: `A label such as ${text.lists.Q.letter}12` }, ...itemFields(text) },
134 required: ['id'],
135 },
136 },
137 close: {
138 type: 'array',
139 items: {
140 type: 'object',
141 properties: {
142 id: { type: 'string' },
143 unasked: {
144 type: 'boolean',
145 description: 'True when you close it on your own judgment, not on the user\'s word',
146 },
147 },
148 required: ['id'],
149 },
150 },
151 language: {
152 type: 'string',
153 description:
154 "The language the user writes to you in, as a code such as de or pt-BR. Set it only when the docket asks you to: it's on auto and nothing else told it the user's language.",
155 },
156 },
157})
158
159const withoutAnswer = ({ answered: _, answeredAt: __, ...item }: Item): Item => item
160
161const STAKES_GLYPH = { emergency: '[🚨emergency]', high: '❗', med: '🔶' }
162const STAKES_TEXT = { emergency: '[emergency]', high: '[high stakes]', med: '[med stakes]' }
163const STAKES_COLOR = { emergency: 'red', high: '#ff8800', med: 'yellow' }
164
165// Whether the pane is on screen, and whether the person closed it. The band stands in only where no
166// pane could be placed: a closed pane stays closed, everywhere, until /docket opens it again.
167let isPaneShown = false
168let isPaneClosed = false
169
170// The docket's own pane, docked beside the transcript where the surface docks one.
171// docket's version, from its own manifest, as the engine doesn't say it: shown in the pane's title.
172const versionOf = async ($: Dollar) => {
173 try {
174 const { version } = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
175
176 return typeof version === 'string' ? version : ''
177 } catch {
178 return ''
179 }
180}
181
182const openPane = async ($: Dollar, version = '') => {
183 const { isPlaced } = await $.ui.open({ id: PANE, title: version ? `Docket 📋 ${version}` : 'Docket 📋' })
184 isPaneShown = isPlaced
185 isPaneClosed = false
186 $.ui.invalidate('ui.render')
187
188 return isPlaced
189}
190
191// Every list with something to show, or null when the docket is empty.
192const draw = async (
193 $: Dollar,
194 { Box, Button, Text, Link }: Kit,
195 settings: Settings,
196 columns: number,
197 isByPrompt: boolean,
198 surface: string,
199 onAction: (item: Item, kind: string) => Promise<void>,
200 text: Strings,
201 Svg?: Svg,
202 Markdown?: MarkdownElement,
203) => {
204 const label = (item: Pick<Item, 'list' | 'n'>) => labelOf(text, item)
205 // An item's clear button: a trash can, apart from the ✗ beside it, but the terminal can't size 🗑️.
206 const trash = surface === 'terminal' ? '✕' : '🗑️'
207 // The eye has the same trouble on the terminal, which gets a plain ◉ instead.
208 const eye = surface === 'terminal' ? '◉' : '👁️'
209 // Later's "okay, it's time to do this now": a crane, or ▶ on the terminal.
210 const crane = surface === 'terminal' ? '▶' : '🏗️'
211
212 // A heading's counter: a growing list's arrow in moderate green and its number in a fainter green,
213 // a shrinking list's in purple, and no change in the dim gray.
214 const counter = (hidden: number, delta: number) => {
215 const { more, arrow, number, direction } = counterParts(hidden, delta)
216 const colors =
217 direction === 'up'
218 ? { arrow: settings.upArrowColor, number: settings.upNumberColor }
219 : { arrow: settings.downArrowColor, number: settings.downNumberColor }
220
221 return (
222 <Text>
223 {' '}
224 {more ? <Text dimColor>{more}</Text> : null}
225 {direction === 'none' ? (
226 <Text dimColor>{number}</Text>
227 ) : (
228 <Text>
229 <Text color={colors.arrow}>{arrow}</Text>
230 <Text color={colors.number}>{number}</Text>
231 </Text>
232 )}
233 </Text>
234 )
235 }
236
237 // Turns links in an item's text into Link elements: `[label](https://...)` shows the label,
238 // and a bare web address shows itself, its trailing punctuation left as text.
239 const linkify = (text: string) => {
240 const parts: (string | ReturnType<typeof Link>)[] = []
241 let rest = 0
242 for (const match of text.matchAll(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\s)]+)\)|https?:\/\/[^\s<>"')\]]+/g)) {
243 const [whole, label, target] = match
244 const href = target ?? whole.replace(/[.,;:!?]+$/, '')
245 parts.push(text.slice(rest, match.index), <Link href={href}>{label ?? href}</Link>)
246 rest = match.index + (label ? whole.length : href.length)
247 }
248 parts.push(text.slice(rest))
249
250 return parts
251 }
252
253 const all = await read($, items)
254 const at = await read($, turn)
255 const base = await read($, baseline)
256 const now = await $.clock.now()
257 const counts = openCounts(all)
258
259 const order = isByPrompt ? LISTS : [...LISTS].reverse()
260 // Lists the user opened past their cap; "+N more" adds one, "fewer" takes it back out.
261 const open = await read($, expanded)
262 const toggle = (letter: string, isOpening: boolean) => () =>
263 update($, expanded, current => (isOpening ? [...current.filter(each => each !== letter), letter] : current.filter(each => each !== letter)))
264
265 const sections = order.map(list => ({
266 list,
267 ...arrange(
268 all,
269 list,
270 open.includes(list.letter) ? Infinity : (settings.caps[list.letter] ?? 7),
271 at,
272 now,
273 settings.lingerMs,
274 isByPrompt,
275 settings.answeredMs,
276 ),
277 })).filter(section => section.shown.length + section.lingering.length + section.hidden > 0)
278
279 if (!sections.length) {
280 return null
281 }
282
283 // An item's ✕: clears just that item.
284 const dismiss = (id: string) => async () => {
285 // Clearing one of the mod's own offers declines it, so it doesn't come back.
286 const own = all.find(item => item.id === id && item.action)
287 if (own) {
288 await onAction(own, 'no')
289
290 return
291 }
292 const at = await $.clock.now()
293 await update($, items, current =>
294 current.map(item => (item.id === id && isOpen(item) ? { ...item, closed: { at, isUnasked: false } } : item)),
295 )
296 await update($, dismissed, gone => [...gone, id])
297 }
298
299 // The heading's ✕: dismisses every open item in the list.
300 const dismissList = (letter: Letter) => async () => {
301 const at = await $.clock.now()
302 const ids: string[] = []
303 await update($, items, current => {
304 ids.length = 0
305
306 return current.map(item => {
307 if (item.list !== letter || !isOpen(item)) {
308 return item
309 }
310 ids.push(item.id)
311
312 return { ...item, closed: { at, isUnasked: false } }
313 })
314 })
315 await update($, dismissed, gone => [...gone, ...ids])
316 }
317
318 // Marks items answered at once, so the row dims and drops its buttons before Claude acts on the reply.
319 const markAnswered = async (ids: readonly string[], answer: string) => {
320 const answeredAt = await $.clock.now()
321 await update($, items, current =>
322 current.map(item => (ids.includes(item.id) && isOpen(item) ? { ...item, answered: answer, answeredAt } : item)),
323 )
324 // Redraw once the answered rows are due to go.
325 $.clock.after(settings.answeredMs + 500, () => $.ui.invalidate('ui.render'))
326 }
327
328 // A ✔ or ✗: answers one item, as if the user had typed it.
329 const answer = (id: string, kind: string, text: string) => async () => {
330 await markAnswered([id], kind)
331 // The mod's own items are handled here and never reach Claude.
332 const item = all.find(each => each.id === id)
333 if (item?.action) {
334 await onAction(item, kind)
335
336 return
337 }
338 await $.prompt.submit({ text, asUser: true })
339 }
340
341 // Whether a lean fits on one line beside its buttons: an estimate in cells, as no surface reports
342 // rendered widths. The lean's margin and the two buttons side by side take about 9.
343 const isOneLine = (text: string, reserve = 0) => text.length <= columns - 9 - reserve
344
345 // What a list's bulk button would answer: open items not already sent, and for questions only
346 // those with a lean to accept.
347 // Never the mod's own items, and never one changed in the last few seconds.
348 const bulkable = (list: readonly Item[], letter: Letter, at: number) =>
349 list.filter(
350 item =>
351 item.list === letter &&
352 isOpen(item) &&
353 !item.answered &&
354 !item.action &&
355 (letter !== 'Q' || item.lean) &&
356 at - (item.changedAt ?? 0) >= BULK_SETTLE_MS,
357 )
358
359 // A bulk button answers only the items on screen: never the ones hidden past the cap.
360 const accept = (send: keyof Strings['send'], letter: Letter, shown: readonly Item[]) => async () => {
361 const onScreen = new Set(shown.map(item => item.id))
362 const ids = bulkable(await read($, items), letter, await $.clock.now())
363 .filter(item => onScreen.has(item.id))
364 .map(item => item.id)
365 if (ids.length) {
366 await markAnswered(ids, 'yes')
367 await $.prompt.submit({ text: fill(text.send[send], { ids: ids.map(id => labelOfId(text, id)).join(', ') }), asUser: true })
368 }
369 }
370
371 const marker = (item: Item) => {
372 const hasEmoji = settings.isEmoji && !settings.noItemEmoji.has(item.list) && item.emoji
373
374 return hasEmoji ? ` ${item.emoji}` : ''
375 }
376
377 const stakesTag = (item: Item) => {
378 if (!item.stakes || settings.stakesDisplay === 'none') {
379 return null
380 }
381 if (settings.isEmoji && settings.stakesDisplay === 'emoji') {
382 return <Text> {STAKES_GLYPH[item.stakes]}</Text>
383 }
384
385 return <Text color={STAKES_COLOR[item.stakes]}> {STAKES_TEXT[item.stakes]}</Text>
386 }
387
388 // Struck-through items take the surface's dim; open ones take explicit colors, which dim would override.
389 const itemColor = (isLingering: boolean, isDim: boolean) =>
390 isLingering ? { dimColor: true } : { color: isDim ? settings.itemDimColor : settings.itemColor }
391
392 // The pane's text can't be selected, so an item's label copies it: label, text and any lean, the way
393 // the person would quote it. A toast says it took.
394 const copyItem = (item: Item) => async () => {
395 const quoted = [`${label(item)}. ${item.text}`, item.lean ? `${text.lean}: ${item.lean}` : ''].filter(Boolean).join('\n')
396 const { isCopied } = await $.ui.copy({ text: quoted, surface: surface as RenderSurface })
397 if (isCopied) await $.ui.toast(`📋 ${label(item)}`)
398 }
399
400 // A bare web address as a Markdown link, its trailing punctuation left as text, as linkify does; one
401 // already written [label](https://...) is kept.
402 const linked = (words: string) =>
403 words.replace(/\[[^\]]+\]\((?:https?|file):\/\/[^\s)]+\)|https?:\/\/[^\s<>"')\]]+/g, whole => {
404 if (whole.startsWith('[')) return whole
405 const href = whole.replace(/[.,;:!?]+$/, '')
406
407 return `[${href}](${href})${whole.slice(href.length)}`
408 })
409
410 // Where a copy link points, should a surface open it instead of handing docket the press: the site.
411 const copyHref = (item: Item) => `${SITE}/#copy-${item.id}`
412
413 const stakesWords = (item: Item) =>
414 !item.stakes || settings.stakesDisplay === 'none'
415 ? ''
416 : ` ${settings.isEmoji && settings.stakesDisplay === 'emoji' ? STAKES_GLYPH[item.stakes] : STAKES_TEXT[item.stakes]}`
417
418 // An item's own line: label, emoji, stakes, text. Where a surface hands docket a link's press, the
419 // line is Markdown and its label a link that copies the item, flowing with the text; the terminal,
420 // which may open a link before docket hears of it, keeps a plain label, its text selectable anyway.
421 const line = (item: Item, isLingering: boolean, isDim: boolean) => {
422 // Struck through while lingering, and once sent: the answer is out, the item is on its way.
423 const isStruck = isLingering || item.answered !== undefined
424 const isNew = item.addedTurn === at && !isLingering
425 if (Markdown && !isLingering) {
426 const words = `**[${label(item)}.](${copyHref(item)} "${text.copyItem.replace(/"/g, "'")}")**${marker(item)}${stakesWords(item)} ${linked(item.text)}${isNew ? ` ${text.new}` : ''}`
427
428 return (
429 <Markdown
430 key={`line-${item.id}`}
431 text={isStruck ? `~~${words}~~` : words}
432 dimColor={isDim}
433 pressableLinks={[copyHref(item)]}
434 onLinkPress={() => void copyItem(item)()}
435 />
436 )
437 }
438
439 return (
440 <Text {...itemColor(isLingering, isDim)} strikethrough={isStruck}>
441 <Text bold>{label(item)}.</Text>
442 {marker(item)}
443 {stakesTag(item)} {linkify(item.text)}
444 {isNew ? ` ${text.new}` : ''}
445 </Text>
446 )
447 }
448
449 // A ✔ and ✗ pair, side by side when the text beside them fits on one line, stacked when it wraps.
450 // `reserve` is the cells other controls take on the same line.
451 // Once answered, a plain mark of the answer sent stands in for the pair.
452 const yesNo = (item: Item, yes: string, no: string, beside: string, reserve = 0) =>
453 item.answered ? (
454 <Box flexShrink={0} marginLeft={1}>
455 {/* A button holds only text, so a dialect's picture shows here, as the mark of what was sent. */}
456 {Svg && item.answered === 'yes' && text.marks.yesImage ? (
457 <Svg source={text.marks.yesImage} alt={text.marks.yes} width={16} height={16} />
458 ) : Svg && item.answered === 'no' && text.marks.noImage ? (
459 <Svg source={text.marks.noImage} alt={text.marks.no} width={16} height={16} />
460 ) : (
461 <Text size="caption" dimColor>
462 {item.answered === 'yes' ? text.marks.yes : item.answered === 'no' ? text.marks.no : item.answered}
463 </Text>
464 )}
465 </Box>
466 ) : (
467 <Box flexShrink={0} marginLeft={1} flexDirection={isOneLine(beside, reserve) ? 'row' : 'column'} columnGap={1}>
468 <Button key={`ok-${item.id}`} plain dimColor onPress={answer(item.id, 'yes', yes)}>
469 <Text size="caption" color="green">{text.marks.yes}</Text>
470 </Button>
471 <Button key={`no-${item.id}`} plain dimColor onPress={answer(item.id, 'no', no)}>
472 <Text size="caption">{text.marks.no}</Text>
473 </Button>
474 </Box>
475 )
476
477 // A question's lettered options, one button each, in place of ✔ and ✗.
478 const choices = (item: Item, beside: string) =>
479 item.answered ? (
480 <Box flexShrink={0} marginLeft={1}>
481 <Text size="caption" dimColor>
482 {item.answered}
483 </Text>
484 </Box>
485 ) : (
486 <Box
487 flexShrink={0}
488 marginLeft={1}
489 flexDirection={isOneLine(beside, 2 * (item.options?.length ?? 0)) ? 'row' : 'column'}
490 columnGap={1}
491 >
492 {(item.options ?? []).map((option, i) => (
493 <Button key={`opt-${item.id}-${i}`} plain dimColor onPress={answer(item.id, option, fill(text.send.pick, { id: label(item), option }))}>
494 <Text size="caption">{option}</Text>
495 </Button>
496 ))}
497 </Box>
498 )
499
500 const row = (item: Item, isLingering: boolean) => {
501 const isDim = isLingering || item.answered !== undefined || item.changedTurn < at
502 const { answers } = LISTS.find(list => list.letter === item.list) ?? {}
503 const sayOf = answers && { yes: fill(text.send[answers.yes], { id: label(item) }), no: fill(text.send[answers.no], { id: label(item) }) }
504
505 return (
506 // Items sit about 1em in from their heading: 2 cells, a cell being near 0.6em.
507 <Box key={item.id} flexDirection="column" marginBottom={1} marginLeft={2}>
508 {isLingering ? (
509 line(item, isLingering, isDim)
510 ) : (
511 <Box flexDirection="row" flexWrap="nowrap" alignItems="flex-start">
512 <Box flexShrink={1} minWidth={0}>
513 {line(item, isLingering, isDim)}
514 </Box>
515 {sayOf ? yesNo(item, sayOf.yes, sayOf.no, `${label(item)}. ${item.text}`, 3) : null}
516 {item.options && !item.lean ? choices(item, `${label(item)}. ${item.text}`) : null}
517 {/* An item about a file gets 👁️ to open it, and a Later item 🏗️ to start it, stacked above its 🗑️. */}
518 <Box flexShrink={0} marginLeft={1} flexDirection="column">
519 {item.list === 'L' ? (
520 item.answered ? (
521 <Text size="caption" dimColor>
522 {crane}
523 </Text>
524 ) : (
525 <Button key={`go-${item.id}`} plain dimColor onPress={answer(item.id, crane, fill(text.send.start, { id: label(item) }))}>
526 <Text size="caption">{crane}</Text>
527 </Button>
528 )
529 ) : null}
530 {item.open ? (
531 <Button key={`open-${item.id}`} plain dimColor onPress={() => onAction(item, 'open')}>
532 <Text size="caption">{eye}</Text>
533 </Button>
534 ) : null}
535 {/* Once sent, the row leaves on its own: nothing left to clear by hand. */}
536 {item.answered ? null : (
537 <Button key={`x-${item.id}`} plain dimColor onPress={dismiss(item.id)}>
538 <Text size="caption">{trash}</Text>
539 </Button>
540 )}
541 </Box>
542 </Box>
543 )}
544 {item.lean && !isLingering ? (
545 <Box marginLeft={3} flexDirection="row" flexWrap="nowrap" alignItems="flex-start">
546 <Box flexShrink={1} minWidth={0}>
547 {/* Explicit colors, not dimColor: a surface's dim overrides any color given beside it. */}
548 <Text color={isDim ? settings.leanDimColor : settings.leanColor} italic>
549 <Text bold>{text.lean}</Text>: {linkify(item.lean)}
550 </Text>
551 </Box>
552 {item.options
553 ? choices(item, `${text.lean}: ${item.lean}`)
554 : yesNo(
555 item,
556 fill(text.send.acceptLean, { id: label(item) }),
557 fill(text.send.rejectLean, { id: label(item) }),
558 `${text.lean}: ${item.lean}`,
559 )}
560 </Box>
561 ) : null}
562 </Box>
563 )
564 }
565
566 const inline = (list: Item[], lingering: Item[]) => (
567 <Box marginBottom={1} marginLeft={2}>
568 <Text>
569 {[...lingering, ...list].map((item, i) => {
570 const isLingering = !isOpen(item)
571
572 return (
573 <Text key={item.id} {...itemColor(isLingering, item.changedTurn < at)} strikethrough={isLingering}>
574 {i ? ' · ' : ''}
575 <Text bold>{label(item)}</Text>
576 {marker(item)}
577 {stakesTag(item)} {linkify(item.text)}
578 </Text>
579 )
580 })}
581 </Text>
582 </Box>
583 )
584
585 return (
586 <Box flexDirection="column" flexGrow={1}>
587 {sections.map(({ list, shown, lingering, hidden }) => (
588 <Box key={`list-${list.letter}`} flexDirection="column">
589 <Box flexDirection="row">
590 <Text bold>
591 {settings.isEmoji && settings.isHeadingEmoji ? `${list.emoji} ` : ''}
592 {/* Only the name is underlined: not the emoji, nor the space after it. */}
593 <Text underline>{text.lists[list.letter].name}</Text>
594 </Text>
595 {hidden > 0 ? (
596 <Box marginLeft={1}>
597 <Button key={`more-${list.letter}`} plain dimColor label={fill(text.more, { n: hidden })} onPress={toggle(list.letter, true)} />
598 </Box>
599 ) : open.includes(list.letter) && (counts[list.letter] ?? 0) > (settings.caps[list.letter] ?? 7) ? (
600 <Box marginLeft={1}>
601 <Button key={`fewer-${list.letter}`} plain dimColor label={text.fewer} onPress={toggle(list.letter, false)} />
602 </Box>
603 ) : null}
604 {counter(0, (counts[list.letter] ?? 0) - (base[list.letter] ?? 0))}
605 {shown.length ? (
606 // About 2em past the heading and 1em between buttons: a cell is near 0.6em.
607 <Box flexDirection="row" marginLeft={3} columnGap={2}>
608 {/* A bulk button only where there's more than one thing for it to answer. */}
609 {/* It says how many it answers, so a new arrival changes what's pressed visibly. */}
610 {list.bulk && bulkable(shown, list.letter, now).length > 1 ? (
611 <Button
612 key={`bulk-${list.letter}`}
613 label={`${text.bulk[list.letter as 'Q' | 'C' | 'R']} (${bulkable(shown, list.letter, now).length})`}
614 onPress={accept(list.bulk, list.letter, shown)}
615 />
616 ) : null}
617 <Button key={`x-${list.letter}`} plain dimColor onPress={dismissList(list.letter)}>
618 <Text size="caption">✕</Text>
619 </Button>
620 </Box>
621 ) : null}
622 </Box>
623 {settings.inline.has(list.letter)
624 ? inline(shown, lingering)
625 : [...lingering.map(item => row(item, true)), ...shown.map(item => row(item, false))]}
626 </Box>
627 ))}
628 </Box>
629 )
630}
631
632// The ⚙ that flips the pane between the lists and the settings view, at the pane's bottom right.
633// Beside it, a link to docket's repository.
634const REPO = 'https://github.com/StoneCypher/docket'
635const SITE = 'https://stonecypher.github.io/docket'
636
637// Whether the once-a-turn review runs: the switch, once flipped this session, else the config's turnReview.
638const isDeepDiving = async ($: Dollar, settings: Settings) => (await read($, deepDive)) ?? settings.isTurnReview
639
640// The pane's bottom row: the Deep dive switch at the left; docket's repository and the ⚙ at the right.
641// The switch is meant to be rarely touched, so it's a quiet link, its emoji saying the state; the
642// terminal, which may open a link itself, gets an equally quiet button.
643const DEEP_DIVE_HREF = 'https://stonecypher.github.io/docket/#deep-dive'
644
645const gear = async ($: Dollar, { Box, Button, Link, Text }: Kit, surface: string, settings: Settings, text: Strings, Markdown?: MarkdownElement) => {
646 const isOn = await isDeepDiving($, settings)
647 const words = `${isOn ? '☑' : '☐'} ${text.deepDive}`
648 const flip = () => void update($, deepDive, () => !isOn)
649
650 return (
651 // One row, never wrapped: a Markdown block would otherwise take the width and push the rest below.
652 <Box flexDirection="row" flexWrap="nowrap" justifyContent="space-between" alignItems="center" columnGap={2} marginTop={1}>
653 <Box flexShrink={1} minWidth={0}>
654 {Markdown ? (
655 <Markdown key="deep-dive" text={`[${words}](${DEEP_DIVE_HREF})`} dimColor pressableLinks={[DEEP_DIVE_HREF]} onLinkPress={flip} />
656 ) : (
657 <Button key="deep-dive" plain dimColor onPress={flip}>
658 <Text size="caption">{words}</Text>
659 </Button>
660 )}
661 </Box>
662 <Box flexDirection="row" flexShrink={0} alignItems="center" columnGap={2}>
663 <Text dimColor size="caption">
664 <Link href={REPO}>GitHub</Link>
665 </Text>
666 <Button key="gear" plain dimColor onPress={() => update($, isSettings, isOn => !isOn)}>
667 <Text size="caption">{surface === 'terminal' ? '⚙' : '⚙️'}</Text>
668 </Button>
669 </Box>
670 </Box>
671 )
672}
673
674export type ConfigPaths = { user: string; project?: string }
675// What loading a config file had to say: worded once the language is known, as the file may set it.
676export type ConfigNote = { kind: 'unreadable' | 'updated'; path: string; from?: number; added?: string[] }
677export type LoadedConfig = { user?: ConfigFile; project?: ConfigFile; notes: ConfigNote[] }
678
679const noteText = (text: Strings, note: ConfigNote) =>
680 note.kind === 'unreadable'
681 ? fill(text.config.unreadable, { path: fileLink(note.path) })
682 : fill(note.added?.length ? text.config.updatedAdding : text.config.updated, {
683 path: fileLink(note.path),
684 from: note.from ?? 0,
685 to: CONFIG_VERSION,
686 added: (note.added ?? []).join(', '),
687 })
688
689// VS Code draws only https: links, so a config path is shown as plain text.
690const fileLink = (path: string) => path
691
692// Where docket's config lives: ~/.claude/docket/config.json, and the same path inside the project.
693// A session started in the home folder has no separate project config.
694const configPaths = async ($: Dollar, cwd: string): Promise<ConfigPaths> => {
695 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
696 const base = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`).replace(/\\/g, '/')
697 const user = `${base}/docket/config.json`
698 const project = `${cwd.replace(/\\/g, '/')}/.claude/docket/config.json`
699
700 return { user, project: project === user ? undefined : project }
701}
702
703// Reads both files and brings each up to date, writing back any it migrated. A file that won't parse
704// is left alone, and its defaults are used until it's fixed. Each migration or failure leaves a note.
705const loadConfig = async ($: Dollar, paths: ConfigPaths): Promise<LoadedConfig> => {
706 const loaded: LoadedConfig = { notes: [] }
707 for (const scope of ['user', 'project'] as const) {
708 const path = paths[scope]
709 if (!path || !(await $.fs.exists(path))) continue
710 const raw = parse(await $.fs.read(path))
711 if (raw === undefined) {
712 loaded.notes.push({ kind: 'unreadable', path })
713 continue
714 }
715 const migration = migrate(raw, scope === 'project')
716 if (migration.isChanged) {
717 await $.fs.write(path, serialize(migration.config))
718 loaded.notes.push({ kind: 'updated', path, from: migration.from, added: migration.added })
719 }
720 loaded[scope] = migration.config
721 }
722
723 return loaded
724}
725
726// Opens a file in the editor hosting the session, reusing its window; else VS Code by PATH, else the
727// system's default app for it.
728const openFile = async ($: Dollar, path: string) => {
729 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
730 // First, the editor hosting the session, by where it's installed rather than by PATH.
731 const appDir = appDirOf(await $.env.get('VSCODE_NLS_CONFIG'))
732 const namesIn = async (dir: string) => {
733 try {
734 return (await $.fs.list(dir)).filter(entry => entry.kind === 'file').map(entry => entry.name)
735 } catch {
736 return []
737 }
738 }
739 if (appDir) {
740 for (const dir of isWindows ? exeDirsOf(appDir) : scriptDirsOf(appDir)) {
741 const names = await namesIn(dir)
742 const found = isWindows ? exeIn(names) : scriptIn(names)
743 if (!found) continue
744 const { argv, env } = isWindows ? launchOf(`${dir}/${found}`, appDir, path) : { argv: [`${dir}/${found}`, '-r', path], env: {} }
745 try {
746 if ((await $.process.run(argv, { env })).exitCode === 0) return
747 } catch {
748 // That one wouldn't start; try the next way.
749 }
750 }
751 }
752 // cmd reparses what it runs, so a path with &, ^, % or quotes skips VS Code for the system's own opener.
753 const isPlain = /^[\w:\\/. ()-]+$/.test(path)
754 const tries = isWindows
755 ? [...(isPlain ? [['cmd', '/c', 'code', '-r', path]] : []), ['rundll32', 'url.dll,FileProtocolHandler', path]]
756 : [['code', '-r', path], ['open', path], ['xdg-open', path]]
757 for (const argv of tries) {
758 try {
759 if ((await $.process.run(argv)).exitCode === 0) return
760 } catch {
761 // Not on this system; try the next.
762 }
763 }
764}
765
766// Adds items the mod writes itself, numbered like any other.
767const addOwn = async ($: Dollar, specs: (Pick<Item, 'list' | 'text' | 'emoji' | 'rank' | 'action' | 'open' | 'language'>)[]) => {
768 const at = await read($, turn)
769 const made: Item[] = []
770 await update($, counters, current => {
771 const next = { ...current }
772 made.length = 0
773 for (const spec of specs) {
774 const n = (next[spec.list] ?? 0) + 1
775 next[spec.list] = n
776 made.push({ ...spec, id: `${spec.list}${n}`, n, addedTurn: at, changedTurn: at })
777 }
778
779 return next
780 })
781 await update($, items, current => [...current, ...made])
782}
783
784const closeOwn = async ($: Dollar, id: string) => {
785 const at = await $.clock.now()
786 await update($, items, current =>
787 current.map(item => (item.id === id && isOpen(item) ? { ...item, closed: { at, isUnasked: false } } : item)),
788 )
789}
790
791export type SettingsView = {
792 onDone: () => void
793 values: Record<string, string | number | boolean>
794 path: string
795 isProject: boolean
796 userPath: string
797 onSave: (key: string, value: string | number | boolean) => Promise<void>
798 onCopyToProject?: () => Promise<void>
799 // docket's version, for the line that says which docket this is.
800 version: string
801 // The language picker: the language docket speaks, the languages it can, its dialects, and the
802 // group of initials open in it.
803 speaking: string
804 languages: string[]
805 dialects: Dialect[]
806 group: string
807 onGroup: (group: string) => void
808 // The voice pulldown: the casts, and the cast the dialect brings when the config names none.
809 casts: Cast[]
810 dialectCast: string
811 noVoice: string
812}
813
814// The language as three pulldowns, since a pulldown holds at most 64 choices: a group of initials (or
815// auto), then a language in it, then its variant. Picking a group alone saves nothing.
816const languagePicker = (Box: FieldKit['Box'], Select: FieldKit['Select'], view: SettingsView, value: unknown) => {
817 const { base, variant } = pickedOf(value)
818 const bases = [...new Set(view.languages.map(code => code.split('-')[0] ?? code))]
819 const groups = groupsOf(view.speaking, bases)
820 // A setting the picker doesn't know (a code typed into the file by hand) shows as itself, not as auto.
821 const isKnown = base === 'auto' || groups.some(group => group.codes.includes(base))
822 const open = view.group || (base === 'auto' ? 'auto' : (groups.find(group => group.codes.includes(base))?.value ?? 'current'))
823 const inGroup = groups.find(group => group.value === open)
824 const variantsFor = (code: string) => variantsOf(view.speaking, code, view.languages, view.dialects)
825 const variants = base === 'auto' ? [] : variantsFor(base)
826 // Picking a language with variants picks its first region too; one with one or none has no third pulldown.
827 const pick = (code: string) => {
828 const choices = variantsFor(code)
829 void view.onSave('language', isChoosing(choices) ? `${code}-${defaultVariant(code, choices)}` : code)
830 }
831
832 // One line, wrapping only where the pane is too narrow for all three.
833 return (
834 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
835 <Select
836 key="language-group"
837 value={open}
838 options={[
839 ...(isKnown ? [] : [{ value: 'current', label: String(value) }]),
840 { value: 'auto', label: 'auto' },
841 ...groups.map(({ value: group, label }) => ({ value: group, label })),
842 ]}
843 onSelect={choice =>
844 choice === 'auto' ? void view.onSave('language', 'auto') : choice === 'current' ? view.onGroup('') : view.onGroup(choice)
845 }
846 />
847 {inGroup ? (
848 <Select
849 key="language"
850 value={inGroup.codes.includes(base) ? base : undefined}
851 options={inGroup.codes.map(code => ({ value: code, label: labelFor(view.speaking, code) }))}
852 onSelect={pick}
853 />
854 ) : null}
855 {inGroup?.codes.includes(base) && isChoosing(variants) ? (
856 <Select
857 key="language-variant"
858 value={variants.some(each => each.value === variant && each.value !== DIVIDER) ? variant : defaultVariant(base, variants)}
859 options={variants}
860 // The divider picks nothing: saving the setting unchanged redraws the pulldown back to it.
861 onSelect={choice => void view.onSave('language', choice === DIVIDER ? String(value) : `${base}-${choice}`)}
862 />
863 ) : null}
864 </Box>
865 )
866}
867
868// The voice as a pulldown: no voice, or a cast. A character described in words in the config file shows
869// as itself; picking from the pulldown replaces it.
870const voicePicker = (Select: FieldKit['Select'], view: SettingsView, value: unknown) => {
871 const setting = String(value ?? '').trim()
872 const isCast = view.casts.some(cast => cast.id === setting)
873 const shown = setting === 'none' || isCast ? setting : setting ? 'own' : view.dialectCast || 'none'
874
875 return (
876 <Select
877 key="voice"
878 value={shown}
879 options={[
880 { value: 'none', label: view.noVoice },
881 // A pulldown holds 64: room for no voice, the casts and a described character.
882 ...view.casts.slice(0, 62).map(cast => ({ value: cast.id, label: cast.name })),
883 ...(shown === 'own' ? [{ value: 'own', label: setting }] : []),
884 ]}
885 onSelect={choice => (choice === 'own' ? undefined : void view.onSave('voice', choice))}
886 />
887 )
888}
889
890// A text field, saved on Enter or its 💾: the engine's own submit button, which the surface always draws.
891const savedField = ({ Input }: FieldKit, view: SettingsView, key: string, value: unknown, isNumber: boolean) => (
892 <Input key={key} value={String(value)} submitLabel="💾" onSubmit={typed => view.onSave(key, isNumber ? Number(typed) : typed)} />
893)
894
895// Every list's cap in one table: its title right-aligned in a column as wide as the longest, then its
896// field with its 💾. Markdown tables hold only text, so it's laid out in Boxes.
897const capsTable = (kit: FieldKit, view: SettingsView, text: Strings) => {
898 const { Box, Text } = kit
899 const rows = LISTS.map(list => ({
900 key: list.capKey,
901 title: fieldWords(text, list.capKey, '', '')[0],
902 value: view.values[list.capKey] ?? FIELDS[list.capKey]?.default ?? 7,
903 }))
904 const width = Math.max(...rows.map(row => [...row.title].length)) + 1
905
906 return (
907 <Box key="row-caps" flexDirection="column" marginBottom={1} rowGap={0}>
908 <Text bold>{text.settings.capsHeading}</Text>
909 {rows.map(row => (
910 <Box key={`cap-${row.key}`} flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={1}>
911 <Box width={width} flexShrink={0} justifyContent="flex-end">
912 <Text bold>{row.title}</Text>
913 </Box>
914 <Box flexGrow={1} flexShrink={1} minWidth={0}>
915 {savedField(kit, view, row.key, row.value, true)}
916 </Box>
917 </Box>
918 ))}
919 </Box>
920 )
921}
922
923// The ⚙ view: every setting, edited straight into the config file it names.
924// A field's title and description in the user's language; a list's cap names its list.
925const fieldWords = (text: Strings, key: string, title: string, description: string): [string, string] => {
926 const list = LISTS.find(each => each.capKey === key)
927 if (list) {
928 const name = text.lists[list.letter].name
929
930 return [fill(text.settings.capTitle, { list: name }), fill(text.settings.capDescription, { list: name })]
931 }
932
933 return text.fields[key] ?? [title, description]
934}
935
936const drawSettings = ({ Box, Button, Text, Input, Select }: FieldKit, view: SettingsView, text: Strings) => (
937 <Box flexDirection="column">
938 <Box flexDirection="row" marginBottom={1}>
939 <Text bold>{text.settings.title}</Text>
940 <Box marginLeft={3}>
941 <Button key="done" label={text.settings.done} onPress={view.onDone} />
942 </Box>
943 </Box>
944 <Box flexDirection="column" marginBottom={1}>
945 <Text dimColor size="footnote">
946 {view.isProject ? text.settings.editingProject : text.settings.editingYours} {view.path}
947 </Text>
948 <Box marginTop={1}>
949 <Text dimColor size="footnote">
950 {fill(text.settings.about, { version: view.version || '?', date: dayOf(view.speaking, BUILT) })}
951 </Text>
952 </Box>
953 {view.onCopyToProject ? (
954 <Box marginTop={1}>
955 <Button key="copy-project" label={text.settings.copyToProject} onPress={view.onCopyToProject} />
956 </Box>
957 ) : null}
958 </Box>
959 {Object.entries(FIELDS).map(([key, field]) => {
960 // The caps go in one table, where the first of them stands; the rest are in it.
961 const capKeys = LISTS.map(list => list.capKey)
962 if (capKeys.includes(key)) {
963 return key === capKeys.find(each => each in FIELDS) ? capsTable({ Box, Button, Text, Input, Select }, view, text) : null
964 }
965 const value = view.values[key] ?? field.default
966 const control =
967 field.kind === 'boolean' ? (
968 <Select key={key} value={value ? 'on' : 'off'} options={[{ value: 'on' }, { value: 'off' }]} onSelect={choice => view.onSave(key, choice === 'on')} />
969 ) : key === 'language' ? (
970 languagePicker(Box, Select, view, value)
971 ) : key === 'voice' ? (
972 voicePicker(Select, view, value)
973 ) : field.kind === 'choice' && field.options ? (
974 <Select key={key} value={String(value)} options={field.options.map(option => ({ value: option }))} onSelect={choice => view.onSave(key, choice)} />
975 ) : (
976 savedField({ Box, Button, Text, Input, Select }, view, key, value, field.kind === 'number')
977 )
978
979 const [title, description] = fieldWords(text, key, field.title, field.description)
980
981 return (
982 <Box key={`row-${key}`} flexDirection="column" marginBottom={1}>
983 <Text bold>{title}</Text>
984 <Text dimColor size="footnote">
985 {description}
986 </Text>
987 {control}
988 </Box>
989 )
990 })}
991 </Box>
992)
993
994// docket's published manifest, read for the update check.
995const LATEST_MANIFEST = 'https://raw.githubusercontent.com/StoneCypher/docket/main/.claude-plugin/plugin.json'
996const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000
997
998// Whether version a comes after version b, part by part.
999export const isNewer = (a: string, b: string) => {
1000 const parts = (version: string) => version.split('.').map(part => Number.parseInt(part, 10) || 0)
1001 const [x, y] = [parts(a), parts(b)]
1002 for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
1003 if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0)
1004 }
1005
1006 return false
1007}
1008
1009// Once a day at most, reads docket's manifest on GitHub; when it's newer, says so in Verify, once per
1010// version. Off with updateCheck, which also stops the request.
1011const checkForUpdate = async ($: Dollar, current: string, settings: Settings, text: Strings) => {
1012 if (!settings.isUpdateCheck || !current) return
1013 const now = await $.clock.now()
1014 const checkedAt = Number(await $.store.get('updateCheckedAt'))
1015 if (checkedAt && now - checkedAt < UPDATE_CHECK_MS) return
1016 await $.store.set('updateCheckedAt', now)
1017 let latest = ''
1018 try {
1019 const { ok, text: body } = await $.http.fetch(LATEST_MANIFEST)
1020 const { version } = (ok ? JSON.parse(body) : {}) as { version?: unknown }
1021 latest = typeof version === 'string' ? version : ''
1022 } catch {
1023 return
1024 }
1025 if (!latest || !isNewer(latest, current) || (await $.store.get('updateNoticed')) === latest) return
1026 await $.store.set('updateNoticed', latest)
1027 await addOwn($, [{ list: 'V', emoji: '🔔', rank: 1, text: fill(text.update, { latest, current }), action: 'update' }])
1028}
1029
1030// The tool, described in the user's language.
1031const toolSpec = (text: Strings) => ({
1032 name: 'update',
1033 description:
1034 'Add, change and close items on the docket, the numbered lists the user reads above the prompt. Send every change of a turn in one call. Labels are assigned here and returned. Use change only to correct the same item; a new or follow-up question is a close plus an add, so it gets a new number.',
1035 inputSchema: schemaOf(text),
1036 isDeferred: false,
1037})
1038
1039const localeFile = ($: Dollar, code: string) => `${$.plugin.root}/locales/${code}.json`
1040
1041// A language file's translations, or none when it's missing or won't parse.
1042const wordsOf = async ($: Dollar, code: string): Promise<Record<string, unknown>> => {
1043 try {
1044 return JSON.parse(await $.fs.read(localeFile($, code)))
1045 } catch {
1046 return {}
1047 }
1048}
1049
1050// docket's own words in a language. A regional file (es-MX) says only what differs, over its
1051// language's (es); a dialect, from locales/dialects/, says what it changes over both; English
1052// underlies all of them. A dialect's name is what Claude is told the docket is written in.
1053// A cast, from locales/voices/, is the voice Claude writes in and the buttons it brings, in any
1054// language: the one the config names, or else the dialect's own.
1055const stringsOf = async ($: Dollar, language: Language, dialect = '', voiceSetting = '', casts: readonly Cast[] = []) => {
1056 const [base = 'en'] = language.split('-')
1057 const spoken = dialect ? await wordsOf($, `dialects/${dialect}`) : {}
1058 const castId = castIdOf(voiceSetting, casts, typeof spoken.cast === 'string' ? spoken.cast : '')
1059 const cast = castId ? await wordsOf($, `voices/${castId}`) : {}
1060 const words = base === 'en' ? {} : await wordsOf($, base)
1061 const regional = language === base ? {} : await wordsOf($, language)
1062 const voice = voiceOf(words, regional, spoken, cast)
1063 const name = typeof spoken.name === 'string' ? spoken.name : englishName(language)
1064 const marks = cast.marks ?? spoken.marks
1065 if (base === 'en' && !dialect && !marks) {
1066 return { text: EN, voice }
1067 }
1068 const text = translate({ ...words, ...regional, ...spoken, marks }, base === 'en' && !dialect ? EN.language : name)
1069 const pictured = cast.marks ? { file: cast, folder: 'voices' } : { file: spoken, folder: 'dialects' }
1070 for (const key of ['yes', 'no'] as const) {
1071 const image = markImage(pictured.file, key)
1072 if (!image) continue
1073 try {
1074 text.marks[`${key}Image`] = await $.fs.read(`${$.plugin.root}/locales/${pictured.folder}/${image}`)
1075 } catch {
1076 // No picture: the button keeps its text.
1077 }
1078 }
1079
1080 return { text, voice }
1081}
1082
1083// The casts, from locales/voices/: each one's file name and its name.
1084export type Cast = { id: string; name: string }
1085
1086const castsOf = async ($: Dollar): Promise<Cast[]> => {
1087 try {
1088 const ids = (await $.fs.list(`${$.plugin.root}/locales/voices`))
1089 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
1090 .map(entry => entry.name.slice(0, -'.json'.length))
1091 const found = await Promise.all(ids.map(async id => ({ id, file: await wordsOf($, `voices/${id}`) })))
1092
1093 return found.map(({ id, file }) => ({ id, name: typeof file.name === 'string' ? file.name : id })).sort((x, y) => x.name.localeCompare(y.name))
1094 } catch {
1095 return []
1096 }
1097}
1098
1099// The config's voice: a cast's file name, "none", a character described in words (Claudeville), or
1100// nothing, which leaves the dialect's own cast if it has one.
1101const voiceSettingOf = (loaded: LoadedConfig) => String(loaded.project?.voice ?? loaded.user?.voice ?? '').trim()
1102
1103export const castIdOf = (setting: string, casts: readonly Cast[], dialectCast: string) =>
1104 setting === 'none' ? '' : casts.some(cast => cast.id === setting) ? setting : setting ? '' : dialectCast
1105
1106// The voice in force: a character the config describes in words, over any file's.
1107const voiceFor = (setting: string, casts: readonly Cast[], files: Voice): Voice =>
1108 setting && setting !== 'none' && !casts.some(cast => cast.id === setting) ? { all: setting, lists: {} } : files
1109
1110// A language setting may end in a dialect, as BCP 47's variant does: "en-US-startrek" is English with
1111// the startrek dialect. A variant is at least five characters, so "zh-Hant" is still a script.
1112const VARIANT = /^([a-z]{2,3}(?:[-_][a-z]{2,4})?)[-_]([a-z][a-z0-9]{4,})$/i
1113
1114export const splitDialect = (setting: unknown) => {
1115 const [, language, dialect] = VARIANT.exec(String(setting ?? '').trim()) ?? []
1116
1117 return language && dialect ? { language, dialect: dialect.toLowerCase() } : { language: setting, dialect: '' }
1118}
1119
1120// The forced dialect, if any: the language setting's variant, or a "dialect" key, set by hand.
1121const dialectOf = (loaded: LoadedConfig) => {
1122 const named = String(loaded.project?.dialect ?? loaded.user?.dialect ?? '').trim()
1123 const own = /^[a-z0-9-]+$/i.test(named) ? named.toLowerCase() : ''
1124
1125 return own || splitDialect(merged(loaded.user, loaded.project).language).dialect
1126}
1127
1128// Each language file's state, by code: whatever locales/ holds, so a new language is just its file.
1129const localesOf = async ($: Dollar): Promise<Record<string, FileState>> => {
1130 try {
1131 const codes = (await $.fs.list(`${$.plugin.root}/locales`))
1132 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
1133 .map(entry => entry.name.slice(0, -'.json'.length))
1134
1135 return Object.fromEntries(await Promise.all(codes.map(async code => [code, stateOf(await wordsOf($, code))] as const)))
1136 } catch {
1137 return {}
1138 }
1139}
1140
1141// The dialects, by the language tags that pick them ("en-US-startrek") and their names.
1142const dialectsOf = async ($: Dollar): Promise<Dialect[]> => {
1143 try {
1144 const names = (await $.fs.list(`${$.plugin.root}/locales/dialects`))
1145 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
1146 .map(entry => entry.name.slice(0, -'.json'.length))
1147 const found = await Promise.all(names.map(async name => ({ name, file: await wordsOf($, `dialects/${name}`) })))
1148
1149 return found
1150 .filter(({ file }) => typeof file.tag === 'string')
1151 .map(({ name, file }) => ({
1152 tag: String(file.tag),
1153 name: typeof file.name === 'string' ? file.name : name,
1154 cast: typeof file.cast === 'string' ? file.cast : undefined,
1155 }))
1156 } catch {
1157 return []
1158 }
1159}
1160
1161// The languages docket can speak: English, and each file not marked unready.
1162const readyOf = (locales: Record<string, FileState>) =>
1163 ['en', ...Object.keys(locales).filter(code => locales[code]?.ready !== false)].sort()
1164
1165// Where to help translate: the language's file in GitHub's editor, which forks the repository for
1166// someone without access and proposes their change as a pull request; or, with no file yet, a new one.
1167const translateLink = (code: string, locales: Record<string, FileState>) =>
1168 code in locales ? `${REPO}/edit/main/locales/${code}.json` : `${REPO}/new/main/locales?filename=${encodeURIComponent(`${code}.json`)}`
1169
1170// What help a language needs: a translation, when docket can't speak it (asked in English, as
1171// there's nothing else to ask in); a person's eye, when its file is machine-translated; or new strings,
1172// when it's behind English. Each is asked once: per language, and per strings version for the others.
1173const offerTranslation = async ($: Dollar, language: Language, locales: Record<string, FileState>, text: Strings) => {
1174 const [base = 'en'] = language.split('-')
1175 if (base === 'en') return
1176 const spoken = [language, base].find(code => locales[code] && locales[code].ready !== false)
1177 const state = spoken ? locales[spoken] : undefined
1178 const target = spoken ?? (language in locales || !(base in locales) ? language : base)
1179 const need = !state ? 'translate' : state.version < STRINGS_VERSION ? 'behind' : state.ready === AI ? 'machine' : undefined
1180 if (!need) return
1181 const key = need === 'translate' ? target : `${target}:${need}@${STRINGS_VERSION}`
1182 const asked = ((await $.store.get('translateAsked')) as string[] | undefined) ?? []
1183 const isOpenNow = (await read($, items)).some(item => item.action === 'translate' && item.language === key && isOpen(item))
1184 if (asked.includes(key) || isOpenNow) return
1185 const link = translateLink(target, locales)
1186 const name = englishName(target)
1187 await addOwn($, [
1188 {
1189 list: 'R',
1190 emoji: '🌐',
1191 rank: 1,
1192 text:
1193 need === 'translate'
1194 ? `docket doesn't speak ${name} yet. Help [translate it](${link})? ✔ opens locales/${target}.json in GitHub's editor: fill in each empty "text", set its "ready" to true, and save to propose it.`
1195 : fill(text.translate[need], { language: nativeName(target), link }),
1196 action: 'translate',
1197 language: key,
1198 },
1199 ])
1200}hooks/lists.ts 386 lines1import type { PluginOptions } from 'claude-code'
2
3import type { Counts, Item, Letter } from '../types'
4import { fill, idFor, labelOf, labelOfId, letterFor, typedLetters } from './i18n.ts'
5import type { Strings, Voice } from './i18n.ts'
6
7export type ListDef = {
8 letter: Letter
9 name: string
10 emoji: string
11 isRanked: boolean
12 capKey: string
13 // A heading button answering every item shown, sending this message with their labels.
14 bulk?: 'acceptLeans' | 'confirmAll' | 'approveAll'
15 // Yes and no buttons on each item, sending these messages with its label.
16 answers?: { yes: keyof Strings['send']; no: keyof Strings['send'] }
17}
18
19// Least important first: the band's order, so the most important sits by the prompt. The pane reverses it.
20export const LISTS: readonly ListDef[] = [
21 { letter: 'P', name: 'Pinned', emoji: '📌', isRanked: false, capKey: 'pinnedCap' },
22 { letter: 'W', name: 'Waiting', emoji: '⏳', isRanked: false, capKey: 'waitingCap' },
23 { letter: 'L', name: 'Later', emoji: '📥', isRanked: false, capKey: 'laterCap' },
24 { letter: 'U', name: 'Underway', emoji: '🔨', isRanked: false, capKey: 'underwayCap' },
25 { letter: 'V', name: 'Verify', emoji: '👀', isRanked: true, capKey: 'verifyCap' },
26 { letter: 'N', name: 'Needs you', emoji: '🙋', isRanked: true, capKey: 'needsYouCap' },
27 {
28 letter: 'C',
29 name: 'Confirm',
30 emoji: '👍',
31 isRanked: true,
32 capKey: 'confirmCap',
33 bulk: 'confirmAll',
34 answers: { yes: 'confirm', no: 'notConfirm' },
35 },
36 {
37 letter: 'Q',
38 name: 'Questions',
39 emoji: '❓',
40 isRanked: true,
41 capKey: 'questionsCap',
42 bulk: 'acceptLeans',
43 },
44 {
45 letter: 'R',
46 name: 'Requests',
47 emoji: '🙏',
48 isRanked: true,
49 capKey: 'requestsCap',
50 bulk: 'approveAll',
51 answers: { yes: 'approve', no: 'decline' },
52 },
53]
54
55export const LETTERS = LISTS.map(list => list.letter)
56
57export type Settings = {
58 display: 'pane' | 'band'
59 // The once-a-turn pass for what the turn decided or left open.
60 isTurnReview: boolean
61 // The once-a-day look on GitHub for a newer docket.
62 isUpdateCheck: boolean
63 caps: Counts
64 isEmoji: boolean
65 isHeadingEmoji: boolean
66 noItemEmoji: Set<string>
67 inline: Set<string>
68 targetWords: Counts
69 stakesDisplay: 'emoji' | 'text' | 'none'
70 lingerMs: number
71 answeredMs: number
72 leanColor?: string
73 leanDimColor?: string
74 itemColor?: string
75 itemDimColor?: string
76 upArrowColor?: string
77 upNumberColor?: string
78 downArrowColor?: string
79 downNumberColor?: string
80}
81
82const letterSet = (value: unknown) =>
83 new Set(
84 String(value ?? '')
85 .toUpperCase()
86 .split(/[\s,]+/)
87 .filter(Boolean),
88 )
89
90// The colors the engine accepts. Anything else would make it refuse the whole tree, so it falls back.
91const COLOR_NAMES = new Set([
92 'text', 'inverseText', 'inactive', 'subtle', 'suggestion', 'remember', 'success', 'error', 'warning',
93 'merged', 'claude', 'permission', 'planMode', 'autoAccept', 'promptBorder', 'bashBorder', 'ide',
94 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray', 'grey',
95])
96
97const colorOf = (value: unknown, fallback: string) => {
98 const color = String(value ?? fallback).trim()
99 if (!color) {
100 return undefined
101 }
102
103 return /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) || COLOR_NAMES.has(color) ? color : fallback
104}
105
106// A number setting as a number from 0 to `most`: a word, a negative or a timer-breaking size gives way.
107const within = (value: unknown, usual: number, most: number) => {
108 const number = Number(value ?? usual)
109
110 return Number.isFinite(number) ? Math.min(most, Math.max(0, number)) : usual
111}
112
113export const settingsOf = (options: PluginOptions): Settings => {
114 const caps: Counts = {}
115 for (const list of LISTS) {
116 caps[list.letter] = Math.max(1, Number(options[list.capKey] ?? 7))
117 }
118
119 const targetWords: Counts = {}
120 for (const pair of String(options.targetWords ?? 'P=8').split(/[\s,]+/)) {
121 const [, letter, words] = /^([A-Za-z])=(\d+)$/.exec(pair) ?? []
122 // Only the lists' own letters: any other has no list to limit.
123 if (letter && words && LETTERS.includes(letter.toUpperCase() as Letter)) {
124 targetWords[letter.toUpperCase() as Letter] = Number(words)
125 }
126 }
127
128 const stakes = String(options.stakesDisplay ?? 'emoji')
129
130 return {
131 display: options.display === 'band' ? 'band' : 'pane',
132 isTurnReview: options.turnReview !== false,
133 isUpdateCheck: options.updateCheck !== false,
134 caps,
135 isEmoji: options.emoji !== false,
136 isHeadingEmoji: options.headingEmoji === true,
137 noItemEmoji: letterSet(options.noItemEmojiLists),
138 inline: letterSet(options.inlineLists ?? 'P'),
139 targetWords,
140 stakesDisplay: stakes === 'text' || stakes === 'none' ? stakes : 'emoji',
141 lingerMs: within(options.strikeLingerMinutes, 3, 24 * 60) * 60_000,
142 answeredMs: within(options.answeredLingerSeconds, 20, 24 * 60 * 60) * 1000,
143 leanColor: colorOf(options.leanColor, '#b1b1b1'),
144 leanDimColor: colorOf(options.leanDimColor, '#757575'),
145 itemColor: colorOf(options.itemColor, '#e0e0e0'),
146 itemDimColor: colorOf(options.itemDimColor, '#939393'),
147 upArrowColor: colorOf(options.upArrowColor, '#5fb878'),
148 upNumberColor: colorOf(options.upNumberColor, '#7c9a84'),
149 downArrowColor: colorOf(options.downArrowColor, '#a885e6'),
150 downNumberColor: colorOf(options.downNumberColor, '#8f83a8'),
151 }
152}
153
154export const isOpen = (item: Item) => item.closed === undefined
155
156export const openCounts = (items: readonly Item[]): Counts => {
157 const counts: Counts = {}
158 for (const item of items) {
159 if (isOpen(item)) {
160 counts[item.list] = (counts[item.list] ?? 0) + 1
161 }
162 }
163
164 return counts
165}
166
167const urgency = (a: Item, b: Item) => (a.rank ?? 9999) - (b.rank ?? 9999) || a.n - b.n
168
169export type Arranged = { shown: Item[]; lingering: Item[]; hidden: number }
170
171// The capped slice of one list, in display order. Items new this turn always show.
172// Beside the prompt the most urgent goes last, nearest it; elsewhere it goes first.
173export const arrange = (
174 items: readonly Item[],
175 list: ListDef,
176 cap: number,
177 turn: number,
178 now: number,
179 lingerMs: number,
180 isUrgentLast: boolean,
181 answeredMs: number,
182): Arranged => {
183 const own = items.filter(item => item.list === list.letter)
184 // An answered item leaves the view once its time is up, closed by Claude or not.
185 const isGone = (item: Item) => item.answeredAt !== undefined && now - item.answeredAt >= answeredMs
186 const open = own.filter(item => isOpen(item) && !isGone(item)).sort(list.isRanked ? urgency : (a, b) => a.n - b.n)
187 const kept = open.filter((item, i) => i < cap || item.addedTurn === turn)
188 const lingering = own.filter(item => item.closed?.isUnasked && now - item.closed.at < lingerMs)
189
190 return {
191 shown: list.isRanked && isUrgentLast ? kept.reverse() : kept,
192 lingering,
193 hidden: open.length - kept.length,
194 }
195}
196
197// A counter's parts: what's hidden, the direction of the change, and its size.
198export const counterParts = (hidden: number, delta: number) => ({
199 more: hidden > 0 ? `+${hidden} more · ` : '',
200 arrow: delta > 0 ? '↑' : delta < 0 ? '↓' : '',
201 number: delta === 0 ? '+0' : String(Math.abs(delta)),
202 direction: delta > 0 ? ('up' as const) : delta < 0 ? ('down' as const) : ('none' as const),
203})
204
205export const counterText = (hidden: number, delta: number) => {
206 const { more, arrow, number } = counterParts(hidden, delta)
207
208 return `${more}${arrow}${number}`
209}
210
211const describe = (item: Item, text: Strings) =>
212 [
213 `${labelOf(text, item)}${item.emoji ? ` ${item.emoji}` : ''} ${item.text}`,
214 item.lean && `lean: ${item.lean}`,
215 item.stakes && `stakes: ${item.stakes}`,
216 item.rank !== undefined && `rank ${item.rank}`,
217 item.action && "the mod's own item: leave it alone",
218 item.answered && `the user answered ${item.answered === 'yes' ? 'yes' : item.answered === 'no' ? 'no' : item.answered} by button`,
219 ]
220 .filter(Boolean)
221 .join(' | ')
222
223// Phrases a reply uses when it leaves work open: a reply holding one that changed nothing on the
224// docket is flagged, and the next prompt asks for a reconcile. Only phrases that leave work open
225// count; bare words like "next" or "later" also describe how things work, so they don't.
226const PENDING =
227 /\b(next up|next steps?|left to do|still (need|needs|have) to|remaining work|to-?dos?|once you('ve| have)?|when you're ready|haven't (done|built|started|written)|I'll (come back|get to|follow up|do that|pick (it|that) up)|follow[- ]up)\b/i
228
229// Code is quoted, not said: fences and inline code are left out before the check.
230const withoutCode = (text: string) => text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
231
232export const speaksOfPending = (answer: string) => PENDING.test(withoutCode(answer))
233
234export const RECONCILE =
235 'Your last reply spoke of pending or next work but the docket did not change that turn. Make sure every open thread it mentions is an item; nothing open should live only in prose.'
236
237export const snapshot = (items: readonly Item[], dismissed: readonly string[], text: Strings) => {
238 const open = items.filter(isOpen)
239 const lines = ['Docket, open items (the user sees these in the docket pane or band):']
240 lines.push(...(open.length ? open.map(item => describe(item, text)) : ['(empty)']))
241 if (dismissed.length) {
242 lines.push(
243 `The user cleared these from the docket since the last prompt: ${dismissed.map(id => labelOfId(text, id)).join(', ')}. Clearing only empties the display: it cancels no work, answers nothing and approves nothing. Carry on as before, and don't add them back while they stay dormant; if one becomes relevant again, add it fresh, with a new number.`,
244 )
245 }
246
247 return lines.join('\n')
248}
249
250const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
251
252// The items a prompt names by label ("q10", "c23-c25", "質12"), in the user's language, so a closed
253// one is still known. A Latin letter mustn't be the end of a longer word; any other script can sit
254// right after the text before it.
255export const references = (prompt: string, items: readonly Item[], text: Strings) => {
256 // Longest first, so a letter isn't cut short by a shorter one that begins it.
257 const letters = typedLetters(text)
258 .sort((a, b) => b.length - a.length)
259 .map(escapeRegExp)
260 .join('|')
261 const range = new RegExp(`(?<![A-Za-z0-9])(${letters})(\\d+)\\s*(?:-|–|~|〜|to|through|thru|bis|a)\\s*(?:\\1)?(\\d+)`, 'giu')
262 const single = new RegExp(`(?<![A-Za-z0-9])(${letters})(\\d+)`, 'giu')
263 const ids = new Set<string>()
264 for (const [, typed = '', from, to] of prompt.matchAll(range)) {
265 const letter = letterFor(text, typed)
266 const low = Math.min(Number(from), Number(to))
267 const high = Math.min(Math.max(Number(from), Number(to)), low + 50)
268 for (let n = low; letter && n <= high; n += 1) ids.add(`${letter}${n}`)
269 }
270 for (const [whole] of prompt.matchAll(single)) {
271 const id = idFor(text, whole)
272 if (id) ids.add(id)
273 }
274
275 const named = items.filter(item => ids.has(item.id))
276 if (!named.length) {
277 return undefined
278 }
279
280 return [
281 'Docket items the user named in this prompt:',
282 ...named.map(item => `${describe(item, text)}${isOpen(item) ? '' : ' (already closed)'}`),
283 ].join('\n')
284}
285
286// Told to Claude once at the end of each turn, as the old /questions skill was called after every turn:
287// the pass that catches what the turn decided or left open without saying so.
288export const REVIEW = `Before this turn ends, go back over it once for the docket:
289- Any decision you made on the user's behalf that they might want to make themselves: ask it, as a question with your lean.
290- Anything you're not sure you read right, or anything risky about to happen: put it in confirm.
291- Any thread your reply left open (later, waiting, pinned, needs you): make it an item.
292If anything is missing, add it in one update call. Then end the turn with one short line saying what you added, or that nothing was missing: don't repeat your reply.`
293
294// Told to Claude when the docket's language or voice changed since its open items were written.
295export const rewriteNote = (text: Strings, voice: Voice) => {
296 const isVoiced = Boolean(voice.all || Object.keys(voice.lists).length)
297
298 return `The docket's language or voice has changed since its open items were written: it is now ${text.language}, ${
299 isVoiced ? 'in the voice the docket instructions give' : 'with no voice'
300 }. Before anything else, rewrite every open item's text, lean and options that way, in one update call of changes, keeping each label and exactly what each item says; leave the mod's own items alone.${
301 isVoiced ? '' : ' Write them plainly, out of character.'
302 }`
303}
304
305// The docket as it is now, told with every prompt: the system prompt is composed once a session, so a
306// language or voice chosen since never reaches it. Empty for plain English with no voice, which the
307// system prompt already says.
308export const nowNote = (text: Strings, voice: Voice) => {
309 const isEnglish = text.language === 'English'
310 const voiced = (Object.keys(voice.lists) as Letter[]).map(letter => `- ${text.lists[letter].letter} (${text.lists[letter].name}): ${voice.lists[letter]}`)
311 if (isEnglish && !voice.all && !voiced.length) {
312 return ''
313 }
314 const letters = LETTERS.map(letter => `${text.lists[letter].letter} ${text.lists[letter].name}`).join(', ')
315 const id = (letter: Letter, n: number) => `${text.lists[letter].letter}${n}`
316 const say = (key: keyof Strings['send'], values: Record<string, string>) => `"${fill(text.send[key], values)}"`
317
318 return [
319 'The docket right now, which overrides the docket instructions where they differ:',
320 isEnglish
321 ? ''
322 : `- It is in ${text.language}. Write every item's text, lean and options in ${text.language}.`,
323 `- Its lists and their letters, which label items and go in the tool's list field: ${letters}.`,
324 `- Its buttons send ${say('acceptLean', { id: id('Q', 5) })}, ${say('rejectLean', { id: id('Q', 5) })}, ${say('approve', { id: id('R', 3) })}, ${say('decline', { id: id('R', 3) })}, ${say('confirm', { id: id('C', 3) })}, ${say('notConfirm', { id: id('C', 3) })} and ${say('start', { id: id('L', 3) })}.`,
325 voice.all || voiced.length
326 ? `- Write every item, new or changed, in character${voice.all ? `, as ${voice.all}` : ''}${voiced.length ? '; in these lists, as follows:' : '.'}`
327 : '',
328 ...voiced,
329 voice.all || voiced.length
330 ? " The voice is for the items' wording only: they still say exactly what they need to, and the labels, links and paths stay as they are."
331 : '',
332 ]
333 .filter(Boolean)
334 .join('\n')
335}
336
337export const instructions = (settings: Settings, isHeadless: boolean, text: Strings, voice: Voice = { all: '', lists: {} }) => {
338 const L = (letter: Letter) => text.lists[letter].letter
339 const named = (letter: Letter) => (text.language === 'English' ? L(letter) : `${L(letter)} (${text.lists[letter].name})`)
340 const say = (key: keyof Strings['send'], id: string, extra: Record<string, string> = {}) => `"${fill(text.send[key], { id, ids: id, ...extra })}"`
341 const targets = Object.entries(settings.targetWords)
342 .map(([letter, words]) => `${L(letter as Letter)} at most about ${words} words`)
343 .join(', ')
344 const language =
345 text.language === 'English'
346 ? ''
347 : `\n\nThe user's docket is in ${text.language}. Write every item's text, lean and options in ${text.language}, and label items with the letters below (letter then number, such as ${L('Q')}12). The buttons send the messages quoted below, in ${text.language}.`
348
349 const voiced = (Object.keys(voice.lists) as Letter[]).map(letter => `- ${named(letter)}: ${voice.lists[letter]}`)
350 const voices =
351 voice.all || voiced.length
352 ? [
353 `Write the docket's items in character${voice.all ? `, as ${voice.all}` : ''}${voiced.length ? '; in these lists, as follows:' : '.'}`,
354 ...voiced,
355 "The voice is for the items' wording only: they still say exactly what they need to, and the labels, links and paths stay as they are.",
356 ].join('\n')
357 : ''
358
359 return `# Docket
360
361The user runs the docket mod, a pane beside the transcript (or a band above the prompt) showing your open items as numbered lists. You keep it current with the mcp__docket__update tool: one call per turn, holding every change, whenever anything changed. ${
362 isHeadless
363 ? 'Nothing draws the band in this session, so end each reply with the open items as plain text, grouped by list, in the order below.'
364 : 'The docket is where the user reads them: never repeat docket items in your prose reply.'
365 }${language}${voices ? `\n\n${voices}` : ''}
366
367Lists, from least to most important:
368- ${named('P')} pinned: parked for later.
369- ${named('W')} waiting: stalled on something other than the user; informational.
370- ${named('L')} later: your own work that is decided or approved but not started yet, in the order you will take it. When you start one, close it and add it to ${L('U')}. Its 🏗️ button (▶ in the terminal) sends ${say('start', `${L('L')}3`)}: the user wants that one started now, ahead of the order, so start it and move it to ${L('U')}.
371- ${named('U')} underway: only your own work in progress, never the user's.
372- ${named('V')} verify: things for the user to look at and confirm: that a change works, reads right or looks right.
373- ${named('N')} needs you: actions only the user can take. Give the exact console path. Rank the ones you are stuck on first.
374- ${named('C')} confirm: when you are unsure you read the user right, or something risky is about to happen and you want them certain of it. State your reading, so a bare "yes" settles it. ${say('confirm', `${L('C')}3`)} (its ✔ button) settles it; ${say('notConfirm', `${L('C')}3`)} (its ✗ button) means your reading was wrong or the risky step should not go ahead: hold off, and ask what they meant.
375- ${named('Q')} questions: decisions only the user can make. Always give a lean: the answer you would pick. When the question offers up to three choices, pass their labels as options: one to three buttons, each label a single letter or a single emoji, whichever reads best ("a", "b", "c" or "🌐", "📄"), and write each label in the text beside the choice it picks. Their buttons replace ✔ and ✗ and send ${say('pick', `${L('Q')}4`, { option: 'b' })}, meaning the user picked (b).
376- ${named('R')} requests: things you want to do on your own initiative. Wait for permission. Use sparingly. ${say('approve', `${L('R')}3`)} (its ✔ button) means go ahead; ${say('decline', `${L('R')}3`)} (its ✗ button) means don't do it.
377
378The tool's list field takes these same letters.
379
380The docket is the complete set of open threads: anything your reply calls pending, queued, next, later or left must also be an item (${L('L')}, ${L('P')}, ${L('R')} or another list), never only prose.
381
382Writing items: verify cheaply before adding one, and never ask what you can look up. Each item is answerable without scrolling back: define each named thing at its first mention, and give issue or PR numbers. Link whatever has a web address (a repo, an issue or PR, a page, a site, a commit) as a [label](https://...) link with a short label, such as [StoneCypher/docket](https://github.com/StoneCypher/docket); the docket draws it as a link. When an item is about a local file, set open to its absolute path, and the item gets an Open button for it. Keep items to one to three lines${targets ? `, and ${targets}` : ''}. Give each item one emoji: a single code point with default emoji presentation from Unicode 9 or earlier (no VS16, ZWJ, flags or skin tones). Set stakes only when they matter: emergency and high sparingly, med otherwise, none for low. On ${L('V')}, ${L('N')}, ${L('C')}, ${L('Q')} and ${L('R')}, rank 1 is the most urgent.
383
384Answers: the user answers by label ("${L('Q').toLowerCase()}10 yes", "${L('C').toLowerCase()}23-${L('C').toLowerCase()}25 confirmed", "accept all other leans"), or by button. ${say('acceptLean', `${L('Q')}5`)} (a lean's ✔ button) accepts your lean; ${say('rejectLean', `${L('Q')}5`)} (its ✗ button) rejects only your lean: keep the question open, and ask what they want instead, or take the obvious other answer when there are only two. ${say('acceptLeans', `${L('Q')}5, ${L('Q')}6`)}, ${say('confirmAll', `${L('C')}2, ${L('C')}3`)} and ${say('approveAll', `${L('R')}2, ${L('R')}3`)} answer several at once. Act on every answer first, then make one update call closing what is resolved and adding what is new, and confirm each answer briefly by its label in prose. Never re-ask unless the answer was ambiguous; then ask once, in ${L('C')}. Close items on common-sense grounds. Answered items vanish from the user's view on their own about ${Math.round(settings.answeredMs / 1000)} seconds after their button is pressed, so close them, and when an answer starts work you haven't finished, add that work to ${L('U')} (underway) in the same update. The user can also clear items or whole lists from the docket; that only empties the display and never cancels work, answers a question or approves a request. A cleared thread that becomes relevant again comes back as a new item, with a new number. When you close an item the user did not resolve themselves, set unasked: true, and mention every such closure in one \`\`\`diff block of "- " lines. The tool assigns the labels, and numbers only ever go up. Change an item only to correct or sharpen the same item; anything the user would read as a new question or a new item, such as a follow-up after a "no" or a reworded ask, closes the old one and is added fresh, so it gets a new number. A label that suddenly means something else looks like a bug.`
385}
386hooks/config.ts 124 lines1// docket's own config: a versioned JSON file in ~/.claude/docket/, and optionally the same file in a
2// project's .claude/docket/, whose keys win. Every key is always written, at its default if unset.
3import type { PluginOptions } from 'claude-code'
4
5// Raise this whenever FIELDS changes, and say what changed in MIGRATIONS.
6export const CONFIG_VERSION = 6
7
8export type FieldKind = 'number' | 'boolean' | 'choice' | 'text'
9export type Field = { kind: FieldKind; title: string; description: string; default: string | number | boolean; options?: string[] }
10
11export const FIELDS: Record<string, Field> = {
12 language: { kind: 'choice', title: "Language", description: "Language of the docket's own words: auto follows Claude Code's language setting", default: "auto", options: ["auto", "en", "es", "ja", "de"] },
13 voice: { kind: 'text', title: "Voice", description: "A character Claude writes the docket's items as, in any language", default: "" },
14 turnReview: { kind: 'boolean', title: "Deep dive", description: "Whether the Deep dive switch starts on: before a turn ends, Claude goes back over it once for decisions it made for you and threads it left open", default: true },
15 updateCheck: { kind: 'boolean', title: "Update check", description: "Once a day, look on GitHub for a newer docket and say so in Verify", default: true },
16 display: { kind: 'choice', title: "Display", description: "A pane beside the transcript, or a band above the prompt", default: "pane", options: ["pane", "band"] },
17 pinnedCap: { kind: 'number', title: "Pinned cap", description: "Items shown in pinned before \"+N more\"", default: 7 },
18 waitingCap: { kind: 'number', title: "Waiting cap", description: "Items shown in waiting before \"+N more\"", default: 7 },
19 laterCap: { kind: 'number', title: "Later cap", description: "Items shown in Later before \"+N more\"", default: 7 },
20 underwayCap: { kind: 'number', title: "Underway cap", description: "Items shown in underway before \"+N more\"", default: 7 },
21 verifyCap: { kind: 'number', title: "Verify cap", description: "Items shown in Verify before \"+N more\"", default: 7 },
22 needsYouCap: { kind: 'number', title: "Needs you cap", description: "Items shown in needs you before \"+N more\"", default: 7 },
23 confirmCap: { kind: 'number', title: "Confirm cap", description: "Items shown in confirm before \"+N more\"", default: 7 },
24 questionsCap: { kind: 'number', title: "Questions cap", description: "Items shown in questions before \"+N more\"", default: 7 },
25 requestsCap: { kind: 'number', title: "Requests cap", description: "Items shown in requests before \"+N more\"", default: 7 },
26 emoji: { kind: 'boolean', title: "Emoji", description: "Master switch for every emoji the docket draws", default: true },
27 headingEmoji: { kind: 'boolean', title: "Heading emoji", description: "An emoji before each list's heading", default: false },
28 noItemEmojiLists: { kind: 'text', title: "Lists without item emoji", description: "Letters of lists whose items draw no emoji, e.g. \"Q,P\"", default: "" },
29 inlineLists: { kind: 'text', title: "Inline lists", description: "Letters of lists drawn on one line, separated by dots", default: "P" },
30 targetWords: { kind: 'text', title: "Target lengths", description: "Soft word limits per list, e.g. \"P=8,Q=30\"", default: "P=8" },
31 stakesDisplay: { kind: 'choice', title: "Stakes display", description: "How stakes markers show", default: "emoji", options: ["emoji", "text", "none"] },
32 itemColor: { kind: 'text', title: "Item color", description: "Color of an item changed this turn, a hex or a theme key", default: "#e0e0e0" },
33 itemDimColor: { kind: 'text', title: "Dimmed item color", description: "Color of an item unchanged since the last turn", default: "#939393" },
34 upArrowColor: { kind: 'text', title: "Growing arrow", description: "Color of the ↑ when a list grew this turn", default: "#5fb878" },
35 upNumberColor: { kind: 'text', title: "Growing number", description: "Color of the count beside a growing list's ↑", default: "#7c9a84" },
36 downArrowColor: { kind: 'text', title: "Shrinking arrow", description: "Color of the ↓ when a list shrank this turn", default: "#a885e6" },
37 downNumberColor: { kind: 'text', title: "Shrinking number", description: "Color of the count beside a shrinking list's ↓", default: "#8f83a8" },
38 leanColor: { kind: 'text', title: "Lean color", description: "Color of the lean line, a hex or a theme key; empty for the item's own color", default: "#b1b1b1" },
39 leanDimColor: { kind: 'text', title: "Dimmed lean color", description: "Color of the lean line while its item is dimmed as unchanged", default: "#757575" },
40 answeredLingerSeconds: { kind: 'number', title: "Answered linger", description: "Seconds an item answered by button stays up, dimmed, before it goes", default: 20 },
41 strikeLingerMinutes: { kind: 'number', title: "Strike linger", description: "Minutes an item Claude closed on its own stays up struck through", default: 3 },
42}
43
44// What each version added, for the note a migration leaves in View.
45export const MIGRATIONS: Record<number, string> = {
46 1: 'the first versioned config',
47 2: 'adds language',
48 3: 'adds voice',
49 4: 'adds turnReview',
50 5: 'renames viewCap to verifyCap',
51 6: 'adds updateCheck',
52}
53
54export type ConfigFile = { version: number } & Record<string, string | number | boolean>
55
56export const defaults = (): ConfigFile => {
57 const config: ConfigFile = { version: CONFIG_VERSION }
58 for (const [key, field] of Object.entries(FIELDS)) {
59 config[key] = field.default
60 }
61
62 return config
63}
64
65// Keys a version renamed, old name to new: a migration carries the value across and drops the old key.
66export const RENAMES: Record<string, string> = { viewCap: 'verifyCap' }
67
68export type Migration = { config: ConfigFile; from: number; added: string[]; isChanged: boolean }
69
70// Brings a config read from disk up to date: every missing key gets its default, unknown keys are
71// kept, and the version is raised. A file that isn't an object starts over from the defaults.
72// A project's file holds only what it overrides, so it's raised in version but never filled in.
73export const migrate = (raw: unknown, isPartial = false): Migration => {
74 const found = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
75 const from = typeof found.version === 'number' ? found.version : 0
76 const config: ConfigFile = { ...(found as ConfigFile), version: Math.max(from, CONFIG_VERSION) }
77 let isRenamed = false
78 for (const [old, now] of Object.entries(RENAMES)) {
79 if (!(old in config)) continue
80 if (!(now in config)) config[now] = config[old] as string | number | boolean
81 delete config[old]
82 isRenamed = true
83 }
84 const added: string[] = []
85 for (const [key, field] of Object.entries(FIELDS)) {
86 if (!(key in config) && !isPartial) {
87 config[key] = field.default
88 added.push(key)
89 }
90 }
91
92 return { config, from, added, isChanged: added.length > 0 || isRenamed || from < CONFIG_VERSION }
93}
94
95// The settings in force: the project's keys over the user's, both over the defaults.
96export const merged = (user?: ConfigFile, project?: ConfigFile): PluginOptions => {
97 const options: Record<string, string | number | boolean> = {}
98 for (const [key, field] of Object.entries(FIELDS)) {
99 options[key] = project?.[key] ?? user?.[key] ?? field.default
100 }
101
102 return options
103}
104
105// The file as written: version first, then every field in order, then any keys it kept.
106export const serialize = (config: ConfigFile) => {
107 const ordered: Record<string, unknown> = { version: config.version }
108 for (const key of Object.keys(FIELDS)) ordered[key] = config[key]
109 for (const [key, value] of Object.entries(config)) if (!(key in ordered)) ordered[key] = value
110
111 return `${JSON.stringify(ordered, null, 2)}
112`
113}
114
115// Parses a config file's text; a file that doesn't parse reads as missing.
116export const parse = (text: string | undefined): unknown => {
117 if (text === undefined) return undefined
118 try {
119 return JSON.parse(text)
120 } catch {
121 return undefined
122 }
123}
124hooks/i18n.ts 368 lines1// docket's own words, per language. English is here in code; every other language is a file,
2// locales/<code>.json, mapping each English string to its translation, so translators edit JSON beside
3// the English they translate. Each translation, and the file as a whole, says how ready it is: false
4// (not translated; English shows), "ai translated", or true (checked by a person).
5// Each list's letter, which labels its items (Q12, P12, 質12), is the first character of its name in
6// that language, so names are chosen to start with letters unique within the language.
7import type { Item, Letter } from '../types'
8import { codeOf, normalCode } from './languages.ts'
9
10// A language's code, as its file in locales/ is named: "es", "pt-BR".
11export type Language = string
12
13export type Strings = {
14 language: string
15 lists: Record<Letter, { name: string; letter: string }>
16 lean: string
17 new: string
18 more: string
19 fewer: string
20 nothing: string
21 // Hover text on an item's label, which copies the item.
22 copyItem: string
23 // The switch for the once-a-turn review, at the pane's bottom left.
24 deepDive: string
25 bulk: { Q: string; C: string; R: string }
26 send: {
27 acceptLean: string
28 rejectLean: string
29 pick: string
30 approve: string
31 decline: string
32 confirm: string
33 notConfirm: string
34 start: string
35 acceptLeans: string
36 confirmAll: string
37 approveAll: string
38 }
39 settings: {
40 title: string
41 done: string
42 editingYours: string
43 editingProject: string
44 copyToProject: string
45 mobile: string
46 capTitle: string
47 capDescription: string
48 noVoice: string
49 about: string
50 capsHeading: string
51 }
52 // Said in Verify when GitHub has a newer docket.
53 update: string
54 config: { offer: string; lookAt: string; updated: string; updatedAdding: string; unreadable: string }
55 // Offers to help with a language docket speaks: machine-translated, or behind English.
56 translate: { machine: string; behind: string }
57 // The yes and no buttons. Never translated: only a dialect changes them, with text and perhaps an
58 // image's file name, which the mod reads into the SVG's source where a surface draws SVG.
59 marks: { yes: string; no: string; yesImage?: string; noImage?: string }
60 fields: Record<string, [string, string]>
61}
62
63export const EN: Strings = {
64 language: 'English',
65 lists: {
66 R: { name: 'Requests', letter: 'R' },
67 Q: { name: 'Questions', letter: 'Q' },
68 C: { name: 'Confirm', letter: 'C' },
69 N: { name: 'Needs you', letter: 'N' },
70 V: { name: 'Verify', letter: 'V' },
71 U: { name: 'Underway', letter: 'U' },
72 L: { name: 'Later', letter: 'L' },
73 W: { name: 'Waiting', letter: 'W' },
74 P: { name: 'Pinned', letter: 'P' },
75 },
76 lean: 'Lean',
77 new: '(new)',
78 more: '+{n} more',
79 fewer: 'fewer',
80 nothing: 'Nothing on the docket.',
81 copyItem: 'Copy this item',
82 deepDive: 'Deep dive',
83 bulk: { Q: 'Accept all leans', C: 'Confirm all', R: 'Approve all' },
84 send: {
85 acceptLean: 'Accept the lean on {id}.',
86 rejectLean: 'No to the lean on {id}.',
87 pick: '{id} ({option}).',
88 approve: 'Approved {id}.',
89 decline: 'Declined {id}.',
90 confirm: 'Confirmed {id}.',
91 notConfirm: 'Not confirmed {id}.',
92 start: 'Start {id} now.',
93 acceptLeans: 'Accept the leans on {ids}.',
94 confirmAll: 'Confirmed {ids}.',
95 approveAll: 'Approved {ids}.',
96 },
97 settings: {
98 title: 'Settings',
99 done: 'Done',
100 editingYours: 'Editing your config:',
101 editingProject: 'Editing this project’s config, which overrides yours:',
102 copyToProject: 'Make a copy for this project',
103 mobile: 'Settings need the terminal, desktop or VS Code.',
104 capTitle: '{list} cap',
105 capDescription: 'Items shown in {list} before "+N more"',
106 noVoice: 'No voice',
107 about: 'Docket 📋 version {version}, built {date}',
108 capsHeading: 'List caps by item count',
109 },
110 update: "docket {latest} is out; you're using {current}. Update with /plugin, or run claude plugin update docket@docket, then restart.",
111 config: {
112 offer: "Create docket's config at {path}, with every setting at its default? You can edit it there, or with ⚙.",
113 lookAt: 'Look at your new config: {path}. Every setting is there at its default.',
114 updated: 'docket updated its config at {path} from version {from} to {to}.',
115 updatedAdding: 'docket updated its config at {path} from version {from} to {to}, adding {added} at their defaults.',
116 unreadable: "docket couldn't read its config at {path}, so it's using the defaults until the file is fixed.",
117 },
118 translate: {
119 machine:
120 `docket's {language} was machine-translated. Help make it read naturally? ✔ opens [its file]({link}) in GitHub's editor: fix any string, set its "ready" to true, and save to propose it.`,
121 behind:
122 `docket has new English its {language} doesn't translate yet. Help [bring it up to date]({link})? ✔ opens the file in GitHub's editor: fill in each empty "text", and save to propose it.`,
123 },
124 marks: { yes: '✔', no: '✗' },
125 fields: {
126 language: ['Language', "Language of the docket's own words: auto follows Claude Code's language setting"],
127 voice: ['Voice', "A character Claude writes the docket's items as, in any language"],
128 updateCheck: ['Update check', 'Once a day, look on GitHub for a newer docket and say so in Verify'],
129 turnReview: ['Deep dive', 'Whether the Deep dive switch starts on: before a turn ends, Claude goes back over it once for decisions it made for you and threads it left open'],
130 display: ['Display', 'A pane beside the transcript, or a band above the prompt'],
131 emoji: ['Emoji', 'Master switch for every emoji the docket draws'],
132 headingEmoji: ['Heading emoji', "An emoji before each list's heading"],
133 noItemEmojiLists: ['Lists without item emoji', 'Letters of lists whose items draw no emoji, e.g. "Q,P"'],
134 inlineLists: ['Inline lists', 'Letters of lists drawn on one line, separated by dots'],
135 targetWords: ['Target lengths', 'Soft word limits per list, e.g. "P=8,Q=30"'],
136 stakesDisplay: ['Stakes display', 'How stakes markers show'],
137 itemColor: ['Item color', 'Color of an item changed this turn, a hex or a theme key'],
138 itemDimColor: ['Dimmed item color', 'Color of an item unchanged since the last turn'],
139 upArrowColor: ['Growing arrow', 'Color of the ↑ when a list grew this turn'],
140 upNumberColor: ['Growing number', "Color of the count beside a growing list's ↑"],
141 downArrowColor: ['Shrinking arrow', 'Color of the ↓ when a list shrank this turn'],
142 downNumberColor: ['Shrinking number', "Color of the count beside a shrinking list's ↓"],
143 leanColor: ['Lean color', "Color of the lean line, a hex or a theme key; empty for the item's own color"],
144 leanDimColor: ['Dimmed lean color', 'Color of the lean line while its item is dimmed as unchanged'],
145 answeredLingerSeconds: ['Answered linger', 'Seconds an item answered by button stays up, dimmed, before it goes'],
146 strikeLingerMinutes: ['Strike linger', 'Minutes an item Claude closed on its own stays up struck through'],
147 },
148}
149
150// Fills {name} slots in a template.
151export const fill = (template: string, values: Record<string, string | number>) =>
152 template.replace(/\{(\w+)\}/g, (whole, name: string) => (name in values ? String(values[name]) : whole))
153
154const isRecord = (value: unknown): value is Record<string, unknown> =>
155 Boolean(value) && typeof value === 'object' && !Array.isArray(value)
156
157// Every string in a set of words, with where it sits; the language's name and the letters aren't
158// translated, as they come from the language table and the list names.
159const leaves = (value: unknown, at = ''): [string, string][] =>
160 typeof value === 'string'
161 ? at === 'language' || at.endsWith('.letter') || at.startsWith('marks.')
162 ? []
163 : [[at, value]]
164 : Array.isArray(value)
165 ? value.flatMap((each, i) => leaves(each, `${at}.${i}`))
166 : isRecord(value)
167 ? Object.entries(value).flatMap(([key, each]) => leaves(each, at ? `${at}.${key}` : key))
168 : []
169
170// The English strings a language file translates, each once, in the order docket uses them.
171export const keysOf = () => [...new Set(leaves(EN).map(([, english]) => english))]
172
173// Raise this whenever English's strings change, then run `npm run locales`: each file gains the new
174// strings, blank, and loses the ones English no longer says. A file's own version says which of
175// these it was last brought up to date with, so docket can ask for help when it falls behind.
176export const STRINGS_VERSION = 6
177
178export type Readiness = boolean | 'ai translated'
179export const AI = 'ai translated'
180export type Translation = { text: string; ready: Readiness }
181
182const readinessOf = (value: unknown): Readiness => (value === true || value === AI ? value : false)
183
184// What a language file says about itself, beside its strings.
185export type FileState = { ready: Readiness; version: number }
186
187export const stateOf = (file: unknown): FileState => {
188 const found = isRecord(file) ? file : {}
189
190 return { ready: readinessOf(found.ready), version: typeof found.version === 'number' ? found.version : 0 }
191}
192
193// One string's translation as a file holds it; a bare string counts as machine-made.
194export const translationOf = (value: unknown): Translation =>
195 typeof value === 'string'
196 ? { text: value, ready: value.trim() ? AI : false }
197 : isRecord(value)
198 ? { text: typeof value.text === 'string' ? value.text : '', ready: readinessOf(value.ready) }
199 : { text: '', ready: false }
200
201// A language's file brought in line with English: its state first, then every English string in
202// docket's order, translated where it was, blank where not. Strings English no longer says are dropped.
203// A dialect's file also keeps its name, which is what Claude is told the docket is written in, and
204// any buttons of its own; any file keeps its voices.
205export const synced = (file: unknown) => {
206 const found = isRecord(file) ? file : {}
207 const { ready, version } = stateOf(found)
208 const words = Object.fromEntries(keysOf().map(key => [key, translationOf(found[key])]))
209 const kept = Object.fromEntries(
210 (['name', 'tag', 'cast', 'marks', 'voice', 'voices'] as const).filter(key => found[key] !== undefined).map(key => [key, found[key]]),
211 )
212
213 return `${JSON.stringify({ ...kept, ready, version, ...words }, null, 2)}
214`
215}
216
217// How Claude writes the docket's items, if a file or the config says: a voice for everything, and
218// one per list. A file names lists by their English names ("Verify") or letters ("V"); later files
219// win, list by list.
220export type Voice = { all: string; lists: Partial<Record<Letter, string>> }
221
222export const voiceOf = (...files: unknown[]): Voice => {
223 const voice: Voice = { all: '', lists: {} }
224 for (const file of files) {
225 if (!isRecord(file)) continue
226 if (typeof file.voice === 'string' && file.voice.trim()) voice.all = file.voice.trim()
227 if (!isRecord(file.voices)) continue
228 for (const [key, said] of Object.entries(file.voices)) {
229 const letter = (Object.keys(EN.lists) as Letter[]).find(each => each === key || EN.lists[each].name === key)
230 if (letter && typeof said === 'string' && said.trim()) voice.lists[letter] = said.trim()
231 }
232 }
233
234 return voice
235}
236
237// A name's first character, as a capital where the script has one in common use. Georgian's capitals
238// (Mtavruli) are rare in fonts, so Georgian keeps its usual letters.
239export const firstLetter = (name: string) => {
240 const first = [...name.trim()][0] ?? ''
241 const upper = first.toUpperCase()
242
243 return /[\u1C90-\u1CBF]/u.test(upper) ? first : upper
244}
245
246// A dialect's own button, as text or { text, image }, or the usual one.
247const markOf = (marks: unknown, key: string, usual: string) => {
248 const mark = isRecord(marks) ? marks[key] : undefined
249 const text = isRecord(mark) ? mark.text : mark
250
251 return typeof text === 'string' && text.trim() ? text : usual
252}
253
254// The file a dialect's button draws where it can, if it names one: an .svg beside the dialect.
255export const markImage = (file: unknown, key: 'yes' | 'no') => {
256 const marks = isRecord(file) ? file.marks : undefined
257 const mark = isRecord(marks) ? marks[key] : undefined
258 const image = isRecord(mark) ? mark.image : undefined
259
260 return typeof image === 'string' && /^[\w.-]+\.svg$/.test(image) ? image : undefined
261}
262
263// docket's words in a language, from its file: each translated string over the English. Each list's
264// letter is its translated name's first character; if two lists would share one, every list keeps
265// its English letter, so no label is ever ambiguous.
266export const translate = (file: unknown, language: string): Strings => {
267 const words = isRecord(file) ? file : {}
268 const say = (english: string) => {
269 const { text, ready } = translationOf(words[english])
270
271 return ready !== false && text.trim() ? text : english
272 }
273 const walk = (value: unknown, at: string): unknown =>
274 typeof value === 'string'
275 ? at === 'language'
276 ? language
277 : at.endsWith('.letter')
278 ? value
279 : at.startsWith('marks.')
280 ? markOf(words.marks, at.slice('marks.'.length), value)
281 : say(value)
282 : Array.isArray(value)
283 ? value.map((each, i) => walk(each, `${at}.${i}`))
284 : isRecord(value)
285 ? Object.fromEntries(Object.entries(value).map(([key, each]) => [key, walk(each, at ? `${at}.${key}` : key)]))
286 : value
287 const text = walk(EN, '') as Strings
288 const letters = (Object.keys(text.lists) as Letter[]).map(letter => [letter, firstLetter(text.lists[letter].name)] as const)
289 if (new Set(letters.map(([, each]) => each)).size === letters.length && letters.every(([, each]) => each)) {
290 for (const [letter, each] of letters) text.lists[letter].letter = each
291 }
292
293 return text
294}
295
296// docket's setting wins, as a language's name or code, or any code with a file in locales/; "auto" or
297// nothing follows Claude Code's language, and English is the fallback. The code may have no file yet.
298// "auto" takes the first of these hints that names a language: Claude Code's language setting, then
299// the editor's display language, then the terminal's locale.
300export const resolveLanguage = (setting: unknown, hints: unknown, available: readonly string[] = []): Language => {
301 const own = setting && setting !== 'auto' ? String(setting) : undefined
302 const code = own && normalCode(own)
303 const named = own && (codeOf(own) ?? (code && available.includes(code) ? code : undefined))
304
305 return named || (Array.isArray(hints) ? hints : [hints]).map(codeOf).find(Boolean) || 'en'
306}
307
308// A language from a locale variable: VS Code's VSCODE_NLS_CONFIG (its display language, userLocale), or
309// a POSIX locale such as LANG's "de_DE.UTF-8". "C" and "POSIX" name none.
310export const localeOf = (value: unknown) => {
311 const text = String(value ?? '').trim()
312 if (text.startsWith('{')) {
313 try {
314 const config = JSON.parse(text) as { userLocale?: unknown; locale?: unknown }
315
316 return String(config.userLocale ?? config.locale ?? '') || undefined
317 } catch {
318 return undefined
319 }
320 }
321 const [locale = ''] = text.split(/[.@:]/)
322
323 return locale && locale !== 'C' && locale !== 'POSIX' ? locale : undefined
324}
325
326// An item's label as the user sees it: the language's letter for its list, then its number.
327export const labelOf = (text: Strings, item: Pick<Item, 'list' | 'n'>) => `${text.lists[item.list].letter}${item.n}`
328
329// An internal id ("Q12") as the user sees it.
330export const labelOfId = (text: Strings, id: string) => {
331 const list = text.lists[id[0] as Letter]
332
333 return list ? `${list.letter}${id.slice(1)}` : id
334}
335
336// The internal letter for a letter the user typed in this language, if any.
337// A letter without its accents, in lower case: how someone without the accent on their keyboard types it.
338export const foldLetter = (letter: string) => letter.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
339
340// The letters a language's labels can be typed with: each list's own, and its unaccented form where
341// no other list's letter folds to the same (İ as i in Turkish; but Czech's Ž and Z stay apart).
342export const typedLetters = (text: Strings) => {
343 const own = Object.values(text.lists).map(list => list.letter)
344 const folded = own.map(foldLetter)
345 const isUnique = (each: string) => folded.filter(other => other === each).length === 1
346
347 return [...new Set([...own, ...folded.filter(isUnique)])]
348}
349
350// The internal letter for a letter the user typed in this language, if any: as written, else unaccented.
351export const letterFor = (text: Strings, typed: string): Letter | undefined => {
352 const letters = Object.keys(text.lists) as Letter[]
353 const exact = letters.find(letter => text.lists[letter].letter.toLowerCase() === typed.toLowerCase())
354 if (exact) return exact
355 const loose = letters.filter(letter => foldLetter(text.lists[letter].letter) === foldLetter(typed))
356
357 return loose.length === 1 ? loose[0] : undefined
358}
359
360// An id the user or Claude wrote in this language ("P12" in Spanish) as the internal id ("Q12"), or
361// undefined when no list of this language has its letter.
362export const idFor = (text: Strings, label: string) => {
363 const [, typed = '', n = ''] = /^(.+?)(\d+)$/u.exec(label.trim()) ?? []
364 const letter = letterFor(text, typed)
365
366 return letter ? `${letter}${Number(n)}` : undefined
367}
368hooks/languages.ts 184 lines1// The languages docket can recognize, whether or not it has a file for them yet: each code with its
2// English name first, then the other names Claude Code's free-form language setting may use.
3// A language docket has no file for gets English, and an offer to help translate.
4export const NAMES: Record<string, readonly string[]> = {
5 en: ['English'],
6 es: ['Spanish', 'Español', 'Espanol', 'Castellano'],
7 fr: ['French', 'Français', 'Francais'],
8 de: ['German', 'Deutsch'],
9 it: ['Italian', 'Italiano'],
10 'pt-BR': ['Brazilian Portuguese', 'Portuguese (Brazil)', 'Português do Brasil', 'Português brasileiro'],
11 pt: ['Portuguese', 'Português', 'Portugues'],
12 nl: ['Dutch', 'Nederlands'],
13 sv: ['Swedish', 'Svenska'],
14 no: ['Norwegian', 'Norsk', 'Bokmål'],
15 da: ['Danish', 'Dansk'],
16 fi: ['Finnish', 'Suomi'],
17 is: ['Icelandic', 'Íslenska'],
18 pl: ['Polish', 'Polski'],
19 cs: ['Czech', 'Čeština'],
20 sk: ['Slovak', 'Slovenčina'],
21 hu: ['Hungarian', 'Magyar'],
22 ro: ['Romanian', 'Română'],
23 bg: ['Bulgarian', 'Български'],
24 ru: ['Russian', 'Русский'],
25 uk: ['Ukrainian', 'Українська'],
26 el: ['Greek', 'Ελληνικά'],
27 tr: ['Turkish', 'Türkçe'],
28 ar: ['Arabic', 'العربية'],
29 he: ['Hebrew', 'עברית'],
30 fa: ['Persian', 'Farsi', 'فارسی'],
31 hi: ['Hindi', 'हिन्दी'],
32 bn: ['Bengali', 'Bangla', 'বাংলা'],
33 ur: ['Urdu', 'اردو'],
34 ta: ['Tamil', 'தமிழ்'],
35 th: ['Thai', 'ไทย'],
36 vi: ['Vietnamese', 'Tiếng Việt'],
37 id: ['Indonesian', 'Bahasa Indonesia'],
38 ms: ['Malay', 'Bahasa Melayu'],
39 tl: ['Filipino', 'Tagalog'],
40 'zh-TW': ['Traditional Chinese', 'Chinese (Traditional)', '繁體中文', '繁体中文'],
41 zh: ['Chinese', 'Simplified Chinese', 'Chinese (Simplified)', 'Mandarin', '简体中文', '中文'],
42 ja: ['Japanese', '日本語'],
43 ko: ['Korean', '한국어'],
44 ca: ['Catalan', 'Català'],
45 eu: ['Basque', 'Euskara'],
46 gl: ['Galician', 'Galego'],
47 hr: ['Croatian', 'Hrvatski'],
48 sr: ['Serbian', 'Српски', 'Srpski'],
49 sl: ['Slovenian', 'Slovenščina'],
50 lt: ['Lithuanian', 'Lietuvių'],
51 lv: ['Latvian', 'Latviešu'],
52 et: ['Estonian', 'Eesti'],
53 sw: ['Swahili', 'Kiswahili'],
54 af: ['Afrikaans'],
55 cy: ['Welsh', 'Cymraeg'],
56 ga: ['Irish', 'Gaeilge'],
57 eo: ['Esperanto'],
58 sq: ['Albanian', 'Shqip'],
59 hy: ['Armenian', 'Հայերեն'],
60 az: ['Azerbaijani', 'Azərbaycan dili'],
61 be: ['Belarusian', 'Беларуская'],
62 bs: ['Bosnian', 'Bosanski'],
63 mk: ['Macedonian', 'Македонски'],
64 mt: ['Maltese', 'Malti'],
65 lb: ['Luxembourgish', 'Lëtzebuergesch'],
66 fo: ['Faroese', 'Føroyskt'],
67 nn: ['Norwegian Nynorsk', 'Nynorsk'],
68 gd: ['Scottish Gaelic', 'Gàidhlig'],
69 br: ['Breton', 'Brezhoneg'],
70 fy: ['Frisian', 'Frysk', 'West Frisian'],
71 co: ['Corsican', 'Corsu'],
72 oc: ['Occitan', 'Occitan'],
73 rm: ['Romansh', 'Rumantsch'],
74 ast: ['Asturian', 'Asturianu'],
75 'fr-CA': ['Canadian French', 'French (Canada)', 'Français canadien'],
76 la: ['Latin', 'Latina'],
77 yi: ['Yiddish', 'ייִדיש'],
78 ka: ['Georgian', 'ქართული'],
79 kk: ['Kazakh', 'Қазақ тілі'],
80 ky: ['Kyrgyz', 'Кыргызча'],
81 uz: ['Uzbek', 'Oʻzbek'],
82 tg: ['Tajik', 'Тоҷикӣ'],
83 tk: ['Turkmen', 'Türkmençe'],
84 mn: ['Mongolian', 'Монгол'],
85 tt: ['Tatar', 'Татар'],
86 ba: ['Bashkir', 'Башҡорт'],
87 ug: ['Uyghur', 'ئۇيغۇرچە'],
88 bo: ['Tibetan', 'བོད་ཡིག'],
89 ku: ['Kurdish', 'Kurdî', 'Kurmanji'],
90 ckb: ['Central Kurdish', 'Sorani', 'کوردی'],
91 ps: ['Pashto', 'پښتو'],
92 sd: ['Sindhi', 'سنڌي'],
93 ne: ['Nepali', 'नेपाली'],
94 si: ['Sinhala', 'සිංහල'],
95 mr: ['Marathi', 'मराठी'],
96 gu: ['Gujarati', 'ગુજરાતી'],
97 pa: ['Punjabi', 'ਪੰਜਾਬੀ'],
98 kn: ['Kannada', 'ಕನ್ನಡ'],
99 ml: ['Malayalam', 'മലയാളം'],
100 te: ['Telugu', 'తెలుగు'],
101 or: ['Odia', 'ଓଡ଼ିଆ', 'Oriya'],
102 as: ['Assamese', 'অসমীয়া'],
103 sa: ['Sanskrit', 'संस्कृतम्'],
104 my: ['Burmese', 'မြန်မာ', 'Myanmar'],
105 km: ['Khmer', 'ខ្មែរ', 'Cambodian'],
106 lo: ['Lao', 'ລາວ'],
107 yue: ['Cantonese', '粵語', '廣東話'],
108 jv: ['Javanese', 'Basa Jawa'],
109 su: ['Sundanese', 'Basa Sunda'],
110 ceb: ['Cebuano', 'Sinugbuanon'],
111 mi: ['Māori', 'Te Reo Māori', 'Maori'],
112 haw: ['Hawaiian', 'ʻŌlelo Hawaiʻi'],
113 sm: ['Samoan', 'Gagana Sāmoa'],
114 to: ['Tongan', 'Lea faka-Tonga'],
115 fj: ['Fijian', 'Na Vosa Vakaviti'],
116 am: ['Amharic', 'አማርኛ'],
117 ti: ['Tigrinya', 'ትግርኛ'],
118 om: ['Oromo', 'Afaan Oromoo'],
119 so: ['Somali', 'Soomaali'],
120 ha: ['Hausa', 'Harshen Hausa'],
121 yo: ['Yoruba', 'Yorùbá'],
122 ig: ['Igbo', 'Asụsụ Igbo'],
123 zu: ['Zulu', 'isiZulu'],
124 xh: ['Xhosa', 'isiXhosa'],
125 st: ['Sesotho', 'Sotho'],
126 tn: ['Tswana', 'Setswana'],
127 sn: ['Shona', 'chiShona'],
128 ny: ['Chichewa', 'Chinyanja', 'Nyanja'],
129 rw: ['Kinyarwanda', 'Ikinyarwanda'],
130 mg: ['Malagasy', 'Fiteny malagasy'],
131 lg: ['Ganda', 'Luganda'],
132 ln: ['Lingala', 'Lingála'],
133 wo: ['Wolof', 'Wolof'],
134 ff: ['Fula', 'Fulfulde', 'Pulaar'],
135 ak: ['Akan', 'Twi'],
136 ee: ['Ewe', 'Eʋegbe'],
137 ht: ['Haitian Creole', 'Kreyòl ayisyen'],
138 qu: ['Quechua', 'Runasimi'],
139 gn: ['Guarani', 'Avañeʼẽ'],
140 ay: ['Aymara', 'Aymar aru'],
141 tlh: ['Klingon', 'tlhIngan Hol'],
142}
143
144// A code as a language file is named: "pt_br" and "PT-BR" are both pt-BR, "es-419" a region by
145// number, and "zh-hant" the script zh-Hant.
146const CODE = /^([a-z]{2,3})(?:[-_]([a-z]{2,4}|\d{3}))?$/i
147
148export const normalCode = (text: string) => {
149 const [, base = '', region] = CODE.exec(text.trim()) ?? []
150
151 const tidy = (part: string) =>
152 part.length === 2 ? part.toUpperCase() : part.length === 4 ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() : part
153
154 return base ? (region ? `${base.toLowerCase()}-${tidy(region)}` : base.toLowerCase()) : undefined
155}
156
157const fold = (text: string) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim()
158
159// The code a language setting names: a code, or any of a language's names, alone or leading the
160// setting ("japanese (日本語)"). Earlier entries win, so Brazilian Portuguese is checked before Portuguese.
161export const codeOf = (name: unknown): string | undefined => {
162 const text = fold(String(name ?? ''))
163 if (!text) return undefined
164 const entries = Object.entries(NAMES)
165 const named = (test: (alias: string) => boolean) => entries.find(([, names]) => names.some(alias => test(fold(alias))))?.[0]
166 const code = normalCode(text)
167 // Chinese by script: Traditional is docket's zh-TW, Simplified its zh.
168 if (code?.startsWith('zh-Hant')) return 'zh-TW'
169 if (code?.startsWith('zh-Hans')) return 'zh'
170
171 return (
172 named(alias => alias === text) ??
173 (code && (code in NAMES || (code.split('-')[0] ?? '') in NAMES) ? code : undefined) ??
174 named(alias => text.startsWith(alias) && !/\p{L}/u.test(text.charAt(alias.length)))
175 )
176}
177
178// A code's English name, for Claude and for the offer to translate; an unlisted code is its own name.
179// A regional code without a name of its own takes its language's: "es-ES" is Spanish.
180export const englishName = (code: string) => NAMES[code]?.[0] ?? NAMES[code.split('-')[0] ?? '']?.[0] ?? code
181
182// A code's name in its own language, for asking its speakers; English where the table has no other.
183export const nativeName = (code: string) => NAMES[code]?.[1] ?? englishName(code)
184hooks/editor.ts 45 lines1// Which editor to open a file in: the one hosting the session, found from where it's installed, not
2// whatever answers to `code` on PATH (another editor's installer may have put its own `code` there).
3// VS Code and its forks tell the processes they start where their app lives, in VSCODE_NLS_CONFIG.
4
5// The editor's app folder, ".../resources/app", from VSCODE_NLS_CONFIG's messages file.
6export const appDirOf = (nlsConfig: unknown) => {
7 try {
8 const { defaultMessagesFile } = JSON.parse(String(nlsConfig ?? '')) as { defaultMessagesFile?: unknown }
9 const path = String(defaultMessagesFile ?? '').replace(/\\/g, '/')
10 // macOS spells it Resources.
11 const at = path.toLowerCase().lastIndexOf('/resources/app/')
12
13 return at < 0 ? undefined : path.slice(0, at + '/resources/app'.length)
14 } catch {
15 return undefined
16 }
17}
18
19const parentOf = (path: string) => path.slice(0, Math.max(0, path.lastIndexOf('/')))
20
21// The folders an editor's executable may sit in, nearest first: beside resources/ (the old Windows
22// layout, and Linux), and one up from a versioned folder (the new Windows layout, <root>/<commit>/).
23export const exeDirsOf = (appDir: string) => {
24 const resources = parentOf(appDir)
25 const beside = parentOf(resources)
26
27 return [beside, parentOf(beside)].filter(Boolean)
28}
29
30// The editor's own executable among a folder's files: not its uninstaller, tunnel or staged update.
31export const exeIn = (names: readonly string[]) =>
32 names.find(name => /\.exe$/i.test(name) && !/^(unins|new_)/i.test(name) && !/tunnel/i.test(name))
33
34// The command that opens a file in that editor, reusing its window, as its own `code` script does:
35// the executable running its CLI as Node.
36export const launchOf = (exe: string, appDir: string, path: string) => ({
37 argv: [exe, `${appDir}/out/cli.js`, '-r', path],
38 env: { ELECTRON_RUN_AS_NODE: '1' },
39})
40
41// On macOS and Linux, the editor's own launcher script: in app/bin (macOS) or <root>/bin (Linux).
42export const scriptDirsOf = (appDir: string) => [`${appDir}/bin`, `${parentOf(parentOf(appDir))}/bin`]
43
44export const scriptIn = (names: readonly string[]) => names.find(name => !/[.]/.test(name) && !/tunnel/i.test(name))
45hooks/release.ts 3 lines1// Written by `npm run release`: the day this version was cut, shown in the settings view.
2export const BUILT = '2026-10-23'
3hooks/picker.ts 182 lines1// The language picker in the ⚙ view: three pulldowns, as a pulldown holds at most 64 choices and docket
2// knows more languages than that. A group of initials, then a language in it, then its variant: a
3// region with a file of its own, or a dialect. Every name is in the language docket speaks.
4import { NAMES, englishName, nativeName } from './languages.ts'
5
6export type Choice = { value: string; label: string }
7export type Group = Choice & { codes: string[] }
8export type Dialect = { tag: string; name: string; cast?: string }
9
10// A language's name in another, from the runtime's own names where it has them, else the table's.
11export const nameIn = (speaking: string, code: string) => {
12 try {
13 const named = new Intl.DisplayNames([speaking], { type: 'language' }).of(code)
14 // A runtime without names for a language answers in English, or with the code itself.
15 const isMissing = speaking !== 'en' && speaking.split('-')[0] !== 'en' && named === englishName(code)
16 if (named && named.toLowerCase() !== code.toLowerCase() && !isMissing) return named
17 } catch {
18 // No names for that language here.
19 }
20
21 return speaking === code ? nativeName(code) : englishName(code)
22}
23
24// "Französisch (français)": the name in the language docket speaks, then the language's own, when they differ.
25export const labelFor = (speaking: string, code: string) => {
26 const own = nameIn(speaking, code)
27 const native = nameIn(code, code)
28
29 return native.toLowerCase() === own.toLowerCase() ? own : `${own} (${native})`
30}
31
32const initial = (label: string) => ([...label.normalize('NFD').replace(/\p{M}/gu, '')][0] ?? '').toUpperCase()
33
34// The languages docket knows by their base code ("pt", not "pt-BR"), with any that only have a file.
35export const basesOf = (files: readonly string[] = []) =>
36 [...new Set([...Object.keys(NAMES), ...files].map(code => code.split('-')[0] ?? code))].filter(Boolean)
37
38// The most a pulldown holds is 64; the language pulldown keeps a little under.
39export const MOST = 60
40
41// About `count` groups of about the same size, sorted as the language docket speaks sorts, never
42// splitting an initial between two groups unless the initial alone is more than a pulldown holds.
43// Each is labelled by its first and last initials ("A–D"), or two letters where an initial is split.
44export const groupsOf = (speaking: string, codes: readonly string[], count = 6): Group[] => {
45 const labelled = codes.map(code => ({ code, label: labelFor(speaking, code) }))
46 labelled.sort((a, b) => a.label.localeCompare(b.label, speaking))
47 const prefix = (label: string, length: number) => [...label.normalize('NFD').replace(/\p{M}/gu, '')].slice(0, length).join('')
48 const buckets: { first: string; last: string; codes: string[] }[] = []
49 for (const { code, label } of labelled) {
50 const first = initial(label)
51 const last = buckets[buckets.length - 1]
52 if (last?.first === first) last.codes.push(code)
53 else buckets.push({ first, last: first, codes: [code] })
54 }
55 const named = new Map(labelled.map(({ code, label }) => [code, label]))
56 const pieces = buckets.flatMap(bucket => {
57 if (bucket.codes.length <= MOST) return [bucket]
58 const size = Math.ceil(bucket.codes.length / Math.ceil(bucket.codes.length / MOST))
59 const out = []
60 for (let i = 0; i < bucket.codes.length; i += size) {
61 const slice = bucket.codes.slice(i, i + size)
62 const two = (code: string | undefined) => {
63 const word = prefix(named.get(code ?? '') ?? '', 2)
64
65 return word.charAt(0).toUpperCase() + word.slice(1)
66 }
67 out.push({ first: two(slice[0]), last: two(slice[slice.length - 1]), codes: slice })
68 }
69
70 return out
71 })
72 const groups: Group[] = []
73 let left = labelled.length
74 let current: { first: string; last: string; codes: string[] } | undefined
75 pieces.forEach((piece, i) => {
76 const wouldBe = (current?.codes.length ?? 0) + piece.codes.length
77 if (current && wouldBe > MOST) {
78 groups.push({ value: `group-${groups.length}`, label: current.first === current.last ? current.first : `${current.first}–${current.last}`, codes: current.codes })
79 left -= current.codes.length
80 current = undefined
81 }
82 current = current ? { first: current.first, last: piece.last, codes: [...current.codes, ...piece.codes] } : { ...piece, codes: [...piece.codes] }
83 const target = left / Math.max(1, count - groups.length)
84 if (current.codes.length >= target || i === pieces.length - 1) {
85 groups.push({ value: `group-${groups.length}`, label: current.first === current.last ? current.first : `${current.first}–${current.last}`, codes: current.codes })
86 left -= current.codes.length
87 current = undefined
88 }
89 })
90
91 return groups
92}
93
94// The regions where a language is the first language of most people, offered whether or not docket
95// has a file for them yet: one without a file speaks its language's, until someone writes it. A
96// region where it's mostly a second language isn't listed, as naming it reads as a judgment.
97export const REGIONS: Record<string, readonly string[]> = {
98 en: ['US', 'GB', 'AU', 'CA', 'IE', 'NZ'],
99 es: ['ES', 'MX', 'AR', 'CO', 'CL'],
100 pt: ['PT', 'BR'],
101 fr: ['FR', 'CA', 'BE', 'CH'],
102 de: ['DE', 'AT', 'CH'],
103 zh: ['CN', 'TW'],
104 nl: ['NL', 'BE'],
105 ar: ['SA', 'EG', 'MA'],
106}
107
108// The pulldowns have no separator, so this entry stands for one: picking it changes nothing.
109export const DIVIDER = '-'
110
111// English's own names for its regions, in an English UI: one pattern, and US English, not American.
112const ENGLISHES: Record<string, string> = {
113 'en-US': 'US English',
114 'en-GB': 'British English',
115 'en-AU': 'Australian English',
116 'en-CA': 'Canadian English',
117 'en-IE': 'Irish English',
118 'en-NZ': 'New Zealand English',
119}
120
121const regionName = (speaking: string, code: string) =>
122 (speaking.split('-')[0] === 'en' ? ENGLISHES[code] : undefined) ?? nameIn(speaking, code)
123
124// A language's variants, as the rest of its tag: its real regions, from the table and any with a file
125// ("BR"), then a divider and its dialects ("US-startrek"), each part in alphabetical order and named in
126// the language docket speaks. There's no "none": a language with variants always has one picked.
127export const variantsOf = (speaking: string, base: string, files: readonly string[], dialects: readonly Dialect[]): Choice[] => {
128 const byName = (a: Choice, b: Choice) => a.label.localeCompare(b.label, speaking)
129 const regions = [
130 ...new Set([...(REGIONS[base] ?? []), ...files.filter(code => code.startsWith(`${base}-`)).map(code => code.slice(base.length + 1))]),
131 ]
132 .map(region => ({ value: region, label: regionName(speaking, `${base}-${region}`) }))
133 .sort(byName)
134 const comic = dialects
135 .filter(each => each.tag.startsWith(`${base}-`))
136 .map(each => ({ value: each.tag.slice(base.length + 1), label: each.name }))
137 .sort(byName)
138
139 return [
140 ...regions,
141 ...(comic.length ? [{ value: DIVIDER, label: '──────────' }, ...comic] : []),
142 ]
143}
144
145// The variant a language starts with: its first region in the table (US for English), else the first.
146export const defaultVariant = (base: string, variants: readonly Choice[]) => {
147 const first = REGIONS[base]?.find(region => variants.some(each => each.value === region))
148
149 return first ?? variants.find(each => each.value !== DIVIDER)?.value ?? ''
150}
151
152// Whether a language's variants need a pulldown: only with two or more to choose between.
153export const isChoosing = (variants: readonly Choice[]) => variants.filter(each => each.value !== DIVIDER).length > 1
154
155// A release's day ("2026-10-23") as the language docket speaks writes a date: "Oct 23 2026" in
156// English, the runtime's own short form elsewhere.
157export const dayOf = (speaking: string, iso: string) => {
158 const day = new Date(`${iso}T12:00:00Z`)
159 if (Number.isNaN(day.getTime())) return iso
160 try {
161 if (speaking.split('-')[0] === 'en') {
162 const parts = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).formatToParts(day)
163 const part = (type: string) => parts.find(each => each.type === type)?.value ?? ''
164
165 return `${part('month')} ${part('day')} ${part('year')}`
166 }
167
168 return new Intl.DateTimeFormat(speaking, { dateStyle: 'medium', timeZone: 'UTC' }).format(day)
169 } catch {
170 return iso
171 }
172}
173
174// A language setting read back into the pulldowns: its base and the rest of its tag.
175export const pickedOf = (setting: unknown) => {
176 const text = String(setting ?? '').trim()
177 if (!text || text === 'auto') return { base: 'auto', variant: '' }
178 const [base = '', ...rest] = text.split(/[-_]/)
179
180 return { base: base.toLowerCase(), variant: rest.join('-') }
181}
182types/index.d.ts 68 lines1export type Letter = 'P' | 'W' | 'L' | 'U' | 'V' | 'N' | 'C' | 'Q' | 'R'
2export type Stakes = 'emergency' | 'high' | 'med'
3
4export type Closure = {
5 at: number
6 // Closed on Claude's own judgment rather than on the user's word.
7 isUnasked: boolean
8}
9
10export type Item = {
11 id: string
12 list: Letter
13 n: number
14 text: string
15 emoji?: string
16 lean?: string
17 stakes?: Stakes
18 // 1 is most urgent; only the ranked lists read it.
19 rank?: number
20 // Up to three lettered options a question offers; their buttons replace ✔ and ✗.
21 options?: string[]
22 addedTurn: number
23 changedTurn: number
24 closed?: Closure
25 // The user pressed ✔ or ✗ and the reply is on its way; cleared when Claude changes the item.
26 // 'yes', 'no', or the option picked.
27 answered?: string
28 // When it was answered: the docket hides it a little later, whatever Claude has done by then.
29 answeredAt?: number
30 // When Claude last added or changed it: a bulk button leaves it out for a few seconds after.
31 changedAt?: number
32 // An item the mod itself handles, rather than Claude: its buttons act locally.
33 action?: 'createConfig' | 'translate' | 'update'
34 // For a translate offer: the language it asks for.
35 language?: string
36 // A local file the item is about: it gets an Open button that opens the file in the editor.
37 open?: string
38}
39
40export type Counts = Partial<Record<Letter, number>>
41
42declare module 'claude-code' {
43 interface PluginState {
44 docket: {
45 items: Item[]
46 counters: Counts
47 turn: number
48 // Open counts per list when the current turn began, for the deltas.
49 baseline: Counts
50 // Ids the user dismissed from the band since the last prompt.
51 dismissed: string[]
52 // Whether the pane shows the settings view instead of the lists.
53 isSettings: boolean
54 // Letters of the lists the user expanded past their cap.
55 expanded: string[]
56 // The turn the docket last changed in, and whether the last reply spoke of pending work without changing it.
57 updatedTurn: number
58 isUnreconciled: boolean
59 // The group of initials open in the language picker, before a language in it is picked.
60 languageGroup: string
61 // The language and voice the open items were last written in, to see when they need rewriting.
62 spokenAs: string
63 // The Deep dive switch, once flipped this session; null until then, when the config's turnReview says.
64 deepDive: boolean | null
65 }
66 }
67}
68