SLOPSHOPPER

clipboard

A side panel that keeps snippets you want to hold on to, one list per project: your mouse selection, Claude's last answer, or a typed note; export them to a…

panerowscommandtoast
v0.1.2no licenseupdated 2026-10-20Mengchutsai/mod/clipboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clipboard
│ ┃ Clipboard ✕ › fix the failing auth test and add an audit log call │ ┃ Clips for app │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Keep selection ] [ Keep last answer ] [ Ex ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Type a note and press Enter ⏎ Keep ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Nothing is selected. Select text in the chat ⎿ 3 pass, 1 fail │ ┃ with the mouse first, then keep it. │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ Nothing kept in this project yet. Select │ ┃ text in the chat and press Keep selection, ╭───────────────────────────────────────────────────────────────────╮ │ ┃ keep Claude's last answer, or type a note. │ keep │ │ ┃ /keep does the same from the prompt. ╰───────────────────────────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /clipboard │ ⎿ clipboard: Clipboard panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Clipboard
Clips for app [ Keep selection ] [ Keep last answer ] [ Export ] Type a note and press Enter ⏎ Keep Nothing is selected. Select text in the chat with the mouse first, then keep it. Nothing kept in this project yet. Select text in the chat and press Keep selection, keep Claude's last answer, or type a note. /keep does the same from the prompt.
Claude's reply
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ keep │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Claude Code mods

ModWhat it does
token-buddyA cute critter in a side panel showing how much context, 5-hour and weekly usage you have left. /tokens reopens it.
my-questionsA side panel listing every question you asked in the session; click one to jump to it in the chat. /questions reopens it.
maryville-themeMaryville University maroon and red in the transcript, in the terminal and the desktop app: tool rows with icons and timings, edit diff cards, shell output cards, your own messages in a red frame, brand spinner words. /maryville (dark) or /maryville light also installs a matching terminal theme into ~/.claude/themes/; /maryville off restores Claude Code's own drawing, /maryville band toggles a usage band above the prompt, /maryville status reports what it is drawing. In the desktop app, text follows the app's own light or dark appearance.
clipboardA Clipboard side panel for snippets you want to hold on to across sessions, one list per project folder. A red keep button under every reply files that reply; the panel also keeps Claude's last answer or a note you type, and each clip has copy and delete links. /keep <text> files any text, /keep answer the last reply, /keep export writes everything to clipboard.md in the project folder, /keep clear empties it; /clipboard reopens the panel. In the terminal, Keep selection files whatever you highlighted with the mouse.

Install

At the prompt of a Claude Code terminal session:

/plugin install token-buddy --marketplace Mengchutsai/mod
/plugin install my-questions --marketplace Mengchutsai/mod
/plugin install maryville-theme --marketplace Mengchutsai/mod
/plugin install clipboard --marketplace Mengchutsai/mod

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install token-buddy --marketplace Mengchutsai/mod
claude plugin install my-questions --marketplace Mengchutsai/mod
claude plugin install maryville-theme --marketplace Mengchutsai/mod
claude plugin install clipboard --marketplace Mengchutsai/mod

In the desktop app: click + next to the prompt box → Plugins → Add marketplace with Mengchutsai/mod → Add plugin.

Source 2 files
hooks/register.tsx 319 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Clip } from '../types'
5
6const PANE = 'clipboard'
7const TITLE = 'Clipboard'
8const MAX_CLIPS = 200
9const EXPORT_FILE = 'clipboard.md'
10
11/** The keep pill's color: university red, as an outline, readable on light and dark alike. */
12const MARYVILLE_RED = '#ce2431'
13
14const clips = atom({ plugin: 'clipboard', key: 'clips' } as const, [] as Clip[])
15const lastAnswer = atom({ plugin: 'clipboard', key: 'lastAnswer' } as const, null as string | null)
16const note = atom({ plugin: 'clipboard', key: 'note' } as const, null as string | null)
17const project = atom({ plugin: 'clipboard', key: 'project' } as const, null as string | null)
18
19/** A project folder in one spelling: forward slashes, no trailing slash, lower case (Windows hands the same folder over as `c:\` and `C:\`). */
20function normalizeRoot(root: string): string {
21  return root.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
22}
23
24/** The store key under which a project's clips live: one list per project folder; `clips` alone when no project is known. */
25export function storeKey(root: string | null): string {
26  return root ? `clips:${normalizeRoot(root)}` : 'clips'
27}
28
29/** The project's name for the pane: the last segment of its folder. */
30export function projectName(root: string | null): string | null {
31  if (!root) return null
32  const parts = root.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
33  return parts[parts.length - 1] || root
34}
35
36/** The list with `text` kept at the front, a clip identical to the newest one not kept twice, the oldest past the cap dropped. */
37export function addClip(list: readonly Clip[], text: string, from: Clip['from'], at: string): Clip[] {
38  const clean = text.replace(/\r\n?/g, '\n').trim()
39  if (!clean) return [...list]
40  if (list[0]?.text === clean) return [...list]
41  const id = list.reduce((max, c) => Math.max(max, c.id), 0) + 1
42  return [{ id, text: clean, from, at }, ...list].slice(0, MAX_CLIPS)
43}
44
45/** Every clip as one markdown document, oldest first, each under a heading that says where and when it came from. */
46export function exportMarkdown(list: readonly Clip[]): string {
47  const FROM = { selection: 'selected in the chat', answer: "Claude's answer", note: 'typed note' }
48  const parts = [...list]
49    .reverse()
50    .map(c => `## #${c.id} · ${FROM[c.from]} · ${c.at}\n\n${c.text}\n`)
51  return `# Clipboard\n\n${parts.join('\n')}`
52}
53
54/** A short label for a clip's source and time, for the pane. */
55export function stamp(c: Clip): string {
56  const FROM = { selection: 'selection', answer: 'answer', note: 'note' }
57  const t = c.at.length >= 16 ? c.at.slice(11, 16) : c.at
58  return `#${c.id} · ${FROM[c.from]} · ${t}`
59}
60
61/** What `/keep <args>` asks for. */
62export function parseKeep(args: string): { action: 'selection' | 'answer' | 'export' | 'clear' | 'note'; text: string } {
63  const trimmed = args.trim()
64  const word = trimmed.toLowerCase()
65  if (word === '') return { action: 'selection', text: '' }
66  if (word === 'answer') return { action: 'answer', text: '' }
67  if (word === 'export') return { action: 'export', text: '' }
68  if (word === 'clear') return { action: 'clear', text: '' }
69  return { action: 'note', text: trimmed }
70}
71
72async function persist($: EngineInterface, list: readonly Clip[]): Promise<void> {
73  try {
74    await $.store.set(storeKey(await read($, project)), list)
75  } catch {
76    // The store is unavailable (a test kit, a host without one): the session keeps the list in state.
77  }
78}
79
80async function keep($: EngineInterface, text: string, from: Clip['from']): Promise<number | null> {
81  const at = new Date(await $.clock.now()).toISOString()
82  let kept: number | null = null
83  await update($, clips, list => {
84    const next = addClip(list, text, from, at)
85    kept = next.length > list.length ? next[0].id : null
86    return next
87  })
88  await persist($, await read($, clips))
89  return kept
90}
91
92async function say($: EngineInterface, text: string | null): Promise<void> {
93  await update($, note, () => text)
94}
95
96async function keepSelection($: EngineInterface): Promise<string> {
97  const selected = await $.ui.selection()
98  if (!selected?.text.trim()) {
99    const surfaces = await $.session.surfaces()
100    if (!surfaces.includes('terminal')) {
101      return 'This app does not tell mods what you highlight. Use the keep button under a reply, Keep last answer, or /keep followed by the text.'
102    }
103    return 'Nothing is selected. Select text in the chat with the mouse first, then keep it.'
104  }
105  const id = await keep($, selected.text, 'selection')
106  return id === null ? 'That text is already the newest clip.' : `Kept the selection as #${id}.`
107}
108
109async function keepAnswer($: EngineInterface): Promise<string> {
110  const text = await read($, lastAnswer)
111  if (!text) return 'No answer to keep yet; ask something first.'
112  const id = await keep($, text, 'answer')
113  return id === null ? 'That answer is already the newest clip.' : `Kept the last answer as #${id}.`
114}
115
116async function exportClips($: EngineInterface): Promise<string> {
117  const list = await read($, clips)
118  if (list.length === 0) return 'Nothing to export yet.'
119  const path = `${await $.session.cwd()}/${EXPORT_FILE}`
120  await $.fs.write(path, exportMarkdown(list))
121  return `Exported ${list.length} clips to ${EXPORT_FILE} in the project folder.`
122}
123
124export const register: Register = on => {
125  on('session.start', async ($, e, next) => {
126    await $.command.register({ name: 'clipboard', description: 'Open the Clipboard panel' })
127    await $.command.register({
128      name: 'keep',
129      description: 'Keep a snippet in the Clipboard panel: the selected text, "answer" for the last reply, or any text you type; "export" writes clipboard.md, "clear" empties it',
130      argumentHint: '[text] | answer | export | clear',
131    })
132
133    // Clips are kept per project: the list loaded is the one filed under this session's project folder.
134    let root: string | null = null
135    try {
136      root = await $.session.root()
137    } catch {
138      // No project known (a test kit, a host without one): one shared list.
139    }
140    await update($, project, () => root)
141    try {
142      const stored = await $.store.get(storeKey(root))
143      await update($, clips, () => (Array.isArray(stored) ? (stored as Clip[]) : []))
144    } catch {
145      // No store: start empty.
146    }
147
148    void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
149    return next(e)
150  })
151
152  on('session.attach', async ($, e, next) => {
153    void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
154    return next(e)
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const result = await next(e)
159    if (!e.agentId && !e.isAborted && result.text.trim()) await update($, lastAnswer, () => result.text)
160    return result
161  })
162
163  on('command.run', { command: 'clipboard' }, async $ => {
164    const opened = await $.ui.open({ id: PANE, title: TITLE })
165    if (opened.isPlaced) return { text: 'Clipboard panel opened.' }
166    const list = await read($, clips)
167    const name = projectName(await read($, project))
168    return { text: `${list.length} clips kept${name ? ` for ${name}` : ''}. (panel not shown: ${opened.reason})` }
169  })
170
171  on('command.run', { command: 'keep' }, async ($, e) => {
172    const { action, text } = parseKeep(e.args)
173    void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
174    let result: string
175    switch (action) {
176      case 'selection': {
177        result = await keepSelection($)
178        if (result.startsWith('Nothing is selected')) {
179          // With nothing selected, /keep alone keeps the last answer instead.
180          const fallback = await keepAnswer($)
181          result = fallback.startsWith('No answer') ? result : fallback
182        }
183        break
184      }
185      case 'answer':
186        result = await keepAnswer($)
187        break
188      case 'export':
189        result = await exportClips($)
190        break
191      case 'clear':
192        await update($, clips, () => [])
193        await persist($, [])
194        result = 'Clipboard emptied.'
195        break
196      default: {
197        const id = await keep($, text, 'note')
198        result = id === null ? 'That note is already the newest clip.' : `Kept your note as #${id}.`
199      }
200    }
201    await say($, result)
202    return { text: result }
203  })
204
205  // A keep button under each reply: the one way to pick a reply on a surface
206  // that reports no mouse selection. The reply itself stays as the engine
207  // draws it; the button is appended beneath.
208  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
209    const own = await next(e)
210    if (e.props.isSummary || !e.props.text.trim()) return own
211
212    const { Box, Button, Text } = $.ui.resolve(e)
213    const text = e.props.text
214    return (
215      <Box flexDirection="column">
216        {own}
217        {/* A small pill at the end of the reply: a thin red rounded outline the
218            desktop draws faithfully. A desktop draws a button's label in its
219            own colors; the terminal draws the red text child. */}
220        <Box flexDirection="row" justifyContent="flex-end" marginTop={1}>
221          <Box borderStyle="round" borderColor={MARYVILLE_RED} paddingX={1}>
222            <Button
223              key={`keep-${e.requestId}`}
224              plain
225              label="keep"
226              onPress={async () => {
227                const id = await keep($, text, 'answer')
228                const said = id === null ? 'That reply is already the newest clip.' : `Kept this reply as #${id}.`
229                await say($, said)
230                $.ui.toast(said)
231                void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
232              }}
233            >
234              <Text color={MARYVILLE_RED} bold>
235                keep
236              </Text>
237            </Button>
238          </Box>
239        </Box>
240      </Box>
241    )
242  })
243
244  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245    const { Box, Text, Button, Input, Markdown } = $.ui.resolve(e)
246    const list = await read($, clips)
247    const status = await read($, note)
248    const name = projectName(await read($, project))
249
250    return (
251      <Box flexDirection="column" rowGap={1}>
252        {name && (
253          <Box key="project">
254            <Text dimColor>Clips for {name}</Text>
255          </Box>
256        )}
257        <Box key="actions" flexDirection="row" columnGap={1} flexWrap="wrap">
258          <Button key="keep-selection" label="Keep selection" hotkey="s" onPress={async () => say($, await keepSelection($))} />
259          <Button key="keep-answer" label="Keep last answer" hotkey="a" onPress={async () => say($, await keepAnswer($))} />
260          <Button key="export" label="Export" hotkey="e" onPress={async () => say($, await exportClips($))} />
261        </Box>
262        <Input
263          key="note"
264          placeholder="Type a note and press Enter"
265          submitLabel="Keep"
266          onSubmit={async (value: string) => {
267            const id = await keep($, value, 'note')
268            await say($, id === null ? 'That note is already the newest clip.' : `Kept your note as #${id}.`)
269          }}
270        />
271        {status && (
272          <Box key="status">
273            <Text dimColor>{status}</Text>
274          </Box>
275        )}
276        {list.length === 0 && (
277          <Box key="empty">
278            <Text dimColor wrap="wrap">
279              Nothing kept in this project yet. Select text in the chat and press Keep selection, keep Claude's last answer, or type a note. /keep does the same from the prompt.
280            </Text>
281          </Box>
282        )}
283        {list.map(c => (
284          <Box key={`clip-${c.id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
285            <Box key={`head-${c.id}`} flexDirection="row" columnGap={1}>
286              <Text dimColor>{stamp(c)}</Text>
287              <Button
288                key={`copy-${c.id}`}
289                plain
290                label="copy"
291                onPress={async press => {
292                  const r = await $.ui.copy({ text: c.text, surface: press.surface })
293                  await say(
294                    $,
295                    r.isCopied
296                      ? `Copied #${c.id} to the clipboard.`
297                      : `This app does not let a mod write the clipboard (${r.reason}); select the text here and press Ctrl+C, or Export.`,
298                  )
299                }}
300              />
301              <Button
302                key={`del-${c.id}`}
303                plain
304                label="delete"
305                onPress={async () => {
306                  await update($, clips, l => l.filter(x => x.id !== c.id))
307                  await persist($, await read($, clips))
308                  await say($, `Deleted #${c.id}.`)
309                }}
310              />
311            </Box>
312            <Markdown key={`text-${c.id}`} text={c.text} />
313          </Box>
314        ))}
315      </Box>
316    )
317  })
318}
319
types/index.d.ts 23 lines
1/** One kept snippet: its text (markdown), where it came from, and when. */
2export type Clip = {
3  id: number
4  text: string
5  from: 'selection' | 'answer' | 'note'
6  /** ISO timestamp of when it was kept. */
7  at: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    clipboard: {
13      clips: Clip[]
14      /** The main loop's last answer, kept so "Keep last answer" has it. */
15      lastAnswer: string | null
16      /** The one-line status shown at the top of the pane after an action. */
17      note: string | null
18      /** The session's project root; clips are filed in the store under it, one list per project. */
19      project: string | null
20    }
21  }
22}
23