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.2.3no licenseupdated 2026-10-08jidhra/claude-mods/plugins/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 │ ┃ BUFFER PANE · 0 QUEUED │ ┃ ■ next up ■ in prompt ■ new bloc ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ queue ⏺ Update(src/auth.ts) │ ┃ │ nothing yet ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ NEW BLOCK ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ › the next thing to tell the agent │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [+] replaces the prompt box with the block │ ┃ │ sends the block as a prompt. [x] deletes t › /buffer-pane │ ┃ │ [^] [v] move the block. Enter in the last ⎿ buffer-pane: buffer-pane shown │ ┃ │ adds a block. │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Buffer pane
BUFFER PANE · 0 QUEUED ■ next up ■ in prompt ■ new block ╭─────────────────────────────────────────────────────╮ │ queue 0 queued │ │ nothing yet │ ╰─────────────────────────────────────────────────────╯ ─────────────────────────────────────────────────────── ╭───────────────────────────────────────────────────────╮ │ NEW BLOCK ⏎ adds │ │ › 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

claude-mods

All of my Claude Code mods in one plugin marketplace.

Install

claude plugin marketplace add jidhra/claude-mods
claude plugin install <mod>@claude-mods --scope user

Mods

ModFolderOrigin
Mod Toolsplugins/mod-toolsmine
Clean Viewplugins/clean-viewmine
Usageplugins/usage-panemine
Pinboardplugins/pinboardpersonal copy of sirkitree/pinboard (MIT)
Buffer Paneplugins/buffer-pane (plugin in plugin/)personal copy of meganemura/buffer-pane (MIT)
Flightdeckplugins/flightdeckcopy of scasella/claude-flightdeck (MIT)
Secret Redactorplugins/secret-redactorcopy of plugins/secret-redactor from ray-amjad/awesome-claude-code-function-hooks (MIT)

Each mod was brought in with git subtree, so its full history is here. The mods by other authors keep tracking their originals; see UPSTREAM.md for pulling in their changes.

The .claude-plugin/marketplace.json files inside each mod's folder are left over from when each was its own marketplace. Only the one at the repo root is used.

