A pane on the right listing the decisions Claude needs from you, each with its recommendation and Approve / Chat buttons

A pane beside the chat that lists the decisions Claude needs from you. Each one is condensed to two lines, a title and Claude's recommendation, so the list is easy to scroll:
3 waiting for you
1. Capsule sky [AD-Q1]
✓ "Race under the lights": the track's own sky, no painted glow
[ Approve ] Chat more
Re [id] <title>: in your prompt box so you can answer in your own words.▸ Decided (n) row.Claude adds items itself: the mod gives it three tools (ask, resolve, list) and a short instruction to use them whenever it puts a decision to you with a recommendation. If you answer in chat instead of pressing Approve, Claude marks the item settled.
The list is kept per project and survives restarts. The pane opens by itself when something is waiting; /approvals shows or hides it.
At the prompt of a Claude Code terminal session:
/plugin install approvals --marketplace dbroadhurst/claude-mod-approvals
Answer y to add the marketplace, then choose a scope (user scope loads it in every session).
In the fullscreen layout with a terminal at least 110 columns wide, the pane docks beside the transcript (144 columns when it opens by itself). Narrower, or on the main-screen layout, it sits above the prompt.
claude --plugin-dir <path>; check with claude plugin validate . and claude plugin test ..MIT licensed.
hooks/register.tsx 234 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Approval, Board } from '../types'
5
6// One board of decisions per project, kept across sessions in $.store.
7const PANE = 'approvals'
8const TITLE = 'Approvals'
9const board = atom({ plugin: 'approvals', key: 'board' } as const, { root: '', items: [] })
10const expanded = atom({ plugin: 'approvals', key: 'expanded' } as const, [])
11
12const MAX_RESOLVED = 30
13const storeKey = (root: string) => `board:${root}`
14
15const GUIDE = [
16 'Approvals pane (the `approvals` plugin): the person sees the decisions you need from them in a pane beside the chat, each with Approve and Chat buttons.',
17 '- Whenever you put a decision to the person with a recommendation, also call `mcp__approvals__ask` with a short id (the design-doc id if there is one), a title of a few words, and the recommendation condensed to one line (about 100 characters). Put the reasoning in `why` and the other options in `alternatives`, a line each.',
18 '- When the person answers in chat (approves, picks another option, or drops it), call `mcp__approvals__resolve` with the id and the outcome in a few words.',
19 '- A prompt starting "Approved [id]" came from the pane\'s Approve button: act on the recommendation as given. "Re [id]" is the person discussing it.',
20].join('\n')
21
22const pending = (b: Board) => b.items.filter(i => i.status === 'pending')
23const oneLine = (s: string | undefined, max: number) => {
24 const t = (s ?? '').replace(/\s+/g, ' ').trim()
25 return t.length > max ? `${t.slice(0, max - 1)}…` : t
26}
27
28// Applies `fn` to the board, keeps the decided list bounded and persists it.
29async function change($: EngineInterface, fn: (items: Approval[]) => Approval[]): Promise<Board> {
30 let saved: Board = { root: '', items: [] }
31 await update($, board, prev => {
32 const items = fn(prev.items)
33 const open = items.filter(i => i.status === 'pending')
34 const done = items
35 .filter(i => i.status !== 'pending')
36 .sort((a, b) => (b.resolvedAt ?? 0) - (a.resolvedAt ?? 0))
37 .slice(0, MAX_RESOLVED)
38 saved = { root: prev.root, items: [...open, ...done] }
39 return saved
40 })
41 await $.store.set(storeKey(saved.root), saved.items)
42 return saved
43}
44
45async function settle($: EngineInterface, id: string, status: Approval['status'], outcome: string): Promise<Board> {
46 const now = await $.clock.now()
47 return change($, items => items.map(i => (i.id === id ? { ...i, status, outcome, resolvedAt: now } : i)))
48}
49
50async function openPane($: EngineInterface) {
51 return $.ui.open({ id: PANE, title: TITLE })
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 const root = await $.session.root()
57 const items = ((await $.store.get(storeKey(root))) as Approval[] | undefined) ?? []
58 await update($, board, () => ({ root, items }))
59
60 await $.command.register({
61 name: 'approvals',
62 description: 'Show or hide the pane of decisions waiting for you',
63 })
64 await $.tool.register({
65 name: 'ask',
66 description:
67 'Adds (or updates, by id) a decision the person needs to make to their Approvals pane, with your recommendation condensed to one line. Call it whenever you put a decision to them with a recommendation.',
68 inputSchema: {
69 type: 'object',
70 properties: {
71 id: { type: 'string', description: 'Short id, e.g. the design-doc decision id (K7) or a slug' },
72 title: { type: 'string', description: 'A few words naming the decision' },
73 recommendation: { type: 'string', description: 'Your recommendation in one line, about 100 characters' },
74 why: { type: 'string', description: 'The reasoning, one or two lines' },
75 alternatives: { type: 'string', description: 'The other options, one line' },
76 },
77 required: ['id', 'title', 'recommendation'],
78 },
79 isDeferred: false,
80 })
81 await $.tool.register({
82 name: 'resolve',
83 description:
84 'Marks a decision on the Approvals pane as settled once the person has answered it in chat (approved, another option chosen, or dropped).',
85 inputSchema: {
86 type: 'object',
87 properties: {
88 id: { type: 'string' },
89 outcome: { type: 'string', description: 'What was decided, a few words' },
90 },
91 required: ['id', 'outcome'],
92 },
93 isDeferred: false,
94 })
95 await $.tool.register({
96 name: 'list',
97 description: 'Lists the decisions still waiting on the Approvals pane.',
98 isDeferred: false,
99 })
100
101 if (items.some(i => i.status === 'pending')) void openPane($)
102
103 return next(e)
104 })
105
106 on('prompt.compose', async ($, e, next) => {
107 const composed = await next(e)
108 return {
109 ...composed,
110 sections: [...composed.sections, { id: 'approvals:guide', text: GUIDE, scope: 'session' as const }],
111 }
112 })
113
114 on('command.run', { command: 'approvals' }, async $ => {
115 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
116 if (isOpen) {
117 await $.ui.close({ id: PANE })
118 return { text: 'Approvals pane closed.' }
119 }
120 await openPane($)
121 return { text: 'Approvals pane opened.' }
122 })
123
124 on('tool.call', { tool: 'mcp__approvals__ask' }, async ($, e) => {
125 const input = e as unknown as Partial<Approval>
126 const id = oneLine(input.id, 40)
127 if (!id || !input.title || !input.recommendation) {
128 return { deny: 'approvals: id, title and recommendation are required.' }
129 }
130 const now = await $.clock.now()
131 const item: Approval = {
132 id,
133 title: oneLine(input.title, 80),
134 recommendation: oneLine(input.recommendation, 300),
135 why: input.why ? oneLine(input.why, 600) : undefined,
136 alternatives: input.alternatives ? oneLine(input.alternatives, 600) : undefined,
137 status: 'pending',
138 askedAt: now,
139 }
140 const saved = await change($, items => [...items.filter(i => i.id !== id), item])
141 void openPane($)
142 return { result: `Added ${id} to the Approvals pane (${pending(saved).length} waiting).` }
143 })
144 .catch(() => ({ deny: 'approvals: the ask tool failed.' }))
145
146 on('tool.call', { tool: 'mcp__approvals__resolve' }, async ($, e) => {
147 const { id, outcome } = e as unknown as { id: string; outcome: string }
148 const b = await read($, board)
149 if (!b.items.some(i => i.id === id)) return { result: `No decision ${id} on the pane.` }
150 const saved = await settle($, id, 'resolved', oneLine(outcome, 120))
151 return { result: `Resolved ${id}. ${pending(saved).length} still waiting.` }
152 })
153 .catch(() => ({ deny: 'approvals: the resolve tool failed.' }))
154
155 on('tool.call', { tool: 'mcp__approvals__list' }, async $ => {
156 const open = pending(await read($, board))
157 const text = open.length
158 ? open.map(i => `${i.id}: ${i.title} → ${i.recommendation}`).join('\n')
159 : 'Nothing waiting.'
160 return { result: text }
161 })
162 .catch(() => ({ deny: 'approvals: the list tool failed.' }))
163
164 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
165 const { Box, Text, Button } = $.ui.resolve(e)
166 const b = await read($, board)
167 const open = b.items.filter(i => i.status === 'pending').sort((x, y) => x.askedAt - y.askedAt)
168 const done = b.items.filter(i => i.status !== 'pending')
169 const shown = new Set(await read($, expanded))
170 const width = Math.max(20, (e.props.bodyColumns ?? 40) - 2)
171
172 const toggle = (key: string) =>
173 update($, expanded, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
174
175 const approve = async (i: Approval) => {
176 await settle($, i.id, 'approved', 'approved as recommended')
177 void $.prompt.submit({ text: `Approved [${i.id}] ${i.title}: go with your recommendation (${i.recommendation}).` })
178 }
179 const chat = async (i: Approval) => {
180 await $.prompt.fill({ text: `Re [${i.id}] ${i.title}: `, mode: 'replace' })
181 }
182
183 return (
184 <Box flexDirection="column" gap={1}>
185 <Text bold>
186 {open.length === 0 ? 'Nothing waiting for you' : `${open.length} waiting for you`}
187 </Text>
188 {open.map((i, n) => {
189 const isOpen = shown.has(i.id)
190 return (
191 <Box key={`item-${i.id}`} flexDirection="column">
192 <Text bold wrap="truncate-end">
193 {n + 1}. {i.title} <Text dimColor>[{i.id}]</Text>
194 </Text>
195 <Text color="green" wrap={isOpen ? 'wrap' : 'truncate-end'}>
196 ✓ {isOpen ? i.recommendation : oneLine(i.recommendation, width * 2)}
197 </Text>
198 {isOpen && i.why && <Text dimColor wrap="wrap">Why: {i.why}</Text>}
199 {isOpen && i.alternatives && <Text dimColor wrap="wrap">Or: {i.alternatives}</Text>}
200 <Box flexDirection="row" gap={1}>
201 <Button key={`approve-${i.id}`} variant="primary" label="Approve" onPress={() => approve(i)} />
202 <Button key={`chat-${i.id}`} label="Chat" onPress={() => chat(i)} />
203 {(i.why || i.alternatives || i.recommendation.length > width) && (
204 <Button key={`more-${i.id}`} dimColor plain label={isOpen ? 'less' : 'more'} onPress={() => toggle(i.id)} />
205 )}
206 </Box>
207 </Box>
208 )
209 })}
210 {done.length > 0 && (
211 <Box key="history" flexDirection="column">
212 <Button
213 key="history-toggle"
214 dimColor
215 plain
216 label={`${shown.has('#history') ? '▾' : '▸'} Decided (${done.length})`}
217 onPress={() => toggle('#history')}
218 />
219 {shown.has('#history') &&
220 done.map(i => (
221 <Text key={`done-${i.id}`} dimColor wrap="truncate-end">
222 {i.status === 'approved' ? '✓' : '•'} {i.title} [{i.id}]{i.outcome ? `: ${i.outcome}` : ''}
223 </Text>
224 ))}
225 {shown.has('#history') && (
226 <Button key="clear" dimColor plain label="clear history" onPress={() => change($, items => items.filter(x => x.status === 'pending'))} />
227 )}
228 </Box>
229 )}
230 </Box>
231 )
232 })
233}
234types/index.d.ts 22 lines1export type ApprovalStatus = 'pending' | 'approved' | 'chat' | 'resolved'
2
3export type Approval = {
4 id: string
5 title: string
6 recommendation: string
7 why?: string
8 alternatives?: string
9 status: ApprovalStatus
10 askedAt: number
11 resolvedAt?: number
12 outcome?: string
13}
14
15export type Board = { root: string; items: Approval[] }
16
17declare module 'claude-code' {
18 interface PluginState {
19 approvals: { board: Board; expanded: string[] }
20 }
21}
22