A live recap above the prompt as one card: the goal as its title; now, waiting on you and next with their status; and the next-steps suggestions in the same…

Claude Code mods by Nereo Valeriano. Each one is a plugin in this marketplace.
| Mod | What it does |
|---|---|
| auto-model | Picks the model and effort for each of your prompts, and the model for each subagent. Shows a colored bar under the input with the model, effort, context and limits. |
| where-am-i | A card above the prompt with where the session stands: goal, status and recent progress. Fork of Hamza Zafar's where-am-i. |
| next-steps | Suggested next steps, shown in the where-am-i card with clickable number badges. Fork of Hamza Zafar's next-steps. |
/plugin marketplace add Luminous13/claude-mods
/plugin install auto-model@claude-mods
/plugin install where-am-i@claude-mods
/plugin install next-steps@claude-mods
Install where-am-i and next-steps together: the where-am-i card shows what next-steps suggests.
Don't also install Hamza's originals of where-am-i or next-steps. They have the same names and would clash.
Set picker in the plugin settings:
TYPESAFE_API_KEY.OPENAI_API_KEY.Subagent picking was ported from Hamza Zafar's switchboard. See NOTICE.md.
MIT. See LICENSE. The where-am-i and next-steps forks keep Hamza Zafar's copyright; see their NOTICE.md files.
hooks/register.tsx 325 lines1// Where Am I: a live recap above the prompt (goal, now, waiting on you, next), plus /where.
2// Local fork of hamzafer/claude-code-mods where-am-i 0.1.4: the card, which also draws the
3// next-steps suggestions so the two read as one (next-steps keeps the keys and the picking).
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Recap } from '../types'
8
9const MODEL = 'haiku'
10const MAX_LOG = 20
11
12// Held by the host, so the recap survives a hot reload of this file.
13const recap = atom({ plugin: 'where-am-i', key: 'recap' } as const, null as Recap | null)
14const live = atom({ plugin: 'where-am-i', key: 'live' } as const, '')
15// The turn whose suggestions were dismissed by a click on the card.
16const dismissed = atom({ plugin: 'where-am-i', key: 'dismissed' } as const, '')
17// The pulse's step, moved on a timer while a turn runs so the blocks beside Working travel.
18const frame = atom({ plugin: 'where-am-i', key: 'frame' } as const, 0)
19// True once this mod draws the card: next-steps then leaves its suggestions to it.
20const hosts = atom({ plugin: 'where-am-i', key: 'hosts' } as const, false)
21// The next-steps mod's values, read here. active: its list is on; this card leaves out its own next meanwhile.
22const nextStepsActive = { plugin: 'next-steps', key: 'active' } as const
23const nextStepsSteps = { plugin: 'next-steps', key: 'steps' } as const
24const nextStepsArmed = { plugin: 'next-steps', key: 'armed' } as const
25const nextStepsLeader = { plugin: 'next-steps', key: 'leader' } as const
26
27const PINK = '#f472b6'
28const BORDER = '#fb7185'
29const PALETTE = ['#fdba74', '#f472b6', '#c4b5fd', '#93c5fd'] // the goal's words, in turn
30const PULSE = ['#831843', '#be185d', '#ec4899', '#f9a8d4'] // the blocks beside Working
31const PULSE_MS = 150 // one step of the pulse
32const ROW_COLORS = ['#f472b6', '#a5b4fc', '#5eead4'] // suggestion 1, 2 and 3, as next-steps draws them
33const ROW_TINTS = ['#f9a8d4', '#c7d2fe', '#99f6e4'] // their text, a shade lighter
34// Each row in its own color, as the auto-model bar colors its values: now pink, done green,
35// waiting yellow, next lavender. The bullet and status in the full color, the text a tint of it.
36const TONE = {
37 working: { mark: '#f472b6', text: '#f9a8d4' },
38 done: { mark: '#34d399', text: '#a7f3d0' },
39 waiting: { mark: '#fbbf24', text: '#fde68a' },
40 next: { mark: '#c4b5fd', text: '#ddd6fe' },
41} as const
42const HEADING: [string, string][] = [['Suggested ', '#fdba74'], ['next ', '#f472b6'], ['steps', '#c4b5fd']]
43const CARD_MIN_COLUMNS = 40
44
45let pulse: { cancel: () => void } | null = null
46
47// Steps the pulse while the turn runs; the tick stops itself once the turn is over.
48function startPulse($: EngineInterface) {
49 if (pulse) return
50 pulse = $.clock.every(PULSE_MS, () => void tick($).catch(() => {}))
51}
52
53async function tick($: EngineInterface) {
54 if ((await read($, live)) === '') return stopPulse()
55 await update($, frame, n => (n + 1) % PULSE.length)
56}
57
58function stopPulse() {
59 pulse?.cancel()
60 pulse = null
61}
62
63export const register: Register = on => {
64 let prompt = ''
65 let log: string[] = []
66
67 on('session.start', async ($, e, next) => {
68 const r = await next(e)
69 await $.command.register({ name: 'where', description: 'Recap the session so far in a few bullets' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
70 await update($, hosts, () => true).catch(() => {})
71 return r
72 })
73
74 on('prompt.submit', async ($, e, next) => {
75 prompt = e.text.slice(0, 1500)
76 log = []
77 await update($, live, () => 'reading your message')
78 startPulse($)
79 return next(e)
80 })
81
82 // Observe only: note what is happening, then let the call run untouched.
83 // Only Claude's own calls count: one another mod makes in the background (`$.tool.call`,
84 // `$.mcp.call`) is raised by that plugin, and `next.origin` names it instead of the engine.
85 on('tool.call', async ($, e, next) => {
86 if (next.origin.plugin !== 'engine') return next(e)
87 const line = describe(e as unknown as Record<string, unknown>)
88 log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
89 if (!e.agentId) await update($, live, () => line)
90 const r = await next(e)
91 return r
92 })
93
94 on('turn.complete', async ($, e, next) => {
95 const r = await next(e)
96 if (!e.agentId) {
97 await update($, live, () => '')
98 stopPulse()
99 void summarize($, prompt, log, e.answer).catch(() => {}) // in the background, so the turn ends at once
100 }
101 return r
102 })
103
104 on('command.run', { command: 'where' }, async $ => ({ text: await longRecap($, prompt, log) }))
105
106 // One card above the prompt, like the auto-model bar: the goal as its title; now, waiting on
107 // you and next as rows with their status on the right; and, once a turn ends, the next-steps
108 // suggestions inside the same box. A band too short or narrow for it gets compact lines.
109 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110 const rest = await next(e) // what other mods and Claude Code draw here stays
111 const r = await read($, recap)
112 if (e.props.hasSurvey || !r) return rest
113
114 const { Box, Button, Text } = $.ui.resolve(e)
115 const doing = await read($, live)
116 const busy = e.props.isWorking || doing !== ''
117 const now = clip(doing || r.now)
118 const { value: hasNextSteps = false } = await $.state.get(nextStepsActive)
119 const { value: steps = null } = await $.state.get(nextStepsSteps)
120 const { value: isArmed = false } = await $.state.get(nextStepsArmed)
121 const { value: leader = ';' } = await $.state.get(nextStepsLeader)
122 const isDismissed = steps !== null && steps.turnId === (await read($, dismissed))
123 const items = hasNextSteps && !busy && !isDismissed ? (steps?.items ?? []) : []
124 const nextStep = hasNextSteps && !isDismissed ? '' : r.next
125 const waiting = busy ? '' : r.waiting // a stale ask while Claude works again
126
127 const row = (left: JSX.Element[], right: JSX.Element[]) => (
128 <Box flexDirection="row">
129 <Box flexGrow={1} flexShrink={1}>
130 <Text wrap="truncate-end">{left}</Text>
131 </Box>
132 <Box flexShrink={0} marginLeft={2}>
133 <Text>{right}</Text>
134 </Box>
135 </Box>
136 )
137 const title = (
138 <Text wrap="truncate-end">
139 <Text color={PINK}>{'✧ '}</Text>
140 {words(r.goal).map((w, i) => (
141 <Text color={PALETTE[i % PALETTE.length] ?? PINK} bold>
142 {w}
143 </Text>
144 ))}
145 </Text>
146 )
147 const tone = busy ? TONE.working : TONE.done
148 const step = busy ? await read($, frame) : 0 // read only while busy, so an idle card is not redrawn
149 const working = [...PULSE.map((_, i) => <Text color={PULSE[(i - step + PULSE.length) % PULSE.length]}>{'▇'}</Text>), <Text color={TONE.working.mark} bold>{' Working'}</Text>]
150 const nowLeft = [<Text color={tone.mark}>{busy ? '● ' : '✓ '}</Text>, <Text color={tone.text} {...(busy ? { bold: true } : {})}>{now}</Text>]
151 const rows: JSX.Element[] = []
152 if (now !== '') rows.push(row(nowLeft, busy ? working : [<Text color={TONE.done.mark} bold>{'Done'}</Text>]))
153 if (waiting !== '') rows.push(row([<Text color={TONE.waiting.mark}>{'◆ '}</Text>, <Text color={TONE.waiting.text}>{clip(waiting)}</Text>], [<Text color={TONE.waiting.mark} bold>{'Waiting on you'}</Text>]))
154 if (nextStep !== '') rows.push(row([<Text color={TONE.next.mark}>{'○ '}</Text>, <Text color={TONE.next.text}>{clip(nextStep)}</Text>], [<Text color={TONE.next.mark} bold>{'Next'}</Text>]))
155
156 const hint = isArmed ? <Text color="warning" bold>{`press ${choices(items.length)} · 0 dismisses`}</Text> : <Text dimColor>{`click one, or type ${leader} then a number`}</Text>
157 // The number on a filled keycap-like square that a click presses; the leader stays out of it.
158 const badge = (n: number, color: string, onPress: () => void) => (
159 <Button key={`pick-${n}`} label={`${n}`} onPress={onPress}>
160 <Text backgroundColor={color} color="black" bold>{` ${n} `}</Text>
161 </Button>
162 )
163 const rowB = (left: JSX.Element[], right: JSX.Element) => (
164 <Box flexDirection="row">
165 <Box flexGrow={1} flexShrink={1}>
166 <Text wrap="truncate-end">{left}</Text>
167 </Box>
168 <Box flexShrink={0} marginLeft={2}>
169 {right}
170 </Box>
171 </Box>
172 )
173 const turnId = steps?.turnId ?? ''
174 // The badges, shared by the card and the compact lines: a pick drafts its suggestion, 0 hides them.
175 const pick = (i: number) => badge(i + 1, ROW_COLORS[i] ?? PINK, () => void $.prompt.fill({ text: items[i] ?? '' }).catch(() => {}))
176 const dismiss = badge(0, '#6b7280', () => void update($, dismissed, () => turnId).catch(() => {}))
177 const suggestions = items.length === 0 ? [] : [
178 row([<Text color={PINK}>{'✦ '}</Text>, ...HEADING.map(([w, c]) => <Text color={c} bold>{w}</Text>)], [hint]),
179 ...items.map((t, i) => rowB([<Text color={ROW_COLORS[i] ?? PINK}>{'● '}</Text>, <Text color={ROW_TINTS[i] ?? PINK}>{t}</Text>], pick(i))),
180 rowB([<Text dimColor>{'○ '}</Text>, <Text dimColor>{'Dismiss'}</Text>], dismiss),
181 ]
182
183 // Two borders, the title and the rows; a blank row between the recap and the suggestions if it fits.
184 const tight = 3 + rows.length + suggestions.length
185 const fits = e.props.bodyColumns >= CARD_MIN_COLUMNS && tight <= e.props.maxRows
186 const spaced = fits && suggestions.length > 0 && rows.length > 0 && tight + 1 <= e.props.maxRows
187 if (fits) {
188 return (
189 <Box flexDirection="column">
190 <Box flexDirection="column" borderStyle="round" borderColor={isArmed ? 'warning' : BORDER} paddingX={1} width={e.props.bodyColumns}>
191 {title}
192 {rows}
193 {spaced && <Text>{' '}</Text>}
194 {suggestions}
195 </Box>
196 {rest}
197 </Box>
198 )
199 }
200
201 // Compact: the title, one line for now / waiting / next, one row of key chips.
202 const dot = <Text dimColor>{' · '}</Text>
203 const status = [
204 ...(now !== '' ? [<Text color={tone.mark}>{busy ? '● ' : '✓ '}</Text>, <Text color={tone.text}>{now}</Text>] : []),
205 ...(waiting !== '' ? [dot, <Text color={TONE.waiting.mark} bold>{'◆ waiting on you: '}</Text>, <Text color={TONE.waiting.text}>{clip(waiting)}</Text>] : []),
206 ...(nextStep !== '' ? [dot, <Text color={TONE.next.mark} bold>{'next: '}</Text>, <Text color={TONE.next.text}>{clip(nextStep)}</Text>] : []),
207 ]
208 return (
209 <Box flexDirection="column">
210 <Box flexDirection="column" paddingX={1}>
211 {title}
212 {status.length > 0 && <Text wrap="truncate-end">{[<Text>{' '}</Text>, ...status]}</Text>}
213 {items.length > 0 && (
214 // The same badges as the card, each text giving way first where the band is narrow.
215 <Box flexDirection="row">
216 <Box flexShrink={0}>
217 <Text color={isArmed ? 'warning' : PINK} bold>{isArmed ? '✦ pick ' : '✦ next '}</Text>
218 </Box>
219 {items.flatMap((t, i) => [
220 <Box flexShrink={0}>{pick(i)}</Box>,
221 <Box flexShrink={1} marginRight={2}>
222 <Text color={ROW_TINTS[i] ?? PINK} wrap="truncate-end">{` ${t}`}</Text>
223 </Box>,
224 ])}
225 <Box flexShrink={0}>{dismiss}</Box>
226 <Box flexShrink={1}>
227 <Text dimColor wrap="truncate-end">{' dismiss'}</Text>
228 </Box>
229 </Box>
230 )}
231 </Box>
232 {rest}
233 </Box>
234 )
235 })
236}
237
238// The goal's words, each to take the next color of the title's palette.
239export function words(goal: string): string[] {
240 const all = clip(goal).split(' ').filter(w => w !== '')
241 return all.map((w, i) => (i < all.length - 1 ? `${w} ` : w))
242}
243
244// "1, 2 or 3" for three suggestions.
245export function choices(n: number): string {
246 const digits = Array.from({ length: Math.max(1, n) }, (_, i) => `${i + 1}`)
247 return digits.length > 1 ? `${digits.slice(0, -1).join(', ')} or ${digits.at(-1)}` : (digits[0] ?? '1')
248}
249
250// A short label for one tool call: what a person would say Claude is doing.
251export function describe(e: Record<string, unknown>): string {
252 const tool = String(e.tool)
253 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
254 const file = (p: string) => p.split('/').slice(-2).join('/')
255 if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 60)}`
256 if (tool === 'Edit' || tool === 'Write') return `editing ${file(s('file_path'))}`
257 if (tool === 'Read') return `reading ${file(s('file_path'))}`
258 if (tool === 'Grep' || tool === 'Glob') return `searching for ${s('pattern').slice(0, 40)}`
259 if (tool === 'Agent') return `starting an agent: ${s('description')}`
260 if (tool === 'AskUserQuestion') return 'asking you a question'
261 if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ')}`
262 return `using ${tool}`
263}
264
265async function summarize($: EngineInterface, prompt: string, log: string[], answer: string) {
266 const before = await read($, recap)
267 const r = await $.model.complete({
268 model: MODEL,
269 maxTokens: 300,
270 system:
271 'You keep a one-glance recap of a coding session for someone with ADHD. Plain words, no em dashes, ' +
272 'each field at most 70 characters. Reply with JSON only: {"goal","now","waiting","next"}. ' +
273 '"goal": the overall aim of the session (keep the previous goal unless it clearly changed). ' +
274 '"now": what was just done. "waiting": what the assistant is waiting on from the person, or "". ' +
275 '"next": the next step.',
276 prompt: [
277 `Previous recap: ${before ? JSON.stringify(before) : 'none'}`,
278 `The person's latest message: ${prompt}`,
279 `Tools used this turn: ${log.join('; ') || 'none'}`,
280 `The assistant's reply: ${answer.slice(0, 2500)}`,
281 ].join('\n\n'),
282 })
283 if (!r.isAnswered) return
284 const parsed = parseRecap(r.text)
285 if (parsed) await update($, recap, () => parsed)
286}
287
288async function longRecap($: EngineInterface, prompt: string, log: string[]) {
289 const messages = (await $.session.messages()).slice(-12)
290 const r = await $.model.complete({
291 model: MODEL,
292 maxTokens: 500,
293 system:
294 'Write a recap of this coding session for someone who lost track. Plain words, no em dashes. ' +
295 'At most 6 short bullets: the goal, what is done, what is happening now, what is waiting on them, the next step.',
296 prompt: [
297 `<transcript>\n${messages.filter(m => m.text.trim() !== '').map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
298 `Latest message: ${prompt}`,
299 `Recent tool calls: ${log.join('; ') || 'none'}`,
300 'Write the recap of the transcript above now: the bullets only, not a reply to it.',
301 ].join('\n\n'),
302 })
303 return r.isAnswered ? r.text : 'Could not build a recap right now.'
304}
305
306// At most 70 characters, cut at a word: the model does not always keep to the limit.
307export function clip(text: string, max = 70) {
308 const first = text.split(/(?<=[.!?])\s/)[0] ?? text // one sentence
309 if (first.length <= max) return first.replace(/\.$/, '')
310 return `${first.slice(0, first.lastIndexOf(' ', max - 1))}…`
311}
312
313// The model's JSON, tolerating a code fence around it.
314export function parseRecap(text: string): Recap | null {
315 const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
316 try {
317 const o = JSON.parse(body) as Record<string, unknown>
318 const field = (k: string) => (typeof o[k] === 'string' ? clip((o[k] as string).replace(/\s*—\s*/g, ', ').trim()) : '')
319 if (!field('goal')) return null
320 return { goal: field('goal'), now: field('now'), waiting: field('waiting'), next: field('next') }
321 } catch {
322 return null
323 }
324}
325types/index.d.ts 11 lines1export type Recap = { goal: string; now: string; waiting: string; next: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 // hosts: true once this mod draws the one card, the next-steps list inside it.
6 'where-am-i': { recap: Recap | null; live: string; frame: number; dismissed: string; hosts: boolean }
7 // Owned by the next-steps mod, read here. Kept the same as its own contract.
8 'next-steps': { steps: { turnId: string; items: string[] } | null; active: boolean; armed: boolean; leader: string }
9 }
10}
11