pi-one-ui 0.1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,286 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { CompactThinkingController } from "../../features/compact-thinking.ts";
3
+ import {
4
+ installToolGrouping,
5
+ type ToolGroupingHooks,
6
+ } from "./tool/grouping.ts";
7
+ import {
8
+ installCompactMode,
9
+ refreshCompactModeComponents,
10
+ type CompactModeHooks,
11
+ type CompactThinkingQuery,
12
+ } from "./compact-mode.ts";
13
+ import {
14
+ installDefaultMode,
15
+ installToolExpandedBackground,
16
+ type DefaultModeHooks,
17
+ } from "./default-mode.ts";
18
+ import { isLazyProxyTui } from "../../tools/fullscreen-detect.ts";
19
+ import {
20
+ GLOBAL_COMPACTION_RENDER_PATCH,
21
+ patchRegistry,
22
+ } from "../../tools/patch-keys.ts";
23
+ import {
24
+ config,
25
+ normalizeConfig,
26
+ setConfig,
27
+ updateConfig,
28
+ type CompactStyleMode,
29
+ type Config,
30
+ } from "../../app/config/renderer.ts";
31
+ import {
32
+ installToolMouseInteraction,
33
+ resetToolHoverState,
34
+ scheduleSessionRender,
35
+ teardownToolMouseInteraction,
36
+ TOOL_MOUSE_DISABLE,
37
+ } from "./mouse/interaction.ts";
38
+ import { getToolMouseTui } from "./mouse/scroll.ts";
39
+ import { setHoveredToolGroup, setHoveredToolIo } from "./mouse/hover.ts";
40
+ import { clearAllAnimations } from "./tool/result.ts";
41
+ import {
42
+ installWriteOverride,
43
+ WriteExecutionMetadataStore,
44
+ } from "./tool/diff/index.ts";
45
+ import {
46
+ installMessageDisplayRendering,
47
+ refreshMessageDisplays,
48
+ setMessageDisplayTheme,
49
+ } from "./tool/message-display.ts";
50
+
51
+ /**
52
+ * Claude Code Style for pi — 装配入口。
53
+ *
54
+ * mode=on → default-mode(工具卡样式 + 展开背景)
55
+ * mode=compact → compact-mode(消息折叠摘要)
56
+ * 共用 → tool-grouping / message-display / mouse / write override
57
+ */
58
+
59
+ let compactModeHooks: CompactModeHooks | undefined;
60
+
61
+ function refreshCurrentTranscript(
62
+ ctx?: any,
63
+ toolGrouping?: ToolGroupingHooks,
64
+ ): void {
65
+ const tui = getToolMouseTui();
66
+ toolGrouping?.refresh(tui);
67
+ refreshMessageDisplays(tui);
68
+ refreshCompactModeComponents(tui);
69
+ compactModeHooks?.refresh();
70
+ tui?.requestRender?.(true);
71
+ ctx?.ui?.requestRender?.(true);
72
+ }
73
+
74
+ function syncCompactMode(ctx: any): void {
75
+ refreshCompactModeComponents(getToolMouseTui());
76
+ compactModeHooks?.sync(ctx);
77
+ }
78
+
79
+ function applyStyleMode(
80
+ mode: CompactStyleMode,
81
+ ctx: any,
82
+ toolGrouping?: ToolGroupingHooks,
83
+ ): void {
84
+ updateConfig({ mode });
85
+ if (mode === "off") {
86
+ // Native rendering:清 hover/click,关闭鼠标上报以恢复终端默认滚轮。
87
+ resetToolHoverState();
88
+ setHoveredToolGroup(null);
89
+ setHoveredToolIo(null, null);
90
+ // 惰性 Proxy(regular/fullscreen)不持有 reporting:regular 保终端回滚,
91
+ // fullscreen 归官方所有,均不能在此关闭。
92
+ const tui = getToolMouseTui();
93
+ if (tui && !isLazyProxyTui(tui)) {
94
+ tui.terminal?.write?.(TOOL_MOUSE_DISABLE);
95
+ }
96
+ } else if (mode === "compact") {
97
+ // 切入 compact:先收集当前 transcript,再同步全局展开状态和补丁所有权。
98
+ syncCompactMode(ctx);
99
+ }
100
+ // 立即重塑一次;再延迟一帧(与 session_start 同款),等面板/custom
101
+ // 卸下、主 transcript 重新挂载后再扫树,避免必须 /reload。
102
+ refreshCurrentTranscript(ctx, toolGrouping);
103
+ scheduleSessionRender(() => {
104
+ if (mode === "compact") syncCompactMode(ctx);
105
+ refreshCurrentTranscript(ctx, toolGrouping);
106
+ });
107
+ ctx.ui.notify(`Claude Code style: ${mode}`, "info");
108
+ }
109
+
110
+ type LegacyCompactionRenderPatch = {
111
+ enabled?: () => boolean;
112
+ };
113
+
114
+ /** Disable the pre-native compaction monkey patch left alive by /reload. */
115
+ function deactivateLegacyCompactionRendering() {
116
+ const patch = patchRegistry.get<LegacyCompactionRenderPatch>(
117
+ GLOBAL_COMPACTION_RENDER_PATCH,
118
+ );
119
+ if (patch) patch.enabled = () => false;
120
+ }
121
+
122
+ export type RendererRuntimeController = {
123
+ setMode: (mode: CompactStyleMode, ctx: any) => void;
124
+ updateConfig: (partial: Partial<Config>, ctx: any) => void;
125
+ };
126
+
127
+ export type RendererExtensionOptions = {
128
+ onRuntimeController?: (controller: RendererRuntimeController) => void;
129
+ };
130
+
131
+ export default function (
132
+ pi: ExtensionAPI,
133
+ configOverride?: Partial<Config>,
134
+ compactThinking?: CompactThinkingController,
135
+ options: RendererExtensionOptions = {},
136
+ ) {
137
+ // 可选 override:集成测试不依赖用户全局配置。
138
+ if (configOverride)
139
+ setConfig(normalizeConfig({ ...config, ...configOverride }));
140
+ const writeExecutionMetadata = new WriteExecutionMetadataStore();
141
+ const mouseOwner = {};
142
+ let installation:
143
+ | {
144
+ defaultMode: DefaultModeHooks;
145
+ toolGrouping: ToolGroupingHooks;
146
+ compactMode: CompactModeHooks;
147
+ disposeMessageDisplay: () => void;
148
+ disposeToolExpandedBackground: () => void;
149
+ }
150
+ | undefined;
151
+
152
+ const ensureTuiInstallation = (ctx: any) => {
153
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return undefined;
154
+ // 渲染层(工具样式/分组)是原型与组件级 patch,fullscreen 官方布局
155
+ // 同样渲染这些组件,因此两种模式都安装。
156
+ if (installation) return installation;
157
+ const defaultMode = installDefaultMode(writeExecutionMetadata);
158
+ const toolGrouping = installToolGrouping(() => config.mode === "on");
159
+ const compactMode = installCompactMode({
160
+ query: compactThinking as CompactThinkingQuery | undefined,
161
+ writeMetadata: writeExecutionMetadata,
162
+ });
163
+ compactModeHooks = compactMode;
164
+ const disposeMessageDisplay = installMessageDisplayRendering();
165
+ // 展开背景必须在 compact-mode 之后装,shutdown 时先于 compact 释放。
166
+ const disposeToolExpandedBackground = installToolExpandedBackground();
167
+ deactivateLegacyCompactionRendering();
168
+ installation = {
169
+ defaultMode,
170
+ toolGrouping,
171
+ compactMode,
172
+ disposeMessageDisplay,
173
+ disposeToolExpandedBackground,
174
+ };
175
+ return installation;
176
+ };
177
+
178
+ options.onRuntimeController?.({
179
+ setMode: (mode, ctx) =>
180
+ applyStyleMode(mode, ctx, installation?.toolGrouping),
181
+ updateConfig: (partial, ctx) => {
182
+ updateConfig(partial);
183
+ refreshCurrentTranscript(ctx, installation?.toolGrouping);
184
+ },
185
+ });
186
+
187
+ pi.on("message_update", async (event) => {
188
+ // compact-thinking 在 session_start 时于 compact 补丁之上再装一层;
189
+ // 扩展事件先于 interactive-mode 的 updateContent 派发,此处先重新认领。
190
+ if (config.mode === "compact" && event.message?.role === "assistant") {
191
+ compactModeHooks?.assertOwnership();
192
+ }
193
+ });
194
+
195
+ pi.on("tool_execution_end", async (event) => {
196
+ if (config.mode !== "compact") return;
197
+ // Agent 等工具收尾后延迟刷新,让 compact-thinking 先落最终态。
198
+ const toolCallId: string | undefined = event?.toolCallId;
199
+ setTimeout(() => {
200
+ compactModeHooks?.refreshToolCallMessage(toolCallId);
201
+ }, 0);
202
+ });
203
+
204
+ pi.on("session_start", async (event, ctx) => {
205
+ // 延迟到 session_start 注册 write override:加载阶段 getAllTools 不可用且其他扩展
206
+ // 尚未注册工具,无法检测外部 write 所有者(如 pi-spark),直接注册会与对方撞名。
207
+ // session_start 时所有扩展已加载完毕,installWriteOverride 内部会检测并让位。
208
+ installWriteOverride(pi, writeExecutionMetadata);
209
+ const hooks = ensureTuiInstallation(ctx);
210
+ // 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
211
+ // 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
212
+ if (ctx?.mode === "tui" && ctx?.hasUI)
213
+ installToolMouseInteraction(ctx, mouseOwner);
214
+ if (!hooks) return;
215
+ hooks.toolGrouping.setTheme(ctx.ui.theme);
216
+ setMessageDisplayTheme(ctx.ui.theme);
217
+ ctx.ui.setStatus("ccstyle", undefined);
218
+ // 先收集 resume transcript,再同步 compact 补丁与全局展开状态。
219
+ syncCompactMode(ctx);
220
+ // compact-thinking 的 session_start 处理在本 handler 之后执行(在其之上再装
221
+ // 一层 updateContent);延迟再同步一次,保证 compact 补丁最终位于外层。
222
+ setTimeout(() => syncCompactMode(ctx), 0);
223
+ scheduleSessionRender(() => hooks.toolGrouping.refresh(getToolMouseTui()));
224
+ });
225
+
226
+ pi.on("session_compact", async (event, ctx) => {
227
+ const hooks = ensureTuiInstallation(ctx);
228
+ // Compaction rebuilds the transcript without session_start. Rebind after
229
+ // other TUI extensions may have replaced the root input dispatcher.
230
+ if (ctx?.mode === "tui" && ctx?.hasUI)
231
+ installToolMouseInteraction(ctx, mouseOwner);
232
+ if (!hooks) return;
233
+ hooks.toolGrouping.setTheme(ctx.ui.theme);
234
+ setMessageDisplayTheme(ctx.ui.theme);
235
+ syncCompactMode(ctx);
236
+ scheduleSessionRender(() => {
237
+ syncCompactMode(ctx);
238
+ hooks.toolGrouping.refresh(getToolMouseTui());
239
+ });
240
+ });
241
+
242
+ pi.on("session_tree", async (event, ctx) => {
243
+ // 会话树重建后在当前帧和下一帧各同步一次,替换旧组件引用。
244
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
245
+ syncCompactMode(ctx);
246
+ scheduleSessionRender(() => syncCompactMode(ctx));
247
+ });
248
+
249
+ pi.on("tool_execution_start", async (_event, ctx) => {
250
+ installation?.toolGrouping.setTheme(ctx.ui.theme);
251
+ });
252
+
253
+ pi.on("session_shutdown", async () => {
254
+ writeExecutionMetadata.clear();
255
+ // 鼠标交互独立于渲染层:fullscreen 下 installation 为 undefined,
256
+ // 但 onTerminalInput 监听与 handleViewportInput 包装仍需释放。
257
+ teardownToolMouseInteraction(mouseOwner);
258
+ const current = installation;
259
+ if (!current || !current.defaultMode.isOwner()) return;
260
+ current.defaultMode.shutdown();
261
+ current.toolGrouping.shutdown();
262
+ current.disposeToolExpandedBackground();
263
+ current.compactMode.shutdown();
264
+ compactModeHooks = undefined;
265
+ current.disposeMessageDisplay();
266
+ deactivateLegacyCompactionRendering();
267
+ clearAllAnimations();
268
+ installation = undefined;
269
+ });
270
+ }
271
+
272
+ // ---- 对外导出:入口/测试实际消费的符号 ----
273
+ export { getCompactThinkingConfig } from "../../app/config/renderer.ts";
274
+ export {
275
+ humanizeMcpToolName,
276
+ isMcpToolDefinition,
277
+ preservesOriginalRenderer,
278
+ shouldRenderRichDiff,
279
+ } from "./default-mode.ts";
280
+ export {
281
+ ExpandedToolIoView,
282
+ ExpandedToolResultText,
283
+ formatToolInputArgs,
284
+ SHOW_MORE_LABEL,
285
+ } from "./tool/result.ts";
286
+ export { installToolMouseInteraction } from "./mouse/interaction.ts";
@@ -0,0 +1,325 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { render as renderMermaid, sourceBox } from "grok-mermaid";
3
+
4
+ // ============================================================================
5
+ // Mermaid 方言渲染
6
+ // ============================================================================
7
+
8
+ // 内置 mermaid transformer 只认 ```mermaid,这里补上 grok-mermaid 支持的其他方言
9
+ const DIAGRAM_FENCE =
10
+ /^(`{3,})\s*(mermaid|statediagram|statediagram-v2|classdiagram|classdiagram-v2|erdiagram|sequencediagram)\s*$/i;
11
+ const FENCE_OPEN = /^(`{3,})/;
12
+ const FENCE_CLOSE = /^`{3,}\s*$/;
13
+
14
+ /** 把一行图内容包成行内代码(保持空格与框线字符对齐)。 */
15
+ function codeSpan(line: string): string {
16
+ const content = line || "\u00a0";
17
+ const longestRun = Math.max(
18
+ 0,
19
+ ...Array.from(content.matchAll(/`+/g), (m) => m[0].length),
20
+ );
21
+ const fence = "`".repeat(longestRun + 1);
22
+ const padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
23
+ return `${fence}${padding}${content}${padding}${fence}`;
24
+ }
25
+
26
+ /** 渲染失败或图宽超出终端宽度时,把源码包进带标题的框里显示。 */
27
+ function framedSource(src: string, width: number): string {
28
+ const box = sourceBox(src, Math.max(8, width - 2));
29
+ return `\`\`\`\n${box.plain.join("\n")}\n\`\`\``;
30
+ }
31
+
32
+ /** 解析 ```diagram 代码块,返回 { 源码, 块内行数 };未闭合返回 null。 */
33
+ function collectFence(
34
+ lines: string[],
35
+ i: number,
36
+ ): { diagram: string; next: number } | null {
37
+ const open = lines[i].match(FENCE_OPEN)?.[0] ?? "```";
38
+ const src: string[] = [];
39
+ let j = i + 1;
40
+ while (j < lines.length) {
41
+ if (lines[j].match(FENCE_CLOSE) && lines[j].startsWith(open)) {
42
+ return { diagram: src.join("\n"), next: j + 1 };
43
+ }
44
+ src.push(lines[j]);
45
+ j++;
46
+ }
47
+ return null; // 未闭合:放弃转换,保持原文
48
+ }
49
+
50
+ // ============================================================================
51
+ // GitHub 风格提示框(admonition)
52
+ // ============================================================================
53
+
54
+ const ADMONITION =
55
+ /^>\s*\[\s*!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\s*\]\s*(.*)$/i;
56
+ const ADMONITION_STYLE: Record<string, { icon: string; label: string }> = {
57
+ NOTE: { icon: "💡", label: "NOTE" },
58
+ TIP: { icon: "✅", label: "TIP" },
59
+ IMPORTANT: { icon: "❗", label: "IMPORTANT" },
60
+ WARNING: { icon: "⚠️", label: "WARNING" },
61
+ CAUTION: { icon: "⚠️", label: "CAUTION" },
62
+ };
63
+
64
+ /**
65
+ * 把 > [!TYPE] 及其后续 > 行转成带加粗标签的引用块,视觉上成提示框。
66
+ * 多行内容用空格合并为一行。返回 null 表示该行不是提示框。
67
+ */
68
+ function renderAdmonition(
69
+ lines: string[],
70
+ i: number,
71
+ ): { output: string[]; next: number } | null {
72
+ const m = lines[i].match(ADMONITION);
73
+ if (!m) return null;
74
+ const style = ADMONITION_STYLE[m[1].toUpperCase()];
75
+ const body: string[] = [m[2]];
76
+ let j = i + 1;
77
+ while (
78
+ j < lines.length &&
79
+ /^>\s?/.test(lines[j]) &&
80
+ !ADMONITION.test(lines[j])
81
+ ) {
82
+ body.push(lines[j].replace(/^>\s?/, ""));
83
+ j++;
84
+ }
85
+ while (body.length > 0 && body[body.length - 1].trim() === "") body.pop();
86
+ // 内容合并为一行,作为引用块正文
87
+ const content = body.join(" ").trim();
88
+ // 保留引用块形态(pi 渲染为左侧竖线),标签加粗
89
+ return {
90
+ output: [
91
+ `> **${style.icon} ${style.label}**${content ? ` ${content}` : ""}`,
92
+ "",
93
+ ],
94
+ next: j,
95
+ };
96
+ }
97
+
98
+ // ============================================================================
99
+ // 裸 URL 转可点击超链接
100
+ // ============================================================================
101
+
102
+ const URL_RE =
103
+ /(?<!<)(?<!\]\()https?:\/\/[^\s<>'"|,。;:!?、」』】()【】《》『』「」]+/g;
104
+ const TRIM_URL_RE = /[.,;:!?】」』"'》)}]+$/;
105
+
106
+ /**
107
+ * 去掉 URL 尾部标点;括号按平衡保留(如 Wikipedia 链接含括号、
108
+ * IPv6 地址含 [::1]),尾部不成对的 ] 与 ) 会截掉。
109
+ */
110
+ function trimUrl(url: string): string {
111
+ let t = url.replace(TRIM_URL_RE, "");
112
+ for (;;) {
113
+ if (
114
+ t.endsWith(")") &&
115
+ (t.match(/\(/g)?.length ?? 0) < (t.match(/\)/g)?.length ?? 0)
116
+ ) {
117
+ t = t.slice(0, -1);
118
+ } else if (
119
+ t.endsWith("]") &&
120
+ (t.match(/\[/g)?.length ?? 0) < (t.match(/\]/g)?.length ?? 0)
121
+ ) {
122
+ t = t.slice(0, -1);
123
+ } else {
124
+ break;
125
+ }
126
+ }
127
+ return t;
128
+ }
129
+
130
+ /**
131
+ * 把代码块和行内代码外的裸 URL 转成 markdown 链接。
132
+ * 已有的 [text](url) / ![alt](url) / <url> 自动链接用 lookbehind 排除。
133
+ */
134
+ function linkifyUrls(markdown: string): string {
135
+ const lines = markdown.split("\n");
136
+ const out: string[] = [];
137
+ let inFence = false;
138
+ for (const line of lines) {
139
+ if (/^```/.test(line)) {
140
+ inFence = !inFence;
141
+ out.push(line);
142
+ continue;
143
+ }
144
+ if (inFence) {
145
+ out.push(line);
146
+ continue;
147
+ }
148
+ // 行内代码(`...`)按反引号分片,只处理偶数片(代码外)
149
+ const parts = line.split("`");
150
+ for (let p = 0; p < parts.length; p++) {
151
+ if (p % 2 === 0) {
152
+ parts[p] = parts[p].replace(URL_RE, (url) => {
153
+ const trimmed = trimUrl(url);
154
+ if (trimmed.length === 0) return url;
155
+ return `[${trimmed}](${trimmed})`;
156
+ });
157
+ }
158
+ }
159
+ out.push(parts.join("`"));
160
+ }
161
+ return out.join("\n");
162
+ }
163
+
164
+ // ============================================================================
165
+ // 圈数字 → 半角括号
166
+ // ============================================================================
167
+
168
+ // Nerd Font 补丁字形(U+2460-U+2473)ink 超界,DirectWrite 下渲染会压住相邻字符;
169
+ // 转成 ASCII "(n)" 后任何字体下宽度一致。U+2460-U+2473 连续,差值即序号。
170
+ const CIRCLED_RE = /[①-⑳]/g;
171
+
172
+ function deCircled(markdown: string): string {
173
+ return markdown.replace(
174
+ CIRCLED_RE,
175
+ (ch) => `(${ch.codePointAt(0)! - 0x2460 + 1})`,
176
+ );
177
+ }
178
+
179
+ // ============================================================================
180
+ // 跨行链接规整
181
+ // ============================================================================
182
+
183
+ const MULTILINE_LINK_RE = /\[([^\]\n]*(?:\n[^\]\n]*)+)\](?=\()/g;
184
+
185
+ /** 防止 Markdown 跨行链接把行尾填充空格纳入 OSC 8 点击区。 */
186
+ function normalizeMultilineLinks(markdown: string): string {
187
+ const out: string[] = [];
188
+ let prose: string[] = [];
189
+ let inFence = false;
190
+ const flush = () => {
191
+ if (prose.length === 0) return;
192
+ out.push(
193
+ prose.join("\n").replace(MULTILINE_LINK_RE, (_match, label: string) => {
194
+ const normalized = label.replace(/[ \t]*\n[ \t]*/g, " ").trim();
195
+ return `[${normalized}]`;
196
+ }),
197
+ );
198
+ prose = [];
199
+ };
200
+ for (const line of markdown.split("\n")) {
201
+ if (/^```/.test(line)) {
202
+ flush();
203
+ inFence = !inFence;
204
+ out.push(line);
205
+ } else if (inFence) {
206
+ out.push(line);
207
+ } else {
208
+ prose.push(line);
209
+ }
210
+ }
211
+ flush();
212
+ return out.join("\n");
213
+ }
214
+
215
+ // ============================================================================
216
+ // 注册
217
+ // ============================================================================
218
+
219
+ export default function (pi: ExtensionAPI): void {
220
+ // 注意:pi 每个扩展只有一个 markdownTransformer 槽位,多次注册会互相覆盖,
221
+ // 所以三个转换合并为一次注册,内部按序链式执行。
222
+ pi.registerMarkdownTransformer((markdown, context) => {
223
+ const { messageType, isStreaming = false } = context ?? {};
224
+ // thinking 保持原文;普通回复即使流式中也先关闭跨行链接的空白点击区。
225
+ if (messageType === "assistant-thinking") return markdown;
226
+ markdown = normalizeMultilineLinks(markdown);
227
+ if (isStreaming) return markdown;
228
+ // 0. 圈数字转半角括号(Nerd Font 补丁字形缺陷规避)
229
+ markdown = deCircled(markdown);
230
+ // 1. Mermaid 方言渲染
231
+ markdown = renderDiagrams(markdown, context);
232
+ // 2. GitHub 风格提示框
233
+ markdown = renderAdmonitions(markdown);
234
+ // 3. 裸 URL 转超链接
235
+ return linkifyUrls(markdown);
236
+ });
237
+ }
238
+
239
+ // grok-mermaid 渲染结果缓存:resize/restored 重绘会重复转换,按源码缓存 art。
240
+ // ponytail: 固定上限 50 条,防长期会话膨胀;超出后清空重来。
241
+ const ART_CACHE = new Map<string, ReturnType<typeof renderMermaid>>();
242
+ const ART_CACHE_MAX = 50;
243
+
244
+ /** 获取/计算 mermaid art(带缓存)。 */
245
+ function getArt(src: string): ReturnType<typeof renderMermaid> | null {
246
+ const cached = ART_CACHE.get(src);
247
+ if (cached) return cached;
248
+ let art = null;
249
+ try {
250
+ art = renderMermaid(src);
251
+ } catch {
252
+ art = null;
253
+ }
254
+ if (ART_CACHE.size >= ART_CACHE_MAX) ART_CACHE.clear();
255
+ if (art) ART_CACHE.set(src, art);
256
+ return art;
257
+ }
258
+
259
+ /** Mermaid 方言代码块 → ASCII 图(流式跳过)。 */
260
+ function renderDiagrams(
261
+ markdown: string,
262
+ context?: { isStreaming?: boolean; availableWidth?: number },
263
+ ): string {
264
+ const { isStreaming = false, availableWidth } = context ?? {};
265
+ if (isStreaming) return markdown;
266
+ const lines = markdown.split("\n");
267
+ const out: string[] = [];
268
+ let i = 0;
269
+ while (i < lines.length) {
270
+ const line = lines[i];
271
+ const fence = line.match(DIAGRAM_FENCE);
272
+ if (fence) {
273
+ const collected = collectFence(lines, i);
274
+ if (collected) {
275
+ const { diagram, next } = collected;
276
+ // grok-mermaid 需要源码自带类型头,方言头在 fence 标签里时补回去
277
+ const label = fence[2];
278
+ const src =
279
+ label.toLowerCase() === "mermaid" ? diagram : `${label}\n${diagram}`;
280
+ const art = getArt(src);
281
+ const width = availableWidth ?? 80;
282
+ if (art && art.width <= width) {
283
+ // 图行用硬换行(行尾两空格)连接,防止 Markdown 软换行合并行
284
+ out.push(art.plain.map(codeSpan).join(" \n"));
285
+ } else {
286
+ out.push(framedSource(src, width));
287
+ }
288
+ i = next;
289
+ continue;
290
+ }
291
+ // 未闭合:保持原文
292
+ }
293
+ out.push(line);
294
+ i++;
295
+ }
296
+ return out.join("\n");
297
+ }
298
+
299
+ /** GitHub 风格提示框 → 加粗标签引用块(跳过代码块)。 */
300
+ function renderAdmonitions(markdown: string): string {
301
+ const lines = markdown.split("\n");
302
+ const out: string[] = [];
303
+ let inFence = false;
304
+ let i = 0;
305
+ while (i < lines.length) {
306
+ const line = lines[i];
307
+ if (/^```/.test(line)) {
308
+ inFence = !inFence;
309
+ out.push(line);
310
+ i++;
311
+ continue;
312
+ }
313
+ if (!inFence && /^>\s*\[\s*!/i.test(line)) {
314
+ const result = renderAdmonition(lines, i);
315
+ if (result) {
316
+ out.push(...result.output);
317
+ i = result.next;
318
+ continue;
319
+ }
320
+ }
321
+ out.push(line);
322
+ i++;
323
+ }
324
+ return out.join("\n");
325
+ }