SLOPSHOPPER

apexyard-explainer

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

newpaneguardcommand
★ 529v0.1.0MITupdated 2026-10-15me2resh/apexyard/plugins/apexyard-explainer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · apexyard-explainer
│ ┃ Explainer ✕ › fix the failing auth test and add an audit log call │ ┃ Writes the file cache.ts to save new │ ┃ content. ⏺ Read(src/auth.ts) │ ┃ Run the test suite. ⎿ Read 6 lines │ ┃ Show working tree status. ⏺ Update(src/auth.ts) │ ┃ Clean build output and push. ⎿ Added 2 lines, removed 1 line │ ┃ Show environment file. ⏺ 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 │ │ › /explainer-toggle │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Explainer
Writes the file cache.ts to save new content. Run the test suite. Show working tree status. Clean build output and push. Show environment file.
README

ApexYard explainer

What it shows

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.

Rules

  • Display only.
  • Local descriptions and templates only.
  • No model, network or process calls.
  • No file writes.
  • It keeps only the last 5 lines in session memory, even when the pane is closed.

Install

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

Use

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

Writing rule

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

Coverage and fallback order

  1. Use the call's non-empty 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.
  2. Use an existing template. Bash still detects git, gh, and test commands.
  3. For 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.
  4. Use the built-in templates below.
  5. Use the generic line for an unknown tool.

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.

Tools and lines

Tool or callLine
ReadReads the file {file} to learn the code.
Edit, MultiEditEdits the file {file} to change the code.
WriteWrites the file {file} to save new content.
GrepSearches the code for {pattern} to find where it appears.
GlobLooks for files that match {pattern} to find the right ones.
Agent or Task with a typeAsks the {agent} agent to do one part of the work.
Agent or Task with no typeAsks a helper agent to do one part of the work.
Agent or Task for a reviewAsks a reviewer agent to check the PR.
SkillRuns the skill {skill} to follow its steps.
WebFetchReads a web page to get facts for the task.
WebSearchSearches the web to find facts for the task.
TodoWrite, TaskCreate, TaskUpdateUpdates the task list to track the work.
AskUserQuestionAsks you a question to choose the next step.
WorkflowRuns a multi-agent workflow to split the work.
ToolSearchLoads more tools for the next step.
SendMessageSends a message to another agent.
MonitorWatches a background task until it finishes.
TaskStopStops a background task.
ArtifactPublishes or reads a web page made for this session.
LSPAsks the language server about the code.
NotebookEditEdits a notebook cell.
ScheduleWakeupSets a time to continue the work.
ReadNotificationsReads new notifications for this session.
BashOutput, TaskOutput, task-output, task_outputReads the output of a background command.
Other toolsRuns the tool {tool}.
Bash: test runRuns the tests to check the change.
Bash: git statusChecks git status to see what changed.
Bash: git diff, git showShows the git diff to review the changes.
Bash: git logReads the git log to learn the recent history.
Bash: git addAdds files to the next commit to prepare it.
Bash: git commitMakes a git commit to save the change.
Bash: git pushPushes the branch to share the change.
Bash: git fetch, git pullGets new commits from the remote to stay current.
Bash: git checkout, git switch, git branchChanges or lists git branches to keep work apart.
Bash: git worktreeMakes or removes a separate work folder for the task.
Bash: other git commandRuns a git command to manage the code history.
Bash: gh pr createOpens a pull request so others can review the change.
Bash: gh pr mergeMerges the pull request to finish the change.
Bash: gh pr review, gh pr commentAdds a review or comment on the pull request.
Bash: other gh pr commandReads the pull request to check its status.
Bash: gh issue createOpens a new issue to track the work.
Bash: other gh issue commandReads the issue to learn what to build.
Bash: other gh commandRuns a GitHub command to work with the repository.
Bash: other shell commandRuns 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.

Tests

claude plugin validate --strict plugins/apexyard-explainer
claude plugin test plugins/apexyard-explainer

Risk

The hook API is early access. If it breaks, the pane disappears. No gate is affected.

Source 3 files
hooks/register.tsx 51 lines
1// 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}
51
hooks/explain.ts 192 lines
1// 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}
192
types/index.d.ts 8 lines
1export type Line = { id: string; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'apexyard-explainer': { lines: Line[] }
6  }
7}
8