SLOPSHOPPER

next-asks

After each turn, a band above the prompt suggests a few things worth asking Claude next that you might not think of.

newbandpromptmodeltimer
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code mods by @sreshtalluri.

session-receipt: a receipt for every Claude Code session

Install

/plugin marketplace add sreshtalluri/claude-mods
/plugin install session-receipt@claude-mods
/plugin install daily-grind@claude-mods
/plugin install next-asks@claude-mods

Mods use Claude Code's function-hooks plugin API, which is early access, so you need a current Claude Code.

Mods

ModWhat it does
session-receipt/receipt prints a shareable receipt of your session. /receipt week totals your week. Copy image gives you a square PNG to post.
daily-grind/grind opens a hub of daily puzzles to play while Claude works. Developer: Git Golf (reshape a commit graph under par), Heisenbug (find the buggy line), Install Order (dependency logic). Everyone: Ladder, Pixel Logic, Codebreaker. A Classics tab holds Lexer, Buckets and Daemons. Streaks, and /grind share for a shareable result.
next-asksAfter each turn, a band above the prompt suggests up to three things worth asking next that you might not think of: a missed edge case, an unrun check, a related file. Press its number to put it in the prompt. One small Haiku call per turn.

session-receipt

session-receipt demo

Command
/receiptThis session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right.
/receipt weekThe last seven days: sessions, busiest day, top project, usual model, total.
/receipt lastThe previous session's receipt.
  • Copy image puts a 1200×1200 PNG on your clipboard on macOS, Windows and Linux. If nothing can draw it, the image is saved and its path is shown.
  • Every session's receipt is saved to ~/.claude/receipts/ when it ends. Nothing leaves your machine.
  • In the terminal it's a text slip; in the desktop app it's drawn as paper.

Adding a mod

  1. Put it in plugins/<name>/ (with .claude-plugin/plugin.json).
  2. Add an entry to .claude-plugin/marketplace.json.
  3. claude plugin validate plugins/<name>, claude plugin test plugins/<name> and claude plugin validate .

The demo clip is a HyperFrames composition in media/receipt-demo: bun build.ts && npm run render.

License

MIT

