From 226507801f69eafe671b16cf3f0d0d4616acee5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:34:53 +0000 Subject: [PATCH 1/3] Initial plan From 57868f6be798c08af3efdc4075aa67543b506e55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:44:47 +0000 Subject: [PATCH 2/3] Add preview-only configui widgets Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .../app/configui/configvalidation.test.ts | 35 ++ frontend/app/configui/configvalidation.ts | 67 +++ frontend/app/configui/configwidgets.tsx | 403 ++++++++++++++++++ .../preview/previews/configui.preview.tsx | 257 +++++++++++ 4 files changed, 762 insertions(+) create mode 100644 frontend/app/configui/configvalidation.test.ts create mode 100644 frontend/app/configui/configvalidation.ts create mode 100644 frontend/app/configui/configwidgets.tsx create mode 100644 frontend/preview/previews/configui.preview.tsx diff --git a/frontend/app/configui/configvalidation.test.ts b/frontend/app/configui/configvalidation.test.ts new file mode 100644 index 0000000000..5ee5873a04 --- /dev/null +++ b/frontend/app/configui/configvalidation.test.ts @@ -0,0 +1,35 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + 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, + }); + }); +}); diff --git a/frontend/app/configui/configvalidation.ts b/frontend/app/configui/configvalidation.ts new file mode 100644 index 0000000000..ba0f44f0b8 --- /dev/null +++ b/frontend/app/configui/configvalidation.ts @@ -0,0 +1,67 @@ +// 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 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..21570905a4 --- /dev/null +++ b/frontend/app/configui/configwidgets.tsx @@ -0,0 +1,403 @@ +// 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, + normalizeConfigStringInput, + validateConfigNumberInput, + validateConfigStringInput, +} from "./configvalidation"; + +export type ConfigSelectOption = { + value: string; + label: string; + description?: string; +}; + +type ConfigFieldTone = "muted" | "error"; + +type ConfigFieldFrameProps = { + fieldId?: string; + configKey: string; + label: string; + description?: string; + hint?: string; + message?: string; + messageTone?: ConfigFieldTone; + clearable?: boolean; + onClear?: () => void; + children: ReactNode; +}; + +type ConfigBooleanFieldProps = Omit & { + value?: boolean; + onValueChange: (value: boolean) => void; +}; + +type ConfigSelectFieldProps = Omit & { + value?: string; + options: ConfigSelectOption[]; + placeholder?: string; + onValueChange: (value: string | undefined) => void; +}; + +type ConfigStringFieldProps = Omit & { + value?: string; + placeholder?: string; + blankValue?: string; + onValueChange: (value: string | undefined) => void; + validation?: ConfigStringValidationOptions; +}; + +type ConfigNumberFieldProps = Omit & { + value?: number; + placeholder?: string; + step?: number; + onValueChange: (value: number | undefined) => void; + validation?: ConfigNumberValidationOptions; +}; + +type ConfigFontSizeFieldProps = Omit & { + sampleText?: string; + presets?: number[]; +}; + +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", + clearable, + onClear, + children, +}: ConfigFieldFrameProps) { + const messageClassName = messageTone === "error" ? "text-error" : "text-muted"; + + return ( +
+
+ +
{configKey}
+ {description &&

{description}

} +
+
+
+
{children}
+ {clearable && ( + + )} +
+ {(message || hint) &&
{message ?? hint}
} +
+
+ ); +} + +export function ConfigBooleanField({ + value, + onValueChange, + hint, + clearable, + onClear, + ...frameProps +}: ConfigBooleanFieldProps) { + const fieldId = useId(); + + return ( + + + + ); +} + +export function ConfigSelectField({ + value, + options, + placeholder = "Select a value", + onValueChange, + hint, + clearable, + onClear, + ...frameProps +}: ConfigSelectFieldProps) { + const fieldId = useId(); + + return ( + + + + ); +} + +export function ConfigStringField({ + value, + placeholder, + blankValue, + onValueChange, + validation, + hint, + clearable, + onClear, + ...frameProps +}: ConfigStringFieldProps) { + const fieldId = useId(); + const [draftValue, setDraftValue] = useState(value ?? ""); + + useEffect(() => { + setDraftValue(value ?? ""); + }, [value]); + + const error = useMemo(() => validateConfigStringInput(draftValue, validation), [draftValue, 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) { + onValueChange(blankValue); + return; + } + onValueChange(normalizedValue); + }; + + return ( + + applyValue(event.target.value)} + onBlur={() => { + if (error == null) { + setDraftValue(normalizeConfigStringInput(draftValue, validation)); + } + }} + placeholder={placeholder} + 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, + placeholder, + step = 1, + onValueChange, + validation, + hint, + clearable, + onClear, + ...frameProps +}: ConfigNumberFieldProps) { + const fieldId = useId(); + const [draftValue, setDraftValue] = useState(value == null ? "" : String(value)); + + 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 ( + + 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} + 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, + onValueChange, + presets = [11, 12, 13, 14, 16, 18], + sampleText = "Sphinx of black quartz, judge my vow.", + hint, + clearable, + onClear, + ...frameProps +}: ConfigFontSizeFieldProps) { + const fieldId = useId(); + const [draftValue, setDraftValue] = useState(value == null ? "" : String(value)); + + useEffect(() => { + setDraftValue(value == null ? "" : String(value)); + }, [value]); + + const result = useMemo(() => validateConfigNumberInput(draftValue, { min: 8, max: 36 }), [draftValue]); + + const applyValue = (nextValue: string) => { + setDraftValue(nextValue); + const nextResult = validateConfigNumberInput(nextValue, { min: 8, max: 36 }); + if (nextResult.error != null) { + return; + } + onValueChange(nextResult.value); + }; + + const sampleFontSize = result.value ?? value ?? 13; + + return ( + +
+ applyValue(event.target.value)} + onBlur={() => { + if (result.error == null) { + setDraftValue(result.value == null ? "" : String(result.value)); + } + }} + min={8} + max={36} + step={0.5} + className={cn( + "w-full rounded-md border bg-panel px-3 py-2 text-sm text-foreground 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..e18b58e754 --- /dev/null +++ b/frontend/preview/previews/configui.preview.tsx @@ -0,0 +1,257 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + ConfigBooleanField, + ConfigFontSizeField, + ConfigNumberField, + ConfigSection, + ConfigSelectField, + ConfigStringField, +} from "@/app/configui/configwidgets"; +import type { Dispatch, SetStateAction } from "react"; +import { useMemo, useState } from "react"; + +const InitialSettings: SettingsType = { + "app:hideaibutton": false, + "app:focusfollowscursor": "off", + "term:cursor": "block", + "term:scrollback": 2000, + "term:fontsize": 13, + "term:fontfamily": "JetBrains Mono", + "term:cursorblink": false, + "preview:showhiddenfiles": true, + "preview:defaultsort": "name", + "web:defaultsearch": "https://www.google.com/search?q={query}", + "conn:localhostdisplayname": "My Laptop", +}; + +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; + }); +} + +export function ConfiguiPreview() { + const [settings, setSettings] = useState(InitialSettings); + + const previewJson = useMemo(() => JSON.stringify(settings, null, 2), [settings]); + const terminalFontSize = settings["term:fontsize"] ?? 13; + const fontFamily = settings["term:fontfamily"] || "system-ui"; + const sampleLabel = settings["conn:localhostdisplayname"] ?? "localhost"; + + return ( +
+
+

Config UI preview

+

+ Preview-only form controls for JSON-backed config keys. These widgets are not wired into production yet; they only demonstrate how a sample settings editor could feel. +

+
+ + +
+
+ +
+
+ + setSettingsValue(setSettings, "app:hideaibutton", value)} + clearable + onClear={() => setSettingsValue(setSettings, "app:hideaibutton", undefined)} + /> + setSettingsValue(setSettings, "app:focusfollowscursor", value)} + clearable + onClear={() => setSettingsValue(setSettings, "app:focusfollowscursor", undefined)} + /> + setSettingsValue(setSettings, "conn:localhostdisplayname", value)} + hint="Blank is allowed and will still be written as an empty string" + clearable + onClear={() => setSettingsValue(setSettings, "conn:localhostdisplayname", undefined)} + /> + + + + setSettingsValue(setSettings, "term:cursor", value)} + clearable + onClear={() => setSettingsValue(setSettings, "term:cursor", undefined)} + /> + setSettingsValue(setSettings, "term:cursorblink", value)} + clearable + onClear={() => setSettingsValue(setSettings, "term:cursorblink", undefined)} + /> + setSettingsValue(setSettings, "term:scrollback", value)} + clearable + onClear={() => setSettingsValue(setSettings, "term:scrollback", undefined)} + /> + setSettingsValue(setSettings, "term:fontfamily", value)} + clearable + onClear={() => setSettingsValue(setSettings, "term:fontfamily", undefined)} + /> + setSettingsValue(setSettings, "term:fontsize", value)} + clearable + onClear={() => setSettingsValue(setSettings, "term:fontsize", undefined)} + /> + + + + setSettingsValue(setSettings, "preview:showhiddenfiles", value)} + clearable + onClear={() => setSettingsValue(setSettings, "preview:showhiddenfiles", undefined)} + /> + setSettingsValue(setSettings, "preview:defaultsort", value)} + clearable + onClear={() => setSettingsValue(setSettings, "preview:defaultsort", undefined)} + /> + (!value.includes("{query}") ? "Must include {query}" : undefined), + }} + onValueChange={(value) => setSettingsValue(setSettings, "web:defaultsearch", value)} + clearable + onClear={() => setSettingsValue(setSettings, "web:defaultsearch", undefined)} + /> + +
+ +
+ +
+
Terminal sample
+
+
+ {sampleLabel === "" ? "localhost label hidden" : `connection label: ${sampleLabel}`} +
+
+
$ echo "waveterm config preview"
+
cursor: {settings["term:cursor"] ?? "unset"}
+
blink: {String(settings["term:cursorblink"] ?? false)}
+
scrollback: {settings["term:scrollback"] ?? "unset"}
+
+
+
+
+ + +
+                            {previewJson}
+                        
+
+
+
+
+ ); +} From 2b4022ee167f2c1376497e9404b87168386762fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:07:51 +0000 Subject: [PATCH 3/3] Make configui defaults first-class Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .../app/configui/configvalidation.test.ts | 10 + frontend/app/configui/configvalidation.ts | 11 + frontend/app/configui/configwidgets.tsx | 242 +++++++++++++----- .../preview/previews/configui.preview.tsx | 213 +++++++++------ 4 files changed, 326 insertions(+), 150 deletions(-) diff --git a/frontend/app/configui/configvalidation.test.ts b/frontend/app/configui/configvalidation.test.ts index 5ee5873a04..0f4d77920b 100644 --- a/frontend/app/configui/configvalidation.test.ts +++ b/frontend/app/configui/configvalidation.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { + getEffectiveConfigValue, + isConfigValueOverridden, normalizeConfigStringInput, validateConfigNumberInput, validateConfigStringInput, @@ -32,4 +34,12 @@ describe("configvalidation", () => { 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 index ba0f44f0b8..3786c4886b 100644 --- a/frontend/app/configui/configvalidation.ts +++ b/frontend/app/configui/configvalidation.ts @@ -15,6 +15,17 @@ export type ConfigNumberValidationOptions = { 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; diff --git a/frontend/app/configui/configwidgets.tsx b/frontend/app/configui/configwidgets.tsx index 21570905a4..6444f6c816 100644 --- a/frontend/app/configui/configwidgets.tsx +++ b/frontend/app/configui/configwidgets.tsx @@ -7,6 +7,7 @@ import { useEffect, useId, useMemo, useState } from "react"; import { ConfigNumberValidationOptions, ConfigStringValidationOptions, + isConfigValueOverridden, normalizeConfigStringInput, validateConfigNumberInput, validateConfigStringInput, @@ -20,6 +21,8 @@ export type ConfigSelectOption = { type ConfigFieldTone = "muted" | "error"; +type ConfigValueFormatter = (value: T) => string; + type ConfigFieldFrameProps = { fieldId?: string; configKey: string; @@ -28,35 +31,46 @@ type ConfigFieldFrameProps = { hint?: string; message?: string; messageTone?: ConfigFieldTone; - clearable?: boolean; - onClear?: () => void; + defaultText?: string; + isOverridden?: boolean; + showUseDefault?: boolean; + onUseDefault?: () => void; children: ReactNode; }; -type ConfigBooleanFieldProps = Omit & { +type ConfigBooleanFieldProps = Omit & { value?: boolean; - onValueChange: (value: boolean) => void; + defaultValue?: boolean; + onValueChange: (value: boolean | undefined) => void; + trueLabel?: string; + falseLabel?: string; }; -type ConfigSelectFieldProps = Omit & { +type ConfigSelectFieldProps = Omit & { value?: string; + defaultValue?: string; options: ConfigSelectOption[]; placeholder?: string; + valueFormatter?: ConfigValueFormatter; onValueChange: (value: string | undefined) => void; }; -type ConfigStringFieldProps = Omit & { +type ConfigStringFieldProps = Omit & { value?: string; + defaultValue?: string; placeholder?: string; blankValue?: string; + valueFormatter?: ConfigValueFormatter; onValueChange: (value: string | undefined) => void; validation?: ConfigStringValidationOptions; }; -type ConfigNumberFieldProps = Omit & { +type ConfigNumberFieldProps = Omit & { value?: number; + defaultValue?: number; placeholder?: string; step?: number; + valueFormatter?: ConfigValueFormatter; onValueChange: (value: number | undefined) => void; validation?: ConfigNumberValidationOptions; }; @@ -66,6 +80,27 @@ type ConfigFontSizeFieldProps = Omit & { 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 (
@@ -86,8 +121,10 @@ function ConfigFieldFrame({ hint, message, messageTone = "muted", - clearable, - onClear, + defaultText, + isOverridden, + showUseDefault, + onUseDefault, children, }: ConfigFieldFrameProps) { const messageClassName = messageTone === "error" ? "text-error" : "text-muted"; @@ -95,25 +132,38 @@ function ConfigFieldFrame({ return (
- +
+ + + {isOverridden ? "Overridden" : "Using default"} + +
{configKey}
{description &&

{description}

}
{children}
- {clearable && ( + {showUseDefault && ( )}
+ {defaultText != null &&
Default: {defaultText}
} {(message || hint) &&
{message ?? hint}
}
@@ -122,58 +172,74 @@ function ConfigFieldFrame({ export function ConfigBooleanField({ value, + defaultValue, onValueChange, hint, - clearable, - onClear, - ...frameProps + 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, - clearable, - onClear, - ...frameProps + configKey, + label, + description, }: ConfigSelectFieldProps) { const fieldId = useId(); + const defaultText = formatDefaultText(defaultValue, valueFormatter); + const isOverridden = isConfigValueOverridden(value); return ( { setDraftValue(value == null ? "" : String(value)); @@ -287,13 +375,17 @@ export function ConfigNumberField({ return ( onValueChange(undefined)} > `${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); @@ -346,17 +443,19 @@ export function ConfigFontSizeField({ onValueChange(nextResult.value); }; - const sampleFontSize = result.value ?? value ?? 13; - return ( onValueChange(undefined)} >
@@ -385,7 +485,7 @@ export function ConfigFontSizeField({ onClick={() => applyValue(String(preset))} className={cn( "rounded border px-2.5 py-1 text-xs transition-colors cursor-pointer", - sampleFontSize === preset + sampleFontSize === preset && isOverridden ? "border-accent bg-accent/15 text-accent" : "border-border text-muted hover:bg-hover" )} diff --git a/frontend/preview/previews/configui.preview.tsx b/frontend/preview/previews/configui.preview.tsx index e18b58e754..043297011e 100644 --- a/frontend/preview/previews/configui.preview.tsx +++ b/frontend/preview/previews/configui.preview.tsx @@ -9,21 +9,19 @@ import { 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 = { - "app:hideaibutton": false, - "app:focusfollowscursor": "off", - "term:cursor": "block", - "term:scrollback": 2000, - "term:fontsize": 13, - "term:fontfamily": "JetBrains Mono", + "term:fontsize": 16, "term:cursorblink": false, - "preview:showhiddenfiles": true, - "preview:defaultsort": "name", - "web:defaultsearch": "https://www.google.com/search?q={query}", - "conn:localhostdisplayname": "My Laptop", + "preview:defaultsort": "modtime", + "conn:localhostdisplayname": "", + "window:magnifiedblocksize": 0.95, }; const FocusFollowsCursorOptions = [ @@ -59,20 +57,45 @@ function setSettingsValue( }); } +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 terminalFontSize = settings["term:fontsize"] ?? 13; - const fontFamily = settings["term:fontfamily"] || "system-ui"; - const sampleLabel = settings["conn:localhostdisplayname"] ?? "localhost"; + + 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. These widgets are not wired into production yet; they only demonstrate how a sample settings editor could feel. +

+ 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.

@@ -96,160 +119,192 @@ export function ConfiguiPreview() {
setSettingsValue(setSettings, "app:hideaibutton", value)} - clearable - onClear={() => setSettingsValue(setSettings, "app:hideaibutton", undefined)} + trueLabel="True" + falseLabel="False" /> setSettingsValue(setSettings, "app:focusfollowscursor", value)} - clearable - onClear={() => setSettingsValue(setSettings, "app:focusfollowscursor", undefined)} /> setSettingsValue(setSettings, "conn:localhostdisplayname", value)} - hint="Blank is allowed and will still be written as an empty string" - clearable - onClear={() => setSettingsValue(setSettings, "conn:localhostdisplayname", undefined)} + 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)} - clearable - onClear={() => setSettingsValue(setSettings, "term:cursor", undefined)} /> setSettingsValue(setSettings, "term:cursorblink", value)} - clearable - onClear={() => setSettingsValue(setSettings, "term:cursorblink", undefined)} - /> - setSettingsValue(setSettings, "term:scrollback", value)} - clearable - onClear={() => setSettingsValue(setSettings, "term:scrollback", undefined)} - /> - setSettingsValue(setSettings, "term:fontfamily", value)} - clearable - onClear={() => setSettingsValue(setSettings, "term:fontfamily", undefined)} + trueLabel="On" + falseLabel="Off" /> setSettingsValue(setSettings, "term:fontsize", value)} - clearable - onClear={() => setSettingsValue(setSettings, "term:fontsize", undefined)} /> - setSettingsValue(setSettings, "preview:showhiddenfiles", value)} - clearable - onClear={() => setSettingsValue(setSettings, "preview:showhiddenfiles", undefined)} - /> setSettingsValue(setSettings, "preview:defaultsort", value)} - clearable - onClear={() => setSettingsValue(setSettings, "preview:defaultsort", undefined)} /> (!value.includes("{query}") ? "Must include {query}" : undefined), }} onValueChange={(value) => setSettingsValue(setSettings, "web:defaultsearch", value)} - clearable - onClear={() => setSettingsValue(setSettings, "web:defaultsearch", undefined)} + /> + 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)} +
+ ))} +
+ )} +
+
+ +
-
Terminal sample
+
Effective settings sample
- {sampleLabel === "" ? "localhost label hidden" : `connection label: ${sampleLabel}`} + {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: {settings["term:cursor"] ?? "unset"}
-
blink: {String(settings["term:cursorblink"] ?? false)}
-
scrollback: {settings["term:scrollback"] ?? "unset"}
+
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"} +
+
+