SLOPSHOPPER

pinned-steps

Pins the step-by-step procedures Claude gives you above the prompt, so you can follow them while the conversation moves on, and stacks the ones that interrupt…

newpanebandcommandtoastprompt
★ 1v0.1.0MITupdated 2026-10-08visiblesoft-es/claude-mods/pinned-steps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pinned-steps
│ ┃ Pinned steps ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No steps pinned. When Claude gives you a │ pinned-steps │ │ ┃ procedure to follow, it is pinned here; or ⏺ Read(src/auth.ts) │ The steps pane is open as a tab behind │ │ ┃ run /steps pin to pin the last numbered list ⎿ Read 6 lines │ another pane: press its tab or ctrl+x tab. │ │ ┃ it wrote. ⏺ 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 │ │ › /steps │ ⎿ pinned-steps: Pinned steps pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pinned steps
No steps pinned. When Claude gives you a procedure to follow, it is pinned here; or run /steps pin to pin the last numbered list it wrote.
README

pinned-steps

A Claude Code mod that pins the step-by-step procedures Claude gives you above the prompt. Ask a question or fix a problem halfway through and the steps stay where you can see them, with the one you are on highlighted. A procedure that interrupts another goes on top of it; when you finish it, the first one comes back.

────────────────────────────────────────────────────────────────────────────────
▶ Deploy to staging ▓▓▓░░░░░ 3/7
 ☐ Run `dotnet ef database update` on staging  [ ✓ Done ] [ Continue ] [ Copy ] [ Steps › ]
  ↳ paused: Configure the server · step 2/5

Installation

Requires Claude Code with mod support (tested on the latest version).

From a Claude Code session in the terminal:

/plugin install pinned-steps --marketplace visiblesoft-es/claude-mods

Answer y to add the marketplace and pick a scope (the user scope is the first one). The mod is active as soon as it is installed, with no restart.

Or from your shell:

claude plugin marketplace add visiblesoft-es/claude-mods
claude plugin install pinned-steps@claude-mods

To update or uninstall, see the repository README.

How it works

  1. Claude writes the procedure as a steps block. The mod adds a short instruction to Claude's system prompt: when it gives you a procedure to carry out yourself, it writes the steps in a fenced block like this one, not as an ordinary list:

