diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx
index 3845e9e5f8..6667436a6c 100644
--- a/apps/app/src/components/plugin/PluginSettings.test.tsx
+++ b/apps/app/src/components/plugin/PluginSettings.test.tsx
@@ -29,9 +29,18 @@ function jsonOk(body: unknown): Response {
const SETTINGS_VIEW = {
ok: true,
schema: {
- greeting: { type: "string", label: "Greeting" },
+ greeting: {
+ type: "string",
+ label: "Greeting",
+ placeholder: "Enter a greeting",
+ },
enabled: { type: "boolean", label: "Enabled" },
- apiKey: { type: "string", label: "API key", secret: true },
+ apiKey: {
+ type: "string",
+ label: "API key",
+ secret: true,
+ placeholder: "Paste an API key",
+ },
},
values: { greeting: "hello", enabled: true, apiKey: { set: false } },
};
@@ -66,11 +75,12 @@ describe("PluginSettingsForm", () => {
"Greeting",
)) as HTMLInputElement;
expect(greeting.value).toBe("hello");
+ expect(greeting.placeholder).toBe("Enter a greeting");
// Secrets are write-only: no value, only a set/not-set placeholder.
const apiKey = screen.getByLabelText("API key") as HTMLInputElement;
expect(apiKey.value).toBe("");
- expect(apiKey.placeholder).toBe("[not set]");
+ expect(apiKey.placeholder).toBe("Paste an API key");
const save = screen.getByRole("button", { name: /save settings/i });
expect((save as HTMLButtonElement).disabled).toBe(true);
@@ -125,6 +135,31 @@ describe("PluginSettingsForm", () => {
values: { apiKey: "sk-123" },
});
});
+
+ it("keeps the set marker for configured secrets", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() =>
+ Promise.resolve(
+ jsonOk({
+ ...SETTINGS_VIEW,
+ values: {
+ ...SETTINGS_VIEW.values,
+ apiKey: { set: true },
+ },
+ }),
+ ),
+ ),
+ );
+
+ const { wrapper } = createQueryClientTestHarness();
+ render(, { wrapper });
+
+ const apiKey = (await screen.findByLabelText(
+ "API key",
+ )) as HTMLInputElement;
+ expect(apiKey.placeholder).toBe("[set]");
+ });
});
function rowPlugin(
diff --git a/apps/app/src/components/plugin/PluginSettings.tsx b/apps/app/src/components/plugin/PluginSettings.tsx
index a5d91dd283..a9722f4970 100644
--- a/apps/app/src/components/plugin/PluginSettings.tsx
+++ b/apps/app/src/components/plugin/PluginSettings.tsx
@@ -184,12 +184,17 @@ function PluginSettingField({
: !isSecret && typeof storedValue === "string"
? storedValue
: "";
+ const placeholder = isSecret
+ ? secretIsSet
+ ? "[set]"
+ : (descriptor.placeholder ?? "[not set]")
+ : descriptor.placeholder;
return (
onChange(event.target.value)}
className="h-7 w-full text-xs sm:w-64"
/>
diff --git a/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx b/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
index 82e46516ef..6d88c5f25e 100644
--- a/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
+++ b/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
@@ -191,9 +191,9 @@ function SettingField({
placeholder={
isSecret
? pluginSecretIsSet(storedValue)
- ? "[set] — type to replace"
- : "[not set]"
- : undefined
+ ? "[set]"
+ : (descriptor.placeholder ?? "[not set]")
+ : descriptor.placeholder
}
autoCapitalize="none"
editable={!disabled}
diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts
index 0926a86288..be3112afcc 100644
--- a/packages/plugin-sdk/src/backend-contract.ts
+++ b/packages/plugin-sdk/src/backend-contract.ts
@@ -53,6 +53,7 @@ export type PluginSettingDescriptor =
description?: string;
/** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */
secret?: true;
+ placeholder?: string;
default?: string;
}
| { type: "boolean"; label: string; description?: string; default?: boolean }
diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts
index bb4fc85928..fffa38eb1d 100644
--- a/packages/plugin-sdk/src/internal/host-policy.ts
+++ b/packages/plugin-sdk/src/internal/host-policy.ts
@@ -100,6 +100,7 @@ const settingDescriptorSchema = z.discriminatedUnion("type", [
type: z.literal("string"),
...settingsBaseFields,
secret: z.literal(true).optional(),
+ placeholder: z.string().min(1).optional(),
default: z.string().optional(),
})
.strict(),
diff --git a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
index 05ac4bb468..f25d61903e 100644
--- a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
+++ b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
@@ -238,6 +238,23 @@ describe("settings", () => {
});
});
+ it("retains string input placeholders", () => {
+ const { bb, harness } = createFakePluginHost();
+ bb.settings.define({
+ token: {
+ type: "string",
+ label: "Token",
+ placeholder: "Paste a token",
+ },
+ });
+
+ expect(harness.registrations.settingsDescriptors.token).toEqual({
+ type: "string",
+ label: "Token",
+ placeholder: "Paste a token",
+ });
+ });
+
it("setSettings validates, fires onChange with next/prev, and skips no-op updates", async () => {
const { bb, harness } = createFakePluginHost();
const handle = defineSettings(bb);
@@ -279,6 +296,17 @@ describe("settings", () => {
broken: { type: "select", label: "B", options: ["a"], default: "z" },
}),
).toThrow('default for setting "broken" must be one of its options');
+ expect(() =>
+ bb.settings.define({
+ emptyPlaceholder: {
+ type: "string",
+ label: "Empty placeholder",
+ placeholder: "",
+ },
+ }),
+ ).toThrow(
+ 'invalid descriptor for setting "emptyPlaceholder" (placeholder)',
+ );
});
});
diff --git a/packages/server-contract/src/api/plugins.ts b/packages/server-contract/src/api/plugins.ts
index 9f3a69643f..615ed116d1 100644
--- a/packages/server-contract/src/api/plugins.ts
+++ b/packages/server-contract/src/api/plugins.ts
@@ -296,6 +296,7 @@ export const pluginSettingDescriptorSchema = z.discriminatedUnion("type", [
...pluginSettingBaseSchema,
type: z.literal("string"),
secret: z.literal(true).optional(),
+ placeholder: z.string().min(1).optional(),
default: z.string().optional(),
})
.strict(),