SLOPSHOPPER

side-prompt

A pane docked beside the transcript with prompt and side-question tabs, so you can type while you scroll.

newpanebandcommandtoastmodel
v0.2.0MITupdated 2026-10-23seanperkins/side-prompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-prompt
│ ┃ Side prompt ✕ › fix the failing auth test and add an audit log call │ ┃ Your questions are worth asking. │ ┃ Enter sends. Esc returns to the main prompt. ⏺ Read(src/auth.ts) │ ┃ [ ▸ prompt ] [ btw ] ⎿ Read 6 lines │ ┃ Type here while you scroll ⏎ send ⏺ 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 │ ⎿ side-prompt: Side prompt opened. Esc returns to the main prompt. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side prompt
Your questions are worth asking. Enter sends. Esc returns to the main prompt. [ ▸ prompt ] [ btw ] Type here while you scroll ⏎ send
README

side-prompt

A Claude Code mod that docks a pane beside the transcript with prompt and side-question tabs, so you can type while you scroll. The pane opens on a short affirmation.

The side pane docked beside the transcript: an affirmation, the prompt and btw tabs, and a btw answer in a labeled box, with a toast that it landed

Install

At a Claude Code prompt:

/plugin install side-prompt --marketplace seanperkins/side-prompt

Or from a shell:

claude plugin install side-prompt --marketplace seanperkins/side-prompt

Answer y to add the marketplace, then pick a scope. Run /reload-plugins or start a new session, then type /side.

Written against Claude Code 2.1.309; the mod API is early access and may change.

If you are Claude and a person asked you to install this

  1. Run the shell line above. If claude plugin install does not exist in their build, it has no mod support: say so and stop.
  2. Tell the person to run /reload-plugins or start a new session, then type /side.
  3. Do not edit ~/.claude/settings.json or their shell profile yourself.

From a clone

git clone https://github.com/seanperkins/side-prompt.git
claude --plugin-dir ./side-prompt

Using it

  • /side opens the pane. Run it again to close it. It runs even while Claude is working.
  • A side pane button sits at the right end of the row above the prompt while the pane is closed. Click it to open the pane with no command. It asks for the keyboard only if your prompt box is empty, so a draft in progress keeps it.
  • The pane has two tabs, prompt and btw, and shows one field at a time. Click a tab and the cursor lands in its field, so you can type at once (the pane can only take the keyboard while your main prompt box is empty; with a draft there, click into the field). Each tab keeps its own draft.
  • The top line is an affirmation; the next one shows each time Claude finishes a turn. Edit hooks/affirmations.ts to change them.
  • Esc returns the keys to the main prompt. Back in the pane: ctrl+x tab or a click. Close: ctrl+x x (default keys).
  • If the pane was open when a session ended, the next session reopens it. Closing it yourself keeps it closed.
  • The pane says "Claude is working..." while a turn runs.

The two tabs

  • prompt: Enter sends the text as a normal prompt. It waits until Claude is idle. @file mentions and pasted images are not expanded.
  • btw: Enter asks a one-off question over the session so far, with no tools, and shows the answer in a box labeled btw in the pane, plus a toast. It is not the built-in /btw and its answer is not added to the transcript. Each ask costs tokens and takes a while on a long session (12s idle, 21s mid-response in one check). It has nothing to answer in a new session or right after /clear.

Limits

  • The pane docks beside the transcript only in the fullscreen layout at 110 columns or wider. Elsewhere it sits inline above the prompt.
  • A pane the mod opens on its own (the reopen at session start) is held undrawn below 144 columns, or 110 if you opened it before and have not closed it by hand since. /side and the button always place it.
  • The built-in prompt box stays; the mod API has no way to hide it.
  • Checked in a live session: the btw field answering while Claude was still working, the button opening the pane, the tabs with the cursor landing in the field, and the btw label on the answer box.
  • Not yet checked in a live session: the button's right alignment, /side while Claude is working, scrolling the transcript with the mouse wheel while the pane has focus, reopening in a new session, and closing with ctrl+x x keeping it closed.

Development

claude plugin validate .
claude plugin test .

To type-check, load the mod once so the engine lays its types in .claude-plugin/types/, then run tsc -p ..

License

MIT

