adding config layer

This commit is contained in:
Gustavo Madeira Santana
2026-02-28 19:20:07 -05:00
parent 1828fdee8b
commit 812a996b2f
13 changed files with 487 additions and 31 deletions

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffsPluginDefaults } from "./config.js";
describe("resolveDiffsPluginDefaults", () => {
it("returns built-in defaults when config is missing", () => {
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
});
it("applies configured defaults from plugin config", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
fontFamily: "JetBrains Mono",
fontSize: 17,
layout: "split",
wordWrap: false,
background: false,
theme: "light",
mode: "view",
},
}),
).toEqual({
fontFamily: "JetBrains Mono",
fontSize: 17,
layout: "split",
wordWrap: false,
background: false,
theme: "light",
mode: "view",
});
});
});

View File

@@ -0,0 +1,147 @@
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
import {
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_THEMES,
type DiffLayout,
type DiffMode,
type DiffPresentationDefaults,
type DiffTheme,
type DiffToolDefaults,
} from "./types.js";
type DiffsPluginConfig = {
defaults?: {
fontFamily?: string;
fontSize?: number;
layout?: DiffLayout;
wordWrap?: boolean;
background?: boolean;
theme?: DiffTheme;
mode?: DiffMode;
};
};
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
fontFamily: "Fira Code",
fontSize: 15,
layout: "unified",
wordWrap: true,
background: true,
theme: "dark",
mode: "both",
};
const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
defaults: {
type: "object",
additionalProperties: false,
properties: {
fontFamily: { type: "string", default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily },
fontSize: {
type: "number",
minimum: 10,
maximum: 24,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize,
},
layout: {
type: "string",
enum: [...DIFF_LAYOUTS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.layout,
},
wordWrap: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap },
background: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.background },
theme: {
type: "string",
enum: [...DIFF_THEMES],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.theme,
},
mode: {
type: "string",
enum: [...DIFF_MODES],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.mode,
},
},
},
},
} as const;
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = {
safeParse(value: unknown) {
if (value === undefined) {
return { success: true, data: undefined };
}
try {
return { success: true, data: resolveDiffsPluginDefaults(value) };
} catch (error) {
return {
success: false,
error: {
issues: [{ path: [], message: error instanceof Error ? error.message : String(error) }],
},
};
}
},
jsonSchema: DIFFS_PLUGIN_CONFIG_JSON_SCHEMA,
};
export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
}
const defaults = (config as DiffsPluginConfig).defaults;
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
}
return {
fontFamily: normalizeFontFamily(defaults.fontFamily),
fontSize: normalizeFontSize(defaults.fontSize),
layout: normalizeLayout(defaults.layout),
wordWrap: defaults.wordWrap !== false,
background: defaults.background !== false,
theme: normalizeTheme(defaults.theme),
mode: normalizeMode(defaults.mode),
};
}
export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
const { fontFamily, fontSize, layout, wordWrap, background, theme } = defaults;
return {
fontFamily,
fontSize,
layout,
wordWrap,
background,
theme,
};
}
function normalizeFontFamily(fontFamily?: string): string {
const normalized = fontFamily?.trim();
return normalized || DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily;
}
function normalizeFontSize(fontSize?: number): number {
if (fontSize === undefined || !Number.isFinite(fontSize)) {
return DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize;
}
const rounded = Math.floor(fontSize);
return Math.min(Math.max(rounded, 10), 24);
}
function normalizeLayout(layout?: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : DEFAULT_DIFFS_TOOL_DEFAULTS.layout;
}
function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
}
function normalizeMode(mode?: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
}

View File

@@ -5,5 +5,6 @@ export const DIFFS_AGENT_GUIDANCE = [
"Use `mode=image` when you need a rendered PNG. The tool result includes `details.imagePath` for the generated file.",
"When you need to deliver the PNG to a user or channel, do not rely on the raw tool-result image renderer. Instead, call the `message` tool and pass `details.imagePath` through `path` or `filePath`.",
"Use `mode=both` when you want both the gateway viewer URL and the PNG artifact.",
"Good defaults: `theme=dark` for canvas rendering, `layout=unified` for most diffs, and include `path` for before/after text when you know the file name.",
"If the user has configured diffs plugin defaults, prefer omitting `mode`, `theme`, `layout`, and related presentation options unless you need to override them for this specific diff.",
"Include `path` for before/after text when you know the file name.",
].join("\n");

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
@@ -11,9 +12,8 @@ describe("renderDiffDocument", () => {
path: "src/example.ts",
},
{
layout: "unified",
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
expandUnchanged: false,
theme: "light",
},
);
@@ -48,9 +48,12 @@ describe("renderDiffDocument", () => {
title: "Workspace patch",
},
{
layout: "split",
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
layout: "split",
theme: "dark",
},
expandUnchanged: true,
theme: "dark",
},
);

View File

