From 2b0de06abce7bf74abb34c80852587f1fdd60110 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Fri, 27 Feb 2026 10:27:27 +0200 Subject: [PATCH 1/6] feat(ui): add host-agnostic config editor and shared UI runtime plumbing Introduce a dedicated config editor resource with structured get_config payloads and shared host-context/compact-row infrastructure so MCP UIs behave consistently across hosts while tightening config key validation and preview type coverage. --- package.json | 2 +- scripts/build-ui-runtime.cjs | 11 + src/config-field-definitions.ts | 49 + src/server.ts | 2 + src/tools/config.ts | 117 ++- src/ui/config-editor/index.html | 13 + src/ui/config-editor/src/app.ts | 890 ++++++++++++++++++ src/ui/config-editor/src/array-modal.ts | 196 ++++ src/ui/config-editor/src/main.ts | 3 + .../file-preview/shared/preview-file-types.ts | 4 +- src/ui/file-preview/src/app.ts | 122 +-- src/ui/resources.ts | 20 +- src/ui/shared/compact-row.ts | 30 + src/ui/shared/host-context.ts | 77 ++ src/ui/shared/tool-bridge.ts | 178 ++++ src/ui/shared/tool-shell.ts | 66 +- src/ui/styles/apps/config-editor.css | 430 +++++++++ src/ui/styles/apps/file-preview.css | 46 - src/ui/styles/components/compact-row.css | 65 ++ test/test-file-handlers.js | 21 +- 20 files changed, 2175 insertions(+), 167 deletions(-) create mode 100644 src/config-field-definitions.ts create mode 100644 src/ui/config-editor/index.html create mode 100644 src/ui/config-editor/src/app.ts create mode 100644 src/ui/config-editor/src/array-modal.ts create mode 100644 src/ui/config-editor/src/main.ts create mode 100644 src/ui/shared/compact-row.ts create mode 100644 src/ui/shared/host-context.ts create mode 100644 src/ui/shared/tool-bridge.ts create mode 100644 src/ui/styles/apps/config-editor.css create mode 100644 src/ui/styles/components/compact-row.css diff --git a/package.json b/package.json index fb2b330e..f038f7b2 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "bump": "node scripts/sync-version.js --bump", "bump:minor": "node scripts/sync-version.js --bump --minor", "bump:major": "node scripts/sync-version.js --bump --major", - "build": "tsc && shx cp setup-claude-server.js uninstall-claude-server.js track-installation.js dist/ && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && shx mkdir -p dist/remote-device/scripts && shx cp src/remote-device/scripts/blocking-offline-update.js dist/remote-device/scripts/ && node scripts/build-ui-runtime.cjs file-preview", + "build": "tsc && shx cp setup-claude-server.js uninstall-claude-server.js track-installation.js dist/ && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && shx mkdir -p dist/remote-device/scripts && shx cp src/remote-device/scripts/blocking-offline-update.js dist/remote-device/scripts/ && node scripts/build-ui-runtime.cjs", "watch": "tsc --watch", "start": "node dist/index.js", "start:debug": "node --inspect-brk=9229 dist/index.js", diff --git a/scripts/build-ui-runtime.cjs b/scripts/build-ui-runtime.cjs index 112b7105..9a7fafe4 100644 --- a/scripts/build-ui-runtime.cjs +++ b/scripts/build-ui-runtime.cjs @@ -15,9 +15,20 @@ const TARGETS = { staticDir: 'src/ui/file-preview', styleLayers: [ 'src/ui/styles/base.css', + 'src/ui/styles/components/compact-row.css', 'src/ui/styles/components/tool-header.css', 'src/ui/styles/apps/file-preview.css' ] + }, + 'config-editor': { + entry: 'src/ui/config-editor/src/main.ts', + output: 'dist/ui/config-editor/config-editor-runtime.js', + staticDir: 'src/ui/config-editor', + styleLayers: [ + 'src/ui/styles/base.css', + 'src/ui/styles/components/compact-row.css', + 'src/ui/styles/apps/config-editor.css' + ] } }; diff --git a/src/config-field-definitions.ts b/src/config-field-definitions.ts new file mode 100644 index 00000000..77efa9bf --- /dev/null +++ b/src/config-field-definitions.ts @@ -0,0 +1,49 @@ +export type ConfigFieldValueType = 'string' | 'number' | 'boolean' | 'array' | 'null'; + +export type ConfigFieldDefinition = { + label: string; + description: string; + valueType: ConfigFieldValueType; +}; + +// Single source of truth for user-editable configuration fields. +export const CONFIG_FIELD_DEFINITIONS = { + blockedCommands: { + label: 'Blocked Commands', + description: 'This is your personal safety blocklist. If a command appears here, Desktop Commander will refuse to run it even if a prompt asks for it. Add risky commands you never want executed by mistake.', + valueType: 'array', + }, + allowedDirectories: { + label: 'Allowed Folders', + description: 'These are the folders Desktop Commander is allowed to read and edit. Think of this as a permission list. Keeping it small is safer. If this list is empty, Desktop Commander can access your entire filesystem.', + valueType: 'array', + }, + defaultShell: { + label: 'Default Shell', + description: 'This is the shell used for new command sessions (for example /bin/bash or /bin/zsh). Only change this if you know your environment requires a specific shell.', + valueType: 'string', + }, + telemetryEnabled: { + label: 'Anonymous Telemetry', + description: 'When on, Desktop Commander sends anonymous usage information that helps improve product quality. When off, no telemetry data is sent.', + valueType: 'boolean', + }, + fileReadLineLimit: { + label: 'File Read Limit', + description: 'Maximum number of lines returned from a file in one read action. Lower numbers keep responses short and safer; higher numbers return more text at once.', + valueType: 'number', + }, + fileWriteLineLimit: { + label: 'File Write Limit', + description: 'Maximum number of lines that can be written in one edit operation. This helps prevent accidental oversized writes and keeps file changes predictable.', + valueType: 'number', + }, +} as const satisfies Record; + +export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS; + +export const CONFIG_FIELD_KEYS = Object.keys(CONFIG_FIELD_DEFINITIONS) as ConfigFieldKey[]; + +export function isConfigFieldKey(value: string): value is ConfigFieldKey { + return Object.prototype.hasOwnProperty.call(CONFIG_FIELD_DEFINITIONS, value); +} diff --git a/src/server.ts b/src/server.ts index 2d48a90b..f962bef7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -66,6 +66,7 @@ import { capture, capture_call_tool } from "./utils/capture.js"; import { logToStderr, logger } from './utils/logger.js'; import { buildUiToolMeta, + CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI } from './ui/contracts.js'; import { listUiResources, readUiResource } from './ui/resources.js'; @@ -240,6 +241,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { - systemInfo (operating system and environment details) ${CMD_PREFIX_DESCRIPTION}`, inputSchema: zodToJsonSchema(GetConfigArgsSchema), + _meta: buildUiToolMeta(CONFIG_EDITOR_RESOURCE_URI, true), annotations: { title: "Get Configuration", readOnlyHint: true, diff --git a/src/tools/config.ts b/src/tools/config.ts index b05148c2..f4882e13 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -3,6 +3,86 @@ import { SetConfigValueArgsSchema } from './schemas.js'; import { getSystemInfo } from '../utils/system-info.js'; import { currentClient } from '../server.js'; import { featureFlagManager } from '../utils/feature-flags.js'; +import { access, readFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { + CONFIG_FIELD_DEFINITIONS, + CONFIG_FIELD_KEYS, + isConfigFieldKey, +} from '../config-field-definitions.js'; + +const ALLOWED_CONFIG_KEYS = new Set(CONFIG_FIELD_KEYS); + +async function pathExists(pathValue: string): Promise { + try { + await access(pathValue, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function detectAvailableShells(systemInfo: ReturnType): Promise { + const detected = new Set(); + const add = (shell: string): void => { + if (shell.trim().length > 0) { + detected.add(shell.trim()); + } + }; + + add(systemInfo.defaultShell); + + if (systemInfo.isWindows) { + add(process.env.ComSpec ?? ''); + const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'; + const candidates = [ + `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + `${systemRoot}\\System32\\cmd.exe`, + `${systemRoot}\\System32\\bash.exe`, + 'powershell.exe', + 'pwsh.exe', + 'cmd.exe', + 'bash.exe', + ]; + + for (const shell of candidates) { + if (shell.includes('\\')) { + if (await pathExists(shell)) { + add(shell); + } + } else { + add(shell); + } + } + + return [...detected]; + } + + add(process.env.SHELL ?? ''); + + const shellFiles = ['/etc/shells']; + for (const shellFile of shellFiles) { + try { + const content = await readFile(shellFile, 'utf8'); + content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .forEach(add); + } catch { + // Best-effort discovery only. + } + } + + const fallbackCandidates = ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/fish']; + for (const shell of fallbackCandidates) { + if (await pathExists(shell)) { + add(shell); + } + } + + return [...detected]; +} /** * Get the entire config including system information @@ -34,6 +114,7 @@ export async function getConfig() { memory } }; + const availableShells = await detectAvailableShells(systemInfo); console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`); return { @@ -41,6 +122,22 @@ export async function getConfig() { type: "text", text: `Current configuration:\n${JSON.stringify(configWithSystemInfo, null, 2)}` }], + structuredContent: { + config: configWithSystemInfo, + uiHints: { + availableShells, + }, + entries: CONFIG_FIELD_KEYS.map((key) => { + const definition = CONFIG_FIELD_DEFINITIONS[key]; + const value = (configWithSystemInfo as Record)[key]; + return { + key, + value, + valueType: definition.valueType, + editable: true, + }; + }), + }, }; } catch (error) { console.error(`Error in getConfig: ${error instanceof Error ? error.message : String(error)}`); @@ -55,18 +152,6 @@ export async function getConfig() { } } -// Keys that can be set via the set_config_value MCP tool. -// Internal keys (clientId, usageStats, abTest_*, onboardingState, etc.) -// are managed by the server itself and should not be settable by clients. -const ALLOWED_CONFIG_KEYS = new Set([ - 'blockedCommands', - 'allowedDirectories', - 'defaultShell', - 'telemetryEnabled', - 'fileReadLineLimit', - 'fileWriteLineLimit', -]); - /** * Set a specific config value */ @@ -85,7 +170,7 @@ export async function setConfigValue(args: unknown) { }; } - if (!ALLOWED_CONFIG_KEYS.has(parsed.data.key)) { + if (!isConfigFieldKey(parsed.data.key)) { return { content: [{ type: "text", @@ -96,6 +181,7 @@ export async function setConfigValue(args: unknown) { } try { + const fieldDefinition = CONFIG_FIELD_DEFINITIONS[parsed.data.key]; // Parse string values that should be arrays or objects let valueToStore = parsed.data.value; @@ -111,8 +197,7 @@ export async function setConfigValue(args: unknown) { } // Special handling for known array configuration keys - if ((parsed.data.key === 'allowedDirectories' || parsed.data.key === 'blockedCommands') && - !Array.isArray(valueToStore)) { + if (fieldDefinition.valueType === 'array' && !Array.isArray(valueToStore)) { if (typeof valueToStore === 'string') { const originalString = valueToStore; try { @@ -169,4 +254,4 @@ export async function setConfigValue(args: unknown) { isError: true }; } -} \ No newline at end of file +} diff --git a/src/ui/config-editor/index.html b/src/ui/config-editor/index.html new file mode 100644 index 00000000..7af25a1d --- /dev/null +++ b/src/ui/config-editor/index.html @@ -0,0 +1,13 @@ + + + + + + Desktop Commander Config Editor + + + +
+ + + diff --git a/src/ui/config-editor/src/app.ts b/src/ui/config-editor/src/app.ts new file mode 100644 index 00000000..df4465b3 --- /dev/null +++ b/src/ui/config-editor/src/app.ts @@ -0,0 +1,890 @@ +import { App } from '@modelcontextprotocol/ext-apps'; +import { createToolBridge } from '../../shared/tool-bridge.js'; +import { createCompactRowShellController, type ToolShellController } from '../../shared/tool-shell.js'; +import { renderCompactRow } from '../../shared/compact-row.js'; +import { escapeHtml } from '../../shared/escape-html.js'; +import { createWidgetStateStorage } from '../../shared/widget-state.js'; +import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js'; +import { createArrayModalController, renderArrayModalMarkup } from './array-modal.js'; +import { CONFIG_FIELD_DEFINITIONS, isConfigFieldKey } from '../../../config-field-definitions.js'; + +export interface ConfigEntry { + key: string; + label?: string; + description?: string; + value: unknown; + valueType: 'string' | 'number' | 'boolean' | 'array' | 'null' | string; + editable: boolean; +} + +export interface ConfigEditorPayload { + config?: Record; + uiHints?: { + availableShells?: string[]; + }; + entries: ConfigEntry[]; +} + +export interface ConfigEditorState { + payload: ConfigEditorPayload | null; + selectedKey: string | null; + draftValue: string; +} + +export type TooltipTone = 'info' | 'error' | 'success'; + +export interface TooltipMessage { + message: string; + tone: TooltipTone; +} + +export interface ApplyConfigResult { + ok: boolean; + tooltip?: TooltipMessage; +} + +interface RenderHooks { + onConfigChanged?: (change: { key: string; value: unknown }) => void; + onTooltip?: (tooltip: TooltipMessage) => void; +} + +type ToolCall = (name: string, args?: Record) => Promise; + +let shellController: ToolShellController | undefined; + +function isConfigEditorPayload(value: unknown): value is ConfigEditorPayload { + return isObjectRecord(value) && Array.isArray((value as Record).entries); +} + +function stringifyValueForInput(value: unknown): string { + if (Array.isArray(value)) { + return value.map((item) => String(item)).join('\n'); + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value === null) { + return 'null'; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function formatKeyLabel(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function getConfigFieldMetadata(key: string): { label: string; description: string } | undefined { + if (!isConfigFieldKey(key)) { + return undefined; + } + return { + label: CONFIG_FIELD_DEFINITIONS[key].label, + description: CONFIG_FIELD_DEFINITIONS[key].description, + }; +} + +function extractPayload(result: unknown): ConfigEditorPayload | null { + if (!isObjectRecord(result)) { + return null; + } + const structured = result.structuredContent; + if (!isObjectRecord(structured) || !Array.isArray(structured.entries)) { + return null; + } + + const entries: ConfigEntry[] = structured.entries + .filter((entry): entry is Record => isObjectRecord(entry)) + .filter((entry) => typeof entry.key === 'string') + .map((entry) => { + const key = entry.key as string; + const metadata = getConfigFieldMetadata(key); + return { + key, + label: metadata?.label, + description: metadata?.description, + value: entry.value, + valueType: typeof entry.valueType === 'string' ? entry.valueType : 'string', + editable: entry.editable !== false, + }; + }); + + return { + config: isObjectRecord(structured.config) ? structured.config : undefined, + uiHints: isObjectRecord(structured.uiHints) + ? { + availableShells: Array.isArray(structured.uiHints.availableShells) + ? structured.uiHints.availableShells.filter((value): value is string => typeof value === 'string') + : undefined, + } + : undefined, + entries, + }; +} + +function extractToolText(result: unknown): string | undefined { + if (!isObjectRecord(result) || !Array.isArray(result.content)) { + return undefined; + } + + for (const item of result.content) { + if (!isObjectRecord(item)) { + continue; + } + if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) { + return item.text; + } + } + + return undefined; +} + +function isToolErrorResult(result: unknown): boolean { + return isObjectRecord(result) && result.isError === true; +} + +function parseDraftValue(rawValue: string, valueType: string): { ok: true; value: unknown } | { ok: false; message: string } { + if (valueType === 'string') { + return { ok: true, value: rawValue.replace(/\r?\n/g, ' ') }; + } + + if (valueType === 'number') { + const numeric = Number(rawValue); + if (!Number.isFinite(numeric)) { + return { ok: false, message: 'Enter a valid number.' }; + } + return { ok: true, value: numeric }; + } + + if (valueType === 'boolean') { + const normalized = rawValue.trim().toLowerCase(); + if (normalized !== 'true' && normalized !== 'false') { + return { ok: false, message: 'Boolean values must be true or false.' }; + } + return { ok: true, value: normalized === 'true' }; + } + + if (valueType === 'null') { + if (rawValue.trim().toLowerCase() !== 'null') { + return { ok: false, message: 'Null values must be exactly null.' }; + } + return { ok: true, value: null }; + } + + if (valueType === 'array') { + const trimmed = rawValue.trim(); + if (trimmed.length === 0) { + return { ok: true, value: [] }; + } + + if (!trimmed.startsWith('[')) { + const items = rawValue + .split(/\r?\n/) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return { ok: true, value: items }; + } + + try { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) { + return { ok: false, message: 'Array values must be valid JSON arrays.' }; + } + if (!parsed.every((item) => typeof item === 'string')) { + return { ok: false, message: 'Array values must contain only strings.' }; + } + return { ok: true, value: parsed }; + } catch { + return { ok: false, message: 'Array values must be valid JSON arrays.' }; + } + } + + return { ok: true, value: rawValue }; +} + +function areConfigValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (!Object.is(left[index], right[index])) { + return false; + } + } + + return true; + } + + return false; +} + +function parseDraftArrayValues(draft: string): string[] { + const trimmed = draft.trim(); + if (!trimmed) { + return []; + } + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.filter((item) => typeof item === 'string') as string[]; + } + } catch { + // fallback below + } + } + return draft + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function getSettingSummary(entry: ConfigEntry): string | null { + if (entry.key !== 'blockedCommands' && entry.key !== 'allowedDirectories') { + return null; + } + + const values = Array.isArray(entry.value) + ? entry.value.filter((item) => typeof item === 'string' && item.trim().length > 0) + : []; + + const count = values.length; + if (entry.key === 'blockedCommands') { + return `${count} command${count === 1 ? '' : 's'} blocked`; + } + + if (count === 0) { + return 'All folders allowed (no restriction)'; + } + + return `${count} folder${count === 1 ? '' : 's'} allowed`; +} + +function getShellOptions(payload: ConfigEditorPayload | null, currentShell: string): string[] { + const hintedShells = Array.isArray(payload?.uiHints?.availableShells) + ? payload.uiHints.availableShells.filter((shell): shell is string => typeof shell === 'string' && shell.trim().length > 0) + : []; + + const config = payload?.config; + const systemInfo = isObjectRecord(config?.systemInfo) ? config.systemInfo : null; + const isWindows = Boolean(systemInfo && systemInfo.isWindows === true); + const isMacOS = Boolean(systemInfo && systemInfo.isMacOS === true); + + const baseOptions = hintedShells.length > 0 + ? hintedShells + : isWindows + ? ['powershell.exe', 'pwsh.exe', 'cmd.exe', 'bash.exe'] + : isMacOS + ? ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/fish', 'zsh', 'bash', 'sh', 'fish'] + : ['/bin/bash', '/bin/sh', '/usr/bin/fish', '/bin/zsh', 'bash', 'sh', 'fish', 'zsh']; + + const options = new Set(); + for (const shell of baseOptions) { + options.add(shell); + } + if (currentShell.trim().length > 0) { + options.add(currentShell); + } + + return [...options]; +} + +export function createConfigEditorController(callTool: ToolCall) { + const state: ConfigEditorState = { + payload: null, + selectedKey: null, + draftValue: '', + }; + + const getSelectedEntry = (): ConfigEntry | undefined => { + if (!state.payload || !state.selectedKey) { + return undefined; + } + return state.payload.entries.find((entry) => entry.key === state.selectedKey); + }; + + const setPayload = (payload: ConfigEditorPayload | null): void => { + state.payload = payload; + if (!payload || payload.entries.length === 0) { + state.selectedKey = null; + state.draftValue = ''; + return; + } + + if (!state.selectedKey || !payload.entries.some((entry) => entry.key === state.selectedKey)) { + state.selectedKey = payload.entries[0].key; + } + + const current = getSelectedEntry(); + state.draftValue = current ? stringifyValueForInput(current.value) : ''; + }; + + const setSelection = (key: string): void => { + state.selectedKey = key; + const selected = getSelectedEntry(); + state.draftValue = selected ? stringifyValueForInput(selected.value) : ''; + }; + + const setDraftValue = (value: string): void => { + state.draftValue = value; + }; + + const apply = async (): Promise => { + const selected = getSelectedEntry(); + if (!selected) { + return { + ok: false, + tooltip: { + message: 'Select a config key before applying.', + tone: 'error', + }, + }; + } + + if (!selected.editable) { + return { + ok: false, + tooltip: { + message: `The selected key (${selected.key}) is read-only.`, + tone: 'error', + }, + }; + } + + const parsed = parseDraftValue(state.draftValue, selected.valueType); + if (!parsed.ok) { + return { + ok: false, + tooltip: { + message: parsed.message, + tone: 'error', + }, + }; + } + + if (areConfigValuesEqual(parsed.value, selected.value)) { + state.draftValue = stringifyValueForInput(selected.value); + return { ok: false }; + } + + try { + const setResult = await callTool('set_config_value', { + key: selected.key, + value: parsed.value, + }); + if (isToolErrorResult(setResult)) { + return { + ok: false, + tooltip: { + message: extractToolText(setResult) ?? `Failed to update ${selected.key}.`, + tone: 'error', + }, + }; + } + + const refreshed = await callTool('get_config', {}); + if (isToolErrorResult(refreshed)) { + return { + ok: false, + tooltip: { + message: extractToolText(refreshed) ?? 'Value was updated but config refresh failed.', + tone: 'error', + }, + }; + } + + const refreshedPayload = extractPayload(refreshed); + if (refreshedPayload) { + setPayload(refreshedPayload); + if (state.selectedKey === selected.key) { + const updatedSelected = getSelectedEntry(); + state.draftValue = updatedSelected + ? stringifyValueForInput(updatedSelected.value) + : state.draftValue; + } + } + + return { ok: true }; + } catch (error) { + return { + ok: false, + tooltip: { + message: `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`, + tone: 'error', + }, + }; + } + }; + + return { + state, + callTool, + extractPayload, + setPayload, + setSelection, + setDraftValue, + apply, + }; +} + +function render(container: HTMLElement, controller: ReturnType, chrome: UiChromeState, hooks: RenderHooks = {}): void { + shellController?.dispose(); + shellController = undefined; + + const { state } = controller; + const entries = state.payload?.entries ?? []; + const shellClasses = [ + 'shell', + 'tool-shell', + chrome.expanded ? 'expanded' : 'collapsed', + 'config-shell', + chrome.hideSummaryRow ? 'host-framed' : '', + chrome.compact ? 'compact' : '', + ].filter(Boolean).join(' '); + + const settingsHtml = entries.map((entry, index) => { + const keyTitle = entry.label ?? formatKeyLabel(entry.key); + const description = entry.description ?? ''; + const summary = getSettingSummary(entry); + + let controlHtml: string; + if (entry.key === 'defaultShell') { + const currentShell = String(entry.value ?? ''); + const shellOptions = getShellOptions(state.payload, currentShell); + const isCustomShell = !shellOptions.includes(currentShell); + const options = shellOptions + .map((shell) => ``) + .join(''); + controlHtml = ` +
+ + +
+ `; + } else if (entry.valueType === 'boolean') { + const checked = String(entry.value) === 'true' ? 'checked' : ''; + controlHtml = ``; + } else if (entry.valueType === 'number') { + controlHtml = ``; + } else if (entry.valueType === 'array') { + controlHtml = ``; + } else if (entry.valueType === 'null') { + controlHtml = `null`; + } else { + controlHtml = ``; + } + + return ` +
+
+

${escapeHtml(keyTitle)}

+ ${description ? `

${escapeHtml(description)}

` : ''} +

${summary ? escapeHtml(summary) : ''}

+
+
${controlHtml}
+
+ `; + }).join(''); + + container.innerHTML = ` +
+ ${renderCompactRow({ id: 'compact-toggle', label: 'View config', filename: 'Desktop Commander', variant: 'ready', expandable: true, expanded: chrome.expanded, interactive: true })} + +
+
+
${settingsHtml}
+
+
+ + ${renderArrayModalMarkup('List Setting')} +
+ + `; + + const toolShell = container.querySelector('#tool-shell') as HTMLElement | null; + const compactRow = container.querySelector('.compact-row--ready') as HTMLElement | null; + + const getUpdatedEntryByKey = (key: string): ConfigEntry | undefined => { + return controller.state.payload?.entries.find((item) => item.key === key); + }; + + const refreshSettingSummary = (key: string): void => { + const summaryElement = container.querySelector(`[data-setting-summary-key="${key}"]`) as HTMLElement | null; + if (!summaryElement) { + return; + } + const updatedEntry = getUpdatedEntryByKey(key); + const summary = updatedEntry ? getSettingSummary(updatedEntry) : null; + summaryElement.textContent = summary ?? ''; + summaryElement.classList.toggle('hidden', !summary); + }; + + const emitConfigChanged = (key: string, fallbackValue: unknown): void => { + const updatedEntry = getUpdatedEntryByKey(key); + refreshSettingSummary(key); + hooks.onConfigChanged?.({ + key, + value: updatedEntry ? updatedEntry.value : fallbackValue, + }); + }; + + const emitTooltip = (result: ApplyConfigResult): void => { + if (result.tooltip) { + hooks.onTooltip?.(result.tooltip); + } + }; + + const arrayModal = createArrayModalController({ + container, + parseEntryItems: (entry) => parseDraftArrayValues(stringifyValueForInput(entry.value)), + formatEntryTitle: (entry) => entry.label ?? formatKeyLabel(entry.key), + onSave: async (changedKey, items) => { + controller.setSelection(changedKey); + controller.setDraftValue(items.join('\n')); + const result = await controller.apply(); + emitTooltip(result); + if (result.ok) { + emitConfigChanged(changedKey, items); + } + }, + }); + + container.querySelectorAll('[data-action]').forEach((element) => { + const target = element as HTMLElement; + const action = target.dataset.action; + const keyIndex = Number(target.dataset.keyIndex); + const entry = entries[keyIndex]; + if (!entry) { + return; + } + + if (action === 'toggle-boolean') { + target.addEventListener('change', async () => { + const input = target as HTMLInputElement; + const previousChecked = input.checked; + controller.setSelection(entry.key); + controller.setDraftValue(input.checked ? 'true' : 'false'); + const result = await controller.apply(); + emitTooltip(result); + const updatedEntry = getUpdatedEntryByKey(entry.key); + if (updatedEntry && typeof updatedEntry.value === 'boolean') { + input.checked = updatedEntry.value; + } else if (!result.ok) { + input.checked = !previousChecked; + } + if (result.ok) { + emitConfigChanged(entry.key, input.checked); + } + }); + return; + } + + if (action === 'edit-number') { + target.addEventListener('blur', async () => { + const input = target as HTMLInputElement; + controller.setSelection(entry.key); + controller.setDraftValue(input.value); + const result = await controller.apply(); + emitTooltip(result); + const updatedEntry = getUpdatedEntryByKey(entry.key); + input.value = String(updatedEntry?.value ?? controller.state.draftValue); + if (result.ok) { + emitConfigChanged(entry.key, input.value); + } + }); + return; + } + + if (action === 'shell-select') { + target.addEventListener('change', async () => { + const select = target as HTMLSelectElement; + const customInput = container.querySelector(`input[data-action="shell-custom"][data-key-index="${keyIndex}"]`) as HTMLInputElement | null; + if (select.value === '__custom__') { + customInput?.classList.remove('hidden'); + customInput?.focus(); + return; + } + + customInput?.classList.add('hidden'); + controller.setSelection(entry.key); + controller.setDraftValue(select.value); + const result = await controller.apply(); + emitTooltip(result); + const updatedEntry = getUpdatedEntryByKey(entry.key); + const shellValue = String(updatedEntry?.value ?? select.value); + const shellCustomInput = container.querySelector(`input[data-action="shell-custom"][data-key-index="${keyIndex}"]`) as HTMLInputElement | null; + if (shellCustomInput) { + shellCustomInput.value = shellValue; + } + if (result.ok) { + emitConfigChanged(entry.key, shellValue); + } + }); + return; + } + + if (action === 'shell-custom') { + const commitCustomShell = async () => { + const input = target as HTMLInputElement; + if (!input.value.trim()) { + return; + } + controller.setSelection(entry.key); + controller.setDraftValue(input.value.trim()); + const result = await controller.apply(); + emitTooltip(result); + const updatedEntry = getUpdatedEntryByKey(entry.key); + input.value = String(updatedEntry?.value ?? controller.state.draftValue); + if (result.ok) { + emitConfigChanged(entry.key, input.value); + } + }; + + target.addEventListener('blur', () => { + void commitCustomShell(); + }); + + target.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + void commitCustomShell(); + }); + return; + } + + if (action === 'edit-string') { + target.addEventListener('keydown', async (event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + const input = target as HTMLInputElement; + controller.setSelection(entry.key); + controller.setDraftValue(input.value.replace(/\r?\n/g, ' ')); + const result = await controller.apply(); + emitTooltip(result); + const updatedEntry = getUpdatedEntryByKey(entry.key); + input.value = String(updatedEntry?.value ?? controller.state.draftValue); + if (result.ok) { + emitConfigChanged(entry.key, input.value); + } + }); + return; + } + + if (action === 'open-array') { + target.addEventListener('click', () => { + const latestEntry = getUpdatedEntryByKey(entry.key) ?? entry; + arrayModal.open(latestEntry); + }); + } + }); + + shellController = createCompactRowShellController({ + shell: toolShell, + compactRow, + initialExpanded: chrome.expanded, + onToggle: (expanded) => { + chrome.expanded = expanded; + }, + }); +} + +function markReady(): void { + document.body.classList.add('dc-ready'); +} + +export function bootstrapConfigEditorApp(): void { + const container = document.getElementById('app'); + if (!container) { + return; + } + + const bridge = createToolBridge(); + const controller = createConfigEditorController((name, args) => bridge.callTool(name, args)); + const widgetState = createWidgetStateStorage(isConfigEditorPayload); + const chrome: UiChromeState = { + hideSummaryRow: false, + compact: false, + expanded: false, + }; + + let quietContextSupported = true; + let tooltipHideTimer: number | null = null; + + const clearTooltipTimer = (): void => { + if (tooltipHideTimer !== null) { + window.clearTimeout(tooltipHideTimer); + tooltipHideTimer = null; + } + }; + + const showTooltip = (tooltip: TooltipMessage): void => { + const tooltipElement = container.querySelector('#config-tooltip') as HTMLElement | null; + if (!tooltipElement) { + return; + } + + clearTooltipTimer(); + tooltipElement.className = `config-tooltip config-tooltip--${tooltip.tone}`; + tooltipElement.textContent = tooltip.message; + tooltipElement.hidden = false; + + tooltipHideTimer = window.setTimeout(() => { + tooltipElement.hidden = true; + tooltipElement.textContent = ''; + tooltipElement.className = 'config-tooltip'; + tooltipHideTimer = null; + }, 2600); + }; + + const syncModelContext = (reason: string, change?: { key: string; value: unknown }): void => { + const payload = controller.state.payload; + if (!payload || !quietContextSupported) { + return; + } + const values = payload.entries + .map((entry) => `${entry.key}=${JSON.stringify(entry.value)}`) + .join(', '); + const changeText = change + ? `Updated ${change.key} to ${JSON.stringify(change.value)}.` + : 'Configuration updated.'; + app.updateModelContext({ + content: [{ type: 'text', text: `${changeText} Snapshot (${reason}): ${values}` }], + structuredContent: { + reason, + changedKey: change?.key, + changedValue: change?.value, + entries: payload.entries.map((entry) => ({ + key: entry.key, + label: entry.label, + value: entry.value, + valueType: entry.valueType, + })), + }, + }).catch(() => { + // Host may not support updateModelContext; avoid repeated failed calls. + quietContextSupported = false; + }); + }; + + let renderFrameId: number | null = null; + const scheduleRender = (): void => { + if (renderFrameId !== null) { + return; + } + renderFrameId = window.requestAnimationFrame(() => { + renderFrameId = null; + render(container, controller, chrome, { + onConfigChanged: (change) => { + if (controller.state.payload) { + widgetState.write(controller.state.payload); + } + syncModelContext('widget-edit', change); + }, + onTooltip: showTooltip, + }); + markReady(); + }); + }; + + scheduleRender(); + + const app = new App( + { name: 'Desktop Commander Config Editor', version: '1.0.0' }, + {}, + { autoResize: true }, + ); + + app.onteardown = async () => { + shellController?.dispose(); + if (renderFrameId !== null) { + window.cancelAnimationFrame(renderFrameId); + renderFrameId = null; + } + clearTooltipTimer(); + return {}; + }; + + const applyPayload = (payload: ConfigEditorPayload): void => { + controller.setPayload(payload); + widgetState.write(payload); + scheduleRender(); + }; + + const refreshConfigFromServer = async (): Promise => { + try { + const result = await bridge.callTool('get_config', {}); + const payload = controller.extractPayload(result); + if (payload) { + applyPayload(payload); + } + } catch { + // Best-effort refresh only + } + }; + + app.ontoolresult = (result) => { + const payload = controller.extractPayload(result); + if (payload) { + applyPayload(payload); + } + }; + + app.ontoolcancelled = (params) => { + showTooltip({ + message: params.reason ?? 'Tool was cancelled.', + tone: 'error', + }); + }; + + void connectWithSharedHostContext({ + app, + chrome, + onContextApplied: scheduleRender, + onConnected: async () => { + const cachedPayload = widgetState.read(); + if (cachedPayload) { + controller.setPayload(cachedPayload); + } + scheduleRender(); + await refreshConfigFromServer(); + }, + }).catch(() => { + scheduleRender(); + window.setTimeout(() => { + showTooltip({ + message: 'Failed to connect to host.', + tone: 'error', + }); + }, 0); + }); + + window.addEventListener('beforeunload', () => { + shellController?.dispose(); + clearTooltipTimer(); + }, { once: true }); +} diff --git a/src/ui/config-editor/src/array-modal.ts b/src/ui/config-editor/src/array-modal.ts new file mode 100644 index 00000000..d3d6c202 --- /dev/null +++ b/src/ui/config-editor/src/array-modal.ts @@ -0,0 +1,196 @@ +import { escapeHtml } from '../../shared/escape-html.js'; + +export interface ArrayModalEntry { + key: string; + label?: string; + description?: string; + value: unknown; +} + +interface CreateArrayModalControllerOptions { + container: HTMLElement; + parseEntryItems: (entry: ArrayModalEntry) => string[]; + formatEntryTitle: (entry: ArrayModalEntry) => string; + onSave: (entryKey: string, items: string[]) => void | Promise; +} + +export interface ArrayModalController { + open: (entry: ArrayModalEntry) => void; + close: () => void; +} + +export function renderArrayModalMarkup(initialTitle: string): string { + return ` + + `; +} + +export function createArrayModalController(options: CreateArrayModalControllerOptions): ArrayModalController { + const { container, parseEntryItems, formatEntryTitle, onSave } = options; + + const modal = container.querySelector('#array-modal') as HTMLElement | null; + const modalList = container.querySelector('#array-modal-list') as HTMLElement | null; + const modalTitleElement = container.querySelector('#array-modal-title') as HTMLElement | null; + const modalDescriptionElement = container.querySelector('#array-modal-description') as HTMLElement | null; + const modalClose = container.querySelector('#array-modal-close') as HTMLButtonElement | null; + const modalSave = container.querySelector('#array-modal-save') as HTMLButtonElement | null; + + let modalEntryKey: string | null = null; + let modalItems: string[] = []; + + const collectModalItemsFromDom = (): string[] => { + if (!modalList) { + return [...modalItems]; + } + + const existing = Array.from(modalList.querySelectorAll('.array-modal-item-input')) + .map((input) => (input as HTMLInputElement).value.trim()) + .filter((value) => value.length > 0); + + const newInput = modalList.querySelector('#array-modal-new-item') as HTMLInputElement | null; + const newValue = newInput?.value.trim() ?? ''; + + if (newValue.length > 0) { + return [newValue, ...existing]; + } + + return existing; + }; + + const renderModalList = (): void => { + if (!modalList) { + return; + } + + modalList.innerHTML = ` +
+ +
+ ${modalItems.map((item, index) => ` +
+ + +
+ `).join('')} + `; + + const addModalItem = (value: string): void => { + const normalized = value.trim(); + if (!normalized) { + return; + } + const exists = modalItems.some((item) => item.trim() === normalized); + if (!exists) { + modalItems.unshift(normalized); + } + renderModalList(); + }; + + const newInput = modalList.querySelector('#array-modal-new-item') as HTMLInputElement | null; + newInput?.addEventListener('blur', () => { + const value = newInput.value.trim(); + if (!value) { + return; + } + addModalItem(value); + const refreshedNewInput = modalList.querySelector('#array-modal-new-item') as HTMLInputElement | null; + refreshedNewInput?.focus(); + }); + newInput?.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + const value = newInput.value.trim(); + if (!value) { + return; + } + addModalItem(value); + const refreshedNewInput = modalList.querySelector('#array-modal-new-item') as HTMLInputElement | null; + refreshedNewInput?.focus(); + }); + + modalList.querySelectorAll('.array-modal-row[data-modal-index]').forEach((rowElement) => { + const row = rowElement as HTMLElement; + const index = Number(row.dataset.modalIndex); + const input = row.querySelector('.array-modal-item-input') as HTMLInputElement | null; + const removeButton = row.querySelector('.array-modal-item-remove') as HTMLButtonElement | null; + + input?.addEventListener('blur', () => { + modalItems[index] = input.value.trim(); + modalItems = modalItems.filter((item) => item.length > 0); + renderModalList(); + }); + + removeButton?.addEventListener('click', () => { + modalItems = modalItems.filter((_, itemIndex) => itemIndex !== index); + renderModalList(); + }); + }); + }; + + const close = (): void => { + if (modal) { + modal.hidden = true; + } + modalEntryKey = null; + }; + + const open = (entry: ArrayModalEntry): void => { + modalEntryKey = entry.key; + modalItems = parseEntryItems(entry); + + if (modalTitleElement) { + modalTitleElement.textContent = formatEntryTitle(entry); + } + if (modalDescriptionElement) { + modalDescriptionElement.textContent = entry.description ?? ''; + modalDescriptionElement.classList.toggle('hidden', !modalDescriptionElement.textContent.trim()); + } + + renderModalList(); + if (modal) { + modal.hidden = false; + } + }; + + modalClose?.addEventListener('click', close); + modal?.addEventListener('click', (event) => { + if (event.target === modal) { + close(); + } + }); + modalSave?.addEventListener('click', async () => { + if (!modalEntryKey) { + return; + } + const changedKey = modalEntryKey; + modalItems = collectModalItemsFromDom(); + try { + await onSave(changedKey, modalItems); + } finally { + close(); + } + }); + + return { open, close }; +} diff --git a/src/ui/config-editor/src/main.ts b/src/ui/config-editor/src/main.ts new file mode 100644 index 00000000..e08bff33 --- /dev/null +++ b/src/ui/config-editor/src/main.ts @@ -0,0 +1,3 @@ +import { bootstrapConfigEditorApp } from './app.js'; + +bootstrapConfigEditorApp(); diff --git a/src/ui/file-preview/shared/preview-file-types.ts b/src/ui/file-preview/shared/preview-file-types.ts index 86e4cdb8..9282573a 100644 --- a/src/ui/file-preview/shared/preview-file-types.ts +++ b/src/ui/file-preview/shared/preview-file-types.ts @@ -35,7 +35,9 @@ export const TEXT_PREVIEW_EXTENSIONS = new Set([ '.java', '.go', '.rs', - '.sql' + '.sql', + '.srt', + '.vtt' ]); const TEXT_PREVIEW_BASENAMES = new Set([ diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 68c1f0bc..f44c2df1 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -8,9 +8,11 @@ import { escapeHtml } from './components/highlighting.js'; import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js'; import type { FilePreviewStructuredContent } from '../../../types.js'; import type { HtmlPreviewMode } from './types.js'; -import { createToolShellController, type ToolShellController } from '../../shared/tool-shell.js'; +import { createCompactRowShellController, type ToolShellController } from '../../shared/tool-shell.js'; import { createWidgetStateStorage } from '../../shared/widget-state.js'; -import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from '@modelcontextprotocol/ext-apps'; +import { renderCompactRow } from '../../shared/compact-row.js'; +import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js'; +import { App } from '@modelcontextprotocol/ext-apps'; let isExpanded = false; let hideSummaryRow = false; @@ -31,16 +33,12 @@ function getFileExtensionForAnalytics(filePath: string): string { return fileName.slice(dotIndex + 1).toLowerCase(); } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - // Internal type used only for rendering — extends the public type with the // text content sourced from the MCP content array (not structuredContent). type RenderPayload = FilePreviewStructuredContent & { content: string }; function isPreviewStructuredContent(value: unknown): value is FilePreviewStructuredContent { - if (!isObject(value)) { + if (!isObjectRecord(value)) { return false; } @@ -59,7 +57,7 @@ function buildRenderPayload( } function extractRenderPayload(value: unknown): RenderPayload | undefined { - if (!isObject(value)) { + if (!isObjectRecord(value)) { return undefined; } const meta = isPreviewStructuredContent(value.structuredContent) @@ -73,7 +71,7 @@ function extractRenderPayload(value: unknown): RenderPayload | undefined { } function extractToolText(value: unknown): string | undefined { - if (!isObject(value)) { + if (!isObjectRecord(value)) { return undefined; } const content = value.content; @@ -81,7 +79,7 @@ function extractToolText(value: unknown): string | undefined { return undefined; } for (const item of content) { - if (!isObject(item)) { + if (!isObjectRecord(item)) { continue; } if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) { @@ -556,9 +554,7 @@ function attachTextSelectionHandler(payload: RenderPayload): void { function renderStatusState(container: HTMLElement, message: string): void { container.innerHTML = `
-
- ${escapeHtml(message)} -
+ ${renderCompactRow({ label: message, variant: 'status', interactive: false })}
`; document.body.classList.add('dc-ready'); @@ -567,9 +563,7 @@ function renderStatusState(container: HTMLElement, message: string): void { function renderLoadingState(container: HTMLElement): void { container.innerHTML = `
-
- Preparing preview… -
+ ${renderCompactRow({ label: 'Preparing preview…', variant: 'loading', interactive: false })}
`; document.body.classList.add('dc-ready'); @@ -639,11 +633,7 @@ export function renderApp( container.innerHTML = `
-
- - ${compactLabel} - ${escapeHtml(payload.fileName)} -
+ ${renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })}
${breadcrumb} @@ -672,27 +662,14 @@ export function renderApp( attachLoadAllHandler(container, payload, htmlMode); attachTextSelectionHandler(payload); - // Compact row click toggles expand/collapse - const compactRow = document.getElementById('compact-toggle'); - const handleCompactClick = (): void => { - shellController?.toggle(); - }; - const handleCompactKeydown = (e: KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - shellController?.toggle(); - } - }; - compactRow?.addEventListener('click', handleCompactClick); - compactRow?.addEventListener('keydown', handleCompactKeydown); + const compactRow = document.getElementById('compact-toggle') as HTMLElement | null; - shellController = createToolShellController({ + shellController = createCompactRowShellController({ shell: document.getElementById('tool-shell'), - toggleButton: null, // No separate toggle button; compact row handles it + compactRow, initialExpanded: isExpanded, onToggle: (expanded) => { isExpanded = expanded; - compactRow?.setAttribute('aria-expanded', String(expanded)); trackUiEvent?.(expanded ? 'expand' : 'collapse', { file_type: payload.fileType, file_extension: fileExtension @@ -716,23 +693,6 @@ export function renderApp( } } -function applyHostCtx(ctx: unknown): void { - const c = ctx as any; - if (c?.theme === 'light' || c?.theme === 'dark') applyDocumentTheme(c.theme); - if (c?.styles?.variables) applyHostStyleVariables(c.styles.variables); - if (c?.styles?.css?.fonts) applyHostFonts(c.styles.css.fonts); - - // Hosts may pass additional flags via hostContext (forward-compatible index signature) - const extra = c; - if (typeof extra.initiallyExpanded === 'boolean') { - isExpanded = extra.initiallyExpanded; - } - if (typeof extra.hideSummaryRow === 'boolean') { - hideSummaryRow = extra.hideSummaryRow; - if (hideSummaryRow) isExpanded = true; - } -} - export function bootstrapApp(): void { const container = document.getElementById('app'); if (!container) { @@ -748,6 +708,15 @@ export function bootstrapApp(): void { { autoResize: true }, ); + const chrome: UiChromeState = { + expanded: isExpanded, + hideSummaryRow, + }; + const syncChromeState = (): void => { + isExpanded = chrome.expanded; + hideSummaryRow = chrome.hideSummaryRow; + }; + // Widget state for cross-host persistence (survives page refresh) const widgetState = createWidgetStateStorage( (v): v is RenderPayload => isPreviewStructuredContent(v) && typeof (v as any).content === 'string' @@ -801,10 +770,6 @@ export function bootstrapApp(): void { }; // Register ALL handlers BEFORE connect - app.onhostcontextchanged = (ctx) => { - applyHostCtx(ctx); - }; - app.onteardown = async () => { shellController?.dispose(); return {}; @@ -844,28 +809,27 @@ export function bootstrapApp(): void { }; // Connect to the host (defaults to window.parent via PostMessageTransport) - app.connect().then(() => { - // Apply initial host context received during the initialization handshake - const ctx = app.getHostContext(); - if (ctx) { - applyHostCtx(ctx); - } - - // Try to restore from persisted widget state (survives refresh on some hosts) - const cachedPayload = widgetState.read(); - if (cachedPayload) { - window.setTimeout(() => resolveInitialState(cachedPayload), 50); - } - - // Fallback: if no tool data arrives, show a helpful status message - window.setTimeout(() => { - if (!initialStateResolved) { - resolveInitialState( - undefined, - 'Preview unavailable after page refresh. Switch threads or re-run the tool.' - ); + void connectWithSharedHostContext({ + app, + chrome, + onContextApplied: syncChromeState, + onConnected: () => { + // Try to restore from persisted widget state (survives refresh on some hosts) + const cachedPayload = widgetState.read(); + if (cachedPayload) { + window.setTimeout(() => resolveInitialState(cachedPayload), 50); } - }, 8000); + + // Fallback: if no tool data arrives, show a helpful status message + window.setTimeout(() => { + if (!initialStateResolved) { + resolveInitialState( + undefined, + 'Preview unavailable after page refresh. Switch threads or re-run the tool.' + ); + } + }, 8000); + }, }).catch(() => { renderStatusState(container, 'Failed to connect to host.'); onRender?.(); diff --git a/src/ui/resources.ts b/src/ui/resources.ts index 8cc39f2f..c0fcb425 100644 --- a/src/ui/resources.ts +++ b/src/ui/resources.ts @@ -4,7 +4,7 @@ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; -import { FILE_PREVIEW_RESOURCE_URI } from './contracts.js'; +import { CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI } from './contracts.js'; const UI_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app'; @@ -15,6 +15,13 @@ export const FILE_PREVIEW_RESOURCE = { mimeType: UI_RESOURCE_MIME_TYPE }; +export const CONFIG_EDITOR_RESOURCE = { + uri: CONFIG_EDITOR_RESOURCE_URI, + name: 'Desktop Commander Config Editor', + description: 'Interactive editor for Desktop Commander configuration values.', + mimeType: UI_RESOURCE_MIME_TYPE +}; + interface ReadableUiResource { mimeType: string; getText: () => Promise; @@ -24,6 +31,7 @@ interface ReadableUiResource { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DIST_FILE_PREVIEW_DIR = path.resolve(__dirname, 'file-preview'); +const DIST_CONFIG_EDITOR_DIR = path.resolve(__dirname, 'config-editor'); function replaceOrThrow( source: string, @@ -75,15 +83,23 @@ export async function getFilePreviewResourceText(): Promise { return readInlinedResourceHtml(DIST_FILE_PREVIEW_DIR, 'preview-runtime.js'); } +export async function getConfigEditorResourceText(): Promise { + return readInlinedResourceHtml(DIST_CONFIG_EDITOR_DIR, 'config-editor-runtime.js'); +} + const READABLE_UI_RESOURCES: Record = { [FILE_PREVIEW_RESOURCE_URI]: { mimeType: FILE_PREVIEW_RESOURCE.mimeType, getText: getFilePreviewResourceText + }, + [CONFIG_EDITOR_RESOURCE_URI]: { + mimeType: CONFIG_EDITOR_RESOURCE.mimeType, + getText: getConfigEditorResourceText } }; export function listUiResources() { - return [FILE_PREVIEW_RESOURCE]; + return [FILE_PREVIEW_RESOURCE, CONFIG_EDITOR_RESOURCE]; } export async function readUiResource(uri: string) { diff --git a/src/ui/shared/compact-row.ts b/src/ui/shared/compact-row.ts new file mode 100644 index 00000000..796c3c2c --- /dev/null +++ b/src/ui/shared/compact-row.ts @@ -0,0 +1,30 @@ +import { escapeHtml } from './escape-html.js'; + +interface RenderCompactRowOptions { + id?: string; + label: string; + filename?: string; + variant?: 'ready' | 'loading' | 'status'; + expandable?: boolean; + expanded?: boolean; + interactive?: boolean; +} + +export function renderCompactRow(options: RenderCompactRowOptions): string { + const variant = options.variant ?? 'ready'; + const classNames = `compact-row compact-row--${variant}`; + const id = options.id ? ` id="${escapeHtml(options.id)}"` : ''; + const interactive = options.interactive ?? variant === 'ready'; + const role = interactive ? ' role="button" tabindex="0"' : ''; + const ariaExpanded = typeof options.expanded === 'boolean' + ? ` aria-expanded="${String(options.expanded)}"` + : ''; + const chevron = options.expandable + ? '' + : ''; + const filename = options.filename + ? `${escapeHtml(options.filename)}` + : ''; + + return `
${chevron}${escapeHtml(options.label)}${filename}
`; +} diff --git a/src/ui/shared/host-context.ts b/src/ui/shared/host-context.ts new file mode 100644 index 00000000..e6c6ca5c --- /dev/null +++ b/src/ui/shared/host-context.ts @@ -0,0 +1,77 @@ +import { App, applyDocumentTheme, applyHostFonts, applyHostStyleVariables } from '@modelcontextprotocol/ext-apps'; + +export interface UiChromeState { + expanded: boolean; + hideSummaryRow: boolean; + compact?: boolean; +} + +export interface ConnectWithSharedHostContextOptions { + app: App; + chrome: UiChromeState; + onContextApplied?: () => void; + onConnected?: () => void | Promise; +} + +export function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function applySharedHostContext(context: unknown, chrome: UiChromeState): void { + if (!isObjectRecord(context)) { + return; + } + + if (context.theme === 'light' || context.theme === 'dark') { + applyDocumentTheme(context.theme); + } + + const styles = isObjectRecord(context.styles) ? context.styles : null; + const variables = isObjectRecord(styles?.variables) ? styles.variables : null; + const css = isObjectRecord(styles?.css) ? styles.css : null; + const fonts = typeof css?.fonts === 'string' ? css.fonts : null; + + if (variables) { + applyHostStyleVariables(variables as Record); + } + if (fonts) { + applyHostFonts(fonts); + } + + if (typeof context.initiallyExpanded === 'boolean') { + chrome.expanded = context.initiallyExpanded; + } + + if (typeof context.compact === 'boolean' && typeof chrome.compact === 'boolean') { + chrome.compact = context.compact; + } + + if (typeof context.hideSummaryRow === 'boolean') { + chrome.hideSummaryRow = context.hideSummaryRow; + if (context.hideSummaryRow) { + chrome.expanded = true; + if (typeof chrome.compact === 'boolean') { + chrome.compact = true; + } + } + } +} + +export async function connectWithSharedHostContext(options: ConnectWithSharedHostContextOptions): Promise { + const { app, chrome, onContextApplied, onConnected } = options; + + app.onhostcontextchanged = (context) => { + applySharedHostContext(context, chrome); + onContextApplied?.(); + }; + + await app.connect(); + + const hostContext = app.getHostContext(); + if (hostContext) { + applySharedHostContext(hostContext, chrome); + onContextApplied?.(); + } + + await onConnected?.(); +} diff --git a/src/ui/shared/tool-bridge.ts b/src/ui/shared/tool-bridge.ts new file mode 100644 index 00000000..de1dd57e --- /dev/null +++ b/src/ui/shared/tool-bridge.ts @@ -0,0 +1,178 @@ +type ToolArgs = Record; + +type ToolHelper = { + callTool: (name: string, args: ToolArgs) => Promise | unknown; +}; + +type MessageEventLike = { + data: unknown; + origin?: string; + source?: unknown; +}; + +type MessageListener = (event: MessageEventLike) => void; + +type MessageTarget = { + postMessage: (message: unknown, targetOrigin?: string) => void; +}; + +type BridgeHost = { + openai?: ToolHelper; + mcp?: ToolHelper; + parent?: MessageTarget; + addEventListener?: (type: 'message', listener: MessageListener) => void; + removeEventListener?: (type: 'message', listener: MessageListener) => void; +}; + +export interface ToolBridgeOptions { + host?: BridgeHost; + requestTimeoutMs?: number; + targetOrigin?: string; + idPrefix?: string; +} + +const DEFAULT_TIMEOUT_MS = 5000; + +function getDefaultTargetOrigin(): string { + if (typeof document === 'undefined') { + return '*'; + } + + const referrer = document.referrer; + if (typeof referrer !== 'string' || referrer.trim().length === 0) { + return '*'; + } + + try { + return new URL(referrer).origin; + } catch { + return '*'; + } +} + +function normalizeTargetOrigin(value: string): string { + if (value === '*') { + return value; + } + + try { + return new URL(value).origin; + } catch { + throw new Error(`Invalid targetOrigin: ${value}`); + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeToolArgs(args: ToolArgs | undefined): ToolArgs { + return args ?? {}; +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return String(error); +} + +export function createToolBridge(options: ToolBridgeOptions = {}) { + const host = options.host ?? (globalThis as BridgeHost); + const timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS; + const targetOrigin = normalizeTargetOrigin(options.targetOrigin ?? getDefaultTargetOrigin()); + const idPrefix = options.idPrefix ?? 'tool-bridge'; + let requestCounter = 0; + + async function callViaFallback(name: string, args: ToolArgs): Promise { + if (!host.parent || !host.addEventListener || !host.removeEventListener) { + throw new Error('JSON-RPC fallback is unavailable in this host environment.'); + } + + const parent = host.parent; + const addListener = host.addEventListener; + const removeListener = host.removeEventListener; + + requestCounter += 1; + const requestId = `${idPrefix}:${requestCounter}`; + + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + removeListener('message', onMessage); + reject(new Error(`Tool call fallback timed out after ${timeoutMs}ms (request: ${requestId})`)); + }, timeoutMs); + + const onMessage: MessageListener = (event) => { + const payload = event.data; + if (event.source !== parent) { + return; + } + if (targetOrigin !== '*' && event.origin !== targetOrigin) { + return; + } + if (!isObject(payload) || payload.id !== requestId) { + return; + } + + clearTimeout(timeoutHandle); + removeListener('message', onMessage); + + if (isObject(payload.error)) { + const rawMessage = payload.error.message; + const message = typeof rawMessage === 'string' + ? rawMessage + : 'Unknown tools/call fallback error'; + reject(new Error(`Tool call fallback failed: ${message}`)); + return; + } + + resolve(payload.result); + }; + + addListener('message', onMessage); + parent.postMessage( + { + jsonrpc: '2.0', + id: requestId, + method: 'tools/call', + params: { + name, + arguments: args, + }, + }, + targetOrigin + ); + }); + } + + async function callTool(name: string, args?: ToolArgs): Promise { + const normalizedArgs = normalizeToolArgs(args); + const helperCandidates = [host.openai, host.mcp].filter( + (candidate): candidate is ToolHelper => Boolean(candidate?.callTool) + ); + + let helperFailure: unknown; + for (const helper of helperCandidates) { + try { + return await helper.callTool(name, normalizedArgs); + } catch (error) { + helperFailure = error; + } + } + + try { + return await callViaFallback(name, normalizedArgs); + } catch (fallbackError) { + if (helperFailure) { + throw new Error( + `Helper and fallback tool calls failed. Helper: ${extractErrorMessage(helperFailure)}. Fallback: ${extractErrorMessage(fallbackError)}` + ); + } + throw fallbackError; + } + } + + return { + callTool, + }; +} diff --git a/src/ui/shared/tool-shell.ts b/src/ui/shared/tool-shell.ts index 5e86a206..99444f91 100644 --- a/src/ui/shared/tool-shell.ts +++ b/src/ui/shared/tool-shell.ts @@ -20,6 +20,15 @@ interface CreateToolShellControllerOptions { onRender?: () => void; } +interface CreateCompactRowShellControllerOptions { + shell: HTMLElement | null; + compactRow: HTMLElement | null; + initialExpanded: boolean; + onToggle?: (expanded: boolean) => void; + onScrollAfterExpand?: () => void; + onRender?: () => void; +} + function syncExpandButton(toggleButton: HTMLButtonElement | null, expanded: boolean): void { if (!toggleButton) { return; @@ -42,6 +51,7 @@ export function createToolShellController(options: CreateToolShellControllerOpti const { shell, toggleButton, initialExpanded, onToggle, onScrollAfterExpand, onRender } = options; let isExpanded = initialExpanded; let scrollTrackedForCurrentExpand = false; + const shouldTrackScroll = typeof onScrollAfterExpand === 'function'; const applyExpandedState = (nextExpanded: boolean): void => { const wasExpanded = isExpanded; @@ -73,8 +83,10 @@ export function createToolShellController(options: CreateToolShellControllerOpti applyExpandedState(isExpanded); toggleButton?.addEventListener('click', toggle); - shell?.addEventListener('scroll', handleScroll, { passive: true }); - document.addEventListener('scroll', handleScroll, { passive: true, capture: true }); + if (shouldTrackScroll) { + shell?.addEventListener('scroll', handleScroll, { passive: true }); + document.addEventListener('scroll', handleScroll, { passive: true, capture: true }); + } return { getExpanded: () => isExpanded, @@ -82,8 +94,54 @@ export function createToolShellController(options: CreateToolShellControllerOpti toggle, dispose: () => { toggleButton?.removeEventListener('click', toggle); - shell?.removeEventListener('scroll', handleScroll); - document.removeEventListener('scroll', handleScroll, true); + if (shouldTrackScroll) { + shell?.removeEventListener('scroll', handleScroll); + document.removeEventListener('scroll', handleScroll, true); + } + }, + }; +} + +export function createCompactRowShellController(options: CreateCompactRowShellControllerOptions): ToolShellController { + const { shell, compactRow, initialExpanded, onToggle, onScrollAfterExpand, onRender } = options; + + const controller = createToolShellController({ + shell, + toggleButton: null, + initialExpanded, + onToggle: (expanded) => { + compactRow?.setAttribute('aria-expanded', String(expanded)); + onToggle?.(expanded); + }, + onScrollAfterExpand, + onRender, + }); + + compactRow?.setAttribute('aria-expanded', String(initialExpanded)); + + const handleCompactClick = (): void => { + controller.toggle(); + }; + + const handleCompactKeydown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + controller.toggle(); + }; + + compactRow?.addEventListener('click', handleCompactClick); + compactRow?.addEventListener('keydown', handleCompactKeydown); + + return { + getExpanded: controller.getExpanded, + setExpanded: controller.setExpanded, + toggle: controller.toggle, + dispose: () => { + compactRow?.removeEventListener('click', handleCompactClick); + compactRow?.removeEventListener('keydown', handleCompactKeydown); + controller.dispose(); }, }; } diff --git a/src/ui/styles/apps/config-editor.css b/src/ui/styles/apps/config-editor.css new file mode 100644 index 00000000..855478e5 --- /dev/null +++ b/src/ui/styles/apps/config-editor.css @@ -0,0 +1,430 @@ +:root { + --cfg-bg: var(--bg); + --cfg-surface: var(--panel); + --cfg-text: var(--text); + --cfg-muted: var(--text-secondary); + --cfg-border: var(--border); + --cfg-toggle-on: color-mix(in srgb, var(--success-text) 70%, var(--cfg-border)); +} + +body { + margin: 0; + font-family: var(--font-sans, "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif); + background: var(--cfg-bg); + color: var(--cfg-text); +} + +.config-shell { + max-width: none; + min-height: 0; + padding: 8px; + opacity: 0; + transform: translateY(4px); + animation: cfg-fade-in 180ms ease-out forwards; +} + +@keyframes cfg-fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.config-card { + margin-top: 6px; + background: var(--cfg-surface); + border: 1px solid color-mix(in srgb, var(--cfg-border) 50%, transparent); + border-radius: 16px; + overflow: hidden; +} + +.panel-content-wrapper { + padding: 10px; + display: block; + overflow: hidden; + min-height: 0; + background: inherit; +} + +.settings-stack { + display: grid; + gap: 0; + overflow: visible; + background: inherit; +} + +.setting-row { + border-bottom: none; + background: transparent; + padding: 10px 4px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.setting-row + .setting-row { + border-top: 1px solid color-mix(in srgb, var(--cfg-border) 40%, transparent); +} + +.setting-info h3 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.setting-info p { + margin: 2px 0 0; + font-size: 12px; + color: var(--cfg-muted); + line-height: 1.45; +} + +.setting-info .setting-summary { + margin-top: 6px; + font-size: 11px; + color: var(--cfg-muted); + font-weight: 500; +} + +.setting-control { + min-width: 180px; + justify-self: end; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; +} + +.setting-inline-input { + width: 120px; + box-sizing: border-box; + border: 1px solid var(--cfg-border); + border-radius: 10px; + padding: 8px 10px; + font: inherit; + font-size: 13px; + color: var(--cfg-text); + background: transparent; +} + +.setting-inline-action { + border: 1px solid var(--cfg-border); + background: transparent; + color: var(--cfg-text); + border-radius: 10px; + padding: 7px 12px; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.setting-inline-select { + width: 120px; + box-sizing: border-box; + border: 1px solid var(--cfg-border); + border-radius: 10px; + padding: 8px 10px; + font: inherit; + font-size: 13px; + color: var(--cfg-text); + background: transparent; +} + +.setting-shell-control { + display: grid; + gap: 6px; + justify-items: end; +} + +.setting-shell-custom { + width: 120px; +} + +.hidden { + display: none; +} + +.config-tooltip[hidden] { + display: none; +} + +.config-tooltip { + position: fixed; + right: 12px; + bottom: 12px; + z-index: 40; + max-width: min(420px, calc(100vw - 24px)); + padding: 8px 10px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--cfg-border) 70%, transparent); + background: color-mix(in srgb, var(--panel) 90%, var(--cfg-border)); + color: var(--cfg-text); + font-size: 12px; + line-height: 1.35; + box-shadow: 0 10px 30px color-mix(in srgb, var(--cfg-border) 26%, transparent); +} + +.config-tooltip--error { + border-color: color-mix(in srgb, var(--error-text) 55%, var(--cfg-border)); + background: color-mix(in srgb, var(--error-text) 10%, var(--panel)); +} + +.config-tooltip--success { + border-color: color-mix(in srgb, var(--success-text) 55%, var(--cfg-border)); + background: color-mix(in srgb, var(--success-text) 8%, var(--panel)); +} + +.setting-inline-static { + font-size: 12px; + color: var(--cfg-muted); +} + +.setting-switch { + display: inline-flex; + align-items: center; + position: relative; + width: 40px; + height: 24px; + cursor: pointer; +} + +.setting-switch input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + pointer-events: auto; + cursor: pointer; +} + +.array-modal[hidden] { + display: none; +} + +.array-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.24); + display: grid; + place-items: center; + z-index: 20; +} + +.array-modal-card { + width: min(760px, calc(100vw - 32px)); + max-height: min(88vh, 900px); + background: var(--panel); + border: 1px solid var(--cfg-border); + border-radius: 14px; + padding: 12px; + box-sizing: border-box; + display: grid; + grid-template-rows: auto auto auto auto minmax(0, 1fr); + gap: 8px; + overflow: hidden; +} + +.array-modal-card header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.array-modal-card header h3 { + margin: 0; + font-size: 14px; +} + +.array-modal-description { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: var(--cfg-muted); +} + +.array-modal-hint { + margin: 0; + font-size: 11px; + line-height: 1.35; + color: var(--cfg-muted); +} + +.array-modal-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.array-modal-card header button { + border: 1px solid var(--cfg-border); + background: transparent; + color: var(--cfg-text); + border-radius: 8px; + width: 30px; + height: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.array-modal-card header button svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.array-modal-list { + display: grid; + gap: 8px; + min-height: 0; + overflow: auto; +} + +.array-modal-row { + display: flex; + align-items: center; + gap: 8px; +} + +.array-modal-row input { + flex: 1; + min-width: 0; + box-sizing: border-box; + border: 1px solid var(--cfg-border); + border-radius: 9px; + padding: 8px 10px; + background: transparent; + color: var(--cfg-text); + font: inherit; + font-size: 13px; +} + +.array-modal-item-remove { + border: 1px solid var(--cfg-border); + background: transparent; + color: var(--cfg-text); + border-radius: 8px; + width: 30px; + height: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.array-modal-item-remove svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.config-boolean-slider { + width: 40px; + height: 24px; + border-radius: 999px; + background: color-mix(in srgb, var(--cfg-border) 80%, transparent); + position: relative; + transition: background 150ms ease; +} + +.config-boolean-slider::after { + content: ''; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--panel); + position: absolute; + top: 3px; + left: 3px; + transition: transform 150ms ease; +} + +.setting-switch input:checked + .config-boolean-slider { + background: var(--cfg-toggle-on); +} + +.setting-switch input:checked + .config-boolean-slider::after { + transform: translateX(16px); +} + +.config-shell.host-framed { + max-width: none; + min-height: 0; + padding: 0; + background: var(--cfg-surface); + opacity: 1; + transform: none; + animation: none; +} + +.config-shell.host-framed .config-card { + margin-top: 0; + border: none; + border-radius: 0; + background: var(--cfg-surface); + box-shadow: none; + padding: 0; +} + +.config-shell.host-framed .panel-content-wrapper { + padding: 6px 0; +} + +.config-shell.host-framed .compact-row { + display: none; +} + +.config-shell.compact { + min-height: 0; +} + +@media (max-width: 640px) { + .config-shell { + padding: 12px; + min-height: 0; + } + + .panel-content-wrapper { + padding: 12px; + } + + .setting-row { + grid-template-columns: 1fr; + align-items: start; + gap: 8px; + } + + .setting-control { + justify-self: stretch; + justify-content: flex-start; + min-width: 0; + } + + .setting-inline-input, + .setting-inline-select, + .setting-shell-custom { + width: 100%; + } + + .array-modal-card { + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + } +} diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index d64eae58..2db567b5 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -23,52 +23,6 @@ --notice-text: var(--text-secondary); } -/* ── Compact row ── */ - -.compact-row { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 2px; - font-size: 13px; - color: var(--muted); - line-height: 1; - user-select: none; -} - -.compact-row--ready { - cursor: pointer; - border-radius: 8px; - transition: color 150ms ease; -} - -.compact-row--ready:hover { color: var(--text-secondary); } -.compact-row--ready:hover .compact-chevron { color: var(--text-secondary); } - -.compact-chevron { - width: 14px; - height: 14px; - fill: currentColor; - color: var(--muted); - flex-shrink: 0; - transition: transform 200ms ease, color 150ms ease; -} - -.tool-shell.expanded .compact-chevron { transform: rotate(90deg); } - -.compact-label { color: inherit; } -.compact-filename { color: var(--text); font-weight: 600; } -.compact-row--ready .compact-label::after { content: ' · '; } - -.compact-row--loading { - animation: fade-pulse 1.5s ease-in-out infinite; -} - -@keyframes fade-pulse { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 1; } -} - /* ── Panel (Claude-style card) ── */ .panel { diff --git a/src/ui/styles/components/compact-row.css b/src/ui/styles/components/compact-row.css new file mode 100644 index 00000000..921a9d78 --- /dev/null +++ b/src/ui/styles/components/compact-row.css @@ -0,0 +1,65 @@ +.compact-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 2px; + font-size: 13px; + color: var(--muted); + line-height: 1; + user-select: none; +} + +.compact-row--ready { + cursor: pointer; + border-radius: 8px; + transition: color 150ms ease; +} + +.compact-row--ready:hover { + color: var(--text-secondary); +} + +.compact-row--ready:hover .compact-chevron { + color: var(--text-secondary); +} + +.compact-chevron { + width: 14px; + height: 14px; + fill: currentColor; + color: var(--muted); + flex-shrink: 0; + transition: transform 200ms ease, color 150ms ease; +} + +.tool-shell.expanded .compact-chevron { + transform: rotate(90deg); +} + +.compact-label { + color: inherit; +} + +.compact-filename { + color: var(--text); + font-weight: 600; +} + +.compact-row--ready .compact-label::after { + content: ' · '; +} + +.compact-row--loading { + animation: fade-pulse 1.5s ease-in-out infinite; +} + +@keyframes fade-pulse { + 0%, + 100% { + opacity: 0.5; + } + + 50% { + opacity: 1; + } +} diff --git a/test/test-file-handlers.js b/test/test-file-handlers.js index 710c6b61..14cb429a 100644 --- a/test/test-file-handlers.js +++ b/test/test-file-handlers.js @@ -19,7 +19,6 @@ import assert from 'assert'; import { readFile, writeFile, getFileInfo } from '../dist/tools/filesystem.js'; import { getFileHandler } from '../dist/utils/files/factory.js'; import { handleReadFile } from '../dist/handlers/filesystem-handlers.js'; -import { FILE_PREVIEW_RESOURCE_URI } from '../dist/ui/contracts.js'; // Get directory name const __filename = fileURLToPath(import.meta.url); @@ -284,10 +283,10 @@ async function testWriteModes() { } /** - * Test 9: read_file handler returns preview metadata for markdown/text + * Test 9: read_file handler returns preview structured content for markdown/text */ async function testReadFilePreviewMetadata() { - console.log('\n--- Test 9: read_file preview metadata ---'); + console.log('\n--- Test 9: read_file preview structured content ---'); const markdownContent = '# Title\n\n```js\nconst x = 1;\n```'; const textContent = 'hello\nplain text'; @@ -306,27 +305,18 @@ async function testReadFilePreviewMetadata() { assert.ok(markdownResult.structuredContent, 'Markdown should include structuredContent'); assert.strictEqual(markdownResult.structuredContent.fileType, 'markdown', 'Markdown fileType should be markdown'); assert.strictEqual(markdownResult.structuredContent.filePath, MD_FILE, 'Markdown file path should be present'); - assert.strictEqual(markdownResult._meta['ui/resourceUri'], FILE_PREVIEW_RESOURCE_URI, 'Markdown should include ui/resourceUri'); - assert.strictEqual(markdownResult._meta.ui.resourceUri, FILE_PREVIEW_RESOURCE_URI, 'Markdown should include preview resource URI'); - assert.strictEqual(markdownResult._meta['openai/widgetAccessible'], true, 'Markdown should enable widget accessibility'); const textResult = await handleReadFile({ path: TEXT_FILE }); assert.ok(Array.isArray(textResult.content), 'Result should include content array'); assert.ok(textResult.content[0].text.includes(textContent), 'Legacy content should still include text body'); assert.ok(textResult.structuredContent, 'Text should include structuredContent'); assert.strictEqual(textResult.structuredContent.fileType, 'text', 'Text fileType should be text'); - assert.strictEqual(textResult._meta['ui/resourceUri'], FILE_PREVIEW_RESOURCE_URI, 'Text should include ui/resourceUri'); - assert.strictEqual(textResult._meta.ui.resourceUri, FILE_PREVIEW_RESOURCE_URI, 'Text should include preview resource URI'); - assert.strictEqual(textResult._meta['openai/widgetAccessible'], true, 'Text should enable widget accessibility'); const htmlResult = await handleReadFile({ path: HTML_FILE }); assert.ok(Array.isArray(htmlResult.content), 'Result should include content array'); assert.ok(htmlResult.content[0].text.includes('

Preview

'), 'Legacy content should still include html body'); assert.ok(htmlResult.structuredContent, 'HTML should include structuredContent'); assert.strictEqual(htmlResult.structuredContent.fileType, 'html', 'HTML fileType should be html'); - assert.strictEqual(htmlResult._meta['ui/resourceUri'], FILE_PREVIEW_RESOURCE_URI, 'HTML should include ui/resourceUri'); - assert.strictEqual(htmlResult._meta.ui.resourceUri, FILE_PREVIEW_RESOURCE_URI, 'HTML should include preview resource URI'); - assert.strictEqual(htmlResult._meta['openai/widgetAccessible'], true, 'HTML should enable widget accessibility'); const imageResult = await handleReadFile({ path: IMAGE_FILE }); assert.ok(Array.isArray(imageResult.content), 'Image result should include content array'); @@ -337,9 +327,6 @@ async function testReadFilePreviewMetadata() { assert.ok(imageResult.structuredContent.imageData.length > 0, 'Image structured payload should include non-empty imageData'); assert.strictEqual(imageResult.structuredContent.mimeType, 'image/png', 'Image structured payload should include mimeType'); assert.strictEqual(imageResult.structuredContent.filePath, IMAGE_FILE, 'Image file path should be present'); - assert.strictEqual(imageResult._meta['ui/resourceUri'], FILE_PREVIEW_RESOURCE_URI, 'Image should include ui/resourceUri'); - assert.strictEqual(imageResult._meta.ui.resourceUri, FILE_PREVIEW_RESOURCE_URI, 'Image should include preview resource URI'); - assert.strictEqual(imageResult._meta['openai/widgetAccessible'], true, 'Image should enable widget accessibility'); const svgResult = await handleReadFile({ path: SVG_FILE }); assert.ok(Array.isArray(svgResult.content), 'SVG result should include content array'); @@ -349,15 +336,13 @@ async function testReadFilePreviewMetadata() { assert.strictEqual(svgResult.structuredContent.mimeType, 'image/svg+xml', 'SVG structured payload should include SVG mimeType'); assert.strictEqual(typeof svgResult.structuredContent.imageData, 'string', 'SVG structured payload should include imageData'); assert.ok(svgResult.structuredContent.imageData.length > 0, 'SVG structured payload should include non-empty imageData'); - assert.strictEqual(svgResult._meta['ui/resourceUri'], FILE_PREVIEW_RESOURCE_URI, 'SVG should include ui/resourceUri'); - assert.strictEqual(svgResult._meta['openai/widgetAccessible'], true, 'SVG should enable widget accessibility'); const nullArgsResult = await handleReadFile(null); assert.ok(Array.isArray(nullArgsResult.content), 'Null-args result should include content array'); assert.strictEqual(nullArgsResult.isError, true, 'Null-args should be returned as error'); assert.ok(nullArgsResult.content[0].text.includes('Error: No arguments provided for read_file command'), 'Null-args should include standard error text'); - console.log('✓ read_file preview metadata contract works'); + console.log('✓ read_file preview structured content contract works'); } /** From dcede40cad93228ec779a0237b602d8442871bb2 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Fri, 27 Feb 2026 11:51:16 +0200 Subject: [PATCH 2/6] fix(ui): harden config editor interactions and host bridge fallback Validate blank numeric input, keep array modal open on save failures, bind tool-bridge listeners safely, and tune host-driven toggle styling/expansion behavior for embedded hosts. --- src/ui/config-editor/src/app.ts | 14 ++++++++-- src/ui/config-editor/src/array-modal.ts | 32 +++++++++++++++++++++- src/ui/shared/tool-bridge.ts | 36 ++++++++++++++++++------- src/ui/styles/apps/config-editor.css | 22 ++++++++++++--- 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/ui/config-editor/src/app.ts b/src/ui/config-editor/src/app.ts index df4465b3..f72d097b 100644 --- a/src/ui/config-editor/src/app.ts +++ b/src/ui/config-editor/src/app.ts @@ -161,6 +161,9 @@ function parseDraftValue(rawValue: string, valueType: string): { ok: true; value } if (valueType === 'number') { + if (rawValue.trim() === '') { + return { ok: false, message: 'Enter a valid number.' }; + } const numeric = Number(rawValue); if (!Number.isFinite(numeric)) { return { ok: false, message: 'Enter a valid number.' }; @@ -729,7 +732,7 @@ export function bootstrapConfigEditorApp(): void { const chrome: UiChromeState = { hideSummaryRow: false, compact: false, - expanded: false, + expanded: true, }; let quietContextSupported = true; @@ -864,7 +867,14 @@ export function bootstrapConfigEditorApp(): void { void connectWithSharedHostContext({ app, chrome, - onContextApplied: scheduleRender, + onContextApplied: () => { + // Config editor should default to expanded in all hosts unless the + // host forces framed mode. + if (!chrome.hideSummaryRow) { + chrome.expanded = true; + } + scheduleRender(); + }, onConnected: async () => { const cachedPayload = widgetState.read(); if (cachedPayload) { diff --git a/src/ui/config-editor/src/array-modal.ts b/src/ui/config-editor/src/array-modal.ts index d3d6c202..ac681788 100644 --- a/src/ui/config-editor/src/array-modal.ts +++ b/src/ui/config-editor/src/array-modal.ts @@ -36,6 +36,7 @@ export function renderArrayModalMarkup(initialTitle: string): string {

Type an item, then press Enter (or click away) to add it. A new empty row appears automatically.

+
@@ -49,12 +50,29 @@ export function createArrayModalController(options: CreateArrayModalControllerOp const modalList = container.querySelector('#array-modal-list') as HTMLElement | null; const modalTitleElement = container.querySelector('#array-modal-title') as HTMLElement | null; const modalDescriptionElement = container.querySelector('#array-modal-description') as HTMLElement | null; + const modalErrorElement = container.querySelector('#array-modal-error') as HTMLElement | null; const modalClose = container.querySelector('#array-modal-close') as HTMLButtonElement | null; const modalSave = container.querySelector('#array-modal-save') as HTMLButtonElement | null; let modalEntryKey: string | null = null; let modalItems: string[] = []; + const clearError = (): void => { + if (!modalErrorElement) { + return; + } + modalErrorElement.textContent = ''; + modalErrorElement.classList.add('hidden'); + }; + + const showError = (message: string): void => { + if (!modalErrorElement) { + return; + } + modalErrorElement.textContent = message; + modalErrorElement.classList.remove('hidden'); + }; + const collectModalItemsFromDom = (): string[] => { if (!modalList) { return [...modalItems]; @@ -153,11 +171,13 @@ export function createArrayModalController(options: CreateArrayModalControllerOp modal.hidden = true; } modalEntryKey = null; + clearError(); }; const open = (entry: ArrayModalEntry): void => { modalEntryKey = entry.key; modalItems = parseEntryItems(entry); + clearError(); if (modalTitleElement) { modalTitleElement.textContent = formatEntryTitle(entry); @@ -185,10 +205,20 @@ export function createArrayModalController(options: CreateArrayModalControllerOp } const changedKey = modalEntryKey; modalItems = collectModalItemsFromDom(); + clearError(); + if (modalSave) { + modalSave.disabled = true; + } try { await onSave(changedKey, modalItems); - } finally { close(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showError(message.trim().length > 0 ? message : 'Failed to save list changes.'); + } finally { + if (modalSave) { + modalSave.disabled = false; + } } }); diff --git a/src/ui/shared/tool-bridge.ts b/src/ui/shared/tool-bridge.ts index de1dd57e..0f43b6c9 100644 --- a/src/ui/shared/tool-bridge.ts +++ b/src/ui/shared/tool-bridge.ts @@ -77,6 +77,23 @@ function extractErrorMessage(error: unknown): string { return String(error); } +function isHelperUnavailableError(error: unknown): boolean { + if (!isObject(error)) { + return false; + } + + const code = typeof error.code === 'string' ? error.code.toLowerCase() : ''; + if (code === 'not_implemented' || code === 'not_supported' || code === 'unavailable') { + return true; + } + + const message = extractErrorMessage(error).toLowerCase(); + return message.includes('not implemented') + || message.includes('not supported') + || message.includes('unavailable') + || message.includes('not available'); +} + export function createToolBridge(options: ToolBridgeOptions = {}) { const host = options.host ?? (globalThis as BridgeHost); const timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -90,8 +107,12 @@ export function createToolBridge(options: ToolBridgeOptions = {}) { } const parent = host.parent; - const addListener = host.addEventListener; - const removeListener = host.removeEventListener; + const addListener = (type: 'message', listener: MessageListener): void => { + host.addEventListener?.(type, listener); + }; + const removeListener = (type: 'message', listener: MessageListener): void => { + host.removeEventListener?.(type, listener); + }; requestCounter += 1; const requestId = `${idPrefix}:${requestCounter}`; @@ -151,23 +172,20 @@ export function createToolBridge(options: ToolBridgeOptions = {}) { (candidate): candidate is ToolHelper => Boolean(candidate?.callTool) ); - let helperFailure: unknown; for (const helper of helperCandidates) { try { return await helper.callTool(name, normalizedArgs); } catch (error) { - helperFailure = error; + if (isHelperUnavailableError(error)) { + continue; + } + throw new Error(`Tool helper call failed: ${extractErrorMessage(error)}`); } } try { return await callViaFallback(name, normalizedArgs); } catch (fallbackError) { - if (helperFailure) { - throw new Error( - `Helper and fallback tool calls failed. Helper: ${extractErrorMessage(helperFailure)}. Fallback: ${extractErrorMessage(fallbackError)}` - ); - } throw fallbackError; } } diff --git a/src/ui/styles/apps/config-editor.css b/src/ui/styles/apps/config-editor.css index 855478e5..8294b851 100644 --- a/src/ui/styles/apps/config-editor.css +++ b/src/ui/styles/apps/config-editor.css @@ -4,7 +4,12 @@ --cfg-text: var(--text); --cfg-muted: var(--text-secondary); --cfg-border: var(--border); - --cfg-toggle-on: color-mix(in srgb, var(--success-text) 70%, var(--cfg-border)); + --cfg-toggle-knob: var(--panel); + --cfg-toggle-knob-ring: color-mix(in srgb, var(--text) 72%, transparent); + --cfg-toggle-on: var( + --color-text-info, + var(--color-background-info, var(--color-background-success, color-mix(in srgb, var(--accent) 72%, var(--cfg-border)))) + ); } body { @@ -279,12 +284,19 @@ body { width: 14px; height: 14px; fill: none; - stroke: currentColor; + stroke: currentcolor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.array-modal-error { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: var(--error-text); +} + .array-modal-list { display: grid; gap: 8px; @@ -329,7 +341,7 @@ body { width: 14px; height: 14px; fill: none; - stroke: currentColor; + stroke: currentcolor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; @@ -349,7 +361,9 @@ body { width: 18px; height: 18px; border-radius: 50%; - background: var(--panel); + background: var(--cfg-toggle-knob); + border: 1px solid var(--cfg-toggle-knob-ring); + box-sizing: border-box; position: absolute; top: 3px; left: 3px; From d3e8f85f45e3f23b27e64a0299570e0fa9a8c773 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Tue, 3 Mar 2026 11:14:22 +0200 Subject: [PATCH 3/6] fix(config): normalize telemetryEnabled boolean inputs --- src/config-manager.ts | 34 ++++++++++++++++++++++++++++++---- src/tools/config.ts | 22 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/config-manager.ts b/src/config-manager.ts index 19b7bd8f..1c88700c 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -23,6 +23,27 @@ export interface ClientInfo { version: string; } +export function normalizeTelemetryEnabledValue(value: unknown): unknown { + if (typeof value !== 'string') { + return value; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return true; + } + + if (normalized === 'false') { + return false; + } + + return value; +} + +export function isTelemetryDisabledValue(value: unknown): boolean { + return normalizeTelemetryEnabledValue(value) === false; +} + /** * Singleton config manager for the server */ @@ -185,14 +206,19 @@ class ConfigManager { */ async setValue(key: string, value: any): Promise { await this.init(); + + if (key === 'telemetryEnabled') { + value = normalizeTelemetryEnabledValue(value); + } // Special handling for telemetry opt-out - if (key === 'telemetryEnabled' && value === false) { + if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) { // Get the current value before changing it - const currentValue = this.config[key]; + const currentValue: unknown = this.config[key]; + const telemetryAlreadyDisabled = isTelemetryDisabledValue(currentValue); // Only capture the opt-out event if telemetry was previously enabled - if (currentValue !== false) { + if (!telemetryAlreadyDisabled) { // Import the capture function dynamically to avoid circular dependencies const { capture } = await import('./utils/capture.js'); @@ -251,4 +277,4 @@ class ConfigManager { } // Export singleton instance -export const configManager = new ConfigManager(); \ No newline at end of file +export const configManager = new ConfigManager(); diff --git a/src/tools/config.ts b/src/tools/config.ts index f4882e13..6d681da5 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -222,6 +222,28 @@ export async function setConfigValue(args: unknown) { } } + // Harden boolean fields against stringly-typed inputs like "false". + if (fieldDefinition.valueType === 'boolean') { + if (typeof valueToStore === 'string') { + const normalized = valueToStore.trim().toLowerCase(); + if (normalized === 'true') { + valueToStore = true; + } else if (normalized === 'false') { + valueToStore = false; + } + } + + if (typeof valueToStore !== 'boolean') { + return { + content: [{ + type: "text", + text: `Value for ${parsed.data.key} must be boolean true/false.` + }], + isError: true + }; + } + } + await configManager.setValue(parsed.data.key, valueToStore); // Get the updated configuration to show the user const updatedConfig = await configManager.getConfig(); From bfcdaaff2aef62d21821b9d2df786dbba5536375 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Tue, 3 Mar 2026 11:14:36 +0200 Subject: [PATCH 4/6] fix(telemetry): reuse disabled-value helper in capture paths Use config-manager telemetry flag helpers for GA/proxy gating and add a small regression test that verifies string 'false' handling across set_value and set_config_value. --- src/utils/capture.ts | 9 ++-- test/test-telemetry-handling.js | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 test/test-telemetry-handling.js diff --git a/src/utils/capture.ts b/src/utils/capture.ts index 8f261e11..364264a2 100644 --- a/src/utils/capture.ts +++ b/src/utils/capture.ts @@ -1,6 +1,6 @@ import { platform } from 'os'; import * as https from 'https'; -import { configManager } from '../config-manager.js'; +import { configManager, isTelemetryDisabledValue } from '../config-manager.js'; import { currentClient } from '../server.js'; let VERSION = 'unknown'; @@ -53,7 +53,6 @@ export function sanitizeError(error: any): { message: string, code?: string } { }; } - /** * Send an event to Google Analytics * @param event Event name @@ -65,7 +64,7 @@ export const captureBase = async (captureURL: string, event: string, properties? const telemetryEnabled = await configManager.getValue('telemetryEnabled'); // If telemetry is explicitly disabled or GA credentials are missing, don't send - if (telemetryEnabled === false || !captureURL) { + if (isTelemetryDisabledValue(telemetryEnabled) || !captureURL) { return; } @@ -368,7 +367,7 @@ const buildEventProperties = async (properties?: any) => { const sendToTelemetryProxy = async (event: string, eventProperties: any) => { try { const telemetryEnabled = await configManager.getValue('telemetryEnabled'); - if (telemetryEnabled === false) return; + if (isTelemetryDisabledValue(telemetryEnabled)) return; const payload = JSON.stringify({ client_id: uniqueUserId, @@ -470,4 +469,4 @@ export const captureRemote = async (event: string, properties?: any) => { ...sanitizedProps, remote: String(true) }); -} \ No newline at end of file +} diff --git a/test/test-telemetry-handling.js b/test/test-telemetry-handling.js new file mode 100644 index 00000000..e86b27aa --- /dev/null +++ b/test/test-telemetry-handling.js @@ -0,0 +1,86 @@ +import assert from 'assert'; + +import { + configManager, + isTelemetryDisabledValue, + normalizeTelemetryEnabledValue, +} from '../dist/config-manager.js'; +import { setConfigValue } from '../dist/tools/config.js'; + +function testTelemetryHelpers() { + console.log('\n--- Test: telemetry helper behavior ---'); + + assert.strictEqual(normalizeTelemetryEnabledValue('false'), false); + assert.strictEqual(normalizeTelemetryEnabledValue(' true '), true); + assert.strictEqual(normalizeTelemetryEnabledValue('disabled'), 'disabled'); + + assert.strictEqual(isTelemetryDisabledValue(false), true); + assert.strictEqual(isTelemetryDisabledValue('false'), true); + assert.strictEqual(isTelemetryDisabledValue('FALSE'), true); + assert.strictEqual(isTelemetryDisabledValue(true), false); + assert.strictEqual(isTelemetryDisabledValue('true'), false); + + console.log('ok: telemetry helpers'); +} + +async function testConfigManagerCoercion() { + console.log('\n--- Test: configManager telemetry coercion ---'); + + await configManager.updateConfig({ telemetryEnabled: false }); + await configManager.setValue('telemetryEnabled', 'false'); + + const telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual(telemetryEnabled, false); + assert.strictEqual(typeof telemetryEnabled, 'boolean'); + + console.log('ok: configManager coercion'); +} + +async function testSetConfigValueCoercion() { + console.log('\n--- Test: set_config_value telemetry coercion ---'); + + await configManager.updateConfig({ telemetryEnabled: false }); + const response = await setConfigValue({ key: 'telemetryEnabled', value: 'false' }); + + assert.ok(response); + assert.notStrictEqual(response.isError, true); + + const telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual(telemetryEnabled, false); + assert.strictEqual(typeof telemetryEnabled, 'boolean'); + + console.log('ok: set_config_value coercion'); +} + +export default async function runTests() { + const originalConfig = await configManager.getConfig(); + + try { + testTelemetryHelpers(); + await testConfigManagerCoercion(); + await testSetConfigValueCoercion(); + + console.log('\nTelemetry handling tests passed.'); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('Telemetry handling test failed:', message); + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + return false; + } finally { + await configManager.updateConfig(originalConfig); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runTests() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); + }); +} From db1f0f9b547515d3b29ced1e773e71d1c1a9499c Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Tue, 3 Mar 2026 11:21:15 +0200 Subject: [PATCH 5/6] feat(ui): track config and preview events consistently Add a shared UI event tracker utility, wire config editor and file preview to it, and capture set_config_value call origin for telemetry analytics. --- src/server.ts | 1 + src/tools/schemas.ts | 1 + src/ui/config-editor/src/app.ts | 143 +++++++++++++++++++++++++----- src/ui/file-preview/src/app.ts | 13 +-- src/ui/shared/ui-event-tracker.ts | 43 +++++++++ 5 files changed, 172 insertions(+), 29 deletions(-) create mode 100644 src/ui/shared/ui-event-tracker.ts diff --git a/src/server.ts b/src/server.ts index f962bef7..975574b2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1189,6 +1189,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) { telemetryData.set_config_value_key_name = (args as any).key; + telemetryData.call_origin = (args as any).origin === 'ui' ? 'ui' : 'llm'; } if (name === 'get_prompts' && args && typeof args === 'object') { const promptArgs = args as any; diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index a2cdb39e..774fb99e 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -12,6 +12,7 @@ export const SetConfigValueArgsSchema = z.object({ z.array(z.string()), z.null(), ]), + origin: z.enum(['ui', 'llm']).optional(), }); // Empty schemas diff --git a/src/ui/config-editor/src/app.ts b/src/ui/config-editor/src/app.ts index f72d097b..8aacd518 100644 --- a/src/ui/config-editor/src/app.ts +++ b/src/ui/config-editor/src/app.ts @@ -5,6 +5,7 @@ import { renderCompactRow } from '../../shared/compact-row.js'; import { escapeHtml } from '../../shared/escape-html.js'; import { createWidgetStateStorage } from '../../shared/widget-state.js'; import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js'; +import { createUiEventTracker, type UiEventParams } from '../../shared/ui-event-tracker.js'; import { createArrayModalController, renderArrayModalMarkup } from './array-modal.js'; import { CONFIG_FIELD_DEFINITIONS, isConfigFieldKey } from '../../../config-field-definitions.js'; @@ -46,12 +47,57 @@ export interface ApplyConfigResult { interface RenderHooks { onConfigChanged?: (change: { key: string; value: unknown }) => void; onTooltip?: (tooltip: TooltipMessage) => void; + onExpandedChanged?: (expanded: boolean) => void; } type ToolCall = (name: string, args?: Record) => Promise; +type TrackConfigUiEvent = (event: string, params?: Record) => void; let shellController: ToolShellController | undefined; +const CONFIG_EDITOR_COMPONENT = 'config_editor'; +const GET_CONFIG_TOOL_NAME = 'get_config'; +const MAX_TELEMETRY_MESSAGE_LENGTH = 180; + +function sanitizeTelemetryErrorMessage(message: string): string { + // Keep error signal useful while removing path-like data and bounding payload size. + const collapsed = message.replace(/\s+/g, ' ').trim(); + const withoutPaths = collapsed + .replace(/(?:\/|\\)[\w\d_.\-/\\]+/g, '[PATH]') + .replace(/[A-Za-z]:\\[\w\d_.\-/\\]+/g, '[PATH]'); + + if (withoutPaths.length === 0) { + return 'Unknown error'; + } + + if (withoutPaths.length <= MAX_TELEMETRY_MESSAGE_LENGTH) { + return withoutPaths; + } + + return `${withoutPaths.slice(0, MAX_TELEMETRY_MESSAGE_LENGTH - 3)}...`; +} + +function buildConfigUpdateTelemetryParams(args: { + configKey: string; + valueType: string; + errorMessage?: string; + errorStage?: 'set_config_value' | 'transport'; +}): UiEventParams { + const base: UiEventParams = { + config_key: args.configKey, + value_type: args.valueType, + }; + + if (args.errorStage) { + base.error_stage = args.errorStage; + } + if (args.errorMessage) { + base.error_message = sanitizeTelemetryErrorMessage(args.errorMessage); + } + + return base; +} + function isConfigEditorPayload(value: unknown): value is ConfigEditorPayload { return isObjectRecord(value) && Array.isArray((value as Record).entries); } @@ -310,7 +356,7 @@ function getShellOptions(payload: ConfigEditorPayload | null, currentShell: stri return [...options]; } -export function createConfigEditorController(callTool: ToolCall) { +export function createConfigEditorController(callTool: ToolCall, trackConfigUiEvent?: TrackConfigUiEvent) { const state: ConfigEditorState = { payload: null, selectedKey: null, @@ -392,23 +438,45 @@ export function createConfigEditorController(callTool: ToolCall) { const setResult = await callTool('set_config_value', { key: selected.key, value: parsed.value, + origin: 'ui', }); + if (isToolErrorResult(setResult)) { + const errorMessage = extractToolText(setResult) ?? `Failed to update ${selected.key}.`; + trackConfigUiEvent?.('config_update_failed', { + tool_name: 'set_config_value', + ...buildConfigUpdateTelemetryParams({ + configKey: selected.key, + valueType: selected.valueType, + errorMessage, + errorStage: 'set_config_value', + }), + }); + return { ok: false, tooltip: { - message: extractToolText(setResult) ?? `Failed to update ${selected.key}.`, + message: errorMessage, tone: 'error', }, }; } + trackConfigUiEvent?.('config_update_success', { + tool_name: 'set_config_value', + ...buildConfigUpdateTelemetryParams({ + configKey: selected.key, + valueType: selected.valueType, + }), + }); + const refreshed = await callTool('get_config', {}); if (isToolErrorResult(refreshed)) { + const errorMessage = extractToolText(refreshed) ?? 'Value was updated but config refresh failed.'; return { ok: false, tooltip: { - message: extractToolText(refreshed) ?? 'Value was updated but config refresh failed.', + message: errorMessage, tone: 'error', }, }; @@ -425,12 +493,25 @@ export function createConfigEditorController(callTool: ToolCall) { } } - return { ok: true }; + return { + ok: true, + }; } catch (error) { + const errorMessage = `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`; + trackConfigUiEvent?.('config_update_failed', { + tool_name: 'set_config_value', + ...buildConfigUpdateTelemetryParams({ + configKey: selected.key, + valueType: selected.valueType, + errorMessage, + errorStage: 'transport', + }), + }); + return { ok: false, tooltip: { - message: `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`, + message: errorMessage, tone: 'error', }, }; @@ -712,6 +793,7 @@ function render(container: HTMLElement, controller: ReturnType { chrome.expanded = expanded; + hooks.onExpandedChanged?.(expanded); }, }); } @@ -727,7 +809,17 @@ export function bootstrapConfigEditorApp(): void { } const bridge = createToolBridge(); - const controller = createConfigEditorController((name, args) => bridge.callTool(name, args)); + const trackConfigUiEvent = createUiEventTracker( + (name, args) => bridge.callTool(name, args), + { + component: CONFIG_EDITOR_COMPONENT, + baseParams: { origin: 'ui' }, + } + ); + const controller = createConfigEditorController( + (name, args) => bridge.callTool(name, args), + trackConfigUiEvent + ); const widgetState = createWidgetStateStorage(isConfigEditorPayload); const chrome: UiChromeState = { hideSummaryRow: false, @@ -735,6 +827,8 @@ export function bootstrapConfigEditorApp(): void { expanded: true, }; + let configEditorShownEventSent = false; + let quietContextSupported = true; let tooltipHideTimer: number | null = null; @@ -765,28 +859,16 @@ export function bootstrapConfigEditorApp(): void { }; const syncModelContext = (reason: string, change?: { key: string; value: unknown }): void => { - const payload = controller.state.payload; - if (!payload || !quietContextSupported) { + if (!quietContextSupported || !change) { return; } - const values = payload.entries - .map((entry) => `${entry.key}=${JSON.stringify(entry.value)}`) - .join(', '); - const changeText = change - ? `Updated ${change.key} to ${JSON.stringify(change.value)}.` - : 'Configuration updated.'; + app.updateModelContext({ - content: [{ type: 'text', text: `${changeText} Snapshot (${reason}): ${values}` }], + content: [{ type: 'text', text: `Updated ${change.key} to ${JSON.stringify(change.value)} (${reason}).` }], structuredContent: { reason, - changedKey: change?.key, - changedValue: change?.value, - entries: payload.entries.map((entry) => ({ - key: entry.key, - label: entry.label, - value: entry.value, - valueType: entry.valueType, - })), + changedKey: change.key, + changedValue: change.value, }, }).catch(() => { // Host may not support updateModelContext; avoid repeated failed calls. @@ -809,6 +891,12 @@ export function bootstrapConfigEditorApp(): void { syncModelContext('widget-edit', change); }, onTooltip: showTooltip, + onExpandedChanged: (expanded) => { + trackConfigUiEvent(expanded ? 'expand' : 'collapse', { + tool_name: GET_CONFIG_TOOL_NAME, + expanded, + }); + }, }); markReady(); }); @@ -836,6 +924,15 @@ export function bootstrapConfigEditorApp(): void { controller.setPayload(payload); widgetState.write(payload); scheduleRender(); + + if (!configEditorShownEventSent) { + configEditorShownEventSent = true; + // One-shot impression event for get_config UI card visibility. + trackConfigUiEvent('config_editor_shown', { + tool_name: GET_CONFIG_TOOL_NAME, + entry_count: payload.entries.length, + }); + } }; const refreshConfigFromServer = async (): Promise => { diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index f44c2df1..c66c1fa3 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -12,6 +12,7 @@ import { createCompactRowShellController, type ToolShellController } from '../.. import { createWidgetStateStorage } from '../../shared/widget-state.js'; import { renderCompactRow } from '../../shared/compact-row.js'; import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js'; +import { createUiEventTracker } from '../../shared/ui-event-tracker.js'; import { App } from '@modelcontextprotocol/ext-apps'; let isExpanded = false; @@ -761,13 +762,13 @@ export function bootstrapApp(): void { }); }; - trackUiEvent = (event: string, params: Record = {}): void => { - void rpcCallTool?.('track_ui_event', { - event, + trackUiEvent = createUiEventTracker( + (name, args) => app.callServerTool({ name, arguments: args }), + { component: 'file_preview', - params: { tool_name: 'read_file', ...params } - }).catch(() => {}); - }; + baseParams: { tool_name: 'read_file' }, + } + ); // Register ALL handlers BEFORE connect app.onteardown = async () => { diff --git a/src/ui/shared/ui-event-tracker.ts b/src/ui/shared/ui-event-tracker.ts new file mode 100644 index 00000000..9ddde38b --- /dev/null +++ b/src/ui/shared/ui-event-tracker.ts @@ -0,0 +1,43 @@ +type UiEventParamValue = string | number | boolean | null; + +export type UiEventParams = Record; + +type ToolCaller = (name: string, args: Record) => Promise; + +export interface UiEventTrackerOptions { + component: string; + baseParams?: UiEventParams; +} + +function normalizeUiEventParams(params: Record | undefined): UiEventParams { + const normalized: UiEventParams = {}; + + if (!params) { + return normalized; + } + + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) { + normalized[key] = value; + } + } + + return normalized; +} + +export function createUiEventTracker(callTool: ToolCaller, options: UiEventTrackerOptions) { + const baseParams = options.baseParams ?? {}; + + return (event: string, params: Record = {}): void => { + void callTool('track_ui_event', { + event, + component: options.component, + params: { + ...baseParams, + ...normalizeUiEventParams(params), + }, + }).catch(() => { + // UI analytics should never block UI interactions. + }); + }; +} From b22ece666c598e31d65916ffac1ae996ba65290b Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Tue, 3 Mar 2026 16:27:13 +0200 Subject: [PATCH 6/6] Add test for telemetry --- test/test-telemetry-handling.js | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/test-telemetry-handling.js b/test/test-telemetry-handling.js index e86b27aa..a32f9fd4 100644 --- a/test/test-telemetry-handling.js +++ b/test/test-telemetry-handling.js @@ -52,6 +52,76 @@ async function testSetConfigValueCoercion() { console.log('ok: set_config_value coercion'); } +/** + * Regression test for issue #368: + * When telemetryEnabled is stored as the string 'false' (which happens when + * set via the MCP API), the capture path in captureBase must still treat it + * as disabled. This test reproduces the exact codepath: write string 'false' + * directly to config (bypassing setValue coercion), then read it back via + * getValue and verify isTelemetryDisabledValue gates it. + */ +async function testCapturePathRespectsStringFalse() { + console.log('\n--- Test: capture path respects string "false" (issue #368) ---'); + + // Simulate the pre-fix bug scenario: telemetryEnabled stored as string 'false' + // by writing directly through updateConfig (bypassing setValue normalization). + await configManager.updateConfig({ telemetryEnabled: 'false' }); + + // This is the exact check captureBase and sendToTelemetryProxy perform: + const telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual( + isTelemetryDisabledValue(telemetryEnabled), + true, + `isTelemetryDisabledValue should return true for stored value ${JSON.stringify(telemetryEnabled)} (type: ${typeof telemetryEnabled})` + ); + + console.log('ok: capture path respects string "false"'); +} + +async function testCapturePathRespectsStringFALSE() { + console.log('\n--- Test: capture path respects string "FALSE" ---'); + + await configManager.updateConfig({ telemetryEnabled: 'FALSE' }); + + const telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual( + isTelemetryDisabledValue(telemetryEnabled), + true, + `isTelemetryDisabledValue should return true for stored value ${JSON.stringify(telemetryEnabled)}` + ); + + console.log('ok: capture path respects string "FALSE"'); +} + +async function testCapturePathRespectsBooleanFalse() { + console.log('\n--- Test: capture path respects boolean false ---'); + + await configManager.updateConfig({ telemetryEnabled: false }); + + const telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual( + isTelemetryDisabledValue(telemetryEnabled), + true, + `isTelemetryDisabledValue should return true for stored value ${JSON.stringify(telemetryEnabled)}` + ); + + console.log('ok: capture path respects boolean false'); +} + +async function testCapturePathAllowsTrueValues() { + console.log('\n--- Test: capture path allows true values ---'); + + await configManager.updateConfig({ telemetryEnabled: true }); + let telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual(isTelemetryDisabledValue(telemetryEnabled), false, 'boolean true should not be disabled'); + + await configManager.updateConfig({ telemetryEnabled: 'true' }); + telemetryEnabled = await configManager.getValue('telemetryEnabled'); + assert.strictEqual(isTelemetryDisabledValue(telemetryEnabled), false, 'string "true" should not be disabled'); + + console.log('ok: capture path allows true values'); +} + export default async function runTests() { const originalConfig = await configManager.getConfig(); @@ -59,6 +129,10 @@ export default async function runTests() { testTelemetryHelpers(); await testConfigManagerCoercion(); await testSetConfigValueCoercion(); + await testCapturePathRespectsStringFalse(); + await testCapturePathRespectsStringFALSE(); + await testCapturePathRespectsBooleanFalse(); + await testCapturePathAllowsTrueValues(); console.log('\nTelemetry handling tests passed.'); return true;