SLOPSHOPPER

where-am-i

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…

newbandguardcommandpromptmodel
v0.2.0MITupdated 2026-10-14Luminous13/claude-mods/mods/where-am-i
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · where-am-i
› 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 › /where ⎿ where-am-i: OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods by Nereo Valeriano. Each one is a plugin in this marketplace.

ModWhat it does
auto-modelPicks 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-iA card above the prompt with where the session stands: goal, status and recent progress. Fork of Hamza Zafar's where-am-i.
next-stepsSuggested next steps, shown in the where-am-i card with clickable number badges. Fork of Hamza Zafar's next-steps.

Install

/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.

auto-model picker

Set picker in the plugin settings:

  • jev (default): needs a TypeSafe API key at api.typesafe.ai. Set it in the settings or as TYPESAFE_API_KEY.
  • openai: needs an OpenAI API key, set in the settings or as OPENAI_API_KEY.
  • claude: asks Haiku through your own Claude Code session. Needs no key, and nothing goes to a third party. Its confidence is its own estimate and a pick takes longer.

Subagent picking was ported from Hamza Zafar's switchboard. See NOTICE.md.

Licence

MIT. See LICENSE. The where-am-i and next-steps forks keep Hamza Zafar's copyright; see their NOTICE.md files.

Source 2 files
hooks/register.tsx 325 lines
1// 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}
325
types/index.d.ts 11 lines
1export 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