SLOPSHOPPER

where-were-we

Timestamps your prompts in the transcript and adds up/down prompt navigation to the footer

newspinnerrowscommandtoastprocess
v0.1.0MITupdated 2026-10-06scarrillo/agent-plugins/where-were-we
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · where-were-we
› fix the failing auth test and add an audit log call ● where-were-we: where-were-we: loaded; utc offset -420 min ● where-were-we: where-were-we: backfill (session startup): no transcript yet ⏺ 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-were-we ⎿ where-were-we: No prompts yet. ● where-were-we: where-were-we: /where-were-we: listed 0 of 0 prompts, chron ● where-were-we: where-were-we: no stamp for prompt row msg-1; drawing the engine's row ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

where-were-we

A Claude Code mod that timestamps your prompts in the transcript, lets you jump back through them, and recaps where your last session in a project left off.

Install

/plugin marketplace add scarrillo/agent-plugins
/plugin install where-were-we@scarrillo

Requirements

  • Claude Code 2.1.288 or later. Mods use Claude Code's function-hooks API, which is early access and can change between releases.
  • Jumping to a prompt (footer arrows, clicking a list line) needs the fullscreen layout.

Features

  • Prompt rows show when you sent them: 12:21:00 ❯ my prompt for today, 2026-10-02 18:04:11 ❯ … for older prompts. Wrapped lines stay aligned under the prompt text.
  • Footer: ↑ ↓ at the end of the hint line under the prompt. ↑ and ↓ step through your prompts; while navigating it shows your position (↑ ↓ 2/7). Sending a prompt resets it.
  • /where-were-we lists your 6 most recent prompts, newest at the bottom; click a line to jump to that prompt.
  • /where-were-we count [n] shows or sets how many prompts the list shows (1–50).
  • /where-were-we order [chron|reverse] shows or sets the list order: chron puts the newest last (the default), reverse puts it first.
  • /where-were-we up | down | newest steps like the arrows, from the keyboard (works mid-turn).
  • /where-were-we last recaps how the previous session in this project ended: its final prompts (same count and order) and the claude --resume <id> command to pick it up. It reads Claude Code's prompt history (~/.claude/history.jsonl), skipping slash and ! commands, and never reads pasted content.
  • Startup greeting: when you start a fresh session in a project, a toast says how the last one ended: Last here yesterday 18:04: "run the tests" · /where-were-we last. Not on --resume/--continue, /clear or compaction, and not in claude -p.
  • /where-were-we greeting [on|off] shows or sets whether the startup greeting appears (on by default).
  • /where-were-we status prints the mod's state and recent log lines for troubleshooting.

The count, order and greeting setting are kept in the mod's own store (a JSON file of its own under ~/.claude/), not in settings.json, and last across sessions.

Prompts sent while the mod is loaded are stamped as they're stored. Prompts from before (a --resume, or the mod enabled mid-session) and prompts queued mid-turn are read from the session's transcript file.

What it reads and runs

A mod runs with your permissions, so here is everything this one touches:

  • This session's transcript (~/.claude/projects/<project>/<session>.jsonl), filtered with grep to the lines that can be your prompts, to recover their times.
  • Claude Code's prompt history (~/.claude/history.jsonl), filtered with grep to this project's lines, for /where-were-we last and the startup greeting. Only the prompt text (display) is read, never pasted content.
  • Its own store, a JSON file under ~/.claude/, for the count, order and greeting settings.

It writes nothing else, sends nothing over the network, and never changes your files or settings.json. Run claude plugin validate <path> on the mod to see every hook it registers and every API it calls.

Limitations

  • Jumping scrolls the transcript only in the fullscreen layout. On the main screen the history is the terminal's own scrollback, which a mod can't move.
  • A row drawn as time-only today keeps that form past midnight until it redraws.
  • The prompt list matches listing lines to prompts by second and text, since a command's output row carries no ids. A line that can't be matched draws dim and isn't clickable.
