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…

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 ▸
/plugin install where-am-i@arhun-plugins
/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.hooks/register.tsx 165 lines1// 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}
165hooks/recap.ts 68 lines1// 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.'
68hooks/view.tsx 107 lines1// 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}
107types/index.d.ts 29 lines1// 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