SLOPSHOPPER

walkthrough

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

newpaneguardcommandtoasttool
★ 1v0.1.0MITupdated 2026-10-10enhki/claude-mods/mods/walkthrough
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · walkthrough
│ ┃ Walkthrough ✕ › fix the failing auth test and add an audit log call │ ┃ No walkthrough yet. Claude publishes one │ ┃ when there is something to test by hand. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /walkthrough │ ⎿ walkthrough: Walkthrough pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Walkthrough
No walkthrough yet. Claude publishes one when there is something to test by hand.
README

claude-mods

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.

Install

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.

Mods

ModWhat it does
statusline-desktopDraws your terminal status line above the prompt in the desktop app
forgejo-issuesBrowse, search and manage the current repo's Forgejo or Gitea issues in a pane
work-sessionKicks off and wraps up working sessions with your own start and end commands, and titles each session
walkthroughA checklist pane for testing by hand: mark each step, add notes, send the results back in one message

statusline-desktop

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.

  • ANSI colours keep the terminal's palette when ghostty's config (and the files it pulls in with config-file) sets one; otherwise they map to the app's own theme colours.
  • Refreshes on session start, after each tool call and turn, and every 30 s.
  • Stays out of the terminal, which already draws the real status line.
  • Only the input side of the context window is known to mods, so a token count your script sums from current_usage reads slightly lower than in the terminal (output tokens come through as 0).

forgejo-issues

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

  • Browse: every issue as a card, grouped by milestone; Open, Closed, All; label chips with counts (scoped labels like kind/bug sit under their scope, exclusive scopes pick one); a milestone picker.
  • Search: fzf-style fuzzy matching with highlights, plus label:bug, -label:wontfix, label:kind/, milestone:"Some name", is:closed. Enter opens the top match.
  • Read: the body on a raised panel, comments as cards, #N mentions as chips that open in the pane.
  • Write: comment, close and reopen (behind a Confirm), edit labels, set the milestone, file a new issue. Text goes in a full-width Markdown editor with a toolbar and a preview.

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):

SettingDefaultWhat it does
apiUrlemptyThe API base when it isn't https://<remote host>/api/v1
requireCommentOnCloseonClosing needs a comment, posted before the close
requireMilestoneoffNew issues need a milestone
requireLabelFromemptyNew issues need one of these comma-separated labels
editorCommandemptyAdds 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.

work-session

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.

  • Kickoff: when a session starts fresh (launch or /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.
  • Title: Claude gets a 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.
  • Wrap: once the context window passes Offer the wrap at (75% by default) or a rate limit passes 90%, the band offers the end command, with Later to dismiss it for the session.

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):

SettingDefaultWhat it does
startCommandcwsThe command that kicks a session off, without the slash
endCommandewsThe command that wraps one up
kickoffbandband offers it, auto runs it at start, off never
wrapAtPercent75Context fullness that brings up the wrap offer; 0 turns it off

walkthrough

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.

  • Steps: each one says what to run, where (directory, terminal, window size) and what you should see. Claude's publish_walkthrough tool refuses a step that doesn't say what to expect. Publishing again replaces the list.
  • The pane opens when Claude publishes, or with /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.
  • Keys (terminal, once the pane has the keyboard): p pass, f fail, s skip, c copy, r send.
  • Send results submits one message: a tally, then each step as PASS, FAIL, SKIP or NOT DONE with your note beside it.

Screenshots don't go through the pane: paste them in the prompt as usual. No settings.

Writing a mod

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.

Where it lives

KindWhereLoads
General, shareablehere, mods/<name>/ + an entry in .claude-plugin/marketplace.jsoneverywhere, once installed at user scope
Tied to one project, or naming private hoststhat 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.

Workflow

  1. Sketch in the session's dev folder. The plugin-authoring skill gives a mods folder that hot-reloads when each turn ends: the quickest loop.
  2. Move it here as mods/<name>/, list it in the marketplace file, and install it from this clone (below). From then on, edits are live after /reload-plugins.
  3. Test it on the surface you'll use. The terminal and the desktop app draw the same tree differently (see the notes below); try both when it matters.
  4. Before each commit: 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.

Conventions shared by these mods

  • Pure logic in its own files, with tests; the hooks module stays thin. claude plugin test runs *.test.ts(x) against the engine itself; a test stands in for engine calls with on('<event>', () => ({ value: … })).
  • Settings are the manifest's userConfig (shown in the plugin's options); nothing personal is hard-coded.
  • Secrets go in ~/.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).
  • Saved state ($.state) outlives reloads and upgrades: read and update it through helpers that fill in defaults, or a field a new version adds arrives undefined.

Desktop app notes (Claude Code 2.1.29x)

  • Button and Markdown-link presses from a plugin pane don't arrive (the app logs 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).
  • Set a 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.
  • Don't map pointer rows by arithmetic. Borders are thin lines, not rows, and text is proportional (about 1.2 characters per cell). One Client per clickable item makes hit-testing unnecessary.
  • No paste into a 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).
  • A "Nothing to show yet" pane means the drawing threw; a transcript line <mod>: <event> hook skipped: threw … names the handler that did.

Developing

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>
Source 4 files
hooks/register.tsx 193 lines
1import { 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}
193
hooks/walk.ts 114 lines
1/** 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(' ')
114
hooks/pill.tsx 56 lines
1import 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
56
types/index.d.ts 32 lines
1/** 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