A pane beside the transcript that holds the next things to tell the agent: write blocks of text while a turn runs, then press [+] to put one block into the…

A Claude Code plugin (a Claude Mod) that opens a pane beside the transcript where you write the next things to tell the agent.
An idea for the next request frequently comes while the agent runs a long turn. Text in the prompt box is one Enter away from the agent: Enter interrupts the turn or puts the text in the queue. The pane is a place to collect that text until you are ready.
[+] beside a block to put that block into the prompt box, read it, then press Enter yourself.[>] to send the block as a prompt in one press.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1-p) has no pane.claude plugin marketplace add meganemura/buffer-pane
claude plugin install buffer-pane@buffer-pane
To develop against a checkout, run the plugin from its working tree:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/buffer-pane/plugin
To set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 for each session, add it to the env of settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Type /buffer-pane to show the pane. Type it again to hide the pane.
[+] [>] [x] [^] [v] rename the flag to --dry-run
[+] [>] [x] [^] [v] ✓ add a test for the empty list
the next thing to tell the agent
[+] replaces the prompt box with the block. [>] sends the block as a prompt. [x] deletes the block. [^] [v] move the block.
[+]. The block goes into the prompt box and stays in the pane with a ✓. You can send the block again. An edit to the block removes the ✓.[>]. The block goes to the agent as your next prompt and leaves the pane. During a turn, it waits in the queue until the turn ends.[^] to move it up one place, [v] to move it down.[x]. You can also make the field empty and press Enter.To press a control, click it, or move the focus to it with the arrow keys and press Enter.
[+] replaces the prompt box[+] replaces all the text in the prompt box with the block. The function-hooks API can only replace the box, and it cannot read the box, so the plugin cannot warn you about text that is already there. Send a block when the prompt box is empty.
When a dialog is open, the prompt box cannot take the text. The status line then shows a message, and the block gets no ✓.
The buffer is one text, and blank lines split it into blocks. In this version, one block is one line. A multi-line editor is the next milestone (decision 0003).
The plugin store of Claude Code holds the buffer under the key buffer:<working directory>. A session in another directory shows another buffer.
Run /plugin-types one time in a new checkout. It writes the type declarations to .claude/types/, which git ignores.
Run the three quality gates before a commit:
claude plugin validate plugin
npx -p typescript tsc -p plugin/hooks
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugin
docs/decisions/ holds the design decisions. AGENTS.md holds the rules for changes to this repository.
Early access. The function-hooks API can change between Claude Code releases without notice.
hooks/mod.ts 392 lines1// The plugin's one function-hooks module (the validator admits one per plugin). `/buffer-pane`
2// opens a pane beside the transcript that holds text the person writes for later: the next
3// things to tell the agent. The buffer is one text. Blank lines split it into blocks. Each
4// block has a `[+]` that writes the block into the prompt box, a `[>]` that submits the block as
5// a prompt and deletes it, and a `[x]` that deletes it. The
6// buffer lives in the plugin store, one key for each working directory, so it survives sessions
7// and hot reloads.
8//
9// Must NOT know about: what the person does with the prompt box after a fill; what the prompt box holds
10// (the API cannot read it, and `prompt.fill` always replaces it); any other repository's
11// buffer (one key, the current working directory's).
12//
13// It loads only where Claude Code has function hooks enabled. The engine's validator reads
14// this file statically, so every call on `$` is spelled `$.noun.event(...)` and `$` is handed
15// only to the function declarations at the top of the file; the rest of the module holds a
16// `Host`, a bundle of closures built once at `session.start`.
17
18import type { Elements, On, RenderElement } from 'claude-code'
19
20const PANE_ID = 'buffer-pane'
21const PANE_TITLE = 'buffer-pane'
22const COMMAND = 'buffer-pane'
23
24const STORE_KEY_PREFIX = 'buffer:'
25
26const PANE_PADDING_RIGHT = 1
27
28// `✓` is a long-established narrow symbol: it draws in one cell. A symbol that a font draws
29// two cells wide would push the text field out of line with the rows that have no mark.
30const SENT_MARK = '✓'
31const NOT_SENT_MARK = ' '
32
33const FILL_REFUSED_TEXT = 'buffer-pane: the prompt box did not take the block (a dialog is open, or there is no prompt box)'
34const SUBMIT_REFUSED_TEXT = 'buffer-pane: the prompt was refused: '
35// The surface draws `⏎ <submitLabel>` beside a field while it has the focus, and the field
36// shrinks by that width, so the row moved on each focus change (real-terminal feedback). An
37// empty label is the one value the props offer to give it nothing to draw. What Enter does is
38// in the note at the bottom of the pane instead.
39const SUBMIT_LABEL = ''
40
41const REPLACE_NOTE = '[+] replaces the prompt box with the block. [>] sends the block as a prompt. [x] deletes the block. [^] [v] move the block. Enter in the last field adds a block.'
42
43type Host = {
44 cwd: () => Promise<string>
45 status: (text: string | undefined) => void
46 open: () => Promise<void>
47 close: () => Promise<void>
48 invalidate: () => void
49 log: (text: string) => void
50 register: () => Promise<unknown>
51 fill: (text: string) => Promise<{ isFilled: boolean }>
52 submit: (text: string) => Promise<{ drop?: string | undefined }>
53 storeGet: (key: string) => Promise<unknown>
54 storeSet: (key: string, value: unknown) => Promise<void>
55}
56
57// A block holds an id because its position changes when `[x]` deletes a block above it. The
58// id is the element key, so the text field of block 3 does not become the text field of
59// block 2 under the person's cursor.
60export type Block = { id: number; text: string }
61
62// What the pane edits. `sent` holds the text of each block that a `[+]` wrote into the prompt
63// box. A block shows the mark only while its text is in `sent`, so an edit removes the mark
64// with no separate bookkeeping. `draft` is the text in the new-block field before Enter.
65export type Buffer = { blocks: Block[]; sent: string[]; draft: string; nextId: number }
66
67// What the store holds. The buffer is one text with blank lines between blocks, not a list:
68// a later multi-line editor edits the same text, and a person can read the stored value.
69export type Stored = { text: string; sent: string[]; draft: string }
70
71type State = {
72 host: Host | null
73 isOpen: boolean
74 storeKey: string | null
75 buffer: Buffer
76 // Counts Enter presses in the new-block field. The count is part of that field's key, so
77 // each Enter draws a new element. The surface keeps the typed text of an element it has
78 // seen before, and an empty `value` drawn again for the same key might not clear it.
79 draftGeneration: number
80}
81
82// The host is a bundle of closures over `$`, built once at `session.start`, so the rest of
83// this file never holds `$` itself. That is the validator's rule and also the seam a test
84// fakes: every world a test builds stubs these same calls with `on(...)`.
85function hostOf($: any): Host {
86 return {
87 cwd: () => $.session.cwd(),
88 status: (text) => $.ui.status(text),
89 open: () => $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true }),
90 close: () => $.ui.close({ id: PANE_ID }),
91 invalidate: () => $.ui.invalidate('ui.render'),
92 log: (text) => $.ui.log(text),
93 register: () => $.command.register({ name: COMMAND, description: 'Show or hide the buffer-pane' }),
94 fill: (text) => $.prompt.fill({ text }),
95 submit: (text) => $.prompt.submit({ text }),
96 storeGet: (key) => $.store.get(key),
97 storeSet: (key, value) => $.store.set(key, value),
98 }
99}
100
101function messageOf(error: unknown): string {
102 return error instanceof Error ? error.message : String(error)
103}
104
105export function storeKeyOf(cwd: string): string {
106 return `${STORE_KEY_PREFIX}${cwd}`
107}
108
109export function emptyBuffer(): Buffer {
110 return { blocks: [], sent: [], draft: '', nextId: 1 }
111}
112
113// A line that holds only spaces also separates blocks: a person cannot see the difference
114// between that line and an empty line.
115export function blockTextsOf(text: string): string[] {
116 return text
117 .split(/\n[ \t]*(?:\n[ \t]*)+/)
118 .map((block) => block.trim())
119 .filter((block) => block !== '')
120}
121
122export function textOfBlocks(blocks: readonly Block[]): string {
123 return blocks
124 .map((block) => block.text.trim())
125 .filter((text) => text !== '')
126 .join('\n\n')
127}
128
129// A mark for a text that is no longer in the buffer is dropped. Without this, a block that
130// the person edits and then edits back shows a mark for a fill of a different text.
131function withSentPruned(buffer: Buffer): Buffer {
132 const texts = new Set(buffer.blocks.map((block) => block.text.trim()))
133 return { ...buffer, sent: buffer.sent.filter((text) => texts.has(text)) }
134}
135
136export function afterSubmitOf(buffer: Buffer, value: string): Buffer {
137 const text = value.trim()
138 if (text === '') return { ...buffer, draft: '' }
139 return { ...buffer, blocks: [...buffer.blocks, { id: buffer.nextId, text }], draft: '', nextId: buffer.nextId + 1 }
140}
141
142export function afterDraftOf(buffer: Buffer, value: string): Buffer {
143 return { ...buffer, draft: value }
144}
145
146export function afterEditOf(buffer: Buffer, id: number, value: string): Buffer {
147 return withSentPruned({ ...buffer, blocks: buffer.blocks.map((block) => (block.id === id ? { ...block, text: value } : block)) })
148}
149
150// Enter in the field of a block that the person emptied deletes the block. An empty block
151// has nothing to send, and the stored text could not hold it.
152export function afterEditSubmitOf(buffer: Buffer, id: number, value: string): Buffer {
153 if (value.trim() === '') return afterRemoveOf(buffer, id)
154 return afterEditOf(buffer, id, value.trim())
155}
156
157// Moves a block one place up (`-1`) or down (`+1`). A block at the end stays where it is.
158// Buttons in place of a drag: a drag needs pointer events, which only a `Client` receives,
159// and keys and pointer events did not reach a `Client` in a repeatable way (docs/decisions/0008).
160export function afterMoveOf(buffer: Buffer, id: number, direction: -1 | 1): Buffer {
161 const index = buffer.blocks.findIndex((block) => block.id === id)
162 const target = index + direction
163 if (index < 0 || target < 0 || target >= buffer.blocks.length) return buffer
164 const blocks = [...buffer.blocks]
165 const [moved] = blocks.splice(index, 1)
166 blocks.splice(target, 0, moved!)
167 return { ...buffer, blocks }
168}
169
170export function afterRemoveOf(buffer: Buffer, id: number): Buffer {
171 return withSentPruned({ ...buffer, blocks: buffer.blocks.filter((block) => block.id !== id) })
172}
173
174// `sent` holds trimmed text, the form the store keeps (`textOfBlocks`). A field holds the text
175// as typed, with a possible space at the end, so each comparison trims first. Without this, a
176// block sent with a space at its end loses its mark on the next load.
177export function afterSentOf(buffer: Buffer, text: string): Buffer {
178 const sent = text.trim()
179 return buffer.sent.includes(sent) ? buffer : { ...buffer, sent: [...buffer.sent, sent] }
180}
181
182export function isSentOf(buffer: Buffer, block: Block): boolean {
183 return buffer.sent.includes(block.text.trim())
184}
185
186export function storedOf(buffer: Buffer): Stored {
187 return { text: textOfBlocks(buffer.blocks), sent: withSentPruned(buffer).sent, draft: buffer.draft }
188}
189
190// What comes out of the store is this file's own past write, not the engine's word. A
191// version with a different shape could have written it, so each field is checked. A value
192// that does not fit reads as an empty buffer, never as an error: the pane must still open.
193export function bufferFromStore(value: unknown): Buffer {
194 if (typeof value !== 'object' || value === null) return emptyBuffer()
195 const text = Reflect.get(value, 'text')
196 const sent = Reflect.get(value, 'sent')
197 const draft = Reflect.get(value, 'draft')
198 if (typeof text !== 'string') return emptyBuffer()
199 const blocks = blockTextsOf(text).map((blockText, index) => ({ id: index + 1, text: blockText }))
200 return withSentPruned({
201 blocks,
202 sent: Array.isArray(sent) ? sent.filter((item): item is string => typeof item === 'string') : [],
203 draft: typeof draft === 'string' ? draft : '',
204 nextId: blocks.length + 1,
205 })
206}
207
208// Reads the buffer of the current working directory. It runs at `session.start` and again
209// each time the pane opens, because the working directory can change during a session.
210// It does not read again for a key that is already loaded: the state is newer than the store
211// while a write is in flight.
212async function load(state: State, host: Host): Promise<void> {
213 const key = storeKeyOf(await host.cwd())
214 if (key === state.storeKey) return
215 state.buffer = bufferFromStore(await host.storeGet(key).catch(() => undefined))
216 state.storeKey = key
217}
218
219// Every change goes through here: state first, then the store, then a redraw. The render
220// hook never writes the store. It fires many times a second.
221function commit(state: State, host: Host, buffer: Buffer): void {
222 state.buffer = buffer
223 if (state.storeKey !== null) {
224 void host.storeSet(state.storeKey, storedOf(buffer)).catch((error: unknown) => {
225 host.log(`buffer-pane: the buffer was not saved: ${messageOf(error)}`)
226 })
227 }
228 host.invalidate()
229}
230
231function blockTextOf(state: State, id: number): string {
232 const block = state.buffer.blocks.find((candidate) => candidate.id === id)
233 return block?.text.trim() ?? ''
234}
235
236// `[+]`: the block goes into the prompt box, and the person presses Enter.
237async function fill(state: State, host: Host, id: number): Promise<void> {
238 const text = blockTextOf(state, id)
239 if (text === '') return
240 const { isFilled } = await host.fill(text)
241 if (!isFilled) {
242 host.status(FILL_REFUSED_TEXT)
243 return
244 }
245 host.status(undefined)
246 // The block stays in the buffer. The pane is the source of truth: the person can lose the
247 // text in the prompt box with one key, and then sends the same block again.
248 commit(state, host, afterSentOf(state.buffer, text))
249}
250
251// `[>]`: the block goes to the model as a prompt (asked for, beside `[+]`: a block that needs no
252// second look is sent in one press). `$.prompt.submit` runs the prompt when the session is idle,
253// so a press during a turn queues it. The block is deleted once the prompt entered (asked for:
254// a sent request is done, and a block that stays reads as one still to send). A refused prompt
255// leaves the block in place.
256async function submit(state: State, host: Host, id: number): Promise<void> {
257 const text = blockTextOf(state, id)
258 if (text === '') return
259 const result = await host.submit(text)
260 if (result.drop !== undefined) {
261 host.status(`${SUBMIT_REFUSED_TEXT}${result.drop}`)
262 return
263 }
264 host.status(undefined)
265 commit(state, host, afterRemoveOf(state.buffer, id))
266}
267
268// The real element types, so the typecheck refuses a prop the engine would refuse. One
269// unknown prop drops the whole tree with no message. `Text` takes no `key`.
270type Ui = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text' | 'Input'>
271
272// No `hotkey` on a Button: a hotkey does not fire in a pane (measured in pull-request-pane,
273// two terminal setups). The arrow keys with Enter, or a click, press a Button. `plain` draws
274// the label alone, and the focus and the pointer still invert it, so the brackets of `[+]` are
275// the only chrome and the gutter keeps a fixed width.
276function blockRowOf(ui: Ui, block: Block, state: State, host: Host): RenderElement {
277 const { Box, Button, Text, Input } = ui
278 const key = `block:${block.id}`
279 return Box({
280 key,
281 flexDirection: 'row',
282 width: '100%',
283 columnGap: 1,
284 children: [
285 Button({ key: `${key}:fill`, label: '[+]', plain: true, onPress: () => void fill(state, host, block.id).catch(() => undefined) }),
286 Button({ key: `${key}:submit`, label: '[>]', plain: true, onPress: () => void submit(state, host, block.id).catch(() => undefined) }),
287 Button({ key: `${key}:remove`, label: '[x]', plain: true, onPress: () => commit(state, host, afterRemoveOf(state.buffer, block.id)) }),
288 // `^` and `v` are ASCII: no font draws them wider than one cell (an arrow glyph can).
289 Button({ key: `${key}:up`, label: '[^]', plain: true, onPress: () => commit(state, host, afterMoveOf(state.buffer, block.id, -1)) }),
290 Button({ key: `${key}:down`, label: '[v]', plain: true, onPress: () => commit(state, host, afterMoveOf(state.buffer, block.id, 1)) }),
291 Text({ color: 'green', children: isSentOf(state.buffer, block) ? SENT_MARK : NOT_SENT_MARK }),
292 fieldBoxOf(ui, `${key}:field`, Input({
293 key: `${key}:text`,
294 value: block.text,
295 submitLabel: SUBMIT_LABEL,
296 onInput: (value) => commit(state, host, afterEditOf(state.buffer, block.id, value)),
297 onSubmit: (value) => commit(state, host, afterEditSubmitOf(state.buffer, block.id, value)),
298 })),
299 ],
300 })
301}
302
303// A field on its own draws as wide as its text (real-terminal feedback: the new-block field was
304// too narrow to write in). `InputProps` has no width, so the Box around it takes the rest of
305// the row and the field fills the Box.
306function fieldBoxOf(ui: Ui, key: string, field: RenderElement): RenderElement {
307 return ui.Box({ key, flexGrow: 1, width: '100%', children: [field] })
308}
309
310function draftRowOf(ui: Ui, state: State, host: Host): RenderElement {
311 const { Box, Input } = ui
312 return Box({
313 key: 'draft',
314 flexDirection: 'row',
315 width: '100%',
316 // Lines the field up with the block fields: five 3-cell buttons, the mark, six gaps.
317 paddingLeft: 22,
318 children: [
319 fieldBoxOf(ui, 'draft:field', Input({
320 key: `draft:${state.draftGeneration}`,
321 value: state.buffer.draft,
322 placeholder: 'the next thing to tell the agent',
323 submitLabel: SUBMIT_LABEL,
324 autoFocus: true,
325 onInput: (value) => commit(state, host, afterDraftOf(state.buffer, value)),
326 onSubmit: (value) => {
327 state.draftGeneration += 1
328 commit(state, host, afterSubmitOf(state.buffer, value))
329 },
330 })),
331 ],
332 })
333}
334
335function paneOf(ui: Ui, state: State, host: Host): RenderElement {
336 const { Box, Text } = ui
337 return Box({
338 key: 'buffer-pane',
339 flexDirection: 'column',
340 paddingTop: 1,
341 paddingRight: PANE_PADDING_RIGHT,
342 children: [
343 Box({ flexDirection: 'column', children: [...state.buffer.blocks.map((block) => blockRowOf(ui, block, state, host)), draftRowOf(ui, state, host)] }),
344 Box({ flexDirection: 'column', marginTop: 1, children: [Text({ dimColor: true, children: REPLACE_NOTE })] }),
345 ],
346 })
347}
348
349export function register(on: On) {
350 const state: State = { host: null, isOpen: false, storeKey: null, buffer: emptyBuffer(), draftGeneration: 0 }
351
352 on('session.start', async ($, e, next) => {
353 state.host = hostOf($)
354 await state.host.register().catch((error: unknown) => {
355 state.host?.log(`buffer-pane: /${COMMAND} is not available: ${messageOf(error)}`)
356 })
357 // A hot reload of this module starts a new session under an open pane. The buffer is
358 // read here so the first redraw after the reload shows it.
359 await load(state, state.host).catch(() => undefined)
360 return next(e)
361 })
362
363 on('command.run', { command: COMMAND }, async ($, e, next) => {
364 const host = state.host
365 if (host === null) return next(e)
366
367 if (state.isOpen) {
368 await host.close()
369 state.isOpen = false
370 return { text: 'buffer-pane hidden' }
371 }
372
373 await load(state, host)
374 await host.open()
375 state.isOpen = true
376 return { text: 'buffer-pane shown' }
377 })
378
379 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
380 if (e.requestId !== PANE_ID || state.host === null) return next(e)
381 if (e.surface !== 'terminal') return next(e)
382 const { Box, Button, Text, Input } = await $.ui.resolve(e)
383 return paneOf({ Box, Button, Text, Input }, state, state.host)
384 })
385
386 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
387 const result = await next(e)
388 if (result.deny === undefined) state.isOpen = false
389 return result
390 })
391}
392