Source 3 files
hooks/register.tsx 271 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { AFFIRMATIONS } from './affirmations'
5
6const PANE = 'side-prompt'
7// The `$.store` key that remembers the pane was open when the session ended.
8const WAS_OPEN = 'wasOpen'
9
10const prompt = atom({ plugin: 'side-prompt', key: 'prompt' } as const, '')
11const btw = atom({ plugin: 'side-prompt', key: 'btw' } as const, '')
12// Change the tag when what `note` holds changes: it declines the value an
13// earlier version stored, which outlives a reload.
14const note = atom({ plugin: 'side-prompt', key: 'note' } as const, '', { shape: 'note-2' })
15const answer = atom({ plugin: 'side-prompt', key: 'answer' } as const, '')
16const isWorking = atom({ plugin: 'side-prompt', key: 'isWorking' } as const, false)
17const affirmationAt = atom({ plugin: 'side-prompt', key: 'affirmationAt' } as const, 0)
18const isPaneOpen = atom({ plugin: 'side-prompt', key: 'isPaneOpen' } as const, false)
19const tab = atom({ plugin: 'side-prompt', key: 'tab' } as const, 'prompt')
20
21const TABS = ['prompt', 'btw'] as const
22
23const NO_ANSWER: Record<string, string> = {
24  'nothing-to-fork': 'Nothing to ask about yet: send a prompt first (or you just ran /clear).',
25  'empty-reply': 'The model sent back nothing.',
26  aborted: 'The question was interrupted.',
27}
28
29const openPane = async ($: EngineInterface, options: { focus?: true } = {}) => {
30  const opened = await $.ui.open({ id: PANE, title: 'Side prompt', columns: 52, ...options })
31  await update($, isPaneOpen, () => opened.isPlaced)
32
33  return opened
34}
35
36// What `/side` and the band's button both do: open the pane, ask for the
37// keyboard, and remember it was open for the next session.
38const showPane = async ($: EngineInterface) => {
39  const opened = await openPane($, { focus: true })
40  await $.store.set(WAS_OPEN, true)
41
42  return opened
43}
44
45// The band button's press: a failure here has no hook to catch it, so say it.
46const pressSide = async ($: EngineInterface) => {
47  try {
48    const opened = await showPane($)
49    if (!opened.isPlaced) {
50      $.ui.toast(`side pane: ${String(opened.reason)}`)
51    }
52  } catch (error) {
53    $.ui.toast(`side pane: ${error instanceof Error ? error.message : String(error)}`)
54  }
55}
56
57export const register: Register = on => {
58  on('turn.start', async ($, e, next) => {
59    if (e.agentId === undefined) {
60      await update($, isWorking, () => true)
61    }
62
63    return next(e)
64  })
65
66  on('turn.complete', async ($, e, next) => {
67    if (e.agentId === undefined) {
68      await update($, isWorking, () => false)
69      await update($, affirmationAt, at => (at + 1) % AFFIRMATIONS.length)
70    }
71
72    return next(e)
73  })
74
75  on('session.start', async ($, e, next) => {
76    await $.command.register({
77      name: 'side',
78      description: 'Open or close a side pane to prompt or ask a side question without touching the main prompt',
79      // Runs at once while Claude is working, instead of waiting for the turn.
80      immediate: true,
81    })
82    // Start each session on a different line, one that holds for the minute.
83    const now = await $.clock.now()
84    await update($, affirmationAt, () => Math.floor(now / 60_000) % AFFIRMATIONS.length)
85    if ((await $.store.get(WAS_OPEN)) === true) {
86      void openPane($)
87    }
88
89    return next(e)
90  })
91
92  on('command.run', { command: 'side' }, async $ => {
93    const panes = await $.ui.panes()
94    if (panes.some(pane => pane.id === PANE && pane.isPlaced)) {
95      await $.store.set(WAS_OPEN, false)
96      await $.ui.close({ id: PANE })
97      await update($, isPaneOpen, () => false)
98
99      return { text: 'Side prompt closed.' }
100    }
101    await showPane($)
102
103    return { text: 'Side prompt opened. Esc returns to the main prompt.' }
104  })
105
106  // Only the person's own close forgets the pane: the session ending drops it
107  // without a word, and the next session should bring it back.
108  on('ui.close', { id: PANE }, async ($, e, next) => {
109    if (e.origin.kind === 'person') {
110      await $.store.set(WAS_OPEN, false)
111    }
112    const closed = await next(e)
113    await update($, isPaneOpen, () => false)
114
115    return closed
116  })
117
118  // The button's press is answered here, not in its `onPress` closure: the
119  // engine counts a pane opened from a Button's hook as asked for, placed at
120  // any width, and one opened from the closure as unasked (held undrawn below
121  // 144 columns). The hook answers for itself and never calls `next`: opening
122  // the pane redraws the band without the button and releases its handle, so
123  // `next` would find no `onPress` to run and throw.
124  on('ui.press', { plugin: 'side-prompt', element: 'side' }, async $ => {
125    await pressSide($)
126
127    return { element: 'side' }
128  })
129
130  // A small button above the prompt that opens the pane with one click, even
131  // while Claude is working. It gives the row back once the pane is up.
132  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133    if (e.props.hasSurvey || (await read($, isPaneOpen))) {
134      return next(e)
135    }
136
137    const { Box, Button } = $.ui.resolve(e)
138
139    return (
140      <Box width={e.props.bodyColumns} justifyContent="flex-end">
141        <Button key="side" label="side pane" onPress={() => undefined} />
142      </Box>
143    )
144  })
145
146  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
147    if (e.surface === 'mobile') {
148      return next(e)
149    }
150
151    const { Box, Text, Button, Input, Markdown } = $.ui.resolve(e)
152    const [promptText, btwText, noteText, answerText, isBusy, affirmation, activeTab] = await Promise.all([
153      read($, prompt),
154      read($, btw),
155      read($, note),
156      read($, answer),
157      read($, isWorking),
158      read($, affirmationAt),
159      read($, tab),
160    ])
161
162    const selectTab = async (name: (typeof TABS)[number]) => {
163      await update($, tab, () => name)
164      // Put the cursor in the new tab's field so the person can type at once.
165      // Best effort: the keyboard is the person's to give, so a refusal (a
166      // draft in the main prompt box keeps it) leaves the cursor where it is.
167      try {
168        // A click on a tab presses it without giving the pane the keyboard, so
169        // ask for it first (granted only while the main prompt box is empty).
170        await openPane($, { focus: true })
171        // Waits for the field the next drawing brings.
172        await $.ui.focus({ requestId: PANE, key: name })
173      } catch {
174        // Nothing to mend: the tab has switched, only the cursor stayed put.
175      }
176    }
177
178    const sendPrompt = async (text: string) => {
179      if (text.trim() === '') {
180        return
181      }
182      await update($, prompt, () => '')
183      await update($, note, () => 'Prompt queued; it sends when Claude is idle.')
184      await $.prompt.submit({ text, asUser: true })
185      await update($, note, () => '')
186    }
187
188    const askBtw = async (text: string) => {
189      if (text.trim() === '') {
190        return
191      }
192      const fail = async (why: string) => {
193        await update($, note, () => why)
194        $.ui.toast(`btw: ${why}`)
195      }
196      await update($, btw, () => '')
197      await update($, note, () => 'Asking...')
198      const startedAt = await $.clock.now()
199      try {
200        const reply = await $.model.fork({ prompt: text })
201        if (reply.isAnswered) {
202          const took = `${(((await $.clock.now()) - startedAt) / 1000).toFixed(1)}s`
203          await update($, answer, () => reply.text)
204          await update($, note, () => '')
205          $.ui.toast(`btw answered (${took})`)
206        } else {
207          await fail(
208            reply.reason === 'api-error'
209              ? `API error${reply.status === null ? '' : ` ${reply.status}`}.`
210              : (NO_ANSWER[reply.reason] ?? reply.reason),
211          )
212        }
213      } catch (error) {
214        await fail(`Question failed: ${error instanceof Error ? error.message : String(error)}`)
215      }
216    }
217
218    return (
219      <Box flexDirection="column">
220        <Text italic>{AFFIRMATIONS[affirmation % AFFIRMATIONS.length] ?? ''}</Text>
221        <Text dimColor>Enter sends. Esc returns to the main prompt.</Text>
222        {isBusy && <Text dimColor>Claude is working...</Text>}
223        <Box gap={1}>
224          {TABS.map(name => (
225            <Button
226              key={`tab-${name}`}
227              label={name === activeTab ? `▸ ${name}` : name}
228              onPress={() => void selectTab(name)}
229            />
230          ))}
231        </Box>
232        {activeTab === 'prompt' ? (
233          <Input
234            key="prompt"
235            placeholder="Type here while you scroll"
236            rows={5}
237            autoFocus
238            submitLabel="send"
239            value={promptText}
240            onInput={value => void update($, prompt, () => value)}
241            onSubmit={value => void sendPrompt(value)}
242          />
243        ) : (
244          <Input
245            key="btw"
246            placeholder="Side question about this session"
247            autoFocus
248            submitLabel="ask"
249            value={btwText}
250            onInput={value => void update($, btw, () => value)}
251            onSubmit={value => void askBtw(value)}
252          />
253        )}
254        {noteText !== '' && <Text dimColor>{noteText}</Text>}
255        {answerText !== '' && (
256          <Box flexDirection="column">
257            <Box borderStyle="round" paddingX={1}>
258              <Markdown text={answerText} />
259            </Box>
260            {/* The border paints over its own children, so the label is a later
261                sibling, laid on the top border row: ╭── btw ───╮ */}
262            <Box position="absolute" top={0} left={3}>
263              <Text> btw </Text>
264            </Box>
265          </Box>
266        )}
267      </Box>
268    )
269  })
270}
271
hooks/affirmations.ts 17 lines
1// Shown one at a time at the top of the side pane, the next one each time
2// Claude finishes a turn. Edit freely: keep each to a short line.
3export const AFFIRMATIONS: readonly string[] = [
4  'One step is enough. Start with the smallest one.',
5  "You don't have to hold it all in your head.",
6  'Progress counts, even when it is small.',
7  'Done is better than perfect.',
8  "It's fine to pause and come back.",
9  "You've likely done more than you think.",
10  'Your questions are worth asking.',
11  'A messy first draft is how good things start.',
12  'Take a breath. The next step will still be there.',
13  'You can change your mind and keep going.',
14  'Asking for help is part of the work.',
15  'You are allowed to go at your own pace.',
16]
17
types/index.d.ts 17 lines
1export type SideNote = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'side-prompt': {
6      prompt: string
7      btw: string
8      note: Shaped<SideNote>
9      answer: string
10      isWorking: boolean
11      affirmationAt: number
12      isPaneOpen: boolean
13      tab: 'prompt' | 'btw'
14    }
15  }
16}
17