A checklist pane for manual testing: Claude publishes the steps, you mark each pass, fail or skip with a note, and the results go back as one message

Small Claude Code mods: plugins of function hooks that change what Claude Code shows or does, in the terminal and in the desktop app's Code tab.
In a terminal Claude Code session:
/plugin install <mod> --marketplace enhki/claude-mods
Answer y to add the marketplace, then pick the user scope so the mod loads in every session, desktop ones included.
| Mod | What it does |
|---|---|
statusline-desktop | Draws your terminal status line above the prompt in the desktop app |
forgejo-issues | Browse, search and manage the current repo's Forgejo or Gitea issues in a pane |
work-session | Kicks off and wraps up working sessions with your own start and end commands, and titles each session |
walkthrough | A checklist pane for testing by hand: mark each step, add notes, send the results back in one message |
The desktop app doesn't run your statusLine command. This mod does: it picks the command the way Claude Code does (project .claude/settings.local.json, then .claude/settings.json, then ~/.claude/settings.json), pipes it the same JSON the terminal would (model, cwd, cost, context window, rate limits), and draws the first line it prints in the band above the prompt.
config-file) sets one; otherwise they map to the app's own theme colours.current_usage reads slightly lower than in the terminal (output tokens come through as 0)./issues opens a pane on the issues of the repo the session runs in. The forge and repo come from git remote get-url origin (ssh or https), so it works for any Forgejo or Gitea host, Codeberg included.
kind/bug sit under their scope, exclusive scopes pick one); a milestone picker.label:bug, -label:wontfix, label:kind/, milestone:"Some name", is:closed. Enter opens the top match.#N mentions as chips that open in the pane.Token. The mod needs an API token with issue read/write and repository read. Put it in ~/.config/claude-mods/secrets/forgejo.env (folder 700, file 600), keyed by host so a token only ever goes to its own forge:
FORGEJO_TOKEN_CODEBERG_ORG=...
FORGEJO_TOKEN_GIT_EXAMPLE_COM=...
Without one, it falls back to fgj's config for that host. The mod also stops Claude's own file and shell tools from reading that secrets folder.
Settings (the plugin's options):
| Setting | Default | What it does |
|---|---|---|
apiUrl | empty | The API base when it isn't https://<remote host>/api/v1 |
requireCommentOnClose | on | Closing needs a comment, posted before the close |
requireMilestone | off | New issues need a milestone |
requireLabelFrom | empty | New issues need one of these comma-separated labels |
editorCommand | empty | Adds Open in editor: e.g. ghostty --gtk-single-instance=false --class=popup.editor -e nvim {file} |
Desktop notes. The desktop app currently drops Button and Markdown-link presses from plugin panes, so every control here is a small Client that posts a message instead; and it passes no paste into a Client, so for long or pasted text use Open in editor. The editor command must wait until you close the file; one that returns at once (a launcher that forks) leaves a Use editor text button to pull the file back by hand. Ghostty needs --gtk-single-instance=false for this, or it may hand the window to a running instance and return at once.
For projects where you start and end each working session with your own slash commands (a "continue working session" and an "end working session", say /cws and /ews), this mod offers them at the right moments and gives each session a title you can tell apart in the session list.
/clear, not a resume) in a project that has the start command, a band above the prompt offers it: press 1 to run it, 2 to skip, or just type something else. Set Kickoff to auto to run it straight away, or off.set_session_title tool and a line of context asking it to title the session once its focus is clear: <Project> S<n> · <focus> when the project numbers its sessions, else <Project> · <focus>. The title shows from your next message on. A /rename of your own wins: the mod stops setting it.Projects without the start command see nothing. In the desktop app the band draws above the desktop status line rather than in place of it.
Settings (the plugin's options):
| Setting | Default | What it does |
|---|---|---|
startCommand | cws | The command that kicks a session off, without the slash |
endCommand | ews | The command that wraps one up |
kickoff | band | band offers it, auto runs it at start, off never |
wrapAtPercent | 75 | Context fullness that brings up the wrap offer; 0 turns it off |
When Claude asks you to check something by hand (run a demo, resize a window, try a key), it publishes the steps to a Walkthrough pane instead of a list in chat, and you send the results back from there.
publish_walkthrough tool refuses a step that doesn't say what to expect. Publishing again replaces the list./walkthrough. The step you're on is drawn open: its command with Copy, what to expect, Pass / Fail / Skip and a note. Marking it opens the next one; done steps fold to one line you can click to reopen. Pressing a mark again takes it back.p pass, f fail, s skip, c copy, r send.Screenshots don't go through the pane: paste them in the prompt as usual. No settings.
A mod is a plugin of function hooks: a plugin.json, a hooks/hooks.json naming one TypeScript module, and that module's register(on, options). In a Claude Code session, the bundled plugin-authoring skill holds the full API (types, examples, the test kit); load it before writing one.
| Kind | Where | Loads |
|---|---|---|
| General, shareable | here, mods/<name>/ + an entry in .claude-plugin/marketplace.json | everywhere, once installed at user scope |
| Tied to one project, or naming private hosts | that project's .claude/skills/<name>/ | in that project's sessions only, watched for edits |
| Just for you, everywhere, private | ~/.claude/skills/<name>/ | every session, watched for edits |
Anything that names a private host, a person or a machine stays out of this public repo: make it a project mod, or read the value from a setting.
plugin-authoring skill gives a mods folder that hot-reloads when each turn ends: the quickest loop.mods/<name>/, list it in the marketplace file, and install it from this clone (below). From then on, edits are live after /reload-plugins.claude plugin validate mods/<name>, claude plugin test mods/<name>, and a type-check. Bump version in the mod's plugin.json when behaviour changes, so GitHub installs update.claude plugin test runs *.test.ts(x) against the engine itself; a test stands in for engine calls with on('<event>', () => ({ value: … })).userConfig (shown in the plugin's options); nothing personal is hard-coded.~/.config/claude-mods/secrets/<service>.env (folder 700, file 600), keyed by host where a token belongs to a host. A mod that reads one also keeps Claude's own tools out of that folder (see forgejo-issues/hooks/guard.ts).$.state) outlives reloads and upgrades: read and update it through helpers that fill in defaults, or a field a new version adds arrives undefined.ui_press not handled in ~/.config/Claude/logs/claude.ai-web.log). Input, Select and Client messages do. Make each pressable control its own small Client that posts a message (forgejo-issues/hooks/pill.tsx).Client's pointer and key listeners once, on its first draw. Each set is a message to the page, and many Clients re-setting them on every redraw get unmounted for flooding it.Client per clickable item makes hit-testing unnecessary.Client, and the native Input is one line at a fixed width. For long text, hand off to an external editor (forgejo-issues's Open in editor).<mod>: <event> hook skipped: threw … names the handler that did.Clone, then add the clone itself as your marketplace so edits are live after /reload-plugins with no reinstall:
claude plugin marketplace add ~/repos/claude-mods
claude plugin install <mod>@claude-mods --scope user
Check a mod before committing:
claude plugin validate mods/<mod>
claude plugin test mods/<mod>hooks/register.tsx 193 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as $, RenderSurface, Register } from 'claude-code'
3
4import type { Mark, Walkthrough } from '../types'
5import { EMPTY, GLYPH, mark, note, openStep, parse, results, summary, tally, toggle, TOOL_DESCRIPTION, whole } from './walk'
6
7const PANE = 'walkthrough'
8const TOOL = 'publish_walkthrough'
9
10const current = atom({ plugin: 'walkthrough', key: 'current' } as const, EMPTY)
11
12// Saved state from an older version lacks newer fields: fill defaults on reads and updates.
13const readWalk = async ($: $): Promise<Walkthrough> => whole(await read($, current))
14const setWalk = ($: $, fn: (w: Walkthrough) => Walkthrough) => update($, current, (x): Walkthrough => fn(whole(x)))
15
16const MARKS: readonly Mark[] = ['pass', 'fail', 'skip']
17const LABEL: Record<Mark, string> = { pass: 'Pass', fail: 'Fail', skip: 'Skip', pending: '' }
18const TONE: Record<Mark, string> = { pass: 'success', fail: 'error', skip: 'warning', pending: 'subtle' }
19
20/** Press ids: `<action>` or `<action>:<step>`, every one a control of this pane. */
21async function press($: $, id: string, surface: RenderSurface): Promise<void> {
22 const [action, at] = id.split(':')
23 const i = Number(at)
24 if (action === 'pass' || action === 'fail' || action === 'skip') {
25 await setWalk($, w => mark(w, i, action))
26 } else if (action === 'open') {
27 await setWalk($, w => toggle(w, i))
28 } else if (action === 'copy') {
29 const w = await readWalk($)
30 const run = w.steps[i]?.run
31 if (!run) return
32 const copied = await $.ui.copy({ text: run, surface })
33 $.ui.toast(copied.isCopied ? 'Command copied' : 'Could not copy the command here')
34 } else if (action === 'send') {
35 const w = await readWalk($)
36 if (w.steps.length === 0) return
37 await setWalk($, x => ({ ...x, sent: x.sent + 1 }))
38 try {
39 await $.prompt.submit({ text: results(w) })
40 $.ui.toast('Results sent to Claude')
41 } catch (err) {
42 $.ui.log(`walkthrough: could not send the results: ${err instanceof Error ? err.message : String(err)}`)
43 }
44 }
45}
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 const result = await next(e)
50 await $.command.register({ name: 'walkthrough', description: 'Show the manual test checklist Claude published' })
51 await $.tool.register({
52 name: TOOL,
53 description: TOOL_DESCRIPTION,
54 inputSchema: {
55 type: 'object',
56 properties: {
57 title: { type: 'string', description: 'What is being validated, e.g. "Sidebar resize, issue #12"' },
58 intro: { type: 'string', description: 'One or two lines of setup that applies to every step' },
59 steps: {
60 type: 'array',
61 items: {
62 type: 'object',
63 properties: {
64 title: { type: 'string', description: 'The check, in a few words' },
65 run: { type: 'string', description: 'The exact command to run, if any' },
66 where: { type: 'string', description: 'Directory, worktree, terminal, window size' },
67 expect: { type: 'string', description: 'What the person should see when it works' },
68 },
69 required: ['title', 'expect'],
70 },
71 },
72 },
73 required: ['title', 'steps'],
74 },
75 isDeferred: false,
76 })
77 return result
78 })
79
80 on('command.run', { command: 'walkthrough' }, async $ => {
81 await $.ui.open({ id: PANE, title: 'Walkthrough', focus: true })
82 return { text: 'Walkthrough pane opened.' }
83 })
84
85 on('tool.call', { tool: 'mcp__walkthrough__publish_walkthrough' }, async ($, e) => {
86 const parsed = parse(e)
87 if ('error' in parsed) return { deny: `${TOOL}: ${parsed.error}` }
88 await setWalk($, () => parsed.walkthrough)
89 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Walkthrough' })
90 const n = parsed.walkthrough.steps.length
91 const where = isPlaced ? 'It is open in the Walkthrough pane' : 'The person opens it with /walkthrough'
92 return {
93 result:
94 `Published ${n} step${n === 1 ? '' : 's'}. ${where}. ` +
95 'The results arrive as a message from the walkthrough plugin; wait for them instead of asking in chat.',
96 }
97 })
98
99 // The desktop's controls are Clients posting { press }.
100 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
101 const id = (e.data as { press?: unknown } | null)?.press
102 if (typeof id === 'string') await press($, id, e.surface)
103 return {}
104 })
105
106 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
107 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
108 const { Text } = $.ui.resolve(e)
109 return <Text>The walkthrough draws in the terminal and the desktop app.</Text>
110 }
111 const surface = e.surface
112 const { Box, Text, Button, Client, Code, Input } = $.ui.resolve({ ...e, surface })
113 const w = await readWalk($)
114 if (w.steps.length === 0) {
115 return <Text dimColor>No walkthrough yet. Claude publishes one when there is something to test by hand.</Text>
116 }
117
118 // The terminal draws Buttons (hotkeys, Tab); the desktop drops Button
119 // presses from plugin panes, so it gets Client pills.
120 const control = (id: string, label: string, opts: { hotkey?: string; tone?: string } = {}) =>
121 surface === 'terminal' ? (
122 <Button key={id} label={label} hotkey={opts.hotkey} plain onPress={() => press($, id, surface)} />
123 ) : (
124 // Client props are plain data: an absent tone is left out, never undefined.
125 <Client key={id} module="./pill.tsx" props={{ id, label, kind: 'button', ...(opts.tone ? { tone: opts.tone } : {}) }} />
126 )
127 const row = (id: string, label: string, tone: string, dim: boolean) =>
128 surface === 'terminal' ? (
129 <Button key={id} label={label} plain onPress={() => press($, id, surface)} />
130 ) : (
131 <Client key={id} module="./pill.tsx" props={{ id, label, kind: 'row', tone, dim }} />
132 )
133
134 const t = tally(w)
135 const open = openStep(w)
136 const steps = w.steps.map((s, i) => {
137 const m = w.marks[i] ?? 'pending'
138 const head = `${GLYPH[m]} ${i + 1}. ${s.title}`
139 if (i !== open) {
140 const n = (w.notes[i] ?? '').trim()
141 return row(`open:${i}`, n ? `${head} (${n})` : head, TONE[m], m !== 'pending')
142 }
143 return (
144 <Box key={`step-${i}`} flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1}>
145 {row(`open:${i}`, head, TONE[m], false)}
146 {s.where && <Text dimColor>in {s.where}</Text>}
147 {s.run && (
148 <Box key={`run-${i}`} flexDirection="row" gap={1}>
149 <Box flexGrow={1} flexDirection="column">
150 <Code source={s.run} language="bash" />
151 </Box>
152 {control(`copy:${i}`, 'Copy', { hotkey: 'c' })}
153 </Box>
154 )}
155 <Text>Expect: {s.expect}</Text>
156 <Box key={`marks-${i}`} flexDirection="row" gap={1}>
157 {MARKS.map(k =>
158 control(`${k}:${i}`, m === k ? `${LABEL[k]} ✓` : LABEL[k], { hotkey: k[0], tone: m === k ? TONE[k] : undefined }),
159 )}
160 </Box>
161 <Input
162 key={`note:${i}`}
163 label="Note "
164 placeholder="what you saw (optional)"
165 value={w.notes[i] ?? ''}
166 submitLabel="save"
167 onInput={value => setWalk($, x => note(x, i, value))}
168 onSubmit={value => setWalk($, x => note(x, i, value))}
169 />
170 </Box>
171 )
172 })
173
174 const done = t.pending === 0
175 return (
176 <Box flexDirection="column" gap={1}>
177 <Box key="head" flexDirection="column">
178 <Text bold>{w.title}</Text>
179 <Text dimColor>{summary(t)}</Text>
180 {w.intro && <Text>{w.intro}</Text>}
181 </Box>
182 <Box key="steps" flexDirection="column">
183 {steps}
184 </Box>
185 <Box key="foot" flexDirection="row" gap={2}>
186 {control('send', w.sent > 0 ? 'Send results again' : 'Send results', { hotkey: 'r', tone: done ? 'suggestion' : undefined })}
187 {w.sent > 0 && <Text dimColor>sent</Text>}
188 </Box>
189 </Box>
190 )
191 })
192}
193hooks/walk.ts 114 lines1/** Pure decisions behind the pane: what a walkthrough may hold, where it stands, what goes back. */
2
3import type { Mark, Step, Walkthrough } from '../types'
4
5export const MAX_STEPS = 40
6const MAX_TEXT = 2_000
7
8export const EMPTY: Walkthrough = { title: '', intro: '', steps: [], marks: [], notes: [], open: null, sent: 0 }
9
10function text(raw: unknown, max = MAX_TEXT): string {
11 return typeof raw === 'string' ? raw.trim().slice(0, max) : ''
12}
13
14/** A walkthrough from the tool's input, or why it is refused. */
15export function parse(input: unknown): { walkthrough: Walkthrough } | { error: string } {
16 const o = (input ?? {}) as { title?: unknown; intro?: unknown; steps?: unknown }
17 const title = text(o.title, 120)
18 if (!title) return { error: 'title is required' }
19 if (!Array.isArray(o.steps) || o.steps.length === 0) return { error: 'steps must be a non-empty list' }
20 if (o.steps.length > MAX_STEPS) return { error: `${o.steps.length} steps; keep it to ${MAX_STEPS} or split it` }
21 const steps: Step[] = []
22 for (const [i, raw] of o.steps.entries()) {
23 const s = (raw ?? {}) as Record<string, unknown>
24 const step = { title: text(s.title, 160), run: text(s.run), where: text(s.where, 300), expect: text(s.expect) }
25 if (!step.title) return { error: `step ${i + 1} has no title` }
26 if (!step.expect) return { error: `step ${i + 1} ("${step.title}") says nothing about what to expect` }
27 steps.push(step)
28 }
29 return {
30 walkthrough: {
31 ...EMPTY,
32 title,
33 intro: text(o.intro),
34 steps,
35 marks: steps.map((): Mark => 'pending'),
36 notes: steps.map(() => ''),
37 },
38 }
39}
40
41/** Saved state from an older version, or a half-written one, made whole. */
42export function whole(w: Partial<Walkthrough> | undefined): Walkthrough {
43 const x = { ...EMPTY, ...w }
44 const steps = Array.isArray(x.steps) ? x.steps : []
45 return {
46 ...x,
47 steps,
48 marks: steps.map((_, i) => x.marks?.[i] ?? 'pending'),
49 notes: steps.map((_, i) => x.notes?.[i] ?? ''),
50 }
51}
52
53/** The step drawn open: the one the person picked, else the first not yet marked. */
54export function openStep(w: Walkthrough): number | null {
55 if (w.open === -1) return null
56 if (w.open !== null && w.open >= 0 && w.open < w.steps.length) return w.open
57 const i = w.marks.indexOf('pending')
58 return i === -1 ? null : i
59}
60
61/** Marks a step; marking it again the same way takes the mark back. The open step moves on. */
62export function mark(w: Walkthrough, i: number, m: Mark): Walkthrough {
63 if (i < 0 || i >= w.steps.length) return w
64 const marks = w.marks.map((cur, j) => (j === i ? (cur === m ? 'pending' : m) : cur))
65 return { ...w, marks, open: null }
66}
67
68export function note(w: Walkthrough, i: number, value: string): Walkthrough {
69 if (i < 0 || i >= w.steps.length) return w
70 return { ...w, notes: w.notes.map((cur, j) => (j === i ? value.slice(0, MAX_TEXT) : cur)) }
71}
72
73/** Opens a step, or closes it again when it is the one open. */
74export function toggle(w: Walkthrough, i: number): Walkthrough {
75 if (i < 0 || i >= w.steps.length) return w
76 return { ...w, open: openStep(w) === i ? -1 : i }
77}
78
79export type Tally = Record<Mark, number>
80
81export function tally(w: Walkthrough): Tally {
82 const t: Tally = { pending: 0, pass: 0, fail: 0, skip: 0 }
83 for (const m of w.marks) t[m] += 1
84 return t
85}
86
87const WORD: Record<Mark, string> = { pass: 'PASS', fail: 'FAIL', skip: 'SKIP', pending: 'NOT DONE' }
88export const GLYPH: Record<Mark, string> = { pass: '✓', fail: '✗', skip: '–', pending: '·' }
89
90export function summary(t: Tally): string {
91 const parts = [`${t.pass} passed`, `${t.fail} failed`]
92 if (t.skip) parts.push(`${t.skip} skipped`)
93 if (t.pending) parts.push(`${t.pending} not done`)
94 return parts.join(', ')
95}
96
97/** The message the results go back as: one line per step, notes beside their step. */
98export function results(w: Walkthrough): string {
99 const lines = w.steps.map((s, i) => {
100 const n = (w.notes[i] ?? '').replace(/\s+/g, ' ').trim()
101 return `${i + 1}. ${WORD[w.marks[i] ?? 'pending']}: ${s.title}${n ? ` (note: ${n})` : ''}`
102 })
103 return [`Walkthrough results for "${w.title}": ${summary(tally(w))}.`, ...lines].join('\n')
104}
105
106export const TOOL_DESCRIPTION = [
107 "Publishes a manual test checklist to the person's Walkthrough pane. They mark each step pass, fail or skip,",
108 'add a note, and send the results back to you as one message.',
109 'Use it whenever you ask the person to check something by hand, instead of listing the steps in chat.',
110 'One step per thing to check. Each step says the exact command to run (run), where to run it (where: the directory,',
111 'worktree, terminal, window size), and what the person should see when it works (expect), concretely enough for',
112 'someone who did not follow the conversation. Publishing replaces the walkthrough shown before.',
113].join(' ')
114hooks/pill.tsx 56 lines1import type { ClientModule } from 'claude-code'
2
3/**
4 * A pressable control drawn as a Client: a click, Enter or Space posts
5 * `{ press: id }` to the hooks module. The desktop app drops Button presses
6 * from plugin panes, while Client messages arrive (claude-mods' desktop rules);
7 * the terminal draws real Buttons instead, for their hotkeys.
8 */
9export type PillProps = {
10 id: string
11 label: string
12 /** `button`: a bordered control; `row`: a line of text, underlined on hover. */
13 kind: 'button' | 'row'
14 /** A colour for the border at rest (buttons) or the text (rows). */
15 tone?: string
16 dim?: boolean
17}
18
19type Local = { hover: boolean }
20
21const Pill: ClientModule<PillProps, Local> = (props, surface) => {
22 const { Box, Text } = surface.elements
23 const state = surface.state ?? { hover: false }
24 // Listeners are set once, on the first draw: each set is a message to the page.
25 if (surface.state === undefined) {
26 const id = props.id
27 surface.onPointer(e => {
28 const cur = surface.state ?? { hover: false }
29 if (e.type === 'leave') {
30 if (cur.hover) surface.setState({ hover: false })
31 } else if (!cur.hover) {
32 surface.setState({ hover: true })
33 }
34 if (e.type === 'down' && (e.button ?? 'left') === 'left') surface.post({ press: id })
35 })
36 surface.onKey(e => {
37 if (e.key === 'return' || e.key === ' ') surface.post({ press: id })
38 })
39 surface.setState(state)
40 }
41 if (props.kind === 'row') {
42 return (
43 <Text color={props.tone} dimColor={props.dim && !state.hover} underline={state.hover}>
44 {props.label}
45 </Text>
46 )
47 }
48 return (
49 <Box flexDirection="row" borderStyle="round" borderColor={state.hover ? 'suggestion' : (props.tone ?? 'subtle')} paddingX={1}>
50 <Text>{props.label}</Text>
51 </Box>
52 )
53}
54
55export default Pill
56types/index.d.ts 32 lines1/** One thing for the person to check by hand. */
2export type Step = {
3 title: string
4 /** The exact command to run, if there is one. */
5 run: string
6 /** Where to run it: a directory, a terminal, a window size. */
7 where: string
8 /** What the person should see when it works. */
9 expect: string
10}
11
12export type Mark = 'pending' | 'pass' | 'fail' | 'skip'
13
14export type Walkthrough = {
15 title: string
16 intro: string
17 steps: Step[]
18 /** One per step, in step order. */
19 marks: Mark[]
20 notes: string[]
21 /** The step drawn open; null means the first one not yet marked, -1 none. */
22 open: number | null
23 /** How many times the results were sent back. */
24 sent: number
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 walkthrough: { current: Walkthrough }
30 }
31}
32