SLOPSHOPPER

side-pane

Pins the options and open questions Claude asks you to choose from in a side pane, so you never scroll back to find "option 2"

paneguardcommandtoastprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-pane
│ ┃ Side pane ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing open. Claude fills this pane when it │ ┃ asks you to choose. ⏺ 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 │ │ › /side-pane │ ⎿ side-pane: Side pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side pane
Nothing open. Claude fills this pane when it asks you to choose.
README

side-pane

A Claude Code plugin that keeps the options and open questions Claude asks you about in a pane beside the conversation. In a long chat you no longer scroll back to remember what "option 2" was.

Claude fills the side pane with its options and questions, then updates it as you answer

Claude fills the pane itself: the plugin gives it a tool and a short instruction to call it whenever it lists options or asks you something. The topic you are on stays at the top; unanswered items of an earlier topic move under "On hold". Each session has its own pane, and the last pane of a directory comes back when you start a new session there.

Requirements

Claude Code 2.1.289 or newer. The pane is built on Claude Code mods (plugins of function hooks), which are in early access.

Use

  • The pane opens the first time Claude fills it.
  • /side-pane shows or hides it; /side-pane clear empties it. Once you hide the pane, Claude keeps it current but no longer reopens it in that session.
  • Works in the terminal and in the Code tab of Claude Code Desktop.
  • In the terminal, a pane Claude Code was not asked for is placed only at least 144 columns wide (110 once you have opened it yourself). In a narrower terminal you get a toast; run /side-pane and it opens at any width. Desktop places the pane itself.

Settings

Change them in /config:

SettingDefaultWhat it does
autoOpentrueOpen the pane when Claude first fills it
maxChars3000Longest pane content in characters; more is cut

Data

The plugin runs no commands, opens no network connections and reads no files. It keeps the pane's text in the session's plugin state, and the last pane of each working directory in Claude Code's local plugin store, keyed by that directory's path, so a new session there can show it again. /side-pane clear deletes that saved copy.

License

MIT. Source and issues: https://github.com/xixiaofinland/side-pane

