SLOPSHOPPER

pin-it

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

newpanerowscommandprompttimer
v0.1.0MITupdated 2026-10-15notyourcpa/pin-it
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pin-it
│ ┃ Pinned ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing pinned. Hover a reply and click the │ ┃ pin at its top right, or type /pin. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ⟨Claude Code's own drawing⟩ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pin │ ⎿ pin-it: Pinned: I updated src/auth.ts to reject expired claims a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pinned
Nothing pinned. Hover a reply and click the pin at its top right, or type /pin.
Claude's reply
⟨Claude Code's own drawing⟩
README

pin-it

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.

A checklist reply pinned in the side pane while the chat moves on to a follow-up question

It runs in the terminal and in the Claude desktop app.

Use

  • Hover a reply and click the ⚲ at its top right to pin it; click again to unpin. A reply that uses tools is drawn in parts, and each part has its own ⚲. Pinned parts keep their ⚲ showing.
  • /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.
  • Each pin in the pane is headed by the first line of the prompt it answered.

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.

Limits

  • The pane draws pins with the app's generic markdown, so styling (e.g. inline-code color) differs slightly from the chat.
  • The desktop app draws the ⚲ grey; the terminal draws it orange.

Configure

Set these in /config. They're stored in ~/.claude/settings.json, so the desktop app follows them too.

SettingOptionsDefaultWhat it does
Pin it: keep pins7 days, forever7 daysHow long saved pins are kept. forever keeps them until you unpin them.
Pin it: sync pinson activity, every 5 secondson activityWhen an open session picks up pins another session saved in the same folder. every 5 seconds also checks on a timer.

Privacy

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.

Install

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.

Troubleshooting

If the ⚲ or the Pinned pane doesn't appear:

  1. Check that claude --version is 2.1.295 or later.
  2. Run /reload-plugins.
  3. Run /pins to reopen the pane.

Develop

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.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 283 lines
1import { 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}
283
types/index.d.ts 12 lines
1// `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