SLOPSHOPPER

next-prompts

Suggests 3 next prompts after each turn; click one or type 1/2/3 + Enter to send it

newbandguardtoastprompttool
★ 1v0.1.0no licenseupdated 2026-10-05BEXAI/Mod-3-Next-Prompts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-prompts
› 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 ⏺ Edit(/work/app/src/auth.ts) ⎿ Denied by next-prompts: First call mcp__next-prompts__estimate with your best estimate (in seconds) of how ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Next prompts: thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Next prompts: thinking…
README

next-prompts

A Claude Code mod that keeps you moving: after every turn it suggests the 3 best prompts to send next, shows a live timer against Claude's own time estimate, and tracks your goal's task progress, all in a panel above the prompt.

Features

Next-prompt suggestions

  • After each turn, Haiku reads the last 3 exchanges plus the project state (goal, open tasks, git status) and proposes 3 concrete next prompts, ordered by impact.
  • If tasks are unfinished, suggestion #1 continues the next one.
  • Click a suggestion, or type 1, 2 or 3 and press Enter to send it.

Estimate-gated timer

  • Registers an estimate MCP tool (mcp__next-prompts__estimate) and tells Claude to call it with a time estimate and a short plan before making any change.
  • Edit, Write, NotebookEdit and Bash calls are refused until that estimate is in (subagents are exempt).
  • A full-width progress bar shows elapsed time against the estimate, the projected finish time and the plan, and turns yellow when the request runs over.

Goal and task panel

  • Picks up TodoWrite / TaskCreate / TaskUpdate calls and shows the goal, a progress bar, elapsed time, and each task with how long it took or how long it has been running.
  • Shows 5 tasks by default, with a button to expand the rest. The list resets after every task is done and you send a new prompt.

Done alert

  • Requests that run 2 minutes or longer end with a toast, a sound (Glass.aiff) and a macOS notification.

Install

Clone into your Claude Code mods directory:

git clone https://github.com/BEXAI/Mod-3-Next-Prompts.git ~/.claude/mods/next-prompts

Then enable it as a plugin in Claude Code and restart the session. Claude Code generates the type definitions under .claude-plugin/types/ itself.

Layout

.claude-plugin/plugin.json   plugin manifest
hooks/hooks.json             hook module list
hooks/register.tsx           all hooks: suggestions, estimate tool, timer, tasks, UI
hooks/register.test.ts       unit tests for the pure helpers
types/index.d.ts             plugin state types

Configuration

Settings are constants at the top of hooks/register.tsx:

ConstantDefaultMeaning
LOOKBACK3Number of past prompts sent to Haiku
MAX_CHARS1500Character cap per message in that context
SUGGEST_TIMEOUT_MS15000Time limit for generating suggestions
LONG_TURN_MS120000How long a request runs before the done alert fires
SHOWN_TASKS5Tasks shown before the expand button
DONE_SOUNDGlass.aiffSound for the done alert

Notes

  • The sound and notification use afplay and osascript, so they work only on macOS. Everything else works on any platform.
  • Suggestions run on Haiku with low effort and are cancelled once you send a new prompt.
