SLOPSHOPPER

command-deck

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-deck
│ ┃ Da fare ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Da fare (0) │ ┃ ⏺ Read(src/auth.ts) │ ┃ Niente da fare. Qui finiscono le cose che ⎿ Read 6 lines │ ┃ Claude ti lascia da fare, con i loro ⏺ Update(src/auth.ts) │ ┃ comandi, e i comandi delle sue risposte: ⎿ Added 2 lines, removed 1 line │ ┃ premi il numero per copiarli. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /command-deck │ ⎿ command-deck: Pannello Da fare aperto: premi il numero per copia │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Da fare
◆ Da fare (0) Niente da fare. Qui finiscono le cose che Claude ti lascia da fare, con i loro comandi, e i comandi delle sue risposte: premi il numero per copiarli.
README

command-deck

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.

Come si usa

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

TastoCosa fa
1 … 9Copia il comando con quel numero («Copia 1», «Copia 2»…)
fMostra o nasconde l'elenco «Fatti (N)»
xSvuota 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».

Cosa fa da sola

  • A fine risposta di Claude (non dei subagent):
  • prende i comandi dai blocchi di codice della risposta: quelli marcati 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.
  • fa a Claude una domanda tecnica sulla conversazione, per tenere il pannello allineato al lavoro com'è adesso. Gli mostra le cose da fare aperte per intero (titolo, dettaglio, comandi), i comandi sciolti e la sua ultima risposta, che la domanda altrimenti non vedrebbe. Claude:
  • chiude («chiuso da Claude») le cose fatte da te o da lui, quelle superate o non più pertinenti, e i doppioni;
  • riscrive quelle aperte quando i fatti sono cambiati (titolo, dettaglio, comandi nuovi);
  • aggiunge quelle nuove che restano da fare a te (prove, comandi, decisioni, verifiche);
  • toglie i comandi sciolti che non servono più: già eseguiti, sostituiti da una versione nuova, legati a lavoro finito o ripresi in una cosa da fare. Quelli dell'ultima risposta restano almeno fino alla risposta dopo.
  • Quando una cosa da fare si chiude (con «Fatto» o da Claude) o Claude ne cambia i comandi: i comandi che non stanno più sotto nessuna cosa aperta lasciano il pannello, invece di finire tra gli altri comandi. «Riapri» riporta una cosa chiusa con i suoi comandi.
  • Quando la modalità auto nega un comando Bash: il comando diventa una cosa da fare («Esegui tu: …»), il pannello si apre e compare «Comando negato: copialo da /command-deck ed eseguilo tu». Un «No» premuto da te non conta.

Cosa legge e cosa manda

  • La domanda a fine risposta usa $.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ù.
  • Questa mod non oscura nulla: legge la conversazione così com'è.
  • Copia negli appunti del terminale solo quando premi «Copia».
  • Non legge file e non lancia processi.
  • Nello stato della sessione tiene i comandi (commands), le cose da fare (todos) e se i fatti sono in vista (isDoneShown).

Requisiti

Nessuno strumento esterno.

Limiti

  • Tiene al massimo 30 comandi, 30 cose da fare aperte (le più vecchie escono) e 20 chiuse.
  • Per ogni risposta accetta al massimo 8 cose nuove, ognuna con al massimo 5 comandi; titolo fino a 120 caratteri, dettaglio fino a 400, comando fino a 2000.
  • Un blocco di codice più lungo di 25 righe non viene preso come comando.
  • Solo i primi 9 comandi hanno un tasto.
  • Se la domanda a Claude fallisce o la risposta non è JSON valido, l'elenco resta com'è.
  • Una cosa nuova con lo stesso titolo di una già aperta (a parte maiuscole e spazi) non viene aggiunta. I doppioni scritti in altro modo li chiude Claude, a giudizio suo.
  • Della sua ultima risposta, Claude rilegge al massimo gli ultimi 8000 caratteri; dei comandi, i primi 300.
  • Chiudere, riscrivere e togliere dipendono dal giudizio del modello: se sbaglia, «Riapri» riporta una cosa chiusa, ma un comando tolto non torna.

Installazione

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.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

Oggi i test sono 49.

Source 6 files
hooks/register.tsx 190 lines
1import { 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}
190
hooks/deck.ts 35 lines
1import 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}
35
hooks/extract.ts 118 lines
1/** 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}
118
hooks/todos.ts 167 lines
1import 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}
167
hooks/view.tsx 115 lines
1import 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}
115
types/index.d.ts 45 lines
1/** 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