Pin Claude replies to a side pane so they stay in view

A Claude Code mod that pins Claude's replies to a side pane, so steps or info you need stay in view while the conversation moves on.

It runs in the terminal and in the Claude desktop app.
/pin pins Claude's whole last reply, every part of it around tool calls; parts of it you pinned from the chat merge into that one pin. Clicking the ⚲ on any of its parts unpins the whole reply./unpin N removes pin #N, /unpin alone removes the last pin, and /unpin all clears them; in the pane, click a pin's ⚲ to unpin it./pins reopens the Pinned pane.Pins are saved, so they come back when you restart Claude Code. Each pin is cleared 7 days after you pinned it, unless you set keep pins to forever (see Configure). Expired pins are cleared when a session starts and whenever you pin or unpin.
Pins are kept per folder you start Claude in, so a subfolder or a git worktree of a project has its own list; this includes worktrees the desktop app makes for its sessions.
Open sessions started in the same folder share pins. An open session picks up a pin saved elsewhere when you send a prompt, when Claude finishes a turn, or when you run /pins.
Set these in /config. They're stored in ~/.claude/settings.json, so the desktop app follows them too.
| Setting | Options | Default | What it does |
|---|---|---|---|
| Pin it: keep pins | 7 days, forever | 7 days | How long saved pins are kept. forever keeps them until you unpin them. |
| Pin it: sync pins | on activity, every 5 seconds | on activity | When an open session picks up pins another session saved in the same folder. every 5 seconds also checks on a timer. |
Pinned replies are stored locally, in ~/.claude/plugins/store/, along with the first line of the prompt each one answered. pin-it makes no network requests, runs no processes, and doesn't change or block anything Claude does. /unpin all deletes a folder's saved pins.
Requires Claude Code 2.1.295 or later.
Inside a Claude Code session (run claude first; this is a slash command, not a shell command):
/plugin install pin-it --marketplace notyourcpa/pin-it
Answer y to add the marketplace, then pick a scope.
Or from your shell:
claude plugin install pin-it --marketplace notyourcpa/pin-it
This adds the marketplace and installs for your user; pass --scope project or --scope local to install elsewhere.
If the ⚲ or the Pinned pane doesn't appear:
claude --version is 2.1.295 or later./reload-plugins./pins to reopen the pane.claude --plugin-dir path/to/pin-it
The folder is watched; saving a file reloads the mod.
claude plugin validate path/to/pin-it
claude plugin test path/to/pin-it
Code: hooks/register.tsx. State types: types/index.d.ts. Tests: pin.test.ts.
MIT. See LICENSE.
hooks/register.tsx 283 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register, SessionMessage } from 'claude-code'
3
4import type { Pin } from '../types'
5
6const PANE = 'pinned'
7// A plain text glyph shaped like a map pin: takes the text color, never emoji red.
8const PIN_GLYPH = '⚲'
9// Anthropic orange; Button has no color prop, so the glyph is a colored Text child.
10const PIN_COLOR = '#D97757'
11
12// The chat's pin and the pane's unpin draw the same glyph. Terminal: bold and padded,
13// since a glyph can't be drawn bigger there.
14function pinLabel(surface: string): { bold: boolean; text: string } {
15 return surface === 'terminal' ? { bold: true, text: ` ${PIN_GLYPH} ` } : { bold: false, text: PIN_GLYPH }
16}
17
18const pins = atom({ plugin: 'pin-it', key: 'pins' } as const, [])
19
20type Dollar = Parameters<Hook<'session.start'>>[0]
21
22// Session state draws the UI but resets on restart; $.store keeps pins between sessions,
23// one list per project root. Changes apply to the stored list, so two open sessions in a
24// project don't drop each other's pins.
25async function storeKey($: Dollar) {
26 return `pins:${await $.session.root()}`
27}
28
29const DAY_MS = 24 * 60 * 60 * 1000
30// From the "keep" setting: how long a pin is kept after it was pinned. Set by register.
31let keepMs = 7 * DAY_MS
32
33function unexpired(list: Pin[], now: number): Pin[] {
34 return list.filter(p => now - p.pinnedAt < keepMs)
35}
36
37async function change($: Dollar, fn: (list: Pin[]) => Pin[]) {
38 const key = await storeKey($)
39 const stored = await $.store.get(key)
40 const next = unexpired(fn(Array.isArray(stored) ? (stored as Pin[]) : await read($, pins)), await $.clock.now())
41 await $.store.set(key, next)
42 await update($, pins, () => next)
43}
44
45// Pulls in pins another session saved for this project. There's no notice when one does,
46// so this runs at set moments (and on a timer, with the "sync" setting on "every 5 seconds").
47async function refresh($: Dollar) {
48 const stored = await $.store.get(await storeKey($))
49 if (!Array.isArray(stored)) return
50 const fresh = unexpired(stored as Pin[], await $.clock.now())
51 if (JSON.stringify(fresh) !== JSON.stringify(await read($, pins))) await update($, pins, () => fresh)
52}
53
54// Clears expired pins from every project's saved list, dropping lists left empty.
55async function sweep($: Dollar) {
56 const now = await $.clock.now()
57 for (const key of await $.store.keys()) {
58 const stored = await $.store.get(key)
59 if (!key.startsWith('pins:') || !Array.isArray(stored)) continue
60 const kept = unexpired(stored as Pin[], now)
61 if (kept.length === 0) await $.store.delete(key)
62 else if (kept.length !== stored.length) await $.store.set(key, kept)
63 }
64}
65
66function isPrompt(m: SessionMessage): boolean {
67 return m.role === 'user' && m.text !== '' && !m.toolResults?.length
68}
69
70// One reply = every assistant text from one prompt of the person's to the next; each
71// reply here is its blocks, the texts the chat draws apart (split by tool calls).
72function replyParts(messages: readonly SessionMessage[]): string[][] {
73 const out: string[][] = []
74 let current: string[] | null = null
75 for (const m of messages) {
76 if (isPrompt(m)) {
77 if (current?.length) out.push(current)
78 current = []
79 } else if (m.role === 'assistant' && m.text.trim() !== '') {
80 ;(current ??= []).push(m.text.trim())
81 }
82 }
83 if (current?.length) out.push(current)
84 return out
85}
86
87export function replies(messages: readonly SessionMessage[]): string[] {
88 return replyParts(messages).map(parts => parts.join('\n\n'))
89}
90
91// Whether `pin` holds the chat's block `id`: pinned from its button, or as part of a whole
92// reply pinned with /pin. Messages carry no ids, so /pin's blocks are matched by text.
93function holds(pin: Pin, id: string, text: string): boolean {
94 return pin.id === id || (pin.parts?.includes(text) ?? false)
95}
96
97// The first line of the prompt before the last assistant message that `text` holds or is
98// part of. Messages carry no ids, so a reply is found by its text.
99export function promptBefore(messages: readonly SessionMessage[], text: string): string | undefined {
100 let prompt: string | undefined
101 let found: string | undefined
102 for (const m of messages) {
103 const own = m.text.trim()
104 if (isPrompt(m)) prompt = m.text
105 else if (m.role === 'assistant' && own !== '' && (own.includes(text) || text.includes(own))) found = prompt
106 }
107 const line = found?.split('\n').find(l => l.trim() !== '')?.trim()
108 return line === undefined ? undefined : line.slice(0, 200)
109}
110
111// The terminal cuts the pane's header at its edge (wrap="truncate-end"); the desktop app
112// ignores that and wraps, so there the preview is cut to fit one line of a narrow pane.
113const DESKTOP_PREVIEW_CHARS = 45
114
115export function promptPreview(prompt: string, surface: string): string {
116 if (surface === 'terminal' || prompt.length <= DESKTOP_PREVIEW_CHARS) return prompt
117 return `${prompt.slice(0, DESKTOP_PREVIEW_CHARS - 1).trimEnd()}…`
118}
119
120export function firstLine(text: string): string {
121 const line = text.split('\n').find(l => l.trim() !== '') ?? ''
122 const plain = line.replace(/^[#>*\-`\s]+/, '')
123 return plain.length > 60 ? `${plain.slice(0, 57)}...` : plain
124}
125
126export const register: Register = (on, options) => {
127 keepMs = options.keep === 'forever' ? Infinity : 7 * DAY_MS
128
129 on('session.start', async ($, e, next) => {
130 await sweep($)
131 await refresh($)
132 if (options.sync === 'every 5 seconds') $.clock.every(5000, () => void refresh($))
133 await $.command.register({
134 name: 'pin',
135 description: "Pin Claude's last reply to the Pinned pane",
136 })
137 await $.command.register({
138 name: 'unpin',
139 description: 'Unpin a reply: /unpin N (as numbered in the pane), or /unpin all',
140 argumentHint: '[N|all]',
141 })
142 await $.command.register({ name: 'pins', description: 'Show the Pinned pane' })
143
144 return next(e)
145 })
146
147 on('command.run', { command: 'pin' }, async $ => {
148 const messages = await $.session.messages()
149 const parts = (Array.isArray(messages) ? replyParts(messages) : []).at(-1)
150 if (parts === undefined) return { text: 'No reply to pin yet.' }
151 const text = parts.join('\n\n')
152 const list = await read($, pins)
153 if (!list.some(p => p.text === text)) {
154 const now = await $.clock.now()
155 const prompt = promptBefore(messages as SessionMessage[], text)
156 const pin: Pin = { id: `cmd-${now}`, text, pinnedAt: now, prompt, parts }
157 // The whole reply takes the place of its blocks pinned from their buttons.
158 await change($, l => [...l.filter(p => p.parts !== undefined || !parts.includes(p.text)), pin])
159 }
160 await $.ui.open({ id: PANE, title: 'Pinned' })
161
162 return { text: `Pinned: ${firstLine(text)}` }
163 })
164
165 on('command.run', { command: 'unpin' }, async ($, e) => {
166 const arg = e.args.trim()
167 const list = await read($, pins)
168 if (arg === 'all') {
169 await change($, () => [])
170 return { text: `Unpinned ${list.length}.` }
171 }
172 const n = arg === '' ? list.length : Number(arg)
173 const target = list[n - 1]
174 if (!Number.isInteger(n) || target === undefined) {
175 return { text: list.length ? `Usage: /unpin [1-${list.length}|all]` : 'Nothing pinned.' }
176 }
177 await change($, l => l.filter(p => p.id !== target.id))
178
179 return { text: `Unpinned #${n}.` }
180 })
181
182 on('prompt.submit', async ($, e, next) => {
183 await refresh($)
184 return next(e)
185 })
186
187 on('turn.complete', async ($, e, next) => {
188 const result = await next(e)
189 await refresh($)
190 return result
191 })
192
193 on('command.run', { command: 'pins' }, async $ => {
194 await refresh($)
195 await $.ui.open({ id: PANE, title: 'Pinned' })
196
197 return { text: 'Pinned pane opened.' }
198 })
199
200 // A pin button under each block of Claude's reply: shown on hover, always once pinned.
201 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
202 const drawn = await next(e)
203 if (e.props.isSummary || e.props.text.trim() === '') return drawn
204
205 const { Box, Text, Button } = $.ui.resolve(e)
206 const id = e.requestId
207 const text = e.props.text.trim()
208 const isPinned = (await read($, pins)).some(p => holds(p, id, text))
209 const toggle = async () => {
210 if (isPinned) {
211 // A block of a reply pinned with /pin unpins that whole reply.
212 await change($, l => l.filter(p => !holds(p, id, text)))
213 return
214 }
215 const now = await $.clock.now()
216 const messages = await $.session.messages()
217 const prompt = Array.isArray(messages) ? promptBefore(messages, text) : undefined
218 await change($, l => [...l.filter(p => p.id !== id), { id, text, pinnedAt: now, prompt }])
219 await $.ui.open({ id: PANE, title: 'Pinned' })
220 }
221
222 return (
223 <Box key={`row-${id}`} flexDirection="column">
224 {drawn}
225 {/* Absolute: shown or hidden, it never moves the transcript. Pinned: always shown;
226 the engine refuses a hover reveal on a Box that's already shown, so only unpinned gets one. */}
227 <Box
228 position="absolute"
229 top={0}
230 right={0}
231 {...(isPinned ? { display: 'flex' } : { display: 'none', hover: { display: 'flex' } })}
232 >
233 <Button key={`pin-${id}`} plain dimColor={!isPinned} onPress={toggle}>
234 <Text color={PIN_COLOR} bold={pinLabel(e.surface).bold}>
235 {pinLabel(e.surface).text}
236 </Text>
237 </Button>
238 </Box>
239 </Box>
240 )
241 })
242
243 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
244 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
245 const list = await read($, pins)
246
247 if (list.length === 0) {
248 return (
249 <Box flexDirection="column">
250 <Text dimColor>Nothing pinned. Hover a reply and click the pin at its top right, or type /pin.</Text>
251 </Box>
252 )
253 }
254
255 return (
256 <Box flexDirection="column">
257 {list.map((pin, i) => (
258 <Box flexDirection="column" marginBottom={1}>
259 <Box justifyContent="space-between">
260 <Box flexShrink={1} marginRight={1}>
261 <Text wrap="truncate-end">
262 <Text bold>#{i + 1}</Text>
263 {pin.prompt !== undefined && <Text dimColor> {promptPreview(pin.prompt, e.surface)}</Text>}
264 </Text>
265 </Box>
266 <Button
267 key={`unpin-${pin.id}`}
268 plain
269 onPress={() => change($, l => l.filter(p => p.id !== pin.id))}
270 >
271 <Text color={PIN_COLOR} bold={pinLabel(e.surface).bold}>
272 {pinLabel(e.surface).text}
273 </Text>
274 </Button>
275 </Box>
276 <Markdown text={pin.text} />
277 </Box>
278 ))}
279 </Box>
280 )
281 })
282}
283types/index.d.ts 12 lines1// `prompt`: the first line of the person's prompt before the reply; absent on pins saved
2// before it was recorded, or when the prompt couldn't be found.
3// `parts`: on a whole reply pinned with /pin, the text of each block it joins; absent on a
4// block pinned from its button, and on /pin pins saved before it was recorded.
5export type Pin = { id: string; text: string; pinnedAt: number; prompt?: string; parts?: string[] }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'pin-it': { pins: Pin[] }
10 }
11}
12