Develop

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
Source 2 files
hooks/mod.ts 530 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// Docked width; matches Flightdeck's so the dock doesn't jump between panes
28const PANE_COLUMNS = 66
29
30// `✓` is a long-established narrow symbol: it draws in one cell. A symbol that a font draws
31// two cells wide would push the text field out of line with the rows that have no mark.
32const SENT_MARK = '✓'
33const NOT_SENT_MARK = ' '
34
35const FILL_REFUSED_TEXT = 'buffer-pane: the prompt box did not take the block (a dialog is open, or there is no prompt box)'
36const SUBMIT_REFUSED_TEXT = 'buffer-pane: the prompt was refused: '
37// The surface draws `⏎ <submitLabel>` beside a field while it has the focus, and the field
38// shrinks by that width, so the row moved on each focus change (real-terminal feedback). An
39// empty label is the one value the props offer to give it nothing to draw. What Enter does is
40// in the note at the bottom of the pane instead.
41const SUBMIT_LABEL = ''
42
43const 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.'
44
45type Host = {
46  cwd: () => Promise<string>
47  status: (text: string | undefined) => void
48  open: () => Promise<void>
49  close: () => Promise<void>
50  invalidate: () => void
51  log: (text: string) => void
52  register: () => Promise<unknown>
53  fill: (text: string) => Promise<{ isFilled: boolean }>
54  submit: (text: string) => Promise<{ drop?: string | undefined }>
55  storeGet: (key: string) => Promise<unknown>
56  storeSet: (key: string, value: unknown) => Promise<void>
57  // Whether the pane is up, for Mod Tools' Show column (it reads buffer-pane.paneOpen)
58  isPaneUp: () => Promise<boolean>
59  publishOpen: (isOpen: boolean) => Promise<unknown>
60}
61
62// A block holds an id because its position changes when `[x]` deletes a block above it. The
63// id is the element key, so the text field of block 3 does not become the text field of
64// block 2 under the person's cursor.
65export type Block = { id: number; text: string }
66
67// What the pane edits. `sent` holds the text of each block that a `[+]` wrote into the prompt
68// box. A block shows the mark only while its text is in `sent`, so an edit removes the mark
69// with no separate bookkeeping. `draft` is the text in the new-block field before Enter.
70export type Buffer = { blocks: Block[]; sent: string[]; draft: string; nextId: number }
71
72// What the store holds. The buffer is one text with blank lines between blocks, not a list:
73// a later multi-line editor edits the same text, and a person can read the stored value.
74export type Stored = { text: string; sent: string[]; draft: string }
75
76type State = {
77  host: Host | null
78  isOpen: boolean
79  storeKey: string | null
80  buffer: Buffer
81  // Counts Enter presses in the new-block field. The count is part of that field's key, so
82  // each Enter draws a new element. The surface keeps the typed text of an element it has
83  // seen before, and an empty `value` drawn again for the same key might not clear it.
84  draftGeneration: number
85}
86
87// The host is a bundle of closures over `$`, built once at `session.start`, so the rest of
88// this file never holds `$` itself. That is the validator's rule and also the seam a test
89// fakes: every world a test builds stubs these same calls with `on(...)`.
90function hostOf($: any): Host {
91  return {
92    cwd: () => $.session.cwd(),
93    status: (text) => $.ui.status(text),
94    open: () => $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS, focus: true }),
95    close: () => $.ui.close({ id: PANE_ID }),
96    invalidate: () => $.ui.invalidate('ui.render'),
97    log: (text) => $.ui.log(text),
98    register: () => $.command.register({ name: COMMAND, description: 'Show or hide the buffer-pane' }),
99    fill: (text) => $.prompt.fill({ text }),
100    submit: (text) => $.prompt.submit({ text }),
101    storeGet: (key) => $.store.get(key),
102    storeSet: (key, value) => $.store.set(key, value),
103    isPaneUp: async () => ((await $.ui.panes()) as { id: string }[]).some((pane) => pane.id === PANE_ID),
104    publishOpen: (isOpen) => $.state.set({ plugin: 'buffer-pane', key: 'paneOpen' }, isOpen),
105  }
106}
107
108function messageOf(error: unknown): string {
109  return error instanceof Error ? error.message : String(error)
110}
111
112export function storeKeyOf(cwd: string): string {
113  return `${STORE_KEY_PREFIX}${cwd}`
114}
115
116export function emptyBuffer(): Buffer {
117  return { blocks: [], sent: [], draft: '', nextId: 1 }
118}
119
120// A line that holds only spaces also separates blocks: a person cannot see the difference
121// between that line and an empty line.
122export function blockTextsOf(text: string): string[] {
123  return text
124    .split(/\n[ \t]*(?:\n[ \t]*)+/)
125    .map((block) => block.trim())
126    .filter((block) => block !== '')
127}
128
129export function textOfBlocks(blocks: readonly Block[]): string {
130  return blocks
131    .map((block) => block.text.trim())
132    .filter((text) => text !== '')
133    .join('\n\n')
134}
135
136// A mark for a text that is no longer in the buffer is dropped. Without this, a block that
137// the person edits and then edits back shows a mark for a fill of a different text.
138function withSentPruned(buffer: Buffer): Buffer {
139  const texts = new Set(buffer.blocks.map((block) => block.text.trim()))
140  return { ...buffer, sent: buffer.sent.filter((text) => texts.has(text)) }
141}
142
143export function afterSubmitOf(buffer: Buffer, value: string): Buffer {
144  const text = value.trim()
145  if (text === '') return { ...buffer, draft: '' }
146  return { ...buffer, blocks: [...buffer.blocks, { id: buffer.nextId, text }], draft: '', nextId: buffer.nextId + 1 }
147}
148
149export function afterDraftOf(buffer: Buffer, value: string): Buffer {
150  return { ...buffer, draft: value }
151}
152
153export function afterEditOf(buffer: Buffer, id: number, value: string): Buffer {
154  return withSentPruned({ ...buffer, blocks: buffer.blocks.map((block) => (block.id === id ? { ...block, text: value } : block)) })
155}
156
157// Enter in the field of a block that the person emptied deletes the block. An empty block
158// has nothing to send, and the stored text could not hold it.
159export function afterEditSubmitOf(buffer: Buffer, id: number, value: string): Buffer {
160  if (value.trim() === '') return afterRemoveOf(buffer, id)
161  return afterEditOf(buffer, id, value.trim())
162}
163
164// Moves a block one place up (`-1`) or down (`+1`). A block at the end stays where it is.
165// Buttons in place of a drag: a drag needs pointer events, which only a `Client` receives,
166// and keys and pointer events did not reach a `Client` in a repeatable way (docs/decisions/0008).
167export function afterMoveOf(buffer: Buffer, id: number, direction: -1 | 1): Buffer {
168  const index = buffer.blocks.findIndex((block) => block.id === id)
169  const target = index + direction
170  if (index < 0 || target < 0 || target >= buffer.blocks.length) return buffer
171  const blocks = [...buffer.blocks]
172  const [moved] = blocks.splice(index, 1)
173  blocks.splice(target, 0, moved!)
174  return { ...buffer, blocks }
175}
176
177export function afterRemoveOf(buffer: Buffer, id: number): Buffer {
178  return withSentPruned({ ...buffer, blocks: buffer.blocks.filter((block) => block.id !== id) })
179}
180
181// `sent` holds trimmed text, the form the store keeps (`textOfBlocks`). A field holds the text
182// as typed, with a possible space at the end, so each comparison trims first. Without this, a
183// block sent with a space at its end loses its mark on the next load.
184export function afterSentOf(buffer: Buffer, text: string): Buffer {
185  const sent = text.trim()
186  return buffer.sent.includes(sent) ? buffer : { ...buffer, sent: [...buffer.sent, sent] }
187}
188
189export function isSentOf(buffer: Buffer, block: Block): boolean {
190  return buffer.sent.includes(block.text.trim())
191}
192
193export function storedOf(buffer: Buffer): Stored {
194  return { text: textOfBlocks(buffer.blocks), sent: withSentPruned(buffer).sent, draft: buffer.draft }
195}
196
197// What comes out of the store is this file's own past write, not the engine's word. A
198// version with a different shape could have written it, so each field is checked. A value
199// that does not fit reads as an empty buffer, never as an error: the pane must still open.
200export function bufferFromStore(value: unknown): Buffer {
201  if (typeof value !== 'object' || value === null) return emptyBuffer()
202  const text = Reflect.get(value, 'text')
203  const sent = Reflect.get(value, 'sent')
204  const draft = Reflect.get(value, 'draft')
205  if (typeof text !== 'string') return emptyBuffer()
206  const blocks = blockTextsOf(text).map((blockText, index) => ({ id: index + 1, text: blockText }))
207  return withSentPruned({
208    blocks,
209    sent: Array.isArray(sent) ? sent.filter((item): item is string => typeof item === 'string') : [],
210    draft: typeof draft === 'string' ? draft : '',
211    nextId: blocks.length + 1,
212  })
213}
214
215// Reads the buffer of the current working directory. It runs at `session.start` and again
216// each time the pane opens, because the working directory can change during a session.
217// It does not read again for a key that is already loaded: the state is newer than the store
218// while a write is in flight.
219async function load(state: State, host: Host): Promise<void> {
220  const key = storeKeyOf(await host.cwd())
221  if (key === state.storeKey) return
222  state.buffer = bufferFromStore(await host.storeGet(key).catch(() => undefined))
223  state.storeKey = key
224}
225
226// Every change goes through here: state first, then the store, then a redraw. The render
227// hook never writes the store. It fires many times a second.
228function commit(state: State, host: Host, buffer: Buffer): void {
229  state.buffer = buffer
230  if (state.storeKey !== null) {
231    void host.storeSet(state.storeKey, storedOf(buffer)).catch((error: unknown) => {
232      host.log(`buffer-pane: the buffer was not saved: ${messageOf(error)}`)
233    })
234  }
235  host.invalidate()
236}
237
238function blockTextOf(state: State, id: number): string {
239  const block = state.buffer.blocks.find((candidate) => candidate.id === id)
240  return block?.text.trim() ?? ''
241}
242
243// `[+]`: the block goes into the prompt box, and the person presses Enter.
244async function fill(state: State, host: Host, id: number): Promise<void> {
245  const text = blockTextOf(state, id)
246  if (text === '') return
247  const { isFilled } = await host.fill(text)
248  if (!isFilled) {
249    host.status(FILL_REFUSED_TEXT)
250    return
251  }
252  host.status(undefined)
253  // The block stays in the buffer. The pane is the source of truth: the person can lose the
254  // text in the prompt box with one key, and then sends the same block again.
255  commit(state, host, afterSentOf(state.buffer, text))
256}
257
258// `[>]`: the block goes to the model as a prompt (asked for, beside `[+]`: a block that needs no
259// second look is sent in one press). `$.prompt.submit` runs the prompt when the session is idle,
260// so a press during a turn queues it. The block is deleted once the prompt entered (asked for:
261// a sent request is done, and a block that stays reads as one still to send). A refused prompt
262// leaves the block in place.
263async function submit(state: State, host: Host, id: number): Promise<void> {
264  const text = blockTextOf(state, id)
265  if (text === '') return
266  const result = await host.submit(text)
267  if (result.drop !== undefined) {
268    host.status(`${SUBMIT_REFUSED_TEXT}${result.drop}`)
269    return
270  }
271  host.status(undefined)
272  commit(state, host, afterRemoveOf(state.buffer, id))
273}
274
275// The real element types, so the typecheck refuses a prop the engine would refuse. One
276// unknown prop drops the whole tree with no message. `Text` takes no `key`.
277type Ui = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text' | 'Input'>
278
279// Flightdeck's look (its `theme` palette): theme keys, never fixed hex, so light, dark and
280// colour-blind themes all work. Only the keys this pane uses.
281const C = {
282  main: 'claude',
283  agent: 'suggestion',
284  gate: 'success',
285  dim: 'inactive',
286  faint: 'subtle',
287} as const
288
289// The narrowest pane the layout is sized for; a narrower body clips at its right edge.
290const MIN_WIDTH = 40
291// The number column at the left of each block row: a next-up glyph and a two-digit number.
292const GUTTER_WIDTH = 3
293const NEXT_UP_MARK = '▶'
294
295// Legend items that fit on one row of `width` cells, in order; the rest are dropped (Flightdeck's
296// `fitLegend`).
297function fitLegend<T extends { label: string }>(items: readonly T[], width: number): T[] {
298  const out: T[] = []
299  let used = 0
300  for (const item of items) {
301    const cells = item.label.length + 4
302    if (used + cells > width) break
303    out.push(item)
304    used += cells
305  }
306  return out
307}
308
309// No `hotkey` on a Button: a hotkey does not fire in a pane (measured in pull-request-pane,
310// two terminal setups). The arrow keys with Enter, or a click, press a Button. `plain` draws
311// the label alone, and the focus and the pointer still invert it, so the brackets of `[+]` are
312// the only chrome and the gutter keeps a fixed width.
313function blockRowOf(ui: Ui, block: Block, index: number, state: State, host: Host): RenderElement {
314  const { Box, Button, Text, Input } = ui
315  const key = `block:${block.id}`
316  const isNext = index === 0
317  const isSent = isSentOf(state.buffer, block)
318  return Box({
319    key,
320    flexDirection: 'row',
321    width: '100%',
322    columnGap: 1,
323    children: [
324      // The number sits in a Box so the row's own Text children stay the sent mark alone.
325      Box({
326        width: GUTTER_WIDTH,
327        flexShrink: 0,
328        children: [Text(isNext
329          ? { color: C.main, bold: true, children: `${NEXT_UP_MARK}${String(index + 1).padStart(GUTTER_WIDTH - 1)}` }
330          : { color: C.faint, children: String(index + 1).padStart(GUTTER_WIDTH) })],
331      }),
332      Button({ key: `${key}:fill`, label: '[+]', plain: true, onPress: () => void fill(state, host, block.id).catch(() => undefined) }),
333      Button({ key: `${key}:submit`, label: '[>]', plain: true, onPress: () => void submit(state, host, block.id).catch(() => undefined) }),
334      Button({ key: `${key}:remove`, label: '[x]', plain: true, onPress: () => commit(state, host, afterRemoveOf(state.buffer, block.id)) }),
335      // `^` and `v` are ASCII: no font draws them wider than one cell (an arrow glyph can).
336      Button({ key: `${key}:up`, label: '[^]', plain: true, onPress: () => commit(state, host, afterMoveOf(state.buffer, block.id, -1)) }),
337      Button({ key: `${key}:down`, label: '[v]', plain: true, onPress: () => commit(state, host, afterMoveOf(state.buffer, block.id, 1)) }),
338      Text({ color: C.gate, bold: true, children: isSent ? SENT_MARK : NOT_SENT_MARK }),
339      fieldBoxOf(ui, `${key}:field`, Input({
340        key: `${key}:text`,
341        value: block.text,
342        submitLabel: SUBMIT_LABEL,
343        onInput: (value) => commit(state, host, afterEditOf(state.buffer, block.id, value)),
344        onSubmit: (value) => commit(state, host, afterEditSubmitOf(state.buffer, block.id, value)),
345      })),
346    ],
347  })
348}
349
350// A field on its own draws as wide as its text (real-terminal feedback: the new-block field was
351// too narrow to write in). `InputProps` has no width, so the Box around it takes the rest of
352// the row and the field fills the Box.
353function fieldBoxOf(ui: Ui, key: string, field: RenderElement): RenderElement {
354  return ui.Box({ key, flexGrow: 1, width: '100%', children: [field] })
355}
356
357function draftRowOf(ui: Ui, state: State, host: Host): RenderElement {
358  const { Box, Text, Input } = ui
359  return Box({
360    key: 'draft',
361    flexDirection: 'row',
362    width: '100%',
363    columnGap: 1,
364    children: [
365      Text({ color: C.agent, bold: true, children: '›' }),
366      fieldBoxOf(ui, 'draft:field', Input({
367        key: `draft:${state.draftGeneration}`,
368        value: state.buffer.draft,
369        placeholder: 'the next thing to tell the agent',
370        submitLabel: SUBMIT_LABEL,
371        autoFocus: true,
372        onInput: (value) => commit(state, host, afterDraftOf(state.buffer, value)),
373        onSubmit: (value) => {
374          state.draftGeneration += 1
375          commit(state, host, afterSubmitOf(state.buffer, value))
376        },
377      })),
378    ],
379  })
380}
381
382// A card's first row: an upper-case label in the accent on the left, a dim state on the right.
383function cardHeadOf(ui: Ui, label: RenderElement, state: string): RenderElement {
384  const { Box, Text } = ui
385  return Box({ justifyContent: 'space-between', children: [label, Text({ dimColor: true, children: state })] })
386}
387
388function queueCardOf(ui: Ui, state: State, host: Host, width: number): RenderElement {
389  const { Box, Text } = ui
390  const blocks = state.buffer.blocks
391  const inPrompt = blocks.filter((block) => isSentOf(state.buffer, block)).length
392  const count = `${blocks.length} queued${inPrompt > 0 ? ` · ${inPrompt} in prompt` : ''}`
393  if (blocks.length === 0) {
394    return Box({
395      flexDirection: 'column',
396      borderStyle: 'round',
397      borderColor: C.faint,
398      paddingX: 1,
399      width,
400      children: [
401        cardHeadOf(ui, Text({ dimColor: true, children: 'queue' }), '0 queued'),
402        Text({ color: C.faint, children: 'nothing yet' }),
403      ],
404    })
405  }
406  return Box({
407    flexDirection: 'column',
408    borderStyle: 'round',
409    borderColor: C.main,
410    paddingX: 1,
411    width,
412    children: [
413      cardHeadOf(ui, Text({ color: C.main, bold: true, children: 'QUEUE · next up' }), count),
414      Box({ flexDirection: 'column', children: blocks.map((block, index) => blockRowOf(ui, block, index, state, host)) }),
415    ],
416  })
417}
418
419function newBlockCardOf(ui: Ui, state: State, host: Host, width: number): RenderElement {
420  const { Box, Text } = ui
421  return Box({
422    flexDirection: 'column',
423    borderStyle: 'round',
424    borderColor: C.agent,
425    paddingX: 1,
426    width,
427    children: [
428      cardHeadOf(ui, Text({ color: C.agent, bold: true, children: 'NEW BLOCK' }), '⏎ adds'),
429      draftRowOf(ui, state, host),
430      Box({ flexDirection: 'column', marginTop: 1, children: [Text({ dimColor: true, children: REPLACE_NOTE })] }),
431    ],
432  })
433}
434
435function paneOf(ui: Ui, state: State, host: Host, bodyColumns: number): RenderElement {
436  const { Box, Text } = ui
437  const width = Math.max(MIN_WIDTH, bodyColumns) - PANE_PADDING_RIGHT
438  const queued = state.buffer.blocks.length
439  const legend = fitLegend(
440    [
441      { label: 'next up', color: C.main },
442      { label: 'in prompt', color: C.gate },
443      { label: 'new block', color: C.agent },
444    ],
445    width,
446  )
447  return Box({
448    key: 'buffer-pane',
449    flexDirection: 'column',
450    paddingRight: PANE_PADDING_RIGHT,
451    width: width + PANE_PADDING_RIGHT,
452    children: [
453      Box({
454        justifyContent: 'center',
455        children: [Text({
456          bold: true,
457          wrap: 'truncate',
458          children: [
459            Text({ children: 'BUFFER PANE' }),
460            Text({ color: C.dim, children: ' · ' }),
461            Text({ color: queued > 0 ? C.main : C.dim, children: String(queued) }),
462            Text({ children: ' QUEUED' }),
463          ],
464        })],
465      }),
466      Box({
467        justifyContent: 'center',
468        columnGap: 2,
469        children: legend.map((item) => Text({ children: [Text({ color: item.color, children: '■' }), Text({ dimColor: true, children: ` ${item.label}` })] })),
470      }),
471      queueCardOf(ui, state, host, width),
472      Text({ color: C.faint, children: '─'.repeat(width) }),
473      newBlockCardOf(ui, state, host, width),
474    ],
475  })
476}
477
478export function register(on: On) {
479  const state: State = { host: null, isOpen: false, storeKey: null, buffer: emptyBuffer(), draftGeneration: 0 }
480
481  on('session.start', async ($, e, next) => {
482    state.host = hostOf($)
483    await state.host.register().catch((error: unknown) => {
484      state.host?.log(`buffer-pane: /${COMMAND} is not available: ${messageOf(error)}`)
485    })
486    // A hot reload of this module starts a new session under an open pane. The buffer is
487    // read here so the first redraw after the reload shows it.
488    await load(state, state.host).catch(() => undefined)
489    // A reload keeps the pane up but starts this module over: ask the engine whether it is open
490    state.isOpen = await state.host.isPaneUp().catch(() => false)
491    await state.host.publishOpen(state.isOpen).catch(() => undefined)
492    // Local patch: no open on launch. Only Flightdeck opens unasked; /buffer-pane opens this one.
493    return next(e)
494  })
495
496  on('command.run', { command: COMMAND }, async ($, e, next) => {
497    const host = state.host
498    if (host === null) return next(e)
499
500    if (state.isOpen) {
501      await host.close()
502      state.isOpen = false
503      await host.publishOpen(false).catch(() => undefined)
504      return { text: 'buffer-pane hidden' }
505    }
506
507    await load(state, host)
508    await host.open()
509    state.isOpen = true
510    await host.publishOpen(true).catch(() => undefined)
511    return { text: 'buffer-pane shown' }
512  })
513
514  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
515    if (e.requestId !== PANE_ID || state.host === null) return next(e)
516    if (e.surface !== 'terminal') return next(e)
517    const { Box, Button, Text, Input } = await $.ui.resolve(e)
518    return paneOf({ Box, Button, Text, Input }, state, state.host, e.props.bodyColumns)
519  })
520
521  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
522    const result = await next(e)
523    if (result.deny === undefined) {
524      state.isOpen = false
525      await state.host?.publishOpen(false).catch(() => undefined)
526    }
527    return result
528  })
529}
530
types/index.d.ts 11 lines
1/** Whether the Buffer pane is open; Mod Tools reads it for its Show column. */
2export type PaneOpen = boolean
3
4declare module 'claude-code' {
5  interface PluginState {
6    'buffer-pane': {
7      paneOpen: PaneOpen
8    }
9  }
10}
11