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

@@ -54,6 +54,35 @@ Useful options:
- `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link
## Plugin Defaults
Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
```json5
{
plugins: {
entries: {
diffs: {
enabled: true,
config: {
defaults: {
fontFamily: "Fira Code",
fontSize: 15,
layout: "unified",
wordWrap: true,
background: true,
theme: "dark",
mode: "both",
},
},
},
},
},
}
```
Explicit tool parameters still win over these defaults.
## Example Agent Prompts
Open in canvas:

View File

@@ -1,4 +1,6 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
@@ -40,4 +42,82 @@ describe("diffs plugin registration", () => {
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
});
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
let registeredTool:
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
| undefined;
let registeredHttpHandler:
| ((
req: IncomingMessage,
res: ReturnType<typeof createMockServerResponse>,
) => Promise<boolean>)
| undefined;
plugin.register?.({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
theme: "light",
background: false,
layout: "split",
},
},
runtime: {} as never,
logger: {
info() {},
warn() {},
error() {},
},
registerTool(tool) {
registeredTool = typeof tool === "function" ? undefined : tool;
},
registerHook() {},
registerHttpHandler(handler) {
registeredHttpHandler = handler as typeof registeredHttpHandler;
},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
resolvePath(input: string) {
return input;
},
on() {},
});
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpHandler?.(
{
method: "GET",
url: viewerPath,
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="light"');
expect(String(res.body)).toContain('"backgroundEnabled":false');
expect(String(res.body)).toContain('"diffStyle":"split"');
});
});

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
import { diffsPluginConfigSchema, resolveDiffsPluginDefaults } from "./src/config.js";
import { createDiffsHttpHandler } from "./src/http.js";
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js";
@@ -10,14 +11,15 @@ const plugin = {
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG renderer for agents.",
configSchema: emptyPluginConfigSchema(),
configSchema: diffsPluginConfigSchema,
register(api: OpenClawPluginApi) {
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger,
});
api.registerTool(createDiffsTool({ api, store }));
api.registerTool(createDiffsTool({ api, store, defaults }));
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger }));
api.on("before_prompt_build", async () => ({
prependContext: DIFFS_AGENT_GUIDANCE,

View File

@@ -2,9 +2,79 @@
"id": "diffs",
"name": "Diffs",
"description": "Read-only diff viewer and image renderer for agents.",
"uiHints": {
"defaults.fontFamily": {
"label": "Default Font",
"help": "Preferred font family name for diff content and headers."
},
"defaults.fontSize": {
"label": "Default Font Size",
"help": "Base diff font size in pixels."
},
"defaults.layout": {
"label": "Default Layout",
"help": "Initial diff layout shown in the viewer."
},
"defaults.wordWrap": {
"label": "Default Word Wrap",
"help": "Wrap long lines by default."
},
"defaults.background": {
"label": "Default Background Highlights",
"help": "Show added/removed background highlights by default."
},
"defaults.theme": {
"label": "Default Theme",
"help": "Initial viewer theme."
},
"defaults.mode": {
"label": "Default Output Mode",
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"defaults": {
"type": "object",
"additionalProperties": false,
"properties": {
"fontFamily": {
"type": "string",
"default": "Fira Code"
},
"fontSize": {
"type": "number",
"minimum": 10,
"maximum": 24,
"default": 15
},
"layout": {
"type": "string",
"enum": ["unified", "split"],
"default": "unified"
},
"wordWrap": {
"type": "boolean",
"default": true
},
"background": {
"type": "boolean",
"default": true
},
"theme": {
"type": "string",
"enum": ["light", "dark"],
"default": "dark"
},
"mode": {
"type": "string",
"enum": ["view", "image", "both"],
"default": "both"
}
}
}
}
}
}

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("&", "&amp;")
@@ -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";
}