Da fare: quello che ti resta da fare dopo le risposte di Claude, con i comandi da copiare e quelli negati dai permessi

Un pannello «Da fare» con quello che resta da fare a te dopo le risposte di Claude, con i comandi pronti da copiare, così non si perdono nel testo della conversazione.
/command-deck apre il pannello «Da fare». In alto le cose da fare aperte, ognuna con i suoi comandi; sotto, «Altri comandi delle risposte»; in fondo, i fatti.
| Tasto | Cosa fa |
|---|---|
1 … 9 | Copia il comando con quel numero («Copia 1», «Copia 2»…) |
f | Mostra o nasconde l'elenco «Fatti (N)» |
x | Svuota gli altri comandi delle risposte |
I pulsanti «Fatto» (chiude una cosa da fare) e «Riapri» (la riporta tra le aperte) si premono senza tasto. Sotto un comando compaiono gli avvisi, con «⚠», se non rispetta le regole dell'autore per i comandi: Segnaposto da sostituire: <host>, «Valore da completare» (YOUR_…, TUO_…, CHANGEME), «xxx», path/to/, «...» al posto di un argomento, ssh senza -t con un comando remoto, più di un ssh, >> sul server.
Nella riga di stato: ☐ 3 da fare · /command-deck, niente quando non c'è nulla. Quando il pannello è chiuso, un avviso (toast) dice cosa è arrivato, ad esempio «2 cose da fare e 1 comando: /command-deck».
bash, sh, zsh, shell, console o terminal, e quelli senza linguaggio in cui ogni riga comincia con un programma noto (git, npm, ssh, docker…) o con ./ o !. Se un blocco ha righe che cominciano con $ , prende solo quelle, senza il $ ; righe vuote e commenti # non contano.$.model.fork: lo stesso modello della sessione rilegge la conversazione fino all'ultima richiesta, con gli strumenti negati, più la domanda. La domanda contiene l'ultima risposta di Claude, l'elenco aperto e i comandi sciolti: tutte cose già nella conversazione, quindi non escono dati nuovi. Ogni risposta costa una richiesta in più.commands), le cose da fare (todos) e se i fatti sono in vista (isDoneShown).Nessuno strumento esterno.
claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install command-deck@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install command-deck --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
Oggi i test sono 49.
hooks/register.tsx 190 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DeckCommand, Todo } from '../types'
5import { addCommands, dropCommands, newTexts, toCommands, withoutSettled } from './deck'
6import { commandsIn } from './extract'
7import { applyReply, buildTodoPrompt, closedTodos, closeTodo, deniedTodo, openTodos, parseTodoReply, readyText, reopenTodo } from './todos'
8import type { Actions } from './view'
9import { deckView } from './view'
10
11/** How the auto-mode classifier words a refusal: "Permission for this action was denied by the Claude Code auto mode classifier." */
12const CLASSIFIER_DENY = /auto mode classifier/i
13const PLUGIN = 'command-deck'
14const PANE = 'command-deck'
15const TITLE = 'Da fare'
16
17const commands = atom({ plugin: 'command-deck', key: 'commands' } as const, [])
18const todos = atom({ plugin: 'command-deck', key: 'todos' } as const, [])
19const isDoneShown = atom({ plugin: 'command-deck', key: 'isDoneShown' } as const, false)
20
21/** A question about the last answer is out; a new answer meanwhile asks again once it is back. */
22let isReading = false
23let isReadAgain = false
24/** Commands the answers added since the last toast: told together with the to-dos the question finds. */
25let unannouncedCommands = 0
26/** The last answer of the session's own, and when it came: the question needs it, the fork does not see it. */
27let lastAnswer = ''
28let lastAnswerAt: number | null = null
29
30/** Why a copy did not reach the clipboard, in the person's words. */
31const COPY_FAILURES: Record<'no-surface' | 'no-clipboard' | 'refused', string> = {
32 'no-surface': 'nessun terminale collegato',
33 'no-clipboard': 'questo terminale non dà accesso agli appunti',
34 refused: 'copia rifiutata',
35}
36
37const isPaneShown = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
38
39/** The status line: how much is left to do, nothing when nothing is. */
40const showCount = async ($: EngineInterface) => {
41 const open = openTodos(await read($, todos)).length
42 $.ui.status(open === 0 ? undefined : `☐ ${open} da fare · /command-deck`)
43}
44
45/** Changes the to-dos; the commands of the ones closed or rewritten leave the deck with them. */
46const setTodos = async ($: EngineInterface, fn: (list: Todo[]) => Todo[]) => {
47 const before = await read($, todos)
48 await update($, todos, fn)
49 const after = await read($, todos)
50 await update($, commands, deck => withoutSettled(deck, before, after))
51 await showCount($)
52}
53
54/** The deck's commands not under an open to-do: the loose ones, as shown. */
55const looseCommands = async ($: EngineInterface) => {
56 const underTodos = new Set(openTodos(await read($, todos)).flatMap(t => t.commands.map(c => c.text)))
57 return (await read($, commands)).filter(c => !underTodos.has(c.text))
58}
59
60/** Puts the commands on top of the deck; answers how many of them are new. */
61const remember = async ($: EngineInterface, fresh: DeckCommand[]): Promise<number> => {
62 const added = newTexts(await read($, commands), fresh).length
63 await update($, commands, d => addCommands(d, fresh))
64 return added
65}
66
67const afterAnswer = async ($: EngineInterface, answer: string) => {
68 const at = await $.clock.now()
69 lastAnswer = answer
70 lastAnswerAt = at
71 const texts = commandsIn(answer)
72 if (texts.length > 0) unannouncedCommands += await remember($, toCommands(texts, 'answer', at))
73}
74
75const announce = async ($: EngineInterface, todoCount: number) => {
76 const text = readyText(todoCount, unannouncedCommands)
77 unannouncedCommands = 0
78 if (text !== null && !(await isPaneShown($))) $.ui.toast(text)
79}
80
81/** Asks Claude, over this session's transcript, what is left for the person and what got done. */
82const readTodos = async ($: EngineInterface) => {
83 if (isReading) {
84 isReadAgain = true
85 return
86 }
87 isReading = true
88 $.ui.invalidate('ui.render')
89 let found = 0
90 try {
91 do {
92 isReadAgain = false
93 const before = openTodos(await read($, todos))
94 const loose = (await looseCommands($)).map(c => ({ id: c.id, text: c.text, isLatest: c.at === lastAnswerAt }))
95 const reply = await $.model.fork({ prompt: buildTodoPrompt(before, loose, lastAnswer) })
96 if (!reply.isAnswered) continue
97 // The last answer's commands were just shown: they stay at least until the next one.
98 const droppable = loose.filter(c => !c.isLatest).map(c => c.id)
99 const parsed = parseTodoReply(reply.text, before.map(t => t.id), droppable)
100 const at = await $.clock.now()
101 await update($, commands, deck => dropCommands(deck, parsed.drop))
102 await setTodos($, list => applyReply(list, parsed, at))
103 // New ones are those minted now: their ids start with this moment.
104 found += openTodos(await read($, todos)).filter(t => t.at === at && !before.some(b => b.id === t.id)).length
105 } while (isReadAgain)
106 } catch (error) {
107 $.ui.log(`${PLUGIN}: cose da fare non lette: ${String(error)}`, { to: 'debug' })
108 } finally {
109 isReading = false
110 $.ui.invalidate('ui.render')
111 // Whether the question worked or not, the commands found are told.
112 await announce($, found).catch(() => undefined)
113 }
114}
115
116/** A Bash call the permissions refused: the person runs it, so it becomes a to-do, shown. */
117const afterDenied = async ($: EngineInterface, command: string) => {
118 const at = await $.clock.now()
119 await remember($, toCommands([command], 'denied', at))
120 await setTodos($, list => applyReply(list, { add: [deniedTodo(command)], done: [], update: [], drop: [] }, at))
121 await $.ui.open({ id: PANE, title: TITLE })
122 $.ui.toast('Comando negato: copialo da /command-deck ed eseguilo tu')
123}
124
125const actionsFor = ($: EngineInterface): Actions => ({
126 copy: async (text, surface) => {
127 const copied = await $.ui.copy({ text, surface })
128 $.ui.toast(copied.isCopied ? 'Copiato' : `Non copiato: ${COPY_FAILURES[copied.reason]}`)
129 },
130 close: async t => {
131 const at = await $.clock.now()
132 await setTodos($, list => closeTodo(list, t.id, 'you', at))
133 },
134 reopen: t => setTodos($, list => reopenTodo(list, t.id)),
135 toggleDone: () => update($, isDoneShown, shown => !shown),
136 clearCommands: () => update($, commands, () => []),
137})
138
139export const register: Register = on => {
140 on('session.start', async ($, e, next) => {
141 await $.command.register({ name: 'command-deck', description: 'Quello che ti resta da fare e i comandi da copiare' })
142 await showCount($)
143 return next(e)
144 })
145
146 on('command.run', { command: 'command-deck' }, async $ => {
147 await $.ui.open({ id: PANE, title: TITLE, focus: true })
148 return { text: 'Pannello Da fare aperto: premi il numero per copiare un comando.' }
149 })
150
151 on('turn.complete', async ($, e, next) => {
152 const done = await next(e)
153 // A subagent's or a fork's answer is not the person's: no commands, no to-dos (and no question about the question).
154 if (e.agentId === undefined) {
155 await afterAnswer($, e.answer).catch((error: unknown) =>
156 $.ui.log(`${PLUGIN}: comandi non letti: ${String(error)}`, { to: 'debug' }),
157 )
158 // The question takes seconds: the turn ends now, the list follows.
159 $.clock.after(0, () => void readTodos($))
160 }
161 return done
162 }).catch(($, e, next) => next(e))
163
164 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
165 const ran = await next(e)
166 // Only the auto-mode classifier's refusals: a "No" the person pressed is theirs, not a command to hand back.
167 if (ran.deny !== undefined && CLASSIFIER_DENY.test(ran.deny)) {
168 await afterDenied($, e.command).catch((error: unknown) =>
169 $.ui.log(`${PLUGIN}: comando negato non salvato: ${String(error)}`, { to: 'debug' }),
170 )
171 }
172 return ran
173 }).catch(($, e, next) => next(e))
174
175 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
176 const list = await read($, todos)
177 return deckView(
178 $.ui.resolve(e),
179 {
180 open: openTodos(list),
181 closed: closedTodos(list),
182 commands: await looseCommands($),
183 isDoneShown: await read($, isDoneShown),
184 isReading,
185 },
186 actionsFor($),
187 )
188 })
189}
190hooks/deck.ts 35 lines1import type { CommandSource, DeckCommand, Todo } from '../types'
2import { lint } from './extract'
3
4/** Commands kept: the recent ones, which are the ones still worth pasting. */
5export const MAX_COMMANDS = 30
6
7/** Entries for commands found at `at`, checked against the person's rules. */
8export const toCommands = (texts: readonly string[], source: CommandSource, at: number): DeckCommand[] =>
9 texts.map((text, i) => ({ id: `${at}-${i}`, text, source, warnings: lint(text), at }))
10
11/** The texts among `fresh` the deck does not hold yet. */
12export const newTexts = (deck: readonly DeckCommand[], fresh: readonly DeckCommand[]): string[] =>
13 fresh.map(c => c.text).filter(text => !deck.some(c => c.text === text))
14
15/** The deck with `fresh` on top, in their order; a command already there moves up, once; at most MAX_COMMANDS. */
16export const addCommands = (deck: readonly DeckCommand[], fresh: readonly DeckCommand[]): DeckCommand[] => {
17 const texts = new Set(fresh.map(c => c.text))
18 return [...fresh, ...deck.filter(c => !texts.has(c.text))].slice(0, MAX_COMMANDS)
19}
20
21/** The deck without the commands Claude found no longer worth keeping. */
22export const dropCommands = (deck: readonly DeckCommand[], ids: readonly string[]): DeckCommand[] => deck.filter(c => !ids.includes(c.id))
23
24/**
25 * The deck without the commands that left the open to-dos between `before` and
26 * `after`, closed or rewritten away: settled, they are not to be pasted again.
27 * One an open to-do still holds stays; so does one a later answer brings back.
28 */
29export const withoutSettled = (deck: readonly DeckCommand[], before: readonly Todo[], after: readonly Todo[]): DeckCommand[] => {
30 const openTexts = (list: readonly Todo[]) => new Set(list.filter(t => t.closed === undefined).flatMap(t => t.commands.map(c => c.text)))
31 const stillOpen = openTexts(after)
32 const settled = [...openTexts(before)].filter(text => !stillOpen.has(text))
33 return deck.filter(c => !settled.includes(c.text))
34}
35hooks/extract.ts 118 lines1/** Fence languages that hold commands for a terminal. */
2const SHELL_LANGUAGES = new Set(['bash', 'sh', 'zsh', 'shell', 'console', 'terminal'])
3
4/** Programs a line of a fence without a language may start with to count as a command. */
5const KNOWN_COMMANDS = new Set([
6 'cd', 'git', 'gh', 'npm', 'npx', 'bun', 'node', 'python3', 'pip', 'go', 'brew', 'claude', 'ssh', 'scp', 'rsync',
7 'cp', 'mv', 'rm', 'ls', 'cat', 'mkdir', 'open', 'code', 'docker', 'colima', 'kubectl', 'make', 'curl', 'sips',
8 'defaults', 'launchctl', 'export', 'source', 'sudo',
9])
10
11/** A block longer than this is code to read, not a command to paste. */
12const MAX_BLOCK_LINES = 25
13const PROMPT = '$ '
14
15const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g
16const HEREDOC_START = /<<-?\s*['"]?([A-Za-z_]\w*)['"]?/
17const ENV_ASSIGNMENT = /^[A-Za-z_]\w*=\S*$/
18
19const isComment = (line: string) => line.trimStart().startsWith('#')
20
21/** The lines without comment-only ones, a heredoc's body kept whole (its `#` may be code). */
22const withoutComments = (lines: readonly string[]): string[] =>
23 lines.reduce<{ kept: string[]; until: string | null }>(
24 (acc, line) => {
25 if (acc.until !== null) return { kept: [...acc.kept, line], until: line.trim() === acc.until ? null : acc.until }
26 if (isComment(line)) return acc
27 const heredoc = HEREDOC_START.exec(line)
28 return { kept: [...acc.kept, line], until: heredoc?.[1] ?? null }
29 },
30 { kept: [], until: null },
31 ).kept
32
33/** A console transcript: the lines after `$ ` are the commands, the rest is output. */
34const promptedLines = (lines: readonly string[]): string[] | null => {
35 const prompted = lines.filter(line => line.startsWith(PROMPT))
36 return prompted.length === 0 ? null : prompted.map(line => line.slice(PROMPT.length))
37}
38
39/** The program a line runs: its first word past `VAR=value` assignments. */
40const programOf = (line: string): string => line.trim().split(/\s+/).find(word => !ENV_ASSIGNMENT.test(word)) ?? ''
41
42const looksLikeCommand = (line: string): boolean => {
43 const program = programOf(line)
44 return KNOWN_COMMANDS.has(program) || program.startsWith('./') || program.startsWith('!')
45}
46
47/** The command a fence holds, or null when it holds none. */
48const commandOf = (language: string, body: string): string | null => {
49 const lines = body.replace(/\n$/, '').split('\n')
50 if (lines.length > MAX_BLOCK_LINES) return null
51 const lang = language.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
52 const isShell = SHELL_LANGUAGES.has(lang)
53 if (!isShell && lang !== '') return null
54
55 const commands = withoutComments(promptedLines(lines) ?? lines).filter(line => line.trim() !== '')
56 if (commands.length === 0) return null
57 if (!isShell && !commands.every(looksLikeCommand)) return null
58 return commands.join('\n')
59}
60
61/**
62 * The terminal commands an answer holds: fences tagged as shell, and untagged
63 * fences whose every line starts with a known program. Each text once.
64 */
65export const commandsIn = (answer: string): string[] => {
66 const found = [...answer.matchAll(FENCE)].flatMap(m => {
67 const command = commandOf(m[1] ?? '', m[2] ?? '')
68 return command === null ? [] : [command]
69 })
70 return [...new Set(found)]
71}
72
73/** An `ssh` call: its options, host and remote command, from the text after `ssh`. */
74type SshCall = { hasTty: boolean; remote: string }
75
76/** ssh options that take a value: the word after them is that value, not the host. */
77const SSH_VALUE_OPTIONS = new Set(['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w'])
78const SSH_CALL = /(?:^|[\s;&|(])ssh\s+([^\n]*)/g
79
80const parseSsh = (rest: string): SshCall => {
81 const words = rest.trim().split(/\s+/)
82 let i = 0
83 let hasTty = false
84 while (i < words.length && words[i]!.startsWith('-')) {
85 const option = words[i]!
86 if (/^-[A-Za-z]*t/.test(option) && !SSH_VALUE_OPTIONS.has(option)) hasTty = true
87 i += SSH_VALUE_OPTIONS.has(option) ? 2 : 1
88 }
89 // words[i] is the host; whatever follows is run on it.
90 return { hasTty, remote: words.slice(i + 1).join(' ') }
91}
92
93const PLACEHOLDER = /(?<![<\w])<([A-Za-z_][\w-]*)>/g
94const FILL_INS: { pattern: RegExp; warning: string }[] = [
95 { pattern: /\b(YOUR|TUO|TUA)_[A-Z_]*/, warning: 'Valore da completare' },
96 { pattern: /\bCHANGEME\b/i, warning: 'Valore da completare' },
97 { pattern: /\bx{3,}\b/i, warning: 'Valore finto «xxx» da sostituire' },
98 { pattern: /\bpath\/to\//, warning: 'Percorso d’esempio «path/to/» da sostituire' },
99 { pattern: /(^|\s)(\.\.\.|…)(\s|$)/, warning: '«...» al posto di un argomento' },
100]
101
102export const SSH_WITHOUT_TTY = 'ssh senza -t: con un comando remoto usa ssh -t utente@host \'…\''
103export const SSH_MORE_THAN_ONE = 'Più di un ssh: usa un solo ssh -t utente@host \'…\''
104export const SSH_APPEND = 'Scrive con >> sul server: prima una guardia sulla macchina e sul file atteso'
105
106/** What in a command breaks the person's rules: placeholders, unfinished values, ssh used otherwise than once with -t. */
107export const lint = (command: string): string[] => {
108 const placeholders = [...command.matchAll(PLACEHOLDER)].map(m => `Segnaposto da sostituire: <${m[1]}>`)
109 const fillIns = FILL_INS.filter(f => f.pattern.test(command)).map(f => f.warning)
110 const calls = [...command.matchAll(SSH_CALL)].map(m => parseSsh(m[1] ?? ''))
111 const sshWarnings = [
112 ...(calls.some(c => c.remote !== '' && !c.hasTty) ? [SSH_WITHOUT_TTY] : []),
113 ...(calls.length > 1 ? [SSH_MORE_THAN_ONE] : []),
114 ...(calls.some(c => c.remote.includes('>>')) ? [SSH_APPEND] : []),
115 ]
116 return [...new Set([...placeholders, ...fillIns, ...sshWarnings])]
117}
118hooks/todos.ts 167 lines1import type { ClosedBy, Todo } from '../types'
2import { lint } from './extract'
3
4/** Open to-dos kept: past this many the oldest drop, as stale. */
5export const MAX_OPEN = 30
6/** Closed to-dos kept for «Riapri». */
7export const MAX_CLOSED = 20
8const MAX_ADDS_PER_REPLY = 8
9const MAX_COMMANDS_PER_TODO = 5
10const MAX_TITLE = 120
11const MAX_DETAIL = 400
12const MAX_COMMAND = 2_000
13
14/** A to-do as the reply proposes it, before it gets an id and a time. */
15export type NewTodo = { title: string; detail: string; commands: string[] }
16/** An open to-do rewritten because the facts changed. */
17export type TodoUpdate = NewTodo & { id: string }
18/**
19 * What the question brings back: to-dos to add, to close, to rewrite, and the
20 * loose commands no longer worth keeping.
21 */
22export type TodoReply = { add: NewTodo[]; done: string[]; update: TodoUpdate[]; drop: string[] }
23
24const NOTHING: TodoReply = { add: [], done: [], update: [], drop: [] }
25/** The last answer goes in the question: the fork's transcript stops before it. Its tail, past this, is enough. */
26const MAX_ANSWER = 8_000
27const MAX_COMMAND_SHOWN = 300
28
29export const openTodos = (todos: readonly Todo[]): Todo[] => todos.filter(t => t.closed === undefined)
30export const closedTodos = (todos: readonly Todo[]): Todo[] =>
31 todos.filter(t => t.closed !== undefined).sort((a, b) => (b.closed?.at ?? 0) - (a.closed?.at ?? 0))
32
33/** A loose command as the question shows it: the ones of the last answer are not Claude's to drop. */
34export type LooseCommand = { id: string; text: string; isLatest: boolean }
35
36const todoLines = (t: Todo): string[] => [
37 `- id=${t.id}: ${t.title}`,
38 ...(t.detail === '' ? [] : [` dettaglio: ${t.detail}`]),
39 ...t.commands.map(c => ` comando: ${c.text.slice(0, MAX_COMMAND_SHOWN)}`),
40]
41
42/**
43 * The question asked over the session's own transcript after each answer of
44 * Claude's. The fork's transcript is the main thread's last request, which
45 * holds no final answer yet: the answer comes in the question.
46 */
47export const buildTodoPrompt = (open: readonly Todo[], loose: readonly LooseCommand[], lastAnswer: string): string =>
48 [
49 'Domanda tecnica del pannello command-deck, non dell’utente: non usare strumenti, non rispondere all’utente.',
50 'Il pannello deve rispecchiare il lavoro com’è ADESSO. Rileggi la conversazione e la tua ultima risposta,',
51 'riportata in fondo: sopra non c’è ancora.',
52 '1. «done»: gli id delle cose da fare già fatte (dall’utente o da te), superate, non più pertinenti,',
53 ' o doppioni di un’altra (tieni la più completa). Resta aperta solo una cosa che chiede ancora un’azione all’utente.',
54 '2. «update»: le cose aperte da riscrivere perché i fatti sono cambiati (passi o comandi nuovi):',
55 ' {"id":"…","title":"…","detail":"…","commands":["…"]}.',
56 '3. «add»: le cose che restano da fare ALL’UTENTE (non a te): prove dal vivo, comandi da eseguire, decisioni,',
57 ' verifiche, avvisi da gestire (es. ruotare una chiave). Solo azioni concrete, non già nell’elenco.',
58 ' Titolo breve all’imperativo (max 100 caratteri), «detail» 0-2 frasi, «commands» i comandi da terminale',
59 ' copiati ALLA LETTERA dalle tue risposte ([] se nessuno).',
60 '4. «drop»: gli id dei comandi sciolti che non servono più: già eseguiti (da te o dall’utente),',
61 ' sostituiti da una versione nuova, legati a lavoro finito o ripresi in una cosa da fare.',
62 'Rispondi SOLO con JSON: {"done":["<id>"],"update":[],"add":[],"drop":["<id>"]}.',
63 '',
64 'Cose da fare aperte:',
65 ...(open.length === 0 ? ['(nessuna)'] : open.flatMap(todoLines)),
66 '',
67 'Comandi sciolti nel pannello:',
68 ...(loose.length === 0 ? ['(nessuno)'] : loose.map(c => `- id=${c.id}${c.isLatest ? ' (ultima risposta)' : ''}: ${c.text.slice(0, MAX_COMMAND_SHOWN)}`)),
69 '',
70 'La tua ultima risposta:',
71 '<<<',
72 lastAnswer.slice(-MAX_ANSWER),
73 '>>>',
74 ].join('\n')
75
76const cut = (value: unknown, max: number): string => (typeof value === 'string' ? value.trim().slice(0, max) : '')
77
78const toNewTodo = (raw: unknown): NewTodo | null => {
79 if (typeof raw !== 'object' || raw === null) return null
80 const { title, detail, commands } = raw as Record<string, unknown>
81 const name = cut(title, MAX_TITLE)
82 if (name === '') return null
83 const texts = Array.isArray(commands) ? commands.map(c => cut(c, MAX_COMMAND)).filter(c => c !== '') : []
84 return { title: name, detail: cut(detail, MAX_DETAIL), commands: texts.slice(0, MAX_COMMANDS_PER_TODO) }
85}
86
87const toUpdate = (raw: unknown, openIds: readonly string[]): TodoUpdate | null => {
88 const todo = toNewTodo(raw)
89 const id = (raw as Record<string, unknown> | null)?.id
90 return todo !== null && typeof id === 'string' && openIds.includes(id) ? { ...todo, id } : null
91}
92
93const idsAmong = (value: unknown, known: readonly string[]): string[] =>
94 (Array.isArray(value) ? value : []).filter((id): id is string => typeof id === 'string' && known.includes(id))
95
96/**
97 * Reads the reply's JSON: proposals kept when well formed; «done» and «update»
98 * only of to-dos that are open, «drop» only of the commands Claude may drop.
99 */
100export const parseTodoReply = (text: string, openIds: readonly string[], droppableIds: readonly string[] = []): TodoReply => {
101 const start = text.indexOf('{')
102 const end = text.lastIndexOf('}')
103 if (start < 0 || end <= start) return NOTHING
104 try {
105 const parsed: unknown = JSON.parse(text.slice(start, end + 1))
106 if (typeof parsed !== 'object' || parsed === null) return NOTHING
107 const { add, done, update, drop } = parsed as Record<string, unknown>
108 const closing = idsAmong(done, openIds)
109 return {
110 add: (Array.isArray(add) ? add : []).map(toNewTodo).filter((t): t is NewTodo => t !== null).slice(0, MAX_ADDS_PER_REPLY),
111 done: closing,
112 // A to-do both closed and rewritten is closed.
113 update: (Array.isArray(update) ? update : []).map(u => toUpdate(u, openIds)).filter((u): u is TodoUpdate => u !== null && !closing.includes(u.id)),
114 drop: idsAmong(drop, droppableIds),
115 }
116 } catch {
117 return NOTHING
118 }
119}
120
121const sameTitle = (a: string, b: string) => a.toLowerCase().replace(/\s+/g, ' ') === b.toLowerCase().replace(/\s+/g, ' ')
122
123const trim = (todos: readonly Todo[]): Todo[] => [
124 ...openTodos(todos).slice(-MAX_OPEN),
125 ...closedTodos(todos).slice(0, MAX_CLOSED),
126]
127
128const checked = (texts: readonly string[]) => texts.map(text => ({ text, warnings: lint(text) }))
129
130/** The list after a reply: the done ones closed by Claude, the changed ones rewritten, the new ones (not already open) added at the end. */
131export const applyReply = (todos: readonly Todo[], reply: TodoReply, at: number): Todo[] => {
132 const rewrite = new Map(reply.update.map(u => [u.id, u]))
133 const closed = todos.map(t => {
134 if (t.closed !== undefined) return t
135 if (reply.done.includes(t.id)) return { ...t, closed: { by: 'claude' as const, at } }
136 const u = rewrite.get(t.id)
137 return u === undefined ? t : { ...t, title: u.title, detail: u.detail, commands: checked(u.commands) }
138 })
139 const fresh = reply.add
140 .filter(n => !openTodos(closed).some(t => sameTitle(t.title, n.title)))
141 .map((n, i): Todo => ({ id: `${at}-${i}`, title: n.title, detail: n.detail, commands: checked(n.commands), at }))
142 return trim([...closed, ...fresh])
143}
144
145/** A Bash call the classifier refused: the person runs it. */
146export const deniedTodo = (command: string): NewTodo => ({
147 // The command in the title keeps two refusals apart: equal titles count as one to-do.
148 title: `Esegui tu: ${(command.split('\n')[0] ?? '').slice(0, 70)}`,
149 detail: 'Claude non ha potuto lanciarlo: controllalo e incollalo nel terminale.',
150 commands: [command],
151})
152
153export const closeTodo = (todos: readonly Todo[], id: string, by: ClosedBy, at: number): Todo[] =>
154 trim(todos.map(t => (t.id === id && t.closed === undefined ? { ...t, closed: { by, at } } : t)))
155
156export const reopenTodo = (todos: readonly Todo[], id: string): Todo[] =>
157 trim(todos.map(t => (t.id === id ? { id: t.id, title: t.title, detail: t.detail, commands: t.commands, at: t.at } : t)))
158
159/** One toast for what an answer left: the new to-dos and the new commands, when the pane is closed. */
160export const readyText = (todoCount: number, commandCount: number): string | null => {
161 const things = todoCount === 1 ? '1 cosa da fare' : `${todoCount} cose da fare`
162 const cmds = commandCount === 1 ? '1 comando pronto da copiare' : `${commandCount} comandi pronti da copiare`
163 if (todoCount > 0 && commandCount > 0) return `${things} e ${commandCount === 1 ? '1 comando' : `${commandCount} comandi`}: /command-deck`
164 if (todoCount > 0) return `${things}: /command-deck`
165 return commandCount > 0 ? `${cmds}: /command-deck` : null
166}
167hooks/view.tsx 115 lines1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { DeckCommand, Todo } from '../types'
4
5const MAX_HOTKEYS = 9
6
7export type Actions = {
8 /** Copies the text on the surface the press came from. */
9 copy: (text: string, surface: RenderSurface) => unknown
10 close: (todo: Todo) => unknown
11 reopen: (todo: Todo) => unknown
12 toggleDone: () => unknown
13 clearCommands: () => unknown
14}
15
16export type DeckState = {
17 open: readonly Todo[]
18 closed: readonly Todo[]
19 /** The answers' commands not already under a to-do. */
20 commands: readonly DeckCommand[]
21 isDoneShown: boolean
22 /** Claude is reading the last answer for new to-dos. */
23 isReading: boolean
24}
25
26/** A command with its Copia; `n` is its place among every copyable command, 1 first. */
27const commandBlock = (el: ElementTable, key: string, text: string, warnings: readonly string[], n: number, act: Actions) => (
28 <el.Box key={key} flexDirection="column">
29 <el.Box flexDirection="row" columnGap={1}>
30 <el.Button
31 key={`copy:${key}`}
32 label={`Copia ${n}`}
33 hotkey={n <= MAX_HOTKEYS ? String(n) : undefined}
34 variant={n === 1 ? 'primary' : undefined}
35 onPress={press => act.copy(text, press.surface)}
36 />
37 </el.Box>
38 <el.Code source={text} language="bash" />
39 {warnings.map(w => (
40 <el.Text key={`warn:${key}:${w}`} color="warning">{`⚠ ${w}`}</el.Text>
41 ))}
42 </el.Box>
43)
44
45const todoCard = (el: ElementTable, t: Todo, firstN: number, act: Actions) => (
46 <el.Box key={`todo:${t.id}`} flexDirection="column">
47 <el.Box flexDirection="row" columnGap={1}>
48 <el.Text bold>{`○ ${t.title}`}</el.Text>
49 <el.Button key={`close:${t.id}`} label="Fatto" plain dimColor onPress={() => act.close(t)} />
50 </el.Box>
51 {t.detail !== '' && <el.Text dimColor>{` ${t.detail}`}</el.Text>}
52 {t.commands.map((c, i) => commandBlock(el, `todo:${t.id}:${i}`, c.text, c.warnings, firstN + i, act))}
53 </el.Box>
54)
55
56const doneList = (el: ElementTable, s: DeckState, act: Actions) =>
57 s.closed.length > 0 && (
58 <el.Box flexDirection="column">
59 <el.Button
60 key="toggle-done"
61 label={`${s.isDoneShown ? '▾' : '▸'} Fatti (${s.closed.length})`}
62 hotkey="f"
63 plain
64 dimColor
65 onPress={() => act.toggleDone()}
66 />
67 {s.isDoneShown &&
68 s.closed.map(t => (
69 <el.Box key={`done:${t.id}`} flexDirection="row" columnGap={1}>
70 <el.Text dimColor>{`✓ ${t.title} · ${t.closed?.by === 'claude' ? 'chiuso da Claude' : 'fatto da te'}`}</el.Text>
71 <el.Button key={`reopen:${t.id}`} label="Riapri" plain dimColor onPress={() => act.reopen(t)} />
72 </el.Box>
73 ))}
74 </el.Box>
75 )
76
77/** The deck: what is left to do, with its commands, then the answers' other commands, then what is done. */
78export const deckView = (el: ElementTable, s: DeckState, act: Actions) => {
79 // Copia numbers run through the to-dos' commands, then the loose ones: the hotkey is the number shown.
80 const starts = s.open.reduce<number[]>((acc, t, i) => [...acc, (acc[i] ?? 1) + t.commands.length], [1])
81 const looseStart = starts[s.open.length] ?? 1
82 const isEmpty = s.open.length === 0 && s.commands.length === 0
83
84 return (
85 <el.Box flexDirection="column" gap={1}>
86 <el.Box flexDirection="row" justifyContent="space-between">
87 <el.Text bold color="claude">{`◆ Da fare (${s.open.length})`}</el.Text>
88 {s.isReading && <el.Text dimColor italic>Claude rilegge l’ultima risposta…</el.Text>}
89 </el.Box>
90 {isEmpty && (
91 <el.Text dimColor>
92 Niente da fare. Qui finiscono le cose che Claude ti lascia da fare, con i loro comandi, e i comandi delle sue
93 risposte: premi il numero per copiarli.
94 </el.Text>
95 )}
96 {s.open.map((t, i) => todoCard(el, t, starts[i] ?? 1, act))}
97 {s.commands.length > 0 && (
98 <el.Box flexDirection="column" gap={1}>
99 <el.Box flexDirection="row" justifyContent="space-between">
100 <el.Text bold>Altri comandi delle risposte</el.Text>
101 <el.Button key="clear" label="Svuota" hotkey="x" plain dimColor onPress={() => act.clearCommands()} />
102 </el.Box>
103 {s.commands.map((c, i) => (
104 <el.Box key={`cmd:${c.id}`} flexDirection="column">
105 {c.source === 'denied' && <el.Text color="warning">negato, da eseguire tu</el.Text>}
106 {commandBlock(el, `cmd:${c.id}`, c.text, c.warnings, looseStart + i, act)}
107 </el.Box>
108 ))}
109 </el.Box>
110 )}
111 {doneList(el, s, act)}
112 </el.Box>
113 )
114}
115types/index.d.ts 45 lines1/** Where a command came from: an answer of Claude's, or a Bash call the permissions refused. */
2export type CommandSource = 'answer' | 'denied'
3
4/** One command in the deck. */
5export type DeckCommand = {
6 id: string
7 text: string
8 source: CommandSource
9 /** What breaks the person's rules for commands (placeholders, ssh without -t…), in Italian. */
10 warnings: string[]
11 /** When it was added, ms on the session clock. */
12 at: number
13}
14
15/** Who closed a to-do: the person with «Fatto», or Claude when the conversation showed it done. */
16export type ClosedBy = 'you' | 'claude'
17
18/** Something left for the person to do, as Claude read it off its answers, with the commands it takes. */
19export type Todo = {
20 id: string
21 /** What to do, in a short imperative line. */
22 title: string
23 /** Why or how, in a sentence or two; empty when the title says it all. */
24 detail: string
25 /** Commands to paste for it, verbatim, each checked against the person's rules. */
26 commands: { text: string; warnings: string[] }[]
27 /** When it was added, ms on the session clock. */
28 at: number
29 /** Absent while open. */
30 closed?: { by: ClosedBy; at: number }
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'command-deck': {
36 /** Newest first, each text once, at most MAX_COMMANDS. */
37 commands: DeckCommand[]
38 /** Open ones first in the order they came, then the closed ones, newest closed first. */
39 todos: Todo[]
40 /** The closed to-dos are listed under the open ones. */
41 isDoneShown: boolean
42 }
43 }
44}
45