Source 3 files
hooks/register.tsx 515 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Move, Prompt } from '../types'
5import {
6  TRANSCRIPT_MARKERS,
7  findListed,
8  formatStamp,
9  fullStamp,
10  greetingText,
11  historyMarker,
12  listText,
13  mergePrompts,
14  moveCursor,
15  parseListing,
16  previousSession,
17  promptFromLine,
18  toPreview,
19  truncate,
20} from './prompts'
21import type { PastSession } from './prompts'
22
23const COMMAND = 'where-were-we'
24/** How many prompts the bare command lists until `/where-were-we count` sets it. */
25const LISTED_DEFAULT = 6
26const LISTED_MAX = 50
27/** How long after the session is ready the startup greeting waits. */
28const GREETING_DELAY_MS = 1_500
29/** `chron` lists the newest prompt last (nearest the prompt), `reverse` first. */
30const ORDERS = ['chron', 'reverse'] as const
31type Order = (typeof ORDERS)[number]
32const USAGE = `Usage: /${COMMAND} [up|down|newest|last|count [n]|order [chron|reverse]|greeting [on|off]|status]`
33const POINTER = '❯'
34/** The UserMessage origins that are the person's own prompts. */
35const PERSON = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
36
37// The shape tag drops a list kept by an older version of this code (one
38// without previews); the next backfill fills it again.
39const prompts = atom({ plugin: 'where-were-we', key: 'prompts' } as const, [], { shape: 'with-preview' })
40const cursor = atom({ plugin: 'where-were-we', key: 'cursor' } as const, null)
41const stamp = atom({ plugin: 'where-were-we', key: 'stamp' } as const, null)
42const transcriptPath = atom({ plugin: 'where-were-we', key: 'transcriptPath' } as const, null)
43const backfilledFrom = atom({ plugin: 'where-were-we', key: 'backfilledFrom' } as const, null)
44
45/** The latest log lines, for `/where-were-we status` (a reload starts it over). */
46const recent: string[] = []
47
48const debug = ($: EngineInterface, text: string) => {
49  const now = Date.now()
50  recent.push(`${formatStamp(now, now)} ${text}`)
51  recent.splice(0, recent.length - 20)
52  $.ui.log(`where-were-we: ${text}`, { to: 'debug' })
53}
54
55/** Stamps each prompt's row and adds the new ones to the navigation list. */
56async function remember($: EngineInterface, found: readonly Prompt[]) {
57  const known = new Set((await read($, prompts)).map(prompt => prompt.uuid))
58  const added = found.filter(prompt => !known.has(prompt.uuid))
59  for (const prompt of added) {
60    await update($, memberOf(stamp, { requestId: prompt.uuid }), () => prompt.at)
61  }
62  if (added.length > 0) {
63    await update($, prompts, list => mergePrompts(list, added))
64  }
65
66  return added.length
67}
68
69/**
70 * Reads the prompts' timestamps out of the session's transcript: the rows a
71 * `--resume` loads (loads are no appends, so nothing stamped them live) and
72 * prompts queued mid-turn. grep pre-filters, so a transcript over the 4 MiB a
73 * read may copy still works.
74 */
75async function backfill($: EngineInterface, path: string, why: string) {
76  if (path === '') {
77    return
78  }
79  // A brand-new session has no transcript file until its first row.
80  if (!(await $.fs.exists(path))) {
81    debug($, `backfill (${why}): no transcript yet`)
82    return
83  }
84  const argv = ['grep', '-F', ...TRANSCRIPT_MARKERS.flatMap(marker => ['-e', marker]), '--', path]
85  try {
86    const run = await $.process.run(argv, { timeoutMs: 15_000 })
87    if (run.exitCode > 1) {
88      debug($, `backfill (${why}) grep failed with ${run.exitCode}: ${run.stderr.trim()}`)
89      return
90    }
91    const found = run.stdout
92      .split('\n')
93      .map(promptFromLine)
94      .filter((prompt): prompt is Prompt => prompt !== null)
95    const added = await remember($, found)
96    await update($, backfilledFrom, () => path)
97    debug(
98      $,
99      `backfill (${why}): ${found.length} prompts in transcript, ${added} new` +
100        (run.isStdoutTruncated ? ' (grep output truncated at 4 MiB)' : ''),
101    )
102  } catch (error) {
103    debug($, `backfill (${why}) failed: ${String(error)}`)
104  }
105}
106
107/** The one implementation behind the footer arrows, /where-were-we and the list. */
108async function jump($: EngineInterface, move: Move) {
109  const list = await read($, prompts)
110  if (list.length === 0) {
111    debug($, `jump ${move}: no prompts yet`)
112    $.ui.toast('where-were-we: no prompts to jump to yet')
113    return
114  }
115  const from = await read($, cursor)
116  await goTo($, list, moveCursor(from, list.length, move), `jump ${move} from ${from ?? 'none'}`)
117}
118
119/**
120 * How many prompts the bare command lists: the count kept in the mod's own
121 * store (a JSON file of its own, not settings.json), else LISTED_DEFAULT.
122 */
123async function listCount($: EngineInterface) {
124  const kept = await $.store.get('listCount')
125  return typeof kept === 'number' && Number.isInteger(kept) && kept >= 1 && kept <= LISTED_MAX ? kept : LISTED_DEFAULT
126}
127
128/** The bare command's order, kept in the mod's store like the count; `chron` until set. */
129async function listOrder($: EngineInterface): Promise<Order> {
130  const kept = await $.store.get('listOrder')
131  return ORDERS.find(order => order === kept) ?? 'chron'
132}
133
134/**
135 * Claude Code's prompt history file: beside `projects/` in the configuration
136 * directory the transcript lives under, so a custom CLAUDE_CONFIG_DIR is kept.
137 */
138async function historyPath($: EngineInterface) {
139  const transcript = await read($, transcriptPath)
140  const projects = transcript?.lastIndexOf('/projects/') ?? -1
141  if (transcript && projects > 0) {
142    return `${transcript.slice(0, projects)}/history.jsonl`
143  }
144  const home = await $.env.get('HOME')
145  return home ? `${home}/.claude/history.jsonl` : null
146}
147
148/**
149 * The previous session in this project from Claude Code's prompt history, or
150 * the problem that kept it from being read. Shared by `last` and the greeting.
151 */
152async function findPreviousSession(
153  $: EngineInterface,
154  why: string,
155): Promise<{ past: PastSession | null } | { problem: string }> {
156  const path = await historyPath($)
157  if (path === null) {
158    return { problem: 'Could not find Claude Code\'s prompt history.' }
159  }
160  const project = await $.session.cwd()
161  const run = await $.process.run(['grep', '-F', '-e', historyMarker(project), '--', path], { timeoutMs: 15_000 })
162  if (run.exitCode > 1) {
163    debug($, `${why}: grep failed with ${run.exitCode}: ${run.stderr.trim()}`)
164    return { problem: `Could not read ${path}.` }
165  }
166  const past = previousSession(run.stdout.split('\n'), project, await $.session.id())
167  debug($, `${why}: ${past ? `session ${past.sessionId}, ${past.prompts.length} prompts` : 'no earlier session'} in ${project}`)
168
169  return { past }
170}
171
172/** Whether the startup greeting is on: kept in the mod's store, on until turned off. */
173async function isGreetingOn($: EngineInterface) {
174  return (await $.store.get('greeting')) !== false
175}
176
177/**
178 * Shows, as a toast, when this project's previous session ended and its last
179 * prompt. Never in the way of the session starting: every skip is logged and
180 * any failure is logged and dropped.
181 */
182async function greet($: EngineInterface) {
183  try {
184    if (!(await isGreetingOn($))) {
185      debug($, 'greeting: turned off, skipped')
186      return
187    }
188    const found = await findPreviousSession($, 'greeting')
189    if ('problem' in found) {
190      debug($, `greeting: skipped, ${found.problem}`)
191      return
192    }
193    const text = found.past === null ? null : greetingText(found.past, await $.clock.now())
194    if (text === null) {
195      debug($, 'greeting: nothing to recap, skipped')
196      return
197    }
198    $.ui.toast(text, { timeoutMs: 8_000 })
199    debug($, 'greeting: shown')
200  } catch (error) {
201    debug($, `greeting failed: ${String(error)}`)
202  }
203}
204
205/**
206 * The greeting needs two events, in either order: a fresh start (classic
207 * SessionStart, `startup`), which fires before anything draws, and
208 * session.start, which says whether something draws. A reload fires only
209 * session.start, and starts this module over, so it never greets.
210 */
211const startup: { isGreetingPending: boolean; ready: { isDrawn: boolean } | null } = {
212  isGreetingPending: false,
213  ready: null,
214}
215
216/** Greets once both startup events have come; called by each. */
217function greetWhenReady($: EngineInterface) {
218  if (!startup.isGreetingPending || startup.ready === null) {
219    return
220  }
221  startup.isGreetingPending = false
222  if (!startup.ready.isDrawn) {
223    debug($, 'greeting: nothing draws (claude -p or SDK), skipped')
224    return
225  }
226  // A moment for the screen to come up, so the toast isn't drawn under it.
227  $.clock.after(GREETING_DELAY_MS, () => void greet($))
228}
229
230/**
231 * `/where-were-we last`: how the previous session in this project ended, from
232 * Claude Code's prompt history (this session's transcript can't know), with
233 * the command that resumes it. Listed with the same count and order.
234 */
235async function lastSession($: EngineInterface) {
236  const found = await findPreviousSession($, 'last')
237  if ('problem' in found) {
238    return found.problem
239  }
240  const { past } = found
241  if (past === null) {
242    return 'No earlier session in this project.'
243  }
244  const ended = past.prompts.at(-1)?.at ?? 0
245  const recentPrompts = past.prompts.slice(-(await listCount($)))
246  const listed = (await listOrder($)) === 'reverse' ? recentPrompts.reverse() : recentPrompts
247  const count = past.prompts.length
248
249  return [
250    `Previous session in this project, last active ${fullStamp(ended)} (${count} prompt${count === 1 ? '' : 's'}):`,
251    listText(listed),
252    `Resume it: claude --resume ${past.sessionId}`,
253  ].join('\n')
254}
255
256/** Jumps to one prompt by its id, its place in the list read at the press. */
257async function jumpTo($: EngineInterface, uuid: string) {
258  const list = await read($, prompts)
259  await goTo($, list, list.findIndex(prompt => prompt.uuid === uuid), 'list click')
260}
261
262/** Moves the cursor to `list[to]` and scrolls that prompt to the top of the view. */
263async function goTo($: EngineInterface, list: readonly Prompt[], to: number, why: string) {
264  const target = list[to]
265  if (target === undefined) {
266    debug($, `${why}: no prompt at ${to} of ${list.length}`)
267    return
268  }
269  await update($, cursor, () => to)
270  let deny: string | undefined
271  try {
272    deny = (await $.ui.scroll({ to: { requestId: target.uuid }, block: 'start' })).deny
273  } catch (error) {
274    deny = String(error)
275  }
276  debug($, `${why} -> ${to} of ${list.length} (${target.uuid})${deny ? ` failed: ${deny}` : ''}`)
277  if (deny) {
278    $.ui.toast(`where-were-we: can't scroll there (${deny})`)
279  }
280}
281
282export const register: Register = on => {
283  /** Row ids already logged as drawn without a stamp, so the debug log names each once. */
284  const unstamped = new Set<string>()
285
286  on('session.start', async ($, e, next) => {
287    await $.command.register({
288      name: COMMAND,
289      description: 'List your recent prompts; up, down, newest jump through them; last recaps the previous session',
290      argumentHint: '[up|down|newest|last|count [n]|order [chron|reverse]|greeting [on|off]|status]',
291      immediate: true,
292    })
293    debug($, `loaded; utc offset ${-new Date().getTimezoneOffset()} min`)
294    startup.ready = { isDrawn: e.isInteractive && e.surface !== null }
295    greetWhenReady($)
296
297    return next(e)
298  })
299
300  on('classic.SessionStart', async ($, e, next) => {
301    await update($, transcriptPath, () => e.transcript_path)
302    await backfill($, e.transcript_path, `session ${e.source}`)
303    // Only a fresh start: a resume, /clear, compaction or reload is no return.
304    if (e.source === 'startup') {
305      startup.isGreetingPending = true
306      greetWhenReady($)
307    }
308
309    return next(e)
310  })
311
312  // Covers a mod enabled mid-session: no SessionStart came, so the first
313  // prompt after the load backfills the prompts sent before it.
314  on('classic.UserPromptSubmit', async ($, e, next) => {
315    await update($, transcriptPath, () => e.transcript_path)
316    if ((await read($, backfilledFrom)) !== e.transcript_path) {
317      await backfill($, e.transcript_path, 'first prompt since load')
318    }
319
320    return next(e)
321  })
322
323  on('session.append', { door: 'prompt' }, async ($, e, next) => {
324    const stored = await next(e)
325    const isPerson = e.message.type === 'user' && !e.message.isMeta && PERSON.has(e.origin.kind)
326    if (isPerson) {
327      const text = e.message.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join(' ')
328      const added = await remember($, [{ uuid: e.uuid, at: await $.clock.now(), preview: toPreview(text) }])
329      await update($, cursor, () => null)
330      debug($, `stamped prompt ${e.uuid} (${e.origin.kind})${added ? '' : ' (already known)'}`)
331    }
332
333    return stored
334  })
335
336  // Picks up prompts queued while a turn ran, which arrive as deliveries.
337  on('turn.complete', async ($, e, next) => {
338    const result = await next(e)
339    const path = await read($, transcriptPath)
340    if (path !== null) {
341      await backfill($, path, 'turn complete')
342    }
343
344    return result
345  })
346
347  on('session.end', async ($, e, next) => {
348    if (e.reason === 'clear') {
349      await update($, prompts, () => [])
350      await update($, cursor, () => null)
351      debug($, 'cleared prompt list on /clear')
352    }
353
354    return next(e)
355  })
356
357  on('command.run', { command: COMMAND }, async ($, e) => {
358    const arg = e.args.trim().toLowerCase()
359    const [word = '', value, ...extra] = arg.split(/\s+/)
360    if (word === 'count') {
361      if (value === undefined) {
362        return { text: `Listing ${await listCount($)} prompts. Change it with /${COMMAND} count <1-${LISTED_MAX}>.` }
363      }
364      const count = Number(value)
365      if (extra.length > 0 || !Number.isInteger(count) || count < 1 || count > LISTED_MAX) {
366        return { text: `The count is a whole number from 1 to ${LISTED_MAX}.` }
367      }
368      await $.store.set('listCount', count)
369      debug($, `/${COMMAND} count ${count}`)
370      return { text: `Listing ${count} prompts from now on.` }
371    }
372    if (word === 'order') {
373      const describe = (order: Order) => (order === 'chron' ? 'newest last' : 'newest first')
374      if (value === undefined) {
375        const order = await listOrder($)
376        return { text: `Listing ${order} (${describe(order)}). Change it with /${COMMAND} order chron|reverse.` }
377      }
378      const order = ORDERS.find(one => one === value)
379      if (order === undefined || extra.length > 0) {
380        return { text: `The order is chron (newest last) or reverse (newest first).` }
381      }
382      await $.store.set('listOrder', order)
383      debug($, `/${COMMAND} order ${order}`)
384      return { text: `Listing ${order} (${describe(order)}) from now on.` }
385    }
386    if (word === 'greeting') {
387      if (value === undefined) {
388        const state = (await isGreetingOn($)) ? 'on' : 'off'
389        return { text: `The startup greeting is ${state}. Change it with /${COMMAND} greeting on|off.` }
390      }
391      if ((value !== 'on' && value !== 'off') || extra.length > 0) {
392        return { text: 'The greeting is on or off.' }
393      }
394      await $.store.set('greeting', value === 'on')
395      debug($, `/${COMMAND} greeting ${value}`)
396      return { text: `The startup greeting is ${value}.` }
397    }
398    if (arg === 'last') {
399      return { text: await lastSession($) }
400    }
401    if (arg === 'status') {
402      const list = await read($, prompts)
403      const now = await $.clock.now()
404      const lines = [
405        `prompts known: ${list.length}; cursor: ${await read($, cursor) ?? 'none'}; listing: ${await listCount($)}, ${await listOrder($)}; greeting: ${(await isGreetingOn($)) ? 'on' : 'off'}`,
406        `transcript: ${await read($, transcriptPath) ?? 'not seen yet'}`,
407        `backfilled from: ${await read($, backfilledFrom) ?? 'never'}`,
408        `fullscreen: ${e.presentation.isFullscreen}; utc offset: ${-new Date().getTimezoneOffset()} min`,
409        ...list.slice(-5).map(prompt => `  ${formatStamp(prompt.at, now)}  ${prompt.uuid}`),
410        'recent log:',
411        ...recent.map(line => `  ${line}`),
412      ]
413      return { text: lines.join('\n') }
414    }
415    if (arg === '') {
416      const list = await read($, prompts)
417      const listed = await listCount($)
418      const order = await listOrder($)
419      const recentPrompts = list.slice(-listed)
420      debug($, `/${COMMAND}: listed ${recentPrompts.length} of ${list.length} prompts, ${order}`)
421      return { text: listText(order === 'reverse' ? recentPrompts.reverse() : recentPrompts) }
422    }
423    const moves: Record<string, Move> = { up: 'older', down: 'newer', newest: 'last' }
424    const move = moves[arg]
425    if (move === undefined) {
426      return { text: USAGE }
427    }
428    debug($, `/${COMMAND} ${arg}`)
429    await jump($, move)
430
431    return {}
432  })
433
434  // Draws the bare command's listing with each line a button that jumps to
435  // its prompt. The stored text stays plain; each line is matched back to its
436  // prompt by its second and preview, as the row carries no ids.
437  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
438    const lines = e.props.args.trim() === '' ? parseListing(e.props.text) : []
439    if (lines.length === 0) {
440      return next(e)
441    }
442    const list = await read($, prompts)
443    const width = Math.max((e.viewport?.columns ?? 80) - 4, 20)
444    const { Box, Button, Text } = $.ui.resolve(e)
445
446    return (
447      <Box flexDirection="column">
448        {lines.map((line, index) => {
449          const target = findListed(list, line)
450          if (target === undefined) {
451            return <Text dimColor>{truncate(`${line.stamp}  ${line.preview}`, width)}</Text>
452          }
453          const label = truncate(`${fullStamp(target.at)}  ${target.preview}`, width)
454          const { uuid } = target
455          return <Button key={`prompt-${index}`} plain label={label} onPress={() => jumpTo($, uuid)} />
456        })}
457      </Box>
458    )
459  })
460
461  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
462    const { origin, from, task } = e.props
463    if (!PERSON.has(origin.kind) || from !== undefined || task !== undefined) {
464      return next(e)
465    }
466    const at = await read($, memberOf(stamp, e))
467    if (at === null) {
468      // `placeholder` is the row the engine draws while a prompt is still being
469      // submitted, before it has an id; it is replaced by the stamped row.
470      if (e.requestId !== 'placeholder' && !unstamped.has(e.requestId)) {
471        unstamped.add(e.requestId)
472        debug($, `no stamp for prompt row ${e.requestId}; drawing the engine's row`)
473      }
474      return next(e)
475    }
476    const { Box, Text } = $.ui.resolve(e)
477    const label = formatStamp(at, await $.clock.now())
478
479    return (
480      <Box flexDirection="row" marginTop={1} paddingRight={1} backgroundColor="userMessageBackground">
481        <Box flexShrink={0}>
482          <Text color="inactive">
483            {label} {POINTER}{' '}
484          </Text>
485        </Box>
486        <Box flexGrow={1} flexShrink={1}>
487          <Text color="text">{e.props.text}</Text>
488        </Box>
489      </Box>
490    )
491  })
492
493  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
494    const list = await read($, prompts)
495    const engine = await next(e)
496    if (list.length === 0) {
497      return engine
498    }
499    const at = await read($, cursor)
500    const { Box, Button, Text } = $.ui.resolve(e)
501
502    return (
503      <Box flexDirection="row">
504        {engine}
505        <Box flexShrink={0} marginLeft={2}>
506          <Button key="older" plain label={'↑'} dimColor onPress={() => jump($, 'older')} />
507          <Text> </Text>
508          <Button key="newer" plain label={'↓'} dimColor onPress={() => jump($, 'newer')} />
509          {at !== null && <Text dimColor> {`${at + 1}/${list.length}`}</Text>}
510        </Box>
511      </Box>
512    )
513  })
514}
515
hooks/prompts.ts 265 lines
1import type { Prompt } from '../types'
2
3/**
4 * Fixed strings that only appear on the transcript lines that can be prompts
5 * (quotes inside JSON string values are escaped, so tool output never matches).
6 * grep pre-filters with these so a large transcript never crosses into the
7 * plugin whole; `promptFromLine` makes the real decision.
8 */
9export const TRANSCRIPT_MARKERS = [
10  '"type":"user","message":{"role":"user","content":"',
11  '"type":"user","message":{"role":"user","content":[{"type":"text"',
12  '"attachment":{"type":"queued_command"',
13] as const
14
15/** How much of a prompt's text is kept for the list (one line, whitespace collapsed). */
16export const PREVIEW_LIMIT = 200
17
18/** How much of a prompt the stored list text (what the model reads) shows. */
19const LISTED_LIMIT = 60
20
21const pad = (n: number) => String(n).padStart(2, '0')
22
23const isSameDay = (a: Date, b: Date) =>
24  a.getFullYear() === b.getFullYear() &&
25  a.getMonth() === b.getMonth() &&
26  a.getDate() === b.getDate()
27
28/** `2026-10-02 18:04:11`, in local time. */
29export function fullStamp(at: number): string {
30  const sent = new Date(at)
31
32  return (
33    `${sent.getFullYear()}-${pad(sent.getMonth() + 1)}-${pad(sent.getDate())} ` +
34    `${pad(sent.getHours())}:${pad(sent.getMinutes())}:${pad(sent.getSeconds())}`
35  )
36}
37
38/** `12:21:00` for a prompt sent today, `2026-10-02 18:04:11` for an older one. */
39export function formatStamp(at: number, now: number): string {
40  const stamp = fullStamp(at)
41
42  return isSameDay(new Date(at), new Date(now)) ? stamp.slice(11) : stamp
43}
44
45/** `text` cut to `limit` characters, with an ellipsis when cut. */
46export function truncate(text: string, limit: number): string {
47  if (text.length <= limit) {
48    return text
49  }
50
51  return `${text.slice(0, Math.max(limit - 1, 0))}\u2026`
52}
53
54/** A prompt's text on one line, whitespace collapsed, cut to PREVIEW_LIMIT. */
55export function toPreview(text: string): string {
56  return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT)
57}
58
59/**
60 * The bare command's output as the transcript stores it and the model reads
61 * it: one `- [date time] preview` line per prompt, in the order given, full
62 * dates so a listing stays right on later days. `parseListing` reads it back.
63 */
64export function listText(recent: readonly Pick<Prompt, 'at' | 'preview'>[]): string {
65  if (recent.length === 0) {
66    return 'No prompts yet.'
67  }
68
69  return recent.map(prompt => `- [${fullStamp(prompt.at)}] ${truncate(prompt.preview, LISTED_LIMIT)}`).join('\n')
70}
71
72/** One line of a listing: its full stamp and the (possibly cut) preview. */
73export type ListedLine = { stamp: string; preview: string }
74
75/**
76 * The lines `listText` wrote; anything else in `text` is skipped. A first
77 * line may carry the engine's `<plugin>: ` prefix.
78 */
79export function parseListing(text: string): ListedLine[] {
80  return text.split('\n').flatMap(line => {
81    const match = /^(?:[\w-]+: )?- \[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\] (.*)$/.exec(line)
82    return match?.[1] !== undefined && match[2] !== undefined ? [{ stamp: match[1], preview: match[2] }] : []
83  })
84}
85
86/** The prompt a listing line names: same second, and its preview starts the same way. */
87export function findListed(prompts: readonly Prompt[], line: ListedLine): Prompt | undefined {
88  const head = line.preview.endsWith('\u2026') ? line.preview.slice(0, -1) : line.preview
89
90  return prompts.find(prompt => fullStamp(prompt.at) === line.stamp && prompt.preview.startsWith(head))
91}
92
93function firstText(content: unknown): string | null {
94  if (typeof content === 'string') {
95    return content
96  }
97  if (!Array.isArray(content)) {
98    return null
99  }
100  const first = content[0] as { type?: unknown; text?: unknown } | undefined
101
102  return first?.type === 'text' && typeof first.text === 'string' ? first.text : null
103}
104
105type TranscriptRow = {
106  type?: string
107  uuid?: string
108  timestamp?: string
109  isMeta?: boolean
110  isVisibleInTranscriptOnly?: boolean
111  isCompactSummary?: boolean
112  message?: { content?: unknown }
113  attachment?: { type?: string; prompt?: unknown; commandMode?: string; isMeta?: boolean }
114}
115
116/**
117 * The prompt a transcript line records, or null when it is no prompt of the
118 * person's. Mirrors Claude Code's own rule: a non-meta user row whose first
119 * block is text, or a queued prompt delivered mid-turn; text opening with `<`
120 * (command records, notifications) is not a prompt.
121 */
122export function promptFromLine(line: string): Prompt | null {
123  let row: TranscriptRow
124  try {
125    row = JSON.parse(line) as TranscriptRow
126  } catch {
127    return null
128  }
129
130  let text: string | null = null
131  if (row.type === 'user') {
132    if (row.isMeta || row.isVisibleInTranscriptOnly || row.isCompactSummary) {
133      return null
134    }
135    text = firstText(row.message?.content)
136  } else if (
137    row.type === 'attachment' &&
138    row.attachment?.type === 'queued_command' &&
139    row.attachment.commandMode !== 'task-notification' &&
140    !row.attachment.isMeta
141  ) {
142    text = firstText(row.attachment.prompt)
143  }
144
145  const trimmed = text?.trim() ?? ''
146  if (trimmed === '' || trimmed.startsWith('<')) {
147    return null
148  }
149  const at = Date.parse(row.timestamp ?? '')
150  if (typeof row.uuid !== 'string' || Number.isNaN(at)) {
151    return null
152  }
153
154  return { uuid: row.uuid, at, preview: toPreview(trimmed) }
155}
156
157/** `known` plus whichever of `found` it lacks, oldest first. */
158export function mergePrompts(known: readonly Prompt[], found: readonly Prompt[]): Prompt[] {
159  const ids = new Set(known.map(prompt => prompt.uuid))
160  const added = found.filter(prompt => !ids.has(prompt.uuid))
161  if (added.length === 0) {
162    return [...known]
163  }
164
165  return [...known, ...added].sort((a, b) => a.at - b.at)
166}
167
168/**
169 * Where a move lands in a list of `count` prompts from `cursor` (null: not
170 * navigating, i.e. just past the newest). Clamped to the ends.
171 */
172export function moveCursor(cursor: number | null, count: number, move: 'older' | 'newer' | 'last'): number {
173  const newest = count - 1
174  if (move === 'last') {
175    return newest
176  }
177  const from = cursor ?? count
178  const to = move === 'older' ? from - 1 : from + 1
179
180  return Math.min(Math.max(to, 0), newest)
181}
182
183/** The fixed string grep keeps a project's lines of `history.jsonl` by. */
184export const historyMarker = (project: string) => `"project":${JSON.stringify(project)}`
185
186/** The latest earlier session in a project, as Claude Code's prompt history recorded it. */
187export type PastSession = {
188  sessionId: string
189  /** Its prompts, oldest first: when each was sent and its text on one line. */
190  prompts: Pick<Prompt, 'at' | 'preview'>[]
191}
192
193type HistoryRow = { display?: unknown; timestamp?: unknown; project?: unknown; sessionId?: unknown }
194
195/**
196 * The most recent session other than `currentId` in `project`, from lines of
197 * `~/.claude/history.jsonl`. Slash commands and `!` shell commands are not
198 * prompts to Claude and are skipped. Only `display` is read: `pastedContents`
199 * can hold pasted secrets, and `display` already shows pastes as placeholders.
200 */
201export function previousSession(lines: readonly string[], project: string, currentId: string): PastSession | null {
202  const sessions = new Map<string, Pick<Prompt, 'at' | 'preview'>[]>()
203  for (const line of lines) {
204    let row: HistoryRow
205    try {
206      row = JSON.parse(line) as HistoryRow
207    } catch {
208      continue
209    }
210    const { display, timestamp, sessionId } = row
211    if (row.project !== project || typeof sessionId !== 'string' || sessionId === currentId) {
212      continue
213    }
214    if (typeof display !== 'string' || typeof timestamp !== 'number') {
215      continue
216    }
217    const text = display.trim()
218    if (text === '' || text.startsWith('/') || text.startsWith('!')) {
219      continue
220    }
221    const prompts = sessions.get(sessionId) ?? []
222    prompts.push({ at: timestamp, preview: toPreview(text) })
223    sessions.set(sessionId, prompts)
224  }
225
226  let latest: PastSession | null = null
227  let latestAt = -Infinity
228  for (const [sessionId, prompts] of sessions) {
229    prompts.sort((a, b) => a.at - b.at)
230    const endedAt = prompts.at(-1)?.at ?? -Infinity
231    if (endedAt > latestAt) {
232      latest = { sessionId, prompts }
233      latestAt = endedAt
234    }
235  }
236
237  return latest
238}
239
240/** `today 18:04`, `yesterday 18:04`, or `2026-09-28 18:04`, in local time. */
241export function describeWhen(at: number, now: number): string {
242  const time = fullStamp(at).slice(11, 16)
243  const sent = new Date(at)
244  const today = new Date(now)
245  const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
246  if (isSameDay(sent, today)) {
247    return `today ${time}`
248  }
249  if (isSameDay(sent, yesterday)) {
250    return `yesterday ${time}`
251  }
252
253  return fullStamp(at).slice(0, 16)
254}
255
256/** The startup greeting: when the project's last session ended and its final prompt. */
257export function greetingText(past: PastSession, now: number): string | null {
258  const last = past.prompts.at(-1)
259  if (last === undefined) {
260    return null
261  }
262
263  return `Last here ${describeWhen(last.at, now)}: “${truncate(last.preview, LISTED_LIMIT)}” · /where-were-we last`
264}
265
types/index.d.ts 26 lines
1/**
2 * One of the person's prompts: its transcript row id, when it was sent (epoch
3 * ms), and its text on one line, cut to PREVIEW_LIMIT characters.
4 */
5export type Prompt = { uuid: string; at: number; preview: string }
6
7/** Which way the footer arrows and /where-were-we move through the prompts. */
8export type Move = 'older' | 'newer' | 'last'
9
10declare module 'claude-code' {
11  interface PluginState {
12    'where-were-we': {
13      /** Every known prompt of this session, oldest first. */
14      prompts: Shaped<Prompt[]>
15      /** Index into `prompts` while navigating; null when not navigating. */
16      cursor: number | null
17      /** Each prompt row's timestamp, keyed by the row's id. */
18      stamp: StateFamily<number | null>
19      /** The session's transcript file, as the classic hook events report it. */
20      transcriptPath: string | null
21      /** The transcript the prompts were last backfilled from. */
22      backfilledFrom: string | null
23    }
24  }
25}
26