fix(ui): cap tool output + sync config form

This commit is contained in:
Peter Steinberger
2026-01-08 04:24:27 +00:00
parent b86a5c94ae
commit b4f85968c9
6 changed files with 117 additions and 14 deletions

View File

@@ -51,6 +51,9 @@
- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
- Web UI: add Connect button on Overview to apply connection changes. Thanks @wizaj for PR #385.
- Web UI: keep Focus toggle on the top bar (swap with theme toggle) so it stays visible. Thanks @RobOK2050 for reporting. (#440)
- Web UI: add Logs tab for gateway file logs with filtering, auto-follow, and export.
- Web UI: cap tool output + large markdown rendering to avoid UI freezes on huge tool results.
- Web UI: keep config form edits synced to raw JSON so form saves persist.
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.

View File

@@ -17,6 +17,7 @@ import {
type ResolvedTheme,
type ThemeMode,
} from "./theme";
import { truncateText } from "./format";
import {
startThemeTransition,
type ThemeTransitionContext,
@@ -88,6 +89,7 @@ type EventLogEntry = {
};
const TOOL_STREAM_LIMIT = 50;
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
trace: true,
debug: true,
@@ -138,17 +140,25 @@ function extractToolOutputText(value: unknown): string | null {
function formatToolOutput(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
const contentText = extractToolOutputText(value);
if (contentText) return contentText;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
let text: string;
if (typeof value === "string") {
text = value;
} else if (contentText) {
text = contentText;
} else {
try {
text = JSON.stringify(value, null, 2);
} catch {
text = String(value);
}
}
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
if (!truncated.truncated) return truncated.text;
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
}
declare global {

View File

@@ -145,6 +145,25 @@ describe("applyConfigSnapshot", () => {
expect(state.slackForm.botToken).toBe("");
expect(state.slackForm.actions).toEqual(defaultSlackActions);
});
it("does not clobber form edits while dirty", () => {
const state = createState();
state.configFormMode = "form";
state.configFormDirty = true;
state.configForm = { gateway: { mode: "local", port: 18789 } };
state.configRaw = "{\n}\n";
applyConfigSnapshot(state, {
config: { gateway: { mode: "remote", port: 9999 } },
valid: true,
issues: [],
raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n",
});
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
);
});
});
describe("updateConfigFormValue", () => {
@@ -165,6 +184,22 @@ describe("updateConfigFormValue", () => {
gateway: { mode: "local", port: 18789 },
});
});
it("keeps raw in sync while editing the form", () => {
const state = createState();
state.configSnapshot = {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: "{\n}\n",
};
updateConfigFormValue(state, ["gateway", "port"], 18789);
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
);
});
});
describe("applyConfig", () => {

View File

@@ -92,10 +92,18 @@ export function applyConfigSchema(
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
state.configSnapshot = snapshot;
if (typeof snapshot.raw === "string") {
state.configRaw = snapshot.raw;
} else if (snapshot.config && typeof snapshot.config === "object") {
state.configRaw = `${JSON.stringify(snapshot.config, null, 2).trimEnd()}\n`;
const rawFromSnapshot =
typeof snapshot.raw === "string"
? snapshot.raw
: snapshot.config && typeof snapshot.config === "object"
? serializeConfigForm(snapshot.config as Record<string, unknown>)
: state.configRaw;
if (!state.configFormDirty || state.configFormMode === "raw") {
state.configRaw = rawFromSnapshot;
} else if (state.configForm) {
state.configRaw = serializeConfigForm(state.configForm);
} else {
state.configRaw = rawFromSnapshot;
}
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
@@ -388,7 +396,7 @@ export async function saveConfig(state: ConfigState) {
try {
const raw =
state.configFormMode === "form" && state.configForm
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
? serializeConfigForm(state.configForm)
: state.configRaw;
await state.client.request("config.set", { raw });
state.configFormDirty = false;
@@ -407,7 +415,7 @@ export async function applyConfig(state: ConfigState) {
try {
const raw =
state.configFormMode === "form" && state.configForm
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
? serializeConfigForm(state.configForm)
: state.configRaw;
await state.client.request("config.apply", {
raw,
@@ -448,6 +456,9 @@ export function updateConfigFormValue(
setPathValue(base, path, value);
state.configForm = base;
state.configFormDirty = true;
if (state.configFormMode === "form") {
state.configRaw = serializeConfigForm(base);
}
}
export function removeConfigFormValue(
@@ -460,6 +471,9 @@ export function removeConfigFormValue(
removePathValue(base, path);
state.configForm = base;
state.configFormDirty = true;
if (state.configFormMode === "form") {
state.configRaw = serializeConfigForm(base);
}
}
function cloneConfigObject<T>(value: T): T {
@@ -469,6 +483,10 @@ function cloneConfigObject<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function serializeConfigForm(form: Record<string, unknown>): string {
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
}
function setPathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,

View File

@@ -40,6 +40,21 @@ export function clampText(value: string, max = 120): string {
return `${value.slice(0, Math.max(0, max - 1))}`;
}
export function truncateText(value: string, max: number): {
text: string;
truncated: boolean;
total: number;
} {
if (value.length <= max) {
return { text: value, truncated: false, total: value.length };
}
return {
text: value.slice(0, Math.max(0, max)),
truncated: true,
total: value.length,
};
}
export function toNumber(value: string, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
@@ -51,4 +66,3 @@ export function parseList(input: string): string[] {
.map((v) => v.trim())
.filter((v) => v.length > 0);
}

View File

@@ -1,5 +1,6 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { truncateText } from "./format";
marked.setOptions({
gfm: true,
@@ -39,6 +40,8 @@ const allowedTags = [
const allowedAttrs = ["class", "href", "rel", "target", "title"];
let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000;
const MARKDOWN_PARSE_LIMIT = 40_000;
function installHooks() {
if (hooksInstalled) return;
@@ -57,10 +60,30 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim();
if (!input) return "";
installHooks();
const rendered = marked.parse(input) as string;
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
const suffix = truncated.truncated
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
: "";
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
const escaped = escapeHtml(`${truncated.text}${suffix}`);
const html = `<pre class="code-block">${escaped}</pre>`;
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
}
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
return DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}