SLOPSHOPPER

Fold POC

Proof-of-concept: can one /config row fold a plugin's other rows away (a config.describe hook answering isHidden, with $.ui.invalidate on change)? Throwaway…

newcommand
A shopper browsing a rack in a slop shop
README

POC: can one /config row fold a plugin's other rows away?

Question (keys-and-input design, 2026-10-20, candidate A/B's falsifier): a plugin cannot group or nest its /config rows (the manifest schema has no such key; the typings have no such kind). The emulation: one row, "settings: shown | hidden", whose config.describe hook answers isHidden: true for the plugin's other rows while it reads hidden, and which calls $.ui.invalidate("config.describe") when it changes. If that works, the keymap can be one /config row per command behind one fold. If it does not, the keymap is one row holding the whole map (the palette's shape).

The kit cannot observe this: $.config.list() is a stub the harness answers (plugin/tests/harness.ts:149), and the describe cache is the engine's. So both arms are a live look, with this throwaway plugin loaded beside nothing else.

Run it (MANUAL, two minutes, a scratch terminal)

claude --plugin-dir C:/code/claude-projects/claude-bookmarks/tests/one-offs/thinking/config-fold

Then:

  1. /config, filter Fold POC. Expect five rows: the two fold rows and test rows A, B, C.
  2. Set Fold POC settings (invalidates) to hidden. Look without closing the menu. Arm (b).
  3. Run /bmfold-rows and read the log line. Arm (a).
  4. Set it back to shown (rows return?). Then set CONTROL: no invalidate to hidden and look: the rows are predicted to STAY. Run /bmfold-rows: predicted to still list A B C (cached). Control arm.
  5. Close /config, reopen it: are A, B, C hidden now (for the control, still cached; for the fold, if (b) failed, this is the "reopen" question).

Predictions, pass criteria, fallbacks -- written before running

armpredictionpass criterionfallback if it fails
(a) $.config.list() after the fold is set to hidden/bmfold-rows logs [fold foldNoInvalidate], A B C absentthe log line names no row A/B/Cthe describe hook or invalidate is not doing what the typings say: candidate D (one row), and a draft note for the engine
(b) the open menu repaintsrows A, B, C vanish from the open menu within a moment of the pick, no close/reopenseen vanishing while the menu is open"close and reopen /config" in the fold row's description; still candidate B if (a) and (5) pass
control: foldNoInvalidate set to hiddenrows A, B, C STAY in the menu and in /bmfold-rows (describe answers cached)they stayif the control passes (rows vanish without invalidate) the method is broken -- describe is not cached as documented, or the engine re-asks on every draw; discard every result and re-read the typings
(5) reopenafter close+reopen with the fold hidden: A B C absent; with only the control hidden: present (cache survives reopen)as statedif the control's rows vanish on reopen, the cache is per-menu-open, not per-session: still usable, note it

Record (fill in after the look)

armobservedverdict
(a)bmfoldpoc rows drawn now: [fold foldNoInvalidate] (fold=hidden, control=shown)held
(b)Rows A, B, C left the open menu as soon as the fold was set to hidden; no close or reopen. They came back on shown.held
controlRows A, B, C left too: control=hidden, cache NOT invalidated (control arm), then rows drawn now: [fold foldNoInvalidate] (fold=shown, control=hidden)passed, against the prediction
(5)not run (not needed: (b) held in the open menu)--

Why the control passed: every row change reloads the plugin (bmfoldpoc: options changed — reloaded (4 hooks: config.describe, config.set, session.start, command.run) after each pick), and the reload asks config.describe for every row again. So the reload, not $.ui.invalidate, is what hides the rows on this build. The control could not separate the two arms because both go through a reload; it does not break the product question, which is whether one row can hide the others in an open menu, and that held.

Verdict for the design: survived, by a different mechanism than predicted. The keymap is rows behind a fold (candidate B). A3 keeps the $.ui.invalidate("config.describe") call after a change anyway, so the fold survives a future build that stops reloading on every change. Observed by djdarcy, 2026-10-24, Claude Code 2.1.308, the session resumed with --plugin-dir on this folder; the log lines read from the session's own transcript.

Side finding: the poc's session.start hook returns the wrong shape (session.start hook skipped: returned the wrong shape (no result)); harmless here, and a reminder that a session.start hook must return next(e).

Source 1 files
hooks/register.ts 58 lines
1// Proof-of-concept: the /config fold. One row ("fold") hides this plugin's other rows
2// through a config.describe hook while it reads "hidden", and calls
3// $.ui.invalidate("config.describe") when it changes so the menu can redraw. A second row
4// ("foldNoInvalidate") does the same hiding WITHOUT the invalidate: the control arm,
5// predicted to leave the rows visible (the describe answers are cached until invalidate
6// or a reload -- typings 4380-4384).
7//
8// /bmfold-rows logs the rows $.config.list() returns now (hidden rows are left out), so
9// arm (a) can be read from the log; arm (b), whether the OPEN menu repaints, is a look.
10// Throwaway: tests/one-offs/thinking/config-fold; never part of the shipped plugin.
11import type { Register } from 'claude-code'
12
13let fold = 'shown'
14let control = 'shown'
15
16export const register: Register = (on, options) => {
17  fold = String(options.fold ?? 'shown')
18  control = String(options.foldNoInvalidate ?? 'shown')
19
20  // The three test rows are hidden while either fold row says so.
21  on('config.describe', { key: /^bmfoldpoc\.row[ABC]$/ }, async (_$, e, next) => {
22    const r = await next(e)
23    return { ...r, isHidden: fold === 'hidden' || control === 'hidden' }
24  })
25
26  // The real candidate: remember the value, then drop the describe cache.
27  on('config.set', { key: 'bmfoldpoc.fold' }, async ($, e, next) => {
28    const r = await next(e)
29    if (!r.deny) {
30      fold = String(r.value)
31      await $.ui.invalidate('config.describe')
32      $.ui.log(`bmfoldpoc: fold=${fold}, describe cache invalidated`)
33    }
34    return r
35  })
36
37  // The control: remember the value, do NOT invalidate.
38  on('config.set', { key: 'bmfoldpoc.foldNoInvalidate' }, async ($, e, next) => {
39    const r = await next(e)
40    if (!r.deny) {
41      control = String(r.value)
42      $.ui.log(`bmfoldpoc: control=${control}, cache NOT invalidated (control arm)`)
43    }
44    return r
45  })
46
47  on('session.start', async $ => {
48    await $.command.register({ name: 'bmfold-rows', description: 'Fold POC: log which of this plugin\'s /config rows would be drawn now', immediate: true })
49  })
50
51  on('command.run', { command: 'bmfold-rows' }, async $ => {
52    const rows = await $.config.list()
53    const mine = rows.filter(r => r.key.startsWith('bmfoldpoc.')).map(r => r.key.slice('bmfoldpoc.'.length))
54    $.ui.log(`bmfoldpoc rows drawn now: [${mine.join(' ')}]  (fold=${fold}, control=${control})`)
55    return {}
56  })
57}
58