SLOPSHOPPER

buffer-pane

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…

newpanecommandstatus
v0.1.0MITupdated 2026-09-22meganemura/buffer-pane/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buffer-pane
│ ┃ buffer-pane ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ the next thing to tell ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [+] replaces the prompt box with the block. ⏺ Update(src/auth.ts) │ ┃ [>] sends the block as a prompt. [x] ⎿ Added 2 lines, removed 1 line │ ┃ deletes the block. [^] [v] move the block. ⏺ Bash(bun test) │ ┃ Enter in the last field adds a block. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /buffer-pane │ ⎿ buffer-pane: buffer-pane shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · buffer-pane
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. Enter in the last field adds a block.
README

buffer-pane

test

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.

  • Write blocks of text in the pane while a turn runs.
  • Press [+] beside a block to put that block into the prompt box, read it, then press Enter yourself.
  • Press [>] to send the block as a prompt in one press.
  • The buffer survives sessions. Each working directory has its own buffer.

Requirements

  • Claude Code 2.1.273 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
  • A terminal session. Print mode (-p) has no pane.

Install

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"
  }
}

Use

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.
  • Add a block. Type in the last field and press Enter. The block goes above the field, and the field becomes empty for the next block.
  • Edit a block. Move the focus to the field of the block and type. The pane saves each change.
  • Put a block into the prompt box. Press [+]. 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 ✓.
  • Send a block as a prompt. Press [>]. 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.
  • Move a block. Press [^] to move it up one place, [v] to move it down.
  • Delete a block. Press [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 ✓.

Blocks

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).

Where the buffer lives

The plugin store of Claude Code holds the buffer under the key buffer:<working directory>. A session in another directory shows another buffer.

Development

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.

Status

Early access. The function-hooks API can change between Claude Code releases without notice.

Source 1 files
hooks/mod.ts 392 lines
1// 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