SLOPSHOPPER

prompt-dock

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…

newpanebandrowscommandtoast
v1.0.0MITupdated 2026-10-05aliemrevezir/mods/plugins/prompt-dock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-dock
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────╮ │ prompt-dock │ ⏺ Read(src/auth.ts) │ Select some text with the mouse first. │ ⎿ 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-dock: Prompt list docked beside the transcript. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-dock

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.

prompt-dock: previewing prompts on the rail, jumping from the dock, quoting a selection

What it does

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  [–]
││││┃
  • Hover a tick to preview that prompt in the line above it.
  • Click a tick to scroll the transcript to that prompt.
  • [–] 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…
  • Click a row to jump to it.
  • × 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.

Commands

CommandWhat it does
/promptsMove the list between the rail and the dock
/replyQuote the current selection at the top of the prompt

Install

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.

Requirements

  • A Claude Code build with mods (plugin hook modules).
  • The fullscreen layout for the "where you are" tick and for reading the selection. On the main-screen layout the rail still works, and the thick tick follows your last jump or newest prompt.
  • If the terminal has already cleared the highlight, Reply falls back to the clipboard text: pbpaste on macOS, wl-paste or xclip on Linux.

Credits

Development

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.

License

MIT

Source 4 files
hooks/register.tsx 257 lines
1import { 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}
257
hooks/exchanges.ts 37 lines
1import 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}
37
hooks/rail.ts 59 lines
1import 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}
59
types/index.d.ts 14 lines
1export 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