cc-viewer 1.7.0 → 1.7.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.
Files changed (115) hide show
  1. package/dist/assets/App-gUSmU6Ny.js +2 -0
  2. package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  3. package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-qfRsRth0.js} +1 -1
  4. package/dist/assets/index-B51Ci0GL.js +2 -0
  5. package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  6. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/package.json +2 -1
  9. package/server/lib/log-watcher.js +29 -3
  10. package/server/lib/v2/adapter.js +71 -6
  11. package/server/lib/v2/live-feed.js +110 -3
  12. package/server/lib/v2/meta-rows.js +360 -0
  13. package/server/lib/v2/v2-writer.js +15 -0
  14. package/server/lib/wire-compress.js +167 -0
  15. package/server/routes/events.js +96 -26
  16. package/server/routes/logs.js +15 -12
  17. package/server/routes/v2.js +60 -0
  18. package/server/routes/workspaces.js +7 -6
  19. package/server/server.js +23 -1
  20. package/src/utils/apiUrl.js +26 -0
  21. package/src/utils/askFallback.js +21 -0
  22. package/src/utils/askOptionDesc.js +55 -0
  23. package/src/utils/askPortalMatcher.js +54 -0
  24. package/src/utils/autoApproveOptions.js +27 -0
  25. package/src/utils/avatarAnimationPostPass.js +49 -0
  26. package/src/utils/builtinPresets.js +18 -0
  27. package/src/utils/clearCheckpoint.js +7 -0
  28. package/src/utils/commandValidator.js +45 -0
  29. package/src/utils/contentFilter.js +620 -0
  30. package/src/utils/contextRaw.js +18 -0
  31. package/src/utils/contextSidebarNavigation.js +16 -0
  32. package/src/utils/contextTurns.js +117 -0
  33. package/src/utils/displayScaleHelper.js +47 -0
  34. package/src/utils/effectiveModel.js +13 -0
  35. package/src/utils/entry-slim.js +563 -0
  36. package/src/utils/entryCache.js +144 -0
  37. package/src/utils/errorReport.js +6 -0
  38. package/src/utils/fileExpandedPathsStorage.js +70 -0
  39. package/src/utils/fileIcons.jsx +36 -0
  40. package/src/utils/fileOpen.js +37 -0
  41. package/src/utils/formatters.js +48 -0
  42. package/src/utils/gitApi.js +50 -0
  43. package/src/utils/gitTreeBuilder.js +19 -0
  44. package/src/utils/helpers.js +846 -0
  45. package/src/utils/identityHeal.js +99 -0
  46. package/src/utils/imConnState.js +59 -0
  47. package/src/utils/imOrigin.js +24 -0
  48. package/src/utils/imTr.js +7 -0
  49. package/src/utils/imageCompress.js +83 -0
  50. package/src/utils/imageDownscale.js +28 -0
  51. package/src/utils/imageResize.js +112 -0
  52. package/src/utils/ingestPipeline.js +47 -0
  53. package/src/utils/lazyWithReload.js +53 -0
  54. package/src/utils/markdown.js +30 -0
  55. package/src/utils/markdownIncremental.js +39 -0
  56. package/src/utils/markdownProfiler.js +113 -0
  57. package/src/utils/mdExtensionDetect.js +64 -0
  58. package/src/utils/memoryLinkParser.js +44 -0
  59. package/src/utils/modalMask.js +15 -0
  60. package/src/utils/monotime.js +6 -0
  61. package/src/utils/pinnedMenu.js +55 -0
  62. package/src/utils/presetShortcuts.js +14 -0
  63. package/src/utils/projectAlias.js +135 -0
  64. package/src/utils/promptClassifier.js +112 -0
  65. package/src/utils/promptDetect.js +196 -0
  66. package/src/utils/promptNav.js +60 -0
  67. package/src/utils/ptyChunkBuilder.js +251 -0
  68. package/src/utils/quickMenuHoverIntent.js +35 -0
  69. package/src/utils/rateLimitParser.js +119 -0
  70. package/src/utils/readResultPool.js +106 -0
  71. package/src/utils/refreshCachedItemProp.js +30 -0
  72. package/src/utils/requestType.js +208 -0
  73. package/src/utils/resizeCalc.js +21 -0
  74. package/src/utils/resolveLocalized.js +17 -0
  75. package/src/utils/searchApi.js +47 -0
  76. package/src/utils/searchReplace.js +51 -0
  77. package/src/utils/seqResourceLoaders.js +79 -0
  78. package/src/utils/sessionManager.js +471 -0
  79. package/src/utils/sessionMerge.js +180 -0
  80. package/src/utils/skillModalController.js +111 -0
  81. package/src/utils/skillsParser.js +116 -0
  82. package/src/utils/slashCommandLabels.js +80 -0
  83. package/src/utils/splitDragCalc.js +73 -0
  84. package/src/utils/stickyBottomController.js +643 -0
  85. package/src/utils/svgSanitize.js +48 -0
  86. package/src/utils/systemTags.js +47 -0
  87. package/src/utils/tClaude.js +20 -0
  88. package/src/utils/teamModalBuilder.js +351 -0
  89. package/src/utils/teamSessionParser.js +190 -0
  90. package/src/utils/teammateAvatars.js +194 -0
  91. package/src/utils/teammateDetector.js +121 -0
  92. package/src/utils/termDiag.js +114 -0
  93. package/src/utils/terminalClipboard.js +117 -0
  94. package/src/utils/terminalWriteQueue.js +398 -0
  95. package/src/utils/thinkingMerge.js +49 -0
  96. package/src/utils/toolCatalog.js +29 -0
  97. package/src/utils/toolResultBuilder.js +393 -0
  98. package/src/utils/toolResultClassifier.js +16 -0
  99. package/src/utils/toolResultCore.js +167 -0
  100. package/src/utils/toolsDiff.js +44 -0
  101. package/src/utils/toolsXmlFormatter.js +3 -0
  102. package/src/utils/ultraplanController.js +109 -0
  103. package/src/utils/ultraplanExperts.js +86 -0
  104. package/src/utils/ultraplanTemplates.js +154 -0
  105. package/src/utils/userImageRefs.js +49 -0
  106. package/src/utils/v3Assembler.js +112 -0
  107. package/src/utils/v3Rows.js +48 -0
  108. package/src/utils/voicePackPlayer.js +233 -0
  109. package/src/utils/webSearchGrouping.js +111 -0
  110. package/src/utils/workflowFormat.js +52 -0
  111. package/src/utils/workflowRuns.js +118 -0
  112. package/src/utils/workflowStore.js +94 -0
  113. package/dist/assets/App-DTkacfRW.js +0 -2
  114. package/dist/assets/index-Ac8vnV3v.js +0 -2
  115. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Incremental tool result state builder.
