feat(plugin-sdk): render long-form string settings in a textarea - #1238
Closed
smsunarto wants to merge 1 commit into
Closed
feat(plugin-sdk): render long-form string settings in a textarea#1238smsunarto wants to merge 1 commit into
smsunarto wants to merge 1 commit into
Conversation
smsunarto
force-pushed
the
feat/plugin-settings-multiline
branch
from
August 9, 2026 23:57
9dbb2db to
046ab54
Compare
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
force-pushed
the
feat/plugin-settings-multiline
branch
from
August 10, 2026 00:23
046ab54 to
6d2807e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ownsettingsSectionsurface just to get a textarea.This adds
multiline?: trueto thestringdescriptor:PluginSettingValue,PluginSettingsValues, the value-coercion paths, secret file storage, and the DB layer are all untouched — only the rendered control changes.typewould force edits to the CLI's type enum (apps/cli/src/commands/plugin.ts:82), the two-way compile-time guard inplugin-authoring-docs.test.ts:86-100, and everytype === "string"branch. A flag touches none of them.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 tosinglelineText(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.SettingsWithControlgainslayout="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.multilinecomposes withsecretThe two are orthogonal:
secretgoverns storage (0600 file, write-only, never on the wire),multilinegoverns 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:
[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 controltype="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.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:apps/server/.../plugin-settings.tspackages/plugin-sdk/src/testing/fake-plugin-host.tspackages/server-contract/src/api/plugins.tsA fourth copy in the CLI (
apps/cli/src/commands/plugin.ts:81) is a plainz.object, so it strips the unknown key and needs no change:printSettingsrenders a non-secret value throughJSON.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-sdkandapps/serverboth 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.tsandpackages/templates/src/generated/plugin-sdk-dts.generated.tsare 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--checkgates pass.Note:
bundled-types/is not covered by.prettierignore, soprettier --checkflags that file. It also flags it onmain; the generator's output is authoritative. Do not run prettier on it — that breaksbuild-bundled-dts.mjs --check.Test plan
turbo run typecheckacross@bb/plugin-sdk,@bb/server-contract,@bb/templates,@bb/app,@bb/server— 6/6 greenturbo run test --filter=@bb/plugin-sdk --filter=@bb/templates— green, including both--checkgatesapps/serverplugin-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 bodyapps/appPluginSettings.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 offFollow-up
This unblocks migrating the builtin
custom-instructionsplugin off its bespokesettingsSectionsurface. Not included here — that migration needs a decision aboutbb instructions set|clear, sincePluginSettingsHandleexposes onlyget()/onChange()and a plugin cannot write its own setting.🤖 Generated with Claude Code