SLOPSHOPPER

jump-list

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

newpanebandspinnerrowsguard
v0.7.3MITupdated 2026-10-06GururOzatmaca/claude-mods/jump-list
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jump-list
│ ┃ Jumps ✕ › fix the failing auth test and add an audit log call │ ┃ – │ ┃ No prompts 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 │ │ › /jumps │ ⎿ jump-list: Jump list opened. │ ⎿ jump-list: History: transcript (streamed), 0 prompts. Row ids ma │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Jumps
– No prompts yet.
README

claude-mods

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.

prompt-boost

Add ++ to a messy prompt. It gets rewritten in clear English and put back in the box. You check it, then press Enter.

Typing a prompt with ++ Rewritten prompt in the box

  • ++ at the start or end. ++o uses Opus, ++q adds open questions.
  • Only the wording changes. Names, paths and errors stay exactly as you wrote them.
  • /ap undo brings back your original.

reply-tools

A copy button on every code block, and a tl;dr button under long replies.

Reply with copy and tl;dr buttons tl;dr of the reply

  • ⧉ copy all copies several shell commands at once.
  • tl;dr keeps every step and warning. ↑ original switches back.
  • Turn parts off with /plugin configure reply-tools@claude-mods.

jump-list

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.
  • Works after resume too. Open it with /jumps.
  • A very old prompt may need a second click.

Good to know

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.

Source 3 files
hooks/register.tsx 284 lines
1import 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}
284
hooks/entries.ts 90 lines
1export 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}
90
hooks/transcript.ts 63 lines
1export 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