Explains each Claude Code permission request in plain English (WHAT, WHY, RISK) in a side pane

A Claude Code mod. When Claude stops to ask "is this ok?" before running something, a pane opens beside the conversation and explains the request in plain English:
The dialog appears right away; the explanation fills in a few seconds later. Read the pane before you answer.
For example, before a gh repo archive loop, the pane reads something like:
What is Claude asking?
WHAT
Archives 52 of your GitHub repositories, one after another. Archived repos become read-only
but keep all their code and history.
WHY
You agreed to the cleanup list Claude proposed; these are the repos it marked for archiving.
RISK · MEDIUM
This changes your GitHub account. It can be undone repo by repo with gh repo unarchive.
claude --plugin-dir /path/to/explain-permission
Type /explain-permission to open the pane yourself. It docks on the right in fullscreen mode, and otherwise sits above the prompt. It opens by itself on terminals at least 144 columns wide.
Made by Hermann Peterscheck · https://hermann.fyi
This folder is also a plugin marketplace. Once it's in a GitHub repo:
claude plugin marketplace add petershk/explain-permission
claude plugin install explain-permission@hermann-mods
or the same with /plugin inside Claude Code. Needs Claude Code 2.1.287 or later.
hooks/register.tsx 194 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Explanation } from '../types'
5
6const PANE = 'explain-permission'
7const TITLE = 'What is Claude asking?'
8const current = atom({ plugin: 'explain-permission', key: 'current' } as const, null)
9
10// The request as one readable line: the command for Bash, the file for edits, else the arguments.
11export function describe(tool: string, input: unknown): string {
12 const args = (input ?? {}) as Record<string, unknown>
13 if (typeof args.command === 'string') return args.command
14 if (typeof args.file_path === 'string') return args.file_path
15 if (typeof args.url === 'string') return args.url
16 const json = JSON.stringify(args)
17 return json.length > 400 ? json.slice(0, 400) + '...' : json
18}
19
20export function promptFor(tool: string, request: string): string {
21 return [
22 'A permission dialog is asking the user whether to let you run this:',
23 `Tool: ${tool}`,
24 `Request: ${request}`,
25 '',
26 'Explain it to someone who is not a programmer, in plain English, with no jargon and no markdown.',
27 'Write three short lines, each starting with its label:',
28 'WHAT: what this will actually do on their computer or online.',
29 'WHY: why it is needed for what they asked for (use the conversation so far).',
30 'RISK: low, medium or high, and what could go wrong or what changes for good (anything sent, deleted, published or paid for).',
31 'Keep it under 90 words. Do not run anything.',
32 ].join('\n')
33}
34
35type Section = { label?: 'WHAT' | 'WHY' | 'RISK'; body: string }
36
37// Splits the reply into its WHAT / WHY / RISK parts; a line under no label stays plain text.
38export function sections(text: string): Section[] {
39 const out: Section[] = []
40 for (const raw of text.split('\n')) {
41 const line = raw.replace(/\*\*/g, '').trim()
42 if (!line) continue
43 const m = line.match(/^(WHAT|WHY|RISK)\s*:\s*(.*)$/i)
44 if (m) out.push({ label: m[1]!.toUpperCase() as Section['label'], body: m[2]! })
45 else if (out.length && out[out.length - 1]!.label) out[out.length - 1]!.body += ' ' + line
46 else out.push({ body: line })
47 }
48 return out
49}
50
51export function riskLevel(body: string): 'low' | 'medium' | 'high' | 'unknown' {
52 const m = body.match(/\b(low|medium|high)\b/i)
53 return m ? (m[1]!.toLowerCase() as 'low' | 'medium' | 'high') : 'unknown'
54}
55
56// WHAT is cyan, WHY is magenta, RISK is green / yellow / red by its level.
57function colorFor(label: Section['label'], body: string): string {
58 if (label === 'WHAT') return 'cyan'
59 if (label === 'WHY') return 'magenta'
60 return { low: 'green', medium: 'yellow', high: 'red', unknown: 'yellow' }[riskLevel(body)]
61}
62
63// The request line is cut to this many rows of the pane, so a long command doesn't push the explanation out of view.
64const REQUEST_ROWS = 3
65
66export function clip(text: string, columns: number, rows = REQUEST_ROWS): string {
67 const most = Math.max(20, columns * rows)
68 return text.length > most ? text.slice(0, most - 3) + '...' : text
69}
70
71// While the "is this ok?" dialog is up it holds the keyboard, so the pane's own scroll keys can't reach it. When the
72// explanation is taller than the pane, the pane steps through it by itself: WHY, then RISK, then back to the top, a few
73// seconds each. It stops when the question is answered, a new one comes, or the person scrolls the pane themselves.
74// (When everything fits, the steps have nowhere to go and nothing moves.)
75const STEP_MS = 6000
76const PASSES = 3
77let tour: { id: number; tool: string; cancel: () => void } | undefined
78
79function stopTour() {
80 tour?.cancel()
81 tour = undefined
82}
83
84export function tourSteps(parts: Section[]): string[] {
85 const keys = parts.filter(p => p.label && p.label !== 'WHAT').map(p => p.label!.toLowerCase())
86 return keys.length ? [...keys, 'top'] : []
87}
88
89function startTour($: EngineInterface, id: number, tool: string, steps: string[]) {
90 stopTour()
91 if (!steps.length) return
92 let at = 0
93 const timer = $.clock.every(STEP_MS, () => {
94 if (at >= steps.length * PASSES) return stopTour()
95 const step = steps[at++ % steps.length]!
96 void $.ui.scroll(step === 'top' ? { to: 'start', in: PANE } : { to: { key: step }, in: PANE, block: 'start' }).catch(() => {})
97 })
98 tour = { id, tool, cancel: () => timer.cancel() }
99}
100
101let nextId = 1
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 await $.command.register({
106 name: 'explain-permission',
107 description: 'Show the plain-English permission explainer pane',
108 })
109 return next(e)
110 })
111
112 on('command.run', { command: 'explain-permission' }, async $ => {
113 await $.ui.open({ id: PANE, title: TITLE })
114 return { text: 'Permission explainer opened.' }
115 })
116
117 // Fires just before the engine shows the "is this ok?" dialog to the person.
118 on('classic.PermissionRequest', async ($, e, next) => {
119 const tool = e.tool_name
120 if (tool === 'AskUserQuestion' || tool === 'ExitPlanMode') return next(e)
121
122 const entry: Explanation = {
123 id: nextId++,
124 tool,
125 request: describe(tool, e.tool_input),
126 status: 'thinking',
127 text: '',
128 }
129 stopTour()
130 await update($, current, () => entry)
131 void $.ui.scroll({ to: 'start', in: PANE }).catch(() => {})
132 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
133 if (opened && !opened.isPlaced) $.ui.toast('Permission explainer: widen the terminal or type /explain-permission to see it')
134
135 // Explain in the background so the dialog is never held up.
136 void (async () => {
137 const prompt = promptFor(tool, entry.request)
138 let reply = await $.model.fork({ prompt })
139 if (!reply.isAnswered) reply = await $.model.complete({ model: 'haiku', prompt, maxTokens: 300 })
140 const done: Partial<Explanation> = reply.isAnswered
141 ? { status: 'ready', text: reply.text.trim() }
142 : { status: 'failed', text: `Could not get an explanation (${reply.reason}).` }
143 const now = await update($, current, now => (now && now.id === entry.id ? { ...now, ...done } : now))
144 if (now?.id === entry.id && now.status === 'ready') startTour($, entry.id, tool, tourSteps(sections(now.text)))
145 })().catch(err => {
146 void update($, current, now =>
147 now && now.id === entry.id ? { ...now, status: 'failed' as const, text: String(err) } : now,
148 )
149 })
150
151 return next(e)
152 })
153
154 // The call ends once the question is answered (and the tool has run, or was refused): the tour has done its job.
155 on('tool.call', async ($, e, next) => {
156 const result = await next(e)
157 if (tour?.tool === e.tool) stopTour()
158 return result
159 })
160
161 // The person scrolled the pane themselves (the wheel, or its keys once the question is gone): leave it where they put it.
162 on('ui.scroll', { component: 'Pane', requestId: PANE }, ($, e, next) => {
163 if (e.origin.kind === 'person') stopTour()
164 return next(e)
165 })
166
167 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168 const { Box, Text } = $.ui.resolve(e)
169 const now = await read($, current)
170 if (!now) return <Text dimColor>No permission requests yet. When Claude asks "is this ok?", the plain-English version shows up here.</Text>
171
172 return (
173 <Box flexDirection="column">
174 <Text bold>Claude wants to use: {now.tool}</Text>
175 <Text dimColor>{clip(now.request, e.props.bodyColumns)}</Text>
176 <Text> </Text>
177 {now.status === 'thinking' && <Text color="yellow">Working out what this means...</Text>}
178 {now.status === 'failed' && <Text color="red">{now.text}</Text>}
179 {now.status === 'ready' &&
180 sections(now.text).map((part, i, all) =>
181 part.label ? (
182 <Box key={part.label.toLowerCase()} flexDirection="column" marginBottom={i < all.length - 1 ? 1 : 0}>
183 <Text bold color={colorFor(part.label, part.body)}>{part.label === 'RISK' && riskLevel(part.body) !== 'unknown' ? `RISK · ${riskLevel(part.body).toUpperCase()}` : part.label}</Text>
184 <Text>{part.label === 'RISK' ? part.body.replace(/^(low|medium|high)\b[\s,.:;-]*/i, '') : part.body}</Text>
185 </Box>
186 ) : (
187 <Text>{part.body}</Text>
188 ),
189 )}
190 </Box>
191 )
192 })
193}
194types/index.d.ts 14 lines1export type Explanation = {
2 id: number
3 tool: string
4 request: string
5 status: 'thinking' | 'ready' | 'failed'
6 text: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'explain-permission': { current: Explanation | null }
12 }
13}
14