SLOPSHOPPER

where-am-i

A live recap above the prompt: the session's goal, what Claude is doing right now, what's next and what waits on you. Folds to one line; /where for a longer…

newbandguardcommandpromptmodel
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 ◆ fix the failing auth test and add an audit log call ▸ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ fix the failing auth test and add an audit log call ▸ ⟨Claude Code's own drawing⟩
README

where-am-i

A Claude Code mod that keeps a one-glance recap above the prompt: the session's goal, what Claude is doing right now, what's next, and what waits on you. Made for coming back to a session after a break, or keeping several in your head.

Adapted from hamzafer/claude-code-mods (commit e687416) by Hamza Zafar, MIT licensed. See LICENSE. Changed here: a new aligned layout, the live activity with a working dot, the goal shown from your first message before any recap, elapsed time and turn count, folding, no model call for trivial turns, and no dependency on other mods.

◆ Goal   Rebuild the context-cache panel as rings        12m · 4 turns  ▾
  Now    ● editing hooks/panel-svg.ts
  You    ⏳ choose whether to keep the card background

Between turns, Now shows what was just done (✓) and Next the next step. Folded:

◆ Rebuild the context-cache panel as rings  ·  ● editing hooks/panel-svg.ts  ▸

Install

/plugin install where-am-i@arhun-plugins

How it works

  • While Claude works, Now follows its tool calls live ("editing hooks/view.tsx", "running the tests"). No model call for that.
  • After each turn, one small Haiku call rewrites the recap from your message, the tools used and the reply. A turn with no tools and a short reply (a thank-you, a quick answer) skips it.
  • Until the first recap, your latest message stands in as the goal, dimmed.
  • The time beside the goal counts from when that goal was set; a new goal restarts it.

Commands

  • /where: a longer recap in a few bullets, from the last messages.
  • /where-collapse: fold it to one line, or open it again (the same as the chevron). Kept across sessions.
