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

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.
/plugin marketplace add scarrillo/agent-plugins
/plugin install where-were-we@scarrillo
12:21:00 ❯ my prompt for today, 2026-10-02 18:04:11 ❯ … for older prompts. Wrapped lines stay aligned under the prompt text.↑ ↓ 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.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.
A mod runs with your permissions, so here is everything this one touches:
~/.claude/projects/<project>/<session>.jsonl), filtered with grep to the lines that can be your prompts, to recover their times.~/.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.~/.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.
hooks/register.tsx 515 lines1import { 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}
515hooks/prompts.ts 265 lines1import 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}
265types/index.d.ts 26 lines1/**
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