SLOPSHOPPER

prompt-jump

A bar above the prompt input with a tick for every prompt of the session; hover to read one, click to jump to it

newbandrowscommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-jump
› 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 › /prompts ⎿ prompt-jump: Prompt bar shown. 还没有 prompt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
还没有 prompt
README

prompt-jump

在 Claude Code 里一键跳回当前会话中你输入过的任意一条 prompt。

灵感来自 Grok Build 的 prompt 导航条:输入 /prompts,输入框上方出现一行刻度,每条 prompt 一个,悬停能看到内容,点击就跳过去。

❯ write 30 numbered lines, each just the word line
⏺ 1. line
  2. line
  …
◀ │ ┃ │ │  ▶  2/4  reply with just: two          ← 悬停第 2 个刻度
──────────────────────────────────────────────
❯

功能

  • 一条 prompt 一个刻度:│ 表示一条 prompt,加粗的 ┃ 是 transcript 当前停留的那条(屏幕上最靠上的 prompt),滚动时自动更新
  • 悬停预览:鼠标停在刻度上,右侧显示这条 prompt 的内容
  • 点击跳转:点刻度,transcript 滚动到那条 prompt,并把它放在视口顶部
  • ◀ / ▶:跳到上一条 / 下一条;当前 prompt 的开头已经滚出屏幕时,◀ 先回到它自己的开头
  • 位置计数:2/4 表示当前第几条、一共几条
  • 恢复会话也能用:启动时从会话的 transcript 文件里补全历史 prompt,--resume / --continue 的会话同样有完整列表
  • prompt 太多一行放不下时,只显示当前 prompt 附近的一段刻度

只收录你自己输入的 prompt;slash command、! bash 模式、工具结果、后台任务通知都会被过滤掉。

用法

命令作用
/prompts显示 / 收起导航条(切换)
/prompts open显示导航条
/prompts close收起导航条

键盘操作:ctrl+x tab 聚焦导航条,Tab 在刻度之间移动,Enter 跳转,Esc 回到输入框。

安装

在 Claude Code 里执行:

/plugin marketplace add ruanss4/prompt-jump
/plugin install prompt-jump@prompt-jump

或者在终端里:

claude plugin marketplace add ruanss4/prompt-jump
claude plugin install prompt-jump@prompt-jump

装好后重启 Claude Code,输入 /prompts 即可。

本地试用(不安装)

git clone https://github.com/ruanss4/prompt-jump.git
claude --plugin-dir ./prompt-jump

要求

  • Claude Code 2.1.287 或更新版本。本插件基于 Claude Code 的 function hooks(mod)API 编写,这套 API 目前是 early access,后续版本可能变动。
  • fullscreen 模式。跳转依赖 fullscreen(alternate screen)布局下可滚动的 transcript;在普通的主屏模式下 transcript 直接打印进终端 scrollback,插件无法滚动它,点击后会弹 toast 说明原因。
  • 如果安装后 /prompts 不存在,可能是你的 Claude Code 还没开启 function hooks,在 ~/.claude/settings.json 的 env 里加上:
  {
    "env": {
      "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
    }
  }

已知限制

  • 导航条的位置固定在输入框上方。Grok Build 那种叠在对话右侧的竖条在 Claude Code 里做不到:插件绘制的内容会被裁剪在自己的区域内,无法覆盖 transcript。
  • 悬停预览是单行文字,长 prompt 会截断。
  • /compact 之后,压缩前的消息不再显示在 transcript 里,跳到这些 prompt 会失败并提示原因。
  • transcript 文件超过 4 MiB 时,改用 awk 过滤后读取;极端情况下最早的部分 prompt 可能读不全(当前会话里新输入的 prompt 不受影响)。

开发

.claude-plugin/
  plugin.json         插件清单
  marketplace.json    让这个仓库本身就是一个 marketplace
hooks/
  hooks.json          指向 hooks 模块
  register.tsx        全部逻辑
types/index.d.ts      $.state 的类型契约
tests/                claude plugin test 的测试
claude plugin validate .   # 校验清单和 hooks 模块
claude plugin test .       # 跑测试

用 claude --plugin-dir . 加载后,Claude Code 会在 .claude-plugin/types/ 下生成类型声明,之后 tsc -p . 就能做类型检查。改动文件会自动热重载。

