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

View File

@@ -145,6 +145,25 @@ describe("applyConfigSnapshot", () => {
expect(state.slackForm.botToken).toBe(""); expect(state.slackForm.botToken).toBe("");
expect(state.slackForm.actions).toEqual(defaultSlackActions); 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", () => { describe("updateConfigFormValue", () => {
@@ -165,6 +184,22 @@ describe("updateConfigFormValue", () => {
gateway: { mode: "local", port: 18789 }, 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", () => { describe("applyConfig", () => {

View File

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

View File

@@ -1,5 +1,6 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { marked } from "marked"; import { marked } from "marked";
import { truncateText } from "./format";
marked.setOptions({ marked.setOptions({
gfm: true, gfm: true,
@@ -39,6 +40,8 @@ const allowedTags = [
const allowedAttrs = ["class", "href", "rel", "target", "title"]; const allowedAttrs = ["class", "href", "rel", "target", "title"];
let hooksInstalled = false; let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000;
const MARKDOWN_PARSE_LIMIT = 40_000;
function installHooks() { function installHooks() {
if (hooksInstalled) return; if (hooksInstalled) return;
@@ -57,10 +60,30 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim(); const input = markdown.trim();
if (!input) return ""; if (!input) return "";
installHooks(); 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, { return DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: allowedTags, ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs, 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;");
}