SLOPSHOPPER

open-items

Keeps unresolved questions, pending decisions and deferred items outside the context, reminds you above the prompt box, and quotes one in a press

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-items
│ ┃ open-items ✕ › fix the failing auth test and add an audit log call │ ┃ open-items ───────────────────────────────── │ ┃ ─ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Resolved in the last 7 days: 0 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ v: view │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No open items (they show here when Claude │ ┃ tracks a decision, a question, a bug or a ✻ Worked for 42s · done 4:20 PM │ ┃ deferred item). │ ┃ › /open-items │ ┃ Esc closes ⎿ open-items: Opened the open items list. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · open-items
open-items ───────────────────────────────────────────── 0 Resolved in the last 7 days: 0 v: view No open items (they show here when Claude tracks a decision, a question, a bug or a deferred item). Esc closes
README

cc-mod-trimtab

English | 繁體中文

Small mods that keep Claude Code from losing your work, your secrets and your tokens.

A trim tab is the small flap on a rudder that steers the whole ship. These mods do the same for Claude Code: each one is a few hooks that sit quietly in the session and step in only at the moment something would go wrong. Install the ones you want; none of them depends on another.

  • Fewer mistakes: risky git and rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.
  • Fewer tokens: long command output is trimmed before the model reads it; turn on session-keeper and the prompt cache stays warm while you step away, and a large context is handed off to a fresh session instead of dragging on.
  • More fault tolerance: interrupted background agents come back in one key press, open questions survive /clear, and with project-memory on, what you learn in one session is waiting in the next.
  • No extra services, no surprise spending: no mod contacts a service of its own. The few features that call a model go through Claude Code's own model API with your account, and they are off until you turn them on.

The mods

ModWhat it does for you
secret-scrubMasks API keys, tokens and private keys in your prompt before it is sent, so they stay out of the transcript and the prompt cache.
tool-guardHolds git reset --hard, force pushes, rm -rf and migrations, lists exactly which files or commits they would lose (or which migrations would run), and lets you decide. Harmless runs pass straight through.
output-trimmerWhen a Bash command prints thousands of lines, the model reads only the head, the tail and the errors; the full output is saved to a file.
subagent-dispatchBrings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn.
open-itemsKeeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press.
session-keeperShows the prompt cache state on desktop, and, once turned on, keeps a 1h cache warm while you are away and offers a handoff to a fresh session when the context gets expensive.
self-checkAsks Claude to find the cause when the same tool fails twice the same way, and to verify before it says "done" after editing code.
project-memoryOnce turned on, distills each conversation in the background into Claude Code's own memory and a progress file; rules confirmed across sessions are proposed for CLAUDE.md.

Quick start

Requires Claude Code 2.1.296 or later, in the CLI or desktop app (cloud sessions cannot load mods yet).

claude plugin install all --marketplace wen-hsiu-hsu/cc-mod-trimtab

This installs every mod, secret-scrub first so that it sees every prompt before other mods do. To install only some of them, or to drop one later, see Install and update.

The mods work right after install, with safe defaults. Features that call a model (in session-keeper and project-memory) stay off until you run /<mod> on or turn them on in /config. Run /<mod> status to see what a mod is set to.

Documentation

License

MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.

