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…

| Mod | What it does |
|---|---|
| token-buddy | A cute critter in a side panel showing how much context, 5-hour and weekly usage you have left. /tokens reopens it. |
| my-questions | A side panel listing every question you asked in the session; click one to jump to it in the chat. /questions reopens it. |
| maryville-theme | Maryville 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. |
| clipboard | A 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. |
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.
hooks/register.tsx 319 lines1import { 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}
319types/index.d.ts 23 lines1/** 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