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

A Claude Code mod that shows a timeline of your prompts in the current session, in the style of ampcode's prompt history.
≡ N prompts toggle sits at the bottom right, just above the prompt box. Press it, or run /timeline, to open the list.11m ago or 3d ago. The newest prompt is brighter. ▲ N earlier and ▼ N newer page through older and newer prompts.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.
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.
claude plugin validate .
claude plugin test .
node --test hooks/read-transcript.test.mjs
claude --plugin-dir .hooks/register.tsx 269 lines1import { 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}
269hooks/transcript.mjs 42 lines1export 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}
42types/index.d.ts 19 lines1/**
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