@kingsoftcloud/agentkit-harness-ui 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +116 -0
- package/README.md +292 -2
- package/markdown/build/build-markdown.cjs +66 -0
- package/markdown/dist/THIRD_PARTY_LICENSES.txt +2993 -0
- package/markdown/dist/markdown.css +1 -0
- package/markdown/dist/markdown.mjs +325 -0
- package/markdown/source/markdown-factory.mjs +613 -0
- package/markdown/source/markdown.css +288 -0
- package/markdown/source/table-factory.mjs +1588 -0
- package/markdown/source/table.css +487 -0
- package/package.json +96 -4
- package/src/activity-groups.mjs +74 -0
- package/src/browser-response-groups.mjs +57 -0
- package/src/command-output.mjs +57 -0
- package/src/components.mjs +9 -0
- package/src/context-markers.mjs +19 -0
- package/src/context-status.mjs +34 -0
- package/src/diff.mjs +240 -0
- package/src/explore-tool-call.mjs +139 -0
- package/src/file-preview.mjs +50 -0
- package/src/host/native-a2a.mjs +90 -0
- package/src/host/native-child-file-pane.mjs +49 -0
- package/src/host/native-child-pane.mjs +94 -0
- package/src/host/native-command-pane.mjs +64 -0
- package/src/host/native-run-panel.mjs +202 -0
- package/src/host/session-task-events.mjs +247 -0
- package/src/host/state/native-a2a.mjs +122 -0
- package/src/host/state/native-child-observation.mjs +35 -0
- package/src/host/state/native-children.mjs +93 -0
- package/src/host/state/native-outcome.mjs +35 -0
- package/src/host/state/native-recovery.mjs +187 -0
- package/src/host/state/native-runtime-route.mjs +26 -0
- package/src/host/state/native-session-stop.mjs +40 -0
- package/src/host/state/native-status-panel.mjs +23 -0
- package/src/host/state/native-task-read-model.mjs +89 -0
- package/src/host/state/task-links.mjs +40 -0
- package/src/host/task-timeline.mjs +185 -0
- package/src/index.mjs +52 -0
- package/src/input-preview.mjs +44 -0
- package/src/inspector.mjs +40 -0
- package/src/project.mjs +132 -0
- package/src/protocol.mjs +362 -0
- package/src/react-binding.mjs +486 -0
- package/src/reducer.mjs +355 -0
- package/src/renderers.mjs +644 -0
- package/src/status-panel.mjs +1156 -0
- package/src/styles.mjs +278 -0
- package/src/summary-playback.mjs +39 -0
- package/src/summary.mjs +160 -0
- package/src/terminal.mjs +157 -0
- package/src/tool-detail.mjs +208 -0
- package/src/tool-error.mjs +33 -0
- package/src/tool-identity.mjs +40 -0
- package/src/view.mjs +364 -0
- package/src/work-segments.mjs +41 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui — tool-call renderer registry
|
|
2
|
+
//
|
|
3
|
+
// Replaces the single generic tool block with a registry of purpose-built
|
|
4
|
+
// renderers that mirror the ZCode DESKTOP task conversation:
|
|
5
|
+
// - execute (terminal): `$` prompt + bounded command, live output with
|
|
6
|
+
// stdout/stderr/exit/cwd, safe-ANSI plain text, copy + open-terminal actions
|
|
7
|
+
// - read: a file chip (icon + name) that opens the file via a host callback,
|
|
8
|
+
// not toggleable
|
|
9
|
+
// - edit: file chips, +N/-N change count, inline diff with REAL line numbers
|
|
10
|
+
// from the patch hunk headers, unified + split responsive, bounded preview
|
|
11
|
+
// with a full-retrieval callback; never infers a write succeeded from input
|
|
12
|
+
// - default: the bounded, redacted generic detail block for unknown/MCP tools
|
|
13
|
+
//
|
|
14
|
+
// Every renderer returns a function-free view-model fragment (plain `h` nodes)
|
|
15
|
+
// carrying host actions as `data-action`/`data-*` attributes. The React binding
|
|
16
|
+
// delegates a single click handler on the root and dispatches to the host
|
|
17
|
+
// callbacks, so the tree stays serializable and testable with node:test.
|
|
18
|
+
|
|
19
|
+
import { h, terminalIcon, fileTextIcon, searchIcon, pencilIcon, wrenchIcon, chevronIcon, skillIcon, globeIcon } from "./view.mjs";
|
|
20
|
+
import { ToolStatus } from "./protocol.mjs";
|
|
21
|
+
import { toolTitle, toolDetailLines, workspaceRelative, redactError } from "./tool-detail.mjs";
|
|
22
|
+
import { truncateText, latestNonEmptyLine, redactValue, boundedDetailLines } from "./summary.mjs";
|
|
23
|
+
import { parseDiffHunks, countChangeStat, inferOperationKind } from "./diff.mjs";
|
|
24
|
+
import { extractCommand, extractOutput } from "./terminal.mjs";
|
|
25
|
+
import { toolIdentity, localizedToolTitle } from './tool-identity.mjs';
|
|
26
|
+
import { toolErrorSummary } from './tool-error.mjs';
|
|
27
|
+
|
|
28
|
+
/** Tool-name → renderer classification. Host tool names vary across runtimes. */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pick a renderer for a tool name. Unknown names fall through to the default
|
|
32
|
+
* bounded detail block so MCP and future tools still render safely.
|
|
33
|
+
* @param {string} name
|
|
34
|
+
* @returns {"execute"|"read"|"edit"|"default"}
|
|
35
|
+
*/
|
|
36
|
+
export function toolRendererKind(name) {
|
|
37
|
+
const family = toolIdentity(name).family;
|
|
38
|
+
if (family === 'agent' || family === 'execute') return family;
|
|
39
|
+
if (['file', 'list', 'search'].includes(family)) return 'read';
|
|
40
|
+
if (['edit', 'write', 'delete'].includes(family)) return 'edit';
|
|
41
|
+
return 'default';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** "read" splits internally into a file read vs a search/list summary. */
|
|
45
|
+
function readSubKind(name, input) {
|
|
46
|
+
if (['list', 'search'].includes(toolIdentity(name).family)) return "search";
|
|
47
|
+
// A file_read display or an explicit file path ⇒ treat as a file read even
|
|
48
|
+
// when the host reused a generic tool name.
|
|
49
|
+
return "file";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a tool item by dispatching to the matching renderer. Returns a
|
|
54
|
+
* function-free view-model fragment rooted at a `tool-block` section.
|
|
55
|
+
* @param {object} item reducer tool item
|
|
56
|
+
* @param {object} opts view options (workspaceRoot, labels, diffMaxLines, toolOpen, callbacks)
|
|
57
|
+
*/
|
|
58
|
+
export function renderTool(item, opts) {
|
|
59
|
+
// An argument draft shares the tool's presentation family, but has no call
|
|
60
|
+
// authority, result or host actions. Never turn it into a scheduled tool.
|
|
61
|
+
if (item.kind === 'tool-preview') {
|
|
62
|
+
item = { kind: item.kind, id: item.id, name: item.name, input: item.input, streaming: item.streaming };
|
|
63
|
+
opts = { ...opts, onOpenFile: undefined, onOpenTerminal: undefined, onRetrieveFullDiff: undefined,
|
|
64
|
+
onOpenSubagent: undefined, onInspectTool: undefined, onCopy: undefined };
|
|
65
|
+
}
|
|
66
|
+
if (item.child) return agentRenderer(item, opts);
|
|
67
|
+
switch (toolRendererKind(item.name)) {
|
|
68
|
+
case "agent": return agentRenderer(item, opts);
|
|
69
|
+
case "execute": return executeRenderer(item, opts);
|
|
70
|
+
case "read": return readRenderer(item, opts);
|
|
71
|
+
case "edit": return editRenderer(item, opts);
|
|
72
|
+
default: return defaultRenderer(item, opts);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const toolIsLive = item => item.kind === 'tool-preview' ? item.streaming === true
|
|
77
|
+
: item.status === ToolStatus.Running || item.status === ToolStatus.Scheduled;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Common shell that owns the compact summary row (icon + title/content +
|
|
81
|
+
* status + disclosure chevron) and the error placement. Mirrors the desktop
|
|
82
|
+
* ToolLayout/ToolSummaryRow: a static kind icon, the kind/title text (which
|
|
83
|
+
* takes the running gradient while streaming — applied by each renderer on its
|
|
84
|
+
* own title span), the primary/secondary content, the status word, and a
|
|
85
|
+
* chevron that rotates when the body is open.
|
|
86
|
+
*
|
|
87
|
+
* Error placement preserves the reason for non-toggleable tools too: a read or
|
|
88
|
+
* search that cannot expand still surfaces its redacted error inline in the
|
|
89
|
+
* summary (role=alert), so the failure is never hidden behind a missing toggle.
|
|
90
|
+
*/
|
|
91
|
+
function toolShell(item, opts, { icon, summary, body, canToggle = true, defaultOpen, actions = [], summaryAction }) {
|
|
92
|
+
const status = item.status;
|
|
93
|
+
const provisional = item.kind === 'tool-preview';
|
|
94
|
+
const streaming = toolIsLive(item);
|
|
95
|
+
if (provisional) { canToggle = false; summaryAction = undefined; actions = []; }
|
|
96
|
+
const error = status === ToolStatus.Failed && item.error ? redactError(item.error) : null;
|
|
97
|
+
if (error && !canToggle) { canToggle = true; defaultOpen = false; }
|
|
98
|
+
const open = resolveOpen(item, opts, defaultOpen == null ? streaming : defaultOpen, canToggle);
|
|
99
|
+
const summaryKids = [];
|
|
100
|
+
if (icon && opts.showToolIcon !== false) summaryKids.push(icon);
|
|
101
|
+
if (Array.isArray(summary)) summaryKids.push(...summary);
|
|
102
|
+
else if (summary) summaryKids.push(summary);
|
|
103
|
+
const errorSummary = error ? toolErrorSummary(item.error, opts) : null;
|
|
104
|
+
// Non-toggleable tools have no body to expand into, so their error must live
|
|
105
|
+
// in the summary; toggleable tools show it inline only when collapsed.
|
|
106
|
+
const showInlineError = error && (canToggle ? !open : true);
|
|
107
|
+
if (showInlineError) summaryKids.push(h("span", {
|
|
108
|
+
className: "harness-tool-error harness-tool-error-inline",
|
|
109
|
+
role: "alert",
|
|
110
|
+
"data-testid": "tool-error",
|
|
111
|
+
}, errorSummary));
|
|
112
|
+
if ([ToolStatus.Failed, ToolStatus.Uncertain, ToolStatus.Interrupted, ToolStatus.Waiting, ToolStatus.NotDispatched].includes(status)) summaryKids.push(h("span", {
|
|
113
|
+
className: `harness-tool-status harness-tool-status-${status}`,
|
|
114
|
+
"data-testid": "tool-status",
|
|
115
|
+
"data-status": status,
|
|
116
|
+
}, statusLabel(status, opts)));
|
|
117
|
+
for (const action of actions) {
|
|
118
|
+
if (action) summaryKids.push(action);
|
|
119
|
+
}
|
|
120
|
+
if (canToggle) summaryKids.push(chevronIcon("harness-tool-chevron"));
|
|
121
|
+
const bodyKids = Array.isArray(body) ? body : [body];
|
|
122
|
+
if (error && open && canToggle) bodyKids.push(h("div", {
|
|
123
|
+
className: "harness-tool-error", role: "alert", "data-testid": "tool-error",
|
|
124
|
+
}, error));
|
|
125
|
+
const detailsProps = canToggle ? {
|
|
126
|
+
open, className: "harness-tool-details", "data-testid": "tool-details",
|
|
127
|
+
"data-call-id": item.callId, "aria-expanded": open,
|
|
128
|
+
} : {
|
|
129
|
+
className: "harness-tool-details harness-tool-details-static",
|
|
130
|
+
"data-testid": "tool-details", "data-call-id": item.callId, "data-static": "true",
|
|
131
|
+
};
|
|
132
|
+
return h("section", {
|
|
133
|
+
className: `harness-tool harness-tool-${toolRendererKind(item.name)}`,
|
|
134
|
+
"data-testid": "tool-block",
|
|
135
|
+
"data-call-id": item.callId,
|
|
136
|
+
...(provisional ? { 'data-item-id': item.id, 'data-preview-id': item.id, 'data-uncommitted': 'true',
|
|
137
|
+
'aria-label': streaming ? opts.labels?.toolPreparing || 'Generating tool arguments'
|
|
138
|
+
: opts.labels?.previewStopped || 'Argument generation interrupted' } : {}),
|
|
139
|
+
"data-status": status,
|
|
140
|
+
"data-renderer": toolRendererKind(item.name),
|
|
141
|
+
"data-streaming": streaming ? "true" : "false",
|
|
142
|
+
}, h(canToggle ? "details" : "div", detailsProps,
|
|
143
|
+
h(canToggle ? "summary" : summaryAction ? "button" : "div", { className: "harness-tool-summary", "data-testid": canToggle ? "tool-toggle" : summaryAction ? "subagent-summary" : "tool-summary-static", ...(summaryAction ?? {}) }, ...summaryKids),
|
|
144
|
+
...(canToggle ? bodyKids : [])));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function agentRenderer(item, opts) {
|
|
148
|
+
const child = item.child;
|
|
149
|
+
const running = toolIsLive(item);
|
|
150
|
+
const title = child?.title || item.input?.description || item.input?.task || item.input?.prompt || item.name;
|
|
151
|
+
const activity = running && child?.activity && toolRendererKind(child.activity.name) === 'read' ? child.activity : null;
|
|
152
|
+
const activityPath = activity && readMeta(activity).path;
|
|
153
|
+
const primary = activityPath ? `${opts.labels.readKind || 'Read'} ${leafName(activityPath)}` : title;
|
|
154
|
+
const role = child?.role || item.input?.role || item.input?.subagent_type || '';
|
|
155
|
+
let hash = 0; for (let i = 0; i < role.length; i++) hash = (hash * 31 + role.charCodeAt(i)) >>> 0;
|
|
156
|
+
const colors = ['yellow', 'red', 'orange', 'green', 'cyan', 'blue', 'purple', 'pink'];
|
|
157
|
+
const icon = h('svg', { className: 'harness-icon harness-tool-kind-icon', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
158
|
+
h('path', { d: 'M12 8V4H8' }), h('rect', { width: 16, height: 12, x: 4, y: 8, rx: 2 }),
|
|
159
|
+
h('path', { d: 'M2 14h2M20 14h2M15 13v2M9 13v2' }));
|
|
160
|
+
return toolShell(item, opts, { icon, canToggle: false, body: [],
|
|
161
|
+
summaryAction: child && opts.onOpenSubagent ? { type: 'button', 'data-action': 'open-subagent', 'data-call-id': item.callId,
|
|
162
|
+
'aria-label': opts.labels.openSubagent || 'Open on the right', title: String(title).slice(0, 512) } : undefined,
|
|
163
|
+
summary: [h('span', { className: running ? 'harness-tool-title harness-tool-title-streaming' : 'harness-tool-title' }, opts.labels.subagent || 'SubAgent'),
|
|
164
|
+
role ? h('span', { className: 'harness-agent-name', 'data-color': colors[hash % colors.length] }, role) : null,
|
|
165
|
+
h('span', { className: 'harness-agent-separator', 'aria-hidden': true }, '·'),
|
|
166
|
+
h('span', { className: 'harness-agent-title' }, String(primary).slice(0, 512))] });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveOpen(item, opts, defaultOpen, canToggle) {
|
|
170
|
+
if (!canToggle) return true;
|
|
171
|
+
if (opts.toolOpen && opts.toolOpen.has(item.callId)) return Boolean(opts.toolOpen.get(item.callId));
|
|
172
|
+
return defaultOpen;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function statusLabel(status, opts) {
|
|
176
|
+
const l = opts.labels || {};
|
|
177
|
+
switch (status) {
|
|
178
|
+
case "scheduled": return l.toolScheduled || "queued";
|
|
179
|
+
case "running": return l.toolRunning || "running";
|
|
180
|
+
case "completed": return l.toolCompleted || "done";
|
|
181
|
+
case "failed": return l.toolFailed || "failed";
|
|
182
|
+
case "not_dispatched": return l.toolNotDispatched || (String(opts.locale ?? '').startsWith('zh') ? '未执行' : 'not executed');
|
|
183
|
+
case "uncertain": return l.toolUncertain || "outcome unknown";
|
|
184
|
+
case "interrupted": return l.interrupted || "interrupted";
|
|
185
|
+
case "waiting": return l.waiting || "waiting";
|
|
186
|
+
default: return status;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── execute / terminal ──────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function executeRenderer(item, opts) {
|
|
193
|
+
const command = extractCommand(item.input);
|
|
194
|
+
const running = toolIsLive(item);
|
|
195
|
+
const out = extractOutput(item.result, item.display);
|
|
196
|
+
// cwd: prefer the result display, fall back to the input the host scheduled.
|
|
197
|
+
const cwd = out.cwd || (typeof item.input?.cwd === "string" ? item.input.cwd.trim() : "");
|
|
198
|
+
// One extractor handles both normalized event displays (fullText) and
|
|
199
|
+
// Host observation snapshots (stdout/stderr). An empty alias must not hide
|
|
200
|
+
// real streaming bytes supplied through the other supported representation.
|
|
201
|
+
const stdout = out.stdout;
|
|
202
|
+
const hasOutput = Boolean(stdout || out.stderr || out.exit != null);
|
|
203
|
+
const title = localizedToolTitle(item.name, toolTitle(item.name, item.input) || item.name, opts.locale);
|
|
204
|
+
const summary = [h("span", {
|
|
205
|
+
className: running
|
|
206
|
+
? "harness-tool-title harness-tool-title-streaming"
|
|
207
|
+
: "harness-tool-title",
|
|
208
|
+
"data-testid": "tool-title",
|
|
209
|
+
}, title)];
|
|
210
|
+
if (command) summary.push(h("code", {
|
|
211
|
+
className: "harness-tool-command-summary",
|
|
212
|
+
"data-testid": "tool-command-summary",
|
|
213
|
+
title: command,
|
|
214
|
+
}, truncateText(command, { maxLines: 1, maxChars: 80 })));
|
|
215
|
+
|
|
216
|
+
const body = [commandBlock(item.callId, command, cwd, opts),
|
|
217
|
+
outputBlock(item.callId, { stdout, stderr: out.stderr, exit: out.exit, cwd, running, hasOutput }, opts)];
|
|
218
|
+
// A host progress message (e.g. "running 4 tests") is shown as a live status
|
|
219
|
+
// line under the command, above the output. The reducer never invents it.
|
|
220
|
+
if (item.progress && item.status !== ToolStatus.Completed) {
|
|
221
|
+
body.splice(1, 0, h("div", {
|
|
222
|
+
className: "harness-tool-progress",
|
|
223
|
+
role: "status",
|
|
224
|
+
"data-testid": "tool-progress",
|
|
225
|
+
}, item.progress));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return toolShell(item, opts, { icon: terminalIcon("harness-tool-kind-icon"), summary, body, canToggle: true, defaultOpen: false });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function commandBlock(callId, command, cwd, opts) {
|
|
232
|
+
return h("div", {
|
|
233
|
+
className: "harness-execute-command",
|
|
234
|
+
"data-testid": "execute-command",
|
|
235
|
+
"data-call-id": callId,
|
|
236
|
+
"data-expand-label": opts.labels?.expandCommand || "Expand command",
|
|
237
|
+
"data-collapse-label": opts.labels?.collapseCommand || "Collapse command",
|
|
238
|
+
}, h("div", { className: "harness-execute-command-row" },
|
|
239
|
+
h("span", { className: "harness-execute-prompt", "data-testid": "execute-command-prompt", "aria-hidden": "true" }, "$"),
|
|
240
|
+
h("pre", {
|
|
241
|
+
className: "harness-execute-command-text",
|
|
242
|
+
"data-testid": "execute-command-text",
|
|
243
|
+
}, command || "")),
|
|
244
|
+
h("div", { className: "harness-execute-command-actions" },
|
|
245
|
+
command ? copyButton(opts, command, "copy-command") : null,
|
|
246
|
+
opts.onOpenTerminal ? h("button", {
|
|
247
|
+
type: "button",
|
|
248
|
+
className: "harness-tool-action",
|
|
249
|
+
"data-testid": "execute-open-terminal",
|
|
250
|
+
"data-action": "open-terminal",
|
|
251
|
+
"data-call-id": callId,
|
|
252
|
+
"data-cwd": cwd || "",
|
|
253
|
+
}, opts.labels && opts.labels.openTerminal || "Open terminal") : null));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function outputBlock(callId, out, opts) {
|
|
257
|
+
const { stdout, stderr, exit, cwd, running, hasOutput } = out;
|
|
258
|
+
const parts = [];
|
|
259
|
+
if (stdout || running || hasOutput) parts.push(h("pre", {
|
|
260
|
+
className: "harness-execute-output harness-execute-stdout",
|
|
261
|
+
"data-call-id": callId,
|
|
262
|
+
tabIndex: 0,
|
|
263
|
+
"data-testid": running ? "bash-output-preview-full" : "bash-result-output",
|
|
264
|
+
"data-streaming": running ? "true" : "false",
|
|
265
|
+
"data-following": "true",
|
|
266
|
+
}, stdout));
|
|
267
|
+
if (stderr) parts.push(h("pre", {
|
|
268
|
+
className: "harness-execute-output harness-execute-stderr",
|
|
269
|
+
"data-testid": "bash-result-stderr",
|
|
270
|
+
}, stderr));
|
|
271
|
+
const meta = [];
|
|
272
|
+
if (exit != null) meta.push(h("span", {
|
|
273
|
+
className: `harness-execute-exit ${exit === 0 ? "harness-execute-exit-ok" : "harness-execute-exit-err"}`,
|
|
274
|
+
"data-testid": "execute-exit",
|
|
275
|
+
"data-exit": String(exit),
|
|
276
|
+
}, `exit ${exit}`));
|
|
277
|
+
if (cwd) meta.push(h("span", { className: "harness-execute-cwd", "data-testid": "execute-cwd" }, cwd));
|
|
278
|
+
if (hasOutput) meta.push(copyButton(opts, stdout || stderr || "", "copy-output"));
|
|
279
|
+
if (meta.length) parts.push(h("div", { className: "harness-execute-meta" }, ...meta));
|
|
280
|
+
if (!hasOutput && !running) {
|
|
281
|
+
parts.push(h("p", { className: "harness-execute-no-output", "data-testid": "execute-no-output" },
|
|
282
|
+
opts.labels && opts.labels.noOutput || "No output"));
|
|
283
|
+
}
|
|
284
|
+
if (!parts.length) return null;
|
|
285
|
+
return h("div", { className: "harness-execute-output-block", "data-testid": "execute-output" }, ...parts);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function copyButton(opts, text, testid) {
|
|
289
|
+
if (!opts.onCopy) return null;
|
|
290
|
+
return h("button", {
|
|
291
|
+
type: "button",
|
|
292
|
+
className: "harness-tool-action harness-copy-action",
|
|
293
|
+
"data-testid": testid,
|
|
294
|
+
"data-action": "copy",
|
|
295
|
+
"data-copy-text": String(text || ""),
|
|
296
|
+
}, opts.labels && opts.labels.copy || "Copy");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── read / search / list ─────────────────────────────────────────────────────
|
|
300
|
+
//
|
|
301
|
+
// The desktop conversation splits "file-read" (ReadToolCallBlock: a file chip)
|
|
302
|
+
// from "search" (SearchToolCallBlock: "Find {query}" / "List {cwd}"). Both are
|
|
303
|
+
// non-toggleable one-line summaries. The path is shown once (leaf in the chip +
|
|
304
|
+
// the workspace-relative path as secondary, full path only in the chip title)
|
|
305
|
+
// rather than repeated across the title, the chip, and a third path line.
|
|
306
|
+
|
|
307
|
+
function readRenderer(item, opts) {
|
|
308
|
+
return readSubKind(item.name, item.input) === "search"
|
|
309
|
+
? searchListRenderer(item, opts)
|
|
310
|
+
: readFileRenderer(item, opts);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function readFileRenderer(item, opts) {
|
|
314
|
+
const meta = readMeta(item);
|
|
315
|
+
const path = meta.path;
|
|
316
|
+
const fileName = leafName(path) || toolTitle(item.name, item.input) || item.name;
|
|
317
|
+
const running = toolIsLive(item);
|
|
318
|
+
const kindLabel = running
|
|
319
|
+
? (opts.labels && opts.labels.readReading || "Reading")
|
|
320
|
+
: (opts.labels && opts.labels.readKind || "Read");
|
|
321
|
+
const canOpen = Boolean(opts.onOpenFile) && meta.entryType === "file";
|
|
322
|
+
// Primary content is the file chip itself; the kind label precedes it. The
|
|
323
|
+
// path is NOT duplicated in the title — only the leaf shows in the chip and
|
|
324
|
+
// the workspace-relative path shows once as the secondary.
|
|
325
|
+
const summary = [
|
|
326
|
+
h("span", {
|
|
327
|
+
className: running
|
|
328
|
+
? "harness-tool-title harness-tool-title-streaming"
|
|
329
|
+
: "harness-tool-title",
|
|
330
|
+
"data-testid": "tool-title",
|
|
331
|
+
}, kindLabel),
|
|
332
|
+
fileChip(path, fileName, canOpen, opts, "read-chip", "read-file-name",
|
|
333
|
+
{ kind: meta.entryType === "directory" ? "directory" : "file", callId: item.callId, path }),
|
|
334
|
+
];
|
|
335
|
+
if (path) summary.push(h("span", {
|
|
336
|
+
className: "harness-tool-secondary harness-read-path",
|
|
337
|
+
"data-testid": "read-file-path",
|
|
338
|
+
title: path,
|
|
339
|
+
}, workspaceRelative(path, opts.workspaceRoot)));
|
|
340
|
+
// Read is a one-line chip; it does not toggle. A failed read surfaces its
|
|
341
|
+
// error inline via the shell, and an optional inspect action lets the host
|
|
342
|
+
// open the result/details (the public result-inspection action).
|
|
343
|
+
return toolShell(item, opts, {
|
|
344
|
+
icon: fileTextIcon("harness-tool-kind-icon"),
|
|
345
|
+
summary, body: [], canToggle: false,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function searchListRenderer(item, opts) {
|
|
350
|
+
const running = toolIsLive(item);
|
|
351
|
+
const isList = isListInput(item.name, item.input);
|
|
352
|
+
const query = isList ? '' : searchQuery(item.input);
|
|
353
|
+
const cwd = searchCwd(item.input);
|
|
354
|
+
const labels = opts.labels || {};
|
|
355
|
+
const kindLabel = running
|
|
356
|
+
? (isList ? (labels.listSearching || "Listing") : (labels.searchSearching || "Searching"))
|
|
357
|
+
: (isList ? (labels.listKind || "List") : (labels.searchKind || "Search"));
|
|
358
|
+
const primary = isList
|
|
359
|
+
? (cwd ? truncateText(cwd, { maxLines: 1, maxChars: 60 }) : '')
|
|
360
|
+
: (query ? (labels.searchFindQuery || "Find {query}").replace("{query}", truncateText(query, { maxLines: 1, maxChars: 80 }))
|
|
361
|
+
: (labels.searchFind || "Find"));
|
|
362
|
+
const summary = [
|
|
363
|
+
h("span", {
|
|
364
|
+
className: running
|
|
365
|
+
? "harness-tool-title harness-tool-title-streaming"
|
|
366
|
+
: "harness-tool-title",
|
|
367
|
+
"data-testid": "tool-title",
|
|
368
|
+
}, kindLabel),
|
|
369
|
+
...((isList ? cwd : query) ? [h("span", {
|
|
370
|
+
className: "harness-tool-primary harness-search-primary",
|
|
371
|
+
"data-testid": "search-primary",
|
|
372
|
+
}, primary)] : []),
|
|
373
|
+
];
|
|
374
|
+
return toolShell(item, opts, {
|
|
375
|
+
icon: searchIcon("harness-tool-kind-icon"),
|
|
376
|
+
summary, body: [], canToggle: false,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A directory listing (list_workspace_files / parsed list_files) vs a search. */
|
|
381
|
+
function isListInput(name, input) {
|
|
382
|
+
const n = String(name || "");
|
|
383
|
+
if (n === "list_workspace_files" || n === "List") return true;
|
|
384
|
+
const pc = input && Array.isArray(input.parsed_cmd) ? input.parsed_cmd : null;
|
|
385
|
+
if (pc) return pc.some(p => p && typeof p === "object" && p.type === "list_files");
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function searchQuery(input) {
|
|
390
|
+
if (!input || typeof input !== "object") {
|
|
391
|
+
if (typeof input === "string") return input.trim();
|
|
392
|
+
return "";
|
|
393
|
+
}
|
|
394
|
+
const pc = Array.isArray(input.parsed_cmd) ? input.parsed_cmd : null;
|
|
395
|
+
if (pc) {
|
|
396
|
+
for (const p of pc) {
|
|
397
|
+
if (!p || typeof p !== "object") continue;
|
|
398
|
+
if (p.type === "search" || p.type === "grep" || p.type === "glob") {
|
|
399
|
+
const q = [p.pattern, p.query].find(v => typeof v === "string" && v.trim());
|
|
400
|
+
if (q) return q.trim();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const key of ["search_query", "searchQuery", "query", "pattern"]) {
|
|
405
|
+
if (typeof input[key] === "string" && input[key].trim()) return input[key].trim();
|
|
406
|
+
}
|
|
407
|
+
return "";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function searchCwd(input) {
|
|
411
|
+
if (!input || typeof input !== "object") return "";
|
|
412
|
+
for (const path of [input.cwd, input.path]) {
|
|
413
|
+
if (typeof path === 'string' && path.trim()) return path.trim().replace(/\/+$/, '') || '/';
|
|
414
|
+
}
|
|
415
|
+
const pc = Array.isArray(input.parsed_cmd) ? input.parsed_cmd : null;
|
|
416
|
+
if (pc) {
|
|
417
|
+
for (const p of pc) {
|
|
418
|
+
if (p && typeof p === "object" && p.type === "list_files" && typeof p.path === "string" && p.path.trim()) {
|
|
419
|
+
return p.path.trim().replace(/\/+$/, "") || '/';
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return "";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function readMeta(item) {
|
|
427
|
+
const disp = item.display || {};
|
|
428
|
+
if (disp.kind === "file_read") return { path: disp.path || readInputPath(item.input), entryType: disp.entryType || "file" };
|
|
429
|
+
return { path: readInputPath(item.input), entryType: "file" };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function readInputPath(input) {
|
|
433
|
+
if (!input || typeof input !== "object") return "";
|
|
434
|
+
for (const key of ["file_path", "filePath", "path", "filename"]) {
|
|
435
|
+
if (typeof input[key] === "string" && input[key].trim()) return input[key].trim();
|
|
436
|
+
}
|
|
437
|
+
return "";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── edit ───────────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
function editRenderer(item, opts) {
|
|
443
|
+
const files = editFiles(item);
|
|
444
|
+
const running = toolIsLive(item);
|
|
445
|
+
// Never infer a write succeeded: the diff/count come from the result display
|
|
446
|
+
// only. While running (no result), show the intent + file chips, no diff.
|
|
447
|
+
const opKind = files.length ? inferOperationKind(files[0].operationKind, null, files[0].patch) : "edit";
|
|
448
|
+
const pending = files.some(file => file.pending);
|
|
449
|
+
const title = pending ? /write|Write/.test(item.name) ? opts.labels.writePending || 'Write requested'
|
|
450
|
+
: opts.labels.editPending || 'Edit requested' : editKindLabel(opKind, running, opts) || toolTitle(item.name, item.input) || item.name;
|
|
451
|
+
const summary = [h("span", {
|
|
452
|
+
className: running
|
|
453
|
+
? "harness-tool-title harness-tool-title-streaming"
|
|
454
|
+
: "harness-tool-title",
|
|
455
|
+
"data-testid": "tool-title",
|
|
456
|
+
}, title)];
|
|
457
|
+
summary.push(h("span", { className: "harness-edit-chips", "data-testid": "edit-chips" },
|
|
458
|
+
...files.map((f, i) => h("span", { className: "harness-edit-chip-wrap", key: f.path + i },
|
|
459
|
+
i > 0 ? h("span", { className: "harness-edit-chip-sep" }, ",") : null,
|
|
460
|
+
fileChip(f.path, leafName(f.path) || f.path, !f.pending && Boolean(opts.onOpenFile), opts, "edit-chip", "edit-file-name", { kind: f.patch ? "patch" : "file", callId: item.callId, path: f.path })))));
|
|
461
|
+
|
|
462
|
+
const totalStat = files.reduce((acc, f) => {
|
|
463
|
+
const s = f.changeStat || countChangeStat(f.patch);
|
|
464
|
+
if (!s) return acc;
|
|
465
|
+
return { added: acc.added + s.added, removed: acc.removed + s.removed };
|
|
466
|
+
}, { added: 0, removed: 0 });
|
|
467
|
+
if (totalStat.added || totalStat.removed) {
|
|
468
|
+
summary.push(h("span", {
|
|
469
|
+
className: "harness-diff-count",
|
|
470
|
+
"data-testid": "edit-diff-count",
|
|
471
|
+
"aria-label": `+${totalStat.added} -${totalStat.removed}`,
|
|
472
|
+
}, totalStat.added > 0 ? h("span", { className: "harness-diff-added" }, `+${totalStat.added}`) : null,
|
|
473
|
+
totalStat.removed > 0 ? h("span", { className: "harness-diff-removed" }, `-${totalStat.removed}`) : null));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const body = [];
|
|
477
|
+
for (const f of files) {
|
|
478
|
+
if (!f.patch) continue;
|
|
479
|
+
const parsed = parseDiffHunks(f.patch, { maxLines: opts.diffMaxLines || 200 });
|
|
480
|
+
if (parsed) body.push(diffBlock(item.callId, f, { ...parsed, truncated: parsed.truncated || f.truncated }, opts));
|
|
481
|
+
else if (f.truncated) body.push(h("p", { className: "harness-diff-truncated", "data-testid": "edit-diff-truncated" }, opts.labels.diffUnavailable || "Diff preview omitted because of its size"));
|
|
482
|
+
}
|
|
483
|
+
if (item.display?.omittedFiles) body.push(h("p", { className: "harness-diff-truncated", "data-testid": "edit-diff-omitted" }, `${item.display.omittedFiles} ${opts.labels.diffFilesOmitted || "file previews omitted"}`));
|
|
484
|
+
if (!body.length && !running && files.length) {
|
|
485
|
+
// Result arrived without a patch: surface the file chips only, no diff.
|
|
486
|
+
}
|
|
487
|
+
return toolShell(item, opts, { icon: pencilIcon("harness-tool-kind-icon"), summary, body, canToggle: true,
|
|
488
|
+
defaultOpen: running && !files.some(f => f.patch) ? true : (files.length > 1 ? false : running) });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function editFiles(item) {
|
|
492
|
+
const disp = item.display || {};
|
|
493
|
+
if (disp.kind === "file_edit" && Array.isArray(disp.files)) {
|
|
494
|
+
return disp.files.map(f => ({ path: f.path, patch: f.patch, changeStat: f.changeStat, operationKind: f.operationKind, truncated: f.truncated }));
|
|
495
|
+
}
|
|
496
|
+
if (disp.kind === "file_diff") {
|
|
497
|
+
return [{ path: disp.path, patch: disp.patch, changeStat: disp.changeStat, operationKind: disp.operationKind }];
|
|
498
|
+
}
|
|
499
|
+
const paths = typeof item.input?.path === 'string' ? [item.input.path] : Array.isArray(item.input?.files)
|
|
500
|
+
? item.input.files.map(file => file?.path).filter(path => typeof path === 'string') : [];
|
|
501
|
+
return paths.map(path => ({ path, pending: true }));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function editKindLabel(opKind, running, opts) {
|
|
505
|
+
const l = opts.labels || {};
|
|
506
|
+
if (String(opts.locale ?? '').startsWith('zh')) return opKind === 'write' ? (running ? '写入中' : '写入')
|
|
507
|
+
: opKind === 'delete' ? (running ? '删除中' : '删除') : (running ? '修改中' : '修改');
|
|
508
|
+
if (opKind === "write") return running ? (l.editWriting || "writing") : (l.editWrite || "write");
|
|
509
|
+
if (opKind === "delete") return running ? (l.editDeleting || "deleting") : (l.editDelete || "delete");
|
|
510
|
+
return running ? (l.editEditing || "editing") : (l.editEdit || "edit");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function diffBlock(callId, file, parsed, opts) {
|
|
514
|
+
const unified = parsed.unified.map((line, i) => diffLine(line, i, "unified"));
|
|
515
|
+
const splitOld = parsed.split.old.map((line, i) => diffLine(line, i, "old"));
|
|
516
|
+
const splitNew = parsed.split.new.map((line, i) => diffLine(line, i, "new"));
|
|
517
|
+
const header = [
|
|
518
|
+
h("span", { className: "harness-diff-path", "data-testid": "edit-diff-path" }, file.path || parsed.path),
|
|
519
|
+
parsed.added || parsed.removed ? h("span", { className: "harness-diff-count", "data-testid": "edit-diff-count-detail" },
|
|
520
|
+
parsed.added > 0 ? h("span", { className: "harness-diff-added" }, `+${parsed.added}`) : null,
|
|
521
|
+
parsed.removed > 0 ? h("span", { className: "harness-diff-removed" }, `-${parsed.removed}`) : null) : null,
|
|
522
|
+
];
|
|
523
|
+
const actions = [];
|
|
524
|
+
if (opts.onOpenFile) actions.push(h("button", {
|
|
525
|
+
type: "button", className: "harness-tool-action", "data-testid": "edit-open-file",
|
|
526
|
+
"data-action": "open-file", "data-path": file.path || parsed.path, "data-kind": "patch", "data-call-id": callId,
|
|
527
|
+
}, opts.labels && opts.labels.openFile || "Open"));
|
|
528
|
+
if (parsed.truncated && opts.onRetrieveFullDiff) actions.push(h("button", {
|
|
529
|
+
type: "button", className: "harness-tool-action", "data-testid": "edit-show-full-diff",
|
|
530
|
+
"data-action": "retrieve-diff", "data-call-id": callId, "data-path": file.path || parsed.path,
|
|
531
|
+
}, opts.labels && opts.labels.showFullDiff || "Show full diff"));
|
|
532
|
+
return h("div", {
|
|
533
|
+
className: "harness-edit-diff",
|
|
534
|
+
"data-testid": "edit-diff",
|
|
535
|
+
"data-call-id": callId,
|
|
536
|
+
"data-path": file.path || parsed.path,
|
|
537
|
+
}, h("div", { className: "harness-diff-header" }, ...header, actions.length ? h("span", { className: "harness-diff-actions" }, ...actions) : null),
|
|
538
|
+
h("div", { className: "harness-diff-view harness-diff-unified", "data-testid": "edit-diff-unified", "data-diff-view": "unified" }, ...unified),
|
|
539
|
+
h("div", { className: "harness-diff-view harness-diff-split", "data-testid": "edit-diff-split", "data-diff-view": "split" },
|
|
540
|
+
h("div", { className: "harness-diff-split-col", "data-diff-side": "old" }, ...splitOld),
|
|
541
|
+
h("div", { className: "harness-diff-split-col", "data-diff-side": "new" }, ...splitNew)),
|
|
542
|
+
parsed.truncated ? h("div", { className: "harness-diff-truncated", "data-testid": "edit-diff-truncated" }, "…") : null);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function diffLine(line, index, side) {
|
|
546
|
+
return h("div", {
|
|
547
|
+
className: `harness-diff-line harness-diff-${markerClass(line.marker)}`,
|
|
548
|
+
"data-marker": line.marker,
|
|
549
|
+
"data-testid": "edit-diff-line",
|
|
550
|
+
"data-diff-side": side,
|
|
551
|
+
key: String(index),
|
|
552
|
+
}, h("span", { className: "harness-diff-gutter harness-diff-gutter-old", "data-testid": "edit-diff-old-line" }, line.oldLine == null ? "" : String(line.oldLine)),
|
|
553
|
+
h("span", { className: "harness-diff-gutter harness-diff-gutter-new", "data-testid": "edit-diff-new-line" }, line.newLine == null ? "" : String(line.newLine)),
|
|
554
|
+
h("span", { className: "harness-diff-marker", "aria-hidden": "true" }, line.marker),
|
|
555
|
+
h("span", { className: "harness-diff-text" }, line.text || ""));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function markerClass(marker) {
|
|
559
|
+
if (marker === "+") return "added";
|
|
560
|
+
if (marker === "-") return "removed";
|
|
561
|
+
return "context";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function fileChip(path, label, clickable, opts, chipTestid, nameTestid, action) {
|
|
565
|
+
const isDir = action && action.kind === "directory" || String(path || "").endsWith("/");
|
|
566
|
+
const glyph = isDir ? searchIcon("harness-file-icon") : fileTextIcon("harness-file-icon");
|
|
567
|
+
const inner = [
|
|
568
|
+
glyph,
|
|
569
|
+
h("span", { className: "harness-file-name", "data-testid": nameTestid }, label),
|
|
570
|
+
];
|
|
571
|
+
if (clickable && opts.onOpenFile) {
|
|
572
|
+
return h("button", {
|
|
573
|
+
type: "button",
|
|
574
|
+
className: "harness-file-chip",
|
|
575
|
+
"data-testid": chipTestid,
|
|
576
|
+
"data-action": "open-file",
|
|
577
|
+
"data-path": path,
|
|
578
|
+
"data-kind": action && action.kind || "file",
|
|
579
|
+
"data-call-id": action && action.callId,
|
|
580
|
+
title: path,
|
|
581
|
+
}, ...inner);
|
|
582
|
+
}
|
|
583
|
+
return h("span", { className: "harness-file-chip harness-file-chip-static", "data-testid": chipTestid, title: path }, ...inner);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function leafName(path) {
|
|
587
|
+
const p = String(path || "").replace(/\/+$/, "");
|
|
588
|
+
if (!p) return "";
|
|
589
|
+
const parts = p.split("/").filter(Boolean);
|
|
590
|
+
return parts[parts.length - 1] || p;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── default (unknown / MCP) ────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
function defaultRenderer(item, opts) {
|
|
596
|
+
const title = localizedToolTitle(item.name, toolTitle(item.name, item.input) || item.name, opts.locale);
|
|
597
|
+
const detail = toolDetailLines(item.name, item.input, { workspaceRoot: opts.workspaceRoot });
|
|
598
|
+
const running = toolIsLive(item);
|
|
599
|
+
const summary = [h("span", {
|
|
600
|
+
className: running
|
|
601
|
+
? "harness-tool-title harness-tool-title-streaming"
|
|
602
|
+
: "harness-tool-title",
|
|
603
|
+
"data-testid": "tool-title",
|
|
604
|
+
}, title)];
|
|
605
|
+
const family = toolIdentity(item.name).family;
|
|
606
|
+
const primary = family === 'skill' ? item.input?.skill_id ?? item.input?.name ?? item.input?.skill
|
|
607
|
+
: family === 'web-search' || family === 'skill-catalog' ? item.input?.query
|
|
608
|
+
: family === 'web-fetch' ? item.input?.url : null;
|
|
609
|
+
if (typeof primary === 'string' && primary) summary.push(h('span', { className: 'harness-tool-primary' },
|
|
610
|
+
truncateText(primary, { maxLines: 1, maxChars: 120 })));
|
|
611
|
+
const body = [];
|
|
612
|
+
if (detail.length) {
|
|
613
|
+
body.push(h("dl", { className: "harness-tool-detail", "data-testid": "tool-detail" },
|
|
614
|
+
detail.map(row => h("div", { className: "harness-tool-detail-line", key: row.key },
|
|
615
|
+
h("dt", {}, row.key + ":"), h("dd", {}, row.value)))));
|
|
616
|
+
}
|
|
617
|
+
if (item.progress && item.status !== ToolStatus.Completed) {
|
|
618
|
+
body.push(h("div", { className: "harness-tool-progress", role: "status", "data-testid": "tool-progress" }, item.progress));
|
|
619
|
+
}
|
|
620
|
+
if (item.result !== undefined && item.result !== null) {
|
|
621
|
+
const raw = typeof item.result === "string" ? item.result : safeStringify(item.result);
|
|
622
|
+
const preview = truncateText(raw, { maxLines: 80, maxChars: 6000 });
|
|
623
|
+
body.push(h("details", { className: "harness-tool-result", "data-testid": "tool-result" },
|
|
624
|
+
h("summary", {}, opts.labels && opts.labels.result || "Result"),
|
|
625
|
+
h("pre", { style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere", maxHeight: "24rem", overflow: "auto" } }, preview)));
|
|
626
|
+
}
|
|
627
|
+
// A file_diff display on an unknown tool still renders a diff so the user can
|
|
628
|
+
// see the change without the host reclassifying the tool.
|
|
629
|
+
if (item.display && item.display.kind === "file_diff" && item.display.patch) {
|
|
630
|
+
const parsed = parseDiffHunks(item.display.patch, { maxLines: opts.diffMaxLines || 200 });
|
|
631
|
+
if (parsed) body.push(diffBlock(item.callId, { path: item.display.path, patch: item.display.patch, changeStat: item.display.changeStat }, parsed, opts));
|
|
632
|
+
}
|
|
633
|
+
const icon = ({ skill: skillIcon, 'skill-catalog': skillIcon, 'web-search': searchIcon,
|
|
634
|
+
'web-fetch': globeIcon, browser: globeIcon, 'tool-search': searchIcon })[family] ?? wrenchIcon;
|
|
635
|
+
// Input parameters are inspection data, rather than a live output stream.
|
|
636
|
+
// Tool execution state must not change the user's disclosure choice.
|
|
637
|
+
return toolShell(item, opts, { icon: icon("harness-tool-kind-icon"), summary, body, canToggle: true, defaultOpen: false });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function safeStringify(value) {
|
|
641
|
+
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export { redactValue, boundedDetailLines, latestNonEmptyLine };
|