SLOPSHOPPER

docket

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

newpanebandguardcommandtoast
★ 1v0.3.2MITupdated 2026-10-23StoneCypher/docket
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · docket
│ ┃ Docket 📋 ✕ › fix the failing auth test and add an audit log call │ ┃ Requests +0 x-R │ ┃ R1. ⚙️ Create docket's config at ⏺ Read(src/auth.ts) │ ┃ /Users/dev/.claude/docket/config.json, ⎿ Read 6 lines │ ┃ with every setting at its default? You can ⏺ Update(src/auth.ts) │ ┃ edit it there, or with ⚙. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ deep-dive https://github.com/StoneCypher/do │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /docket │ ⎿ docket: Docket pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Docket 📋
Requests +0 x-R R1. ⚙️ Create docket's config at /Users/dev/.claude/docket/config.json, with every setting at its default? You can edit it there, or with ⚙. deep-dive https://github.com/StoneCypher/docket gear
README

docket

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.

Install

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.

The lists

In the pane, the most important list comes first:

LetterListWhat's in it
RRequestsThings Claude wants to do on its own initiative. It waits for your ✔ or ✗.
QQuestionsDecisions only you can make. Each has a lean: the answer Claude would pick.
CConfirmClaude checking that it read you right, or making sure you know something risky is coming.
NNeeds youThings only you can do: consoles, accounts, hardware.
VVerifyThings for you to check: that a change works, reads right or looks right.
UUnderwayClaude's work in progress.
LLaterClaude's work that's decided but not started, in order.
WWaitingThings stalled on something other than you.
PPinnedParked 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.

Buttons

  • ✔ / ✗ on a lean, a request or a confirm item answers it. The row dims at once and leaves about 20 seconds later.
  • 🗑️ on an item clears just that item. ✕ on a heading clears the whole list. Clearing only empties the display: it never cancels work, answers a question or approves a request.
  • Accept all leans, Confirm all and Approve all answer a whole list.
  • ⚙ at the bottom of the pane opens the settings.

/docket reopens the pane if you close it.

Settings

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 de
  • turnReview: 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 prompt
  • a cap for each list (7 by default) before "+N more"
  • itemColor, itemDimColor, leanColor, leanDimColor: hex colors or theme keys
  • emoji, headingEmoji, noItemEmojiLists: emoji on or off, overall or per list
  • stakesDisplay: emoji, text or none
  • inlineLists, targetWords: which lists draw on one line, and soft word limits per list
  • answeredLingerSeconds, strikeLingerMinutes: how long answered and auto-closed items stay up

Languages

docket 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.

Development

  • 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.

How it works

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.

Source 9 files
hooks/register.tsx 1690 lines
1import { 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 lines
1import 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}
386
hooks/config.ts 124 lines
1// 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}
124
hooks/i18n.ts 368 lines
1// 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}
368
hooks/languages.ts 184 lines
1// 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)
184
hooks/editor.ts 45 lines
1// 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))
45
hooks/release.ts 3 lines
1// Written by `npm run release`: the day this version was cut, shown in the settings view.
2export const BUILT = '2026-10-23'
3
hooks/picker.ts 182 lines
1// 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}
182
types/index.d.ts 68 lines
1export 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