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

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.
rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.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./clear, and with project-memory on, what you learn in one session is waiting in the next.| Mod | What it does for you |
|---|---|
secret-scrub | Masks 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-guard | Holds 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-trimmer | When 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-dispatch | Brings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn. |
open-items | Keeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press. |
session-keeper | Shows 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-check | Asks 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-memory | Once 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. |
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.
MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.
hooks/register.tsx 1115 lines1import { 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}
1115hooks/config.ts 792 lines1// 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}
792hooks/ui.tsx 114 lines1// 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}
114hooks/items.ts 610 lines1import 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}
610types/index.d.ts 93 lines1export 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