实现要点

  • 收集 prompt:session.append(door: 'prompt')拿到每条新 prompt 和它在 transcript 里的 uuid;启动时读 ~/.claude/projects/<cwd>/<session-id>.jsonl 补全历史。
  • 跳转:$.ui.scroll({ to: { requestId: uuid }, block: 'start' }),transcript 里每条用户消息的渲染 id 就是它的 uuid。
  • 当前位置:挂 UserMessage 的 ui.render,读 props.onScreen 判断哪些 prompt 在屏幕上;render hook 里不能写 state,所以用 $.clock.after 延后一拍写入。
  • 导航条:AbovePrompt 的 ui.render;悬停预览是每个刻度里一个 position: "absolute"、display: "none" + hover: { display: "flex" } 的 Box。

License

MIT

Source 2 files
hooks/register.tsx 325 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PromptEntry } from '../types'
5
6const COMMAND = 'prompts'
7const MAX_READ = 4 * 1024 * 1024
8
9const prompts = atom({ plugin: 'prompt-jump', key: 'prompts' } as const, [] as PromptEntry[])
10// The prompt the transcript is showing: the topmost one on screen, '' before any is seen.
11const current = atom({ plugin: 'prompt-jump', key: 'current' } as const, '')
12// Whether /prompts has the bar up above the prompt input.
13const isOpen = atom({ plugin: 'prompt-jump', key: 'isOpen' } as const, false)
14
15// Which rows of each prompt the transcript's viewport shows (null: off screen), as the
16// UserMessage render hook last heard. Render hooks may not write state, so a timer flushes it.
17const onScreen = new Map<string, { first: number } | null>()
18let flushPending = false
19
20// Rows the transcript stores as user messages that the person did not type as a prompt.
21const NOT_A_PROMPT = /^\s*<(command-|local-command|bash-|task-notification|system-reminder)/
22
23type Block = { type?: string; text?: string }
24
25export function promptText(content: unknown): string | undefined {
26  let text: string
27  if (typeof content === 'string') {
28    text = content
29  } else if (Array.isArray(content)) {
30    const blocks = content as Block[]
31    if (blocks.some(b => b?.type === 'tool_result')) return undefined
32    text = blocks
33      .filter(b => b?.type === 'text' && typeof b.text === 'string')
34      .map(b => b.text)
35      .join('\n')
36  } else {
37    return undefined
38  }
39  text = text.trim()
40  if (text === '' || NOT_A_PROMPT.test(text)) return undefined
41  return text
42}
43
44// One transcript line (JSONL) to a prompt, or undefined when it is anything else.
45export function parseLine(line: string): PromptEntry | undefined {
46  if (!line.includes('"user"')) return undefined
47  let row: {
48    type?: string
49    uuid?: string
50    isMeta?: boolean
51    isSidechain?: boolean
52    message?: { role?: string; content?: unknown }
53  }
54  try {
55    row = JSON.parse(line)
56  } catch {
57    return undefined
58  }
59  if (row.type !== 'user' || row.isMeta || row.isSidechain || !row.uuid) return undefined
60  const text = promptText(row.message?.content)
61  return text === undefined ? undefined : { id: row.uuid, text }
62}
63
64export function parseTranscript(jsonl: string): PromptEntry[] {
65  const out: PromptEntry[] = []
66  const seen = new Set<string>()
67  for (const line of jsonl.split('\n')) {
68    const entry = parseLine(line)
69    if (entry && !seen.has(entry.id)) {
70      seen.add(entry.id)
71      out.push(entry)
72    }
73  }
74  return out
75}
76
77// Transcript entries first, in their order; anything recorded live but not yet read back after them.
78export function merge(fromFile: PromptEntry[], live: PromptEntry[]): PromptEntry[] {
79  const ids = new Set(fromFile.map(p => p.id))
80  return [...fromFile, ...live.filter(p => !ids.has(p.id))]
81}
82
83const sanitize = (path: string) => path.replace(/[^a-zA-Z0-9]/g, '-')
84
85async function exists($: EngineInterface, path: string): Promise<boolean> {
86  try {
87    return (await $.fs.stat(path)).kind === 'file'
88  } catch {
89    return false
90  }
91}
92
93async function transcriptPath($: EngineInterface): Promise<string | undefined> {
94  const configDir =
95    (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
96  const projects = `${configDir}/projects`
97  const file = `${await $.session.id()}.jsonl`
98  for (const dir of new Set([await $.session.cwd(), await $.session.root()])) {
99    const path = `${projects}/${sanitize(dir)}/${file}`
100    if (await exists($, path)) return path
101  }
102  try {
103    for (const entry of await $.fs.list(projects)) {
104      if (entry.kind !== 'dir') continue
105      const path = `${projects}/${entry.name}/${file}`
106      if (await exists($, path)) return path
107    }
108  } catch {}
109  return undefined
110}
111
112async function readTranscript($: EngineInterface, path: string): Promise<string> {
113  const { size } = await $.fs.stat(path)
114  if (size <= MAX_READ) return await $.fs.read(path)
115  // Too big for one read: let awk keep only the user rows that are not tool results.
116  const { stdout } = await $.process.run([
117    'awk',
118    'index($0, "\\"type\\":\\"user\\"") && !index($0, "\\"tool_use_id\\"")',
119    path,
120  ])
121  return stdout
122}
123
124async function backfill($: EngineInterface): Promise<void> {
125  const path = await transcriptPath($)
126  if (!path) return
127  let fromFile: PromptEntry[]
128  try {
129    fromFile = parseTranscript(await readTranscript($, path))
130  } catch {
131    return
132  }
133  await update($, prompts, live => merge(fromFile, live ?? []))
134}
135
136// Display width, counting CJK and other wide glyphs as two cells.
137const cellWidth = (ch: string) => {
138  const code = ch.codePointAt(0) ?? 0
139  return code >= 0x1100 &&
140    (code <= 0x115f ||
141      (code >= 0x2e80 && code <= 0xa4cf) ||
142      (code >= 0xac00 && code <= 0xd7a3) ||
143      (code >= 0xf900 && code <= 0xfaff) ||
144      (code >= 0xfe30 && code <= 0xfe4f) ||
145      (code >= 0xff00 && code <= 0xff60) ||
146      (code >= 0xffe0 && code <= 0xffe6) ||
147      code >= 0x1f300)
148    ? 2
149    : 1
150}
151
152export function clip(text: string, cells: number): string {
153  const flat = text.replace(/\s+/g, ' ').trim()
154  let used = 0
155  let out = ''
156  for (const ch of flat) {
157    const w = cellWidth(ch)
158    if (used + w > cells - 1) return `${out}…`
159    used += w
160    out += ch
161  }
162  return out
163}
164
165async function jumpTo($: EngineInterface, id: string): Promise<void> {
166  let reason: string | undefined
167  try {
168    reason = (await $.ui.scroll({ to: { requestId: id }, block: 'start' })).deny
169  } catch (err) {
170    reason = err instanceof Error ? err.message : String(err)
171  }
172  if (reason) void $.ui.toast(`没能跳到这条 prompt:${reason}`)
173  else await update($, current, () => id)
174}
175
176// The topmost prompt on screen, or undefined when none is.
177export function topmostVisible(
178  list: readonly PromptEntry[],
179  seen: ReadonlyMap<string, { first: number } | null>,
180): string | undefined {
181  return list.find(p => seen.get(p.id))?.id
182}
183
184// Where ◀ / ▶ go from the current prompt: ◀ first to the current prompt's own top when
185// it is scrolled away, then to the one before it; ▶ to the one after it.
186export function neighbor(
187  list: readonly PromptEntry[],
188  currentId: string,
189  dir: -1 | 1,
190  seen: ReadonlyMap<string, { first: number } | null>,
191): string | undefined {
192  if (list.length === 0) return undefined
193  let at = list.findIndex(p => p.id === currentId)
194  if (at < 0) at = list.length - 1
195  if (dir === -1 && (seen.get(list[at]!.id)?.first ?? 0) > 1) return list[at]!.id
196  return list[at + dir]?.id
197}
198
199// The prompts the minimap draws: all of them when they fit, else a window around the current.
200export function tickWindow<T>(list: readonly T[], at: number, room: number): { items: T[]; from: number } {
201  if (list.length <= room) return { items: [...list], from: 0 }
202  const from = Math.min(Math.max(0, at - Math.floor(room / 2)), list.length - room)
203  return { items: list.slice(from, from + room), from }
204}
205
206async function flushCurrent($: EngineInterface): Promise<void> {
207  const list = await read($, prompts)
208  const top = topmostVisible(list, onScreen)
209  if (top !== undefined) await update($, current, was => (was === top ? was : top))
210}
211
212export const register: Register = on => {
213  on('session.start', async ($, e, next) => {
214    await $.command.register({
215      name: COMMAND,
216      description: '在输入框上方显示/收起 prompt 导航条:悬停看内容,点击跳转',
217    })
218    await backfill($)
219    return next(e)
220  })
221
222  on('command.run', { command: COMMAND }, async ($, e) => {
223    const arg = e.args.trim()
224    const open = arg === 'close' ? false : arg === 'open' ? true : !(await read($, isOpen))
225    if (open) await backfill($)
226    await update($, isOpen, () => open)
227    return { text: open ? 'Prompt bar shown.' : 'Prompt bar hidden.' }
228  })
229
230  on('session.append', { door: 'prompt' }, async ($, e, next) => {
231    const stored = await next(e)
232    if (stored.deny !== undefined || e.agentId !== undefined) return stored
233    if (stored.message.type !== 'user' || stored.message.isMeta) return stored
234    const text = promptText(stored.message.content)
235    if (text !== undefined) {
236      const entry = { id: stored.uuid, text }
237      await update($, prompts, list =>
238        (list ?? []).some(p => p.id === entry.id) ? list ?? [] : [...(list ?? []), entry],
239      )
240      await update($, current, () => entry.id)
241    }
242    return stored
243  })
244
245  on('session.end', async ($, e, next) => {
246    if (e.reason === 'clear') {
247      onScreen.clear()
248      await update($, prompts, () => [])
249      await update($, current, () => '')
250    }
251    return next(e)
252  })
253
254  // Hear which prompts the transcript shows; draw them as the engine does.
255  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
256    const seen = e.props.onScreen
257    if (e.props.origin.kind === 'composer' && seen !== undefined) {
258      onScreen.set(e.requestId, seen && { first: seen.first })
259      if (!flushPending) {
260        flushPending = true
261        $.clock.after(60, () => {
262          flushPending = false
263          void flushCurrent($)
264        })
265      }
266    }
267    return next(e)
268  })
269
270  // The bar above the prompt input: ◀, one tick per prompt with the current one bold, ▶,
271  // the position. Hovering a tick shows its prompt to the right; pressing it jumps there.
272  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
273    if (e.props.hasSurvey || !(await read($, isOpen))) return next(e)
274    const { Box, Text, Button } = $.ui.resolve(e)
275    const list = await read($, prompts)
276    if (list.length === 0) return <Text dimColor>还没有 prompt</Text>
277
278    const cur = await read($, current)
279    let at = list.findIndex(p => p.id === cur)
280    if (at < 0) at = list.length - 1
281    const width = Math.max(20, e.props.bodyColumns)
282    // Each tick takes two cells; leave a third of the row (at least 24 cells) for the text.
283    const room = Math.max(1, Math.floor((width - Math.max(24, Math.floor(width / 3))) / 2))
284    const { items, from } = tickWindow(list, at, room)
285    const counter = `  ${at + 1}/${list.length}  `
286    // The hover text starts after ◀ (2), the ticks (2 each), ▶ (2) and the counter.
287    const textAt = 2 + items.length * 2 + 2 + counter.length
288    const textRoom = Math.max(8, width - textAt - 4)
289    const go = (dir: -1 | 1) => {
290      const id = neighbor(list, list[at]!.id, dir, onScreen)
291      if (id !== undefined) void jumpTo($, id)
292    }
293
294    return (
295      <Box flexDirection="row" height={1}>
296        <Button key="jump:prev" plain dimColor label="◀ " onPress={() => go(-1)} />
297        {items.map((p, i) => {
298          const isCurrent = from + i === at
299          return (
300            <Box key={`tick:${p.id}`} width={2}>
301              <Button
302                key={`jump:${p.id}`}
303                plain
304                dimColor={!isCurrent}
305                label={isCurrent ? '┃' : '│'}
306                onPress={() => void jumpTo($, p.id)}
307              />
308              <Box
309                position="absolute"
310                left={textAt - 2 - i * 2}
311                display="none"
312                hover={{ display: 'flex' }}
313              >
314                <Text bold wrap="truncate-end">{clip(p.text, textRoom)}</Text>
315              </Box>
316            </Box>
317          )
318        })}
319        <Button key="jump:next" plain dimColor label=" ▶" onPress={() => go(1)} />
320        <Text dimColor>{counter}</Text>
321      </Box>
322    )
323  })
324}
325
types/index.d.ts 8 lines
1export type PromptEntry = { id: string; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'prompt-jump': { prompts: PromptEntry[]; current: string; isOpen: boolean }
6  }
7}
8