Display-only pane that explains each tool call in one plain sentence

The pane shows one plain sentence for each tool call. Each line explains the call and its purpose.
Reads the file app.ts to learn the code.
Runs the tests to check the change.
Pushes the branch to share the change.
Run these two commands from the ops-fork root:
claude plugin marketplace add ./ --scope local
claude plugin install apexyard-explainer@apexyard --scope local
The local scope enables the plugin only for you on this machine.
Run this command to remove it:
claude plugin uninstall apexyard-explainer@apexyard --scope local
Type /explainer-toggle to open or close the pane. A wide terminal shows a sidebar beside the transcript. A narrow terminal shows a box above the prompt. Claude Code places the pane. The text wraps to fit.
The name cannot be /explainer:toggle. Claude Code command names allow only letters, digits, _ and -.
Use ASD-STE100 Simplified Technical English for each line. Use one sentence with 15 words or fewer for templates, simple common words and active voice. Start each line with a present-tense verb, such as “Reads” or “Runs”. End each line with a full stop. Say what the call does and why. A test checks the template word limit. Names and patterns use one short word to keep lines short. For a tool with no template, the line is "Runs the tool <name>."
description first. Replace whitespace with spaces, trim it, cap the line at 120 characters, and end it with a period. Agent and Task reviews keep the reviewer template.mcp__<server>__<tool>, use "Uses <Server>: <tool words>." Remove a leading claude_ai_ from the server. Replace underscores and hyphens with spaces and title-case the server.Descriptions and commands are checked before shortening. Token-like text (ghp_, sk-, xox, or a run of 32 or more base64 or hex characters) causes the description to fall back to a template. Commands are never shown. Descriptions and MCP names can exceed the template word limit. All of this runs locally, with zero model or network calls.
| Tool or call | Line |
|---|---|
| Read | Reads the file {file} to learn the code. |
| Edit, MultiEdit | Edits the file {file} to change the code. |
| Write | Writes the file {file} to save new content. |
| Grep | Searches the code for {pattern} to find where it appears. |
| Glob | Looks for files that match {pattern} to find the right ones. |
| Agent or Task with a type | Asks the {agent} agent to do one part of the work. |
| Agent or Task with no type | Asks a helper agent to do one part of the work. |
| Agent or Task for a review | Asks a reviewer agent to check the PR. |
| Skill | Runs the skill {skill} to follow its steps. |
| WebFetch | Reads a web page to get facts for the task. |
| WebSearch | Searches the web to find facts for the task. |
| TodoWrite, TaskCreate, TaskUpdate | Updates the task list to track the work. |
| AskUserQuestion | Asks you a question to choose the next step. |
| Workflow | Runs a multi-agent workflow to split the work. |
| ToolSearch | Loads more tools for the next step. |
| SendMessage | Sends a message to another agent. |
| Monitor | Watches a background task until it finishes. |
| TaskStop | Stops a background task. |
| Artifact | Publishes or reads a web page made for this session. |
| LSP | Asks the language server about the code. |
| NotebookEdit | Edits a notebook cell. |
| ScheduleWakeup | Sets a time to continue the work. |
| ReadNotifications | Reads new notifications for this session. |
| BashOutput, TaskOutput, task-output, task_output | Reads the output of a background command. |
| Other tools | Runs the tool {tool}. |
| Bash: test run | Runs the tests to check the change. |
| Bash: git status | Checks git status to see what changed. |
| Bash: git diff, git show | Shows the git diff to review the changes. |
| Bash: git log | Reads the git log to learn the recent history. |
| Bash: git add | Adds files to the next commit to prepare it. |
| Bash: git commit | Makes a git commit to save the change. |
| Bash: git push | Pushes the branch to share the change. |
| Bash: git fetch, git pull | Gets new commits from the remote to stay current. |
| Bash: git checkout, git switch, git branch | Changes or lists git branches to keep work apart. |
| Bash: git worktree | Makes or removes a separate work folder for the task. |
| Bash: other git command | Runs a git command to manage the code history. |
| Bash: gh pr create | Opens a pull request so others can review the change. |
| Bash: gh pr merge | Merges the pull request to finish the change. |
| Bash: gh pr review, gh pr comment | Adds a review or comment on the pull request. |
| Bash: other gh pr command | Reads the pull request to check its status. |
| Bash: gh issue create | Opens a new issue to track the work. |
| Bash: other gh issue command | Reads the issue to learn what to build. |
| Bash: other gh command | Runs a GitHub command to work with the repository. |
| Bash: other shell command | Runs a shell command to do one step of the task. |
{file}, {pattern}, {agent}, {skill} and {tool} mark names in the templates. The plugin inserts a short name from the call.
claude plugin validate --strict plugins/apexyard-explainer
claude plugin test plugins/apexyard-explainer
The hook API is early access. If it breaks, the pane disappears. No gate is affected.
hooks/register.tsx 51 lines1// ApexYard explainer pane. Display only; templates only. No model, network,
2// process or file calls. No file writes. All gates stay in .claude/hooks/.
3import { atom, read, update } from 'claude-code'
4import type { Register } from 'claude-code'
5
6import { explain } from './explain'
7
8const PANE = 'apexyard-explainer'
9const COMMAND = 'explainer-toggle'
10export const KEEP = 5
11const lines = atom({ plugin: 'apexyard-explainer', key: 'lines' } as const, [])
12
13export const register: Register = on => {
14 on('session.start', async ($, e, next) => {
15 const result = await next(e)
16 try {
17 await $.command.register({ name: COMMAND, description: 'Open or close the explainer pane', immediate: true })
18 } catch {}
19 return result
20 })
21
22 on('command.run', { command: COMMAND }, async $ => {
23 if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
24 else await $.ui.open({ id: PANE, title: 'Explainer', rows: KEEP + 2 })
25 return {}
26 })
27
28 on('tool.call', async ($, e, next) => {
29 try {
30 const text = explain(e.tool, e as unknown as Record<string, unknown>)
31 await update($, lines, list => [...list, { id: String(e.tool_use_id ?? ''), text }].slice(-KEEP))
32 } catch {}
33 return next(e)
34 })
35
36 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
37 const { Box, Text } = $.ui.resolve(e)
38 const list = await read($, lines)
39 if (!list.length) return <Text key="empty" dimColor>No tool calls yet.</Text>
40 return (
41 <Box flexDirection="column">
42 {list.map((line, index) => (
43 <Text key={`${index}-${line.id}`} wrap="wrap" dimColor={index < list.length - 1}>
44 {line.text}
45 </Text>
46 ))}
47 </Box>
48 )
49 })
50}
51hooks/explain.ts 192 lines1// Pure templates and helpers. No engine calls.
2export const MAX_WORDS = 15
3
4export function wordCount(line: string): number {
5 return line.split(/\s+/).filter(Boolean).length
6}
7
8export const TEMPLATES: Readonly<Record<string, string>> = {
9 read: "Reads the file {file} to learn the code.",
10 edit: "Edits the file {file} to change the code.",
11 write: "Writes the file {file} to save new content.",
12 grep: "Searches the code for {pattern} to find where it appears.",
13 glob: "Looks for files that match {pattern} to find the right ones.",
14 agent: "Asks the {agent} agent to do one part of the work.",
15 agentAny: "Asks a helper agent to do one part of the work.",
16 agentReview: "Asks a reviewer agent to check the PR.",
17 skill: "Runs the skill {skill} to follow its steps.",
18 webFetch: "Reads a web page to get facts for the task.",
19 webSearch: "Searches the web to find facts for the task.",
20 todo: "Updates the task list to track the work.",
21 ask: "Asks you a question to choose the next step.",
22 workflow: "Runs a multi-agent workflow to split the work.",
23 toolSearch: "Loads more tools for the next step.",
24 sendMessage: "Sends a message to another agent.",
25 monitor: "Watches a background task until it finishes.",
26 taskStop: "Stops a background task.",
27 artifact: "Publishes or reads a web page made for this session.",
28 lsp: "Asks the language server about the code.",
29 notebookEdit: "Edits a notebook cell.",
30 scheduleWakeup: "Sets a time to continue the work.",
31 readNotifications: "Reads new notifications for this session.",
32 bashOutput: "Reads the output of a background command.",
33 unknown: "Runs the tool `{tool}`.",
34 tests: "Runs the tests to check the change.",
35 gitStatus: "Checks git status to see what changed.",
36 gitDiff: "Shows the git diff to review the changes.",
37 gitLog: "Reads the git log to learn the recent history.",
38 gitAdd: "Adds files to the next commit to prepare it.",
39 gitCommit: "Makes a git commit to save the change.",
40 gitPush: "Pushes the branch to share the change.",
41 gitFetch: "Gets new commits from the remote to stay current.",
42 gitBranch: "Changes or lists git branches to keep work apart.",
43 gitWorktree: "Makes or removes a separate work folder for the task.",
44 git: "Runs a git command to manage the code history.",
45 ghPrCreate: "Opens a pull request so others can review the change.",
46 ghPrMerge: "Merges the pull request to finish the change.",
47 ghPrReview: "Adds a review or comment on the pull request.",
48 ghPr: "Reads the pull request to check its status.",
49 ghIssueCreate: "Opens a new issue to track the work.",
50 ghIssue: "Reads the issue to learn what to build.",
51 gh: "Runs a GitHub command to work with the repository.",
52 bash: "Runs a shell command to do one step of the task.",
53}
54
55export function fill(template: string, values: Record<string, string>): string {
56 return template.replace(/\{(file|pattern|agent|skill|tool)\}/g, (_, key: string) => values[key] ?? '?')
57}
58
59export function token(value: unknown, max = 32): string {
60 if (typeof value !== 'string' || !value.trim()) return '?'
61 const trimmed = value.trim()
62 const first = trimmed.split(/\s+/)[0] ?? ''
63 const limit = Math.max(1, max)
64 const shortened = first.length < trimmed.length ? `${first}…` : first
65 return shortened.length > limit ? `${shortened.slice(0, limit - 1)}…` : shortened
66}
67
68export function baseName(path: unknown): string {
69 return token(typeof path === 'string' ? path.split(/[\/\\]/).pop() : path)
70}
71
72export type BashKind = 'tests' | 'gitStatus' | 'gitDiff' | 'gitLog' | 'gitAdd' |
73 'gitCommit' | 'gitPush' | 'gitFetch' | 'gitBranch' | 'gitWorktree' | 'git' |
74 'ghPrCreate' | 'ghPrMerge' | 'ghPrReview' | 'ghPr' | 'ghIssueCreate' | 'ghIssue' | 'gh' | 'bash'
75
76const TEST_RUNS = [
77 'npm test', 'npm run test', 'pnpm test', 'pnpm run test', 'yarn test',
78 'bun test', 'npx jest', 'npx vitest', 'jest', 'vitest', 'pytest',
79 'python -m pytest', 'python3 -m pytest', 'go test', 'cargo test',
80 'bats', 'make test', 'claude plugin test',
81].map(run => run.split(' '))
82
83export function bashKind(command: string): BashKind {
84 const segments = command.split(/&&|\|\||;|\||\n/).map(segment => {
85 const words = segment.trim().split(/\s+/).filter(Boolean)
86 while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) words.shift()
87 return words
88 }).filter(words => words.length && !['cd', 'export', 'set', 'source', '.', 'echo', 'true', 'sleep'].includes(words[0] ?? ''))
89 if (segments.some(words => TEST_RUNS.some(run => run.every((word, i) => words[i] === word)) ||
90 words.some(word => /(?:\.bats|test\.sh)$/.test(word)))) return 'tests'
91 const words = segments[0] ?? []
92 if (words[0] === 'git') {
93 let i = 1
94 while (i < words.length) {
95 const word = words[i] ?? ''
96 if (word === '-C' || word === '-c') i += 2
97 else if (word === '--no-pager' || /^(--git-dir|--work-tree)=/.test(word)) i++
98 else break
99 }
100 const kinds: Readonly<Record<string, BashKind>> = {
101 status: 'gitStatus', diff: 'gitDiff', show: 'gitDiff', log: 'gitLog', add: 'gitAdd',
102 commit: 'gitCommit', push: 'gitPush', fetch: 'gitFetch', pull: 'gitFetch',
103 checkout: 'gitBranch', switch: 'gitBranch', branch: 'gitBranch', worktree: 'gitWorktree',
104 }
105 return kinds[words[i] ?? ''] ?? 'git'
106 }
107 if (words[0] === 'gh') {
108 const args: string[] = []
109 for (let i = 1; i < words.length; i++) {
110 if (words[i] === '-R' || words[i] === '--repo') i++
111 else args.push(words[i] ?? '')
112 }
113 if (args[0] === 'pr') {
114 if (args[1] === 'create') return 'ghPrCreate'
115 if (args[1] === 'merge') return 'ghPrMerge'
116 if (args[1] === 'review' || args[1] === 'comment') return 'ghPrReview'
117 return 'ghPr'
118 }
119 if (args[0] === 'issue') return args[1] === 'create' ? 'ghIssueCreate' : 'ghIssue'
120 return 'gh'
121 }
122 return 'bash'
123}
124
125// Check the full input before shortening it, so a hidden suffix cannot leak.
126function looksLikeSecret(value: unknown): boolean {
127 return typeof value === 'string' &&
128 /ghp_|sk-|xox|[A-Za-z0-9+/_=-]{32,}/i.test(value)
129}
130
131function descriptionLine(value: unknown): string | undefined {
132 if (typeof value !== 'string' || looksLikeSecret(value)) return undefined
133 const line = value.replace(/\s+/g, ' ').trim()
134 if (!line) return undefined
135 return line.slice(0, 119).trimEnd().replace(/[.!?]+$/, '') + '.'
136}
137
138export function explain(tool: string, input: Readonly<Record<string, unknown>>): string {
139 const review = (tool === 'Agent' || tool === 'Task') &&
140 [input.subagent_type, input.description].some(value => typeof value === 'string' && /review|rex|auditor/i.test(value))
141 const description = looksLikeSecret(input.command) ? undefined : descriptionLine(input.description)
142 if (description && !review) return description
143 let key: string
144 const values: Record<string, string> = {}
145 switch (tool) {
146 case 'Read': case 'Edit': case 'Write': case 'MultiEdit':
147 key = tool === 'Read' ? 'read' : tool === 'Write' ? 'write' : 'edit'
148 values.file = baseName(input.file_path)
149 break
150 case 'Grep': case 'Glob':
151 key = tool === 'Grep' ? 'grep' : 'glob'
152 values.pattern = token(input.pattern)
153 break
154 case 'Agent': case 'Task':
155 if (review) key = 'agentReview'
156 else if (typeof input.subagent_type === 'string' && input.subagent_type.trim()) {
157 key = 'agent'
158 values.agent = token(input.subagent_type)
159 } else key = 'agentAny'
160 break
161 case 'Skill': key = 'skill'; values.skill = token(input.skill); break
162 case 'Bash': key = bashKind(String(input.command ?? '')); break
163 case 'WebFetch': key = 'webFetch'; break
164 case 'WebSearch': key = 'webSearch'; break
165 case 'TodoWrite': case 'TaskCreate': case 'TaskUpdate': key = 'todo'; break
166 case 'AskUserQuestion': key = 'ask'; break
167 case 'Workflow': key = 'workflow'; break
168 case 'ToolSearch': key = 'toolSearch'; break
169 case 'SendMessage': key = 'sendMessage'; break
170 case 'Monitor': key = 'monitor'; break
171 case 'TaskStop': key = 'taskStop'; break
172 case 'Artifact': key = 'artifact'; break
173 case 'LSP': key = 'lsp'; break
174 case 'NotebookEdit': key = 'notebookEdit'; break
175 case 'ScheduleWakeup': key = 'scheduleWakeup'; break
176 case 'ReadNotifications': key = 'readNotifications'; break
177 case 'BashOutput': case 'TaskOutput': case 'task-output': case 'task_output':
178 key = 'bashOutput'; break
179 default: {
180 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
181 if (mcp) {
182 const server = (mcp[1] ?? '').replace(/^claude_ai_/i, '').replace(/[_-]+/g, ' ')
183 .replace(/\b\w/g, letter => letter.toUpperCase()).trim()
184 const words = (mcp[2] ?? '').replace(/[_-]+/g, ' ').trim()
185 if (server && words) return `Uses ${server}: ${words}.`
186 }
187 key = 'unknown'; values.tool = token(tool, 48)
188 }
189 }
190 return fill(TEMPLATES[key] ?? TEMPLATES.unknown ?? 'Runs the tool `{tool}`.', values)
191}
192types/index.d.ts 8 lines1export type Line = { id: string; text: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'apexyard-explainer': { lines: Line[] }
6 }
7}
8