A side pane listing every prompt in the session: see where you are, click to jump back

Three small mods for Claude Code. Install the ones you want.
claude plugin marketplace add GururOzatmaca/claude-mods
claude plugin install prompt-boost@claude-mods
claude plugin install reply-tools@claude-mods
claude plugin install jump-list@claude-mods
Needs Claude Code 2.1.287 or newer. If the install says options not yet set, ignore it; the defaults work.
Add ++ to a messy prompt. It gets rewritten in clear English and put back in the box. You check it, then press Enter.

++ at the start or end. ++o uses Opus, ++q adds open questions./ap undo brings back your original.A copy button on every code block, and a tl;dr button under long replies.

⧉ copy all copies several shell commands at once.tl;dr keeps every step and warning. ↑ original switches back./plugin configure reply-tools@claude-mods.A side list of every prompt in the session, with its time. Click one to jump back.
<img src="docs/images/jump-list-pane.png" alt="Jump list" width="232">
↓ end goes to the bottom. – folds it into one line./jumps.Mods run with your permissions. prompt-boost and tl;dr make one model call each time you use them. jump-list reads the session's transcript file. Run claude plugin validate <folder> to see exactly what a mod does.
MIT license.
hooks/register.tsx 284 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { addPrompt, type Entry, isPrompt, jumpTarget, label, mark, seeRow } from './entries'
4import { createParser, parseTranscript, projectDirName, textOf, type TranscriptRows } from './transcript'
5
6const PANE = 'jump-list'
7const PANE_COLUMNS = 36
8const REDRAW_DELAY_MS = 60
9const SETTLE_TRIES = 4
10const RESEED_DELAY_MS = 2000
11const RESEED_MIN_GAP_MS = 30000
12const WALK_SETTLE_MS = 150
13const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
14
15const entries: Entry[] = []
16const volatileIds = new Set<string>()
17const checkedRows = new Set<string>()
18const matchedRows = new Set<string>()
19let endId: string | undefined
20let endLineId: string | undefined
21let isAwaitingEndLine = true
22const seenEndLines = new Set<string>()
23let historySource = 'not loaded'
24let isCollapsed = false
25let isReseating = false
26let lastPath: string | undefined
27let lastSeedAt = 0
28let pendingReseed: Timer | undefined
29
30function scheduleReseed($: EngineInterface) {
31 if (pendingReseed !== undefined) return
32 pendingReseed = $.clock.after(RESEED_DELAY_MS, () => {
33 pendingReseed = undefined
34 void reseedIfStale($)
35 })
36}
37
38async function reseedIfStale($: EngineInterface) {
39 if ((await $.clock.now()) - lastSeedAt < RESEED_MIN_GAP_MS) return
40 await seed($, lastPath)
41}
42
43async function setCollapsed($: EngineInterface, collapsed: boolean) {
44 isCollapsed = collapsed
45 isReseating = true
46 if (collapsed) await $.ui.close({ id: PANE })
47 else await $.ui.open({ id: PANE, title: 'Jumps', columns: PANE_COLUMNS })
48 isReseating = false
49 $.ui.invalidate('ui.render')
50}
51let pendingRedraw: Timer | undefined
52
53function scheduleRedraw($: EngineInterface) {
54 if (pendingRedraw !== undefined) return
55 pendingRedraw = $.clock.after(REDRAW_DELAY_MS, () => {
56 pendingRedraw = undefined
57 $.ui.invalidate('ui.render')
58 })
59}
60
61function nearestKnown(index: number): { id: string; isAfter: boolean } | undefined {
62 for (let distance = 1; distance < entries.length; distance += 1) {
63 const after = entries[index + distance]
64 if (after?.rowId !== undefined) return { id: after.rowId, isAfter: true }
65 const before = entries[index - distance]
66 if (before?.rowId !== undefined) return { id: before.rowId, isAfter: false }
67 }
68 return undefined
69}
70
71async function jump($: EngineInterface, index: number) {
72 const target = entries[index]
73 if (target === undefined) return
74 if (target.rowId !== undefined) {
75 await $.ui.scroll({ to: { requestId: target.rowId }, block: 'start' })
76 return
77 }
78 const rough = target.uuid ?? nearestKnown(index)?.id
79 if (rough === undefined) {
80 $.ui.toast('Scroll up once so this prompt is drawn, then click again')
81 return
82 }
83 const isAfter = target.uuid === undefined && nearestKnown(index)?.isAfter === true
84 await $.ui.scroll({ to: { requestId: rough }, block: isAfter ? 'end' : 'start' })
85 for (let wait = 0; wait < SETTLE_TRIES; wait += 1) {
86 await $.clock.sleep(WALK_SETTLE_MS)
87 if (target.rowId !== undefined) {
88 await $.ui.scroll({ to: { requestId: target.rowId }, block: 'start' })
89 return
90 }
91 }
92 $.ui.toast('Getting closer: click again to land exactly')
93}
94
95async function jumpToEnd($: EngineInterface) {
96 const target = endLineId ?? endId
97 if (target === undefined) {
98 $.ui.toast('No answer yet to jump to')
99 return
100 }
101 await $.ui.scroll({ to: { requestId: target }, block: 'end' })
102}
103
104async function findTranscript($: EngineInterface): Promise<string | undefined> {
105 const id = await $.session.id()
106 const home = await $.env.get('HOME')
107 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? undefined : `${home}/.claude`)
108 if (configDir === undefined) return undefined
109 const projects = `${configDir}/projects`
110 for (const dir of [await $.session.cwd(), await $.session.root()]) {
111 const path = `${projects}/${projectDirName(dir)}/${id}.jsonl`
112 if (await $.fs.exists(path)) return path
113 }
114 for (const entry of await $.fs.list(projects)) {
115 const path = `${projects}/${entry.name}/${id}.jsonl`
116 if (entry.kind === 'dir' && (await $.fs.exists(path))) return path
117 }
118 return undefined
119}
120
121async function readRows($: EngineInterface, path: string): Promise<{ rows: TranscriptRows; how: string }> {
122 try {
123 return { rows: parseTranscript(await $.fs.read(path), isPrompt), how: 'read' }
124 } catch {
125 const parser = createParser(isPrompt)
126 for await (const chunk of $.process.spawn({ argv: ['cat', path] })) {
127 if (chunk.stream === 'stdout') parser.push(chunk.text)
128 }
129 return { rows: parser.finish(), how: 'streamed' }
130 }
131}
132
133async function seed($: EngineInterface, knownPath?: string) {
134 const fresh: Entry[] = []
135 let freshEndId: string | undefined
136 let source: string
137 lastSeedAt = await $.clock.now()
138 try {
139 const path = knownPath ?? (await findTranscript($))
140 if (path === undefined) throw new Error('transcript not found')
141 lastPath = path
142 const { rows, how } = await readRows($, path)
143 for (const prompt of rows.prompts) addPrompt(fresh, prompt.text, prompt.uuid, prompt.at)
144 freshEndId = rows.lastAnswer
145 source = `transcript (${how}), ${rows.prompts.length} prompts`
146 } catch (error) {
147 for (const message of await $.session.messages()) {
148 if (message.role === 'user') addPrompt(fresh, message.text)
149 }
150 source = `history without ids (${String(error).slice(0, 60)})`
151 }
152 entries.splice(0, entries.length, ...fresh)
153 endId = freshEndId
154 endLineId = undefined
155 isAwaitingEndLine = true
156 historySource = source
157 scheduleRedraw($)
158}
159
160export const register: Register = (on, options) => {
161 const openOnStart = options.open_on_start !== false
162
163 on('session.start', async ($, e, next) => {
164 await $.command.register({ name: 'jumps', description: 'Open the jump list: every prompt in this session, click to jump' })
165 await seed($)
166 if (openOnStart) void $.ui.open({ id: PANE, title: 'Jumps', columns: PANE_COLUMNS })
167 return next(e)
168 })
169
170 on('command.run', { command: 'jumps' }, async $ => {
171 isCollapsed = false
172 await seed($, lastPath)
173 const opened = await $.ui.open({ id: PANE, title: 'Jumps', columns: PANE_COLUMNS })
174 const status = opened.isPlaced ? 'Jump list opened.' : `Jump list could not open: ${opened.reason}`
175 return { text: `${status}\nHistory: ${historySource}. Row ids matching transcript ids: ${matchedRows.size}/${checkedRows.size}.` }
176 })
177
178 on('session.append', async ($, e, next) => {
179 const result = await next(e)
180 if (e.agentId === undefined && result.uuid !== undefined) {
181 const text = textOf(result.message.content)
182 if (e.door === 'prompt' && e.message.isMeta !== true && addPrompt(entries, text, result.uuid, await $.clock.now())) scheduleRedraw($)
183 if (e.door === 'response' && text.trim() !== '') endId = result.uuid
184 }
185 return result
186 }).catch(($, e, next) => next(e))
187
188 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
189 const id = e.requestId
190 if (id !== undefined && !volatileIds.has(id) && e.props.origin.kind === 'composer') {
191 if (isPrompt(e.props.text)) {
192 checkedRows.add(id)
193 if (entries.some(entry => entry.uuid === id)) matchedRows.add(id)
194 }
195 const isVisible = e.props.onScreen === undefined ? undefined : e.props.onScreen !== null
196 const result = seeRow(entries, id, e.props.text, isVisible)
197 if (result === 'volatile') {
198 volatileIds.add(id)
199 checkedRows.delete(id)
200 matchedRows.delete(id)
201 }
202 if (result === 'unknown' && isPrompt(e.props.text)) scheduleReseed($)
203 if (result !== 'unchanged' && result !== 'unknown') scheduleRedraw($)
204 }
205 return next(e)
206 })
207
208 on('classic.SessionStart', async ($, e, next) => {
209 const result = await next(e)
210 await seed($, e.transcript_path)
211 return result
212 }).catch(($, e, next) => next(e))
213
214 on('ui.close', async ($, e, next) => {
215 const result = await next(e)
216 if (e.id === PANE && e.origin.kind === 'person' && !isReseating) $.ui.toast('Jump list closed. Type /jumps to open it again')
217 return result
218 }).catch(($, e, next) => next(e))
219
220 on('turn.complete', async ($, e, next) => {
221 if (e.agentId === undefined) isAwaitingEndLine = true
222 return next(e)
223 })
224
225 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
226 const id = e.requestId
227 if (id !== undefined && !seenEndLines.has(id)) {
228 seenEndLines.add(id)
229 if (isAwaitingEndLine) {
230 endLineId = id
231 isAwaitingEndLine = false
232 }
233 }
234 return next(e)
235 })
236
237 on('tool.call', async ($, e, next) => {
238 const result = await next(e)
239 const tool = String(e.tool)
240 const changed =
241 (EDIT_TOOLS.has(tool) && result.isError !== true && mark(entries, 'edit')) ||
242 (tool === 'Bash' && result.isError === true && mark(entries, 'fail'))
243 if (changed) scheduleRedraw($)
244 return result
245 }).catch(($, e, next) => next(e))
246
247 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248 if (!isCollapsed || e.props.hasSurvey) return next(e)
249 const { Button } = $.ui.resolve(e)
250 return <Button key="expand" label={`› Jumps (${entries.length})`} plain dimColor onPress={() => void setCollapsed($, false)} />
251 })
252
253 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
254 const { Box, Button, Text } = $.ui.resolve(e)
255 const width = Math.max(12, e.props.bodyColumns)
256 const isFullscreen = e.viewport?.isFullscreen
257 const rows = entries.map((entry, i) => (
258 <Button
259 key={`jump-${i}`}
260 label={label(entry, width)}
261 plain
262 dimColor={entry.isVisible !== true}
263 onPress={() => void jump($, i)}
264 />
265 ))
266 if (entries.length > 0) rows.push(<Button key="jump-end" label="↓ end" plain onPress={() => void jumpToEnd($)} />)
267 const pinAt = Math.min(Math.max(0, e.props.scroll.offset), rows.length)
268
269 return (
270 <Box flexDirection="column">
271 {rows.slice(0, pinAt)}
272 <Box key="header" flexDirection="column">
273 <Box flexDirection="row" justifyContent="flex-end" paddingRight={1}>
274 <Button key="collapse" label="–" plain dimColor onPress={() => void setCollapsed($, true)} />
275 </Box>
276 {isFullscreen === false && <Text dimColor>Not fullscreen: no position or jumps.</Text>}
277 {entries.length === 0 && <Text dimColor>No prompts yet.</Text>}
278 </Box>
279 {rows.slice(pinAt)}
280 </Box>
281 )
282 })
283}
284hooks/entries.ts 90 lines1export type Mark = 'edit' | 'fail'
2
3export type Entry = { text: string; uuid?: string; rowId?: string; at?: number; marks: Set<Mark>; isVisible: boolean | undefined }
4
5export function clockTime(at: number | undefined): string {
6 if (at === undefined) return '--:--'
7 const d = new Date(at)
8 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
9}
10
11const MARK_GLYPH: Record<Mark, string> = { edit: '✎', fail: '✗' }
12
13const PASTE_TAG = /<\/?pasted_content(?:\s[^>]*)?>/g
14
15export function unwrap(text: string): string {
16 return text.replace(PASTE_TAG, '')
17}
18
19export function preview(text: string): string {
20 return unwrap(text).replace(/\s+/g, ' ').trim()
21}
22
23export function isPrompt(text: string): boolean {
24 const clean = unwrap(text).trim()
25 return clean !== '' && !clean.startsWith('/') && !clean.startsWith('<') && !clean.startsWith('[Request interrupted')
26}
27
28export function label(entry: Entry, width: number): string {
29 const marks = [...entry.marks].map(m => MARK_GLYPH[m]).join('')
30 const time = entry.at === undefined ? '' : clockTime(entry.at)
31 const head = [time, marks].filter(part => part !== '').join(' ') + (time === '' && marks === '' ? '' : ' ')
32 const room = Math.max(4, width - head.length)
33 const body = preview(entry.text)
34 return head + (body.length > room ? `${body.slice(0, room - 1)}…` : body)
35}
36
37export function addPrompt(entries: Entry[], text: string, uuid?: string, at?: number): boolean {
38 if (!isPrompt(text)) return false
39 entries.push({ text, uuid, at, marks: new Set(), isVisible: undefined })
40 return true
41}
42
43export function attachUuid(entries: Entry[], text: string, uuid: string): boolean {
44 for (let i = entries.length - 1; i >= 0; i -= 1) {
45 const entry = entries[i]
46 if (entry !== undefined && entry.uuid === undefined && preview(entry.text) === preview(text)) {
47 entry.uuid = uuid
48 return true
49 }
50 }
51 return false
52}
53
54export type RowResult = 'matched' | 'visibility' | 'unchanged' | 'unknown' | 'volatile'
55
56export function seeRow(entries: Entry[], id: string, text: string, isVisible: boolean | undefined): RowResult {
57 const owner = entries.find(e => e.rowId === id)
58 if (owner !== undefined) {
59 if (preview(owner.text) !== preview(text)) {
60 owner.rowId = undefined
61 owner.isVisible = undefined
62 return 'volatile'
63 }
64 if (owner.isVisible === isVisible) return 'unchanged'
65 owner.isVisible = isVisible
66 return 'visibility'
67 }
68 const byUuid = entries.find(e => e.uuid === id && e.rowId === undefined)
69 const candidates = byUuid !== undefined ? [byUuid] : [...entries].reverse()
70 for (const entry of candidates) {
71 if (entry.rowId === undefined && preview(entry.text) === preview(text)) {
72 entry.rowId = id
73 entry.isVisible = isVisible
74 return 'matched'
75 }
76 }
77 return 'unknown'
78}
79
80export function jumpTarget(entry: Entry): string | undefined {
81 return entry.rowId ?? entry.uuid
82}
83
84export function mark(entries: Entry[], kind: Mark): boolean {
85 const last = entries.at(-1)
86 if (last === undefined || last.marks.has(kind)) return false
87 last.marks.add(kind)
88 return true
89}
90hooks/transcript.ts 63 lines1export type TranscriptRows = { prompts: { uuid: string; text: string; at?: number }[]; lastAnswer?: string }
2
3type Row = {
4 type?: string
5 uuid?: string
6 timestamp?: string
7 isMeta?: boolean
8 isSidechain?: boolean
9 message?: { role?: string; content?: unknown }
10}
11
12export function textOf(content: unknown): string {
13 if (typeof content === 'string') return content
14 if (!Array.isArray(content)) return ''
15 return content
16 .filter((b): b is { type: string; text: string } => typeof b === 'object' && b !== null && b.type === 'text' && typeof b.text === 'string')
17 .map(b => b.text)
18 .join('\n')
19}
20
21export function createParser(isPrompt: (text: string) => boolean) {
22 const rows: TranscriptRows = { prompts: [] }
23 let pending = ''
24
25 const line = (raw: string) => {
26 if (raw.trim() === '') return
27 let row: Row
28 try {
29 row = JSON.parse(raw) as Row
30 } catch {
31 return
32 }
33 if (row.uuid === undefined || row.isSidechain === true || row.isMeta === true) return
34 const text = textOf(row.message?.content)
35 const at = row.timestamp === undefined ? Number.NaN : Date.parse(row.timestamp)
36 if (row.type === 'user' && isPrompt(text)) rows.prompts.push({ uuid: row.uuid, text, at: Number.isNaN(at) ? undefined : at })
37 if (row.type === 'assistant' && text.trim() !== '') rows.lastAnswer = row.uuid
38 }
39
40 return {
41 push(chunk: string) {
42 const parts = (pending + chunk).split('\n')
43 pending = parts.pop() ?? ''
44 for (const part of parts) line(part)
45 },
46 finish(): TranscriptRows {
47 line(pending)
48 pending = ''
49 return rows
50 },
51 }
52}
53
54export function parseTranscript(jsonl: string, isPrompt: (text: string) => boolean): TranscriptRows {
55 const parser = createParser(isPrompt)
56 parser.push(jsonl)
57 return parser.finish()
58}
59
60export function projectDirName(cwd: string): string {
61 return cwd.replace(/[^a-zA-Z0-9]/g, '-')
62}
63