# Deploy to staging

  1. Pull main and build
  2. Run dotnet ef database update on staging It takes about a minute.
  3. Restart the service `` ```

An optional # Title comes first, then one numbered line per step. Lines that are not numbered are details of the step above them.

  1. The mod pins it as soon as the answer arrives, and shows the current step in the band above the prompt.
  2. Claude knows where you are. Every prompt you send carries a short note Claude reads and you do not see: which procedure you are following, which step you are on and which ones are paused. So "let's continue" is all you need to say after a detour.

Claude only uses the block for procedures you perform yourself, not for its own plan or for ordinary lists. If it wrote a numbered list without the block, /steps pin pins it anyway.

Using it

  • ✓ Done marks the current step done (and any before it) and moves on.
  • Continue marks it done and sends Claude "I've done step 3 of …, let's continue with step 4", so it walks you through the next one.
  • Copy copies the step's command (its first code span) to the clipboard.
  • Steps › or /steps opens the pane with every procedure and its steps. On the current step there are also:
  • ✗ Failed: marks the step and starts a prompt, Step 3 of "Deploy to staging" (…) failed: , for you to finish with the error.
  • Skip and ↶ Back (undo the last step marked).
  • Resume brings a paused procedure back to the top, Show lists its steps, and Drop removes a procedure. Clear all empties the stack.

The stack

When Claude gives you a new procedure while another is half done, the new one goes on top and the other is paused under it (↳ paused: …). When you finish the new one, it leaves the stack and the paused one comes back, at the step where you left it. If Claude writes a block with the same title as a pinned procedure, it revises that procedure in place and keeps the steps already done.

The stack is kept per project directory, so it survives /compact, restarts and new sessions in the same directory.

Commands

CommandWhat it does
/stepsOpen the pane
/steps pinPin the steps of Claude's last answer: its steps blocks, or else its last numbered list
/steps doneMark the current step done
/steps nextMark it done and ask Claude to continue
/steps clearRemove every pinned procedure

Configuration

In /config:

  • autoPin (on by default): turn it off and Claude is no longer asked to write steps blocks or told where you are; only /steps pin pins.
  • showSeparator: the dim rule between the conversation and the band.

With claudebar-mod

Both mods draw in the band above the prompt and are shown together, each under its own separator. claudebar-mod 0.2.2 or later is needed for that; earlier versions draw over the steps.

Limitations

  • Pinning depends on Claude writing the steps block; when it does not, use /steps pin.
  • Only the main conversation's answers are pinned, not the subagents'.
  • The stack holds at most six procedures; the oldest is dropped past that.
  • Claude Code shows one mod pane at a time; the others become tabs.

Development

hooks/register.tsx     everything that talks to the engine: hooks, band, pane and command
hooks/lib/steps.ts     pure functions: parsing steps and the stack
types/index.d.ts       the mod's state contract
tests/                 steps.test.ts (parsing and the stack), pinned-steps.test.tsx (band, pane and flow)
claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 373 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Procedure, Step, StepStatus } from '../types'
5import {
6  INSTRUCTIONS, copyTarget, currentIndex, doneCount, drop, isFinished, mark, markUpTo, parseBlocks, parseLastList,
7  pin, progressNote, resume, settle,
8} from './lib/steps'
9import type { Parsed } from './lib/steps'
10
11// $ may only be passed to functions declared in this file, so everything that
12// talks to the engine lives here; lib/steps.ts holds the parsing and the stack.
13
14// ── state ──
15
16const PANE = 'pinned-steps'
17
18const stack = atom({ plugin: 'pinned-steps', key: 'stack' } as const, [])
19const expanded = atom({ plugin: 'pinned-steps', key: 'expanded' } as const, null)
20
21let options: PluginOptions = {}
22// The main agent's last answer, for `/steps pin`.
23let lastAnswer = ''
24let ids = 0
25
26const clip = (text: string, max: number) => {
27  const flat = text.replace(/\s+/g, ' ').trim()
28  if (max <= 1) return ''
29  return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
30}
31
32// The stack is kept per project, so it survives restarts and new sessions in
33// the same directory.
34async function storeKey($: EngineInterface) {
35  return `stack:${await $.session.cwd()}`
36}
37
38async function load($: EngineInterface) {
39  const saved = ((await $.store.get(await storeKey($))) as Procedure[] | undefined) ?? []
40  await update($, stack, () => saved)
41}
42
43// Every change goes through here: finished procedures leave the stack, and
44// the person is told which one is back on top.
45async function change($: EngineInterface, fn: (s: Procedure[]) => Procedure[]) {
46  const before = await read($, stack)
47  const { stack: next, finished } = settle(fn(before))
48  await update($, stack, () => next)
49  await $.store.set(await storeKey($), next)
50  for (const p of finished) {
51    const top = next[0]
52    const back = top && before[0]?.id !== top.id ? ` Back to "${top.title}", step ${currentIndex(top) + 1}/${top.steps.length}.` : ''
53    $.ui.toast(`✓ Finished: ${p.title}.${back}`, { timeoutMs: 8000 })
54  }
55}
56
57async function pinAll($: EngineInterface, found: Parsed[]) {
58  const now = await $.clock.now()
59  for (const parsed of found) {
60    let isRevision = false
61    await change($, s => {
62      const r = pin(s, parsed, `${now}-${(ids += 1)}`, now)
63      isRevision = r.isRevision
64      return r.stack
65    })
66    const below = (await read($, stack))[1]
67    const paused = !isRevision && below ? ` "${below.title}" is paused under it.` : ''
68    $.ui.toast(`${isRevision ? 'Updated' : 'Pinned'}: ${parsed.title} (${parsed.steps.length} steps).${paused}`, { timeoutMs: 6000 })
69  }
70}
71
72async function top($: EngineInterface): Promise<{ p: Procedure; i: number } | null> {
73  const p = (await read($, stack))[0]
74  if (!p) return null
75  const i = currentIndex(p)
76  return i < 0 ? null : { p, i }
77}
78
79async function setStatus($: EngineInterface, procId: string, index: number, status: StepStatus) {
80  await change($, s => (status === 'done' ? markUpTo(s, procId, index) : mark(s, procId, index, status)))
81}
82
83// Marks the current step done and tells Claude, so it picks up from there.
84async function continueNext($: EngineInterface) {
85  const t = await top($)
86  if (!t) return
87  const { p, i } = t
88  await setStatus($, p.id, i, 'done')
89  const after = (await read($, stack))[0]
90  const step = `step ${i + 1} of "${p.title}" (${p.steps[i]!.text})`
91  let text: string
92  if (i + 1 >= p.steps.length) {
93    text = after
94      ? `I've done ${step}, so that procedure is finished. Let's go back to "${after.title}", step ${currentIndex(after) + 1}: ${after.steps[currentIndex(after)]!.text}.`
95      : `I've done ${step}, the last one.`
96  } else {
97    text = `I've done ${step}. Let's continue with step ${i + 2}: ${p.steps[i + 1]!.text}.`
98  }
99  await $.prompt.submit({ text, asUser: true })
100}
101
102// Marks the current step failed and starts a prompt for the person to finish.
103async function failCurrent($: EngineInterface) {
104  const t = await top($)
105  if (!t) return
106  await setStatus($, t.p.id, t.i, 'failed')
107  await $.prompt.fill({ text: `Step ${t.i + 1} of "${t.p.title}" (${t.p.steps[t.i]!.text}) failed: `, mode: 'replace' })
108}
109
110// Undoes the last step marked done or skipped.
111async function back($: EngineInterface, p: Procedure) {
112  const i = currentIndex(p)
113  const prev = (i < 0 ? p.steps.length : i) - 1
114  if (prev >= 0) await setStatus($, p.id, prev, 'todo')
115}
116
117async function copy($: EngineInterface, step: Step, surface: string) {
118  const text = copyTarget(step)
119  if (!text) return
120  const r = await $.ui.copy({ text, surface: surface as never })
121  $.ui.toast(r.isCopied ? `Copied: ${clip(text, 60)}` : `Not copied: ${r.reason}`)
122}
123
124// The press must await the open: a pane opened after the press has settled
125// counts as opened unasked, and on a terminal under 144 columns it waits undrawn.
126async function openPane($: EngineInterface) {
127  const opened = await $.ui.open({ id: PANE, title: 'Pinned steps', focus: true })
128  if (!opened.isPlaced) {
129    $.ui.toast(`The steps pane is open but cannot be shown: ${opened.reason}`, { timeoutMs: 8000 })
130    return
131  }
132  const pane = (await $.ui.panes()).find(p => p.id === PANE)
133  if (pane && !pane.isShown) {
134    $.ui.toast('The steps pane is open as a tab behind another pane: press its tab or ctrl+x tab.', { timeoutMs: 8000 })
135  }
136}
137
138// ── band ──
139
140const MARK: Record<StepStatus, string> = { todo: '☐', done: '✓', failed: '✗', skipped: '↷' }
141const MARK_COLOR: Record<StepStatus, string | undefined> = { todo: undefined, done: 'green', failed: 'red', skipped: undefined }
142
143function progressBar(p: Procedure, width = 8) {
144  const filled = Math.round((doneCount(p) / p.steps.length) * width)
145  return '▓'.repeat(filled) + '░'.repeat(width - filled)
146}
147
148async function renderBand($: EngineInterface, e: RenderInput<'AbovePrompt'>, below: RenderElement) {
149  const s = await read($, stack)
150  const p = s[0]
151  if (!p) return below
152  const i = currentIndex(p)
153  if (i < 0) return below
154  const step = p.steps[i]!
155  const { Box, Text, Button } = $.ui.resolve(e)
156  const columns = e.props.bodyColumns
157  const hasCopy = copyTarget(step) !== null
158  // The buttons take about 40 columns; the step's text gets the rest.
159  const room = columns - 4 - (hasCopy ? 48 : 39)
160
161  const rows: RenderElement[] = []
162  if (options.showSeparator !== false) {
163    rows.push(<Text key="separator" dimColor>{'─'.repeat(Math.max(1, columns))}</Text>)
164  }
165  rows.push(
166    <Box key="title" flexDirection="row" gap={1}>
167      <Text color="claude">▶</Text>
168      <Text bold>{clip(p.title, Math.max(10, columns - 24))}</Text>
169      <Text dimColor>
170        {progressBar(p)} {i + 1}/{p.steps.length}
171      </Text>
172    </Box>,
173  )
174  rows.push(
175    <Box key="step" flexDirection="row" gap={1}>
176      <Text color={MARK_COLOR[step.status]}> {step.status === 'failed' ? MARK.failed : MARK.todo}</Text>
177      <Text wrap="truncate-end">{clip(step.text, Math.max(12, room))}</Text>
178      <Button key="done" label="✓ Done" onPress={() => setStatus($, p.id, i, 'done')} />
179      <Button key="continue" label="Continue" variant="primary" onPress={() => continueNext($)} />
180      {hasCopy && <Button key="copy" label="Copy" onPress={press => copy($, step, press.surface)} />}
181      <Button key="open" label="Steps ›" onPress={() => openPane($)} />
182    </Box>,
183  )
184  const paused = s.slice(1)
185  if (paused.length > 0) {
186    const first = paused[0]!
187    const more = paused.length > 1 ? ` (+${paused.length - 1} more)` : ''
188    rows.push(
189      <Text key="paused" dimColor wrap="truncate-end">
190        {clip(`  ↳ paused: ${first.title} · step ${currentIndex(first) + 1}/${first.steps.length}${more}`, columns)}
191      </Text>,
192    )
193  }
194  rows.push(below)
195  return <Box flexDirection="column">{rows}</Box>
196}
197
198// ── pane ──
199
200type Els = ReturnType<EngineInterface['ui']['resolve']>
201
202function stepRows($: EngineInterface, { Box, Text, Button }: Els, p: Procedure, isTop: boolean, columns: number) {
203  const current = currentIndex(p)
204  return p.steps.map((step, i) => {
205    const isCurrent = i === current
206    const marker = isCurrent && step.status !== 'failed' ? '▶' : MARK[step.status]
207    const color = isCurrent && step.status !== 'failed' ? 'claude' : MARK_COLOR[step.status]
208    const isDim = step.status === 'done' || step.status === 'skipped'
209    const target = copyTarget(step)
210    return (
211      <Box key={`${p.id}-s${i}`} flexDirection="column">
212        <Box flexDirection="row" gap={1}>
213          <Text color={color}>{marker}</Text>
214          <Text dimColor={isDim} bold={isCurrent} wrap="wrap">
215            {i + 1}. {step.text}
216          </Text>
217        </Box>
218        {step.detail && (
219          <Box paddingLeft={5}>
220            <Text dimColor wrap="wrap">{step.detail}</Text>
221          </Box>
222        )}
223        {isCurrent && isTop && (
224          <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
225            <Button key={`${p.id}-done`} label="✓ Done" onPress={() => setStatus($, p.id, i, 'done')} />
226            <Button key={`${p.id}-continue`} label="Continue" variant="primary" onPress={() => continueNext($)} />
227            <Button key={`${p.id}-fail`} label="✗ Failed" onPress={() => failCurrent($)} />
228            <Button key={`${p.id}-skip`} label="Skip" onPress={() => setStatus($, p.id, i, 'skipped')} />
229            {target && <Button key={`${p.id}-copy`} label="Copy" onPress={press => copy($, step, press.surface)} />}
230            {i > 0 && <Button key={`${p.id}-back`} label="↶ Back" onPress={() => back($, p)} />}
231          </Box>
232        )}
233      </Box>
234    )
235  })
236}
237
238async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
239  const els = $.ui.resolve(e)
240  const { Box, Text, Button } = els
241  const s = await read($, stack)
242  const open = await read($, expanded)
243  const columns = e.props.bodyColumns
244
245  if (s.length === 0) {
246    return (
247      <Box flexDirection="column" gap={1}>
248        <Text dimColor wrap="wrap">
249          No steps pinned. When Claude gives you a procedure to follow, it is pinned here; or run /steps pin to pin the
250          last numbered list it wrote.
251        </Text>
252      </Box>
253    )
254  }
255
256  return (
257    <Box flexDirection="column" gap={1}>
258      {s.map((p, n) => {
259        const isTop = n === 0
260        const isOpen = isTop || open === p.id
261        const i = currentIndex(p)
262        return (
263          <Box key={p.id} flexDirection="column">
264            <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
265              <Text bold color={isTop ? 'claude' : undefined} dimColor={!isTop}>
266                {isTop ? '▶' : '⏸'} {clip(p.title, Math.max(10, columns - 30))}
267              </Text>
268              <Text dimColor>
269                {progressBar(p)} {isFinished(p) ? 'done' : `${i + 1}/${p.steps.length}`}
270              </Text>
271              {!isTop && <Button key={`${p.id}-resume`} label="Resume" onPress={() => change($, x => resume(x, p.id))} />}
272              {!isTop && (
273                <Button
274                  key={`${p.id}-toggle`}
275                  label={isOpen ? 'Hide' : 'Show'}
276                  onPress={() => update($, expanded, v => (v === p.id ? null : p.id))}
277                />
278              )}
279              <Button key={`${p.id}-drop`} label="Drop" onPress={() => change($, x => drop(x, p.id))} />
280            </Box>
281            {isOpen && <Box flexDirection="column" paddingLeft={1}>{stepRows($, els, p, isTop, columns)}</Box>}
282          </Box>
283        )
284      })}
285      <Box flexDirection="row" columnGap={1}>
286        <Button key="clear" label="Clear all" onPress={() => change($, () => [])} />
287      </Box>
288    </Box>
289  )
290}
291
292// ── commands ──
293
294async function runCommand($: EngineInterface, arg: string): Promise<string> {
295  if (arg === 'pin') {
296    const found = parseBlocks(lastAnswer)
297    const list = found.length > 0 ? found : [parseLastList(lastAnswer)].filter((x): x is Parsed => x !== null)
298    if (list.length === 0) return 'No steps found in the last answer.'
299    await pinAll($, list)
300    return `Pinned: ${list.map(l => l.title).join(', ')}.`
301  }
302  if (arg === 'clear') {
303    await change($, () => [])
304    return 'All pinned steps cleared.'
305  }
306  if (arg === 'done' || arg === 'next') {
307    const t = await top($)
308    if (!t) return 'No steps pinned.'
309    if (arg === 'next') {
310      await continueNext($)
311      return `Step ${t.i + 1} done.`
312    }
313    await setStatus($, t.p.id, t.i, 'done')
314    return `Step ${t.i + 1} of "${t.p.title}" done.`
315  }
316  await openPane($)
317  return 'Pinned steps pane opened.'
318}
319
320export const register: Register = (on, opts) => {
321  options = opts
322
323  on('session.start', async ($, e, next) => {
324    await $.command.register({
325      name: 'steps',
326      description: 'Pinned steps: open the pane, pin the last list, or mark the current step done',
327      argumentHint: '[pin|done|next|clear]',
328    })
329    await load($)
330    return next(e)
331  })
332
333  on('command.run', { command: 'steps' }, async ($, e) => ({ text: await runCommand($, e.args.trim().toLowerCase()) }))
334
335  // Tells the model how to write a procedure so it gets pinned.
336  on('prompt.compose', async ($, e, next) => {
337    const result = await next(e)
338    if (options.autoPin === false) return result
339    return { sections: [...result.sections, { id: 'pinned-steps:instructions', text: INSTRUCTIONS, scope: 'session' as const }] }
340  })
341
342  // Tells the model where the person is, so "let's continue" needs no explaining.
343  on('prompt.submit', async ($, e, next) => {
344    const note = progressNote(await read($, stack))
345    return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
346  }).catch(($, e, next) => next(e))
347
348  // Pins the ```steps blocks of the main agent's answers as they arrive.
349  on('session.append', async ($, e, next) => {
350    const result = await next(e)
351    if (e.door !== 'response' || e.agentId !== undefined || e.message.type !== 'assistant') return result
352    const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
353    if (text.trim() === '') return result
354    lastAnswer = text
355    try {
356      const found = options.autoPin === false ? [] : parseBlocks(text)
357      if (found.length > 0) await pinAll($, found)
358    } catch {
359      // A procedure that failed to pin must not cost the conversation its row.
360    }
361    return result
362  }).catch(($, e, next) => next(e))
363
364  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
365    // Draw above whatever the plugins below draw, rather than instead of it.
366    const below = await next(e)
367    if (e.props.hasSurvey) return below
368    return renderBand($, e, below)
369  })
370
371  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
372}
373
hooks/lib/steps.ts 190 lines
1import type { Procedure, Step, StepStatus } from '../../types'
2
3// Pure functions: parsing procedures out of the model's text, and the stack of
4// procedures (index 0 is the one being followed, the rest are paused under it).
5
6export const MAX_STACK = 6
7
8const FENCE = /^ {0,3}(`{3,}|~{3,})\s*steps\b[^\n]*\n([\s\S]*?)\n {0,3}\1[ \t]*$/gim
9const NUMBERED = /^\s*(\d+)[.)]\s+(.+)$/
10
11export type Parsed = { title: string; steps: { text: string; detail?: string }[] }
12
13/** The ```steps blocks of a text, in order. */
14export function parseBlocks(text: string): Parsed[] {
15  const found: Parsed[] = []
16  for (const m of text.matchAll(FENCE)) {
17    const parsed = parseBody(m[2] ?? '')
18    if (parsed.steps.length > 0) found.push(parsed)
19  }
20  return found
21}
22
23function parseBody(body: string): Parsed {
24  let title = ''
25  const steps: Parsed['steps'] = []
26  for (const raw of body.split('\n')) {
27    const line = raw.replace(/\s+$/, '')
28    if (line.trim() === '') continue
29    const heading = /^\s*#+\s+(.+)$/.exec(line)
30    if (heading && steps.length === 0 && title === '') {
31      title = heading[1]!.trim()
32      continue
33    }
34    const item = NUMBERED.exec(line)
35    if (item) {
36      steps.push({ text: item[2]!.trim() })
37      continue
38    }
39    // Any other line belongs to the step above it.
40    const last = steps[steps.length - 1]
41    if (last) last.detail = last.detail ? `${last.detail}\n${line.trim()}` : line.trim()
42  }
43  return { title: title || 'Steps', steps }
44}
45
46/**
47 * The last numbered list of a plain text (for `/steps pin` on an answer that
48 * had no ```steps block), titled by the line just before it when there is one.
49 */
50export function parseLastList(text: string): Parsed | null {
51  const lines = text.split('\n')
52  let end = -1
53  for (let i = lines.length - 1; i >= 0; i--) {
54    if (NUMBERED.test(lines[i]!)) {
55      end = i
56      break
57    }
58  }
59  if (end < 0) return null
60  let start = end
61  // Walk up over numbered lines, their indented continuations and blank lines between them.
62  for (let i = end - 1; i >= 0; i--) {
63    const line = lines[i]!
64    if (NUMBERED.test(line)) start = i
65    else if (line.trim() === '' || /^\s{2,}\S/.test(line)) continue
66    else break
67  }
68  const body = lines.slice(start, end + 1).join('\n')
69  const parsed = parseBody(body)
70  if (parsed.steps.length < 2) return null
71  const before = lines.slice(0, start).reverse().find(l => l.trim() !== '')
72  const title = before ? cleanTitle(before) : ''
73  return { ...parsed, title: title || 'Steps' }
74}
75
76function cleanTitle(line: string): string {
77  return line
78    .replace(/^\s*#+\s*/, '')
79    .replace(/\*\*|__/g, '')
80    .replace(/[::]\s*$/, '')
81    .trim()
82    .slice(0, 80)
83}
84
85/** What a step's Copy button copies: its first `code` span, else nothing. */
86export function copyTarget(step: Step): string | null {
87  for (const source of [step.text, step.detail ?? '']) {
88    const m = /`([^`\n]+)`/.exec(source)
89    if (m) return m[1]!
90  }
91  return null
92}
93
94/** The step being followed: the first one neither done nor skipped. */
95export function currentIndex(p: Procedure): number {
96  return p.steps.findIndex(s => s.status !== 'done' && s.status !== 'skipped')
97}
98
99export function isFinished(p: Procedure): boolean {
100  return currentIndex(p) < 0
101}
102
103export function doneCount(p: Procedure): number {
104  return p.steps.filter(s => s.status === 'done' || s.status === 'skipped').length
105}
106
107const sameTitle = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
108
109/**
110 * Adds a parsed procedure to the stack. One with the title of a procedure
111 * already there revises it in place, keeping the status of every step whose
112 * text did not change; any other goes on top, pausing the one under it.
113 */
114export function pin(stack: Procedure[], parsed: Parsed, id: string, now: number): { stack: Procedure[]; isRevision: boolean } {
115  const at = stack.findIndex(p => sameTitle(p.title, parsed.title))
116  if (at >= 0) {
117    const old = stack[at]!
118    const steps = parsed.steps.map((s, i): Step => {
119      const prev = old.steps.find(o => o.text === s.text) ?? (old.steps[i]?.text === s.text ? old.steps[i] : undefined)
120      return { ...s, status: prev?.status ?? 'todo' }
121    })
122    const revised = { ...old, title: parsed.title, steps }
123    return { stack: stack.map((p, i) => (i === at ? revised : p)), isRevision: true }
124  }
125  const fresh: Procedure = {
126    id,
127    title: parsed.title,
128    createdAt: now,
129    steps: parsed.steps.map(s => ({ ...s, status: 'todo' as StepStatus })),
130  }
131  return { stack: [fresh, ...stack].slice(0, MAX_STACK), isRevision: false }
132}
133
134/** Sets one step's status; a finished procedure leaves the stack. */
135export function mark(stack: Procedure[], procId: string, index: number, status: StepStatus): Procedure[] {
136  return stack.map(p =>
137    p.id !== procId ? p : { ...p, steps: p.steps.map((s, i) => (i === index ? { ...s, status } : s)) },
138  )
139}
140
141/** Marks the current step done and the steps before it too. */
142export function markUpTo(stack: Procedure[], procId: string, index: number): Procedure[] {
143  return stack.map(p =>
144    p.id !== procId
145      ? p
146      : { ...p, steps: p.steps.map((s, i) => (i <= index && s.status !== 'skipped' ? { ...s, status: 'done' } : s)) },
147  )
148}
149
150/** Brings a paused procedure to the top. */
151export function resume(stack: Procedure[], procId: string): Procedure[] {
152  const p = stack.find(x => x.id === procId)
153  return p ? [p, ...stack.filter(x => x.id !== procId)] : stack
154}
155
156export function drop(stack: Procedure[], procId: string): Procedure[] {
157  return stack.filter(p => p.id !== procId)
158}
159
160/** Splits off the finished procedures, keeping the others in order. */
161export function settle(stack: Procedure[]): { stack: Procedure[]; finished: Procedure[] } {
162  return { stack: stack.filter(p => !isFinished(p)), finished: stack.filter(isFinished) }
163}
164
165const where = (p: Procedure) => {
166  const i = currentIndex(p)
167  return i < 0 ? 'finished' : `step ${i + 1}/${p.steps.length} ("${p.steps[i]!.text}")`
168}
169
170/** The note the model reads beside each prompt while procedures are pinned. */
171export function progressNote(stack: Procedure[]): string | null {
172  const top = stack[0]
173  if (!top) return null
174  const i = currentIndex(top)
175  const failed = i >= 0 && top.steps[i]!.status === 'failed' ? ' The user marked that step as failed.' : ''
176  const lines = [
177    `[pinned-steps] The user is following the pinned procedure "${top.title}", now on ${where(top)}; ${doneCount(top)} of ${top.steps.length} steps are done.${failed}`,
178  ]
179  for (const p of stack.slice(1)) lines.push(`Paused under it: "${p.title}", at ${where(p)}.`)
180  lines.push('If the user asks to continue, pick up from the current step. Do not mention this note.')
181  return lines.join('\n')
182}
183
184/** The instructions the model gets in its system prompt. */
185export const INSTRUCTIONS = [
186  '# Pinned steps',
187  'When you give the user a procedure they will carry out themselves, step by step (commands to run, settings to change, things to check), write its steps in a fenced code block whose info string is `steps`, instead of an ordinary numbered list: an optional first line `# Title`, then one line per step, `1. ...`, short and imperative, with any command in backticks. Lines under a step that are not numbered are its details. The pinned-steps plugin pins the block above the prompt, so the user can follow it while the conversation moves on to other things.',
188  'Use it only for procedures the user performs, not for your own plan of work or for ordinary lists. To revise a pinned procedure, write the block again with the same title; a new title pins a new procedure on top of the current one, and the current one resumes when the new one is finished.',
189].join('\n\n')
190
types/index.d.ts 15 lines
1export type StepStatus = 'todo' | 'done' | 'failed' | 'skipped'
2
3export type Step = { text: string; detail?: string; status: StepStatus }
4
5export type Procedure = { id: string; title: string; createdAt: number; steps: Step[] }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'pinned-steps': {
10      stack: Procedure[]
11      expanded: string | null
12    }
13  }
14}
15