Skip to content

feat(plugin-sdk): render long-form string settings in a textarea - #1238

Closed
smsunarto wants to merge 1 commit into
get-bb:mainfrom
smsunarto:feat/plugin-settings-multiline
Closed

feat(plugin-sdk): render long-form string settings in a textarea#1238
smsunarto wants to merge 1 commit into
get-bb:mainfrom
smsunarto:feat/plugin-settings-multiline

Conversation

@smsunarto

@smsunarto smsunarto commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Declarative plugin settings could only render a single-line <Input>. A plugin with a prose-shaped value — a preamble, a template, a block of custom instructions, a PEM key — had to abandon the descriptor API entirely and ship its own settingsSection surface just to get a textarea.

This adds multiline?: true to the string descriptor:

bb.settings.define({
  preamble: { type: "string", label: "Preamble", multiline: true, default: "" },
  signingKey: { type: "string", label: "Signing key", secret: true, multiline: true },
});
  • The stored value is still a plain string. PluginSettingValue, PluginSettingsValues, the value-coercion paths, secret file storage, and the DB layer are all untouched — only the rendered control changes.
  • A flag, not a fifth descriptor type. A new type would force edits to the CLI's type enum (apps/cli/src/commands/plugin.ts:82), the two-way compile-time guard in plugin-authoring-docs.test.ts:86-100, and every type === "string" branch. A flag touches none of them.
  • A flag, not a presentation enum. VS Code's contributes.configuration — the closest structural analogue, also a plain-data schema a host renders into a settings UI — uses "editPresentation": "multilineText" | "singlelineText" on string settings, defaulting to singlelineText (docs, 1.59 release notes). An enum extends more cleanly if a second presentation mode ever lands (monospace, code editor), where a second boolean would have undefined interaction with the first. I chose the flag anyway: it matches MUI's <TextField multiline>, reads better at the call site, and this descriptor set is small enough that the SDK can absorb an enum migration later if a third mode actually appears. Recording the trade-off so it does not get relitigated blindly.
  • Full-width row. SettingsWithControl gains layout="stacked", which drops the control onto its own row beneath the label. A textarea is unreadable in the narrow right-hand column an inline row gives it. Default stays "inline", so every existing caller is unaffected.

multiline composes with secret

The two are orthogonal: secret governs storage (0600 file, write-only, never on the wire), multiline governs rendering. The combination is the common case — a PEM private key, an SSH key, a TLS chain, service-account JSON. The whole secret path is already newline-agnostic (readFile(path, "utf8") with no trimming, raw write, { set: boolean } on the wire), so nothing had to change to support it.

A secret textarea:

  • stays write-only — same [set]/[not set] placeholder as the secret input, and it inherits the "empty draft means leave unchanged" filter, which keys off the descriptor rather than the control
  • is not masked — a textarea has no type="password", and hiding a pasted key would obscure the one thing worth eyeballing without protecting a value the server never sends back. GitHub and GitLab both take SSH keys in a plain textarea.
  • disables spellcheck and autofill, so a pasted key is not shipped to a spellcheck service or offered as an autofill target

Three schemas move in lockstep

The descriptor union is enumerated by three independent .strict() zod copies. .strict() means an unrecognised key is rejected, not stripped, so all three must change together — and each fails differently if missed:

Copy Failure if missed
apps/server/.../plugin-settings.ts plugin load error
packages/plugin-sdk/src/testing/fake-plugin-host.ts authoring harness diverges from the real host
packages/server-contract/src/api/plugins.ts client parse fails → silently blank settings form

A fourth copy in the CLI (apps/cli/src/commands/plugin.ts:81) is a plain z.object, so it strips the unknown key and needs no change: printSettings renders a non-secret value through JSON.stringify, which escapes newlines onto one line, and a secret shows [set]/[not set]. bb plugin config <id> set <key> <value> already handles a multiline value. It does independently enumerate the type list, so it is a fourth site to remember when a future change adds a descriptor type rather than a flag.

