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

在 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,加粗的 ┃ 是 transcript 当前停留的那条(屏幕上最靠上的 prompt),滚动时自动更新2/4 表示当前第几条、一共几条--resume / --continue 的会话同样有完整列表只收录你自己输入的 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
/prompts 不存在,可能是你的 Claude Code 还没开启 function hooks,在 ~/.claude/settings.json 的 env 里加上: {
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
/compact 之后,压缩前的消息不再显示在 transcript 里,跳到这些 prompt 会失败并提示原因。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 . 就能做类型检查。改动文件会自动热重载。
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。MIT
hooks/register.tsx 325 lines1import { 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}
325types/index.d.ts 8 lines1export 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