A rail of your prompts above the input (hover to preview, click to jump), a dock beside the transcript, and quote-reply from any selected text. Built on top of…

A Claude Code mod that keeps every prompt you sent one click away, and lets you reply to any piece of the conversation by quoting it.
Built on top of oikon48/prompt-rail: the same rail and dock, extended with quote-reply.

Rail (default). A thin rail sits above the input, one tick per prompt. The thick tick is the prompt you are looking at in the transcript.
#5 Compare debounce and throttle as a short list. ↩ Reply [–]
││││┃
[–] moves the list into the dock.Dock. A narrow column beside the transcript, one line per prompt, the current one marked with ━.
5 prompts ↩ ×
─ Explain what a deb…
━ Write a TypeScript…
─ Add a leading-edge…
─ Write three vitest…
─ Compare debounce a…
× sends the list back to the rail.Reply. Select any text with the mouse (an answer, a code line, an earlier prompt), then press ↩ Reply. The selection lands at the top of your prompt as a Markdown quote, with whatever you had already typed kept below it:
> Debounce waits for silence; throttle enforces a minimum time between executions.
Show me both in one example.
| Command | What it does |
|---|---|
/prompts | Move the list between the rail and the dock |
/reply | Quote the current selection at the top of the prompt |
From inside Claude Code:
/plugin marketplace add aliemrevezir/mods
/plugin install prompt-dock@aliemrevezir-mods
Or load it straight from a clone:
git clone https://github.com/aliemrevezir/mods
claude --plugin-dir mods/plugins/prompt-dock
To load it in every session without the flag, set CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json to that folder.
pbpaste on macOS, wl-paste or xclip on Linux./plugin marketplace add oikon48/prompt-rail.claude plugin validate .
claude plugin test .
The pure logic (pairing prompts with transcript rows, picking the current prompt, the quote format) lives in hooks/rail.ts and hooks/exchanges.ts; drawing and events live in hooks/register.tsx.
The demo GIF is drawn, not recorded: python3 assets/make_demo.py (needs Pillow) rebuilds assets/demo.gif.
hooks/register.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Exchange } from '../types'
5import { toExchanges } from './exchanges'
6import type { RailItem } from './rail'
7import { barWindow, currentIndex, oneLine, toItems, withQuote } from './rail'
8
9const PANE = 'prompt-dock'
10const TITLE = 'Prompts'
11const DOCK_COLUMNS = 30
12
13const mode = atom({ plugin: 'prompt-dock', key: 'mode' } as const, 'rail')
14const tick = atom({ plugin: 'prompt-dock', key: 'tick' } as const, 0)
15
16const scope = (i: number) => `prompt-dock-${i}`
17
18const preview = (x: RailItem) => `#${x.index + 1} ${x.prompt}${x.tools > 0 ? ` · ${x.tools} tool${x.tools === 1 ? '' : 's'}` : ''}`
19
20function bump($: EngineInterface) {
21 return update($, tick, n => (n ?? 0) + 1)
22}
23
24async function dock($: EngineInterface) {
25 await update($, mode, () => 'dock')
26 return $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS })
27}
28
29async function rail($: EngineInterface) {
30 await update($, mode, () => 'rail')
31 await $.ui.close({ id: PANE })
32}
33
34// UserMessage request ids in transcript order, keyed by prompt text, so a
35// prompt can be scrolled into view in the transcript.
36const rowIds = new Map<string, string[]>()
37// Prompt rows the transcript's viewport shows now, and the last one it did.
38const visible = new Set<string>()
39let lastSeen: string | undefined
40// Reading the whole transcript on every redraw is slow; once per new message.
41let cache: { tick: number; list: Exchange[] } | undefined
42
43async function items($: EngineInterface) {
44 const t = await read($, tick)
45 if (cache?.tick !== t) cache = { tick: t, list: toExchanges(await $.session.messages()) }
46 const list = toItems(cache.list, rowIds)
47 return { list, current: currentIndex(list, visible, lastSeen) }
48}
49
50async function jump($: EngineInterface, x: RailItem) {
51 if (x.requestId === undefined) return
52 lastSeen = x.requestId
53 await $.ui.scroll({ to: { requestId: x.requestId }, block: 'start' })
54 $.ui.invalidate('ui.render')
55}
56
57// Reads the selection only when asked: polling it every half second got in
58// the way of pasting images into the prompt. The terminal copies a selection
59// on its own and may take the highlight down, so the clipboard backs it up.
60const PASTE_COMMANDS = [['pbpaste'], ['wl-paste', '--no-newline'], ['xclip', '-o', '-selection', 'clipboard']]
61
62async function selectedText($: EngineInterface) {
63 const selected = (await $.ui.selection())?.text.trim()
64 if (selected) return selected
65 for (const argv of PASTE_COMMANDS) {
66 const pasted = await $.process.run(argv).catch(() => undefined)
67 if (pasted?.exitCode === 0 && pasted.stdout.trim() !== '') return pasted.stdout.trim()
68 }
69 return ''
70}
71
72async function reply($: EngineInterface) {
73 const text = await selectedText($)
74 if (!text) {
75 $.ui.toast('Select some text with the mouse first.')
76 return false
77 }
78 const { text: draft } = await $.prompt.read()
79 const filled = await $.prompt.fill({ text: withQuote(text, draft), mode: 'replace' })
80 $.ui.toast(
81 filled.isFilled
82 ? `Quoted: "${oneLine(text, 40)}"`
83 : `Could not write to the prompt${filled.refusal === undefined ? '' : ` (${filled.refusal})`}.`,
84 )
85 return filled.isFilled
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 await $.command.register({
91 name: 'prompts',
92 description: 'Move your prompt list between the rail above the input and a dock beside the transcript',
93 })
94 await $.command.register({
95 name: 'reply',
96 description: 'Quote the text you selected at the top of the prompt',
97 })
98 if ((await read($, mode)) === 'dock') void dock($)
99 return next(e)
100 })
101
102 on('command.run', { command: 'prompts' }, async $ => {
103 await bump($)
104 if ((await read($, mode)) === 'dock') {
105 await rail($)
106 return { text: 'Prompt list moved back to the rail above the input.' }
107 }
108 const opened = await dock($)
109 return { text: opened.isPlaced ? 'Prompt list docked beside the transcript.' : 'The dock could not be placed; widen the terminal.' }
110 })
111
112 on('command.run', { command: 'reply' }, async $ => {
113 return { text: (await reply($)) ? 'Selection quoted in the prompt.' : 'Select some text with the mouse first.' }
114 })
115
116 on('prompt.submit', async ($, e, next) => {
117 const ran = await next(e)
118 lastSeen = undefined
119 await bump($)
120 return ran
121 })
122
123 on('turn.complete', async ($, e, next) => {
124 const ran = await next(e)
125 await bump($)
126 return ran
127 })
128
129 // A person closing the dock sends the list back to the rail.
130 on('ui.close', { id: PANE }, async ($, e, next) => {
131 if (e.origin.kind === 'person') await update($, mode, () => 'rail')
132 return next(e)
133 })
134
135 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
136 const text = e.props.text.trim()
137 const ids = rowIds.get(text) ?? []
138 if (!ids.includes(e.requestId)) rowIds.set(text, [...ids, e.requestId])
139 const onScreen = e.props.onScreen
140 if (onScreen !== undefined) {
141 const was = visible.has(e.requestId)
142 if (onScreen === null) visible.delete(e.requestId)
143 else {
144 visible.add(e.requestId)
145 lastSeen = e.requestId
146 }
147 if (was !== (onScreen !== null)) $.ui.invalidate('ui.render')
148 }
149 return next(e)
150 })
151
152 // Rail: a row of ticks above the prompt, one per prompt, the thick one where
153 // you are; hovering a tick previews its prompt in the line above.
154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155 if (e.props.hasSurvey) return next(e)
156 if ((await read($, mode)) === 'dock') return next(e)
157 const { list, current } = await items($)
158 if (list.length === 0) return next(e)
159 const { Box, Text, Button } = $.ui.resolve(e)
160 const width = Math.max(10, e.props.bodyColumns - 16)
161 const shown = barWindow(list.length, width, current)
162 const here = list[current]
163
164 return (
165 <Box flexDirection="column" paddingX={1}>
166 <Box flexDirection="row">
167 <Box flexGrow={1} flexShrink={1} position="relative">
168 <Text dimColor wrap="truncate-end">
169 {here === undefined ? '' : oneLine(preview(here), width)}
170 </Text>
171 {shown.map(i => {
172 const x = list[i]
173 if (x === undefined) return null
174 return (
175 <Box
176 key={`peek-${i}`}
177 position="absolute"
178 top={0}
179 left={0}
180 display="none"
181 hover={{ display: 'flex', scope: scope(i) }}
182 >
183 <Text bold wrap="truncate-end">
184 {oneLine(preview(x), width).padEnd(width, ' ')}
185 </Text>
186 </Box>
187 )
188 })}
189 </Box>
190 <Box key="reply-box" marginRight={1}>
191 <Button key="reply" plain dimColor label="↩ Reply" hover={{ dimColor: false }} onPress={() => reply($)} />
192 </Box>
193 <Box key="to-dock-box">
194 <Button key="to-dock" plain dimColor label="[–]" hover={{ dimColor: false }} onPress={() => dock($)} />
195 </Box>
196 </Box>
197 <Box flexDirection="row">
198 {shown.map(i => {
199 const x = list[i]
200 if (x === undefined) return null
201 const isHere = i === current
202 return (
203 <Button
204 key={`bar-${i}`}
205 plain
206 dimColor={!isHere}
207 label={isHere ? '┃' : '│'}
208 hover={{ scope: scope(i), dimColor: false, bold: true }}
209 onPress={() => jump($, x)}
210 />
211 )
212 })}
213 </Box>
214 </Box>
215 )
216 })
217
218 // Dock: the same list standing beside the transcript, one line a prompt.
219 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220 const { Box, Text, Button } = $.ui.resolve(e)
221 const { list, current } = await items($)
222 const width = Math.max(8, e.props.bodyColumns - 4)
223
224 return (
225 <Box flexDirection="column" paddingX={1}>
226 <Box flexDirection="row">
227 <Box flexGrow={1}>
228 <Text dimColor>{list.length} prompt{list.length === 1 ? '' : 's'}</Text>
229 </Box>
230 <Box key="reply-box" marginRight={1}>
231 <Button key="reply" plain dimColor label="↩" hover={{ dimColor: false }} onPress={() => reply($)} />
232 </Box>
233 <Box key="to-rail-box">
234 <Button key="to-rail" plain dimColor label="×" hover={{ dimColor: false }} onPress={() => rail($)} />
235 </Box>
236 </Box>
237 {list.length === 0 && <Text dimColor>No prompts yet.</Text>}
238 {list.map((x, i) => {
239 const isHere = i === current
240 return (
241 <Box key={`row-box-${i}`}>
242 <Button
243 key={`row-${i}`}
244 plain
245 dimColor={!isHere}
246 label={`${isHere ? '━' : '─'} ${oneLine(x.prompt, width - 2)}`}
247 hover={{ dimColor: false }}
248 onPress={() => jump($, x)}
249 />
250 </Box>
251 )
252 })}
253 </Box>
254 )
255 })
256}
257hooks/exchanges.ts 37 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Exchange } from '../types'
4
5// Engine-injected user rows (reminders, command records, notifications) are not prompts.
6const isPrompt = (m: SessionMessage) => {
7 if (m.role !== 'user' || (m.toolResults?.length ?? 0) > 0) return false
8 const text = m.text.trim()
9 return text !== '' && !text.startsWith('<') && !text.startsWith('[Request interrupted')
10}
11
12const toolLine = (tool: string, input: Record<string, unknown>) => {
13 const hint = [input.description, input.file_path, input.command, input.pattern, input.prompt]
14 .find(v => typeof v === 'string') as string | undefined
15 const short = hint ? hint.replace(/\s+/g, ' ').slice(0, 80) : ''
16 return `> 🔧 **${tool.replace(/^mcp__/, '')}**${short ? ` · ${short}` : ''}`
17}
18
19export const toExchanges = (messages: readonly SessionMessage[]): Exchange[] => {
20 const out: Exchange[] = []
21 let parts: string[] = []
22 for (const m of messages) {
23 if (isPrompt(m)) {
24 parts = []
25 out.push({ prompt: m.text.trim(), answer: '', tools: 0 })
26 continue
27 }
28 const current = out.at(-1)
29 if (current === undefined || m.role !== 'assistant') continue
30 if (m.text.trim() !== '') parts.push(m.text.trim())
31 for (const use of m.toolUses) parts.push(toolLine(use.tool, use.input))
32 current.tools += m.toolUses.length
33 current.answer = parts.join('\n\n')
34 }
35 return out
36}
37hooks/rail.ts 59 lines1import type { Exchange } from '../types'
2
3export type RailItem = { index: number; prompt: string; tools: number; requestId: string | undefined }
4
5// Pairs each prompt with the UserMessage row that drew it: the nth prompt of a
6// given text takes the nth row seen with that text.
7export const toItems = (list: readonly Exchange[], rowIds: ReadonlyMap<string, readonly string[]>): RailItem[] => {
8 const seen = new Map<string, number>()
9 return list.map((x, index) => {
10 const nth = seen.get(x.prompt) ?? 0
11 seen.set(x.prompt, nth + 1)
12 const ids = rowIds.get(x.prompt) ?? []
13 return { index, prompt: x.prompt, tools: x.tools, requestId: ids[nth] ?? ids.at(-1) }
14 })
15}
16
17// Where the person is: the last prompt the viewport shows; failing that, the
18// one last seen on screen; failing that, the newest.
19export const currentIndex = (
20 items: readonly RailItem[],
21 visible: ReadonlySet<string>,
22 lastSeen: string | undefined,
23): number => {
24 for (let i = items.length - 1; i >= 0; i--) {
25 const id = items[i]?.requestId
26 if (id !== undefined && visible.has(id)) return i
27 }
28 const at = items.findIndex(x => x.requestId !== undefined && x.requestId === lastSeen)
29 return at >= 0 ? at : items.length - 1
30}
31
32export const oneLine = (text: string, width: number) => {
33 const flat = text.replace(/\s+/g, ' ').trim()
34 return flat.length > width ? flat.slice(0, Math.max(1, width - 1)) + '…' : flat
35}
36
37// The newest prompts that fit in `width` one-cell bars, oldest first.
38export const barWindow = (count: number, width: number, current: number): number[] => {
39 const fit = Math.max(1, width)
40 let start = Math.max(0, count - fit)
41 if (current < start) start = current
42 const end = Math.min(count, start + fit)
43 return Array.from({ length: end - start }, (_, k) => start + k)
44}
45
46// The selection as a markdown quote, ready to answer under.
47export const quote = (text: string) =>
48 text
49 .replace(/\s+$/, '')
50 .split('\n')
51 .map(line => (line.trim() === '' ? '>' : `> ${line}`))
52 .join('\n')
53
54// The draft with the quote put on top, a blank line between.
55export const withQuote = (text: string, draft: string) => {
56 const q = quote(text)
57 return draft.trim() === '' ? `${q}\n\n` : `${q}\n\n${draft}`
58}
59types/index.d.ts 14 lines1export type Exchange = { prompt: string; answer: string; tools: number }
2
3// Where the list stands: a rail of ticks above the input, or a dock beside the transcript.
4export type DockMode = 'rail' | 'dock'
5
6declare module 'claude-code' {
7 interface PluginState {
8 'prompt-dock': {
9 mode: DockMode
10 tick: number
11 }
12 }
13}
14