@@ -12,6 +12,10 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt";
function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
@@ -46,21 +50,25 @@ function resolveBeforeAfterFileName(input: Extract<DiffInput, { kind: "before_af
}
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
const fontFamily = escapeCssString(options.presentation.fontFamily);
const fontSize = Math.max(10, Math.floor(options.presentation.fontSize));
const lineHeight = Math.max(20, Math.round(fontSize * 1.6));
return {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: options.layout,
diffStyle: options.presentation.layout,
expandUnchanged: options.expandUnchanged,
themeType: options.theme,
overflow: "wrap" as const,
themeType: options.presentation.theme,
backgroundEnabled: options.presentation.background,
overflow: options.presentation.wordWrap ? "wrap" : "scroll",
unsafeCSS: `
:host {
--diffs-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-header-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-font-size: 15px;
--diffs-line-height: 24px;
--diffs-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-header-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-font-size: ${fontSize}px;
--diffs-line-height: ${lineHeight}px;
}
[data-diffs-header] {
@@ -166,7 +174,7 @@ function renderDiffCard(payload: DiffViewerPayload): string {
function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["theme"];
theme: DiffRenderOptions["presentation"]["theme"];
}): string {
return `<!doctype html>
<html lang="en">
@@ -341,7 +349,7 @@ export async function renderDiffDocument(
html: buildHtmlDocument({
title,
bodyHtml: rendered.bodyHtml,
theme: options.theme,
theme: options.presentation.theme,
}),
title,
fileCount: rendered.fileCount,

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffsTool } from "./tool.js";
@@ -23,6 +24,7 @@ describe("diffs tool", () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const result = await tool.execute?.("tool-1", {
@@ -49,6 +51,7 @@ describe("diffs tool", () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
@@ -69,6 +72,7 @@ describe("diffs tool", () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter: {
screenshotHtml: vi.fn(async () => {
throw new Error("browser missing");
@@ -91,6 +95,7 @@ describe("diffs tool", () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
await expect(
@@ -102,6 +107,66 @@ describe("diffs tool", () => {
}),
).rejects.toThrow("Invalid baseUrl");
});
it("uses configured defaults when tool params omit them", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "view",
theme: "light",
layout: "split",
wordWrap: false,
background: false,
fontFamily: "JetBrains Mono",
fontSize: 17,
},
});
const result = await tool.execute?.("tool-5", {
before: "one\n",
after: "two\n",
path: "README.md",
});
expect(readTextContent(result, 0)).toContain("Diff viewer ready.");
expect((result?.details as Record<string, unknown>).mode).toBe("view");
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="light"');
expect(html).toContain("--diffs-font-size: 17px;");
expect(html).toContain('--diffs-font-family: "JetBrains Mono"');
});
it("prefers explicit tool params over configured defaults", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "view",
theme: "light",
layout: "split",
},
});
const result = await tool.execute?.("tool-6", {
before: "one\n",
after: "two\n",
mode: "both",
theme: "dark",
layout: "unified",
});
expect((result?.details as Record<string, unknown>).mode).toBe("both");
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="dark"');
});
});
function createApi(): OpenClawPluginApi {

View File

@@ -4,6 +4,7 @@ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import type { DiffToolDefaults } from "./types.js";
import {
DIFF_LAYOUTS,
DIFF_MODES,
@@ -63,6 +64,7 @@ type DiffsToolParams = Static<typeof DiffsToolSchema>;
export function createDiffsTool(params: {
api: OpenClawPluginApi;
store: DiffArtifactStore;
defaults: DiffToolDefaults;
screenshotter?: DiffScreenshotter;
}): AnyAgentTool {
return {
@@ -74,16 +76,19 @@ export function createDiffsTool(params: {
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolParams;
const input = normalizeDiffInput(toolParams);
const mode = normalizeMode(toolParams.mode);
const theme = normalizeTheme(toolParams.theme);
const layout = normalizeLayout(toolParams.layout);
const mode = normalizeMode(toolParams.mode, params.defaults.mode);
const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
const rendered = await renderDiffDocument(input, {
layout,
presentation: {
...params.defaults,
layout,
theme,
},
expandUnchanged,
theme,
});
const artifact = await params.store.createArtifact({
@@ -218,16 +223,16 @@ function normalizeBaseUrl(baseUrl?: string): string | undefined {
}
}
function normalizeMode(mode?: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : "both";
function normalizeMode(mode: DiffMode | undefined, fallback: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : fallback;
}
function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : "dark";
function normalizeTheme(theme: DiffTheme | undefined, fallback: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : fallback;
}
function normalizeLayout(layout?: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : "unified";
function normalizeLayout(layout: DiffLayout | undefined, fallback: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : fallback;
}
function normalizeTtlMs(ttlSeconds?: number): number | undefined {

View File

@@ -8,6 +8,19 @@ export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
export type DiffMode = (typeof DIFF_MODES)[number];
export type DiffTheme = (typeof DIFF_THEMES)[number];
export type DiffPresentationDefaults = {
fontFamily: string;
fontSize: number;
layout: DiffLayout;
wordWrap: boolean;
background: boolean;
theme: DiffTheme;
};
export type DiffToolDefaults = DiffPresentationDefaults & {
mode: DiffMode;
};
export type BeforeAfterDiffInput = {
kind: "before_after";
before: string;
@@ -26,9 +39,8 @@ export type PatchDiffInput = {
export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
export type DiffRenderOptions = {
layout: DiffLayout;
presentation: DiffPresentationDefaults;
expandUnchanged: boolean;
theme: DiffTheme;
};
export type DiffViewerOptions = {
@@ -39,6 +51,7 @@ export type DiffViewerOptions = {
diffStyle: DiffLayout;
expandUnchanged: boolean;
themeType: DiffTheme;
backgroundEnabled: boolean;
overflow: "scroll" | "wrap";
unsafeCSS: string;
};

View File

@@ -281,6 +281,7 @@ async function hydrateViewer(): Promise<void> {
if (firstPayload) {
viewerState.theme = firstPayload.options.themeType;
viewerState.layout = firstPayload.options.diffStyle;
viewerState.backgroundEnabled = firstPayload.options.backgroundEnabled;
viewerState.wrapEnabled = firstPayload.options.overflow === "wrap";
}