Source 2 files
hooks/register.tsx 401 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Goal, Run, Task } from '../types'
4
5const suggestions = atom({ plugin: 'next-prompts', key: 'suggestions' } as const, [] as string[])
6const isThinking = atom({ plugin: 'next-prompts', key: 'isThinking' } as const, false)
7const tasks = atom({ plugin: 'next-prompts', key: 'tasks' } as const, [] as Task[])
8const goal = atom({ plugin: 'next-prompts', key: 'goal' } as const, null as Goal | null)
9const run = atom({ plugin: 'next-prompts', key: 'run' } as const, null as Run | null)
10const tick = atom({ plugin: 'next-prompts', key: 'tick' } as const, 0)
11const showAll = atom({ plugin: 'next-prompts', key: 'showAll' } as const, false)
12
13const CYAN = '#00B8D4'
14const NEON_CYAN = '#00FFFF'
15const SHOWN_TASKS = 5
16const IDLE_TICK_EVERY = 15 // seconds between redraws when Claude is idle
17
18// Suggestions: Haiku reads only the last few exchanges, trimmed, so it stays fast.
19const LOOKBACK = 3
20const MAX_CHARS = 1500
21const SUGGEST_TIMEOUT_MS = 15000
22const MAX_GIT_LINES = 30
23const ASK = `Suggest exactly 3 prompts the user should send next to push the project forward most effectively. Use the project state (goal, open tasks, uncommitted changes) as well as the conversation. If tasks are unfinished, make suggestion 1 continue the next unfinished task. Make each one concrete and self-contained (one sentence, under 200 characters), ordered by impact. Reply with exactly 3 lines formatted "1. ...", "2. ...", "3. ..." and nothing else.`
24
25// Alert when a request ran this long, so the user can step away.
26const LONG_TURN_MS = 2 * 60 * 1000
27const DONE_SOUND = '/System/Library/Sounds/Glass.aiff'
28
29// Estimate: Claude must report one before it changes anything.
30const ESTIMATE_TOOL = 'mcp__next-prompts__estimate'
31const CHANGE_TOOLS = /^(Edit|Write|NotebookEdit|Bash)$/
32const ESTIMATE_RULE = `Before making any changes for a request (before any Edit, Write, NotebookEdit or Bash call), call the ${ESTIMATE_TOOL} tool once with your best estimate of how many seconds the whole request will take you, and a few words on the plan. If the scope changes a lot mid-way, call it again with a revised estimate. Skip it for pure questions that need no changes.`
33
34type Msg = { role: 'user' | 'assistant'; text: string; toolUses: readonly { tool: string }[]; toolResults?: readonly unknown[] }
35type Status = Task['status']
36
37// "1m 12s", or "9s" under a minute.
38export function fmtClock(ms: number): string {
39  const total = Math.max(0, Math.round(ms / 1000))
40  const m = Math.floor(total / 60)
41  return m ? `${m}m ${String(total % 60).padStart(2, '0')}s` : `${total}s`
42}
43
44const fmtTime = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
45
46export function bar(done: number, total: number, width: number): [string, string] {
47  const filled = total > 0 ? Math.min(width, Math.round((done / total) * width)) : 0
48  return ['█'.repeat(filled), '░'.repeat(width - filled)]
49}
50
51export function parseSuggestions(text: string): string[] {
52  return text
53    .split('\n')
54    .map(line => line.trim().match(/^[1-3][.)]\s+(.+)$/)?.[1]?.trim())
55    .filter((s): s is string => !!s)
56    .slice(0, 3)
57}
58
59// The last LOOKBACK typed prompts and everything after them, trimmed.
60export function recentExchange(messages: readonly Msg[]): string {
61  const starts = messages.flatMap((m, i) => (m.role === 'user' && !m.toolResults?.length && m.text.trim() ? [i] : []))
62  return messages
63    .slice(starts.at(-LOOKBACK) ?? 0)
64    .filter(m => m.text.trim() || m.toolUses.length)
65    .map(m => {
66      const tools = m.toolUses.length ? ` [tools: ${m.toolUses.map(t => t.tool).join(', ')}]` : ''
67      const text = m.text.length > MAX_CHARS ? `${m.text.slice(0, MAX_CHARS)} ...` : m.text
68      return `${m.role.toUpperCase()}: ${text}${tools}`
69    })
70    .join('\n\n')
71}
72
73// What Haiku should know about the project beyond the chat.
74export function projectState(goalText: string | undefined, list: readonly Task[], git: string): string {
75  const open = list.filter(t => t.status !== 'completed')
76  const lines = [
77    goalText ? `Goal: ${goalText}` : '',
78    list.length ? `Tasks: ${list.length - open.length} of ${list.length} done` : '',
79    ...open.map(t => `- ${t.status === 'in_progress' ? '[in progress]' : '[todo]'} ${t.subject}`),
80    git.trim() ? `git status:\n${git.trim().split('\n').slice(0, MAX_GIT_LINES).join('\n')}` : '',
81  ]
82  return lines.filter(Boolean).join('\n')
83}
84
85async function gitStatus($: EngineInterface): Promise<string> {
86  try {
87    const { exitCode, stdout } = await $.process.run(['git', 'status', '--short', '--branch'])
88    return exitCode === 0 ? String(stdout) : ''
89  } catch {
90    return ''
91  }
92}
93
94// Toast, sound and a macOS notification once a long request finishes.
95async function alertDone($: EngineInterface, tookMs: number) {
96  const msg = `Claude finished · took ${fmtClock(tookMs)}`
97  $.ui.toast(msg)
98  await Promise.allSettled([
99    $.process.run(['afplay', DONE_SOUND]),
100    $.process.run(['osascript', '-e', `display notification "${msg}" with title "Claude Code"`]),
101  ])
102}
103
104// Sets a task's status, stamping when it started and finished.
105function stamp(t: Task, status: Status, now: number): Task {
106  return {
107    ...t,
108    status,
109    startedAt: status === 'pending' ? t.startedAt : (t.startedAt ?? now),
110    doneAt: status === 'completed' ? (t.doneAt ?? now) : undefined,
111  }
112}
113
114// Merges a TodoWrite list into the current tasks, keeping their timestamps.
115export function mergeTodos(prev: readonly Task[], todos: readonly { content: string; status: Status }[], now: number): Task[] {
116  return todos.map((t, i) => {
117    const old = prev.find(p => p.subject === t.content)
118    return stamp(old ?? { id: `todo-${i}`, subject: t.content, status: 'pending' }, t.status, now)
119  })
120}
121
122async function setTasks($: EngineInterface, goalText: string, fn: (prev: Task[], now: number) => Task[]) {
123  const now = await $.clock.now()
124  const after = await update($, tasks, prev => fn(prev, now))
125  if (after.length && !(await read($, goal))) {
126    await update($, goal, () => ({ text: goalText || 'Current goal', startedAt: now }))
127  }
128}
129
130// Clears the suggestions; the caller cancels any still being generated.
131async function clearSuggestions($: EngineInterface) {
132  await update($, suggestions, () => [])
133  await update($, isThinking, () => false)
134}
135
136// Stores Claude's estimate for the running request (typed loosely: the
137// connected-MCP tool union is too deep for TypeScript to infer here).
138async function recordEstimate($: EngineInterface, e: unknown): Promise<{ result: string }> {
139  const input = e as { seconds?: number; plan?: string }
140  const secs = Math.min(4 * 3600, Math.max(5, Math.round(Number(input.seconds) || 0)))
141  await update($, run, r => (r ? { ...r, estimateMs: secs * 1000, plan: input.plan } : r))
142  return { result: `Estimate recorded: ${secs}s. The user sees a live progress bar against it.` }
143}
144
145export const register: Register = on => {
146  let inFlight = new AbortController()
147  let lastPrompt = ''
148  let running = false
149  let nextTaskId = 1
150
151  on('session.start', async ($, e, next) => {
152    await $.tool.register({
153      name: 'estimate',
154      description: 'Report your best estimate of how long the current request will take, before you start making changes. Shown to the user as a live progress bar.',
155      inputSchema: {
156        type: 'object',
157        properties: {
158          seconds: { type: 'integer', minimum: 5, description: 'Estimated total seconds to finish the whole request' },
159          plan: { type: 'string', description: 'A few words on what you will do' },
160        },
161        required: ['seconds'],
162      },
163    })
164    // Every second while Claude works (smooth timer bar); every 15s otherwise.
165    let seconds = 0
166    $.clock.every(1000, () => {
167      seconds += 1
168      if (running || seconds % IDLE_TICK_EVERY === 0) void update($, tick, n => n + 1)
169    })
170    return next(e)
171  })
172
173  on('prompt.compose', async ($, e, next) => {
174    const res = await next(e)
175    return { ...res, sections: [...res.sections, { id: 'next-prompts-estimate', text: ESTIMATE_RULE, scope: 'session' as const }] }
176  })
177
178  // Typing just "1", "2" or "3" and pressing Enter sends that suggestion.
179  on('prompt.submit', async ($, e, next) => {
180    const choice = e.text.trim()
181    const pick = /^[1-3]$/.test(choice) ? (await read($, suggestions))[Number(choice) - 1] : undefined
182    lastPrompt = pick ?? choice
183    const current = await read($, tasks)
184    if (current.length && current.every(t => t.status === 'completed')) {
185      await update($, tasks, () => [])
186      await update($, goal, () => null)
187    }
188    inFlight.abort()
189    await clearSuggestions($)
190    return next(pick ? { ...e, text: pick } : e)
191  })
192
193  on('turn.start', async ($, e, next) => {
194    running = true
195    const startedAt = await $.clock.now()
196    await update($, run, () => ({ startedAt }))
197    return next(e)
198  })
199
200  on('turn.complete', async ($, e, next) => {
201    running = false
202    const finished = await read($, run)
203    await update($, run, () => null)
204    const result = await next(e)
205    if (finished) {
206      const took = (await $.clock.now()) - finished.startedAt
207      if (took >= LONG_TURN_MS) void alertDone($, took)
208    }
209
210    inFlight.abort()
211    const mine = (inFlight = new AbortController())
212    void (async () => {
213      await update($, isThinking, () => true)
214      try {
215        const [messages, git, taskList, g] = await Promise.all([$.session.messages(), gitStatus($), read($, tasks), read($, goal)])
216        const project = projectState(g?.text, taskList, git)
217        const reply = await $.model.complete(
218          {
219            model: 'haiku',
220            prompt: `<project>\n${project || '(no tasks or git changes)'}\n</project>\n\n<conversation>\n${recentExchange(messages)}\n</conversation>\n\n${ASK}`,
221            maxTokens: 300,
222            effort: 'low',
223            timeoutMs: SUGGEST_TIMEOUT_MS,
224          },
225          { signal: mine.signal },
226        )
227        if (!mine.signal.aborted) await update($, suggestions, () => (reply.isAnswered ? parseSuggestions(reply.text) : []))
228      } finally {
229        if (!mine.signal.aborted) await update($, isThinking, () => false)
230      }
231    })()
232    return result
233  })
234
235  // Claude's estimate arrives through its own tool. TypeScript gives up on the
236  // union of every connected MCP tool here; the handler is typed by hand above.
237  // @ts-ignore TS2589
238  on('tool.call', { tool: /^mcp__next-prompts__estimate$/ }, recordEstimate)
239
240  // No changes until Claude has given its estimate for this request.
241  on('tool.call', { tool: CHANGE_TOOLS }, async ($, e, next) => {
242    const r = await read($, run)
243    if (r && r.estimateMs === undefined && !(e as { agentId?: string }).agentId) {
244      return { deny: `First call ${ESTIMATE_TOOL} with your best estimate (in seconds) of how long this whole request will take, then retry.` }
245    }
246    return next(e)
247  })
248
249  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
250    const res = await next(e)
251    await setTasks($, lastPrompt, (prev, now) => mergeTodos(prev, e.todos ?? [], now))
252    return res
253  })
254
255  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
256    const res = await next(e)
257    const id = String(res.text ?? JSON.stringify(res.result ?? '')).match(/#(\d+)/)?.[1] ?? String(nextTaskId)
258    nextTaskId = Number(id) + 1
259    await setTasks($, lastPrompt, prev => [...prev, { id, subject: e.subject, status: 'pending' }])
260    return res
261  })
262
263  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
264    const res = await next(e)
265    await setTasks($, lastPrompt, (prev, now) =>
266      e.status === 'deleted'
267        ? prev.filter(t => t.id !== e.taskId)
268        : prev.map(t => (t.id === e.taskId ? stamp({ ...t, subject: e.subject ?? t.subject }, (e.status as Status) ?? t.status, now) : t)),
269    )
270    return res
271  })
272
273  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
274    const [list, thinking, taskList, g, r, expanded] = await Promise.all([
275      read($, suggestions),
276      read($, isThinking),
277      read($, tasks),
278      read($, goal),
279      read($, run),
280      read($, showAll),
281      read($, tick), // subscribes this drawing to the clock
282    ])
283    if (e.props.hasSurvey || (!r && !thinking && !list.length && !taskList.length)) return next(e)
284
285    const { Box, Button, Text } = $.ui.resolve(e)
286    const now = await $.clock.now()
287    const cols = Math.max(10, e.props.bodyColumns)
288
289    // ── full-width timer: elapsed vs. Claude's own estimate
290    let timer = null
291    if (r) {
292      const elapsed = now - r.startedAt
293      if (r.estimateMs === undefined) {
294        timer = (
295          <Text>
296            <Text color={CYAN}>⏱ {fmtClock(elapsed)}</Text>
297            <Text dimColor> · waiting for Claude's estimate…</Text>
298          </Text>
299        )
300      } else {
301        const estimate = r.estimateMs
302        const over = elapsed > estimate
303        const [filled, empty] = bar(elapsed, estimate, cols)
304        timer = (
305          <Box flexDirection="column">
306            <Text>
307              <Text color={over ? 'yellow' : CYAN}>⏱ {fmtClock(elapsed)}</Text>
308              <Text dimColor>
309                {over
310                  ? ` · ${fmtClock(elapsed - estimate)} past Claude's ~${fmtClock(estimate)} estimate`
311                  : ` of Claude's ~${fmtClock(estimate)} estimate · done ≈${fmtTime(r.startedAt + estimate)} · ${Math.round((elapsed / estimate) * 100)}%`}
312                {r.plan ? ` · ${r.plan.slice(0, 60)}` : ''}
313              </Text>
314            </Text>
315            <Text>
316              <Text color={over ? 'yellow' : CYAN}>{filled}</Text>
317              <Text dimColor>{empty}</Text>
318            </Text>
319          </Box>
320        )
321      }
322    }
323
324    // ── goal panel: title, task progress bar, task list
325    let goalPanel = null
326    if (g && taskList.length) {
327      const done = taskList.filter(t => t.status === 'completed').length
328      const [filled, empty] = bar(done, taskList.length, cols)
329      const order = { in_progress: 0, completed: 1, pending: 2 } as const
330      const sorted = [...taskList].sort((a, b) => order[a.status] - order[b.status])
331      goalPanel = (
332        <Box flexDirection="column">
333          <Box>
334            <Text bold>◎ Goal: {g.text.length > 70 ? `${g.text.slice(0, 70)}…` : g.text}  </Text>
335            <Text color={CYAN}>
336              {done} of {taskList.length} tasks · {Math.round((done / taskList.length) * 100)}%
337            </Text>
338          </Box>
339          <Text>
340            <Text color={CYAN}>{filled}</Text>
341            <Text dimColor>{empty}</Text>
342          </Text>
343          <Text dimColor>
344            {fmtClock(now - g.startedAt)} elapsed{done === taskList.length ? ' · done' : ''}
345          </Text>
346          {(expanded ? sorted : sorted.slice(0, SHOWN_TASKS)).map(t =>
347            t.status === 'completed' ? (
348              <Text key={t.id} dimColor>
349                <Text color="green">✓</Text> {t.subject}  {t.startedAt && t.doneAt ? fmtClock(t.doneAt - t.startedAt) : ''}
350              </Text>
351            ) : t.status === 'in_progress' ? (
352              <Text key={t.id}>
353                <Text color={CYAN}>▶</Text> <Text bold>{t.subject}</Text>
354                <Text dimColor>  running {fmtClock(now - (t.startedAt ?? now))}</Text>
355              </Text>
356            ) : (
357              <Text key={t.id}>○ {t.subject}</Text>
358            ),
359          )}
360          {sorted.length > SHOWN_TASKS && (
361            <Button
362              key="all"
363              label={expanded ? 'fewer tasks' : `+${sorted.length - SHOWN_TASKS} more`}
364              onPress={() => update($, showAll, v => !v)}
365            />
366          )}
367        </Box>
368      )
369    }
370
371    // ── next-prompt suggestions
372    const send = (text: string) => async () => {
373      lastPrompt = text // our own submit skips our prompt.submit hook
374      inFlight.abort()
375    await clearSuggestions($)
376      await $.prompt.submit({ text, asUser: true })
377    }
378    const nextPrompts = thinking ? (
379      <Text dimColor>Next prompts: thinking…</Text>
380    ) : list.length ? (
381      <Box flexDirection="column">
382        <Text dimColor>Next prompts (click, or type 1/2/3 + Enter):</Text>
383        {list.map((text, i) => (
384          <Box key={String(i)}>
385            <Button key={`b${i}`} label={String(i + 1)} onPress={send(text)} />
386            <Text color={i === 1 ? NEON_CYAN : undefined}> {text}</Text>
387          </Box>
388        ))}
389      </Box>
390    ) : null
391
392    return (
393      <Box flexDirection="column">
394        {timer}
395        {goalPanel}
396        {nextPrompts}
397      </Box>
398    )
399  })
400}
401
types/index.d.ts 19 lines
1export type Suggestions = string[]
2export type Task = { id: string; subject: string; status: 'pending' | 'in_progress' | 'completed'; startedAt?: number; doneAt?: number }
3export type Run = { startedAt: number; estimateMs?: number; plan?: string }
4export type Goal = { text: string; startedAt: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'next-prompts': {
9      suggestions: Suggestions
10      isThinking: boolean
11      tasks: Task[]
12      goal: Goal | null
13      run: Run | null
14      tick: number
15      showAll: boolean
16    }
17  }
18}
19