SLOPSHOPPER

pill-steps

next-steps (hamzafer) restyled: next prompts as Claude-colored pills above the prompt. Press 1-3 in an empty prompt, 0 to dismiss.

newbandpromptmodel
v0.2.1no licenseupdated 2026-10-10youngOman/pill-mods/mods/pill-steps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pill-steps
› fix the failing auth test and add an audit log call ⏺ 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 ✻ 下一步 1 0 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ 下一步 1 0 ⟨Claude Code's own drawing⟩
README

pill-mods

三個 Claude Code mod(需要 Claude Code 2.1.291 以上;終端字型用 Nerd Font,膠囊圓角和複製圖示會用到):

mod效果
pill-steps每輪結束後,輸入框上方一列一個繁中「下一步」膠囊。空白輸入框按 1~3 帶入、0 略過,也可以用滑鼠點;hover 會變亮。每輪多一次 Haiku 呼叫(上限 200 token)
pill-copy回答裡每個區塊(code、表格、清單、引用)右上角一個複製圖示,點了複製並顯示 ✓ Copied!
image-peek貼圖後在輸入框上方顯示縮圖(修正原版 image-view 在新版 Claude Code 找不到圖的問題)

安裝

claude plugin marketplace add youngOman/pill-mods
claude plugin install pill-steps@pill-mods --scope user
claude plugin install pill-copy@pill-mods --scope user
claude plugin install image-peek@pill-mods --scope user

裝完重開 Claude Code session(/exit,再照畫面印出的 claude --resume … 接回)。

有裝原版 next-steps、copy-band、image-view 的話先停用,免得重複顯示:

claude plugin disable next-steps@claude-code-mods
claude plugin disable copy-band@claude-mods
claude plugin disable image-view@claude-image-view

更新

claude plugin marketplace update pill-mods
claude plugin update pill-steps@pill-mods
claude plugin update pill-copy@pill-mods
claude plugin update image-peek@pill-mods

來源

改自 hamzafer/claude-code-mods 的 next-steps、yash-gadodia/claude-mods 的 copy-band、jarrodwatts/claude-image-view(MIT)。