Worth flagging for a follow-up: nothing mechanically enforces the lockstep — there is no test asserting the copies agree, only a prose comment at fake-plugin-host.ts:489-490. The dependency graph would permit collapsing them (plugin-sdk and apps/server both already depend on @bb/server-contract, which depends on neither), so a single exported schema looks feasible. Out of scope here, but this PR adds a fourth field that had to be threaded by hand through all of them.

Generated artifacts

packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts and packages/templates/src/generated/plugin-sdk-dts.generated.ts are regenerated, in that order. The d.ts diff carries ~30 lines of incidental zod enum key reordering — the bundler's member ordering shifts when the type graph changes. It is generator output, not a hand edit, and both --check gates pass.

Note: bundled-types/ is not covered by .prettierignore, so prettier --check flags that file. It also flags it on main; the generator's output is authoritative. Do not run prettier on it — that breaks build-bundled-dts.mjs --check.

Test plan

  • turbo run typecheck across @bb/plugin-sdk, @bb/server-contract, @bb/templates, @bb/app, @bb/server — 6/6 green
  • turbo run test --filter=@bb/plugin-sdk --filter=@bb/templates — green, including both --check gates
  • apps/server plugin-settings-storage.test.ts + plugin-authoring-docs.test.ts — 27 passed, with two new cases: a plain multiline round-trip through an embedded-newline value, and a secret multiline round-trip asserting the PEM lands in its 0600 file with newlines intact, surfaces as { set: true }, and never appears in the response body
  • apps/app PluginSettings.test.tsx — 8 passed, with two new cases: a textarea renders and newlines survive the PUT, and a secret multiline renders write-only, unmasked, with spellcheck and autofill off

Follow-up

This unblocks migrating the builtin custom-instructions plugin off its bespoke settingsSection surface. Not included here — that migration needs a decision about bb instructions set|clear, since PluginSettingsHandle exposes only get()/onChange() and a plugin cannot write its own setting.

🤖 Generated with Claude Code

@smsunarto
smsunarto force-pushed the feat/plugin-settings-multiline branch from 9dbb2db to 046ab54 Compare August 9, 2026 23:57
Declarative settings could only render a single-line input, so a plugin
with a prose-shaped value — a preamble, a template, a block of custom
instructions — had to abandon the descriptor API and ship its own
settingsSection surface just to get a textarea.

Add `multiline?: true` to the string descriptor. The stored value is still
a plain string, so `PluginSettingValue`, the value-coercion paths, secret
file storage, and the CLI are all unchanged; only the control changes. The
field renders as a textarea on its own full-width row, since a textarea is
unreadable in the narrow right-hand column an inline row gives it.

A flag rather than a fifth descriptor type: a new `type` would force edits
to the CLI's type enum, the two-way guard in plugin-authoring-docs, and
every `type === "string"` branch. A flag touches none of them.

`multiline` composes with `secret`. The two are orthogonal — `secret`
governs storage (0600 file, write-only), `multiline` governs rendering —
and the combination is the common case for a PEM key, an SSH key, or
service-account JSON. A secret textarea stays write-only, showing the same
`[set]`/`[not set]` placeholder as the secret input, and inherits the
"empty draft means leave unchanged" filter, which keys off the descriptor
rather than the control. It is deliberately not masked: a textarea has no
password mode, and hiding a pasted key would obscure the paste without
protecting a value the server never sends back. Spellcheck and autofill
are disabled instead, so a pasted key reaches no spellcheck service.

All three `.strict()` copies of the descriptor union move in lockstep:
the real host, the SDK's fake testing host, and the wire DTO. Missing one
fails differently — plugin load error, harness divergence, or a silently
blank settings form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@smsunarto
smsunarto force-pushed the feat/plugin-settings-multiline branch from 046ab54 to 6d2807e Compare August 10, 2026 00:23
@smsunarto smsunarto closed this Aug 10, 2026
@smsunarto
smsunarto deleted the feat/plugin-settings-multiline branch August 10, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant