fix: align channel config schemas and env precedence

This commit is contained in:
Peter Steinberger
2026-01-17 00:12:41 +00:00
parent 3ec221c70e
commit 7ecf733342
16 changed files with 249 additions and 38 deletions

View File

@@ -53,6 +53,7 @@
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only when config is unset).
### Changes
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.

View File

@@ -10,9 +10,38 @@ struct ConfigSchemaForm: View {
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let value = store.configValue(at: path)
let storedValue = store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap { $0.literalValue }
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
}
)
}
}
switch schema.schemaType {
case "object":
@@ -48,7 +77,7 @@ struct ConfigSchemaForm: View {
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
return AnyView(
Toggle(isOn: self.boolBinding(path)) {
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? "")
@@ -78,7 +107,8 @@ struct ConfigSchemaForm: View {
{
let hint = hintForPath(path, hints: store.configUiHints)
let placeholder = hint?.placeholder ?? ""
let sensitive = hint?.sensitive ?? false
let sensitive = hint?.sensitive ?? isSensitivePath(path)
let defaultValue = schema.explicitDefault as? String
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
@@ -87,7 +117,7 @@ struct ConfigSchemaForm: View {
.foregroundStyle(.secondary)
}
if let options = schema.enumValues {
Picker("", selection: self.enumBinding(path, options: options)) {
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
Text("Select…").tag(-1)
ForEach(options.indices, id: \ .self) { index in
Text(String(describing: options[index])).tag(index)
@@ -95,10 +125,10 @@ struct ConfigSchemaForm: View {
}
.pickerStyle(.menu)
} else if sensitive {
SecureField(placeholder, text: self.stringBinding(path))
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path))
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@@ -111,6 +141,8 @@ struct ConfigSchemaForm: View {
label: String?,
help: String?) -> some View
{
let defaultValue = (schema.explicitDefault as? Double)
?? (schema.explicitDefault as? Int).map(Double.init)
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
@@ -118,7 +150,14 @@ struct ConfigSchemaForm: View {
.font(.caption)
.foregroundStyle(.secondary)
}
TextField("", text: self.numberBinding(path, isInteger: schema.schemaType == "integer"))
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue
)
)
.textFieldStyle(.roundedBorder)
}
}
@@ -223,10 +262,11 @@ struct ConfigSchemaForm: View {
}
}
private func stringBinding(_ path: ConfigPath) -> Binding<String> {
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
Binding(
get: {
store.configValue(at: path) as? String ?? ""
if let value = store.configValue(at: path) as? String { return value }
return defaultValue ?? ""
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -235,10 +275,11 @@ struct ConfigSchemaForm: View {
)
}
private func boolBinding(_ path: ConfigPath) -> Binding<Bool> {
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
Binding(
get: {
store.configValue(at: path) as? Bool ?? false
if let value = store.configValue(at: path) as? Bool { return value }
return defaultValue ?? false
},
set: { newValue in
store.updateConfigValue(path: path, value: newValue)
@@ -246,11 +287,16 @@ struct ConfigSchemaForm: View {
)
}
private func numberBinding(_ path: ConfigPath, isInteger: Bool) -> Binding<String> {
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?
) -> Binding<String> {
Binding(
get: {
guard let value = store.configValue(at: path) else { return "" }
return String(describing: value)
if let value = store.configValue(at: path) { return String(describing: value) }
guard let defaultValue else { return "" }
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -263,10 +309,15 @@ struct ConfigSchemaForm: View {
)
}
private func enumBinding(_ path: ConfigPath, options: [Any]) -> Binding<Int> {
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?
) -> Binding<Int> {
Binding(
get: {
guard let value = store.configValue(at: path) else { return -1 }
let value = store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
} ?? -1

View File

@@ -42,6 +42,8 @@ struct ConfigSchemaNode {
var title: String? { self.raw["title"] as? String }
var description: String? { self.raw["description"] as? String }
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
var constValue: Any? { self.raw["const"] }
var explicitDefault: Any? { self.raw["default"] }
var requiredKeys: Set<String> {
Set((self.raw["required"] as? [String]) ?? [])
}
@@ -58,11 +60,32 @@ struct ConfigSchemaNode {
return self.typeList.first
}
var isNullSchema: Bool {
let types = self.typeList
return types.count == 1 && types.first == "null"
}
var properties: [String: ConfigSchemaNode] {
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
}
var anyOf: [ConfigSchemaNode] {
guard let raw = self.raw["anyOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var oneOf: [ConfigSchemaNode] {
guard let raw = self.raw["oneOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var literalValue: Any? {
if let constValue { return constValue }
if let enumValues, enumValues.count == 1 { return enumValues[0] }
return nil
}
var items: ConfigSchemaNode? {
if let items = self.raw["items"] as? [Any], let first = items.first {
return ConfigSchemaNode(raw: first)
@@ -161,6 +184,15 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
return nil
}
func isSensitivePath(_ path: ConfigPath) -> Bool {
let key = pathKey(path).lowercased()
return key.contains("token")
|| key.contains("password")
|| key.contains("secret")
|| key.contains("apikey")
|| key.hasSuffix("key")
}
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {

View File

@@ -13,7 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
2) Set the token for Clawdbot:
- Env: `DISCORD_BOT_TOKEN=...`
- Or config: `channels.discord.token: "..."`.
- If both are set, config wins; env is fallback.
- If both are set, config takes precedence (env fallback is default-account only).
3) Invite the bot to your server with message permissions.
4) Start the gateway.
5) DM access is pairing by default; approve the pairing code on first contact.
@@ -39,9 +39,9 @@ Minimal config:
## How it works
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`).
3. Configure Clawdbot with `channels.discord.token` (or `DISCORD_BOT_TOKEN` as a fallback).
4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`.
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (and omit config).
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve discord <code>`.

View File

@@ -32,7 +32,7 @@ Details: [Plugins](/plugin)
2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*`
- Config takes precedence over env; env is fallback.
- If both are set, config takes precedence.
3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact.

View File

@@ -13,7 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
2) Set the token:
- Env: `TELEGRAM_BOT_TOKEN=...`
- Or config: `channels.telegram.botToken: "..."`.
- If both are set, config wins; env is fallback.
- If both are set, config takes precedence (env fallback is default-account only).
3) Start the gateway.
4) DM access is pairing by default; approve the pairing code on first contact.
@@ -61,7 +61,8 @@ Example:
}
```
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account; used only when config is missing).
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account).
If both env and config are set, config takes precedence.
Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.

View File

@@ -948,7 +948,7 @@ Set `web.enabled: false` to keep it off by default.
### `channels.telegram` (bot transport)
Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`.
Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `channels.telegram.botToken` (or `channels.telegram.tokenFile`), with `TELEGRAM_BOT_TOKEN` as a fallback for the default account.
Set `channels.telegram.enabled: false` to disable automatic startup.
Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Set `channels.telegram.configWrites: false` to block Telegram-initiated config writes (including supergroup ID migrations and `/config set|unset`).
@@ -1081,7 +1081,7 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
}
```
Clawdbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `channels.discord.token` (unless `channels.discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
Clawdbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `channels.discord.token`, with `DISCORD_BOT_TOKEN` as a fallback for the default account (unless `channels.discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
Bot-authored messages are ignored by default. Enable with `channels.discord.allowBots` (own messages are still filtered to prevent self-reply loops).
Reaction notification modes:

View File

@@ -3,12 +3,14 @@ import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "../../../src/channels/plugins/config-helpers.js";
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
import type { CoreConfig } from "./types.js";
import {
@@ -95,6 +97,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
media: true,
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
})
.optional();
const matrixDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
})
.optional();
const matrixRoomSchema = z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
autoReply: z.boolean().optional(),
users: z.array(allowFromEntry).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional();
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
password: z.string().optional(),
deviceName: z.string().optional(),
initialSyncLimit: z.number().optional(),
allowlistOnly: z.boolean().optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -1,8 +1,10 @@
import type { ChannelDock, ChannelPlugin } from "../../src/channels/plugins/types.js";
import type { ChannelAccountSnapshot } from "../../src/channels/plugins/types.js";
import type { ChannelAccountSnapshot } from "../../../src/channels/plugins/types.js";
import type { ChannelDock, ChannelPlugin } from "../../../src/channels/plugins/types.js";
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
import { zaloMessageActions } from "./actions.js";
import { ZaloConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -81,6 +83,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: {
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
});
export const ZaloConfigSchema = zaloAccountSchema.extend({
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
defaultAccount: z.string().optional(),
});

View File

@@ -37,7 +37,7 @@ function resolveSendContext(options: ZaloSendOptions): {
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
const proxy = options.proxy;
return { token: token || process.env.ZALO_BOT_TOKEN?.trim() || "", fetcher: resolveZaloProxyFetch(proxy) };
return { token, fetcher: resolveZaloProxyFetch(proxy) };
}
export async function sendMessageZalo(

View File

@@ -1,6 +1,6 @@
import type { ZodTypeAny } from "zod";
import type { ChannelConfigSchema } from "./types.js";
import type { ChannelConfigSchema } from "./types.plugin.js";
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
return {

View File

@@ -27,7 +27,7 @@ describe("resolveTelegramAccount", () => {
}
});
it("prefers TELEGRAM_BOT_TOKEN when accountId is omitted", () => {
it("uses TELEGRAM_BOT_TOKEN when default account config is missing", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "tok-env";
try {
@@ -50,6 +50,29 @@ describe("resolveTelegramAccount", () => {
}
});
it("prefers default config token over TELEGRAM_BOT_TOKEN", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "tok-env";
try {
const cfg: ClawdbotConfig = {
channels: {
telegram: { botToken: "tok-config" },
},
};
const account = resolveTelegramAccount({ cfg });
expect(account.accountId).toBe("default");
expect(account.token).toBe("tok-config");
expect(account.tokenSource).toBe("config");
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
it("does not fall back when accountId is explicitly provided", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";

View File

@@ -74,8 +74,8 @@ export function resolveTelegramAccount(params: {
if (primary.tokenSource !== "none") return primary;
// If accountId is omitted, prefer a configured account token over failing on
// the implicit "default" account. This keeps env-based setups working (env
// still wins) while making config-only tokens work for things like heartbeats.
// the implicit "default" account. This keeps env-based setups working while
// making config-only tokens work for things like heartbeats.
const fallbackId = resolveDefaultTelegramAccountId(params.cfg);
if (fallbackId === primary.accountId) return primary;
const fallback = resolve(fallbackId);

View File

@@ -73,8 +73,10 @@ export function renderNode(params: {
const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0) {
const resolvedValue = value ?? schema.default;
const currentIndex = literals.findIndex(
(lit) => lit === value || String(lit) === String(value),
(lit) =>
lit === resolvedValue || String(lit) === String(resolvedValue),
);
return html`
<label class="field">
@@ -101,8 +103,10 @@ export function renderNode(params: {
if (schema.enum) {
const options = schema.enum;
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === value || String(opt) === String(value),
(opt) =>
opt === resolvedValue || String(opt) === String(resolvedValue),
);
const unset = "__unset__";
return html`
@@ -128,7 +132,11 @@ export function renderNode(params: {
}
if (type === "object") {
const obj = (value ?? {}) as Record<string, unknown>;
const fallback = value ?? schema.default;
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
const sorted = entries.sort((a, b) => {
@@ -184,7 +192,11 @@ export function renderNode(params: {
<div class="muted">Unsupported array schema. Use Raw.</div>
</div>`;
}
const arr = Array.isArray(value) ? value : [];
const arr = Array.isArray(value)
? value
: Array.isArray(schema.default)
? schema.default
: [];
return html`
<div class="field" style="margin-top: 12px;">
${showLabel ? html`<span>${label}</span>` : nothing}
@@ -235,13 +247,19 @@ export function renderNode(params: {
}
if (type === "boolean") {
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="checkbox"
.checked=${Boolean(value)}
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
@@ -251,13 +269,14 @@ export function renderNode(params: {
}
if (type === "number" || type === "integer") {
const displayValue = value ?? schema.default;
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="number"
.value=${value == null ? "" : String(value)}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
@@ -272,6 +291,7 @@ export function renderNode(params: {
if (type === "string") {
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
const displayValue = value ?? schema.default ?? "";
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
@@ -279,7 +299,7 @@ export function renderNode(params: {
<input
type=${isSensitive ? "password" : "text"}
placeholder=${placeholder}
.value=${value == null ? "" : String(value)}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).value)}