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

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.
Next-prompt suggestions
git status) and proposes 3 concrete next prompts, ordered by impact.1, 2 or 3 and press Enter to send it.Estimate-gated timer
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).Goal and task panel
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.Done alert
Glass.aiff) and a macOS notification.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.
.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
Settings are constants at the top of hooks/register.tsx:
| Constant | Default | Meaning |
|---|---|---|
LOOKBACK | 3 | Number of past prompts sent to Haiku |
MAX_CHARS | 1500 | Character cap per message in that context |
SUGGEST_TIMEOUT_MS | 15000 | Time limit for generating suggestions |
LONG_TURN_MS | 120000 | How long a request runs before the done alert fires |
SHOWN_TASKS | 5 | Tasks shown before the expand button |
DONE_SOUND | Glass.aiff | Sound for the done alert |
afplay and osascript, so they work only on macOS. Everything else works on any platform.hooks/register.tsx 401 lines1import { 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}
401types/index.d.ts 19 lines1export 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