pi-cc-extensions 1.0.1 → 1.0.2

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 (28) hide show
  1. package/extensions/feature/compact-thinking.ts +1 -1
  2. package/extensions/feature/reference/subagent.ts +2 -1
  3. package/extensions/feature/shell/footer.ts +9 -6
  4. package/extensions/feature/shell/working-message.ts +2 -2
  5. package/extensions/renderer/compact-mode.ts +38 -28
  6. package/extensions/renderer/default-mode.ts +23 -463
  7. package/extensions/renderer/index.ts +3 -3
  8. package/extensions/renderer/markdown-enhance.ts +63 -22
  9. package/extensions/renderer/mouse/hover.ts +1 -1
  10. package/extensions/renderer/mouse/interaction.ts +2 -2
  11. package/extensions/renderer/mouse/packets.ts +1 -1
  12. package/extensions/renderer/mouse/sticky-prompt.ts +61 -9
  13. package/extensions/renderer/tool/ask-user-question.ts +1 -1
  14. package/extensions/renderer/tool/ccstyle-tool.ts +467 -0
  15. package/extensions/renderer/tool/codemode.ts +1 -1
  16. package/extensions/renderer/tool/diff/diff-edit-render.ts +1 -1
  17. package/extensions/renderer/tool/diff/diff-limits.ts +1 -1
  18. package/extensions/renderer/tool/diff/diff-write-render.ts +1 -1
  19. package/extensions/renderer/tool/diff/line-width-safety.ts +1 -1
  20. package/extensions/renderer/tool/grouping.ts +53 -34
  21. package/extensions/renderer/tool/message-display.ts +1 -1
  22. package/extensions/renderer/tool/names.ts +41 -1
  23. package/extensions/renderer/tool/result.ts +2 -862
  24. package/extensions/renderer/tool/tool-io.ts +735 -0
  25. package/extensions/renderer/tool/tool-state.ts +138 -0
  26. package/package.json +1 -1
  27. package/themes/cc-light.json +1 -1
  28. package/extensions/renderer/tool/show-more-hint.ts +0 -48
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * default mode(config.mode === "on"):Claude Code 风格工具卡渲染。
3
3
  *
4
- * 经 ToolExecutionComponent 原型补丁接管 call/result;edit/write 走 rich diff。
4
+ * 经 ToolExecutionComponent 原型补丁接管 call/result。
5
+ * 卡片怎么画在 tool/ccstyle-tool.ts,本文件只负责安装与所有权。
5
6
  * 生命周期对齐 compact-mode:installDefaultMode → hooks.shutdown。
6
7
  */
7
8
  import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