Source 2 files
hooks/register.tsx 198 lines
1// Next Steps: after each turn, 2 or 3 likely next prompts above the input.
2// In an empty prompt, 1 to 3 drafts one (nothing sends on its own) and 0 dismisses.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { NextSteps } from '../types'
7
8const MODEL = 'haiku'
9const MAX_ITEMS = 3
10const MAX_CHARS = 60 // what the model is asked for
11const MAX_KEPT = 120 // a longer line is dropped, never cut: a cut draft would send half a prompt
12const MIN_ANSWER = 20 // a reply shorter than this is not worth a model call
13
14// Held by the host, so the list survives a hot reload of this file.
15const steps = atom({ plugin: 'pill-steps', key: 'steps' } as const, null as NextSteps | null)
16// True while the list is on screen. where-am-i reads it and leaves out its own "next" meanwhile.
17const active = atom({ plugin: 'pill-steps', key: 'active' } as const, false)
18
19export const register: Register = on => {
20  // The turn a suggestion may still be shown for. A submit or a new turn moves it on.
21  let latest = ''
22  let submits = 0
23  // A survey answers bare digits in the band: leave them to it.
24  let hasSurvey = false
25
26  on('prompt.submit', async ($, e, next) => {
27    latest = `submit-${++submits}`
28    await setSteps($, () => null)
29    return next(e)
30  })
31
32  on('turn.start', async ($, e, next) => {
33    latest = e.turnId
34    await setSteps($, () => null)
35    return next(e)
36  })
37
38  on('turn.complete', async ($, e, next) => {
39    const r = await next(e)
40    if (e.agentId) return r // a subagent's turn, not the person's
41    latest = e.turnId // also for a turn that raised no turn.start, and after a hot reload
42    if (e.reason !== 'answer' || e.answer.trim().length < MIN_ANSWER) return r
43    void suggest($, e.turnId, e.answer, () => latest).catch(() => {}) // in the background, so the turn ends at once
44    return r
45  })
46
47  // A digit typed into an empty prompt picks a suggestion instead of landing as text.
48  // A paste carries no key, so a pasted "1" lands as typed.
49  on('prompt.edit', async ($, e, next) => {
50    if (hasSurvey || !e.key || e.key.ctrl || e.key.meta || e.text !== '' || e.start !== 0 || e.end !== 0 || !/^[0-9]$/.test(e.inputText)) return next(e)
51    const s = await read($, steps)
52    if (!s) return next(e)
53    if (e.inputText === '0') {
54      await setSteps($, () => null)
55      return { text: '', cursor: 0 } // consumed: the box stays empty
56    }
57    const pick = s.items[Number(e.inputText) - 1]
58    if (!pick) return next(e)
59    return next({ ...e, inputText: pick }) // a draft: the person edits it or presses Enter
60  })
61
62  // 點膠囊:數字或文字都把那句放進輸入框當草稿(不會直接送出);點「略過」收起
63  on('ui.press', { plugin: 'pill-steps' }, async ($, e, next) => {
64    const m = /^step:(\d+)(?::text)?$/.exec(e.element)
65    if (e.element === 'step:dismiss' || e.element === 'step:dismiss:text') {
66      await setSteps($, () => null)
67      return { element: e.element }
68    }
69    if (!m) return next(e)
70    const s = await read($, steps)
71    const pick = s?.items[Number(m[1])]
72    if (!pick) return next(e)
73    await $.prompt.fill({ text: pick })
74    return { element: e.element }
75  })
76
77  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78    const rest = await next(e) // what other mods and Claude Code draw here stays
79    hasSurvey = e.props.hasSurvey
80    const s = await read($, steps)
81    if (e.props.hasSurvey || e.props.isWorking || !s || s.items.length === 0) return rest
82
83    const { Box, Text, Button } = $.ui.resolve(e)
84    // 圓角膠囊(Nerd Font 的  ):Claude 橘的數字+深墨底米白字;滑鼠移上去整顆變亮
85    const ORANGE = '#D97757'
86    const ORANGE_HI = '#EE9677'
87    const INK_HI = '#454860'
88    const GREY = '#5B5F6E'
89    const GREY_HI = '#7A7F90'
90    // 一列一個:數字是小圓角膠囊,文字不上底色(疊起來才不會黏成一塊);滑鼠移上去整列亮起
91    const row = (id: string, key: string, text: string, dim = false) => {
92      const head = dim ? GREY : ORANGE
93      const headHi = dim ? GREY_HI : ORANGE_HI
94      return (
95        <Box key={`row:${id}`} flexDirection="row">
96          <Text color={head} hover={{ color: headHi }}>{'\uE0B6'}</Text>
97          <Box backgroundColor={head} hover={{ backgroundColor: headHi }}>
98            <Button key={`step:${id}`} plain label={key} hover={{ bold: true }} onPress={() => undefined} />
99          </Box>
100          <Text color={head} hover={{ color: headHi }}>{'\uE0B4'}</Text>
101          <Box paddingX={1} hover={{ backgroundColor: INK_HI }}>
102            <Button key={`step:${id}:text`} plain dimColor={dim} label={text} hover={{ bold: true }} onPress={() => undefined} />
103          </Box>
104        </Box>
105      )
106    }
107
108    return (
109      <Box flexDirection="column">
110        <Box flexDirection="row" paddingX={1} columnGap={2}>
111          <Text color={ORANGE} bold>{'✻ 下一步'}</Text>
112          <Box flexDirection="column">
113            {s.items.map((t, i) => row(String(i), String(i + 1), t))}
114            {row('dismiss', '0', '略過', true)}
115          </Box>
116        </Box>
117        {rest}
118      </Box>
119    )
120  })
121}
122
123// Writes the list, then whether it shows, for where-am-i.
124async function setSteps($: EngineInterface, fn: (before: NextSteps | null) => NextSteps | null) {
125  const after = await update($, steps, fn)
126  const showing = after !== null && after.items.length > 0
127  if ((await read($, active)) !== showing) await update($, active, () => showing)
128}
129
130// The whole list on one row, or one suggestion per row when it would not fit.
131// 中文字在終端占 2 格;每顆膠囊另有兩端圓角、數字段與內距約 8 格,標籤與「略過」約 20 格
132const cells = (s: string) => [...s].reduce((n, ch) => n + (/[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1), 0)
133export function fitsOnOneLine(items: string[], columns: number) {
134  return items.reduce((n, s) => n + cells(s) + 11, 20) <= columns
135}
136
137async function suggest($: EngineInterface, turnId: string, answer: string, current: () => string) {
138  // Wait for background agents: the main agent picks up again once they finish.
139  const agents = await $.agent.list().catch(() => [])
140  if (agents.some(a => a.status === 'running')) return
141
142  const all = (await $.session.messages().catch(() => [])).filter(m => m.text.trim() !== '')
143  if (all.at(-1)?.role === 'assistant') all.pop() // the latest reply goes in on its own below
144  const messages = all.slice(-6)
145  const r = await $.model.complete({
146    model: MODEL,
147    maxTokens: 200,
148    timeoutMs: 20_000,
149    system:
150      'You predict what a person will type next to their coding assistant, after reading a transcript of their session. ' +
151      'Give 2 or 3 short, concrete follow-up prompts, each an instruction in the imperative, written in Traditional Chinese (Taiwan usage, 繁體中文), ' +
152      'at most 24 Chinese characters each; keep file names, commands and code identifiers as they are. ' +
153      'Specific to what just happened (name the file, test or feature). No em dashes, no numbering, no quotes. ' +
154      'One per line and nothing else. If nothing sensible follows, reply with the single word NONE.\n' +
155      'Example reply:\n再跑一次登入測試\n把同樣的檢查加到 signup.ts\n開一個 draft PR',
156    prompt: [
157      `<transcript>\n${messages.map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
158      `<latest_reply>\n${answer.slice(0, 2000)}\n</latest_reply>`,
159      'Write the follow-up prompts the person is likely to send next: the lines only, not a reply to the transcript.',
160    ].join('\n\n'),
161  })
162  if (!r.isAnswered) return
163  const items = parseSteps(r.text)
164  // Checked inside the write, so a submit or a new turn that lands meanwhile always wins.
165  await setSteps($, before => (current() !== turnId ? before : items.length > 0 ? { turnId, items } : null))
166}
167
168// The model's lines, cleaned: no fences, bullets, numbers or em dashes; at most three.
169// A preamble, a "nothing to add" or a line too long to draft whole is dropped.
170export function parseSteps(text: string): string[] {
171  const body = text.replace(/```[a-z]*\n?/gi, '').trim()
172  let lines = body.split('\n')
173  if (body.startsWith('[') || body.startsWith('{')) {
174    try {
175      const o = JSON.parse(body) as unknown
176      const arr = Array.isArray(o) ? o : Object.values(o as Record<string, unknown>).find(Array.isArray)
177      lines = Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : []
178    } catch {
179      // not JSON after all: read it as lines
180    }
181  }
182  const out: string[] = []
183  for (const line of lines) {
184    const clean = line
185      .replace(/\*\*/g, '')
186      .replace(/^\s*(?:[-*•]|\d+[.):])\s*/, '')
187      .replace(/^["'`]+|["'`,]+$/g, '')
188      .replace(/\s*\u2014\s*/g, ', ')
189      .replace(/\.$/, '')
190      .trim()
191    if (clean === '' || clean.endsWith(':') || clean.length > MAX_KEPT) continue
192    if (/^(none|no |nothing|here are|here's|sure)/i.test(clean) || /^[[\]{}]$/.test(clean)) continue
193    if (!out.some(o => o.toLowerCase() === clean.toLowerCase())) out.push(clean)
194    if (out.length === MAX_ITEMS) break
195  }
196  return out
197}
198
types/index.d.ts 8 lines
1export type NextSteps = { turnId: string; items: string[] }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'pill-steps': { steps: NextSteps | null; active: boolean }
6  }
7}
8