From 838a1c0191dc5733d3561d95f62e4b99326adf41 Mon Sep 17 00:00:00 2001 From: "Syring, Nikolas" Date: Sat, 25 Apr 2026 16:29:15 +0200 Subject: [PATCH] fix(widgets): include change magnitude in patch/render status text Tool results from patchWidget, renderWidget, and upsertWidget previously returned the same one-line success status regardless of whether the call replaced two characters or rewrote the entire renderer. The agent could not tell from the status whether its action was proportional to the request, and a reviewer reading the chat transcript could not spot scope creep at a glance. This change captures the renderer source size before the write, exposes prior and next renderer line counts as structured numeric fields on the write result envelope, and renders them as a magnitude fragment in the existing status string between the verb and the render-status fragment: - "patched, 353 renderer lines (was 351, +2), rendered ok, ..." - "saved, 296 renderer lines (was 351, -55), rendered ok, ..." - "saved, 50 renderer lines, rendered ok, ..." (new widget, no prior) - "patched, 351 renderer lines, rendered ok, ..." (no length change) - "reloaded, rendered ok, ..." (unchanged) Implementation: - spaces/storage.js extracts the dedent + LF-normalize + split body of getWidgetRendererReadLines into shared helpers (getRendererSourceReadLines, countRendererSourceReadLines) so a raw renderer source string can produce the same line count the agent derives from the numbered renderer readback. The line counts in the status are guaranteed to match the highest line index visible in widgetText. - buildWidgetWriteResult(spaceRecord, widgetId, priorRendererSource) accepts the captured pre-write source and exposes priorRendererLineCount and nextRendererLineCount on the result envelope as structured numbers. Full source strings are not exposed. - patchWidget and upsertWidget thread the existing source through. - spaces/store.js adds formatWidgetOperationChangeMagnitude that renders the fragment with ASCII +/- only, returns empty string when counts are missing so callers without source mutation (reloadWidget, removeWidget) keep their existing status text byte-identical. - formatWidgetOperationStatusText accepts priorLineCount and nextLineCount options and inserts the magnitude fragment between the verb and the render-status fragment, preserving the existing substrings consumers grep on. The magnitude is observability, not policy: existing skill rules continue to own when patchWidget vs renderWidget is appropriate. The fragment just gives the agent and reviewer the same data a Senior Dev applies in code review. Skill rules and eval harnesses that want to react programmatically can read the structured priorRendererLineCount / nextRendererLineCount fields without parsing the status string. --- app/L0/_all/mod/_core/spaces/storage.js | 40 +++++++++++++++++---- app/L0/_all/mod/_core/spaces/store.js | 48 +++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/app/L0/_all/mod/_core/spaces/storage.js b/app/L0/_all/mod/_core/spaces/storage.js index 853f8b45..661ef25d 100644 --- a/app/L0/_all/mod/_core/spaces/storage.js +++ b/app/L0/_all/mod/_core/spaces/storage.js @@ -596,11 +596,23 @@ function buildWidgetMetadataLines(widgetRecord) { function getWidgetRendererReadLines(widgetRecord) { const normalizedWidget = normalizeWidgetRecord(widgetRecord, widgetRecord); - return dedentMultilineText(normalizedWidget.rendererSource) + return getRendererSourceReadLines(normalizedWidget.rendererSource); +} + +function getRendererSourceReadLines(rendererSource) { + return dedentMultilineText(typeof rendererSource === "string" ? rendererSource : "") .replace(/\r\n?/gu, "\n") .split("\n"); } +function countRendererSourceReadLines(rendererSource) { + if (typeof rendererSource !== "string" || !rendererSource.length) { + return 0; + } + + return getRendererSourceReadLines(rendererSource).length; +} + function formatWidgetRecordForRead(widgetRecord) { const rendererLines = getWidgetRendererReadLines(widgetRecord); return [ @@ -956,15 +968,29 @@ function applyPatchedWidgetAttributes(widgetRecord, options = {}) { ); } -function buildWidgetWriteResult(spaceRecord, widgetId) { +function buildWidgetWriteResult(spaceRecord, widgetId, priorRendererSource = null) { const widgetRecord = spaceRecord?.widgets?.[widgetId]; - - return { + const result = { space: spaceRecord, widgetId, widgetPath: buildSpaceWidgetFilePath(spaceRecord.id, widgetId), widgetText: widgetRecord ? formatWidgetRecordForRead(widgetRecord) : "" }; + + // Expose line counts (not full source strings) so consumers can report + // change magnitude in tool result status without parsing renderer text. + // Both counts use the same dedent + LF-normalize + split path that + // formatWidgetRecordForRead emits, so the numbers always match what the + // agent counts in the numbered renderer readback. + if (typeof priorRendererSource === "string") { + result.priorRendererLineCount = countRendererSourceReadLines(priorRendererSource); + } + + if (typeof widgetRecord?.rendererSource === "string") { + result.nextRendererLineCount = countRendererSourceReadLines(widgetRecord.rendererSource); + } + + return result; } function buildWidgetWriteResults(spaceRecord, widgetIds = []) { @@ -2056,6 +2082,7 @@ export async function upsertWidget(options = {}) { const currentSpace = cloneSpaceRecord(await readSpace(spaceId)); const widgetFallbackId = normalizeWidgetId(options.widgetId || options.id || options.name || options.title || "widget"); const existingWidget = currentSpace.widgets[widgetFallbackId] || null; + const priorRendererSource = typeof existingWidget?.rendererSource === "string" ? existingWidget.rendererSource : null; const widgetRecord = validateWidgetRendererSourceForWrite( previewWidgetRecord(options, { ...existingWidget, @@ -2103,7 +2130,7 @@ export async function upsertWidget(options = {}) { await runtime.api.fileWrite({ files }); clearRecentListedSpaceRecords(); - return buildWidgetWriteResult(nextSpace, widgetId); + return buildWidgetWriteResult(nextSpace, widgetId, priorRendererSource); } export async function upsertWidgets(options = {}) { @@ -2219,6 +2246,7 @@ export async function patchWidget(options = {}) { throw new Error(`Cannot patch widget "${widgetId}": widget not found in space "${spaceId}".`); } + const priorRendererSource = typeof currentWidget.rendererSource === "string" ? currentWidget.rendererSource : null; const patchedRendererSource = applyWidgetPatchEdits(currentWidget, options.edits ?? options.lineEdits); const nextWidget = validateWidgetRendererSourceForWrite( applyPatchedWidgetAttributes( @@ -2251,7 +2279,7 @@ export async function patchWidget(options = {}) { await runtime.api.fileWrite({ files }); clearRecentListedSpaceRecords(); - return buildWidgetWriteResult(nextSpace, widgetId); + return buildWidgetWriteResult(nextSpace, widgetId, priorRendererSource); } export async function removeWidget(options = {}) { diff --git a/app/L0/_all/mod/_core/spaces/store.js b/app/L0/_all/mod/_core/spaces/store.js index 84d61cfe..a4954193 100644 --- a/app/L0/_all/mod/_core/spaces/store.js +++ b/app/L0/_all/mod/_core/spaces/store.js @@ -1017,21 +1017,57 @@ function getWidgetOperationStatusVerb(operationLabel) { } } +// Render the change magnitude for a widget write. The line counts come from +// `buildWidgetWriteResult` in spaces/storage.js, which uses the same +// dedent + LF-normalize + split path that emits the numbered renderer +// readback the agent sees in `widgetText`. So the count printed here always +// matches the highest line index the agent counts in the readback. +function formatWidgetOperationChangeMagnitude({ priorLineCount, nextLineCount } = {}) { + const hasPrior = Number.isFinite(priorLineCount); + const hasNext = Number.isFinite(nextLineCount); + + if (!hasPrior && !hasNext) { + return ""; + } + + const before = hasPrior ? Math.max(0, priorLineCount) : 0; + const after = hasNext ? Math.max(0, nextLineCount) : 0; + + if (!hasPrior) { + return `${after} renderer line${after === 1 ? "" : "s"}`; + } + + if (!hasNext || !after) { + return `0 renderer lines (was ${before})`; + } + + const delta = after - before; + + if (delta === 0) { + return `${after} renderer lines`; + } + + const sign = delta > 0 ? "+" : "-"; + return `${after} renderer lines (was ${before}, ${sign}${Math.abs(delta)})`; +} + function formatWidgetOperationStatusText(widgetId, operationLabel, widgetRender, options = {}) { const check = cloneWidgetRenderCheck(widgetRender, widgetId); const verb = getWidgetOperationStatusVerb(operationLabel); const targetLabel = widgetId ? `Widget "${widgetId}"` : "Widget"; const suffix = options.transientUpdated ? "loaded to TRANSIENT." : "done."; + const magnitude = formatWidgetOperationChangeMagnitude(options); + const magnitudeFragment = magnitude ? `, ${magnitude}` : ""; if (check.status === "error") { - return `${targetLabel} ${verb}, render failed, ${suffix}`; + return `${targetLabel} ${verb}${magnitudeFragment}, render failed, ${suffix}`; } if (check.status === "ok") { - return `${targetLabel} ${verb}, rendered ok, ${suffix}`; + return `${targetLabel} ${verb}${magnitudeFragment}, rendered ok, ${suffix}`; } - return `${targetLabel} ${verb}, not live-tested, ${suffix}`; + return `${targetLabel} ${verb}${magnitudeFragment}, not live-tested, ${suffix}`; } function extractWidgetIdFromWidgetText(widgetText) { @@ -1271,7 +1307,13 @@ async function buildWidgetToolResult( widgetText }) : false; + const priorRendererLineCount = + Number.isFinite(nextResult.priorRendererLineCount) ? nextResult.priorRendererLineCount : null; + const nextRendererLineCount = + Number.isFinite(nextResult.nextRendererLineCount) ? nextResult.nextRendererLineCount : null; const widgetStatusText = formatWidgetOperationStatusText(normalizedWidgetId, operationLabel, widgetRender, { + nextLineCount: nextRendererLineCount, + priorLineCount: priorRendererLineCount, transientUpdated });