diff --git a/frontend/app/configui/configvalidation.test.ts b/frontend/app/configui/configvalidation.test.ts new file mode 100644 index 0000000000..0f4d77920b --- /dev/null +++ b/frontend/app/configui/configvalidation.test.ts @@ -0,0 +1,45 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + getEffectiveConfigValue, + isConfigValueOverridden, + normalizeConfigStringInput, + validateConfigNumberInput, + validateConfigStringInput, +} from "./configvalidation"; + +describe("configvalidation", () => { + it("normalizes and validates config strings", () => { + expect(normalizeConfigStringInput(" wave ")).toBe("wave"); + expect(validateConfigStringInput(" ", { required: true })).toBe("Required"); + expect(validateConfigStringInput("missing-placeholder", { validate: (value) => (!value.includes("{query}") ? "Must include {query}" : undefined) })).toBe( + "Must include {query}" + ); + expect(validateConfigStringInput("https://example.com/?q={query}", { validate: (value) => (!value.includes("{query}") ? "Must include {query}" : undefined) })).toBeUndefined(); + }); + + it("validates config numbers with integer and range constraints", () => { + expect(validateConfigNumberInput("", { min: 1, max: 10 })).toEqual({ value: undefined }); + expect(validateConfigNumberInput("12", { min: 1, max: 10 })).toEqual({ + value: undefined, + error: "Must be at most 10", + }); + expect(validateConfigNumberInput("8.5", { integer: true })).toEqual({ + value: undefined, + error: "Must be a whole number", + }); + expect(validateConfigNumberInput("256", { min: 128, max: 10000, integer: true })).toEqual({ + value: 256, + }); + }); + + it("distinguishes overridden values from inherited defaults", () => { + expect(isConfigValueOverridden(undefined)).toBe(false); + expect(isConfigValueOverridden(false)).toBe(true); + expect(isConfigValueOverridden(0.95)).toBe(true); + expect(getEffectiveConfigValue(undefined, 0.95)).toBe(0.95); + expect(getEffectiveConfigValue(0.95, 0.9)).toBe(0.95); + }); +}); diff --git a/frontend/app/configui/configvalidation.ts b/frontend/app/configui/configvalidation.ts new file mode 100644 index 0000000000..3786c4886b --- /dev/null +++ b/frontend/app/configui/configvalidation.ts @@ -0,0 +1,78 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export type ConfigStringValidationOptions = { + required?: boolean; + trim?: boolean; + maxLength?: number; + pattern?: RegExp; + validate?: (value: string) => string | undefined; +}; + +export type ConfigNumberValidationOptions = { + min?: number; + max?: number; + integer?: boolean; +}; + +export function isConfigValueOverridden(value: T | undefined): boolean { + return value != null; +} + +export function getEffectiveConfigValue(value: T | undefined, defaultValue: T): T { + if (value != null) { + return value; + } + return defaultValue; +} + +export function normalizeConfigStringInput(value: string, options?: ConfigStringValidationOptions): string { + if (options?.trim === false) { + return value; + } + return value.trim(); +} + +export function validateConfigStringInput(value: string, options?: ConfigStringValidationOptions): string | undefined { + const normalizedValue = normalizeConfigStringInput(value, options); + + if (options?.required && normalizedValue.length === 0) { + return "Required"; + } + if (normalizedValue.length === 0) { + return; + } + if (options?.maxLength != null && normalizedValue.length > options.maxLength) { + return `Must be ${options.maxLength} characters or less`; + } + if (options?.pattern != null && !options.pattern.test(normalizedValue)) { + return "Invalid format"; + } + return options?.validate?.(normalizedValue); +} + +export function validateConfigNumberInput( + value: string, + options?: ConfigNumberValidationOptions +): { value: number | undefined; error?: string } { + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) { + return { value: undefined }; + } + + const parsedValue = Number(trimmedValue); + if (!Number.isFinite(parsedValue)) { + return { value: undefined, error: "Must be a number" }; + } + if (options?.integer && !Number.isInteger(parsedValue)) { + return { value: undefined, error: "Must be a whole number" }; + } + if (options?.min != null && parsedValue < options.min) { + return { value: undefined, error: `Must be at least ${options.min}` }; + } + if (options?.max != null && parsedValue > options.max) { + return { value: undefined, error: `Must be at most ${options.max}` }; + } + + return { value: parsedValue }; +} diff --git a/frontend/app/configui/configwidgets.tsx b/frontend/app/configui/configwidgets.tsx new file mode 100644 index 0000000000..6444f6c816 --- /dev/null +++ b/frontend/app/configui/configwidgets.tsx @@ -0,0 +1,503 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import type { ReactNode } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; +import { + ConfigNumberValidationOptions, + ConfigStringValidationOptions, + isConfigValueOverridden, + normalizeConfigStringInput, + validateConfigNumberInput, + validateConfigStringInput, +} from "./configvalidation"; + +export type ConfigSelectOption = { + value: string; + label: string; + description?: string; +}; + +type ConfigFieldTone = "muted" | "error"; + +type ConfigValueFormatter = (value: T) => string; + +type ConfigFieldFrameProps = { + fieldId?: string; + configKey: string; + label: string; + description?: string; + hint?: string; + message?: string; + messageTone?: ConfigFieldTone; + defaultText?: string; + isOverridden?: boolean; + showUseDefault?: boolean; + onUseDefault?: () => void; + children: ReactNode; +}; + +type ConfigBooleanFieldProps = Omit & { + value?: boolean; + defaultValue?: boolean; + onValueChange: (value: boolean | undefined) => void; + trueLabel?: string; + falseLabel?: string; +}; + +type ConfigSelectFieldProps = Omit & { + value?: string; + defaultValue?: string; + options: ConfigSelectOption[]; + placeholder?: string; + valueFormatter?: ConfigValueFormatter; + onValueChange: (value: string | undefined) => void; +}; + +type ConfigStringFieldProps = Omit & { + value?: string; + defaultValue?: string; + placeholder?: string; + blankValue?: string; + valueFormatter?: ConfigValueFormatter; + onValueChange: (value: string | undefined) => void; + validation?: ConfigStringValidationOptions; +}; + +type ConfigNumberFieldProps = Omit & { + value?: number; + defaultValue?: number; + placeholder?: string; + step?: number; + valueFormatter?: ConfigValueFormatter; + onValueChange: (value: number | undefined) => void; + validation?: ConfigNumberValidationOptions; +}; + +type ConfigFontSizeFieldProps = Omit & { + sampleText?: string; + presets?: number[]; +}; + +function formatBooleanLabel(value: boolean, trueLabel = "True", falseLabel = "False"): string { + return value ? trueLabel : falseLabel; +} + +function formatDefaultText(defaultValue: T | undefined, valueFormatter?: ConfigValueFormatter): string | undefined { + if (defaultValue == null) { + return; + } + if (valueFormatter != null) { + return valueFormatter(defaultValue); + } + return String(defaultValue); +} + +function formatDefaultSelectLabel(defaultText?: string): string { + if (defaultText == null) { + return "Default"; + } + return `Default (${defaultText})`; +} + +export function ConfigSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+ ); +} + +function ConfigFieldFrame({ + fieldId, + configKey, + label, + description, + hint, + message, + messageTone = "muted", + defaultText, + isOverridden, + showUseDefault, + onUseDefault, + children, +}: ConfigFieldFrameProps) { + const messageClassName = messageTone === "error" ? "text-error" : "text-muted"; + + return ( +
+
+
+ + + {isOverridden ? "Overridden" : "Using default"} + +
+
{configKey}
+ {description &&

{description}

} +
+
+
+
{children}
+ {showUseDefault && ( + + )} +
+ {defaultText != null &&
Default: {defaultText}
} + {(message || hint) &&
{message ?? hint}
} +
+
+ ); +} + +export function ConfigBooleanField({ + value, + defaultValue, + onValueChange, + hint, + configKey, + label, + description, + trueLabel = "True", + falseLabel = "False", +}: ConfigBooleanFieldProps) { + const fieldId = useId(); + const isOverridden = isConfigValueOverridden(value); + const defaultText = formatDefaultText(defaultValue, (defaultBool) => formatBooleanLabel(defaultBool, trueLabel, falseLabel)); + + return ( + + + + ); +} + +export function ConfigSelectField({ + value, + defaultValue, + options, + placeholder = "Select a value", + valueFormatter, + onValueChange, + hint, + configKey, + label, + description, +}: ConfigSelectFieldProps) { + const fieldId = useId(); + const defaultText = formatDefaultText(defaultValue, valueFormatter); + const isOverridden = isConfigValueOverridden(value); + + return ( + + + + ); +} + +export function ConfigStringField({ + value, + defaultValue, + placeholder, + blankValue, + valueFormatter, + onValueChange, + validation, + hint, + configKey, + label, + description, +}: ConfigStringFieldProps) { + const fieldId = useId(); + const [draftValue, setDraftValue] = useState(value ?? ""); + const isOverridden = isConfigValueOverridden(value); + const defaultText = formatDefaultText(defaultValue, valueFormatter); + + useEffect(() => { + setDraftValue(value ?? ""); + }, [value]); + + const validationValue = useMemo(() => { + if (isOverridden || defaultValue == null) { + return draftValue; + } + return defaultValue; + }, [defaultValue, draftValue, isOverridden]); + const error = useMemo(() => validateConfigStringInput(validationValue, validation), [validationValue, validation]); + + const applyValue = (nextValue: string) => { + setDraftValue(nextValue); + const nextError = validateConfigStringInput(nextValue, validation); + if (nextError != null) { + return; + } + const normalizedValue = normalizeConfigStringInput(nextValue, validation); + if (normalizedValue.length === 0) { + if (blankValue != null) { + onValueChange(blankValue); + return; + } + onValueChange(undefined); + return; + } + onValueChange(normalizedValue); + }; + + return ( + onValueChange(undefined)} + > + applyValue(event.target.value)} + onBlur={() => { + if (error == null) { + setDraftValue(normalizeConfigStringInput(draftValue, validation)); + } + }} + placeholder={placeholder ?? (defaultText == null ? undefined : `Default: ${defaultText}`)} + className={cn( + "w-full rounded-md border bg-panel px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/60", + error ? "border-error" : "border-border" + )} + /> + + ); +} + +export function ConfigNumberField({ + value, + defaultValue, + placeholder, + step = 1, + valueFormatter, + onValueChange, + validation, + hint, + configKey, + label, + description, +}: ConfigNumberFieldProps) { + const fieldId = useId(); + const [draftValue, setDraftValue] = useState(value == null ? "" : String(value)); + const isOverridden = isConfigValueOverridden(value); + const defaultText = formatDefaultText(defaultValue, valueFormatter); + + useEffect(() => { + setDraftValue(value == null ? "" : String(value)); + }, [value]); + + const result = useMemo(() => validateConfigNumberInput(draftValue, validation), [draftValue, validation]); + + const applyValue = (nextValue: string) => { + setDraftValue(nextValue); + const nextResult = validateConfigNumberInput(nextValue, validation); + if (nextResult.error != null) { + return; + } + onValueChange(nextResult.value); + }; + + return ( + onValueChange(undefined)} + > + applyValue(event.target.value)} + onBlur={() => { + if (result.error == null) { + setDraftValue(result.value == null ? "" : String(result.value)); + } + }} + min={validation?.min} + max={validation?.max} + step={step} + placeholder={placeholder ?? (defaultText == null ? undefined : `Default: ${defaultText}`)} + className={cn( + "w-full rounded-md border bg-panel px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/60", + result.error ? "border-error" : "border-border" + )} + /> + + ); +} + +export function ConfigFontSizeField({ + value, + defaultValue, + onValueChange, + presets = [11, 12, 13, 14, 16, 18], + sampleText = "Sphinx of black quartz, judge my vow.", + valueFormatter, + hint, + configKey, + label, + description, +}: ConfigFontSizeFieldProps) { + const fieldId = useId(); + const [draftValue, setDraftValue] = useState(value == null ? "" : String(value)); + const isOverridden = isConfigValueOverridden(value); + const defaultText = formatDefaultText(defaultValue, valueFormatter ?? ((fontSize) => `${fontSize}px`)); + + useEffect(() => { + setDraftValue(value == null ? "" : String(value)); + }, [value]); + + const result = useMemo(() => validateConfigNumberInput(draftValue, { min: 8, max: 36 }), [draftValue]); + const sampleFontSize = result.value ?? value ?? defaultValue ?? 13; + + const applyValue = (nextValue: string) => { + setDraftValue(nextValue); + const nextResult = validateConfigNumberInput(nextValue, { min: 8, max: 36 }); + if (nextResult.error != null) { + return; + } + onValueChange(nextResult.value); + }; + + return ( + onValueChange(undefined)} + > +
+ applyValue(event.target.value)} + onBlur={() => { + if (result.error == null) { + setDraftValue(result.value == null ? "" : String(result.value)); + } + }} + min={8} + max={36} + step={0.5} + placeholder={defaultText == null ? undefined : `Default: ${defaultText}`} + className={cn( + "w-full rounded-md border bg-panel px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/60", + result.error ? "border-error" : "border-border" + )} + /> +
+ {presets.map((preset) => ( + + ))} +
+
+ {sampleText} +
+
+
+ ); +} diff --git a/frontend/preview/previews/configui.preview.tsx b/frontend/preview/previews/configui.preview.tsx new file mode 100644 index 0000000000..043297011e --- /dev/null +++ b/frontend/preview/previews/configui.preview.tsx @@ -0,0 +1,312 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + ConfigBooleanField, + ConfigFontSizeField, + ConfigNumberField, + ConfigSection, + ConfigSelectField, + ConfigStringField, +} from "@/app/configui/configwidgets"; +import { getEffectiveConfigValue, isConfigValueOverridden } from "@/app/configui/configvalidation"; +import { DefaultFullConfig } from "@/preview/mock/defaultconfig"; +import type { Dispatch, SetStateAction } from "react"; +import { useMemo, useState } from "react"; + +const DefaultSettings = DefaultFullConfig.settings; + +const InitialSettings: SettingsType = { + "term:fontsize": 16, + "term:cursorblink": false, + "preview:defaultsort": "modtime", + "conn:localhostdisplayname": "", + "window:magnifiedblocksize": 0.95, +}; + +const FocusFollowsCursorOptions = [ + { value: "off", label: "Off" }, + { value: "on", label: "All blocks" }, + { value: "term", label: "Terminal blocks only" }, +]; + +const CursorOptions = [ + { value: "block", label: "Block" }, + { value: "underline", label: "Underline" }, + { value: "bar", label: "Bar" }, +]; + +const PreviewSortOptions = [ + { value: "name", label: "Name" }, + { value: "modtime", label: "Modified time" }, +]; + +function setSettingsValue( + setSettings: Dispatch>, + key: Key, + value: SettingsType[Key] +) { + setSettings((prev) => { + const next = { ...prev }; + if (value == null) { + delete next[key]; + return next; + } + next[key] = value; + return next; + }); +} + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function formatNullableString(value: string): string { + if (value === "") { + return "(empty string)"; + } + return value; +} + +function getEffectiveSetting(settings: SettingsType, key: Key): NonNullable { + return getEffectiveConfigValue(settings[key] as any, DefaultSettings[key] as any); +} + +export function ConfiguiPreview() { + const [settings, setSettings] = useState(InitialSettings); + + const overriddenEntries = useMemo(() => Object.entries(settings).sort(([a], [b]) => a.localeCompare(b)), [settings]); + const previewJson = useMemo(() => JSON.stringify(settings, null, 2), [settings]); + + const hideAiButton = getEffectiveSetting(settings, "app:hideaibutton"); + const focusFollowsCursor = getEffectiveSetting(settings, "app:focusfollowscursor"); + const cursorStyle = getEffectiveSetting(settings, "term:cursor"); + const cursorBlink = getEffectiveSetting(settings, "term:cursorblink"); + const terminalFontSize = getEffectiveSetting(settings, "term:fontsize"); + const fontFamily = getEffectiveSetting(settings, "term:fontfamily"); + const previewSort = getEffectiveSetting(settings, "preview:defaultsort"); + const defaultSearch = getEffectiveSetting(settings, "web:defaultsearch"); + const sampleLabel = getEffectiveSetting(settings, "conn:localhostdisplayname"); + const magnifiedBlockSize = getEffectiveSetting(settings, "window:magnifiedblocksize"); + const magnifiedBlockOpacity = getEffectiveSetting(settings, "window:magnifiedblockopacity"); + + return ( +
+
+

Config UI preview

+

+ Preview-only form controls for JSON-backed config keys, with explicit support for inherited defaults versus user overrides. A key being unset means the default still applies, even if the default happens to match an override value. +

+
+ + +
+
+ +
+
+ + setSettingsValue(setSettings, "app:hideaibutton", value)} + trueLabel="True" + falseLabel="False" + /> + setSettingsValue(setSettings, "app:focusfollowscursor", value)} + /> + setSettingsValue(setSettings, "conn:localhostdisplayname", value)} + hint="This preview starts with an explicit empty-string override to show that it is distinct from inheriting the default label" + /> + + + + setSettingsValue(setSettings, "term:cursor", value)} + /> + setSettingsValue(setSettings, "term:cursorblink", value)} + trueLabel="On" + falseLabel="Off" + /> + setSettingsValue(setSettings, "term:fontsize", value)} + /> + + + + setSettingsValue(setSettings, "preview:defaultsort", value)} + /> + (!value.includes("{query}") ? "Must include {query}" : undefined), + }} + onValueChange={(value) => setSettingsValue(setSettings, "web:defaultsearch", value)} + /> + setSettingsValue(setSettings, "window:magnifiedblocksize", value)} + hint="Even when the effective value is 95%, an explicit override should still be visibly different from using the default" + /> + setSettingsValue(setSettings, "window:magnifiedblockopacity", value)} + /> + +
+ +
+ +
+
+ {overriddenEntries.length} overridden {overriddenEntries.length === 1 ? "key" : "keys"} +
+ {overriddenEntries.length === 0 ? ( +
Everything is inheriting from the shipped defaults.
+ ) : ( +
+ {overriddenEntries.map(([key, value]) => ( +
+ {key} + {JSON.stringify(value)} +
+ ))} +
+ )} +
+
+ + +
+
Effective settings sample
+
+
+ {sampleLabel === "" ? "connection label hidden by explicit empty-string override" : `connection label: ${sampleLabel}`} +
+
+ hide AI button: {String(hideAiButton)} · focus follows cursor: {focusFollowsCursor} +
+
+
$ echo "waveterm config preview"
+
cursor: {cursorStyle}
+
blink: {String(cursorBlink)}
+
preview sort: {previewSort}
+
magnified block size: {formatPercent(magnifiedBlockSize)}
+
magnified block opacity: {formatPercent(magnifiedBlockOpacity)}
+
default search: {defaultSearch}
+
+
+
+
+ + +
+                            {previewJson}
+                        
+
+ + +
+
+ term:cursorblink → effective {String(cursorBlink)} · source{" "} + {isConfigValueOverridden(settings["term:cursorblink"]) ? "override" : "default"} +
+
+ window:magnifiedblocksize → effective {formatPercent(magnifiedBlockSize)} · source{" "} + {isConfigValueOverridden(settings["window:magnifiedblocksize"]) ? "override" : "default"} +
+
+
+
+
+
+ ); +}