SLOPSHOPPER

pin-message

Pin selected text or Claude's last response to a side pane that stays visible while the conversation scrolls on

newpanecommand
v0.1.0MITupdated 2026-10-04GruperTal/claude-pin-message
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pin-message
│ ┃ pins ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing pinned. Run /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 │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pin │ ⎿ pin-message: Pinned Claude's last response. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pins
Nothing pinned. Run /pin.
README

Claude Pin Message

A Claude Code mod that pins a response beside the conversation, so the plan, the command or the answer you keep scrolling back to stays on screen while the session goes on.

 ❯ what's the rollout plan?                │ ✕ unpin
 ● 1. Ship behind a flag                   │ 1. Ship behind a flag
   2. Canary at 5%                         │ 2. Canary at 5%
   3. Full rollout Thursday                │ 3. Full rollout Thursday
 ❯ /pin                                    │
 ❯ ok, start with step 1                   │ ✕ unpin
 ● Adding the flag...                      │ npm run deploy -- --canary
   (the conversation keeps scrolling)      │

Install

Inside Claude Code, run:

/plugin marketplace add GruperTal/claude-pin-message
/plugin install pin-message@claude-pin-message
/reload-plugins

Use

You doWhat happens
Select text with the mouse, then /pinThe selection is pinned
/pin with nothing selectedClaude's last response is pinned, drawn as markdown
Press ✕ unpin above a pinThat pin is removed
/unpinEvery pin is removed and the pane closes

Pin as many things as you like; they stack in one "Pinned" pane. Close the pane and your pins are kept: the next /pin brings it back.

Good to know

  • Selections need the fullscreen layout. Claude Code only reports a mouse selection there. Elsewhere /pin pins the last response, and the pane sits above the prompt instead of beside the transcript.
  • Pins last for the session. A new session starts with none.
  • A pin holds up to 10,000 characters. A longer one is cut.

How it works

It is a mod: one hooks module, about 90 lines, in hooks/register.tsx. /pin reads the selection and the session's messages, keeps the text in session state, and opens a pane that draws the pins.

It is local-only: no network requests, no files written, no model calls. Run claude plugin validate on the repo to see every event it hooks and every call it makes.

Requirements

Claude Code with mods support. Built and tested on v2.1.289.

Development

claude --plugin-dir .      # run it from a checkout
claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 93 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Pin } from '../types'
5
6const PANE = 'pins'
7const pins = atom({ plugin: 'pin-message', key: 'pins' } as const, [])
8
9// ponytail: Markdown takes 10000 chars and no control characters but tab and
10// newline, so a longer pin is cut; page it across several elements if that bites.
11const clean = (text: string) =>
12  text
13    .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
14    .trim()
15    .slice(0, 10000)
16
17export const register: Register = on => {
18  on('session.start', async ($, e, next) => {
19    await $.command.register({
20      name: 'pin',
21      description: "Pin the selected text, or Claude's last response, to a side pane",
22    })
23    await $.command.register({
24      name: 'unpin',
25      description: 'Remove every pin and close the pane',
26    })
27
28    return next(e)
29  })
30
31  on('command.run', { command: 'pin' }, async $ => {
32    const selected = clean((await $.ui.selection())?.text ?? '')
33    const messages = await $.session.messages()
34    const replies = Array.isArray(messages)
35      ? messages.filter(one => one.role === 'assistant' && one.text.trim() !== '')
36      : []
37    const text = selected || clean(replies.at(-1)?.text ?? '')
38
39    if (text === '') {
40      return { text: 'Nothing to pin: no selection and no response yet.' }
41    }
42
43    // Selected text is what the screen showed, already rendered: drawn as is.
44    const pin: Pin = { text, isMarkdown: selected === '' }
45    await update($, pins, list =>
46      list.some(one => one.text === text) ? list : [...list, pin],
47    )
48    const opened = await $.ui.open({ id: PANE, title: 'Pinned' })
49
50    return {
51      text: opened.isPlaced
52        ? `Pinned ${selected === '' ? "Claude's last response" : 'the selection'}.`
53        : `Pinned, but the pane is not shown: ${opened.reason}`,
54    }
55  })
56
57  on('command.run', { command: 'unpin' }, async $ => {
58    await update($, pins, () => [])
59    await $.ui.close({ id: PANE })
60
61    return { text: 'Unpinned everything.' }
62  })
63
64  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
65    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
66    const list = await read($, pins)
67
68    return (
69      <Box flexDirection="column">
70        {list.length === 0 && <Text dimColor>Nothing pinned. Run /pin.</Text>}
71        {list.map((pin, index) => (
72          <Box flexDirection="column" marginBottom={1}>
73            <Button
74              key={`unpin:${index}`}
75              plain
76              dimColor
77              label="✕ unpin"
78              onPress={() =>
79                update($, pins, all => all.filter(one => one.text !== pin.text))
80              }
81            />
82            {pin.isMarkdown ? (
83              <Markdown text={pin.text} />
84            ) : (
85              <Text>{pin.text}</Text>
86            )}
87          </Box>
88        ))}
89      </Box>
90    )
91  })
92}
93
types/index.d.ts 8 lines
1export type Pin = { text: string; isMarkdown: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'pin-message': { pins: Pin[] }
6  }
7}
8