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

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.

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.
claude plugin install does not exist in their build, it has no mod support: say so and stop./reload-plugins or start a new session, then type /side.~/.claude/settings.json or their shell profile yourself.git clone https://github.com/seanperkins/side-prompt.git
claude --plugin-dir ./side-prompt
/side opens the pane. Run it again to close it. It runs even while Claude is working.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.hooks/affirmations.ts to change them.ctrl+x tab or a click. Close: ctrl+x x (default keys).@file mentions and pasted images are not expanded.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./side and the button always place it.btw label on the answer box./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.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 ..
MIT
hooks/register.tsx 271 lines1import { 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}
271hooks/affirmations.ts 17 lines1// 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]
17types/index.d.ts 17 lines1export 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