Source 5 files
hooks/register.tsx 1115 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, RenderSurface } from 'claude-code'
3
4import type { ItemKind, KeptRow, MissStats, OpenItem, OptionRef, Outcome, PaneGroup, PaneOrder, PaneView, Tombstone } from '../types'
5import { configCommand, LANGUAGE, loadConfig, pick, type ConfigIO, type Lang, type Specs } from './config'
6import { emptyState, hintLine, switcher, TONE } from './ui'
7import {
8  KINDS,
9  PANE_KINDS,
10  STALE_MS,
11  ageText,
12  appendLog,
13  branchGroups,
14  bandText,
15  carriedNote,
16  cells,
17  clip,
18  combine,
19  compactNote,
20  endsWithQuestion,
21  findSimilar,
22  historyLines,
23  isLogged,
24  isStale,
25  kindLabel,
26  kindShort,
27  listLines,
28  loadSummary,
29  markdownList,
30  metaText,
31  nextId,
32  panelNote,
33  parseOptions,
34  readLog,
35  readStored,
36  recentResolved,
37  renumber,
38  resolvedMeta,
39  sortItems,
40  splitCells,
41  toStored,
42  trimItems,
43  unchosen,
44  wrapCells,
45} from './items'
46
47const items = atom({ plugin: 'open-items', key: 'items' } as const, [])
48const options = atom({ plugin: 'open-items', key: 'options' } as const, [])
49const loadedFor = atom({ plugin: 'open-items', key: 'loadedFor' } as const, '')
50const announce = atom({ plugin: 'open-items', key: 'announce' } as const, false)
51const autoDeferred = atom({ plugin: 'open-items', key: 'autoDeferred' } as const, [])
52const panelResolved = atom({ plugin: 'open-items', key: 'panelResolved' } as const, [])
53const tracked = atom({ plugin: 'open-items', key: 'tracked' } as const, false)
54const asked = atom({ plugin: 'open-items', key: 'asked' } as const, '')
55const view = atom({ plugin: 'open-items', key: 'view' } as const, 'open' as PaneView)
56const focused = atom({ plugin: 'open-items', key: 'focused' } as const, '')
57const kept = atom({ plugin: 'open-items', key: 'kept' } as const, [])
58const group = atom({ plugin: 'open-items', key: 'group' } as const, 'kind' as PaneGroup)
59const order = atom({ plugin: 'open-items', key: 'order' } as const, 'old' as PaneOrder)
60const unfolded = atom({ plugin: 'open-items', key: 'unfolded' } as const, false)
61const paneBranch = atom({ plugin: 'open-items', key: 'branch' } as const, '')
62
63const COMMAND = 'open-items'
64const SPECS: Specs = { language: LANGUAGE }
65const PANE = 'open-items'
66const TOOL = 'mcp__open-items__track'
67const STATS_KEY = 'stats'
68// A reply this short (in characters) to a question is taken as a confirmation, not a miss.
69const SHORT_REPLY = 14
70const MAX_ITEMS = 200
71const MAX_NOTE = 160
72const RESOLVED_SHOWN = 5
73// The pane's id column: ids are o1…o200, so four cells plus a gap keep the texts aligned.
74const ID_COLUMNS = 5
75const ROW = 'row:'
76
77// Each kind's part of the count bar: `ink` on the terminal (a background, so deferred is gray rather
78// than dim), `fill` in desktop's Svg (DESIGN.md, kind colors).
79const KIND_BAR: Record<ItemKind, { ink: string; fill: string }> = {
80  decision: { ink: TONE.attention, fill: '#c98a12' },
81  bug: { ink: TONE.danger, fill: '#c0504d' },
82  question: { ink: 'cyan', fill: '#2f8fb0' },
83  deferred: { ink: 'gray', fill: '#8b8b93' },
84}
85
86function svgStack(parts: { cells: number; fill: string }[]): string {
87  const unit = 8
88  let x = 0
89  const rects = parts.map(part => {
90    const rect = part.cells === 0 ? '' : `<rect x="${x * unit}" width="${part.cells * unit}" height="8" fill="${part.fill}"/>`
91    x += part.cells
92
93    return rect
94  })
95
96  return `<svg xmlns="http://www.w3.org/2000/svg" width="${x * unit}" height="8">${rects.join('')}</svg>`
97}
98
99const PROMPT_RULES =
100  'open-items: when you ask the user to decide something, leave a question open, defer an item, ' +
101  'or find an unresolved bug, call mcp__open-items__track with action "add" (kind, short text). ' +
102  'When it is resolved, call it with action "resolve" and the id; for a decision add a note of what was ' +
103  'chosen and why, and when it will not be done set dropped with the reason as the note. Action "history" ' +
104  'looks up earlier decisions and dropped items. Do not mention this bookkeeping.'
105
106function isKind(value: unknown): value is ItemKind {
107  return KINDS.includes(value as ItemKind)
108}
109
110// Surfaces without the band above the prompt (mobile, the IDE) get a one-line summary instead.
111async function showStatus($: EngineInterface, lang: Lang): Promise<void> {
112  const surface = await $.session.surface()
113  if (surface === 'terminal' || surface === 'desktop') {
114    return
115  }
116  const text = bandText(await read($, items), undefined, lang)
117  $.ui.status(text === '' ? undefined : text)
118}
119
120// The branch (undefined when detached) and short commit the session is on; both undefined outside git.
121async function gitWhere($: EngineInterface): Promise<{ branch?: string; head?: string }> {
122  try {
123    const [branch, head] = await Promise.all([
124      $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3000 }),
125      $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { timeoutMs: 3000 }),
126    ])
127    const name = branch.exitCode === 0 ? branch.stdout.trim() : ''
128    const sha = head.exitCode === 0 ? head.stdout.trim() : ''
129
130    return { branch: name === '' || name === 'HEAD' ? undefined : name, head: sha === '' ? undefined : sha }
131  } catch {
132    return {}
133  }
134}
135
136type Added = { id: string; uid: string; similar?: undefined } | { similar: OpenItem; id?: undefined; uid?: undefined }
137
138// A text equal to or containing an open item's (or contained in it) is not added: the open one is answered.
139async function addItem($: EngineInterface, lang: Lang, kind: ItemKind, text: string, source: OpenItem['source']): Promise<Added> {
140  await ensureLoaded($, lang)
141  const similar = findSimilar(await read($, items), text)
142  if (similar !== undefined) {
143    return { similar }
144  }
145  const now = await $.clock.now()
146  const session = await $.session.id()
147  const where = await gitWhere($)
148  let id = ''
149  const list = await update($, items, current => {
150    id = nextId(current)
151    const item: OpenItem = { id, uid: `${session}:${id}:${now}`, kind, text: clip(text), source, isDone: false, createdAt: now, ...where }
152
153    return trimItems([...current, item], MAX_ITEMS)
154  })
155  await writeStored($, list)
156  await showStatus($, lang)
157
158  return { id, uid: `${session}:${id}:${now}` }
159}
160
161type Resolution = { outcome: Outcome; note: string; fromPanel: boolean }
162
163async function resolveItem($: EngineInterface, lang: Lang, id: string, how: Resolution): Promise<OpenItem | undefined> {
164  await ensureLoaded($, lang)
165  const now = await $.clock.now()
166  const target = (await read($, items)).find(item => item.id === id && !item.isDone)
167  if (target === undefined) {
168    return undefined
169  }
170  const resolved: OpenItem = {
171    ...target,
172    isDone: true,
173    doneAt: now,
174    outcome: how.outcome,
175    ...(how.note === '' ? {} : { note: clip(how.note, MAX_NOTE) }),
176  }
177  const list = await update($, items, current => current.map(item => (item.uid === target.uid ? resolved : item)))
178  await writeStored($, list)
179  if (isLogged(resolved)) {
180    await writeLog($, resolved, now)
181  }
182  if (how.fromPanel) {
183    await update($, panelResolved, current => [...current.filter(uid => uid !== target.uid), target.uid])
184  }
185  await showStatus($, lang)
186
187  return resolved
188}
189
190// Takes back a resolution: the item opens again under a new uid, since the old one's tombstone
191// stays in the store (and in other sessions' lists), and its log entry goes.
192async function reopenItem($: EngineInterface, lang: Lang, id: string): Promise<OpenItem | undefined> {
193  await ensureLoaded($, lang)
194  const now = await $.clock.now()
195  const target = (await read($, items)).find(item => item.id === id && item.isDone)
196  if (target === undefined) {
197    return undefined
198  }
199  const { doneAt: _doneAt, outcome: _outcome, note: _note, ...rest } = target
200  const reopened: OpenItem = { ...rest, uid: `${target.uid}:r${now}`, isDone: false }
201  const list = await update($, items, current => current.map(item => (item.uid === target.uid ? reopened : item)))
202  await writeStored($, list)
203  const logKey = `log:${await repoRoot($)}`
204  const log = readLog(await $.store.get(logKey))
205  if (log.some(entry => entry.uid === target.uid)) {
206    await $.store.set(logKey, log.filter(entry => entry.uid !== target.uid))
207  }
208  await update($, panelResolved, current => current.filter(uid => uid !== target.uid))
209  await showStatus($, lang)
210
211  return target
212}
213
214async function openPane($: EngineInterface, lang: Lang): Promise<void> {
215  await update($, view, () => 'open' as PaneView)
216  await update($, focused, () => '')
217  await update($, kept, () => [])
218  await update($, unfolded, () => false)
219  const { branch: now } = await gitWhere($)
220  await update($, paneBranch, () => now ?? '')
221  await $.ui.open({ id: PANE, title: COMMAND, focus: true, closeOnEscape: true })
222}
223
224// What the focused-row keys do. In the open list d and x close the row, or reopen it when pressed
225// again; in the resolved list d reopens it, and pressing it again puts the resolution back.
226async function actOnRow($: EngineInterface, lang: Lang, id: string, action: Outcome): Promise<void> {
227  const item = (await read($, items)).find(i => i.id === id)
228  if (item === undefined) {
229    return
230  }
231  const rows = await read($, kept)
232  const row = rows.find(r => r.id === id)
233  const keep = (next: KeptRow): Promise<KeptRow[]> => update($, kept, current => [...current.filter(r => r.id !== id), next])
234  if ((await read($, view)) === 'resolved') {
235    if (item.isDone) {
236      await reopenItem($, lang, id)
237      await keep({ id, ...(item.outcome === undefined ? {} : { was: item.outcome }), ...(item.note === undefined ? {} : { note: item.note }) })
238    } else {
239      await resolveItem($, lang, id, { outcome: row?.was ?? 'done', note: row?.note ?? '', fromPanel: false })
240    }
241
242    return
243  }
244  if (item.isDone) {
245    await reopenItem($, lang, id)
246    if ((item.outcome ?? 'done') === action) {
247      return
248    }
249  }
250  await resolveItem($, lang, id, { outcome: action, note: '', fromPanel: true })
251  if (row === undefined) {
252    await keep({ id })
253  }
254}
255
256async function quoteRow($: EngineInterface, lang: Lang, item: OpenItem): Promise<void> {
257  await $.prompt.fill({
258    text: pick(lang, {
259      en: `About ${item.id} (${kindLabel(item.kind, lang)}) "${item.text}": `,
260      'zh-TW': `關於 ${item.id}(${kindLabel(item.kind, lang)})「${item.text}」:`,
261    }),
262    mode: 'insert',
263  })
264}
265
266// Per repository: a worktree's repo root is the main working tree's, so every worktree shares it.
267async function repoRoot($: EngineInterface): Promise<string> {
268  const repo = await $.session.repo()
269
270  return repo?.root ?? (await $.session.root())
271}
272
273async function storeKey($: EngineInterface): Promise<string> {
274  return `items:${await repoRoot($)}`
275}
276
277// The repo's resolution log: decisions and dropped items with their notes, kept without expiry.
278async function writeLog($: EngineInterface, item: OpenItem, now: number): Promise<void> {
279  const key = `log:${await repoRoot($)}`
280  const entry = {
281    uid: item.uid,
282    kind: item.kind,
283    text: item.text,
284    outcome: item.outcome ?? 'done',
285    note: item.note ?? '',
286    at: now,
287    ...(item.branch === undefined ? {} : { branch: item.branch }),
288  }
289  await $.store.set(key, appendLog(readLog(await $.store.get(key)), entry))
290}
291
292async function readHistory($: EngineInterface, lang: Lang, query: string): Promise<string> {
293  const log = readLog(await $.store.get(`log:${await repoRoot($)}`))
294
295  return historyLines(log, query, await $.clock.now(), lang)
296}
297
298// Keys the repo's items were kept under before: the session root, which was a worktree's or a
299// subfolder's own path. Read once into the repo's key, then removed.
300async function legacyKeys($: EngineInterface, key: string): Promise<string[]> {
301  const old = `items:${await $.session.root()}`
302
303  return (await $.store.keys()).filter(k => k !== key && (k === old || k.startsWith(`${key}/`)))
304}
305
306// Merges this session's list into the stored one and writes it back, on every change: a session
307// that never ends cleanly loses nothing, and two sessions on one repo keep each other's items.
308// `removed` are tombstones of items taken out of the list without being resolved (an undo).
309async function writeStored($: EngineInterface, local: readonly OpenItem[], removed: Tombstone[] = []): Promise<void> {
310  const key = await storeKey($)
311  const now = await $.clock.now()
312  const mine = toStored(local, now)
313  const merged = combine(readStored(await $.store.get(key)), { ...mine, done: [...mine.done, ...removed] }, now, MAX_ITEMS)
314  if (merged.items.length === 0 && merged.done.length === 0) {
315    await $.store.delete(key)
316  } else {
317    await $.store.set(key, merged)
318  }
319  // Items another session resolved meanwhile are resolved here too.
320  const gone = new Set(merged.done.map(t => t.uid))
321  if (local.some(item => !item.isDone && gone.has(item.uid))) {
322    await update($, items, current =>
323      current.map(item => (!item.isDone && gone.has(item.uid) ? { ...item, isDone: true, doneAt: now } : item)),
324    )
325  }
326}
327
328// Brings the repo's stored open items into this session once. `/clear` starts a new session id
329// without a `session.start`, so every path that reads or writes the list calls this first.
330async function ensureLoaded($: EngineInterface, lang: Lang): Promise<void> {
331  const session = await $.session.id()
332  if ((await read($, loadedFor)) === session) {
333    return
334  }
335  await update($, loadedFor, () => session)
336  const key = await storeKey($)
337  let stored = readStored(await $.store.get(key))
338  const legacy = await legacyKeys($, key)
339  if (legacy.length > 0) {
340    const now = await $.clock.now()
341    for (const old of legacy) {
342      stored = combine(stored, readStored(await $.store.get(old)), now, MAX_ITEMS)
343    }
344    await $.store.set(key, stored)
345    for (const old of legacy) {
346      await $.store.delete(old)
347    }
348  }
349  const current = await read($, items)
350  const have = new Set(current.flatMap(item => [item.uid, `${item.kind}:${item.text}`]))
351  const open = stored.items.filter(item => !have.has(item.uid) && !have.has(`${item.kind}:${item.text}`))
352  // What the repo resolved in the last 7 days comes along for the pane's resolved list.
353  const recent = recentResolved(stored, current, await $.clock.now())
354  if (open.length + recent.length === 0) {
355    return
356  }
357  // Stored ids come first; items added before the load get fresh ids after them.
358  await update($, items, () => trimItems(renumber([...open, ...recent], current), MAX_ITEMS))
359  if (open.length === 0) {
360    return
361  }
362  await update($, announce, () => true)
363  await showStatus($, lang)
364  const summary = loadSummary(open, (await gitWhere($)).branch, await $.clock.now(), lang)
365  $.ui.toast(
366    pick(lang, {
367      en: `open-items: ${open.length} open item(s) left from last time${summary}`,
368      'zh-TW': `open-items:上次留下 ${open.length} 個待處理項目${summary}`,
369    }),
370  )
371}
372
373async function saveStored($: EngineInterface, lang: Lang): Promise<void> {
374  await ensureLoaded($, lang)
375  await writeStored($, await read($, items))
376}
377
378// Takes back the items the latest prompt deferred on its own: out of the list and out of the store.
379async function undoDeferred($: EngineInterface, lang: Lang): Promise<number> {
380  const uids = new Set(await read($, autoDeferred))
381  if (uids.size === 0) {
382    return 0
383  }
384  const now = await $.clock.now()
385  await update($, autoDeferred, () => [])
386  const list = await update($, items, current => current.filter(item => !uids.has(item.uid)))
387  await writeStored($, list, [...uids].map(uid => ({ uid, doneAt: now })))
388  await showStatus($, lang)
389
390  return uids.size
391}
392
393// The options the prompt left out of the remembered list become deferred items; what the previous
394// prompt deferred can be undone only until this one.
395async function deferUnchosen($: EngineInterface, lang: Lang, text: string, remembered: readonly OptionRef[]): Promise<void> {
396  await update($, autoDeferred, () => [])
397  if (remembered.length === 0) {
398    return
399  }
400  const deferred: { label: string; text: string; uid: string }[] = []
401  for (const option of unchosen(text, remembered)) {
402    if (option.text === '') {
403      continue
404    }
405    const added = await addItem($, lang, 'deferred', option.text, 'auto')
406    if (added.uid !== undefined) {
407      deferred.push({ label: option.label, text: clip(option.text), uid: added.uid })
408    }
409  }
410  if (deferred.length > 0) {
411    await update($, autoDeferred, () => deferred.map(d => d.uid))
412    const what = deferred.map(d => `${d.label}${pick(lang, { en: ': ', 'zh-TW': ':' })}${d.text}`).join(pick(lang, { en: ', ', 'zh-TW': '、' }))
413    $.ui.toast(
414      pick(lang, {
415        en: `open-items: deferred ${what}. Press [Undo] or type /open-items undo to cancel`,
416        'zh-TW': `open-items:已延後 ${what}。按 [復原] 或 /open-items undo 取消`,
417      }),
418      { timeoutMs: 8000 },
419    )
420  }
421  await update($, options, () => [])
422}
423
424async function copyList($: EngineInterface, lang: Lang, surface?: RenderSurface): Promise<string> {
425  await ensureLoaded($, lang)
426  const text = markdownList(await read($, items), lang)
427  if (text === '') {
428    return pick(lang, { en: 'No open items.', 'zh-TW': '目前沒有待處理項目。' })
429  }
430  const copied = await $.ui.copy(surface === undefined ? { text } : { text, surface })
431
432  return copied.isCopied
433    ? pick(lang, { en: 'Copied the open items as markdown.', 'zh-TW': '已把待處理項目複製成 markdown。' })
434    : pick(lang, { en: `Cannot copy (${copied.reason}); here it is:\n\n${text}`, 'zh-TW': `無法複製(${copied.reason}),內容如下:\n\n${text}` })
435}
436
437// Counts kept before the next prompt decided misses ({ questions, missed }) are dropped: their
438// misses included every confirmation the person answered with a word.
439async function readStats($: EngineInterface): Promise<MissStats> {
440  const stats = (await $.store.get(STATS_KEY)) as Partial<MissStats> | undefined
441
442  return stats?.untracked === undefined ? { questions: 0, untracked: 0, missed: 0 } : (stats as MissStats)
443}
444
445async function statsText($: EngineInterface, lang: Lang): Promise<string> {
446  const stats = await readStats($)
447  if (stats.questions === 0) {
448    return pick(lang, { en: 'No answers ending in a question yet.', 'zh-TW': '還沒有以問句結尾的回答。' })
449  }
450  const rate = Math.round((stats.missed / stats.questions) * 100)
451
452  return pick(lang, {
453    en:
454      `${stats.questions} answer(s) ended with a question; ${stats.untracked} made no track call, ` +
455      `and ${stats.missed} of those got a reply longer than ${SHORT_REPLY} characters (${rate}%). ` +
456      'Short replies and prompts not typed by you are not counted. Each miss is recorded in the debug log (open-items: missed).',
457    'zh-TW':
458      `以問句結尾的回答 ${stats.questions} 次,其中 ${stats.untracked} 次沒有呼叫 track,` +
459      `這些當中你回覆超過 ${SHORT_REPLY} 字的有 ${stats.missed} 次(${rate}%)。` +
460      '短答和不是你輸入的訊息不計入。逐筆紀錄在 debug log(open-items: missed)。',
461  })
462}
463
464// An answer that ends on a question without any `track` call this turn may have left something
465// untracked; the next prompt decides (see `judgeMiss`).
466async function measureMiss($: EngineInterface, answer: string): Promise<void> {
467  const didTrack = await read($, tracked)
468  await update($, tracked, () => false)
469  const isQuestion = endsWithQuestion(answer)
470  const last = answer.split('\n').filter(line => line.trim() !== '').slice(-1)[0] ?? ''
471  await update($, asked, () => (isQuestion && !didTrack ? last : ''))
472  if (!isQuestion) {
473    return
474  }
475  const stats = await readStats($)
476  await $.store.set(STATS_KEY, { ...stats, questions: stats.questions + 1, untracked: stats.untracked + (didTrack ? 0 : 1) })
477}
478
479// A question answered with a word ("ok", "commit") was a confirmation, done once answered; only a
480// longer reply typed by the person counts as a miss.
481async function judgeMiss($: EngineInterface, question: string, reply: string | undefined): Promise<void> {
482  await update($, asked, () => '')
483  if (reply === undefined || [...reply.trim()].length <= SHORT_REPLY) {
484    return
485  }
486  const stats = await readStats($)
487  await $.store.set(STATS_KEY, { ...stats, missed: stats.missed + 1 })
488  $.ui.log(`open-items: missed: answer ended with a question and no track call: ${clip(question, 120)}`, { to: 'debug' })
489}
490
491const USAGE = {
492  en: 'Usage: /open-items (opens the panel), copy, undo, log [keyword], stats, status',
493  'zh-TW': '用法:/open-items(開啟面板)、copy、undo、log [關鍵字]、stats、status',
494} as const
495
496// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
497function configIO($: EngineInterface): ConfigIO {
498  return {
499    read: async path => {
500      try {
501        return await $.fs.read(path)
502      } catch {
503        return ''
504      }
505    },
506    write: (path, text) => $.fs.write(path, text),
507    isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
508    configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
509    root: () => $.session.root(),
510    repoRoot: async () => (await $.session.repo())?.root,
511    sessionId: () => $.session.id(),
512    now: () => $.clock.now(),
513    storeGet: key => $.store.get(key),
514    storeSet: (key, value) => $.store.set(key, value),
515  }
516}
517// --- end configIO ---
518
519export const register: Register = (on, pluginOptions) => {
520  // Re-read at session start and after `/open-items set`.
521  let lang: Lang = 'en'
522
523  on('session.start', async ($, e, next) => {
524    try {
525      lang = (await loadConfig(configIO($), COMMAND, SPECS, pluginOptions)).lang
526    } catch (error) {
527      $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
528    }
529    await $.tool.register({
530      name: 'track',
531      description:
532        'Track an open item. add: kind+text, returns id (or the similar open item, not added); ' +
533        'for deferred, the text names when to come back to it. ' +
534        'resolve: id, optional note (a decision: what was chosen and why), dropped=true when it will not be done (note: why). ' +
535        'list: the open items with their ids. history: earlier decisions and dropped items, optional query.',
536      inputSchema: {
537        type: 'object',
538        properties: {
539          action: { type: 'string', enum: ['add', 'resolve', 'list', 'history'] },
540          kind: { type: 'string', enum: ['decision', 'question', 'deferred', 'bug'] },
541          text: { type: 'string', maxLength: 80 },
542          id: { type: 'string' },
543          note: { type: 'string', maxLength: MAX_NOTE },
544          dropped: { type: 'boolean' },
545          query: { type: 'string' },
546        },
547        required: ['action'],
548      },
549    })
550    try {
551      await $.command.register({
552        name: COMMAND,
553        description: pick(lang, { en: 'List the unresolved questions, pending decisions and deferred items', 'zh-TW': '列出尚未解決的問題、待決定與延後事項' }),
554        argumentHint: pick(lang, { en: '[copy | undo | log [keyword] | stats | status]', 'zh-TW': '[copy | undo | log [關鍵字] | stats | status]' }),
555      })
556    } catch (error) {
557      $.ui.log(`session.start: /${COMMAND} not registered: ${String(error)}`, { to: 'debug' })
558      $.ui.toast(pick(lang, { en: `${COMMAND}: /${COMMAND} was not registered (${String(error)})`, 'zh-TW': `${COMMAND}:/${COMMAND} 指令沒有註冊成功(${String(error)})` }))
559    }
560    await ensureLoaded($, lang)
561
562    return next(e)
563  }).catch(($, e, next) => next(e))
564
565  // A `/clear` fires no `session.start`; its classic SessionStart loads the repo's items at once,
566  // so the band and the pane show them before the first prompt.
567  on('classic.SessionStart', async ($, e, next) => {
568    if (e.source === 'clear') {
569      await ensureLoaded($, lang)
570    }
571
572    return next(e)
573  }).catch(($, e, next) => next(e))
574
575  on('session.end', async ($, e, next) => {
576    await saveStored($, lang)
577
578    return next(e)
579  }).catch(($, e, next) => next(e))
580
581  on('command.run', { command: COMMAND }, async ($, e) => {
582    await ensureLoaded($, lang)
583    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
584    if (sub === '') {
585      await openPane($, lang)
586
587      return { text: pick(lang, { en: 'Opened the open items list.', 'zh-TW': '已開啟待處理項目清單。' }) }
588    }
589    if (sub === 'copy') {
590      return { text: await copyList($, lang) }
591    }
592    if (sub === 'undo') {
593      const n = await undoDeferred($, lang)
594
595      return {
596        text:
597          n === 0
598            ? pick(lang, { en: 'No automatic deferrals to undo.', 'zh-TW': '沒有可以復原的自動延後項目。' })
599            : pick(lang, { en: `Undid ${n} automatic deferral(s).`, 'zh-TW': `已復原 ${n} 個自動延後的項目。` }),
600      }
601    }
602    if (sub === 'log') {
603      return { text: await readHistory($, lang, rest.join(' ')) }
604    }
605    if (sub === 'stats') {
606      return { text: await statsText($, lang) }
607    }
608    const text = await configCommand(configIO($), COMMAND, SPECS, pluginOptions, e.args, resolved => {
609      lang = resolved.lang
610    })
611
612    return { text: text ?? pick(lang, USAGE) }
613  })
614
615  on('prompt.compose', async ($, e, next) => {
616    const composed = await next(e)
617
618    return { ...composed, sections: [...composed.sections, { id: 'open-items:rules', text: PROMPT_RULES, scope: 'session' }] }
619  }).catch(($, e, next) => next(e))
620
621  on('tool.call', { tool: TOOL }, async ($, e) => {
622    const input = e as unknown as {
623      action?: unknown
624      kind?: unknown
625      text?: unknown
626      id?: unknown
627      note?: unknown
628      dropped?: unknown
629      query?: unknown
630      agentId?: unknown
631    }
632    if (input.agentId === undefined) {
633      await update($, tracked, () => true)
634    }
635    if (input.action === 'add') {
636      const text = typeof input.text === 'string' ? input.text.trim() : ''
637      if (text === '') {
638        return { deny: 'track add needs text.' }
639      }
640      const added = await addItem($, lang, isKind(input.kind) ? input.kind : 'question', text, 'model')
641      if (added.similar !== undefined) {
642        return { result: `not added: similar to open item ${added.similar.id} ("${added.similar.text}"). Use ${added.similar.id}.` }
643      }
644
645      return { result: `added ${added.id}` }
646    }
647    if (input.action === 'resolve' && typeof input.id === 'string') {
648      const id = input.id.replace(/^#/, '')
649      const note = typeof input.note === 'string' ? input.note.trim() : ''
650      const outcome: Outcome = input.dropped === true ? 'dropped' : 'done'
651      if (await resolveItem($, lang, id, { outcome, note, fromPanel: false })) {
652        return { result: outcome === 'dropped' ? `dropped ${id}` : `resolved ${id}` }
653      }
654
655      return { deny: `no open item ${id}. Open items:\n${listLines(await read($, items))}` }
656    }
657    if (input.action === 'list') {
658      await ensureLoaded($, lang)
659
660      return { result: listLines(await read($, items)) }
661    }
662    if (input.action === 'history') {
663      await ensureLoaded($, lang)
664
665      return { result: await readHistory($, 'en', typeof input.query === 'string' ? input.query : '') }
666    }
667
668    return { deny: 'track needs action add, resolve (with id), list or history.' }
669  }).catch(($, e, next) => next(e))
670
671  // Remember the latest option list of the main loop's answer. An answer without one (e.g. to a
672  // background-task notification) keeps it; the next prompt from the person clears it.
673  on('turn.complete', async ($, e, next) => {
674    if (e.agentId === undefined && e.reason === 'answer') {
675      const found = parseOptions(e.answer)
676      if (found.length > 0) {
677        await update($, options, () => found)
678      }
679      await measureMiss($, e.answer)
680    }
681
682    return next(e)
683  }).catch(($, e, next) => next(e))
684
685  // A short prompt that picks some of those options defers the rest. The prompt is never altered.
686  // State is only read before `next` and changed once the prompt went in: a plugin beneath may
687  // drop it (secret-scrub when its check fails), and the next prompt must find everything as it was.
688  on('prompt.submit', async ($, e, next) => {
689    await ensureLoaded($, lang)
690    const context: string[] = []
691    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
692    const remembered = isPerson ? await read($, options) : []
693    const question = await read($, asked)
694    // Items the person closed in the pane are told beside the prompt, so the model stops raising them.
695    const closed = isPerson ? await read($, panelResolved) : []
696    if (closed.length > 0) {
697      const uids = new Set(closed)
698      const list = (await read($, items)).filter(item => uids.has(item.uid))
699      if (list.length > 0) {
700        context.push(panelNote(list))
701      }
702    }
703    // Items brought in from the store reach the model with the first prompt after the load,
704    // beside the prompt rather than in the system prompt, which would break the prompt cache.
705    const isAnnounced = await read($, announce)
706    if (isAnnounced) {
707      const list = await read($, items)
708      if (list.some(item => !item.isDone)) {
709        context.push(carriedNote(list))
710      }
711    }
712
713    const submitted = await (context.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...context] }))
714    if (submitted.drop !== undefined) {
715      return submitted
716    }
717    // Nothing may throw past `next`: the prompt went in, and the catch would only replay it.
718    try {
719      if (isAnnounced) {
720        await update($, announce, () => false)
721      }
722      if (closed.length > 0) {
723        const told = new Set(closed)
724        await update($, panelResolved, list => list.filter(uid => !told.has(uid)))
725      }
726      if (isPerson) {
727        await deferUnchosen($, lang, e.text, remembered)
728      }
729      if (question !== '') {
730        await judgeMiss($, question, isPerson ? e.text : undefined)
731      }
732    } catch (error) {
733      $.ui.log(`open-items: prompt.submit after next: ${String(error)}`, { to: 'debug' })
734    }
735
736    return submitted
737  }).catch(($, e, next) => next(e))
738
739  // Carry the open list through a compaction as one more message after the summary.
740  on('session.compact', async ($, e, next) => {
741    // Defensive: the engine always hands a list; a bare dispatch (tests) may not.
742    const ran = await next(Array.isArray(e.messages) ? e : { ...e, messages: [] })
743    const list = await read($, items)
744    if (ran.skip !== undefined || e.trigger === 'precompute' || !list.some(item => !item.isDone)) {
745      return ran
746    }
747
748    return { ...ran, messages: [...ran.messages, { role: 'user' as const, text: compactNote(list), toolUses: [] }] }
749  }).catch(($, e, next) => next(e))
750
751  // The band is one instance shared by every plugin: draw above what the plugins beneath drew,
752  // never in place of it.
753  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
754    const text = bandText(await read($, items), ' ', lang)
755    if (e.props.hasSurvey || text === '') {
756      return next(e)
757    }
758    const below = await next(e)
759    const { Box, Button, Text } = $.ui.resolve(e)
760    const canUndo = (await read($, autoDeferred)).length > 0
761
762    return (
763      <Box flexDirection="column">
764        <Box flexDirection="row">
765          <Text color={TONE.attention} wrap="truncate-end">{text}</Text>
766          <Button key="open" label={pick(lang, { en: 'Open', 'zh-TW': '開啟' })} hotkey="o" onPress={() => void openPane($, lang)} />
767          {canUndo && <Button key="undo" label={pick(lang, { en: 'Undo deferral', 'zh-TW': '復原延後' })} hotkey="u" onPress={() => void undoDeferred($, lang)} />}
768        </Box>
769        {below}
770      </Box>
771    )
772  })
773
774  // The ring's last stop on a row is the row the keys act on; another button keeps it, so Tab onto
775  // the actions still acts on that row, while the engine's own stops (the close mark) clear it.
776  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
777    if (e.element === undefined || e.element.startsWith(ROW)) {
778      await update($, focused, () => e.element ?? '')
779    }
780
781    return next(e)
782  }).catch(($, e, next) => next(e))
783
784  // Every item in full: the id (the row's only button, so Tab and ↑↓ step through the rows), the
785  // text wrapped here so each line can carry its group's side line, then its age and branch. Grouped
786  // by kind (the default), by branch, or not; a count with a bar of the kinds on top.
787  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
788    const parts = $.ui.resolve(e)
789    const { Box, Button, Text } = parts
790    // The runtime hands every surface every component, so the surface decides (DESIGN.md).
791    const Svg = e.surface !== 'terminal' && 'Svg' in parts ? parts.Svg : undefined
792    const list = await read($, items)
793    const now = await $.clock.now()
794    const which = await read($, view)
795    const at = await read($, focused)
796    const grouping = which === 'resolved' ? 'none' : await read($, group)
797    const newest = (await read($, order)) === 'new'
798    const isUnfolded = await read($, unfolded)
799    const current = await read($, paneBranch)
800    const keptIds = new Set((await read($, kept)).map(row => row.id))
801    const open = list.filter(item => !item.isDone)
802    const recent = list.filter(item => item.isDone && (item.doneAt ?? now) > now - STALE_MS)
803    const rows = sortItems(
804      which === 'open'
805        ? list.filter(item => !item.isDone || keptIds.has(item.id))
806        : list.filter(item => (item.isDone && (item.doneAt ?? now) > now - STALE_MS) || keptIds.has(item.id)),
807      newest,
808      which === 'resolved',
809    )
810    // The deferred group folds when grouped by kind, unless it is all there is.
811    const canFold = grouping === 'kind' && rows.some(item => item.kind !== 'deferred')
812    const isFolded = (item: OpenItem): boolean => canFold && item.kind === 'deferred' && !isUnfolded
813    const target = rows.find(item => `${ROW}${item.id}` === at && !isFolded(item))
814    const word = (en: string, zh: string): string => pick(lang, { en, 'zh-TW': zh })
815    const kindColumns = Math.max(...KINDS.map(kind => cells(kindShort(kind, lang)))) + 1
816    // Grouped by kind: the kind and its side line on the left; by branch: the side line alone.
817    const lead = grouping === 'kind' ? kindColumns + 2 : grouping === 'branch' ? 2 : 0
818    const hasKindColumn = grouping !== 'kind'
819    const textColumns = Math.max(10, e.props.bodyColumns - lead - ID_COLUMNS - (hasKindColumn ? kindColumns : 0))
820    const ink = (kind: ItemKind, isDim: boolean) => {
821      if (isDim || kind === 'deferred') {
822        return { dimColor: true }
823      }
824
825      return { color: kind === 'decision' ? TONE.attention : kind === 'bug' ? TONE.danger : 'cyan' }
826    }
827    const outcomeWord = (item: OpenItem): string => (item.outcome === 'dropped' ? word("Won't do", '不做') : word('Done', '完成'))
828
829    // The lines of one item, without its group's lead: the text wrapped to its column, then the
830    // dim line. A closed row in the open list is struck through; the resolved list is dim.
831    const itemLines = (item: OpenItem) => {
832      const isStruck = which === 'open' && item.isDone
833      const isDim = isStruck || (which === 'resolved' && item.isDone) || (which === 'open' && isStale(item, now))
834      const prefix = which === 'resolved' ? (item.isDone ? '' : word('Reopened', '重新打開')) : item.isDone ? outcomeWord(item) : ''
835      const note = which === 'resolved' && item.isDone && item.note !== undefined ? ` → ${item.note}` : ''
836      const text = `${prefix === '' ? '' : `${prefix} `}${item.text}${note}`
837      const meta =
838        which === 'resolved' && item.isDone ? resolvedMeta(item, now, lang) : grouping === 'branch' ? ageText(now - item.createdAt, lang) : metaText(item, now, lang)
839      const side =
840        which === 'resolved' ? (
841          <Text dimColor>{item.isDone ? (item.outcome === 'dropped' ? word("Won't do", '不 做') : word('Done', '完 成')) : ''}</Text>
842        ) : (
843          <Text {...ink(item.kind, isDim)}>{kindShort(item.kind, lang)}</Text>
844        )
845      const textStyle = { dimColor: isDim, strikethrough: isStruck }
846      const lines = wrapCells(text, textColumns)
847
848      return [
849        ...lines.map((line, i) => (
850          <Box key={`text:${item.id}:${i}`} flexDirection="row">
851            <Box width={ID_COLUMNS} flexShrink={0}>
852              {i === 0 && <Button key={`${ROW}${item.id}`} label={item.id} plain onPress={() => undefined} />}
853            </Box>
854            {hasKindColumn && (
855              <Box width={kindColumns} flexShrink={0}>
856                {i === 0 && side}
857              </Box>
858            )}
859            <Text {...textStyle}>{line}</Text>
860          </Box>
861        )),
862        <Box key={`meta:${item.id}`} flexDirection="row" paddingLeft={ID_COLUMNS + (hasKindColumn ? kindColumns : 0)}>
863          <Text dimColor>{meta}</Text>
864        </Box>,
865      ]
866    }
867    // Items one after another with a line between them, the blank line a node of its own so a
868    // group's side line runs through it.
869    const spaced = (list: readonly OpenItem[]) =>
870      list.flatMap((item, i) => [...(i === 0 ? [] : [<Text key={`gap:${item.id}`}> </Text>]), ...itemLines(item)])
871    // A group's lines behind its lead: a left column (the kind, on the first line only) and a side line.
872    const framed = (key: string, label: RenderNode | undefined, sideInk: object, lines: RenderNode[]) =>
873      lines.map((line, i) => (
874        <Box key={`${key}:${i}`} flexDirection="row">
875          {label !== undefined && (
876            <Box width={kindColumns} flexShrink={0}>
877              {i === 0 && label}
878            </Box>
879          )}
880          <Box width={2} flexShrink={0}>
881            <Text {...sideInk}>│</Text>
882          </Box>
883          {line}
884        </Box>
885      ))
886    const more = (count: number) =>
887      isUnfolded
888        ? action('more', 'm', word('Hide', '收起'), () => void update($, unfolded, () => false))
889        : action('more', 'm', word(`Show ${count} ▸`, `展開 ${count} 項 ▸`), () => void update($, unfolded, () => true))
890
891    const groups = (): RenderNode[] => {
892      if (grouping === 'none') {
893        return [<Box key="rows" flexDirection="column">{spaced(rows)}</Box>]
894      }
895      if (grouping === 'branch') {
896        return branchGroups(rows, current).map(({ branch, items: inGroup }, g) => (
897          <Box key={`branch:${branch}`} flexDirection="column" marginTop={g === 0 ? 0 : 1}>
898            <Box flexDirection="row" gap={2}>
899              <Text bold>{branch === '' ? word('no branch', '沒有 branch') : branch}</Text>
900              <Text dimColor>{branch !== '' && branch === current ? word(`current · ${inGroup.length}`, `目前 · ${inGroup.length}`) : String(inGroup.length)}</Text>
901            </Box>
902            {framed(`b:${branch}`, undefined, { dimColor: true }, spaced(inGroup))}
903          </Box>
904        ))
905      }
906
907      const firstKind = PANE_KINDS.find(kind => rows.some(item => item.kind === kind))
908
909      return PANE_KINDS.flatMap(kind => {
910        const inGroup = rows.filter(item => item.kind === kind)
911        if (inGroup.length === 0) {
912          return []
913        }
914        const isDim = inGroup.every(item => item.isDone)
915        const label = (
916          <Text bold {...ink(kind, isDim)}>
917            {kindShort(kind, lang)}
918          </Text>
919        )
920        const folding = kind === 'deferred' && canFold
921        const lines = folding && !isUnfolded ? [] : spaced(inGroup)
922        const tail = folding ? [...(lines.length === 0 ? [] : [<Text key="gap:more"> </Text>]), more(inGroup.length)] : []
923
924        return [
925          <Box key={`kind:${kind}`} flexDirection="column" marginTop={kind === firstKind ? 0 : 1}>
926            {framed(`k:${kind}`, label, ink(kind, isDim), [...lines, ...tail])}
927          </Box>,
928        ]
929      })
930    }
931
932    // The count: a bold total and a bar of its parts filling the rest of the line, a legend below.
933    type Part = { key: string; label: string; value: number; ink: string; fill: string }
934    const segments: Part[] =
935      which === 'resolved'
936        ? [
937            { key: 'done', label: word('Done', '完成'), value: recent.filter(item => item.outcome !== 'dropped').length, ink: 'white', fill: '#8b8b93' },
938            { key: 'dropped', label: word("Won't do", '不做'), value: recent.filter(item => item.outcome === 'dropped').length, ink: 'gray', fill: '#4a4a4a' },
939          ]
940        : PANE_KINDS.map(kind => ({
941            key: kind,
942            label: kindShort(kind, lang),
943            value: open.filter(item => item.kind === kind).length,
944            ...KIND_BAR[kind],
945          }))
946    const total = segments.reduce((sum, part) => sum + part.value, 0)
947    const barWidth = Math.max(4, e.props.bodyColumns - cells(String(total)) - 1)
948    const bar = () => {
949      if (total === 0) {
950        return Svg !== undefined ? (
951          <Svg source={svgStack([{ cells: barWidth, fill: '#3a3a3a' }])} alt="0" width={barWidth * 8} height={8} />
952        ) : (
953          <Text dimColor backgroundColor="gray">
954            {' '.repeat(barWidth)}
955          </Text>
956        )
957      }
958      const widths = splitCells(
959        segments.map(part => part.value),
960        barWidth,
961      )
962      if (Svg !== undefined) {
963        const alt = segments.filter(part => part.value > 0).map(part => `${part.label} ${part.value}`).join(', ')
964
965        return <Svg source={svgStack(segments.map((part, i) => ({ cells: widths[i] ?? 0, fill: part.fill })))} alt={alt} width={barWidth * 8} height={8} />
966      }
967
968      return <Text>{segments.map((part, i) => ((widths[i] ?? 0) === 0 ? '' : <Text key={part.key} backgroundColor={part.ink}>{' '.repeat(widths[i] ?? 0)}</Text>))}</Text>
969    }
970    const legend =
971      which === 'open' && total === 0 ? (
972        <Text dimColor wrap="wrap">
973          {word('Resolved in the last 7 days: ', '最近 7 天完成 ')}
974          <Text bold dimColor={false}>
975            {String(recent.length)}
976          </Text>
977          {word('', ' 項')}
978        </Text>
979      ) : (
980        <Text wrap="wrap">
981          {segments
982            .filter(part => part.value > 0)
983            .map((part, i) => [
984              i === 0 ? '' : '  ',
985              <Text key={`dot:${part.key}`} color={part.ink === 'gray' && which === 'open' ? undefined : part.ink} dimColor={part.ink === 'gray' && which === 'open'}>
986                ●
987              </Text>,
988              ' ',
989              <Text key={`label:${part.key}`} dimColor>
990                {part.label}
991              </Text>,
992              ' ',
993              <Text key={`n:${part.key}`} bold>
994                {String(part.value)}
995              </Text>,
996            ])}
997        </Text>
998      )
999
1000    // A plain Button with a hotkey draws its key itself: `d: Done`.
1001    function action(key: string, hotkey: string, label: string, onPress: () => void) {
1002      return <Button key={key} label={label} plain hotkey={hotkey} onPress={onPress} />
1003    }
1004    const onTarget = (fn: (item: OpenItem) => Promise<unknown>) => () => {
1005      if (target !== undefined) {
1006        void fn(target)
1007      }
1008    }
1009    const quote = action('quote', 'q', word('Quote', '引用'), onTarget(item => quoteRow($, lang, item)))
1010    const isEmpty = rows.length === 0
1011    // A dock is as tall as its room: fill it, so the actions and the hint line sit at its foot. An
1012    // inline pane's frame fits the tree, so it keeps its own height.
1013    const room = e.props.placement === 'dock' ? (e.props.scroll as { bodyRows?: number } | undefined)?.bodyRows : undefined
1014
1015    return (
1016      <Box flexDirection="column" {...(room === undefined ? {} : { minHeight: room })}>
1017        {/* The mod's name and a dim rule across the rest of the line. */}
1018        <Box key="header" flexDirection="row" gap={1} marginBottom={1}>
1019          <Text bold>{COMMAND}</Text>
1020          <Text dimColor>{'─'.repeat(Math.max(0, e.props.bodyColumns - cells(COMMAND) - 1))}</Text>
1021        </Box>
1022        <Box key="count" flexDirection="column" marginBottom={1}>
1023          <Box flexDirection="row" gap={1}>
1024            <Text bold dimColor={total === 0}>
1025              {String(total)}
1026            </Text>
1027            {bar()}
1028          </Box>
1029          <Box paddingLeft={cells(String(total)) + 1}>{legend}</Box>
1030        </Box>
1031        <Box key="switches" flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2} marginBottom={1}>
1032          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1033            {switcher(
1034              parts,
1035              e.surface,
1036              'view',
1037              '',
1038              'v',
1039              [
1040                { value: 'open', label: word('Open', '未完成') },
1041                { value: 'resolved', label: word('Last 7 days', '最近 7 天完成') },
1042              ],
1043              which,
1044              value =>
1045                void update($, view, () => value as PaneView)
1046                  .then(() => update($, kept, () => []))
1047                  .then(() => update($, focused, () => '')),
1048            )}
1049            {!isEmpty &&
1050              which === 'open' &&
1051              switcher(
1052                parts,
1053                e.surface,
1054                'group',
1055                '',
1056                'g',
1057                [
1058                  { value: 'kind', label: word('By kind', '依種類') },
1059                  { value: 'none', label: word('No groups', '不分組') },
1060                  { value: 'branch', label: word('By branch', '依 branch') },
1061                ],
1062                grouping,
1063                value => void update($, group, () => value as PaneGroup),
1064              )}
1065            {!isEmpty &&
1066              switcher(
1067                parts,
1068                e.surface,
1069                'order',
1070                '',
1071                's',
1072                [
1073                  { value: 'old', label: word('Oldest first', '舊到新') },
1074                  { value: 'new', label: word('Newest first', '新到舊') },
1075                ],
1076                newest ? 'new' : 'old',
1077                value => void update($, order, () => value as PaneOrder),
1078              )}
1079          </Box>
1080          {which === 'open' &&
1081            open.length > 0 &&
1082            action('copy', 'c', word('Copy all', '複製全部'), () =>
1083              void copyList($, lang, e.surface).then(text => $.ui.toast(`open-items${word(': ', ':')}${text.split('\n')[0]}`)),
1084            )}
1085        </Box>
1086        {isEmpty &&
1087          emptyState(
1088            parts,
1089            which === 'open'
1090              ? word(
1091                  'No open items (they show here when Claude tracks a decision, a question, a bug or a deferred item).',
1092                  '沒有未完成的項目(Claude 登記待決定、問題、bug 或延後的事情時會列在這裡)。',
1093                )
1094              : word('Nothing resolved in the last 7 days.', '最近 7 天沒有完成的項目。'),
1095          )}
1096        {!isEmpty && groups()}
1097        {/* Takes up what the list leaves, so the keys sit at the bottom of a docked pane. */}
1098        <Box key="spacer" flexGrow={1} />
1099        {!isEmpty && (
1100          <Box key="actions" flexDirection="row" gap={2} marginTop={2}>
1101            {which === 'open'
1102              ? [
1103                  action('done', 'd', word('Done', '完成'), onTarget(item => actOnRow($, lang, item.id, 'done'))),
1104                  action('drop', 'x', word("Won't do", '不做'), onTarget(item => actOnRow($, lang, item.id, 'dropped'))),
1105                  quote,
1106                ]
1107              : [action('reopen', 'd', word('Reopen', '重新打開'), onTarget(item => actOnRow($, lang, item.id, 'done'))), quote]}
1108          </Box>
1109        )}
1110        {hintLine(parts, lang, isEmpty ? [] : [{ en: 'Tab/↑↓ moves', 'zh-TW': 'Tab/↑↓ 移動' }])}
1111      </Box>
1112    )
1113  })
1114}
1115
hooks/config.ts 792 lines
1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23  return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28  return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33  return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45  category: Category
46  type: 'boolean' | 'number' | 'string' | 'list'
47  default: Value
48  repo: RepoRule
49  description: string | Text
50  min?: number
51  max?: number
52  // The values a string may take.
53  choices?: readonly string[]
54  // A feature `/<mod> on|off` switches: the value each one sets.
55  toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63  category: 'plain',
64  type: 'string',
65  default: 'en',
66  repo: 'never',
67  choices: ['en', 'zh-TW'],
68  description: {
69    en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70    'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71  },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77  // The language the values come to (`language`, English without it).
78  lang: Lang
79  values: Readonly<Record<string, Value>>
80  sources: Readonly<Record<string, Layer>>
81  ignored: readonly Ignored[]
82  // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83  until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91  options: PluginOptions
92  user: Raw
93  repo: Raw
94  local: Raw
95  session: Raw
96  // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97  until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115  default: { en: 'default', 'zh-TW': '預設' },
116  user: { en: 'user', 'zh-TW': '使用者' },
117  repo: { en: 'repo', 'zh-TW': 'repo' },
118  local: { en: 'local', 'zh-TW': 'local' },
119  session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123  protect: { en: 'protection', 'zh-TW': '保護' },
124  cost: { en: 'cost', 'zh-TW': '花費' },
125  plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129  any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130  'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131  'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132  'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133  'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134  append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135  never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139  return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143  return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150  switch (spec.type) {
151    case 'boolean':
152      return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153    case 'number': {
154      if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155        return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156      }
157      if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158        const min = spec.min ?? '-∞'
159        const max = spec.max ?? '∞'
160
161        return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162      }
163
164      return { value: raw }
165    }
166    case 'string':
167      if (typeof raw !== 'string') {
168        return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169      }
170      if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171        return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172      }
173
174      return { value: raw }
175    case 'list':
176      return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177        ? { value: raw as readonly string[] }
178        : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179  }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185  const t = text.trim()
186  switch (spec.type) {
187    case 'boolean': {
188      const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189      const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191      return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192    }
193    case 'number':
194      return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195    case 'string':
196      return parseValue(spec, t, lang)
197    case 'list': {
198      if (t.startsWith('[')) {
199        try {
200          return parseValue(spec, JSON.parse(t), lang)
201        } catch {
202          return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203        }
204      }
205
206      return { value: t === '' ? [] : [t] }
207    }
208  }
209}
210
211function same(a: Value, b: Value): boolean {
212  return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217  // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218  if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219    return { value }
220  }
221  switch (spec.repo) {
222    case 'only-true':
223      return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224    case 'only-false':
225      return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226    case 'only-lower':
227      return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228    case 'only-higher':
229      return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230    case 'append':
231      return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232    case 'never':
233      return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234  }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240  return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244  let value = spec.default
245  let source: Layer = 'default'
246  const take = (layer: Layer, raw: unknown) => {
247    if (raw === undefined) {
248      return
249    }
250    const parsed = parseValue(spec, raw, lang)
251    if ('error' in parsed) {
252      ignored.push({ key, layer, reason: parsed.error })
253
254      return
255    }
256    const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257    if ('error' in applied) {
258      ignored.push({ key, layer, reason: applied.error })
259
260      return
261    }
262    if (layer === 'user' && same(applied.value, spec.default)) {
263      // `/config` hands over defaults filled in: the same value is no choice of the person's.
264      return
265    }
266    value = applied.value
267    source = layer
268  }
269  // Lists have no `/config` row; their user layer is the file.
270  take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271  take('repo', layers.repo[key])
272  take('local', layers.local[key])
273  take('session', layers.session[key])
274
275  return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279  const values: Record<string, Value> = {}
280  const sources: Record<string, Layer> = {}
281  const ignored: Ignored[] = []
282  // The language first: the other keys' reasons are worded in it.
283  const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284  const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285  for (const [key, spec] of Object.entries(specs)) {
286    const one = resolveKey(key, spec, layers, lang, ignored)
287    values[key] = one.value
288    sources[key] = one.source
289  }
290  for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291    for (const key of Object.keys(layers[layer])) {
292      const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293      if (spec === undefined) {
294        ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295      } else if (layer === 'user' && !isFileValue(key, spec)) {
296        ignored.push({
297          key,
298          layer,
299          reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300        })
301      }
302    }
303  }
304
305  const until: Record<string, number> = {}
306  for (const [key, at] of Object.entries(layers.until ?? {})) {
307    if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308      until[key] = at
309    }
310  }
311
312  return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317  const t = text.trim().toLowerCase()
318  const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319  if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320    return undefined
321  }
322  const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324  return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329  const minutes = Math.ceil(ms / 60_000)
330  if (ms < 60_000) {
331    return '<1m'
332  }
333  const h = Math.floor(minutes / 60)
334  const m = minutes % 60
335
336  return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341  const parts = Object.entries(until)
342    .filter(([, at]) => at > now)
343    .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345  return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350  const times = Object.values(resolved.until)
351
352  return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356  return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360  const lang = resolved.lang
361  const keys = Object.keys(specs)
362  if (keys.length === 0) {
363    return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364  }
365  const lines = [
366    pick(lang, {
367      en: `${mod} settings (precedence: session > local > repo > user > default)`,
368      'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369    }),
370  ]
371  for (const key of keys) {
372    const spec = specs[key] as KeySpec
373    const value = resolved.values[key] as Value
374    const source = resolved.sources[key] as Layer
375    const at = resolved.until[key]
376    const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377    const kind = pick(lang, CATEGORY_NAMES[spec.category])
378    const rule = pick(lang, RULE_NAMES[spec.repo])
379    lines.push(`  ${key} = ${show(value)}  [${pick(lang, LAYER_NAMES[source])}${left}]  ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380    lines.push(`    ${say(lang, spec.description)}`)
381  }
382  if (resolved.ignored.length > 0) {
383    lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384    for (const item of resolved.ignored) {
385      const layer = pick(lang, LAYER_NAMES[item.layer])
386      lines.push(pick(lang, { en: `  ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': `  ${layer} 的 ${item.key}:${item.reason}` }))
387    }
388  }
389  lines.push(
390    pick(lang, {
391      en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392      'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393    }),
394  )
395  const toggles = toggleKeys(specs)
396  if (toggles.length > 0) {
397    const names = listOf(lang, toggles)
398    const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399    lines.push(
400      pick(lang, {
401        en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402        'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403      }),
404    )
405  }
406
407  return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411  if (text.trim() === '') {
412    return {}
413  }
414  const parsed: unknown = JSON.parse(text)
415  const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417  return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422  const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423  const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424  const section = { ...modSection(text, mod) }
425  if (value === undefined) {
426    delete section[key]
427  } else {
428    section[key] = value
429  }
430  if (Object.keys(section).length === 0) {
431    delete all[mod]
432  } else {
433    all[mod] = section
434  }
435
436  return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440  const line = `/${LOCAL_FILE}`
441  if (text.split('\n').some(l => l.trim() === line)) {
442    return undefined
443  }
444
445  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452  const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453  if (targets.length > 1) {
454    return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455  }
456  const text = args.replace(TARGET_FLAG, '$1').trim()
457  const key = text.split(/\s+/)[0] ?? ''
458  if (key === '') {
459    return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460  }
461
462  return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470  // The file's text, '' when it is missing.
471  read: (path: string) => Promise<string>
472  write: (path: string, text: string) => Promise<void>
473  isDir: (path: string) => Promise<boolean>
474  // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475  configDir: () => Promise<string>
476  root: () => Promise<string>
477  // The main working tree, whose `.git/info/exclude` covers every worktree.
478  repoRoot: () => Promise<string | undefined>
479  sessionId: () => Promise<string>
480  now: () => Promise<number>
481  storeGet: (key: string) => Promise<unknown>
482  storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490  try {
491    const own = modSection(text, mod)
492    const shared = modSection(text, SHARED)
493    for (const key of Object.keys(shared)) {
494      if (key !== 'language') {
495        broken.push({
496          key: `${SHARED}.${key}`,
497          layer,
498          reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499        })
500      }
501    }
502
503    return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504  } catch {
505    broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507    return {}
508  }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517  const now = await io.now()
518  const kept: Record<string, T & { written: number }> = {}
519  for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520    if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521      kept[other] = stored
522    }
523  }
524  kept[id] = { ...entry, written: now }
525  await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529  const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531  return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536  const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537  if (stored === undefined) {
538    return { values: {}, until: {} }
539  }
540  const timed = Object.entries(stored.until ?? {})
541  // Read only when needed: a mod without features to switch never asks the clock.
542  const now = timed.length === 0 ? 0 : await io.now()
543  const values: Record<string, unknown> = { ...stored.values }
544  const until: Record<string, number> = {}
545  for (const [key, at] of timed) {
546    if (at > now) {
547      until[key] = at
548    } else {
549      delete values[key]
550    }
551  }
552
553  return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557  const current = await sessionValues(io)
558  const values: Record<string, unknown> = { ...current.values }
559  const times = { ...current.until }
560  delete values[key]
561  delete times[key]
562  if (value !== undefined) {
563    values[key] = value
564  }
565  if (value !== undefined && until !== undefined) {
566    times[key] = until
567  }
568  await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573  const sessionId = await io.sessionId()
574  const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575  if (keys.includes(key)) {
576    return false
577  }
578  await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580  return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584  const root = (await io.root()).replace(/\/$/, '')
585
586  return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590  const paths = await filePaths(io)
591  const broken: Problem[] = []
592  const session = await sessionValues(io)
593  const layers = {
594    options,
595    user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596    repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597    local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598    session: session.values,
599    until: session.until,
600  }
601
602  return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606  const { layers, broken } = await loadLayers(io, mod, options)
607  const resolved = resolve(specs, layers)
608
609  const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611  return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615  if (target === 'session') {
616    await writeSession(io, key, value, undefined)
617
618    return
619  }
620  const paths = await filePaths(io)
621  const path = target === 'repo' ? paths.repo : paths.local
622  await io.write(path, withSetting(await io.read(path), mod, key, value))
623  if (target === 'local') {
624    const repo = await io.repoRoot()
625    if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626      const exclude = `${repo}/.git/info/exclude`
627      const added = withLocalExclude(await io.read(exclude))
628      if (added !== undefined) {
629        await io.write(exclude, added)
630      }
631    }
632  }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638  io: ConfigIO,
639  mod: string,
640  specs: Specs,
641  options: PluginOptions,
642  args: string,
643  reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645  const sub = args.trim().split(/\s+/)[0] ?? ''
646  const rest = args.trim().slice(sub.length)
647  if (sub === 'status') {
648    const resolved = await loadConfig(io, mod, specs, options)
649    reload(resolved)
650
651    return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652  }
653  if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654    return switchFeature(io, mod, specs, options, sub, rest, reload)
655  }
656  if (sub !== 'set' && sub !== 'unset') {
657    return undefined
658  }
659  const before = await loadLayers(io, mod, options)
660  const lang = resolve(specs, before.layers).lang
661  const parsed = parseSetArgs(rest, lang)
662  if ('error' in parsed) {
663    return parsed.error
664  }
665  const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666  if (spec === undefined) {
667    return pick(lang, {
668      en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669      'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670    })
671  }
672  let value: Value | undefined
673  if (sub === 'set') {
674    const input = parseInput(spec, parsed.rest, lang)
675    if ('error' in input) {
676      return `${parsed.key} ${input.error}`
677    }
678    value = input.value
679  }
680  if (parsed.target === 'repo' && value !== undefined) {
681    // Do not commit a value every reader would ignore.
682    const { layers } = before
683    const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684    const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685    if (refused !== undefined) {
686      return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687    }
688  }
689  await writeSetting(io, mod, parsed.key, value, parsed.target)
690  const resolved = await loadConfig(io, mod, specs, options)
691  reload(resolved)
692  const shown = show(resolved.values[parsed.key] as Value)
693  const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694  const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695  const where =
696    parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697  const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699  return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703  return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708  const toggles = toggleKeys(specs)
709  let key: string | undefined
710  let ms: number | undefined
711  for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712    if (key === undefined && toggles.includes(word)) {
713      key = word
714      continue
715    }
716    const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717    if (duration === undefined) {
718      const names = listOf(lang, toggles)
719      const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721      return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722    }
723    ms = duration
724  }
725  if (key === undefined && toggles.length === 1) {
726    key = toggles[0]
727  }
728  if (key === undefined) {
729    return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730  }
731
732  return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736  io: ConfigIO,
737  mod: string,
738  specs: Specs,
739  options: PluginOptions,
740  sub: 'on' | 'off',
741  args: string,
742  reload: (resolved: Resolved) => void,
743): Promise<string> {
744  const { layers } = await loadLayers(io, mod, options)
745  const lang = resolve(specs, layers).lang
746  const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747  if ('error' in parsed) {
748    return parsed.error
749  }
750  const { key, ms } = parsed
751  const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752  const rest: Record<string, unknown> = { ...layers.session }
753  delete rest[key]
754  const below = resolve(specs, { ...layers, session: rest })
755  const belowValue = below.values[key] as Value
756  const isOnBelow = !same(belowValue, toggle.off)
757  const now = await io.now()
758  const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759  if (sub === 'on') {
760    if (isOnBelow) {
761      const wasOff = Object.hasOwn(layers.session, key)
762      await writeSession(io, key, undefined, undefined)
763      reload(await loadConfig(io, mod, specs, options))
764      const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766      return wasOff
767        ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768        : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769    }
770    await writeSession(io, key, toggle.on, now + ms)
771    reload(await loadConfig(io, mod, specs, options))
772    const at = new Date(now + ms)
773    const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774    const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776    return pick(lang, {
777      en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778      'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779    })
780  }
781  // Off for the rest of the session; nothing to keep when the layers below are off already.
782  await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783  reload(await loadConfig(io, mod, specs, options))
784
785  return isOnBelow
786    ? pick(lang, {
787        en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788        'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789      })
790    : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792
hooks/ui.tsx 114 lines
1// The parts every mod's screens share, as DESIGN.md sets them out. Mods cannot import outside their
2// own folder, so each mod that draws a pane or a band keeps a copy; scripts/check-copies.sh checks
3// they match.
4import type { Elements, RenderNode } from 'claude-code'
5
6import { pick, type Lang } from './config'
7
8type Kit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text'>
9type Words = { en: string; 'zh-TW': string }
10
11// What a color means: yellow waits on the user, red is an error, an interruption or a risk.
12export const TONE = { attention: 'yellow', danger: 'red' } as const
13
14// A card, and the yellow frame of a pane that holds Claude until the user answers.
15export const CARD = { borderStyle: 'round', borderDimColor: true, paddingX: 1 } as const
16export const CONFIRM = { borderStyle: 'round', borderColor: TONE.attention, paddingX: 1 } as const
17
18// Where a part of a bar comes from: `ink` on the terminal, `fill` in desktop's Svg. Main session
19// purple, subagent cyan, named model yellow, default model gray, fork green.
20export const COLORS = {
21  main: { ink: 'magenta', fill: '#8a5cc7' },
22  sub: { ink: 'cyan', fill: '#2f8fb0' },
23  named: { ink: 'yellow', fill: '#c98a12' },
24  default: { ink: 'gray', fill: '#8b8b93' },
25  fork: { ink: 'green', fill: '#3f9a4f' },
26} as const
27
28// The main session's parts (its models), one after another in the same bar.
29export const MAIN_SHADES = [COLORS.main, { ink: 'magentaBright', fill: '#b48ee6' }, { ink: 'magenta', fill: '#6b3fa8' }]
30
31export type Option = { value: string; label: string }
32
33// Switches in place, so nothing below moves (a Select pushes it down when it opens): in the
34// terminal one button that steps to the next value (its hotkey too) and wraps, elsewhere a segment
35// per value with the hotkey on the one after the current, so it steps forward the same way.
36export function switcher(
37  { Box, Button, Text }: Kit,
38  surface: string,
39  key: string,
40  label: string,
41  hotkey: string,
42  options: Option[],
43  value: string,
44  onPick: (value: string) => void,
45): RenderNode {
46  const at = Math.max(0, options.findIndex(option => option.value === value))
47  if (surface === 'terminal') {
48    const next = options[(at + 1) % options.length]!.value
49
50    return (
51      <Box key={`switch:${key}`} flexDirection="row" flexShrink={0}>
52        {label !== '' && <Text dimColor>{label}</Text>}
53        <Button key={key} plain hotkey={hotkey} onPress={() => onPick(next)}>
54          <Text bold>‹ {options[at]?.label ?? value} ›</Text>
55        </Button>
56      </Box>
57    )
58  }
59
60  return (
61    <Box key={`switch:${key}`} flexDirection="row" gap={1} flexShrink={0}>
62      {label !== '' && <Text dimColor>{label}</Text>}
63      {options.map((option, i) => (
64        <Button
65          key={`${key}:${option.value}`}
66          label={option.label}
67          {...(i === (at + 1) % options.length ? { hotkey } : {})}
68          {...(i === at ? { variant: 'primary' as const } : { dimColor: true })}
69          onPress={() => onPick(option.value)}
70        />
71      ))}
72    </Box>
73  )
74}
75
76// A pane's last line: each key and what it does, then how to close it unless `closes` is false
77// (a pane that waits for an answer). The key keeps the default color and the rest is dim, so the
78// eye finds the keys first: the first word ("d done"), or the key in brackets ("‹ Back (b) returns").
79export function hintLine({ Box, Text }: Kit, lang: Lang, items: Words[], closes = true): RenderNode {
80  const all = closes ? [...items, { en: 'Esc closes', 'zh-TW': 'Esc 關閉' }] : items
81  const parts = all.flatMap((item, i) => {
82    const text = pick(lang, item)
83    const m = /^(.*?[((])(\S)([))].*)$/.exec(text) ?? (text.startsWith('‹') ? null : /^()(\S+)(.*)$/.exec(text))
84    const sep = i === 0 ? [] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>]
85    if (m === null) {
86      return [...sep, <Text key={`all${i}`} dimColor>{text}</Text>]
87    }
88
89    return [
90      ...sep,
91      ...(m[1] === '' ? [] : [<Text key={`pre${i}`} dimColor>{m[1]}</Text>]),
92      <Text key={`key${i}`}>{m[2]}</Text>,
93      <Text key={`does${i}`} dimColor>{m[3]}</Text>,
94    ]
95  })
96
97  return (
98    <Box key="hint" marginTop={1}>
99      <Text wrap="wrap">{parts}</Text>
100    </Box>
101  )
102}
103
104// What a pane shows when it has nothing to list; under a header, the caller sets it a line apart.
105export function emptyState({ Box, Text }: Kit, text: string): RenderNode {
106  return (
107    <Box key="empty">
108      <Text dimColor wrap="wrap">
109        {text}
110      </Text>
111    </Box>
112  )
113}
114
hooks/items.ts 610 lines
1import type { ItemKind, LogEntry, OpenItem, OptionRef, Stored, Tombstone } from '../types'
2import { pick, type Lang, type Text } from './config'
3
4export const MAX_TEXT = 80
5export const KINDS: readonly ItemKind[] = ['decision', 'question', 'deferred', 'bug']
6
7export function clip(text: string, max = MAX_TEXT): string {
8  const one = text.replace(/\s+/g, ' ').trim()
9
10  return one.length > max ? `${one.slice(0, max - 1)}…` : one
11}
12
13export function nextId(items: readonly OpenItem[]): string {
14  const top = items.reduce((max, item) => Math.max(max, Number(item.id.slice(1)) || 0), 0)
15
16  return `o${top + 1}`
17}
18
19/** Keeps stored ids that are still free, then numbers the rest after them in order. */
20export function renumber(kept: readonly OpenItem[], fresh: readonly OpenItem[]): OpenItem[] {
21  const used = new Set<string>()
22  const out: OpenItem[] = []
23  const later: OpenItem[] = []
24  for (const item of kept) {
25    if (/^o\d+$/.test(item.id) && !used.has(item.id)) {
26      used.add(item.id)
27      out.push(item)
28    } else {
29      later.push(item)
30    }
31  }
32
33  return [...later, ...fresh].reduce<OpenItem[]>((list, item) => [...list, { ...item, id: nextId(list) }], out)
34}
35
36/** At most `max` items, dropping the oldest resolved ones before any open one. */
37export function trimItems(items: readonly OpenItem[], max: number): OpenItem[] {
38  let extra = items.length - max
39  if (extra <= 0) {
40    return [...items]
41  }
42  const dropDone = new Set(
43    items
44      .filter(item => item.isDone)
45      .slice(0, extra)
46      .map(item => item.uid),
47  )
48  extra -= dropDone.size
49  const kept = items.filter(item => !dropDone.has(item.uid))
50
51  return kept.slice(extra)
52}
53
54// ---- Duplicates ----
55
56/** The text as compared for duplicates: width-folded, lower case, no spaces or punctuation. */
57export function normalize(text: string): string {
58  return text.normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]/gu, '')
59}
60
61// Shorter texts than this only match when equal: "bug" is in too many texts to mean the same item.
62const MIN_CONTAINED = 4
63
64/** The first open item whose text equals `text` or contains it (or is contained), once normalized. */
65export function findSimilar(items: readonly OpenItem[], text: string): OpenItem | undefined {
66  const target = normalize(text)
67  if (target === '') {
68    return undefined
69  }
70
71  return items.find(item => {
72    if (item.isDone) {
73      return false
74    }
75    const other = normalize(item.text)
76    if (other === target) {
77      return true
78    }
79    const [short, long] = other.length < target.length ? [other, target] : [target, other]
80
81    return short.length >= MIN_CONTAINED && long.includes(short)
82  })
83}
84
85// ---- What is kept across sessions ----
86
87// Resolved items are kept as tombstones this long, so another session cannot bring them back.
88export const TOMBSTONE_MS = 30 * 24 * 60 * 60 * 1000
89const MAX_TOMBSTONES = 500
90
91const EMPTY: Stored = { items: [], done: [] }
92
93// Items stored before uids existed get one from what both sessions read alike.
94function withUid(item: OpenItem): OpenItem {
95  return typeof item.uid === 'string' && item.uid !== ''
96    ? item
97    : { ...item, uid: `legacy:${item.createdAt}:${item.kind}:${item.text}` }
98}
99
100function isItem(value: unknown): value is OpenItem {
101  const item = value as OpenItem
102  return typeof item === 'object' && item !== null && typeof item.id === 'string' && typeof item.text === 'string'
103}
104
105function isTombstone(value: unknown): value is Tombstone {
106  const t = value as Tombstone
107  return typeof t === 'object' && t !== null && typeof t.uid === 'string' && typeof t.doneAt === 'number'
108}
109
110// A tombstone's item is dropped when unreadable, so a bad one cannot reach the pane.
111function readTombstone(t: Tombstone): Tombstone {
112  return t.item === undefined || isItem(t.item) ? t : { uid: t.uid, doneAt: t.doneAt }
113}
114
115/** A stored value as `{ items, done }`; the older form was a bare array of open items. */
116export function readStored(value: unknown): Stored {
117  if (Array.isArray(value)) {
118    return { items: value.filter(isItem).filter(item => !item.isDone).map(withUid), done: [] }
119  }
120  const stored = value as Partial<Stored> | undefined
121  if (typeof stored !== 'object' || stored === null) {
122    return EMPTY
123  }
124
125  return {
126    items: Array.isArray(stored.items) ? stored.items.filter(isItem).map(withUid) : [],
127    done: Array.isArray(stored.done) ? stored.done.filter(isTombstone).map(readTombstone) : [],
128  }
129}
130
131/** A session's list in the stored form: its open items, and a tombstone for each resolved one. */
132export function toStored(items: readonly OpenItem[], now: number): Stored {
133  return {
134    items: items.filter(item => !item.isDone),
135    done: items.filter(item => item.isDone).map(item => ({ uid: item.uid, doneAt: item.doneAt ?? now, item })),
136  }
137}
138
139/**
140 * Both sides merged by uid: a tombstone on either side wins over an open item, so an item
141 * resolved in one session stays resolved; `b`'s copy of an item replaces `a`'s in place.
142 */
143export function combine(a: Stored, b: Stored, now: number, max: number): Stored {
144  const done = new Map<string, Tombstone>()
145  for (const t of [...a.done, ...b.done]) {
146    const had = done.get(t.uid)
147    const item = t.item ?? had?.item
148    done.set(t.uid, { uid: t.uid, doneAt: Math.max(had?.doneAt ?? 0, t.doneAt), ...(item === undefined ? {} : { item }) })
149  }
150  const open = new Map<string, OpenItem>()
151  for (const item of [...a.items, ...b.items]) {
152    if (!done.has(item.uid)) {
153      open.set(item.uid, { ...item, isDone: false })
154    }
155  }
156  // The item is kept only while the pane lists it (STALE_MS), the uid for TOMBSTONE_MS.
157  const tombstones = [...done.values()]
158    .map(t => (t.item !== undefined && t.doneAt <= now - STALE_MS ? { uid: t.uid, doneAt: t.doneAt } : t))
159    .filter(t => t.doneAt > now - TOMBSTONE_MS)
160    .sort((x, y) => x.doneAt - y.doneAt)
161    .slice(-MAX_TOMBSTONES)
162
163  return { items: [...open.values()].slice(-max), done: tombstones }
164}
165
166/**
167 * Items resolved in the last 7 days that `have` (the session's list) lacks, newest last, for the
168 * pane's resolved list. One reopened since (a uid carrying the old one plus `:r`) is left out.
169 */
170export function recentResolved(stored: Stored, have: readonly OpenItem[], now: number): OpenItem[] {
171  const known = [...stored.items.map(item => item.uid), ...stored.done.map(t => t.uid), ...have.map(item => item.uid)]
172  const mine = new Set(have.map(item => item.uid))
173
174  return stored.done
175    .filter(t => t.item !== undefined && t.doneAt > now - STALE_MS && !mine.has(t.uid))
176    .filter(t => !known.some(uid => uid.startsWith(`${t.uid}:r`)))
177    .map(t => ({ ...t.item!, uid: t.uid, isDone: true, doneAt: t.doneAt }))
178}
179
180const MAX_LOG = 300
181
182function isLogEntry(value: unknown): value is LogEntry {
183  const entry = value as LogEntry
184  return typeof entry === 'object' && entry !== null && typeof entry.uid === 'string' && typeof entry.text === 'string'
185}
186
187/** The repo's resolution log as stored; anything unreadable is left out. */
188export function readLog(value: unknown): LogEntry[] {
189  return Array.isArray(value) ? value.filter(isLogEntry) : []
190}
191
192/** The log with `entry` last (replacing an earlier entry of the same item), at most the newest 300. */
193export function appendLog(log: readonly LogEntry[], entry: LogEntry): LogEntry[] {
194  return [...log.filter(e => e.uid !== entry.uid), entry].slice(-MAX_LOG)
195}
196
197/** Whether resolving an item this way is worth a log entry: a decision, a drop, or any note. */
198export function isLogged(item: OpenItem): boolean {
199  return item.kind === 'decision' || item.outcome === 'dropped' || (item.note ?? '') !== ''
200}
201
202const HISTORY_SHOWN = 20
203
204/** The newest log entries matching `query` (all when empty), for the model. */
205export function historyLines(log: readonly LogEntry[], query: string, now: number, lang: Lang = 'en'): string {
206  const q = normalize(query)
207  const hits = log.filter(e => q === '' || normalize(`${e.text} ${e.note}`).includes(q)).slice(-HISTORY_SHOWN).reverse()
208  if (hits.length === 0) {
209    return q === ''
210      ? pick(lang, { en: 'No resolved decisions or dropped items yet.', 'zh-TW': '還沒有已解決的決定或已放棄的項目。' })
211      : pick(lang, { en: `Nothing in the log matches "${query}".`, 'zh-TW': `紀錄裡沒有符合「${query}」的項目。` })
212  }
213
214  return hits
215    .map(e => {
216      const how =
217        e.outcome === 'dropped' ? pick(lang, { en: 'dropped', 'zh-TW': '已放棄' }) : pick(lang, { en: 'resolved', 'zh-TW': '已解決' })
218      const note = e.note === '' ? '' : ` — ${e.note}`
219      const kind = lang === 'zh-TW' ? kindLabel(e.kind, lang) : e.kind
220
221      return `- [${kind}, ${how} ${ageText(now - e.at, lang)}] ${e.text}${note}`
222    })
223    .join('\n')
224}
225
226// ---- Source B: option lists in the assistant's answer ----
227
228const LABEL_LINE: readonly RegExp[] = [
229  // **A** text, **A.** text, **(A)** text, **A:text**
230  /^\s*(?:[-*]\s+)?\*\*\(?([A-Za-z]|\d{1,2})\)?[.)::]?\*\*[.)::]?\s*(.*)$/,
231  // (a) text
232  /^\s*(?:[-*]\s+)?\(([A-Za-z]|\d{1,2})\)\s*(.*)$/,
233  // A. text, A) text, 1. text
234  /^\s*(?:[-*]\s+)?([A-Za-z]|\d{1,2})[.)]\s+(.*)$/,
235  // A:text, A: text
236  /^\s*(?:[-*]\s+)?([A-Za-z]|\d{1,2})\s*[::]\s*(.+)$/,
237]
238
239function parseLabelLine(line: string): OptionRef | undefined {
240  for (const pattern of LABEL_LINE) {
241    const m = pattern.exec(line)
242    if (m?.[1] !== undefined) {
243      const text = clip((m[2] ?? '').replace(/\*\*/g, '').replace(/^[::\-–—\s]+/, ''), 60)
244
245      return { label: m[1].toUpperCase(), text }
246    }
247  }
248
249  return undefined
250}
251
252function follows(prev: string, label: string): boolean {
253  if (/^\d+$/.test(prev) && /^\d+$/.test(label)) {
254    return Number(label) === Number(prev) + 1
255  }
256
257  return prev.length === 1 && label.length === 1 && label.charCodeAt(0) === prev.charCodeAt(0) + 1
258}
259
260function startsList(label: string): boolean {
261  return label === 'A' || label === '1'
262}
263
264const QUESTION = /[??]$/
265
266function isQuestion(line: string): boolean {
267  return QUESTION.test(line.trim().replace(/[*_))」』\s]+$/, ''))
268}
269
270// At most this many lines of prose may follow a list that is not followed by a question.
271const MAX_TRAILING = 2
272
273/**
274 * Whether a list whose last option is on line `end` is what the answer closes on: only a short
275 * closing follows it, or a question does. Indented lines right after it still belong to it.
276 */
277function closesAnswer(lines: readonly string[], end: number): boolean {
278  let rest = lines.slice(end + 1).filter(line => line.trim() !== '')
279  const continued = rest.findIndex(line => !/^\s/.test(line))
280  rest = continued === -1 ? [] : rest.slice(continued)
281
282  return rest.length <= MAX_TRAILING || rest.slice(0, MAX_TRAILING).some(isQuestion)
283}
284
285/**
286 * The latest list of 2..8 consecutive labelled options (A B C… or 1 2 3…) in an answer, when the
287 * answer ends on it or on a question right after it; a list in the middle of an answer (steps,
288 * a summary) is not a choice put to the person.
289 */
290export function parseOptions(answer: string): OptionRef[] {
291  const lines = answer.split('\n')
292  const groups: { options: OptionRef[]; end: number }[] = []
293  let current: { options: OptionRef[]; end: number } | undefined
294  lines.forEach((line, index) => {
295    const option = parseLabelLine(line)
296    if (option === undefined) {
297      return
298    }
299    const last = current?.options[current.options.length - 1]
300    if (current !== undefined && last !== undefined && follows(last.label, option.label)) {
301      current.options.push(option)
302      current.end = index
303    } else if (startsList(option.label)) {
304      current = { options: [option], end: index }
305      groups.push(current)
306    } else if (last !== undefined) {
307      // A stray label ends the list being read.
308      current = undefined
309    }
310  })
311  const lists = groups.filter(group => group.options.length >= 2 && group.options.length <= 8)
312  const latest = lists[lists.length - 1]
313
314  return latest !== undefined && closesAnswer(lines, latest.end) ? latest.options : []
315}
316
317/** Whether the answer closes on a question (one of its last two lines, as a ➡️ hint may follow). */
318export function endsWithQuestion(answer: string): boolean {
319  return answer
320    .split('\n')
321    .filter(line => line.trim() !== '')
322    .slice(-2)
323    .some(isQuestion)
324}
325
326// Parsers of the person's words: Chinese and English whatever the language setting is.
327const ALL = /全部|全都|都做|都要|全選|全做|兩個都|all of (them|the above)|\ball\b/i
328const DEFER = /先不|不做|不要|先[^。,,;;\n]{0,4}(寫|記|當|放|列)|之後|晚點|暫緩|暫不|跳過|觀察|延後|以後|later|skip|defer|not now/i
329const REF = /\(([A-Za-z]|\d{1,2})\)|(?<![A-Za-z0-9])([A-Z]|\d{1,2})(?![A-Za-z0-9]|[個次行天分秒%])/g
330
331export type Choice = { chosen: string[]; deferred: string[]; isAll: boolean }
332
333/**
334 * Which labels a short user prompt picks. Conservative: undefined unless the prompt is
335 * short and clearly names at least one label of the remembered list (or says "all").
336 */
337export function parseChoice(prompt: string, options: readonly OptionRef[]): Choice | undefined {
338  const text = prompt.normalize('NFKC').trim()
339  if (options.length < 2 || text === '' || text.length >= 120) {
340    return undefined
341  }
342  if (ALL.test(text)) {
343    return { chosen: options.map(o => o.label), deferred: [], isAll: true }
344  }
345  const known = new Set(options.map(o => o.label))
346  const chosen = new Set<string>()
347  const deferred = new Set<string>()
348  for (const clause of text.split(/[。;;,,.\n]/)) {
349    const labels = [...clause.matchAll(REF)]
350      .map(m => (m[1] ?? m[2] ?? '').toUpperCase())
351      .filter(label => known.has(label))
352    const isDeferral = DEFER.test(clause)
353    for (const label of labels) {
354      ;(isDeferral ? deferred : chosen).add(label)
355    }
356  }
357  for (const label of deferred) {
358    chosen.delete(label)
359  }
360  if (chosen.size === 0) {
361    return undefined
362  }
363
364  return { chosen: [...chosen], deferred: [...deferred], isAll: false }
365}
366
367/** The options a prompt left unchosen, as texts for new deferred items. */
368export function unchosen(prompt: string, options: readonly OptionRef[]): OptionRef[] {
369  const choice = parseChoice(prompt, options)
370  if (choice === undefined) {
371    return []
372  }
373
374  return options.filter(option => !choice.chosen.includes(option.label))
375}
376
377// ---- Lines shown to the person and the model ----
378
379export const KIND_LABEL: Record<ItemKind, Text> = {
380  decision: { en: 'To decide', 'zh-TW': '待你決定' },
381  question: { en: 'Open questions', 'zh-TW': '未解決問題' },
382  deferred: { en: 'Deferred', 'zh-TW': '延後' },
383  bug: { en: 'Open bugs', 'zh-TW': '未解決 bug' },
384}
385
386export function kindLabel(kind: ItemKind, lang: Lang): string {
387  return pick(lang, KIND_LABEL[kind])
388}
389
390// The pane's kind column, one per row. A two-character Chinese label has a full-width space in the
391// middle, so it lines up with the three-character one.
392export const KIND_SHORT: Record<ItemKind, Text> = {
393  decision: { en: 'Decide', 'zh-TW': '待決定' },
394  question: { en: 'Question', 'zh-TW': '問 題' },
395  deferred: { en: 'Deferred', 'zh-TW': '延 後' },
396  bug: { en: 'Bug', 'zh-TW': 'bug' },
397}
398
399export function kindShort(kind: ItemKind, lang: Lang): string {
400  return pick(lang, KIND_SHORT[kind])
401}
402
403// ---- Terminal cells ----
404
405// East Asian wide and full-width characters take two cells.
406const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u
407
408export function cells(text: string): number {
409  let n = 0
410  for (const ch of text) {
411    n += WIDE.test(ch) ? 2 : 1
412  }
413
414  return n
415}
416
417/**
418 * `text` broken into lines of at most `width` cells: a Latin word moves whole to the next line
419 * when it fits on one, wide characters break anywhere.
420 */
421export function wrapCells(text: string, width: number): string[] {
422  const max = Math.max(1, width)
423  const tokens = text.match(/\s+|[!-~]+?(?=\s|[^!-~]|$)|./gsu) ?? []
424  const lines: string[] = []
425  let line = ''
426  let used = 0
427  const push = (): void => {
428    lines.push(line.trimEnd())
429    line = ''
430    used = 0
431  }
432  for (const token of tokens) {
433    const size = cells(token)
434    if (/^\s+$/.test(token)) {
435      if (used > 0 && used + size <= max) {
436        line += token
437        used += size
438      } else if (used > 0) {
439        push()
440      }
441      continue
442    }
443    if (used > 0 && used + size > max && size <= max) {
444      push()
445    }
446    for (const ch of size > max ? [...token] : [token]) {
447      const w = cells(ch)
448      if (used > 0 && used + w > max) {
449        push()
450      }
451      line += ch
452      used += w
453    }
454  }
455  if (line !== '' || lines.length === 0) {
456    push()
457  }
458
459  return lines
460}
461
462// `hint` follows the counts: the command where nothing can be pressed (the status line).
463export function bandText(items: readonly OpenItem[], hint = '  /open-items', lang: Lang = 'en'): string {
464  const open = items.filter(item => !item.isDone)
465  if (open.length === 0) {
466    return ''
467  }
468  const count = (...kinds: ItemKind[]): number => open.filter(item => kinds.includes(item.kind)).length
469  const parts = [
470    [count('decision'), pick(lang, { en: 'to decide', 'zh-TW': '待你決定' })],
471    [count('question', 'bug'), pick(lang, { en: 'unresolved', 'zh-TW': '未解決' })],
472    [count('deferred'), pick(lang, { en: 'deferred', 'zh-TW': '延後' })],
473  ] as const
474  const line = parts
475    .filter(([n]) => n > 0)
476    .map(([n, label]) => `${n} ${label}`)
477    .join(' · ')
478
479  return `${line}${hint}`
480}
481
482export function listLines(items: readonly OpenItem[]): string {
483  const open = items.filter(item => !item.isDone)
484
485  return open.length === 0 ? 'No open items.' : open.map(item => `- [${item.kind}] ${item.id}: ${item.text}`).join('\n')
486}
487
488export function compactNote(items: readonly OpenItem[]): string {
489  return `[open-items] Unresolved items tracked so far (resolve with the mcp__open-items__track tool):\n${listLines(items)}`
490}
491
492/** What the model is told of items the person resolved or dropped in the pane since its last turn. */
493export function panelNote(resolved: readonly OpenItem[]): string {
494  const lines = resolved.map(item => {
495    const how = item.outcome === 'dropped' ? 'marked as will not do' : 'resolved'
496
497    return `- ${item.id} (${how}): ${item.text}`
498  })
499
500  return `[open-items] The user closed these in the open-items panel; do not bring them up again:\n${lines.join('\n')}`
501}
502
503export function carriedNote(items: readonly OpenItem[]): string {
504  return `[open-items] Unresolved items carried over from an earlier session (resolve with the mcp__open-items__track tool):\n${listLines(items)}`
505}
506
507// ---- Age and branch ----
508
509export const STALE_MS = 7 * 24 * 60 * 60 * 1000
510
511export function ageText(ms: number, lang: Lang = 'en'): string {
512  const minutes = Math.floor(ms / 60_000)
513  if (minutes < 1) {
514    return pick(lang, { en: 'just now', 'zh-TW': '剛剛' })
515  }
516  if (minutes < 60) {
517    return pick(lang, { en: `${minutes} min ago`, 'zh-TW': `${minutes} 分鐘前` })
518  }
519  const hours = Math.floor(minutes / 60)
520  const days = Math.floor(hours / 24)
521
522  return hours < 24 ? pick(lang, { en: `${hours} h ago`, 'zh-TW': `${hours} 小時前` }) : pick(lang, { en: `${days} d ago`, 'zh-TW': `${days} 天前` })
523}
524
525/** The pane's dim line beside an item: its age and, when known, the branch it was added on. */
526export function metaText(item: OpenItem, now: number, lang: Lang = 'en'): string {
527  return [ageText(now - item.createdAt, lang), item.branch].filter(Boolean).join(' · ')
528}
529
530export function isStale(item: OpenItem, now: number): boolean {
531  return now - item.createdAt > STALE_MS
532}
533
534/** "(2 個在其他 branch、1 個超過 7 天)" for the load toast, or "" when neither applies. */
535export function loadSummary(items: readonly OpenItem[], branch: string | undefined, now: number, lang: Lang = 'en'): string {
536  const elsewhere = items.filter(item => branch !== undefined && item.branch !== undefined && item.branch !== branch).length
537  const stale = items.filter(item => isStale(item, now)).length
538  const days = STALE_MS / 86_400_000
539  const parts = [
540    elsewhere > 0 ? pick(lang, { en: `${elsewhere} on other branches`, 'zh-TW': `${elsewhere} 個在其他 branch` }) : '',
541    stale > 0 ? pick(lang, { en: `${stale} older than ${days} days`, 'zh-TW': `${stale} 個超過 ${days} 天` }) : '',
542  ].filter(Boolean)
543
544  return parts.length === 0 ? '' : pick(lang, { en: ` (${parts.join(', ')})`, 'zh-TW': `(${parts.join('、')})` })
545}
546
547// ---- Markdown for /open-items copy (and a handoff) ----
548
549export function markdownList(items: readonly OpenItem[], lang: Lang = 'en'): string {
550  const open = items.filter(item => !item.isDone)
551  if (open.length === 0) {
552    return ''
553  }
554  const groups = KINDS.map(kind => {
555    const group = open.filter(item => item.kind === kind)
556    if (group.length === 0) {
557      return ''
558    }
559    const lines = group.map(item =>
560      pick(lang, {
561        en: `- ${item.id}: ${item.text}${item.branch === undefined ? '' : ` (${item.branch})`}`,
562        'zh-TW': `- ${item.id}:${item.text}${item.branch === undefined ? '' : `(${item.branch})`}`,
563      }),
564    )
565
566    return `### ${kindLabel(kind, lang)}\n\n${lines.join('\n')}`
567  }).filter(Boolean)
568
569  return `## ${pick(lang, { en: 'Open items', 'zh-TW': '待處理項目' })}\n\n${groups.join('\n\n')}\n`
570}
571
572// ---- The pane ----
573
574// The order of the pane's kind groups, its count bar and legend: what waits on the person first.
575export const PANE_KINDS: readonly ItemKind[] = ['decision', 'bug', 'question', 'deferred']
576
577/** `width` cells shared out by `values`, each part with any value at least one cell. */
578export function splitCells(values: readonly number[], width: number): number[] {
579  const total = values.reduce((sum, value) => sum + value, 0)
580  if (total === 0) {
581    return values.map(() => 0)
582  }
583  const out = values.map(value => (value > 0 ? Math.max(1, Math.round((value / total) * width)) : 0))
584  // Rounding can miss the width by a cell or two: the largest part takes the difference.
585  const largest = out.indexOf(Math.max(...out))
586  out[largest] = Math.max(1, (out[largest] ?? 0) + width - out.reduce((sum, cell) => sum + cell, 0))
587
588  return out
589}
590
591/** Oldest first or newest first, by when the item was resolved in the resolved list, else added. */
592export function sortItems(items: readonly OpenItem[], newest: boolean, byDone: boolean): OpenItem[] {
593  const at = (item: OpenItem): number => (byDone ? (item.doneAt ?? item.createdAt) : item.createdAt)
594
595  return [...items].sort((a, b) => (newest ? at(b) - at(a) : at(a) - at(b)))
596}
597
598/** The items by branch: `current` first, the others in the order they first appear; '' has none. */
599export function branchGroups(items: readonly OpenItem[], current: string): { branch: string; items: OpenItem[] }[] {
600  const names = [...new Set(items.map(item => item.branch ?? ''))]
601  const sorted = names.includes(current) ? [current, ...names.filter(name => name !== current)] : names
602
603  return sorted.map(name => ({ branch: name, items: items.filter(item => (item.branch ?? '') === name) }))
604}
605
606/** The resolved list's dim line: how long ago it was resolved, its kind and branch. */
607export function resolvedMeta(item: OpenItem, now: number, lang: Lang = 'en'): string {
608  return [ageText(now - (item.doneAt ?? now), lang), kindShort(item.kind, lang).replace(' ', ''), item.branch].filter(Boolean).join(' · ')
609}
610
types/index.d.ts 93 lines
1export type ItemKind = 'decision' | 'question' | 'deferred' | 'bug'
2
3// 'done' = resolved, 'dropped' = will not be done (not counted as resolved).
4export type Outcome = 'done' | 'dropped'
5
6export type OpenItem = {
7  id: string
8  // Stable across sessions (ids are renumbered on load); what stored lists are merged by.
9  uid: string
10  kind: ItemKind
11  text: string
12  // 'model' = the track tool, 'auto' = parsed from a partly chosen option list.
13  source: 'model' | 'auto'
14  isDone: boolean
15  createdAt: number
16  doneAt?: number
17  outcome?: Outcome
18  // Why it was dropped, or for a decision what was decided and why.
19  note?: string
20  // The branch and commit the session was on when the item was added.
21  branch?: string
22  head?: string
23}
24
25// A resolved item in the store: kept so a session that still has it open cannot bring it back.
26// `item`: the resolved item itself, kept while it is in the pane's last 7 days list.
27export type Tombstone = { uid: string; doneAt: number; item?: OpenItem }
28
29// The store's value per repo: the open items and the tombstones of resolved ones.
30export type Stored = { items: OpenItem[]; done: Tombstone[] }
31
32// One entry of the repo's resolution log: a decision resolved with a note, or a dropped item.
33// Kept without expiry so a later session can look up why something was decided or dropped.
34export type LogEntry = {
35  uid: string
36  kind: ItemKind
37  text: string
38  outcome: Outcome
39  note: string
40  at: number
41  branch?: string
42}
43
44export type OptionRef = { label: string; text: string }
45
46// Answers that ended with a question; how many of those turns called no `track`; and how many of
47// those the person then answered at length, the ones that may have left something untracked.
48export type MissStats = { questions: number; untracked: number; missed: number }
49
50// A row closed or reopened in the pane while it is open: it stays in place until the list
51// switches or the pane opens again. `was` and `note` are what reopening took off a resolved item,
52// so pressing the same key again puts them back.
53export type KeptRow = { id: string; was?: Outcome; note?: string }
54
55// Which list the pane shows.
56export type PaneView = 'open' | 'resolved'
57
58export type PaneGroup = 'kind' | 'none' | 'branch'
59
60export type PaneOrder = 'old' | 'new'
61
62declare module 'claude-code' {
63  interface PluginState {
64    // loadedFor: the session id whose stored items were last brought in.
65    // announce: stored items were brought in and the model has not been told yet.
66    // autoDeferred: uids of the items the latest prompt deferred, undoable until the next prompt.
67    // panelResolved: uids resolved or dropped in the pane since the last prompt, told to the model with the next.
68    // tracked: the main loop called `track` since its last answer.
69    // asked: the last line of the latest answer that ended with a question and no `track` call, until the next prompt.
70    // view: the pane's list. focused: the key of the pane row the focus ring last landed on ('' for none).
71    // kept: rows closed or reopened in the pane since it opened.
72    // group, order: how the pane groups and sorts its list. unfolded: the deferred group is open.
73    // branch: the branch the session was on when the pane opened ('' when unknown).
74    'open-items': {
75      items: OpenItem[]
76      options: OptionRef[]
77      loadedFor: string
78      announce: boolean
79      autoDeferred: string[]
80      panelResolved: string[]
81      tracked: boolean
82      asked: string
83      view: PaneView
84      focused: string
85      kept: KeptRow[]
86      group: PaneGroup
87      order: PaneOrder
88      unfolded: boolean
89      branch: string
90    }
91  }
92}
93