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

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) │
Inside Claude Code, run:
/plugin marketplace add GruperTal/claude-pin-message
/plugin install pin-message@claude-pin-message
/reload-plugins
| You do | What happens |
|---|---|
Select text with the mouse, then /pin | The selection is pinned |
/pin with nothing selected | Claude's last response is pinned, drawn as markdown |
Press ✕ unpin above a pin | That pin is removed |
/unpin | Every 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.
/pin pins the last response, and the pane sits above the prompt instead of beside the transcript.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.
Claude Code with mods support. Built and tested on v2.1.289.
claude --plugin-dir . # run it from a checkout
claude plugin validate .
claude plugin test .
hooks/register.tsx 93 lines1import { 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}
93types/index.d.ts 8 lines1export type Pin = { text: string; isMarkdown: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'pin-message': { pins: Pin[] }
6 }
7}
8