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…

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.
claude --plugin-dir C:/code/claude-projects/claude-bookmarks/tests/one-offs/thinking/config-fold
Then:
/config, filter Fold POC. Expect five rows: the two fold rows and test rows A, B, C.hidden. Look without closing the menu. Arm (b)./bmfold-rows and read the log line. Arm (a).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./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).| arm | prediction | pass criterion | fallback if it fails |
|---|---|---|---|
(a) $.config.list() after the fold is set to hidden | /bmfold-rows logs [fold foldNoInvalidate], A B C absent | the log line names no row A/B/C | the 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 repaints | rows A, B, C vanish from the open menu within a moment of the pick, no close/reopen | seen 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 hidden | rows A, B, C STAY in the menu and in /bmfold-rows (describe answers cached) | they stay | if 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) reopen | after close+reopen with the fold hidden: A B C absent; with only the control hidden: present (cache survives reopen) | as stated | if the control's rows vanish on reopen, the cache is per-menu-open, not per-session: still usable, note it |
| arm | observed | verdict |
|---|---|---|
| (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 |
| control | Rows 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).
hooks/register.ts 58 lines1// 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