3
+ * Processes assistant tool_use and user tool_result blocks into lookup maps.
4
+ *
5
+ * NOTE: server_tool_use(如 Anthropic 的 web_search)和 web_search_tool_result 不入此 map,
6
+ * 它们由 ChatMessage.renderAssistantContent 直接从 assistant content 数组渲染。
7
+ * 详见 src/utils/webSearchGrouping.js 与 src/components/viewers/WebSearchResultsView.jsx。
8
+ */
9
+
10
+ import { t } from '../i18n';
11
+ import { buildSingleToolResultCore } from './toolResultCore.js';
12
+
13
+ // --- WeakMap cache for tool result state ---
14
+
15
+ const _toolResultCache = new WeakMap();
16
+
17
+ // 稳定引用,避免 `req.body?.messages || []` 每次构造新 `[]` 字面量打穿 WeakMap 缓存
18
+ // (subAgentEntries 扫描在每次 requests-change 时跑,缓存命中是热路径性能假设)。
19
+ const EMPTY_MESSAGES = Object.freeze([]);
20
+
21
+ export function getToolResultCache(messages) {
22
+ return _toolResultCache.get(messages) || null;
23
+ }
24
+
25
+ export function setToolResultCache(messages, state) {
26
+ _toolResultCache.set(messages, state);
27
+ }
28
+
29
+
30
+ // --- State builder ---
31
+
32
+ const MAX_EDIT_SNAPSHOTS = 300;
33
+
34
+ export function createEmptyToolState() {
35
+ return {
36
+ toolUseMap: {},
37
+ toolResultMap: {},
38
+ readContentMap: {},
39
+ editSnapshotMap: {},
40
+ askAnswerMap: {},
41
+ planApprovalMap: {},
42
+ latestPlanContent: null,
43
+ latestPlanFilePath: null,
44
+ _fileState: {},
45
+ _editOrder: [],
46
+ };
47
+ }
48
+
49
+ /**
50
+ * i18n 包装:在 toolResultCore 的 buildSingleToolResultCore 基础上附加本地化 label。
51
+ * 生产路径调用此函数;test 直接调 core 以避开 i18n 依赖。
52
+ */
53
+ export function buildSingleToolResult(block, matchedTool) {
54
+ const core = buildSingleToolResultCore(block, matchedTool);
55
+ let label = t('ui.toolReturn');
56
+ if (matchedTool) {
57
+ if (matchedTool.name === 'Task' && matchedTool.input) {
58
+ const st = matchedTool.input.subagent_type || '';
59
+ const desc = matchedTool.input.description || '';
60
+ label = `SubAgent: ${st}${desc ? ' — ' + desc : ''}`;
61
+ } else {
62
+ label = t('ui.toolReturnNamed', { name: matchedTool.name });
63
+ }
64
+ }
65
+ return { label, ...core };
66
+ }
67
+
68
+ /**
69
+ * 全局聚合所有 requests 的 tool_result 块,按 tool_use_id 索引。
70
+ *
71
+ * 设计动机:并行 SubAgent / Teammate 的请求在日志中互相穿插,K+1 不一定是同一 agent
72
+ * 的下一个 turn。两遍扫描建立"id → result"全局索引,渲染时 O(1) 查询,免去运行时配对。
73
+ *
74
+ * Pass 1: 全量构建 toolUseMap(供 label / toolName / toolInput 解析)
75
+ * - body.messages 里 role=assistant 的 tool_use 块
76
+ * - response.body.content 里的 tool_use 块(末轮)
77
+ * Pass 2: 提取所有 tool_result 块写入索引(role=user 消息的 content[])
78
+ *
79
+ * id 冲突风险:Anthropic API 用 nanoid 24+ char,2^-128 量级,可忽略。
80
+ */
81
+ export function buildGlobalToolResultIndex(requests) {
82
+ const state = createEmptyGlobalIndexState();
83
+ appendToGlobalToolResultIndex(state, requests, 0);
84
+ return state.index;
85
+ }
86
+
87
+ // 同一 session 内并发持有的 base64 image 上限:更早入索引的 entry 一旦超过此数,
88
+ // 其 images 字段被改成 oversized 占位,释放 base64 字节(单图 2MB × N = 几十 MB
89
+ // 常驻内存的隐患)。32 张覆盖大多数实际会话;若超过,旧图回退为占位,新图保留。
90
+ const MAX_LIVE_IMAGE_ENTRIES = 32;
91
+
92
+ /**
93
+ * 增量索引 state:供 ChatView / TeamModal 在 requests 增量到达时复用,避免每次全量扫描。
94
+ * index : { [tool_use_id]: entry } (出参,共享给调用方)
95
+ * _useMap : 已扫到的 tool_use 块 (Pass 1 累积,Pass 2 查 label)
96
+ * _imageEntryIds : FIFO 队列,跟踪持有 base64 image 的 entry id,超 MAX 时驱逐最早
97
+ */
98
+ export function createEmptyGlobalIndexState() {
99
+ return { index: {}, _useMap: {}, _imageEntryIds: [] };
100
+ }
101
+
102
+ // 把超出 LRU 上限的最早 image entry 降级为 oversized 占位,释放 base64 字符串。
103
+ function _enforceImageBudget(state) {
104
+ const { index, _imageEntryIds } = state;
105
+ while (_imageEntryIds.length > MAX_LIVE_IMAGE_ENTRIES) {
106
+ const evictId = _imageEntryIds.shift();
107
+ const entry = index[evictId];
108
+ if (!entry || !Array.isArray(entry.images)) continue;
109
+ entry.images = entry.images.map(img => (
110
+ img && img.src && !img.oversized
111
+ ? { oversized: true, mediaType: img.mediaType, sizeBytes: img.src.length }
112
+ : img
113
+ ));
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 增量追加:扫描 requests[startIndex..] 并把新发现的 tool_use / tool_result 累积到 state。
119
+ * 仅写入新出现的 id(`!(id in index)`),原条目幂等,可重复调用同一切片不会引入副作用。
120
+ */
121
+ export function appendToGlobalToolResultIndex(state, requests, startIndex) {
122
+ if (!Array.isArray(requests)) return;
123
+ const { index, _useMap, _imageEntryIds } = state;
124
+ for (let i = startIndex; i < requests.length; i++) {
125
+ const r = requests[i];
126
+ if (!r) continue;
127
+ const msgs = r.body?.messages;
128
+ if (Array.isArray(msgs)) {
129
+ for (const m of msgs) {
130
+ if (m?.role === 'assistant' && Array.isArray(m.content)) {
131
+ for (const b of m.content) {
132
+ if (b?.type === 'tool_use' && b.id) _useMap[b.id] = b;
133
+ }
134
+ }
135
+ }
136
+ }
137
+ const respContent = r.response?.body?.content;
138
+ if (Array.isArray(respContent)) {
139
+ for (const b of respContent) {
140
+ if (b?.type === 'tool_use' && b.id) _useMap[b.id] = b;
141
+ }
142
+ }
143
+ }
144
+ for (let i = startIndex; i < requests.length; i++) {
145
+ const r = requests[i];
146
+ const msgs = r?.body?.messages;
147
+ if (!Array.isArray(msgs)) continue;
148
+ for (const m of msgs) {
149
+ if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
150
+ for (const b of m.content) {
151
+ if (b?.type === 'tool_result' && b.tool_use_id && !(b.tool_use_id in index)) {
152
+ const entry = buildSingleToolResult(b, _useMap[b.tool_use_id]);
153
+ index[b.tool_use_id] = entry;
154
+ if (Array.isArray(entry.images) && entry.images.some(img => img && img.src)) {
155
+ _imageEntryIds.push(b.tool_use_id);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ _enforceImageBudget(state);
162
+ }
163
+
164
+ /**
165
+ * SubAgent / Teammate 渲染入口:组合本地 cachedBuildToolResultMap 与全局索引。
166
+ * globalIndex 由调用方一次性构建(buildGlobalToolResultIndex),所有 entry 共享,
167
+ * 渲染查询 O(1)。
168
+ *
169
+ * 补偿仅覆盖 toolResultMap;readContentMap / editSnapshotMap / latestPlanContent
170
+ * 等辅助 map 不在补偿范围内 —— SubAgent 卡片只渲染 response.content 块,Read snapshot
171
+ * 等高级展开由 mainAgent 路径消费,与 SubAgent 解耦。
172
+ */
173
+ export function buildSubAgentResultMap(req, globalIndex) {
174
+ const localState = cachedBuildToolResultMap(req?.body?.messages || EMPTY_MESSAGES);
175
+ const respContent = req?.response?.body?.content;
176
+ if (!Array.isArray(respContent) || !globalIndex) {
177
+ return localState.toolResultMap;
178
+ }
179
+ // filled lazy-alloc 为 null sentinel:无补偿时返回原引用,避免下游 ChatMessage memo 抖动。
180
+ let filled = null;
181
+ for (const b of respContent) {
182
+ if (b?.type === 'tool_use' && b.id && !localState.toolResultMap[b.id] && globalIndex[b.id]) {
183
+ if (!filled) filled = {};
184
+ filled[b.id] = globalIndex[b.id];
185
+ }
186
+ }
187
+ if (!filled) return localState.toolResultMap;
188
+ return { ...localState.toolResultMap, ...filled };
189
+ }
190
+
191
+ export function appendToolResultMap(state, messages, startIndex) {
192
+ const { toolUseMap, toolResultMap, readContentMap, editSnapshotMap, askAnswerMap, planApprovalMap, _fileState } = state;
193
+ for (let i = startIndex; i < messages.length; i++) {
194
+ const msg = messages[i];
195
+ if (msg.role === 'assistant' && Array.isArray(msg.content)) {
196
+ for (const block of msg.content) {
197
+ if (block.type === 'tool_use') {
198
+ let parsed = block;
199
+ if (typeof block.input === 'string') {
200
+ try {
201
+ const cleaned = block.input.replace(/^\[object Object\]/, '');
202
+ parsed = { ...block, input: JSON.parse(cleaned) };
203
+ } catch {}
204
+ }
205
+ toolUseMap[parsed.id] = parsed;
206
+ // Write → .claude/plans/ 文件内容追踪
207
+ if (parsed.name === 'Write' && parsed.input?.file_path
208
+ && /[/\\]\.claude[/\\]plans[/\\]/.test(parsed.input.file_path) && parsed.input.content) {
209
+ state.latestPlanContent = parsed.input.content;
210
+ }
211
+ // ExitPlanMode V2: input 直接携带 plan + planFilePath(normalizeToolInput 注入)
212
+ // 不依赖前置 Write/Edit,是 multi-agent-room 等无前置场景的核心数据源
213
+ if (parsed.name === 'ExitPlanMode' && parsed.input && typeof parsed.input === 'object') {
214
+ if (typeof parsed.input.plan === 'string' && parsed.input.plan.trim()) {
215
+ state.latestPlanContent = parsed.input.plan;
216
+ state._planDirty = (state._planDirty || 0) + 1;
217
+ }
218
+ if (typeof parsed.input.planFilePath === 'string' && parsed.input.planFilePath) {
219
+ state.latestPlanFilePath = parsed.input.planFilePath;
220
+ }
221
+ }
222
+ // Edit → editSnapshotMap + _fileState 更新
223
+ if (parsed.name === 'Edit' && parsed.input) {
224
+ const fp = parsed.input.file_path;
225
+ const oldStr = parsed.input.old_string;
226
+ const newStr = parsed.input.new_string;
227
+ if (fp && oldStr != null && newStr != null && _fileState[fp]) {
228
+ const entry = _fileState[fp];
229
+ // 淘汰时留 null 占位:rebuild 时 key 已存在则跳过,避免重建已淘汰条目
230
+ if (!(parsed.id in editSnapshotMap)) {
231
+ editSnapshotMap[parsed.id] = { plainText: entry.plainText, lineNums: entry.lineNums.slice() };
232
+ state._editOrder.push(parsed.id);
233
+ if (state._editOrder.length > MAX_EDIT_SNAPSHOTS) {
234
+ const evictId = state._editOrder.shift();
235
+ editSnapshotMap[evictId] = null;
236
+ }
237
+ }
238
+ const idx = entry.plainText.indexOf(oldStr);
239
+ if (idx >= 0) {
240
+ const before = entry.plainText.substring(0, idx);
241
+ const lineOffset = before.split('\n').length - 1;
242
+ const oldLineCount = oldStr.split('\n').length;
243
+ const newLineCount = newStr.split('\n').length;
244
+ const lineDelta = newLineCount - oldLineCount;
245
+ entry.plainText = entry.plainText.substring(0, idx) + newStr + entry.plainText.substring(idx + oldStr.length);
246
+ if (lineDelta !== 0) {
247
+ const startNum = entry.lineNums[lineOffset] || (lineOffset + 1);
248
+ const newNums = [];
249
+ for (let j = 0; j < newLineCount; j++) {
250
+ newNums.push(startNum + j);
251
+ }
252
+ entry.lineNums = [
253
+ ...entry.lineNums.slice(0, lineOffset),
254
+ ...newNums,
255
+ ...entry.lineNums.slice(lineOffset + oldLineCount).map(n => n + lineDelta),
256
+ ];
257
+ }
258
+ // Edit plan 文件时同步 latestPlanContent(Write 只追踪全量写入,Edit 追踪增量编辑后的完整内容)
259
+ if (/[/\\]\.claude[/\\]plans[/\\]/.test(fp)) {
260
+ state.latestPlanContent = entry.plainText;
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ } else if (msg.role === 'user' && Array.isArray(msg.content)) {
268
+ for (const block of msg.content) {
269
+ if (block.type === 'tool_result') {
270
+ const matchedTool = toolUseMap[block.tool_use_id];
271
+ const entry = buildSingleToolResult(block, matchedTool);
272
+ const { resultText, isPermissionDenied, isUltraplan } = entry;
273
+ toolResultMap[block.tool_use_id] = entry;
274
+ if (matchedTool && matchedTool.name === 'Read' && matchedTool.input?.file_path) {
275
+ readContentMap[matchedTool.input.file_path] = resultText;
276
+ // _fileState 更新(行号解析)
277
+ const readLines = resultText.split('\n');
278
+ const plainLines = [];
279
+ const lineNums = [];
280
+ for (const rl of readLines) {
281
+ const m = rl.match(/^\s*(\d+)[\t→](.*)$/);
282
+ if (m) {
283
+ lineNums.push(parseInt(m[1], 10));
284
+ plainLines.push(m[2]);
285
+ }
286
+ }
287
+ if (plainLines.length > 0) {
288
+ const existing = _fileState[matchedTool.input.file_path];
289
+ if (existing) {
290
+ const mergedMap = new Map();
291
+ const existingLines = existing.plainText.split('\n');
292
+ for (let j = 0; j < existing.lineNums.length; j++) {
293
+ mergedMap.set(existing.lineNums[j], existingLines[j]);
294
+ }
295
+ for (let j = 0; j < lineNums.length; j++) {
296
+ mergedMap.set(lineNums[j], plainLines[j]);
297
+ }
298
+ const sortedKeys = [...mergedMap.keys()].sort((a, b) => a - b);
299
+ _fileState[matchedTool.input.file_path] = {
300
+ plainText: sortedKeys.map(k => mergedMap.get(k)).join('\n'),
301
+ lineNums: sortedKeys,
302
+ };
303
+ } else {
304
+ _fileState[matchedTool.input.file_path] = { plainText: plainLines.join('\n'), lineNums };
305
+ }
306
+ }
307
+ }
308
+ if (matchedTool && matchedTool.name === 'AskUserQuestion') {
309
+ const parsed = parseAskAnswerText(resultText);
310
+ // 被拒绝的 AskUserQuestion:分 cancelled / rejected 两类——
311
+ // - cancelled:cc-viewer 主动取消(Cancel 按钮 / 输入框打字打断)。
312
+ // ask-bridge.js / sdk-manager.js 注入 reason 时统一加 [cc-viewer:cancel] 前缀
313
+ // 作为协议级 sentinel,前缀匹配比模糊文案匹配稳定(SDK 升级换文案不影响)。
314
+ // - rejected:schema 校验失败 / hook deny 等"未触达"语义。
315
+ // ChatMessage 用这两个 sentinel 区分渲染(cancelled 显式带 __cancelReason__ 灰态)。
316
+ if (Object.keys(parsed).length === 0 && isPermissionDenied) {
317
+ const looksCancelled = /\[cc-viewer:cancel\]/.test(resultText);
318
+ if (looksCancelled) {
319
+ // 截掉 [cc-viewer:cancel] 前缀只显示用户可读 reason,再 slice 200 防超长
320
+ const cleanedReason = resultText.replace(/^\s*\[cc-viewer:cancel\]\s*/, '').slice(0, 200);
321
+ askAnswerMap[block.tool_use_id] = { __cancelled__: true, __cancelReason__: cleanedReason };
322
+ } else {
323
+ askAnswerMap[block.tool_use_id] = { __rejected__: true };
324
+ }
325
+ } else {
326
+ askAnswerMap[block.tool_use_id] = parsed;
327
+ }
328
+ state._askDirty = (state._askDirty || 0) + 1;
329
+ }
330
+ if (matchedTool && matchedTool.name === 'ExitPlanMode') {
331
+ if (isPermissionDenied) {
332
+ const userSaid = resultText.match(/the user said:\s*([\s\S]*)/i);
333
+ planApprovalMap[block.tool_use_id] = {
334
+ status: isUltraplan ? 'ultraplan' : 'rejected',
335
+ feedback: userSaid ? userSaid[1].trim() : '',
336
+ };
337
+ } else {
338
+ planApprovalMap[block.tool_use_id] = parsePlanApproval(resultText);
339
+ }
340
+ state._planDirty = (state._planDirty || 0) + 1;
341
+ // Plan 审批完成(approved/rejected)后无条件重置 latestPlanContent / latestPlanFilePath,
342
+ // 防止下一个 plan 周期显示旧内容。已审批卡片的 V2 plan 渲染由 ChatMessage 的
343
+ // approval.planContent || inp.plan || planFileContents 兜底链承担,不依赖 latestPlanContent。
344
+ state.latestPlanContent = null;
345
+ state.latestPlanFilePath = null;
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ export function buildToolResultMap(messages) {
354
+ const state = createEmptyToolState();
355
+ appendToolResultMap(state, messages, 0);
356
+ return state;
357
+ }
358
+
359
+ export function cachedBuildToolResultMap(messages) {
360
+ let cached = _toolResultCache.get(messages);
361
+ if (!cached) {
362
+ cached = buildToolResultMap(messages);
363
+ _toolResultCache.set(messages, cached);
364
+ }
365
+ return cached;
366
+ }
367
+
368
+ /** 从 AskUserQuestion tool_result 文本中提取答案 map */
369
+ export function parseAskAnswerText(text) {
370
+ const answers = {};
371
+ const re = /"([^"]+)"="([^"]*)"/g;
372
+ let m;
373
+ while ((m = re.exec(text)) !== null) {
374
+ answers[m[1]] = m[2];
375
+ }
376
+ return answers;
377
+ }
378
+
379
+ /** 从 ExitPlanMode tool_result 文本中解析审批状态和计划内容 */
380
+ export function parsePlanApproval(text) {
381
+ if (!text) return { status: 'pending' };
382
+ if (/User has approved/i.test(text)) {
383
+ // Heading may carry a suffix before the colon, e.g. "## Approved Plan (edited by user):"
384
+ // (Claude Code CLI ≥2.1.201); [^\n]* keeps matching the plain "## Approved Plan:" form.
385
+ const planMatch = text.match(/##\s*Approved Plan[^\n]*:\s*\n([\s\S]*)/i);
386
+ return { status: 'approved', planContent: planMatch ? planMatch[1].trim() : '' };
387
+ }
388
+ if (/User rejected/i.test(text)) {
389
+ const feedbackMatch = text.match(/feedback:\s*(.+)/i) || text.match(/User rejected[^:]*:\s*(.+)/i);
390
+ return { status: 'rejected', feedback: feedbackMatch ? feedbackMatch[1].trim() : '' };
391
+ }
392
+ return { status: 'pending' };
393
+ }
@@ -0,0 +1,16 @@
1
+ // 孤儿 tool_use:assistant 调工具但 schema/Zod safeParse 在 PreToolUse hook 之前就抛了
2
+ // InputValidationError——cc-viewer 看到 jsonl 里有 tool_use 块但底层 CLI 从未 spawn hook,
3
+ // 用户既无法答也无法干预。区分于 isPermissionDenied(用户主动拒):前者根因在 model schema
4
+ // 与 runtime validator 不一致,后者是用户语义否决。
5
+ //
6
+ // 单独成文件让 Node test runner 可直接 import(toolResultBuilder.js 通过 helpers.js 拉了
7
+ // SVG asset import,在 jsdom-less 环境无法加载)。
8
+ export function classifyToolResultError(resultText, isError) {
9
+ const isPermissionDenied = isError && resultText
10
+ && /doesn't want to proceed|Permission.*denied|rejected.*tool use|interrupted by user for tool use/i.test(resultText);
11
+ const isInputValidationError = isError && resultText
12
+ && /InputValidationError|<tool_use_error>/i.test(resultText)
13
+ && !isPermissionDenied;
14
+ const isUltraplan = isPermissionDenied && resultText && /ultraplan/i.test(resultText);
15
+ return { isPermissionDenied, isInputValidationError, isUltraplan };
16
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * tool_result entry 的纯 JS 核心,无 i18n / SVG 依赖。
3
+ * 拆出独立模块是为了让 node --test 可直接 import(避开 helpers.js → SVG 的 vite-only 链)。
4
+ * 生产路径仍在 toolResultBuilder.js 通过 buildSingleToolResult 包装,补 i18n label。
5
+ */
6
+
7
+ import { internToolResult } from './readResultPool.js';
8
+ import { classifyToolResultError } from './toolResultClassifier.js';
9
+
10
+ export function extractToolResultText(toolResult) {
11
+ if (!toolResult.content) return String(toolResult.content ?? '');
12
+ if (typeof toolResult.content === 'string') return toolResult.content;
13
+ if (Array.isArray(toolResult.content)) {
14
+ return toolResult.content
15
+ .filter(b => b.type === 'text')
16
+ .map(b => b.text)
17
+ .join('\n');
18
+ }
19
+ return JSON.stringify(toolResult.content);
20
+ }
21
+
22
+ // 白名单防恶意 JSONL 拼任意 MIME(svg+xml 在某些浏览器可嵌入脚本;text/html 应被
23
+ // <img> 拒绝但日志污染仍可避免)。
24
+ const SAFE_IMAGE_MIME = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
25
+
26
+ // base64 字符串长度上限(2MB ≈ 1.5MB 原图)。超限不渲染 <img>,降级为文字提示,
27
+ // 避免每次 Popover 重渲染都构造几 MB src 字符串导致 React diff / 浏览器解码卡顿。
28
+ const MAX_IMAGE_BASE64_LEN = 2 * 1024 * 1024;
29
+
30
+ /**
31
+ * 提取 tool_result 内嵌的 image 块为可直接渲染的 src 列表(或大图占位)。
32
+ * Anthropic API 协议:Read 图片文件 / 截图等返回 `{type:'image', source: {type:'base64', media_type, data}}`,
33
+ * 也可能是 `{type:'url', url}`。
34
+ *
35
+ * 安全/性能:
36
+ * - media_type 必须在白名单内,否则跳过
37
+ * - base64 超过 MAX_IMAGE_BASE64_LEN 时,返回 { oversized: true, sizeBytes } 让 UI 降级显示
38
+ */
39
+ export function extractToolResultImages(toolResult) {
40
+ if (!toolResult || !Array.isArray(toolResult.content)) return [];
41
+ const out = [];
42
+ for (const b of toolResult.content) {
43
+ if (!b || b.type !== 'image' || !b.source) continue;
44
+ const s = b.source;
45
+ if (s.type === 'base64' && typeof s.data === 'string' && s.data.length > 0 && typeof s.media_type === 'string') {
46
+ if (!SAFE_IMAGE_MIME.has(s.media_type)) continue;
47
+ if (s.data.length > MAX_IMAGE_BASE64_LEN) {
48
+ out.push({ oversized: true, mediaType: s.media_type, sizeBytes: Math.floor(s.data.length * 0.75) });
49
+ continue;
50
+ }
51
+ out.push({ src: `data:${s.media_type};base64,${s.data}`, mediaType: s.media_type });
52
+ } else if (s.type === 'url' && typeof s.url === 'string' && /^https?:\/\//.test(s.url)) {
53
+ out.push({ src: s.url, mediaType: 'image/url' });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // Workflow tool_result 文本固定以此句开头(后台启动即时返回,完成走单独的 task-notification)。
60
+ const WF_LAUNCH_MARKER = 'Workflow launched in background';
61
+ const WF_TASK_ID_RE = /Task ID:\s*([A-Za-z0-9_-]+)/;
62
+ const WF_RUN_ID_RE = /Run ID:\s*(wf_[A-Za-z0-9_-]+)/;
63
+ // Transcript dir / Script file 路径段:…/projects/<cwd 编码>/<sessionId(UUID)>/…
64
+ const WF_SESSION_RE = /\/projects\/[^/\s]+\/([0-9a-fA-F-]{36})\//;
65
+
66
+ /**
67
+ * 从 Workflow tool_result 原始文本解析定位线索。命中返回 { runId, taskId, sessionId },
68
+ * 否则返回 null。sessionId 为全局唯一 UUID,足以让服务端 /api/workflow-journal 定位 journal
69
+ * 目录,无需 project hint。
70
+ *
71
+ * @param {string} txt - tool_result resultText 原文
72
+ * @returns {{ runId: string|null, taskId: string|null, sessionId: string|null } | null}
73
+ */
74
+ export function parseWorkflowFromText(txt) {
75
+ if (typeof txt !== 'string' || txt.indexOf(WF_LAUNCH_MARKER) === -1) return null;
76
+ const taskId = (txt.match(WF_TASK_ID_RE) || [])[1] || null;
77
+ const runId = (txt.match(WF_RUN_ID_RE) || [])[1] || null;
78
+ const sessionId = (txt.match(WF_SESSION_RE) || [])[1] || null;
79
+ if (!runId && !taskId) return null;
80
+ return { runId, taskId, sessionId };
81
+ }
82
+
83
+ export function buildSingleToolResultCore(block, matchedTool) {
84
+ let toolName = null;
85
+ let toolInput = null;
86
+ if (matchedTool) {
87
+ toolName = matchedTool.name;
88
+ toolInput = matchedTool.input;
89
+ }
90
+ let resultText = extractToolResultText(block);
91
+ resultText = internToolResult(resultText);
92
+ const isError = !!block.is_error;
93
+ const { isPermissionDenied, isInputValidationError, isUltraplan } = classifyToolResultError(resultText, isError);
94
+ const images = extractToolResultImages(block);
95
+ // Workflow 工具:直接从原始 tool_result 文本解析 { runId, taskId, sessionId } 线索定位
96
+ // 并拉取 workflow run journal 渲染面板。线索原生存在于 wire 文本("Workflow launched in
97
+ // background. Task ID: … / Run ID: wf_… / Transcript dir: …/projects/<cwd>/<sessionId>/…"),
98
+ // 不依赖服务端注入——历史日志(含未经 enrich 的旧日志)同样可识别。
99
+ // 回退:兼容旧路径已注入的 block._ccvWorkflow(服务端 enrich-workflow,仍用于 live)。
100
+ // 文本解析命中时补回 _ccvWorkflow 携带的 project(解析线索里没有),用于 journal 定位的精确消歧。
101
+ const parsedWf = parseWorkflowFromText(resultText);
102
+ const ccvWf = (block._ccvWorkflow && typeof block._ccvWorkflow === 'object') ? block._ccvWorkflow : null;
103
+ const workflow = parsedWf
104
+ ? { ...parsedWf, project: ccvWf?.project || null }
105
+ : ccvWf;
106
+ return { toolName, toolInput, resultText, isError, isPermissionDenied, isInputValidationError, isUltraplan, images, workflow };
107
+ }
108
+
109
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*[A-Za-z]/g;
110
+ const READ_LINE_PREFIX = /^\s*\d+[→\t](.*)$/;
111
+
112
+ /**
113
+ * 紧凑模式 Popover 浮窗的 tool_result 预览:从 toolResultMap entry 生成截断文本。
114
+ *
115
+ * 返回 null 的场景(由 caller skip 渲染预览块):
116
+ * - entry 不存在 / resultText 为空
117
+ * - isPermissionDenied / isInputValidationError(外部已有红 badge,避免双显示)
118
+ *
119
+ * 工具特定清洗:
120
+ * - Read:strip 行号前缀(` 123→content` → `content`)
121
+ * - Bash:strip ANSI 转义(`\x1b[31mERROR\x1b[0m` → `ERROR`)
122
+ *
123
+ * 截断策略:行数上限 maxLines(默认 50,留够内容让 CSS max-height + overflow:auto 触发
124
+ * 滚动),每行字符上限 maxChars(默认 500,防止超长单行撑爆 popover)。
125
+ */
126
+ export function compactResultPreview(entry, opts = {}) {
127
+ if (!entry || typeof entry !== 'object') return null;
128
+ if (entry.isPermissionDenied || entry.isInputValidationError) return null;
129
+
130
+ // 图片优先:Read 图片文件 / 截图等场景,images 数组非空则返回图片预览(text 可同时存在,作为辅助文本)
131
+ const images = Array.isArray(entry.images) ? entry.images : null;
132
+ const hasImages = images && images.length > 0;
133
+
134
+ const raw = entry.resultText;
135
+ const hasText = typeof raw === 'string' && raw.length > 0;
136
+ if (!hasImages && !hasText) return null;
137
+
138
+ const maxLines = opts.maxLines || 50;
139
+ const maxChars = opts.maxChars || 500;
140
+
141
+ let text = null;
142
+ if (hasText) {
143
+ let cleaned = raw;
144
+ if (entry.toolName === 'Bash') {
145
+ cleaned = cleaned.replace(ANSI_ESCAPE, '');
146
+ }
147
+ const lines = cleaned.split('\n');
148
+ const totalLines = lines.length;
149
+ const slice = lines.slice(0, maxLines);
150
+ const out = [];
151
+ for (let i = 0; i < slice.length; i++) {
152
+ let line = slice[i];
153
+ if (entry.toolName === 'Read') {
154
+ const m = line.match(READ_LINE_PREFIX);
155
+ if (m) line = m[1];
156
+ }
157
+ if (line.length > maxChars) line = line.slice(0, maxChars) + '…';
158
+ out.push(line);
159
+ }
160
+ text = out.join('\n');
161
+ if (totalLines > maxLines) text = text + '\n…';
162
+ if (text.trim().length === 0) text = null;
163
+ }
164
+
165
+ if (!hasImages && !text) return null;
166
+ return { text, images: hasImages ? images : null };
167
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tools diff —— 计算「当前请求 tools」相对「上一条 MainAgent 请求 tools」的新增/移除。
3
+ *
4
+ * 用于 ContextTab 的 tools 变化可视化(tools_search 等场景下 tools 列表逐请求变化)。
5
+ * 抽成纯函数:① 让渲染逻辑变短;② 统一 added/removed 的统计口径(均按唯一 name 计数,
6
+ * 不再 added 按数组、removed 按集合导致重名时口径不一致);③ 可独立单测。
7
+ *
8
+ * 约定:tool 按 name 比对(无 name 的 tool 不参与 diff);prevTools 非数组(无上一条 /
9
+ * 当前请求非 MainAgent)时视为「无可比对基线」,hasPrev=false、不产生任何 added/removed。
10
+ *
11
+ * @param {Array|undefined|null} prevTools 上一条请求的 tools 数组(可空)
12
+ * @param {Array|undefined|null} curTools 当前请求的 tools 数组
13
+ * @returns {{
14
+ * hasPrev: boolean, // 是否存在可比对基线
15
+ * isAdded: (name: string) => boolean, // 某 name 是否为相对上一条新增
16
+ * addedNames: string[], // 新增的唯一 name 列表
17
+ * removedNames: string[], // 移除的唯一 name 列表(上一条有、当前没有)
18
+ * addedCount: number, // = addedNames.length
19
+ * removedCount: number, // = removedNames.length
20
+ * changed: boolean, // 是否有任何新增或移除
21
+ * }}
22
+ */
23
+ export function computeToolsDiff(prevTools, curTools) {
24
+ const prevNameSet = Array.isArray(prevTools)
25
+ ? new Set(prevTools.map(t => t?.name).filter(Boolean))
26
+ : null;
27
+ const curNameSet = new Set(
28
+ (Array.isArray(curTools) ? curTools : []).map(t => t?.name).filter(Boolean),
29
+ );
30
+
31
+ const addedNames = prevNameSet ? [...curNameSet].filter(n => !prevNameSet.has(n)) : [];
32
+ const removedNames = prevNameSet ? [...prevNameSet].filter(n => !curNameSet.has(n)) : [];
33
+
34
+ return {
35
+ hasPrev: prevNameSet != null,
36
+ // 仅对有 name 且存在基线的 tool 判新增;无 name 无法按名比对,一律不标记
37
+ isAdded: (name) => prevNameSet != null && !!name && !prevNameSet.has(name),
38
+ addedNames,
39
+ removedNames,
40
+ addedCount: addedNames.length,
41
+ removedCount: removedNames.length,
42
+ changed: addedNames.length > 0 || removedNames.length > 0,
43
+ };
44
+ }
@@ -0,0 +1,3 @@
1
+ // Thin re-export: canonical implementation lives in server/lib/tools-xml-formatter.js
2
+ // (no React deps, shared with server-side server/lib/kv-cache-analyzer.js).
3
+ export { formatToolAsXml, formatToolsAsXml } from '../../server/lib/tools-xml-formatter.js';