SLOPSHOPPER

prompt-timeline

Ampcode-style timeline of your prompts this session, above the prompt at the bottom right: press its toggle or run /timeline; press a row to jump to it

newbandcommandtoastpromptprocess
v0.1.2no licenseupdated 2026-10-19Tyru5/claude-timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-timeline
› 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 › /timeline ⎿ prompt-timeline: Prompt timeline opened. ╭──────────────────────────────────────────────────────────────────────╮ │ │ │ fix the failing auth test and add an audit log call now │ ╰──────────────────────────────────────────────────────────────────────╯ × 1 prompts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────╮ │ │ │ fix the failing auth test and add an audit log call now │ ╰──────────────────────────────────────────────────────────────────────╯ × 1 prompts
README

prompt-timeline

A Claude Code mod that shows a timeline of your prompts in the current session, in the style of ampcode's prompt history.

  • A ≡ N prompts toggle sits at the bottom right, just above the prompt box. Press it, or run /timeline, to open the list.
  • The list is a small box above the toggle, showing 8 prompts at a time with ages like 11m ago or 3d ago. The newest prompt is brighter. ▲ N earlier and ▼ N newer page through older and newer prompts.
  • Pressing a prompt scrolls the conversation to it (in fullscreen, where the transcript scrolls).

Prompts and their timestamps come from the session's transcript, so a resumed session lists the prompts it had before. Transcripts over 4 MiB are streamed through a small Node.js helper (node must be on PATH), returning the newest prompt previews within a 48 KiB budget, up to 500 prompts. If history cannot be loaded, the timeline still opens and shows an error alongside any live prompts.

Install

At the prompt of a terminal session:

/plugin install prompt-timeline --marketplace Tyru5/claude-timeline

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install prompt-timeline --marketplace Tyru5/claude-timeline

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Tyru5/claude-timeline, then Add plugin.

Develop