Source 2 files
hooks/register.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const PLUGIN = 'side-pane'
5const PANE = 'side-pane'
6const TITLE = 'Side pane'
7const UPDATE_TOOL = 'mcp__side-pane__update'
8const CLEAR_TOOL = 'mcp__side-pane__clear'
9
10const markdown = atom({ plugin: 'side-pane', key: 'markdown' } as const, '')
11const isRestored = atom({ plugin: 'side-pane', key: 'isRestored' } as const, false)
12const hasWarned = atom({ plugin: 'side-pane', key: 'hasWarned' } as const, false)
13const isDismissed = atom({ plugin: 'side-pane', key: 'isDismissed' } as const, false)
14
15// $.store is shared by every session, so the last content is kept per working
16// directory and parallel sessions never overwrite each other's live pane.
17type Saved = { markdown: string; savedAt: string }
18const storeKey = (cwd: string) => `last:${cwd}`
19
20const instructions = (maxChars: number) =>
21  [
22    'Fill the side pane the user sees next to the conversation, so they need not scroll back to recall what "option 2" was.',
23    '',
24    '- Whenever your reply lists options for the user to pick from, or asks them questions they still have to answer, call this tool first with those items, then write the reply.',
25    '- Pass the whole pane each time: it replaces what was there. When the user settles some items, keep the ones still open and add any new question you ask.',
26    '- Group by context: a heading per project or topic, then its items, numbered the way you number them in the reply. The topic you are on now goes first.',
27    '- When the user moves on from a topic and leaves its items unanswered, move them under a last `## On hold` heading, a few words each. Drop an on-hold item when the user says so, or once a few turns pass without anyone coming back to it.',
28    `- Keep it short: a few words per item, no explanations, under ${maxChars} characters. Write in the language you are replying in.`,
29    `- Call \`${CLEAR_TOOL}\` only when nothing is left open.`,
30    '- Do not mention the pane or the tool calls in your reply.',
31  ].join('\n')
32
33// The pane splits into list items, each drawn with its own buttons, and the
34// runs of other lines (headings, prose) between them.
35type Block = { kind: 'text' | 'item'; text: string }
36const ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\S/
37
38function blocks(body: string): Block[] {
39  const out: Block[] = []
40  for (const line of body.split('\n')) {
41    if (ITEM.test(line)) {
42      out.push({ kind: 'item', text: line.trim() })
43      continue
44    }
45    const last = out[out.length - 1]
46    if (last?.kind === 'text') last.text += `\n${line}`
47    else if (line.trim() !== '') out.push({ kind: 'text', text: line })
48  }
49  return out.filter(block => block.text.trim() !== '')
50}
51
52const NUDGE = `Whenever your reply lists options for the user or asks them questions, call \`${UPDATE_TOOL}\` first, as its description says.`
53
54async function open($: EngineInterface) {
55  if (await read($, isDismissed)) return
56  if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
57  const opened = await $.ui.open({ id: PANE, title: TITLE })
58  if (opened.isPlaced || (await read($, hasWarned))) return
59  await update($, hasWarned, () => true)
60  $.ui.toast('side-pane: no room to open the pane unasked; run /side-pane to show it', {
61    timeoutMs: 8000,
62  })
63}
64
65async function show($: EngineInterface, text: string) {
66  await update($, markdown, () => text)
67  await update($, isRestored, () => false)
68  const cwd = await $.session.cwd()
69  if (text === '') await $.store.delete(storeKey(cwd))
70  else await $.store.set(storeKey(cwd), { markdown: text, savedAt: new Date().toISOString() })
71}
72
73export const register: Register = (on, options) => {
74  const autoOpen = options.autoOpen !== false
75  const maxChars = Math.max(200, Number(options.maxChars) || 3000)
76
77  on('session.start', async ($, e, next) => {
78    await $.tool.register({
79      name: 'update',
80      description: instructions(maxChars),
81      inputSchema: {
82        type: 'object',
83        properties: {
84          markdown: {
85            type: 'string',
86            description: 'The whole pane as Markdown: headings per context, numbered items.',
87          },
88        },
89        required: ['markdown'],
90      },
91    })
92    await $.tool.register({
93      name: 'clear',
94      description: 'Empty the side pane once nothing in it is open any more.',
95    })
96    await $.command.register({
97      name: 'side-pane',
98      description: 'Show or hide the side pane (`clear` empties it)',
99      argumentHint: '[clear]',
100      immediate: true,
101    })
102
103    // A fresh session starts empty; show the last pane of this directory so a
104    // resumed conversation has it back. A hot reload keeps its own state.
105    if ((await read($, markdown)) === '') {
106      const saved = (await $.store.get(storeKey(e.cwd))) as Saved | undefined
107      if (saved?.markdown) {
108        await update($, markdown, () => saved.markdown)
109        await update($, isRestored, () => true)
110      }
111    }
112
113    return next(e)
114  })
115
116  // The rules ride in the tool's own description, kept out of ToolSearch: a
117  // managed plugin may bypass a user plugin's prompt.context and
118  // prompt.compose hooks, and a deferred tool shows the model only its name.
119  on('tool.describe', { tool: UPDATE_TOOL }, async ($, e, next) => {
120    const described = await next(e)
121
122    return { ...described, isDeferred: false }
123  })
124
125  // A one-line nudge where the session lets user plugins add context.
126  on('prompt.context', async ($, e, next) => {
127    const context = await next(e)
128
129    return { ...context, blocks: [...context.blocks, { name: PLUGIN, text: NUDGE }] }
130  })
131
132  on('tool.call', { tool: UPDATE_TOOL }, async ($, e) => {
133    if (typeof e.markdown !== 'string') return { deny: 'markdown must be a string.' }
134    const text = e.markdown.trim()
135    const isCut = text.length > maxChars
136    await show($, isCut ? text.slice(0, maxChars) : text)
137    if (autoOpen && text !== '') await open($)
138
139    return {
140      result: isCut
141        ? `Side pane updated, cut to ${maxChars} characters. Shorten it next time.`
142        : 'Side pane updated.',
143    }
144  })
145
146  on('tool.call', { tool: CLEAR_TOOL }, async $ => {
147    await show($, '')
148
149    return { result: 'Side pane cleared.' }
150  })
151
152  // /side-pane toggles: closing it also stops updates reopening it this session.
153  // A pane still waiting for room, or behind another tab, counts as hidden.
154  on('command.run', { command: 'side-pane' }, async ($, e) => {
155    const arg = e.args.trim()
156    if (arg === 'clear') {
157      await show($, '')
158      return { text: 'Side pane cleared.' }
159    }
160    if (arg !== '')
161      return { text: 'Usage: /side-pane toggles the pane; /side-pane clear empties it.' }
162    const panes = await $.ui.panes()
163    if (panes.some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)) {
164      await update($, isDismissed, () => true)
165      await $.ui.close({ id: PANE })
166      return { text: 'Side pane closed; /side-pane shows it again.' }
167    }
168    await update($, isDismissed, () => false)
169    await $.ui.open({ id: PANE, title: TITLE })
170
171    return { text: 'Side pane opened.' }
172  })
173
174  // Once the person closes the pane (its close mark, ctrl+x x), updates stop
175  // reopening it for this session; /side-pane brings it back.
176  on('ui.close', async ($, e, next) => {
177    if (e.id === PANE && e.origin.kind === 'person') await update($, isDismissed, () => true)
178
179    return next(e)
180  })
181
182  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
183    const { Box, Text, Markdown, Button } = $.ui.resolve(e)
184    const body = await read($, markdown)
185    const restored = await read($, isRestored)
186
187    // Desktop draws the pane with no text selection, so each item carries its
188    // own copy and quote buttons; the person can still select in a terminal.
189    const copy = async (text: string) => {
190      const copied = await $.ui.copy({ text, surface: e.surface })
191      if (!copied.isCopied) $.ui.toast(`side-pane: could not copy (${copied.reason})`)
192    }
193    const quote = async (text: string) => {
194      const filled = await $.prompt.fill({ text: `> ${text}\n`, mode: 'insert' })
195      if (!filled.isFilled) $.ui.toast('side-pane: the prompt box is not taking text right now')
196    }
197
198    return (
199      <Box flexDirection="column">
200        {body.trim() === '' ? (
201          <Text dimColor>Nothing open. Claude fills this pane when it asks you to choose.</Text>
202        ) : (
203          <Box flexDirection="column">
204            {restored && <Text dimColor>From an earlier session here</Text>}
205            {blocks(body).map((block, i) =>
206              block.kind === 'text' ? (
207                <Markdown key={`text:${i}`} text={block.text} />
208              ) : (
209                <Box key={`item:${i}`} flexDirection="row" columnGap={1}>
210                  <Box flexGrow={1}>
211                    <Markdown text={block.text} />
212                  </Box>
213                  <Button key={`copy:${i}`} plain dimColor onPress={() => copy(block.text)}>
214                    copy
215                  </Button>
216                  <Button key={`quote:${i}`} plain dimColor onPress={() => quote(block.text)}>
217                    quote
218                  </Button>
219                </Box>
220              ),
221            )}
222            <Box flexDirection="row">
223              <Button key="copy-all" plain dimColor onPress={() => copy(body)}>
224                copy all
225              </Button>
226            </Box>
227          </Box>
228        )}
229      </Box>
230    )
231  })
232}
233
types/index.d.ts 15 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'side-pane': {
4      // What the model last put in the pane.
5      markdown: string
6      // True when markdown came from an earlier session in this directory.
7      isRestored: boolean
8      // True once this session told the person the pane could not be placed.
9      hasWarned: boolean
10      // True once the person closed the pane, so updates stop reopening it.
11      isDismissed: boolean
12    }
13  }
14}
15