8
- import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
- import { config, getToolDisplayConfig, type CompactStyleMode } from "../config/config.ts";
9
+ import { config, type CompactStyleMode } from "../config/config.ts";
10
10
  import {
11
11
  COMPONENT_TOOL_RENDER_MODE,
12
12
  GLOBAL_TOOL_RENDER_PATCH,
@@ -14,56 +14,31 @@ import {
14
14
  TOOL_EXPANDED_BACKGROUND_PATCH,
15
15
  } from "../utils/patch-keys.ts";
16
16
  import { isToolCallHovered } from "./mouse/hover.ts";
17
- import {
18
- collapsedResultSummary,
19
- formatToolDuration,
20
- hasExpandableDetail,
21
- insetComponent,
22
- isExpandedToolIoView,
23
- isLiveToolCall,
24
- isToolExpanded,
25
- pendingIcon,
26
- renderCollapsedToolResultToWidth,
27
- renderExpandedToolResult,
28
- resolveToolVisualState,
29
- scheduleAnimation,
30
- settledIcon,
31
- setToolVisualState,
32
- textFromResult,
33
- toolIconColor,
34
- toolViewportWidth,
35
- } from "./tool/result.ts";
36
- import { showMoreHintText } from "./tool/show-more-hint.ts";
37
- import {
38
- ASK_USER_QUESTION_TOOL_NAME,
39
- askUserExpandedBody,
40
- askUserQuestionCount,
41
- createAskUserResultComponent,
42
- } from "./tool/ask-user-question.ts";
43
- import {
44
- CODEMODE_TOOL_NAME,
45
- codemodeExpandedBody,
46
- createCodemodeResultComponent,
47
- } from "./tool/codemode.ts";
48
- import { countWriteDiffStats, writeStatsInCollapsedBody } from "./tool/diff/diff-renderer.ts";
49
- import { renderRichToolResult, type WriteExecutionMetadataStore } from "./tool/diff/index.ts";
17
+ import { isLiveToolCall } from "./tool/tool-state.ts";
50
18
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
51
- import { mcpToolTitle } from "./tool/mcp-title.ts";
52
- import {
53
- fitToolCallSummary,
54
- humanizeToolLabel,
55
- SHELL_TOOL_NAMES,
56
- renderToolSummary,
57
- toolCallSummary,
58
- } from "./tool/names.ts";
59
-
60
- // 成功勾:亮绿 truecolor(与 message-display 一致)
61
- const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
62
- const ANSI_FG_RESET = "\x1b[39m";
19
+ import { createCcstyleTool } from "./tool/ccstyle-tool.ts";
20
+ import type { WriteExecutionMetadataStore } from "./tool/diff/index.ts";
21
+ export { shouldRenderRichDiff } from "./tool/ccstyle-tool.ts";
63
22
 
64
23
  // pi-subagents 等扩展为 Agent 提供专用渲染器,ccstyle 必须保留。
65
24
  const DEDICATED_RENDERER_TOOLS = new Set(["Agent"]);
66
25
 
26
+ /** 排除名单内且自带 renderer 的工具保留原渲染。 */
27
+ export function preservesOriginalRenderer(
28
+ extensionDefinition: any,
29
+ toolName: string,
30
+ builtInToolDefinition?: any,
31
+ excludeRenderers: readonly string[] = config.excludeRenderers,
32
+ ): boolean {
33
+ if (!excludeRenderers.includes(toolName)) return false;
34
+ return [extensionDefinition, builtInToolDefinition].some(
35
+ (definition) =>
36
+ definition?.renderShell === "self" ||
37
+ typeof definition?.renderCall === "function" ||
38
+ typeof definition?.renderResult === "function",
39
+ );
40
+ }
41
+
67
42
  export type DefaultModeHooks = {
68
43
  /** 本安装是否仍持有全局工具渲染补丁。 */
69
44
  isOwner(): boolean;
@@ -116,421 +91,6 @@ type ToolExpandedBackgroundPatch = {
116
91
  dispose: () => void;
117
92
  };
118
93
 
119
- /** rich diff 的两个入口(本处与 compact 的 paintCompactEditWrite)都靠 renderRichToolResult
120
- * 返回 undefined 让位,这里只判断模式与工具名,避免多一个可能与让位不一致的 gate。 */
121
- export function shouldRenderRichDiff(
122
- mode: CompactStyleMode,
123
- toolName: string,
124
- isError: boolean,
125
- ): boolean {
126
- return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
127
- }
128
-
129
- /** 排除名单内且自带 renderer 的工具保留原渲染。 */
130
- export function preservesOriginalRenderer(
131
- extensionDefinition: any,
132
- toolName: string,
133
- builtInToolDefinition?: any,
134
- excludeRenderers: readonly string[] = config.excludeRenderers,
135
- ): boolean {
136
- if (!excludeRenderers.includes(toolName)) return false;
137
- return [extensionDefinition, builtInToolDefinition].some(
138
- (definition) =>
139
- definition?.renderShell === "self" ||
140
- typeof definition?.renderCall === "function" ||
141
- typeof definition?.renderResult === "function",
142
- );
143
- }
144
-
145
- function renderDefault(tool: any, slot: "renderCall" | "renderResult", args: any[], fallback = "") {
146
- try {
147
- if (typeof tool?.[slot] === "function") return tool[slot](...args);
148
- } catch {
149
- // Fall through to raw fallback.
150
- }
151
- return new Text(fallback, 0, 0);
152
- }
153
-
154
- type ParsedTask = { id: string; status: string; subject: string };
155
-
156
- function parseTaskList(text: string): ParsedTask[] {
157
- return text
158
- .split("\n")
159
- .map((line) => line.match(/^#(\d+) \[([^\]]+)] (.+)$/))
160
- .filter((match): match is RegExpMatchArray => Boolean(match))
161
- .map((match) => ({ id: match[1]!, status: match[2]!, subject: match[3]! }));
162
- }
163
-
164
- function taskListSummary(tasks: ParsedTask[]): string {
165
- const counts = { pending: 0, in_progress: 0, completed: 0 };
166
- for (const task of tasks) {
167
- if (task.status in counts) counts[task.status as keyof typeof counts]++;
168
- }
169
- return [
170
- `${tasks.length} tasks`,
171
- counts.in_progress ? `${counts.in_progress} in progress` : "",
172
- counts.pending ? `${counts.pending} pending` : "",
173
- counts.completed ? `${counts.completed} completed` : "",
174
- ]
175
- .filter(Boolean)
176
- .join(" · ");
177
- }
178
-
179
- function renderExpandedTaskResult(
180
- toolName: string,
181
- text: string,
182
- theme: any,
183
- isError: boolean,
184
- ): any | undefined {
185
- if (isError) return undefined;
186
- if (toolName === "TaskList") {
187
- const tasks = parseTaskList(text);
188
- if (!tasks.length) return undefined;
189
- const limit = Math.max(1, config.expandedPreviewMaxLines);
190
- const rows = tasks.slice(0, limit).map((task) => {
191
- const color =
192
- task.status === "completed"
193
- ? "success"
194
- : task.status === "in_progress"
195
- ? "warning"
196
- : "muted";
197
- return `${theme.fg("accent", `#${task.id}`)} ${theme.fg(color, task.status)} ${theme.fg("dim", task.subject)}`;
198
- });
199
- if (tasks.length > rows.length)
200
- rows.push(theme.fg("muted", `… ${tasks.length - rows.length} more tasks`));
201
- // 贴左:外层展开卡片 Box(1,1) 提供 1 格 padding
202
- return new Text(
203
- `↳ ${theme.fg("muted", taskListSummary(tasks))}\n${rows.map((r) => ` ${r}`).join("\n")}`,
204
- 0,
205
- 0,
206
- );
207
- }
208
- const line = text.trim();
209
- if (!line || line.includes("\n")) return undefined;
210
- let formatted: string | undefined;
211
- let match: RegExpMatchArray | null;
212
- if (
213
- toolName === "TaskCreate" &&
214
- (match = line.match(/^Task #(\d+) created successfully: (.+)$/))
215
- ) {
216
- formatted = `${theme.fg("success", "Created task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
217
- } else if (toolName === "TaskUpdate" && (match = line.match(/^Updated task #(\d+) (.+)$/))) {
218
- formatted = `${theme.fg("success", "Updated task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
219
- } else if (toolName === "TaskExecute") {
220
- formatted = `${theme.fg("success", "Started")} ${theme.fg("muted", line)}`;
221
- } else if (toolName === "TaskStop") {
222
- formatted = `${theme.fg("success", "Stopped")} ${theme.fg("muted", line)}`;
223
- }
224
- return formatted ? new Text(`↳ ${formatted}`, 0, 0) : undefined;
225
- }
226
-
227
- /**
228
- * shell 的耗时文本。Pi 原生 shell 渲染器会在结果底部报一行,ccstyle 的通用折叠行把它盖掉了。
229
- *
230
- * 两条路都要:新 Pi 在渲染上下文里给 `durationMs`(单调,restore 出来的行也带得到),优先用它;
231
- * 拿不到(旧 Pi、无耗时的结果)再退回自己的时钟——开始时间在 renderCall 记,结束时间在结果定型时定一次。
232
- * 运行中的实时计时也只能走时钟。
233
- */
234
- function shellDurationText(toolName: string, context: any, isPartial: boolean): string {
235
- if (!SHELL_TOOL_NAMES.has(toolName)) return "";
236
- const recorded = context?.durationMs;
237
- if (!isPartial && typeof recorded === "number" && Number.isFinite(recorded) && recorded >= 0) {
238
- return formatToolDuration(recorded);
239
- }
240
- const startedAt = context?.state?.ccstyleShellStartedAt;
241
- if (typeof startedAt !== "number") return "";
242
- const endedAt = context?.state?.ccstyleShellEndedAt;
243
- const end = typeof endedAt === "number" ? endedAt : Date.now();
244
- // 时钟回拨时别吐负数;快照固定时钟也会走到这。
245
- return formatToolDuration(Math.max(0, end - startedAt));
246
- }
247
-
248
- /** 用 ccstyle call/result 包装任意工具定义。 */
249
- function createCcstyleTool(
250
- originalTool: any,
251
- writeExecutionMetadata: WriteExecutionMetadataStore,
252
- toolName: string,
253
- ): any {
254
- const label = originalTool.label || toolName;
255
- const defaultTitle = label === toolName ? humanizeToolLabel(label) : label;
256
- // MCP 工具直接用 adapter 暴露的真实工具名,不做人性化
257
- const title = mcpToolTitle({ toolName, definition: originalTool }) ?? defaultTitle;
258
-
259
- return {
260
- ...originalTool,
261
- renderShell: "self",
262
- renderCall(args: any, theme: any, context: any) {
263
- if (config.mode === "off") {
264
- return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
265
- }
266
-
267
- // shell 的耗时从执行开始算;restore 出来的行没有 executionStarted,不参与。
268
- if (
269
- SHELL_TOOL_NAMES.has(toolName) &&
270
- context?.executionStarted &&
271
- context.state &&
272
- context.state.ccstyleShellStartedAt === undefined
273
- ) {
274
- context.state.ccstyleShellStartedAt = Date.now();
275
- }
276
-
277
- const visualState = resolveToolVisualState(context);
278
- const isPending =
279
- visualState === "pending" ||
280
- (!visualState && (context?.isPartial || context?.executionStarted));
281
- // 等待窗口(声明流式中或等待派发)也要转:Pi 要到 assistant 消息结束
282
- // 才派发执行,这段只靠 executionStarted 会一直冻在第一帧。
283
- const animating =
284
- isPending && (Boolean(context?.executionStarted) || isLiveToolCall(context?.toolCallId));
285
- if (animating) scheduleAnimation(context, { light: true });
286
- const settledRawIcon = isPending ? "" : settledIcon(toolName, visualState);
287
- const settledIconStyled =
288
- visualState === "success"
289
- ? `${BRIGHT_GREEN}${settledRawIcon}${ANSI_FG_RESET}`
290
- : theme.fg(toolIconColor(context), settledRawIcon);
291
- // 逐帧 spinner:帧在 render() 内取,动画定时器只需 requestRender,
292
- // 不再每次 tick 走 updateDisplay 把整张卡重建一遍。
293
- const pendingIconStyled = () => theme.fg(toolIconColor(context), pendingIcon(toolName));
294
- const summary = toolCallSummary(toolName, args, {
295
- title,
296
- variant: "default",
297
- cwd: context?.cwd,
298
- });
299
- // ask_user_question:问题原文在折叠结果树里逐条列出,标题只留工具名和问题数,
300
- // 否则第一个问题会在标题和树里各出现一次。
301
- if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
302
- // Pi 的渲染上下文不带 result,问题数从这里取。
303
- const questionCount = askUserQuestionCount(context?.result, args);
304
- summary.main = title;
305
- summary.detail = questionCount === undefined ? "" : ` (+${questionCount})`;
306
- summary.payload = undefined;
307
- }
308
- let writeStatsText = "";
309
- let writeStatsStyled = "";
310
- // 折叠 0 行预览时计数由正文那行(↳ diff +A -D · created …)显示,标题不重复。
311
- const statsInBody = writeStatsInCollapsedBody(
312
- Boolean(context?.expanded),
313
- getToolDisplayConfig(),
314
- );
315
- if (toolName === "write" && visualState === "success" && !statsInBody) {
316
- const meta = writeExecutionMetadata.get(context?.toolCallId);
317
- const stats = countWriteDiffStats(
318
- typeof args?.content === "string" ? args.content : undefined,
319
- meta?.previousContent,
320
- meta?.fileExistedBeforeWrite,
321
- );
322
- if (stats) {
323
- writeStatsText = ` (+${stats.added} -${stats.removed})`;
324
- writeStatsStyled = ` ${theme.fg("dim", "(")}${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}${theme.fg("dim", ")")}`;
325
- }
326
- }
327
- const extraText = writeStatsText || summary.detail;
328
- const extraStyled = writeStatsStyled || theme.fg("dim", summary.detail);
329
- let cachedWidth: number | undefined;
330
- let cachedLine: string | undefined;
331
- let cachedIcon: string | undefined;
332
- const expanded = Boolean(context?.expanded);
333
- return {
334
- render(width: number) {
335
- // 轻量 tick 只会 requestRender,靠这里续期,动画才能自维持。
336
- if (animating) scheduleAnimation(context, { light: true });
337
- const icon = isPending ? pendingIconStyled() : settledIconStyled;
338
- if (cachedLine !== undefined && cachedWidth === width && cachedIcon === icon) {
339
- return [cachedLine];
340
- }
341
- const viewportWidth = toolViewportWidth(width);
342
- // 展开态贴左(外层 Box 已 pad 1);折叠 self-shell 保留 1 格前导空格
343
- const lead = expanded ? "" : " ";
344
- const callWidth = Math.max(
345
- 0,
346
- viewportWidth - visibleWidth(icon) - 1 - (expanded ? 0 : 1),
347
- );
348
- const mainWidth = Math.max(0, callWidth - visibleWidth(extraText));
349
- cachedWidth = width;
350
- cachedIcon = icon;
351
- // 路径按最终可用宽度中间截断,避免整行二次截断隐藏文件名。
352
- cachedLine = `${lead}${icon} ${renderToolSummary(summary, mainWidth, theme.fg.bind(theme))}${extraStyled}`;
353
- return [truncateToWidth(cachedLine, viewportWidth, "")];
354
- },
355
- invalidate() {
356
- cachedLine = undefined;
357
- cachedIcon = undefined;
358
- },
359
- };
360
- },
361
- renderResult(result: any, options: any, theme: any, context: any) {
362
- if (config.mode === "off") {
363
- return renderDefault(
364
- originalTool,
365
- "renderResult",
366
- [result, options, theme, context],
367
- textFromResult(result),
368
- );
369
- }
370
-
371
- const expanded = isToolExpanded(options, context);
372
- const isError = Boolean(options?.isError || context?.isError);
373
- // codemode:折叠态自己画子调用树,展开态给全部子调用与去掉脚本头的输出
374
- if (toolName === CODEMODE_TOOL_NAME) {
375
- if (!options?.isPartial) setToolVisualState(context, isError ? "error" : "success");
376
- if (expanded) {
377
- return renderExpandedToolResult(
378
- codemodeExpandedBody(result),
379
- theme,
380
- isError,
381
- context?.lastComponent,
382
- context?.args,
383
- context,
384
- true,
385
- );
386
- }
387
- // 折叠态不再复用展开视图,免得 hover/鼠标命中指向已隐藏的组件
388
- if (context?.state) context.state.ccstyleIoView = undefined;
389
- const toolCallId = context?.toolCallId;
390
- return createCodemodeResultComponent({
391
- result,
392
- theme,
393
- running: Boolean(options?.isPartial),
394
- isError,
395
- isHovered: () => isToolCallHovered(toolCallId),
396
- });
397
- }
398
- // ask_user_question:折叠态摊成问答树,展开态按问题逐条列出选择。
399
- if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
400
- if (!options?.isPartial) setToolVisualState(context, isError ? "error" : "success");
401
- if (options?.isPartial) {
402
- // 折叠态 1 格缩进:与完成后的 ├/└ 对齐标题行的勾。
403
- const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
404
- return new Text(theme.fg("muted", pending), 0, 0);
405
- }
406
- if (expanded) {
407
- return renderExpandedToolResult(
408
- askUserExpandedBody(result),
409
- theme,
410
- isError,
411
- context?.lastComponent,
412
- context?.args,
413
- context,
414
- true,
415
- );
416
- }
417
- if (context?.state) context.state.ccstyleIoView = undefined;
418
- const toolCallId = context?.toolCallId;
419
- return createAskUserResultComponent({
420
- result,
421
- theme,
422
- isError,
423
- isHovered: () => isToolCallHovered(toolCallId),
424
- });
425
- }
426
- if (options?.isPartial) {
427
- // 折叠态保持 3 格:与完成后的 ` ↳ …` 同列,工具跑完那行不横跳。
428
- // 展开态贴左(Box 提供 1 格 pad)。
429
- const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
430
- if (!SHELL_TOOL_NAMES.has(toolName)) {
431
- return new Text(theme.fg("muted", pending), 0, 0);
432
- }
433
- // shell 运行中报已用时间:数字在 render() 内取,动画 tick 才能带上新值。
434
- return {
435
- render(width: number) {
436
- const elapsed = shellDurationText(toolName, context, true);
437
- const suffix = elapsed ? theme.fg("dim", ` · ${elapsed}`) : "";
438
- return [truncateToWidth(theme.fg("muted", pending) + suffix, width, "")];
439
- },
440
- invalidate(): void {},
441
- };
442
- }
443
-
444
- // shell:结果定型时定一次结束时间,这一行之后不再变。
445
- if (
446
- SHELL_TOOL_NAMES.has(toolName) &&
447
- context?.state &&
448
- context.state.ccstyleShellEndedAt === undefined
449
- ) {
450
- context.state.ccstyleShellEndedAt = Date.now();
451
- }
452
- // 折叠行接在摘要后,展开态接在 Output 正文后(原生也是在结果底部报一行)。
453
- const duration = shellDurationText(toolName, context, false);
454
-
455
- setToolVisualState(context, isError ? "error" : "success");
456
- const toolCallId = context?.toolCallId;
457
- if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
458
- // getter 保证 Diff indicator / wrap / limits 下次绘制即更新
459
- const richResult = renderRichToolResult(
460
- toolName,
461
- result,
462
- {
463
- ...options,
464
- expanded,
465
- // 全局共享状态避免 /reload 后旧 result renderer 闭包失联
466
- isHovered: () => isToolCallHovered(toolCallId),
467
- },
468
- theme,
469
- context,
470
- writeExecutionMetadata,
471
- getToolDisplayConfig,
472
- );
473
- // 展开态由 Box(1,1) 提供内边距;折叠态 inset 一级缩进
474
- if (richResult) return expanded ? richResult : insetComponent(richResult);
475
- }
476
-
477
- const text = textFromResult(result, expanded);
478
- const args = context?.args;
479
- if (expanded) {
480
- const taskResult = renderExpandedTaskResult(toolName, text, theme, Boolean(isError));
481
- if (taskResult) return taskResult;
482
- }
483
- const tasks = !isError && toolName === "TaskList" ? parseTaskList(text) : [];
484
- const rendered = tasks.length
485
- ? taskListSummary(tasks)
486
- : collapsedResultSummary({ result, toolName, text, isError });
487
- const expandable = !expanded && (tasks.length > 0 || hasExpandableDetail(text, args));
488
- const hintText = showMoreHintText();
489
- const hintPrefix = expandable ? theme.fg("dim", " · ") : "";
490
- const hint = expandable ? hintPrefix + theme.fg("dim", hintText) : "";
491
- const hoveredHint = expandable ? hintPrefix + theme.fg("text", hintText) : "";
492
- if (expanded) {
493
- const view = renderExpandedToolResult(
494
- text || "",
495
- theme,
496
- Boolean(isError),
497
- context?.lastComponent,
498
- args,
499
- context,
500
- true, // mode=on:贴左,由外层 Box(1,1) 提供 1 格 padding
501
- );
502
- // 尾部附注只能挂在 IoView 上:鼠标层按对象识别它,包一层会断掉命中。
503
- // 与折叠态保持同一写法:裸值,不写 Took。
504
- if (isExpandedToolIoView(view)) view.setFooter(duration || undefined);
505
- return view;
506
- }
507
- if (context?.state) context.state.ccstyleIoView = undefined;
508
- // 折叠态的耗时接在摘要后、展开提示前(提示必须留在行尾供鼠标层识别)。
509
- const body = duration ? `${rendered}${theme.fg("dim", ` · ${duration}`)}` : rendered;
510
- let cachedWidth: number | undefined;
511
- let cachedLine: string | undefined;
512
- let cachedHoveredLine: string | undefined;
513
- return {
514
- render(width: number) {
515
- if (cachedLine === undefined || cachedWidth !== width) {
516
- cachedWidth = width;
517
- cachedLine = theme.fg(
518
- isError ? "error" : "muted",
519
- renderCollapsedToolResultToWidth(body, hint, width),
520
- );
521
- cachedHoveredLine = theme.fg(
522
- isError ? "error" : "muted",
523
- renderCollapsedToolResultToWidth(body, hoveredHint, width),
524
- );
525
- }
526
- return [isToolCallHovered(toolCallId) ? cachedHoveredLine! : cachedLine];
527
- },
528
- invalidate() {},
529
- };
530
- },
531
- };
532
- }
533
-
534
94
  function syncToolShell(component: any, shell: "default" | "self"): void {
535
95
  const target = shell === "self" ? component.selfRenderContainer : component.contentBox;
536
96
  if (!target || !Array.isArray(component.children)) return;
@@ -42,7 +42,7 @@ import {
42
42
  clearLiveToolCalls,
43
43
  noteStreamingToolCalls,
44
44
  retireLiveToolCall,
45
- } from "./tool/result.ts";
45
+ } from "./tool/tool-state.ts";
46
46
  import { installWriteOverride, WriteExecutionMetadataStore } from "./tool/diff/index.ts";
47
47
  import {
48
48
  installMessageDisplayRendering,
@@ -324,6 +324,6 @@ export {
324
324
  ExpandedToolIoView,
325
325
  ExpandedToolResultText,
326
326
  formatToolInputArgs,
327
- SHOW_MORE_LABEL,
328
- } from "./tool/result.ts";
327
+ } from "./tool/tool-io.ts";
328
+ export { SHOW_MORE_LABEL } from "./tool/result.ts";
329
329
  export { installToolMouseInteraction } from "./mouse/interaction.ts";
@@ -110,14 +110,11 @@ function trimUrl(url: string): string {
110
110
  function linkifyUrls(markdown: string): string {
111
111
  const lines = markdown.split("\n");
112
112
  const out: string[] = [];
113
- let inFence = false;
113
+ let fence: MarkdownFence | undefined;
114
114
  for (const line of lines) {
115
- if (/^```/.test(line)) {
116
- inFence = !inFence;
117
- out.push(line);
118
- continue;
119
- }
120
- if (inFence) {
115
+ const scanned = scanMarkdownFence(line, fence);
116
+ fence = scanned.fence;
117
+ if (scanned.protected) {
121
118
  out.push(line);
122
119
  continue;
123
120
  }
@@ -145,8 +142,53 @@ function linkifyUrls(markdown: string): string {
145
142
  // 转成 ASCII "(n)" 后任何字体下宽度一致。U+2460-U+2473 连续,差值即序号。
146
143
  const CIRCLED_RE = /[①-⑳]/g;
147
144
 
145
+ const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/;
146
+ const CODE_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
147
+ const CODE_FENCE_CLOSE = /^ {0,3}(`+|~+)[ \t]*$/;
148
+
149
+ type MarkdownFence = { marker: string; blockquoteDepth: number };
150
+
151
+ function parseFenceLine(line: string): { blockquoteDepth: number; content: string } {
152
+ let content = line;
153
+ let blockquoteDepth = 0;
154
+ while (BLOCKQUOTE_PREFIX.test(content)) {
155
+ content = content.replace(BLOCKQUOTE_PREFIX, "");
156
+ blockquoteDepth++;
157
+ }
158
+ return { blockquoteDepth, content };
159
+ }
160
+
161
+ function scanMarkdownFence(
162
+ line: string,
163
+ fence: MarkdownFence | undefined,
164
+ ): { protected: boolean; fence: MarkdownFence | undefined } {
165
+ const parsed = parseFenceLine(line);
166
+ if (fence && parsed.blockquoteDepth >= fence.blockquoteDepth) {
167
+ const close = parsed.content.match(CODE_FENCE_CLOSE)?.[1];
168
+ const closed =
169
+ parsed.blockquoteDepth === fence.blockquoteDepth &&
170
+ close?.[0] === fence.marker[0] &&
171
+ close.length >= fence.marker.length;
172
+ return { protected: true, fence: closed ? undefined : fence };
173
+ }
174
+ const marker = parsed.content.match(CODE_FENCE_OPEN)?.[1];
175
+ return {
176
+ protected: Boolean(marker),
177
+ fence: marker ? { marker, blockquoteDepth: parsed.blockquoteDepth } : undefined,
178
+ };
179
+ }
180
+
148
181
  function deCircled(markdown: string): string {
149
- return markdown.replace(CIRCLED_RE, (ch) => `(${ch.codePointAt(0)! - 0x2460 + 1})`);
182
+ let fence: MarkdownFence | undefined;
183
+ return markdown
184
+ .split("\n")
185
+ .map((line) => {
186
+ const scanned = scanMarkdownFence(line, fence);
187
+ fence = scanned.fence;
188
+ if (scanned.protected) return line;
189
+ return line.replace(CIRCLED_RE, (ch) => `(${ch.codePointAt(0)! - 0x2460 + 1})`);
190
+ })
191
+ .join("\n");
150
192
  }
151
193
 
152
194
  // ============================================================================
@@ -159,7 +201,7 @@ const MULTILINE_LINK_RE = /\[([^\]\n]*(?:\n[^\]\n]*)+)\](?=\()/g;
159
201
  function normalizeMultilineLinks(markdown: string): string {
160
202
  const out: string[] = [];
161
203
  let prose: string[] = [];
162
- let inFence = false;
204
+ let fence: MarkdownFence | undefined;
163
205
  const flush = () => {
164
206
  if (prose.length === 0) return;
165
207
  out.push(
@@ -171,15 +213,13 @@ function normalizeMultilineLinks(markdown: string): string {
171
213
  prose = [];
172
214
  };
173
215
  for (const line of markdown.split("\n")) {
174
- if (/^```/.test(line)) {
175
- flush();
176
- inFence = !inFence;
177
- out.push(line);
178
- } else if (inFence) {
179
- out.push(line);
180
- } else {
181
- prose.push(line);
182
- }
216
+ const scanned = scanMarkdownFence(line, fence);
217
+ const opening = !fence && scanned.fence;
218
+ const closing = fence && !scanned.fence;
219
+ fence = scanned.fence;
220
+ if (opening || closing) flush();
221
+ if (scanned.protected) out.push(line);
222
+ else prose.push(line);
183
223
  }
184
224
  flush();
185
225
  return out.join("\n");
@@ -272,17 +312,18 @@ function renderDiagrams(
272
312
  function renderAdmonitions(markdown: string): string {
273
313
  const lines = markdown.split("\n");
274
314
  const out: string[] = [];
275
- let inFence = false;
315
+ let fence: MarkdownFence | undefined;
276
316
  let i = 0;
277
317
  while (i < lines.length) {
278
318
  const line = lines[i];
279
- if (/^```/.test(line)) {
280
- inFence = !inFence;
319
+ const scanned = scanMarkdownFence(line, fence);
320
+ fence = scanned.fence;
321
+ if (scanned.protected) {
281
322
  out.push(line);
282
323
  i++;
283
324
  continue;
284
325
  }
285
- if (!inFence && /^>\s*\[\s*!/i.test(line)) {
326
+ if (/^>\s*\[\s*!/i.test(line)) {
286
327
  const result = renderAdmonition(lines, i);
287
328
  if (result) {
288
329
  out.push(...result.output);
@@ -6,7 +6,7 @@ import {
6
6
  invalidateIoView,
7
7
  type ExpandedToolIoView,
8
8
  type ToolIoSection,
9
- } from "../tool/result.ts";
9
+ } from "../tool/tool-io.ts";
10
10
  import type { ToolGroupComponent } from "../tool/grouping.ts";
11
11
  import { componentAtLocalRow, type ComponentRowHit } from "./layout.ts";
12
12
  import { setScrollButtonHovered } from "./scroll.ts";
@@ -14,7 +14,7 @@ import {
14
14
  } from "../compact-mode.ts";
15
15
  import { isMessageDisplayComponent } from "../tool/message-display.ts";
16
16
  import { isLazyProxyTui } from "../../utils/fullscreen-detect.ts";
17
- import { setToolTuiFullscreen, isCollapseHintLine } from "../tool/show-more-hint.ts";
17
+ import { setToolTuiFullscreen, isCollapseHintLine } from "../tool/names.ts";
18
18
  import {
19
19
  type ExpandedToolIoView,
20
20
  getActiveIoViewFrame,
@@ -22,7 +22,7 @@ import {
22
22
  type IoViewFrameState,
23
23
  setActiveIoViewFrame,
24
24
  type ToolIoSection,
25
- } from "../tool/result.ts";
25
+ } from "../tool/tool-io.ts";
26
26
  import {
27
27
  collectToolComponents,
28
28
  extractToolFramePlacements,