claude plugin validate .
claude plugin test .
node --test hooks/read-transcript.test.mjs
claude --plugin-dir .
Source 3 files
hooks/register.tsx 269 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import { MAX, cleanPrompt, parseTranscript } from './transcript.mjs'
6
7export { cleanPrompt, parseTranscript } from './transcript.mjs'
8
9const COMMAND = 'timeline'
10const TICK_MS = 30_000
11// Popover size: rows of prompts shown at once, and its widest.
12const WINDOW = 8
13const MAX_WIDTH = 72
14
15const prompts = atom({ plugin: 'prompt-timeline', key: 'prompts' } as const, [])
16const now = atom({ plugin: 'prompt-timeline', key: 'now' } as const, 0)
17// The transcript file found for the session, kept across reloads.
18const transcript = atom({ plugin: 'prompt-timeline', key: 'transcript' } as const, '')
19const isOpen = atom({ plugin: 'prompt-timeline', key: 'isOpen' } as const, false)
20// How many of the newest prompts the window has paged back past.
21const back = atom({ plugin: 'prompt-timeline', key: 'back' } as const, 0)
22const loadError = atom({ plugin: 'prompt-timeline', key: 'loadError' } as const, '')
23
24let ticker: { cancel: () => void } | undefined
25
26/** "3d ago" style age of `at` relative to `nowMs`. */
27export function ago(at: number, nowMs: number): string {
28  const m = Math.floor(Math.max(0, nowMs - at) / 60_000)
29  if (m < 1) return 'now'
30  if (m < 60) return `${m}m ago`
31  const h = Math.floor(m / 60)
32  if (h < 24) return `${h}h ago`
33  const d = Math.floor(h / 24)
34  if (d < 30) return `${d}d ago`
35  return `${Math.floor(d / 30)}mo ago`
36}
37
38/** First line, whitespace collapsed, cut to `width` cells with an ellipsis. */
39export function clip(text: string, width: number): string {
40  const line = text.replace(/\s+/g, ' ').trim()
41  return line.length <= width ? line : `${line.slice(0, Math.max(1, width - 1))}…`
42}
43
44async function tick($: EngineInterface) {
45  const t = await $.clock.now()
46  await update($, now, () => t)
47}
48
49const projectDir = (cwd: string) => cwd.replace(/[^a-zA-Z0-9]/g, '-')
50
51// The transcript's folder is named for the directory the session started in,
52// which a mod loaded mid-session cannot read off `e.cwd` (a shell `cd` moves
53// it): try the likely folders, then every one. Null until the file exists.
54async function findTranscript($: EngineInterface): Promise<string | null> {
55  const file = `${await $.session.id()}.jsonl`
56  const known = await read($, transcript)
57  if (known.endsWith(`/${file}`)) return known
58
59  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
60  const projects = `${dir}/projects`
61  const guesses = [await $.session.root(), await $.session.cwd()].map(
62    cwd => `${projects}/${projectDir(cwd)}/${file}`,
63  )
64  let path: string | undefined
65  for (const guess of guesses) {
66    if (await $.fs.exists(guess)) {
67      path = guess
68      break
69    }
70  }
71  if (!path) {
72    const all = (await $.fs.list(projects))
73      .filter(entry => entry.kind === 'dir')
74      .map(entry => `${projects}/${entry.name}/${file}`)
75    const found = await Promise.all(all.map(p => $.fs.exists(p)))
76    path = all[found.indexOf(true)]
77  }
78  if (!path) return null
79  await update($, transcript, () => path)
80  return path
81}
82
83// The SDK caps fs.read at 4 MiB. Stream oversized transcripts in a host
84// process and return only bounded prompt previews, retaining row ids and ages.
85async function readTranscript($: EngineInterface, path: string): Promise<Entry[]> {
86  try {
87    return parseTranscript(await $.fs.read(path))
88  } catch (error) {
89    // Do not retry permission denials or other filesystem errors as a process.
90    if (!/4\s*(?:MiB|MB)|4194304|too large|size limit/i.test(String(error))) throw error
91    const result = await $.process.run(
92      ['node', `${$.plugin.root}/hooks/read-transcript.mjs`, path],
93      { timeoutMs: 5000 },
94    )
95    if (result.exitCode !== 0) throw new Error(result.stderr || 'Transcript reader failed')
96    return JSON.parse(result.stdout)
97  }
98}
99
100async function reload($: EngineInterface) {
101  try {
102    const path = await findTranscript($)
103    if (!path) {
104      await update($, loadError, () => '')
105      await tick($)
106      return
107    }
108    const list = await readTranscript($, path)
109    if (list.length > 0) await update($, prompts, () => list)
110    await update($, loadError, () => '')
111  } catch (error) {
112    // Keep live prompts, but explain why resumed history could not be read.
113    await update($, loadError, () => `Could not load prompt history: ${String(error)}`)
114  }
115  await tick($)
116}
117
118// A transcript row moves only for the person's own input, so this runs from a press.
119async function jump($: EngineInterface, entry: Entry) {
120  if (!entry.id) {
121    $.ui.toast('That prompt is not in the transcript yet.')
122    return
123  }
124  const { deny } = await $.ui
125    .scroll({ to: { requestId: entry.id }, block: 'start' })
126    .catch((error: unknown) => ({ deny: String(error) }))
127  if (deny) $.ui.toast(`Cannot jump to that prompt: ${deny}`)
128}
129
130async function setOpen($: EngineInterface, open: boolean) {
131  ticker?.cancel()
132  ticker = undefined
133  if (open) {
134    await reload($)
135    await update($, back, () => 0)
136    ticker = $.clock.every(TICK_MS, () => tick($))
137  }
138  await update($, isOpen, () => open)
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: COMMAND,
145      description: 'Toggle the timeline of your prompts this session',
146      immediate: true,
147    })
148    await reload($)
149    // A hot reload drops the ticker; bring it back for a popover left open.
150    if (await read($, isOpen)) await setOpen($, true)
151
152    return next(e)
153  })
154
155  on('command.run', { command: COMMAND }, async $ => {
156    const open = !(await read($, isOpen))
157    await setOpen($, open)
158
159    return { text: open ? 'Prompt timeline opened.' : 'Prompt timeline closed.' }
160  })
161
162  on('prompt.submit', async ($, e, next) => {
163    // Never let bookkeeping hold up a prompt.
164    if (!e.isMeta && e.command?.name !== COMMAND) {
165      try {
166        const text = cleanPrompt(e.text) ?? e.text.trim()
167        const at = await $.clock.now()
168        if (text) await update($, prompts, list => [...list, { text, at }].slice(-MAX))
169        await update($, now, () => at)
170        await update($, back, () => 0)
171      } catch {
172        // drop this one; the next reload picks it up from the transcript
173      }
174    }
175
176    const entered = await next(e)
177    // The prompt is in the transcript now: pick up its row id for jumping.
178    if (!e.isMeta) await reload($).catch(() => undefined)
179
180    return entered
181  })
182
183  on('turn.complete', async ($, e, next) => {
184    if (e.agentId === undefined) await reload($)
185
186    return next(e)
187  })
188
189  // amp-style: a toggle at the bottom right of the band, the popover above it.
190  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
191    const list = await read($, prompts)
192    if (e.props.hasSurvey) return next(e)
193
194    const { Box, Button, Text } = $.ui.resolve(e)
195    const open = await read($, isOpen)
196    // Rows, not alignItems: a column does not carry a Button to its right edge.
197    const toggle = (
198      <Box key="toggle-row" flexDirection="row" justifyContent="flex-end">
199        <Button
200          key="toggle"
201          plain
202          dimColor={!open}
203          label={open ? `× ${list.length} prompts` : `≡ ${list.length} prompts`}
204          onPress={() => setOpen($, !open)}
205        />
206      </Box>
207    )
208    if (!open) return toggle
209
210    const t = await read($, now)
211    const skipped = Math.min(await read($, back), Math.max(0, list.length - WINDOW))
212    const end = list.length - skipped
213    const start = Math.max(0, end - WINDOW)
214    const width = Math.min(MAX_WIDTH, e.props.bodyColumns)
215
216    return (
217      <Box flexDirection="column">
218        <Box flexDirection="row" justifyContent="flex-end">
219          <Box flexDirection="column" width={width} borderStyle="round" borderDimColor paddingX={1}>
220            {(await read($, loadError)) && <Text>{clip(await read($, loadError), width - 4)}</Text>}
221            {list.length === 0 && <Text>No prompts loaded yet.</Text>}
222            {start > 0 && (
223              <Button
224                key="older"
225                plain
226                dimColor
227                label={`▲ ${start} earlier`}
228                onPress={() => update($, back, n => Math.min(n + WINDOW, list.length - WINDOW))}
229              />
230            )}
231            {list.slice(start, end).map((entry, j) => {
232              const i = start + j
233              const isLatest = i === list.length - 1
234              const when = entry.at === null ? '' : ago(entry.at, t)
235              // border and padding take four cells, the gap before the age one
236              const room = Math.max(8, width - 4 - when.length - 1)
237
238              return (
239                <Box key={`row:${i}`} flexDirection="row" justifyContent="space-between">
240                  <Button
241                    key={`prompt:${i}`}
242                    plain
243                    label={clip(entry.text, room)}
244                    dimColor={!isLatest}
245                    onPress={() => jump($, entry)}
246                  />
247                  <Text dimColor={!isLatest} bold={isLatest}>
248                    {when}
249                  </Text>
250                </Box>
251              )
252            })}
253            {skipped > 0 && (
254              <Button
255                key="newer"
256                plain
257                dimColor
258                label={`▼ ${skipped} newer`}
259                onPress={() => update($, back, n => Math.max(0, n - WINDOW))}
260              />
261            )}
262          </Box>
263        </Box>
264        {toggle}
265      </Box>
266    )
267  })
268}
269
hooks/transcript.mjs 42 lines
1export const MAX = 500
2
3// Wrappers added by the engine, rather than typed by the person.
4const NOISE =
5  /<(system-reminder|local-command-[a-z]+|bash-std(?:out|err)|command-message)>[\s\S]*?<\/\1>/g
6
7export function cleanPrompt(raw) {
8  const name = raw.match(/<command-name>([\s\S]*?)<\/command-name>/)?.[1]
9  if (name !== undefined) {
10    const args = raw.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1] ?? ''
11    return `${name.trim()} ${args.trim()}`.trim() || null
12  }
13  const bash = raw.match(/<bash-input>([\s\S]*?)<\/bash-input>/)?.[1]
14  if (bash !== undefined) return `! ${bash.trim()}`
15  const text = raw.replace(NOISE, '').trim()
16  if (!text || text.startsWith('[Request interrupted') || text.startsWith('Caveat:')) return null
17  return text
18}
19
20export function parseTranscript(jsonl) {
21  const out = []
22  for (const line of jsonl.split('\n')) {
23    let row
24    try {
25      row = JSON.parse(line)
26    } catch {
27      continue
28    }
29    if (row?.type !== 'user' || row.isMeta || row.isSidechain || row.isCompactSummary) continue
30    const content = row.message?.content
31    const blocks = typeof content === 'string'
32      ? [{ type: 'text', text: content }]
33      : Array.isArray(content) ? content : []
34    if (blocks.some(b => b?.type === 'tool_result')) continue
35    const text = cleanPrompt(blocks.filter(b => b?.type === 'text').map(b => b.text).join('\n'))
36    if (!text || text === '/timeline' || text.startsWith('/timeline ')) continue
37    const at = Date.parse(row.timestamp)
38    out.push({ text, at: Number.isNaN(at) ? null : at, id: row.uuid })
39  }
40  return out.slice(-MAX)
41}
42
types/index.d.ts 19 lines
1/**
2 * One prompt the person sent; `at` is ms since the epoch, null when unknown;
3 * `id` is its transcript row's uuid, absent until the transcript is read.
4 */
5export type Entry = { text: string; at: number | null; id?: string }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'prompt-timeline': {
10      prompts: Entry[]
11      now: number
12      transcript: string
13      isOpen: boolean
14      back: number
15      loadError: string
16    }
17  }
18}
19