Pin notes to a session: /pin keeps them on show above the prompt and in Claude's system prompt, so they hold through compaction and come back on resume

Sometimes there's one thing I need Claude to remember for the whole session, like "use Australian spelling", "don't touch the tests folder" or the name of the branch I'm on. If I just say it once, it scrolls away, and after a compact it can get lost in the summary. This mod lets you pin it instead.
/pin Use Australian spelling pins a note. It shows above the prompt with a 📌 and stays there for the rest of the session./pin on its own pins the last message you typed, so you can pin something after you've already said it./pin list shows every pin in full, /unpin 2 removes one, /unpin on its own removes the latest one, and /unpin all clears them./pin hide and /pin show hide or show the notes above the prompt. Claude still sees them while they're hidden.http:// or https://) are clickable. In the terminal that's ctrl+click in most terminals; if yours doesn't support links, it shows the text with the URL after it.claude --resume they come back, but /clear or a new session starts with none.Up to 20 pins, each up to 1000 characters.
/pin on its own can pin the last one. It keeps the latest one in memory and doesn't store or send it anywhere. Slash commands and messages from other tools are skipped.MIT
hooks/register.tsx 232 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pin } from '../types'
5
6const PLUGIN = 'session-pins'
7const MAX_PINS = 20
8const MAX_CHARS = 1000
9// Pins rows shown above the prompt before "+N more"; context-bar shares the band.
10const MAX_ROWS = 3
11// Sessions kept in the store, so resume brings pins back without the store growing forever.
12const MAX_SESSIONS = 50
13
14const pins = atom({ plugin: 'session-pins', key: 'pins' } as const, [])
15const isHidden = atom({ plugin: 'session-pins', key: 'isHidden' } as const, false)
16const expanded = atom({ plugin: 'session-pins', key: 'expanded' } as const, null)
17
18type Saved = { pins: Pin[]; savedAt: number }
19const sessionKey = (id: string) => `session:${id}`
20
21// The person's last prompt typed at the prompt box, for /pin with nothing after it.
22let lastPrompt = ''
23
24const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
25
26// Splits a pin into plain text and URLs, so the URLs draw as links. Trailing punctuation stays text.
27export const parts = (text: string) =>
28 text.split(/(https?:\/\/[^\s<>"']*[^\s<>"'.,;:!?)\]])/).filter(Boolean).map(part => ({ part, isUrl: /^https?:\/\//.test(part) }))
29
30export type Piece = { text: string; href?: string }
31const LINK_MIN = 24
32
33// Cut to `max` characters, ending in an ellipsis.
34const cut = (text: string, max: number) => (text.length <= max ? text : max <= 1 ? '…' : `${text.slice(0, max - 1).trimEnd()}…`)
35
36// A link's text, shortened from the middle so the site and the end of the path both show.
37const shortUrl = (url: string, max: number) => {
38 if (url.length <= max) return url
39 const bare = url.replace(/^https?:\/\//, '')
40 if (bare.length <= max) return bare
41 const tail = Math.floor((max - 1) / 3)
42 return `${bare.slice(0, max - 1 - tail)}…${bare.slice(-tail)}`
43}
44
45// Fits a pin into `width` columns so its links stay on screen: the plain text after the last link gives way first,
46// then the text before it, then the links' labels shrink (each still opens the full URL).
47export function fit(text: string, width: number): Piece[] {
48 const pieces: Piece[] = parts(text).map(({ part, isUrl }) => (isUrl ? { text: part, href: part } : { text: part }))
49 let over = pieces.reduce((n, p) => n + p.text.length, 0) - width
50 for (let i = pieces.length - 1; i >= 0 && over > 0; i--) {
51 const p = pieces[i]!
52 if (p.href || p.text.length <= 1) continue
53 const keep = Math.max(1, p.text.length - over)
54 over -= p.text.length - keep
55 pieces[i] = { text: cut(p.text, keep) }
56 }
57 for (let i = 0; i < pieces.length && over > 0; i++) {
58 const p = pieces[i]!
59 if (!p.href || p.text.length <= LINK_MIN) continue
60 const keep = Math.max(LINK_MIN, p.text.length - over)
61 over -= p.text.length - keep
62 pieces[i] = { text: shortUrl(p.href, keep), href: p.href }
63 }
64 return pieces
65}
66
67// Only changes when the pins do, so it doesn't re-cache the conversation every turn.
68export const section = (list: readonly Pin[]) =>
69 [
70 '# Pinned notes',
71 'The user pinned these to the top of this session. Treat them as standing instructions and context for the whole session: they still apply after compaction, until the user unpins them. Don\'t comment on them unless asked.',
72 ...list.map((p, i) => `${i + 1}. ${p.text}`),
73 ].join('\n')
74
75async function save($: EngineInterface, list: Pin[]) {
76 await update($, pins, () => list)
77 const key = sessionKey(await $.session.id())
78 if (list.length === 0) return $.store.delete(key)
79 const saved: Saved = { pins: list, savedAt: await $.clock.now() }
80 await $.store.set(key, saved)
81 const keys = (await $.store.keys()).filter(k => k.startsWith('session:'))
82 for (const old of keys.slice(0, Math.max(0, keys.length - MAX_SESSIONS))) await $.store.delete(old)
83}
84
85async function load($: EngineInterface) {
86 const saved = (await $.store.get(sessionKey(await $.session.id()))) as Saved | undefined
87 await update($, pins, () => saved?.pins ?? [])
88}
89
90const list = (all: readonly Pin[]) =>
91 all.length === 0 ? 'Nothing pinned. /pin <text> pins a note; /pin on its own pins your last message.' : all.map((p, i) => `${i + 1}. ${p.text}`).join('\n')
92
93async function add($: EngineInterface, text: string) {
94 const now = await read($, pins)
95 if (now.length >= MAX_PINS) return `You already have ${MAX_PINS} pins. /unpin one first.`
96 const clean = text.trim().slice(0, MAX_CHARS)
97 if (now.some(p => p.text === clean)) return 'That\'s already pinned.'
98 await save($, [...now, { text: clean, pinnedAt: await $.clock.now() }])
99 await update($, isHidden, () => false)
100 return `Pinned (#${now.length + 1}). Claude sees it for the rest of the session.`
101}
102
103async function remove($: EngineInterface, arg: string) {
104 const now = await read($, pins)
105 if (now.length === 0) return 'Nothing pinned.'
106 if (arg === 'all') {
107 await save($, [])
108 return `Unpinned all ${now.length}.`
109 }
110 // No number: the most recent pin.
111 const n = arg === '' ? now.length : Number(arg)
112 if (!Number.isInteger(n) || n < 1 || n > now.length) return `Usage: /unpin [1-${now.length} | all]`
113 await save($, now.filter((_, i) => i !== n - 1))
114 return `Unpinned #${n}: ${oneLine(now[n - 1]!.text).slice(0, 80)}`
115}
116
117export const register: Register = on => {
118 on('session.start', async ($, e, next) => {
119 await $.command.register({
120 name: 'pin',
121 description: 'Pin a note to this session (shown above the prompt, kept in Claude\'s context); on its own pins your last message',
122 argumentHint: '[<text> | list | hide | show]',
123 })
124 await $.command.register({
125 name: 'unpin',
126 description: 'Unpin a note: /unpin 2, /unpin all; on its own unpins the latest',
127 argumentHint: '[<number> | all]',
128 })
129 // A new or resumed session reads its own pins; a hot reload reads back the same ones.
130 await load($).catch(() => {})
131 return next(e)
132 })
133
134 on('prompt.submit', async ($, e, next) => {
135 if (e.origin.kind === 'composer' && !e.text.trimStart().startsWith('/')) lastPrompt = e.text
136 return next(e)
137 })
138
139 on('prompt.compose', async ($, e, next) => {
140 const composed = await next(e)
141 const all = await read($, pins)
142 if (all.length === 0) return composed
143 return { ...composed, sections: [...composed.sections, { id: `${PLUGIN}:pins`, text: section(all), scope: 'session' as const }] }
144 })
145
146 on('command.run', { command: 'pin' }, async ($, e) => {
147 const arg = e.args.trim()
148 const word = arg.toLowerCase()
149 if (word === 'list') return { text: list(await read($, pins)) }
150 if (word === 'hide' || word === 'show') {
151 await update($, isHidden, () => word === 'hide')
152 return { text: word === 'hide' ? 'Pins hidden above the prompt (Claude still sees them). /pin show brings them back.' : 'Pins shown above the prompt.' }
153 }
154 if (arg) return { text: await add($, arg) }
155 if (!lastPrompt.trim()) return { text: 'Nothing to pin yet. Use /pin <text>, or send a message first and run /pin to pin it.' }
156 return { text: await add($, lastPrompt) }
157 })
158
159 on('command.run', { command: 'unpin' }, async ($, e) => ({ text: await remove($, e.args.trim().toLowerCase()) }))
160
161 // Pins sit on top of whatever else draws above the prompt (context-bar's band included).
162 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
163 const all = await read($, pins)
164 if (e.props.hasSurvey || all.length === 0 || (await read($, isHidden))) return next(e)
165 const beneath = await next(e)
166 const { Box, Text, Link, Button } = $.ui.resolve(e)
167 const open = await read($, expanded)
168 const toggle = (at: string | null) => () => void update($, expanded, () => at).catch(() => {})
169 const linked = (pieces: Piece[]) => pieces.map(piece => (piece.href ? <Link href={piece.href}>{piece.text}</Link> : piece.text))
170 const room = Math.max(1, Math.min(MAX_ROWS, Math.floor(e.props.maxRows / 3)))
171 // The last row left says how many more there are; with one row, it shares the first pin's line.
172 const shown = all.length > room ? all.slice(0, Math.max(1, room - 1)) : all
173 const more = all.length - shown.length
174 const isSqueezed = more > 0 && room === 1
175
176 return (
177 <Box flexDirection="column">
178 {shown.map((p, i) => {
179 const number = all.length > 1 ? `${i + 1}. ` : ''
180 const squeeze = isSqueezed ? `(+${more} more) ` : ''
181 // The pin glyph takes two columns and a space.
182 const width = e.props.bodyColumns - 3 - number.length - squeeze.length
183 const text = oneLine(p.text)
184 const head = (
185 <Text>
186 <Text color="yellow">📌 </Text>
187 <Text dimColor>{number}</Text>
188 {squeeze && <Text dimColor>{squeeze}</Text>}
189 </Text>
190 )
191 // Opened out: the whole pin, wrapped, links live, and a way to fold it back.
192 if (open === p.text)
193 return (
194 <Box flexDirection="column">
195 <Text wrap="wrap">
196 {head}
197 {linked(fit(text, Infinity))}
198 </Text>
199 <Button key={`less-${i + 1}`} plain dimColor label=" ▴ show less" onPress={toggle(null)} />
200 </Box>
201 )
202 if (text.length <= width)
203 return (
204 <Text wrap="truncate">
205 {head}
206 {linked(fit(text, width))}
207 </Text>
208 )
209 // Cut off: the text either side of a link opens it out, the link stays a link, and a ▾ at the end says there's more.
210 return (
211 <Box flexDirection="row">
212 {head}
213 {fit(text, width - 2).map((piece, j) =>
214 piece.href ? (
215 <Text>
216 <Link href={piece.href}>{piece.text}</Link>
217 </Text>
218 ) : (
219 <Button key={`more-${i + 1}-${j}`} plain label={piece.text} onPress={toggle(p.text)} />
220 ),
221 )}
222 <Button key={`more-${i + 1}`} plain dimColor label=" ▾" onPress={toggle(p.text)} />
223 </Box>
224 )
225 })}
226 {more > 0 && !isSqueezed && <Text dimColor> +{more} more (/pin list)</Text>}
227 {beneath}
228 </Box>
229 )
230 })
231}
232types/index.d.ts 14 lines1export type Pin = { text: string; pinnedAt: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-pins': {
6 pins: Pin[]
7 // Hides the band only; Claude still sees the pins.
8 isHidden: boolean
9 // The pin opened out to full length (its text, which no other pin shares), or null.
10 expanded: string | null
11 }
12 }
13}
14