Source 3 files
hooks/register.tsx 95 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { hangingWrap, HOTKEY_PREFIX, parseIdeas, SYSTEM, transcript } from './suggest'
5
6const ideas = atom({ plugin: 'next-asks', key: 'ideas' } as const, [] as string[])
7const isHidden = atom({ plugin: 'next-asks', key: 'isHidden' } as const, false)
8/** True while the draft opens a `/` command or `@` mention, whose picker draws above the band. */
9const isSearching = atom({ plugin: 'next-asks', key: 'isSearching' } as const, false)
10
11const setSearching = async ($: EngineInterface, value: boolean) => {
12  if ((await read($, isSearching)) !== value) await update($, isSearching, () => value)
13}
14
15/** Asks Haiku for follow-ups over the conversation's tail; a failed call just leaves the band empty. */
16const refresh = async ($: EngineInterface) => {
17  const tail = transcript(await $.session.messages())
18  if (tail === '') return
19  const r = await $.model.complete({
20    model: 'haiku',
21    system: SYSTEM,
22    prompt: tail,
23    maxTokens: 200,
24    effort: 'low',
25    timeoutMs: 20_000,
26  })
27  if (r.isAnswered) await update($, ideas, () => parseIdeas(r.text))
28}
29
30export const register: Register = on => {
31  // A new prompt makes the last turn's ideas stale.
32  on('prompt.submit', async ($, e, next) => {
33    await update($, ideas, () => [])
34    await setSearching($, false)
35    return next(e)
36  })
37
38  // Step aside while the person searches commands, skills or files, so the picker sits by the input.
39  on('prompt.edit', async ($, e, next) => {
40    const box = await next(e)
41    await setSearching($, /^\s*[/@]/.test(box.text))
42    return box
43  })
44
45  on('turn.complete', async ($, e, next) => {
46    const result = await next(e)
47    // Main loop only, and only turns that answered; a timer so the turn ends without waiting on Haiku.
48    if (e.agentId === undefined && e.reason === 'answer') {
49      $.clock.after(0, () => void refresh($).catch(() => {}))
50    }
51    return result
52  })
53
54  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
55    const list = await read($, ideas)
56    const isQuiet = e.props.hasSurvey || e.props.isWorking || list.length === 0
57    if (isQuiet || (await read($, isHidden)) || (await read($, isSearching))) {
58      return next(e)
59    }
60
61    const { Box, Button, Text } = $.ui.resolve(e)
62    // The terminal wraps a label under its `1:`; wrap it ourselves with a hanging indent.
63    // Desktop draws a proportional font, so it wraps on its own.
64    const textWidth = e.props.bodyColumns - 4 - HOTKEY_PREFIX // border 2 + padding 2
65    const label = (idea: string) => (e.surface === 'terminal' ? hangingWrap(idea, textWidth) : idea)
66
67    // Theme keys, not hex, so the box follows the person's light or dark theme on every surface.
68    return (
69      <Box
70        flexDirection="column"
71        width={e.props.bodyColumns}
72        borderStyle="round"
73        borderColor="suggestion"
74        paddingX={1}
75      >
76        <Box justifyContent="space-between">
77          <Text color="claude" bold>
78            ✦ Worth asking next
79          </Text>
80          <Button key="hide" label="hide" plain dimColor onPress={() => update($, isHidden, () => true)} />
81        </Box>
82        {list.map((idea, i) => (
83          <Button
84            key={`idea-${i}`}
85            label={label(idea)}
86            hotkey={String(i + 1)}
87            plain
88            onPress={() => $.prompt.fill({ text: idea })}
89          />
90        ))}
91      </Box>
92    )
93  })
94}
95
hooks/suggest.ts 56 lines
1import type { SessionMessage } from 'claude-code'
2
3export const MAX_IDEAS = 3
4/** How much of the conversation's tail Haiku reads: enough for the current task, cheap per turn. */
5const TAIL_CHARS = 12_000
6
7export const SYSTEM = `You watch a developer's session with a coding agent. After the agent's latest turn, suggest up to ${MAX_IDEAS} short follow-up requests the developer could send next.
8
9Favor what they might not think to ask: a missed edge case, a test or check nobody ran, a related file or config that likely needs the same change, a risk, a cleanup, docs or deploy steps. Skip the obvious next step they are already heading to and anything the agent already did.
10
11Write each as the developer would type it to the agent, imperative, under 90 characters, one per line, no numbering or bullets. If nothing is worth suggesting, reply NONE.`
12
13/** The conversation's tail as plain lines, newest last, cut to `TAIL_CHARS`. */
14export const transcript = (messages: readonly SessionMessage[]): string => {
15  const lines: string[] = []
16  for (const m of messages) {
17    const tools = m.toolUses.map(t => t.tool).join(', ')
18    const text = m.text.trim()
19    if (text === '' && tools === '') continue
20    lines.push(`${m.role === 'user' ? 'DEVELOPER' : 'AGENT'}: ${text}${tools ? ` [tools: ${tools}]` : ''}`)
21  }
22  return lines.join('\n\n').slice(-TAIL_CHARS)
23}
24
25/** Width of the `1: ` a hotkeyed Button draws before its label. */
26export const HOTKEY_PREFIX = 3
27
28/**
29 * Word-wraps `text` to `width` cells with continuation lines indented by
30 * `HOTKEY_PREFIX`, so a wrapped label hangs under its first word, not its `1:`.
31 */
32export const hangingWrap = (text: string, width: number): string => {
33  const lines: string[] = []
34  let line = ''
35  for (const word of text.split(/\s+/).filter(Boolean)) {
36    if (line !== '' && line.length + 1 + word.length > width) {
37      lines.push(line)
38      line = word
39    } else {
40      line = line === '' ? word : `${line} ${word}`
41    }
42  }
43  if (line !== '') lines.push(line)
44  return lines.join(`\n${' '.repeat(HOTKEY_PREFIX)}`)
45}
46
47/** Haiku's reply as at most `MAX_IDEAS` clean one-line asks. */
48export const parseIdeas = (reply: string): string[] => {
49  if (/^\s*NONE\s*$/i.test(reply)) return []
50  return reply
51    .split('\n')
52    .map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').replace(/^["“]|["”]$/g, '').trim())
53    .filter(l => l !== '' && !/^NONE$/i.test(l))
54    .slice(0, MAX_IDEAS)
55}
56
types/index.d.ts 8 lines
1export type NextAsksIdeas = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'next-asks': { ideas: NextAsksIdeas; isHidden: boolean; isSearching: boolean }
6  }
7}
8