Source 4 files
hooks/register.tsx 165 lines
1// where-am-i: a live recap above the prompt (goal, now, next, what waits on you), plus /where.
2// Adapted from hamzafer/claude-code-mods (MIT).
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { WhereRecap } from '../types'
8import { describe, isTrivial, LONG_SYSTEM, parseRecap, SUMMARY_SYSTEM } from './recap'
9import { renderBand } from './view'
10
11export { clip, describe, isTrivial, parseRecap } from './recap'
12
13const MODEL = 'haiku'
14const MAX_LOG = 20
15
16// Held by the host, so the recap survives a hot reload of this file.
17const recap = atom({ plugin: 'where-am-i', key: 'recap' } as const, null as WhereRecap | null)
18const live = atom({ plugin: 'where-am-i', key: 'live' } as const, '')
19const prompt = atom({ plugin: 'where-am-i', key: 'prompt' } as const, '')
20const since = atom({ plugin: 'where-am-i', key: 'since' } as const, null as number | null)
21const turns = atom({ plugin: 'where-am-i', key: 'turns' } as const, 0)
22const collapsed = atom({ plugin: 'where-am-i', key: 'isCollapsed' } as const, false)
23const tick = atom({ plugin: 'where-am-i', key: 'tick' } as const, 0)
24
25async function toggleCollapsed($: EngineInterface): Promise<boolean> {
26  const isCollapsed = !(await read($, collapsed))
27  await update($, collapsed, () => isCollapsed)
28  await $.store.set('isCollapsed', isCollapsed)
29  return isCollapsed
30}
31
32async function summarize($: EngineInterface, said: string, log: string[], answer: string) {
33  const before = await read($, recap)
34  const r = await $.model.complete({
35    model: MODEL,
36    maxTokens: 300,
37    system: SUMMARY_SYSTEM,
38    prompt: [
39      `Previous recap: ${before ? JSON.stringify(before) : 'none'}`,
40      `The person's latest message: ${said}`,
41      `Tools used this turn: ${log.join('; ') || 'none'}`,
42      `The assistant's reply: ${answer.slice(0, 2500)}`,
43    ].join('\n\n'),
44  })
45  if (!r.isAnswered) return
46  const parsed = parseRecap(r.text)
47  if (!parsed) return
48  // A new goal restarts the clock beside it.
49  if (!before || before.goal !== parsed.goal) {
50    const at = await $.clock.now()
51    await update($, since, s => (before ? at : (s ?? at)))
52  }
53  await update($, recap, () => parsed)
54}
55
56async function longRecap($: EngineInterface, said: string, log: string[]) {
57  const messages = (await $.session.messages()).slice(-12)
58  const r = await $.model.complete({
59    model: MODEL,
60    maxTokens: 500,
61    system: LONG_SYSTEM,
62    prompt: [
63      `<transcript>\n${messages
64        .filter(m => m.text.trim() !== '')
65        .map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`)
66        .join('\n')}\n</transcript>`,
67      `Latest message: ${said}`,
68      `Recent tool calls: ${log.join('; ') || 'none'}`,
69      'Write the recap of the transcript above now: the bullets only, not a reply to it.',
70    ].join('\n\n'),
71  })
72  return r.isAnswered ? r.text : 'Could not build a recap right now.'
73}
74
75export const register: Register = on => {
76  let said = ''
77  let log: string[] = []
78
79  on('session.start', async ($, e, next) => {
80    const r = await next(e)
81    // A name Claude Code already has is refused: start anyway.
82    await $.command.register({ name: 'where', description: 'Recap the session so far in a few bullets' }).catch(() => {})
83    await $.command.register({ name: 'where-collapse', description: 'Fold the where-am-i recap to one line, or open it again' }).catch(() => {})
84    if ((await $.store.get('isCollapsed')) === true) await update($, collapsed, () => true)
85    // The elapsed time beside the goal moves once a minute.
86    $.clock.every(60_000, () => {
87      void $.clock.now().then(t => update($, tick, () => t)).catch(() => {})
88    })
89    return r
90  })
91
92  on('prompt.submit', async ($, e, next) => {
93    said = e.text.slice(0, 1500)
94    log = []
95    if (!said.startsWith('/')) {
96      await update($, prompt, () => said)
97      await update($, live, () => 'reading your message')
98      if ((await read($, since)) === null) {
99        const at = await $.clock.now()
100        await update($, since, () => at)
101      }
102    }
103    return next(e)
104  })
105
106  // Observe only: note what is happening, then let the call run untouched. Only Claude's own calls count:
107  // one another mod makes in the background is raised by that plugin, and `next.origin` names it.
108  on('tool.call', async ($, e, next) => {
109    if (next.origin.plugin !== 'engine') return next(e)
110    const line = describe(e as unknown as Record<string, unknown>)
111    log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
112    if (!e.agentId) await update($, live, () => line)
113    return next(e)
114  })
115
116  on('turn.complete', async ($, e, next) => {
117    const r = await next(e)
118    if (!e.agentId) {
119      await update($, live, () => '')
120      await update($, turns, n => n + 1)
121      const hasRecap = (await read($, recap)) !== null
122      // In the background, so the turn ends at once.
123      if (!isTrivial(log.length, e.answer, hasRecap)) void summarize($, said, log, e.answer).catch(() => {})
124    }
125    return r
126  })
127
128  on('command.run', { command: 'where' }, async $ => ({ text: await longRecap($, said, log) }))
129
130  on('command.run', { command: 'where-collapse' }, async $ => {
131    const isCollapsed = await toggleCollapsed($)
132    return { text: isCollapsed ? 'Recap folded to one line.' : 'Recap expanded.' }
133  })
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const rest = await next(e) // what other mods and Claude Code draw here stays, under the recap
137    if (e.props.hasSurvey || e.props.view?.agentId !== undefined) return rest
138    const [r, l, p, s, n, isCollapsed] = await Promise.all([
139      read($, recap),
140      read($, live),
141      read($, prompt),
142      read($, since),
143      read($, turns),
144      read($, collapsed),
145      read($, tick),
146    ])
147    const mine = renderBand(
148      $.ui.resolve(e),
149      { recap: r, prompt: p, live: l, isWorking: e.props.isWorking, since: s, turns: n, now: await $.clock.now() },
150      e.props.bodyColumns,
151      isCollapsed,
152      () => void toggleCollapsed($),
153    )
154    if (!mine) return rest
155    if (!rest) return mine
156    const { Box } = $.ui.resolve(e)
157    return (
158      <Box flexDirection="column" rowGap={1}>
159        {mine}
160        {rest}
161      </Box>
162    )
163  })
164}
165
hooks/recap.ts 68 lines
1// Pure helpers for where-am-i: what a tool call is doing, the model's recap, when a turn is worth one.
2
3import type { WhereRecap } from '../types'
4
5/** A short label for one tool call: what a person would say Claude is doing. */
6export function describe(e: Record<string, unknown>): string {
7  const tool = String(e.tool)
8  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
9  const file = (p: string) => p.split('/').slice(-2).join('/')
10  if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 60)}`
11  if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return `editing ${file(s('file_path') || s('notebook_path'))}`
12  if (tool === 'Read') return `reading ${file(s('file_path'))}`
13  if (tool === 'Grep' || tool === 'Glob') return `searching for ${s('pattern').slice(0, 40)}`
14  if (tool === 'WebSearch') return `searching the web for ${s('query').slice(0, 40)}`
15  if (tool === 'WebFetch') return `reading ${s('url').replace(/^https?:\/\//, '').slice(0, 50)}`
16  if (tool === 'Agent' || tool === 'Task') return `starting an agent: ${s('description')}`
17  if (tool === 'AskUserQuestion') return 'asking you a question'
18  if (tool === 'TodoWrite') return 'updating the task list'
19  if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ').replace(/_/g, ' ')}`
20  return `using ${tool}`
21}
22
23/** At most `max` characters, one sentence, cut at a word: the model does not always keep to the limit. */
24export function clip(text: string, max = 70): string {
25  const first = (text.split(/(?<=[.!?])\s/)[0] ?? text).split('\n')[0]!.trim()
26  if (first.length <= max) return first.replace(/\.$/, '')
27  const cut = first.lastIndexOf(' ', max - 1)
28  return `${first.slice(0, cut > max / 2 ? cut : max - 1)}…`
29}
30
31/** The model's JSON, tolerating a code fence around it. */
32export function parseRecap(text: string): WhereRecap | null {
33  const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
34  try {
35    const o = JSON.parse(body) as Record<string, unknown>
36    const field = (k: string) => (typeof o[k] === 'string' ? clip((o[k] as string).replace(/\s*—\s*/g, ', ').trim()) : '')
37    if (!field('goal')) return null
38    return { goal: field('goal'), now: field('now'), waiting: field('waiting'), next: field('next') }
39  } catch {
40    return null
41  }
42}
43
44/** A turn with no tools and a short reply ("thanks", a quick answer) leaves the recap as it is: no model call. */
45export function isTrivial(tools: number, answer: string, hasRecap: boolean): boolean {
46  return hasRecap && tools === 0 && answer.trim().length < 280
47}
48
49/** 45 s → "just now", 12 min → "12m", 3 h 5 min → "3h 5m". */
50export function elapsed(ms: number): string {
51  const m = Math.floor(ms / 60_000)
52  if (m < 1) return 'just now'
53  if (m < 60) return `${m}m`
54  const h = Math.floor(m / 60)
55  return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`
56}
57
58export const SUMMARY_SYSTEM =
59  'You keep a one-glance recap of a coding session for someone who loses track easily. Plain words, no em dashes, ' +
60  'each field at most 70 characters. Reply with JSON only: {"goal","now","waiting","next"}. ' +
61  '"goal": the overall aim of the session (keep the previous goal unless it clearly changed). ' +
62  '"now": what was just done. "waiting": what the assistant needs from the person before it can go on ' +
63  '(a question to answer, a choice, something to test), or "" if nothing. "next": the next step.'
64
65export const LONG_SYSTEM =
66  'Write a recap of this coding session for someone who lost track. Plain words, no em dashes. ' +
67  'At most 6 short bullets: the goal, what is done, what is happening now, what is waiting on them, the next step.'
68
hooks/view.tsx 107 lines
1// The recap above the prompt, laid out as the other mods in this marketplace: host elements and theme colours,
2// a bold header row with dim detail and the fold chevron at its end, aligned labels, no card or background.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { WhereRecap } from '../types'
7import { clip, elapsed } from './recap'
8
9type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
10
11export type Model = {
12  recap: WhereRecap | null
13  /** The goal until the first recap: the person's latest message. */
14  prompt: string
15  live: string
16  isWorking: boolean
17  since: number | null
18  turns: number
19  now: number
20}
21
22const LABEL_W = 7
23
24export function renderBand(E: Els, m: Model, columns: number, isCollapsed: boolean, toggle: () => void): RenderElement | null {
25  const { Box, Text, Button } = E
26  const r = m.recap
27  const goal = r?.goal ?? clip(m.prompt)
28  if (!goal) return null
29  const isProvisional = !r
30  const doing = m.isWorking ? m.live || 'working' : (r?.now ?? '')
31  const meta = [m.since !== null ? elapsed(m.now - m.since) : '', m.turns > 0 ? `${m.turns} turn${m.turns === 1 ? '' : 's'}` : '']
32    .filter(Boolean)
33    .join(' · ')
34  const chevron = <Button key="collapse" plain dimColor label={isCollapsed ? '▸' : '▾'} onPress={toggle} />
35  const status = m.isWorking ? <Text color="claude">{'● '}</Text> : <Text color="success">{'✓ '}</Text>
36
37  if (isCollapsed) {
38    return (
39      <Box key="where-am-i" flexDirection="row" columnGap={1}>
40        <Box flexGrow={1} flexShrink={1} minWidth={0}>
41          <Text wrap="truncate-end">
42            <Text color="suggestion">{'◆ '}</Text>
43            <Text bold dimColor={isProvisional}>{goal}</Text>
44            {doing ? <Text dimColor>{'  ·  '}</Text> : null}
45            {doing ? status : null}
46            {doing ? <Text dimColor={!m.isWorking}>{clip(doing, 60)}</Text> : null}
47            {r?.waiting ? <Text color="warning">{`  ·  ⏳ ${clip(r.waiting, 50)}`}</Text> : null}
48          </Text>
49        </Box>
50        {chevron}
51      </Box>
52    )
53  }
54
55  const row = (key: string, label: RenderElement, body: RenderElement, trailing?: RenderElement | null) => (
56    <Box key={key} flexDirection="row" columnGap={1}>
57      <Box width={LABEL_W} flexShrink={0}>
58        {label}
59      </Box>
60      <Box flexGrow={1} flexShrink={1} minWidth={0}>
61        {body}
62      </Box>
63      {trailing ?? null}
64    </Box>
65  )
66  const showMeta = meta !== '' && columns >= 60
67
68  return (
69    <Box key="where-am-i" flexDirection="column">
70      {row(
71        'goal',
72        <Text>
73          <Text color="suggestion">{'◆ '}</Text>
74          <Text bold>Goal</Text>
75        </Text>,
76        <Text bold={!isProvisional} dimColor={isProvisional} wrap="truncate-end">
77          {goal}
78        </Text>,
79        <Box flexDirection="row" columnGap={1} flexShrink={0}>
80          {showMeta ? <Text dimColor>{meta}</Text> : null}
81          {chevron}
82        </Box>,
83      )}
84      {doing
85        ? row(
86            'now',
87            <Text dimColor>{'  Now'}</Text>,
88            <Text wrap="truncate-end">
89              {status}
90              <Text>{doing}</Text>
91            </Text>,
92          )
93        : null}
94      {r?.next && !m.isWorking ? row('next', <Text dimColor>{'  Next'}</Text>, <Text wrap="truncate-end">{r.next}</Text>) : null}
95      {r?.waiting
96        ? row(
97            'waiting',
98            <Text color="warning">{'  You'}</Text>,
99            <Text color="warning" wrap="truncate-end">
100              {`⏳ ${r.waiting}`}
101            </Text>,
102          )
103        : null}
104    </Box>
105  )
106}
107
types/index.d.ts 29 lines
1// Contract for where-am-i: the values it keeps in $.state.
2
3/** The recap a small model writes after each turn; every field one short line. */
4export type WhereRecap = {
5  goal: string
6  now: string
7  /** What Claude waits on the person for; "" when nothing. */
8  waiting: string
9  next: string
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'where-am-i': {
15      recap: WhereRecap | null
16      /** What Claude is doing this moment ("editing hooks/view.tsx"); "" between turns. */
17      live: string
18      /** The person's latest message, first line: the goal until the first recap is written. */
19      prompt: string
20      /** When the current goal was first set, epoch ms; null before any. */
21      since: number | null
22      turns: number
23      isCollapsed: boolean
24      /** The clock, written each minute so the elapsed time redraws. */
25      tick: number
26    }
27  }
28}
29