@bachi/pi-coder 1.0.0
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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* below-editor-after-statusline — 把「编辑器下方」的扩展 widget 挪到 statusline **下面**。
|
|
3
|
+
*
|
|
4
|
+
* 要解决的问题:pi-subagents 的 fleet 状态行(`1 active agent · ↓ 2.7k window · ↓/← to inspect`,
|
|
5
|
+
* widget key `subagent-fleet-status`,见它的 `src/tui/fleet-status.ts`)注册为
|
|
6
|
+
* `placement: "belowEditor"`,而 pi 的挂载顺序是
|
|
7
|
+
*
|
|
8
|
+
* [document, pendingMessages, status, widgetContainerAbove,
|
|
9
|
+
* editor, widgetContainerBelow, footer]
|
|
10
|
+
*
|
|
11
|
+
* (`interactive-mode.js` 的 `mountInteractiveTui`)—— 于是这行提示出现在**输入框与 statusline
|
|
12
|
+
* 之间**,把 statusline 挤到屏幕最底部。这一行是**状态信息**而不是输入区的一部分,视觉上应当
|
|
13
|
+
* 沉到 statusline 下面去,让「输入框 → statusline」保持紧邻。
|
|
14
|
+
*
|
|
15
|
+
* 为什么只能改结构:pi-subagents 的 `fleetViewPlacement` 只接受 `aboveEditor` / `belowEditor`
|
|
16
|
+
* (`shared/types.ts` 的 `FleetViewPlacement`,默认 belowEditor),pi 的 `setWidget` 也只有这两个
|
|
17
|
+
* 位置,没有任何「footer 之下」的 placement;footer 本身(statusline 那些行)由
|
|
18
|
+
* 本地 `extensions/statusline/` 通过 `ctx.ui.setFooter()` 独占,扩展拿不到它的渲染结果去拼接。
|
|
19
|
+
*
|
|
20
|
+
* 做法:pi-tui 的 `TUI` / `Container` 把 `children` 作为公开字段暴露,而 `TuiBase.render()`
|
|
21
|
+
* 就是**按数组顺序**拼接每个 child 的行(`tui.js`),所以只要把
|
|
22
|
+
* 「编辑器下方的 widget 容器」这个 child 挪到数组末尾(footer 容器之后)即可。
|
|
23
|
+
*
|
|
24
|
+
* 怎么认出是哪个 child:不猜下标 —— 先注册一个**探针 widget**(`placement: belowEditor`,
|
|
25
|
+
* render 返回空数组所以不可见),从 widget 工厂拿到 TUI,再遍历 `children` 找到「子树里装着
|
|
26
|
+
* 那个探针组件」的顶层 child,那就是编辑器下方的 widget 容器;把它移到末尾,然后删掉探针。
|
|
27
|
+
* 探针的注册与删除都发生在同一 tick 内(`setWidget` 只是 `requestRender()`,不会立刻出帧),
|
|
28
|
+
* 所以屏幕上不会闪出任何东西。下面那只 widget 容器里的现有内容(fleet 行)原样跟着搬家。
|
|
29
|
+
*
|
|
30
|
+
* 顺序会保持住:pi 只在 init 与切换 TUI 模式时调 `mountInteractiveTui`,而 `switchTuiMode`
|
|
31
|
+
* 是 `[...previousUi.children]` 原样再挂一遍(顺序被复制),所以移动一次就够了;`/reload`
|
|
32
|
+
* 后 `session_start` 会再跑一次,同样幂等(已经在末尾 → 直接返回)。
|
|
33
|
+
*
|
|
34
|
+
* 刻意不做的事:
|
|
35
|
+
* - 不改 pi-subagents 的任何文件(npm 包,升级即丢),也不改 pi 本身;
|
|
36
|
+
* - fullscreen(`--tui-mode fullscreen`)下 pi 用的是自己那棵 viewport 布局树
|
|
37
|
+
* (`createChatViewport` + `setLayoutRoot`),容器顺序由那棵树决定,本扩展**不保证**在
|
|
38
|
+
* fullscreen 下生效 —— 本机 `settings.json` 的 `tuiMode` 是 `regular`(实测布局就是这个
|
|
39
|
+
* 顺序),fullscreen 下最坏情况只是回到「提示行在 statusline 上面」;
|
|
40
|
+
* - 找不到那个 child(pi 改了挂载结构)时**什么都不做**,不抛错、不猜下标 —— 界面回到改动前
|
|
41
|
+
* 的样子,而不是错位;`tui.children` 万一变成只读数组(`splice`/`push` 抛 TypeError)
|
|
42
|
+
* 同理:只当「没搬成」,绝不让异常漏给宿主去弹 extension error。
|
|
43
|
+
*
|
|
44
|
+
* 环境变量:
|
|
45
|
+
* PI_BELOW_EDITOR_AFTER_STATUSLINE=off 启动时关闭本扩展
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
49
|
+
|
|
50
|
+
/** 探针 widget 的 key(只在本次调用里短暂存在)。 */
|
|
51
|
+
const PROBE_KEY = "below-editor-order-probe";
|
|
52
|
+
|
|
53
|
+
const DISABLED = (process.env.PI_BELOW_EDITOR_AFTER_STATUSLINE ?? "").trim().toLowerCase() === "off";
|
|
54
|
+
|
|
55
|
+
/** 子树里是否装着这个组件 —— 按对象身份比对,不按形状(同形状的组件到处都是)。 */
|
|
56
|
+
function containsComponent(node: unknown, target: unknown, seen = new Set<unknown>()): boolean {
|
|
57
|
+
if (node === target) return true;
|
|
58
|
+
if (node === null || typeof node !== "object" || seen.has(node)) return false;
|
|
59
|
+
seen.add(node);
|
|
60
|
+
const children = (node as { children?: unknown }).children;
|
|
61
|
+
if (!Array.isArray(children)) return false;
|
|
62
|
+
return children.some((child) => containsComponent(child, target, seen));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function (pi: ExtensionAPI): void {
|
|
66
|
+
if (DISABLED) return;
|
|
67
|
+
|
|
68
|
+
function moveBelowEditorWidgetsLast(ctx: ExtensionContext): void {
|
|
69
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
70
|
+
|
|
71
|
+
let tui: unknown;
|
|
72
|
+
let probe: unknown;
|
|
73
|
+
try {
|
|
74
|
+
// 探针必须显式声明 belowEditor —— 默认位置上方的那个容器不是要搬的对象,
|
|
75
|
+
// 弄错会把「编辑器上方」的 widget 区(任务清单 / 摘要)整块搬到 footer 底下。
|
|
76
|
+
ctx.ui.setWidget(
|
|
77
|
+
PROBE_KEY,
|
|
78
|
+
(widgetTui, _theme) => {
|
|
79
|
+
tui = widgetTui;
|
|
80
|
+
const component = { render: (): string[] => [], invalidate() {} };
|
|
81
|
+
probe = component;
|
|
82
|
+
return component;
|
|
83
|
+
},
|
|
84
|
+
{ placement: "belowEditor" },
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
// 没有 UI / ctx 已失效:什么都别做。
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const children = (tui as { children?: unknown } | undefined)?.children;
|
|
93
|
+
if (!Array.isArray(children)) return;
|
|
94
|
+
const index = children.findIndex((child) => containsComponent(child, probe));
|
|
95
|
+
// 找不到(pi 换了内部结构)或本来就在最后(footer 之后)→ 不动。
|
|
96
|
+
if (index < 0 || index >= children.length - 1) return;
|
|
97
|
+
// 数组万一变成只读(splice 抛 TypeError)也只当作「没搬成」:
|
|
98
|
+
// 界面回到改动前的顺序,绝不把异常漏给宿主弹 extension error。
|
|
99
|
+
try {
|
|
100
|
+
const [container] = children.splice(index, 1);
|
|
101
|
+
children.push(container);
|
|
102
|
+
} catch {
|
|
103
|
+
// 只读 / 诡异代理:保持原顺序。
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
try {
|
|
107
|
+
ctx.ui.setWidget(PROBE_KEY, undefined);
|
|
108
|
+
} catch {
|
|
109
|
+
// stale ctx:探针随会话一起消失,不影响已完成的重排。
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// init 时 TUI 先挂载、随后才 emit session_start(`InteractiveMode.init` →
|
|
115
|
+
// `bindCurrentSessionExtensions`),所以这里一定能拿到已经挂好的组件树。
|
|
116
|
+
// resume / fork / /reload 都会触发,重复执行是幂等的。
|
|
117
|
+
pi.on("session_start", async (_event, ctx) => moveBelowEditorWidgetsLast(ctx));
|
|
118
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给 pi 增加 /clear 指令,效果等同于内置的 /new(开一个全新会话,清空当前上下文)。
|
|
3
|
+
*
|
|
4
|
+
* 实现方式:注册扩展命令 clear,内部调用 ctx.newSession() —— 这正是内置 /new 走的
|
|
5
|
+
* 同一条会话替换流程(旧会话先触发 session_shutdown,再绑定新会话,新会话拿到
|
|
6
|
+
* session_start),所以行为、快捷键语义、落盘格式都和 /new 完全一致。
|
|
7
|
+
*
|
|
8
|
+
* 细节:
|
|
9
|
+
* - 先 await ctx.waitForIdle():agent 正在跑的时候直接 newSession 会和运行中的
|
|
10
|
+
* turn 抢会话,等这轮(含自动重试 / 自动压缩 / 排队续跑)结束再换才安全。
|
|
11
|
+
* - 不传 parentSession / setup / withSession,即纯净的新会话,不做任何上下文搬运。
|
|
12
|
+
* - result.cancelled 说明有别的扩展在 session_before_new 里否决了这次切换,
|
|
13
|
+
* 此时给一条提示而不是静默失败。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
pi.registerCommand("clear", {
|
|
20
|
+
description: "Start a new session (alias of /new)",
|
|
21
|
+
handler: async (_args, ctx) => {
|
|
22
|
+
await ctx.waitForIdle();
|
|
23
|
+
const result = await ctx.newSession();
|
|
24
|
+
if (result?.cancelled) {
|
|
25
|
+
ctx.ui.notify("New session cancelled by an extension", "warning");
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cwd-statusline — 在 statusline 底部单独一行显示当前工作目录(完整绝对路径)
|
|
3
|
+
*
|
|
4
|
+
* 走 ctx.ui.setStatus() 输出一条 extension status,由本地 statusline 扩展
|
|
5
|
+
* (`extensions/statusline/`)渲染在主行下面的第二行:那条路径不被任何路径压缩规则处理,
|
|
6
|
+
* 所以看得到完整 pwd。超长只按终端宽度截断加省略号,不折行。
|
|
7
|
+
*
|
|
8
|
+
* 历史备注:这一行原来由第三方包 `npm:@narumitw/pi-statusline` 渲染。它会先 trim() status 文本、
|
|
9
|
+
* 再按 emoji 拆出图标,配置里的 `extensionStatusIcons.cwd = " 📁"` 是唯一能控制行首缩进的地方;
|
|
10
|
+
* 现在的 statusline 扩展直接渲染 status 原文(trim 后统一缩进一格),所以 PI_CWD_ICON 说了算。
|
|
11
|
+
*
|
|
12
|
+
* 可调环境变量:
|
|
13
|
+
* PI_CWD_STATUSLINE 设为 off 关闭本扩展
|
|
14
|
+
* PI_CWD_ICON 图标,默认 📁
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
const STATUS_KEY = "cwd";
|
|
20
|
+
const ICON = process.env.PI_CWD_ICON ?? " 📁";
|
|
21
|
+
const DISABLED = (process.env.PI_CWD_STATUSLINE ?? "").toLowerCase() === "off";
|
|
22
|
+
|
|
23
|
+
export default function (pi: ExtensionAPI) {
|
|
24
|
+
if (DISABLED) return;
|
|
25
|
+
|
|
26
|
+
const refresh = (ctx: ExtensionContext) => {
|
|
27
|
+
if (!ctx.hasUI) return;
|
|
28
|
+
// statusline 扩展会把 status 文本原样渲染(只 trim 一次并统一缩进一格),
|
|
29
|
+
// 所以这里只发「图标 + 空格 + 绝对路径」。
|
|
30
|
+
ctx.ui.setStatus(STATUS_KEY, `${ICON} ${ctx.cwd}`);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// session_start:启动 / 新建 / resume / fork 都会触发,是设置初始值的地方
|
|
34
|
+
pi.on("session_start", async (_event, ctx) => refresh(ctx));
|
|
35
|
+
// session_info_changed:会话元信息变化时兜底刷新
|
|
36
|
+
pi.on("session_info_changed", async (_event, ctx) => refresh(ctx));
|
|
37
|
+
// turn_start:每轮开始时刷新一次,保证目录信息始终是最新的
|
|
38
|
+
pi.on("turn_start", async (_event, ctx) => refresh(ctx));
|
|
39
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 让 pi 支持像 Claude Code 那样直接输入 exit / quit 退出。
|
|
3
|
+
*
|
|
4
|
+
* 实现方式:监听 `input` 事件(在扩展命令之后、skill/template 展开之前触发),
|
|
5
|
+
* 当输入去空格后正好是退出词时返回 { action: "handled" }(跳过 agent,不进 LLM),
|
|
6
|
+
* 同时调用 ctx.shutdown() 做优雅退出 —— 会话照常落盘,退出前会触发 session_shutdown。
|
|
7
|
+
*
|
|
8
|
+
* 细节:
|
|
9
|
+
* - 只在 ctx.mode === "tui" 时生效:--print / --mode json / --mode rpc 里 "exit" 仍是普通 prompt
|
|
10
|
+
* (注意 --print 的 event.source 也是 "interactive",所以不能只靠 source 判断;
|
|
11
|
+
* 且 print 模式下 shutdown 本来就是 no-op,拦下来只会静默丢掉 prompt)。
|
|
12
|
+
* - 精确整行匹配,所以 "exit the loop and print a summary" 这类正常提问不受影响;
|
|
13
|
+
* 带附件(图片)时也放行。
|
|
14
|
+
* - 大小写不敏感;默认退出词 exit / quit / bye。
|
|
15
|
+
* 可用环境变量覆盖:PI_EXIT_WORDS="exit,quit";设为 "off" 则整体关闭。
|
|
16
|
+
* - 顺带注册 /exit 作为内置 /quit 的别名。
|
|
17
|
+
* - agent 正在跑的时候输入 exit:shutdown 会被推迟到空闲后再执行,即等当前这轮结束才退出。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_WORDS = ["exit", "quit", "bye"];
|
|
23
|
+
|
|
24
|
+
function exitWords(): string[] | null {
|
|
25
|
+
const raw = process.env.PI_EXIT_WORDS;
|
|
26
|
+
if (!raw) return DEFAULT_WORDS;
|
|
27
|
+
const trimmed = raw.trim();
|
|
28
|
+
if (!trimmed) return DEFAULT_WORDS;
|
|
29
|
+
if (trimmed.toLowerCase() === "off") return null;
|
|
30
|
+
const words = trimmed
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((w) => w.trim().toLowerCase())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
return words.length > 0 ? words : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function (pi: ExtensionAPI) {
|
|
38
|
+
const words = exitWords();
|
|
39
|
+
if (!words) return; // PI_EXIT_WORDS=off
|
|
40
|
+
|
|
41
|
+
pi.on("input", async (event, ctx) => {
|
|
42
|
+
if (ctx.mode !== "tui" || event.source !== "interactive") return { action: "continue" };
|
|
43
|
+
if (event.images && event.images.length > 0) return { action: "continue" };
|
|
44
|
+
|
|
45
|
+
const text = event.text.trim().toLowerCase();
|
|
46
|
+
if (!words.includes(text)) return { action: "continue" };
|
|
47
|
+
|
|
48
|
+
ctx.ui.notify("bye 👋", "info");
|
|
49
|
+
ctx.shutdown();
|
|
50
|
+
return { action: "handled" };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.registerCommand("exit", {
|
|
54
|
+
description: "Exit pi cleanly (alias of /quit)",
|
|
55
|
+
handler: async (_args, ctx) => {
|
|
56
|
+
ctx.shutdown();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for index.ts — 端到端:pi 自己的扩展加载器真的加载本扩展,补丁真的落在 **pi 渲染用的
|
|
3
|
+
* 那个** `Markdown` 类上,再用 pi 自己的 `AssistantMessageComponent` 渲染一条 assistant 消息,
|
|
4
|
+
* 断言围栏消失、语法着色还在、**没有任何底色**。
|
|
5
|
+
*
|
|
6
|
+
* Run with: node --test clients/pi/extensions/fenceless-code-block/index.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 为什么非得绕这一圈(不直接 new 一个 Markdown 来渲染):这个特性的**唯一**风险就是「补丁打在
|
|
9
|
+
* 另一个类上」—— `pi` 命令跑的是 `dist/bundle/cli.js`,pi-tui 内联在 chunk 里,而扩展 import 到
|
|
10
|
+
* 的是扩展加载器 aliases / virtualModules 给的那一份。只有真过一遍 pi 的加载器 + pi 自己的渲染
|
|
11
|
+
* 组件,才能证明这两者**是同一个类**(链路见 `render.ts` 文件头)。
|
|
12
|
+
* `render.test.ts` 覆盖纯逻辑与边界条件,这里只证明「装得上,而且作用在 pi 身上」。
|
|
13
|
+
* 找不到本机 pi 的库入口就整体 skip(不假装通过)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
const EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
24
|
+
const SKIP = "找不到本机 pi 的库入口(装过 pi 才有)";
|
|
25
|
+
const ESC = "\u001b";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* pi 的库入口(非 CLI):bundle 是 `pi` 实际跑的形态,dist 是 node 构建形态。
|
|
29
|
+
*
|
|
30
|
+
* 先从 `pi` 可执行文件反查**真正的安装位置**(pnpm/npm 的 shim 脚本里留有
|
|
31
|
+
* `# cmd-shim-target=<绝对路径>`;npm 在 Unix 上则是符号链接,两种都试),再退回
|
|
32
|
+
* `~/.pi/agent/npm` 那份副本 —— 后者是**扩展包的安装根**,`pi update --extensions` 会用
|
|
33
|
+
* `--config.auto-install-peers=false` 把自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,
|
|
34
|
+
* 于是那份副本只剩空壳(文件都在、import 报 `ERR_MODULE_NOT_FOUND`;2026-09 升
|
|
35
|
+
* pi-subagents 0.68.0 时实测踩到)。
|
|
36
|
+
*
|
|
37
|
+
* 判定方式是**能不能真 import**,不是路径存不存在 —— 只有真 import 一次才分得清空壳。
|
|
38
|
+
* 全都不行就返回 undefined,调用方整体 skip(不假装通过)。
|
|
39
|
+
*/
|
|
40
|
+
async function findPiLibraryEntry(): Promise<string | undefined> {
|
|
41
|
+
const candidates: string[] = [];
|
|
42
|
+
if (process.env.PI_TEST_PI_ENTRY) candidates.push(process.env.PI_TEST_PI_ENTRY);
|
|
43
|
+
|
|
44
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
45
|
+
if (!dir) continue;
|
|
46
|
+
const shimPath = path.join(dir, "pi");
|
|
47
|
+
try {
|
|
48
|
+
// npm 的 shim 是符号链接
|
|
49
|
+
const real = fs.realpathSync(shimPath);
|
|
50
|
+
if (real !== shimPath) candidates.push(path.join(path.dirname(real), "index.js"));
|
|
51
|
+
} catch {
|
|
52
|
+
// 不是符号链接 / 不存在:看下面的 shim 脚本
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const match = /^# cmd-shim-target=(.+)$/m.exec(fs.readFileSync(shimPath, "utf8"));
|
|
56
|
+
if (match?.[1]) candidates.push(path.join(path.dirname(match[1].trim()), "index.js"));
|
|
57
|
+
} catch {
|
|
58
|
+
// 读不到这个 shim:跳过
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const packageDir = path.join(os.homedir(), ".pi/agent/npm/node_modules/@earendil-works/pi-coding-agent");
|
|
63
|
+
candidates.push(path.join(packageDir, "dist/bundle/index.js"), path.join(packageDir, "dist/index.js"));
|
|
64
|
+
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
if (!fs.existsSync(candidate)) continue;
|
|
67
|
+
try {
|
|
68
|
+
await import(pathToFileURL(candidate).href);
|
|
69
|
+
return candidate;
|
|
70
|
+
} catch {
|
|
71
|
+
// 空壳副本:换下一个候选
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const piEntry = await findPiLibraryEntry();
|
|
78
|
+
const skip = piEntry === undefined ? SKIP : false;
|
|
79
|
+
|
|
80
|
+
type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
|
|
81
|
+
|
|
82
|
+
interface LoadedExtension {
|
|
83
|
+
handlers: Map<string, Handler[]>;
|
|
84
|
+
commands: Map<string, unknown>;
|
|
85
|
+
errors: Array<{ path: string; error: string }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface PiApi {
|
|
89
|
+
discoverAndLoadExtensions: (
|
|
90
|
+
configuredPaths: string[],
|
|
91
|
+
cwd: string,
|
|
92
|
+
agentDir?: string,
|
|
93
|
+
eventBus?: unknown,
|
|
94
|
+
) => Promise<{ extensions: LoadedExtension[]; errors: Array<{ path: string; error: string }> }>;
|
|
95
|
+
getMarkdownTheme: () => Record<string, unknown>;
|
|
96
|
+
initTheme: (name?: string) => void;
|
|
97
|
+
AssistantMessageComponent: new (
|
|
98
|
+
message: unknown,
|
|
99
|
+
hideThinkingBlock: boolean,
|
|
100
|
+
markdownTheme: unknown,
|
|
101
|
+
hiddenThinkingLabel: string,
|
|
102
|
+
outputPad: number,
|
|
103
|
+
transformers: unknown[],
|
|
104
|
+
) => { render(width: number): string[] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let pi: PiApi | undefined;
|
|
108
|
+
if (piEntry) pi = (await import(pathToFileURL(piEntry).href)) as unknown as PiApi;
|
|
109
|
+
|
|
110
|
+
function createTestBus(): { emit(channel: string, data: unknown): void; on(channel: string, handler: (data: unknown) => void): () => void } {
|
|
111
|
+
const handlers = new Map<string, Set<(data: unknown) => void>>();
|
|
112
|
+
return {
|
|
113
|
+
on(channel, handler) {
|
|
114
|
+
const set = handlers.get(channel) ?? new Set<(data: unknown) => void>();
|
|
115
|
+
handlers.set(channel, set);
|
|
116
|
+
set.add(handler);
|
|
117
|
+
return () => {
|
|
118
|
+
set.delete(handler);
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
emit(channel, data) {
|
|
122
|
+
for (const handler of [...(handlers.get(channel) ?? [])]) handler(data);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makeWorkspace(): { agentDir: string; projectDir: string; cleanup: () => void } {
|
|
128
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fenceless-code-block-"));
|
|
129
|
+
const agentDir = path.join(root, "agent");
|
|
130
|
+
const projectDir = path.join(root, "project");
|
|
131
|
+
fs.mkdirSync(agentDir);
|
|
132
|
+
fs.mkdirSync(projectDir);
|
|
133
|
+
return {
|
|
134
|
+
agentDir,
|
|
135
|
+
projectDir,
|
|
136
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 用 pi 自己的加载器加载本扩展(`./render.ts` 的 import 也一起验证)。 */
|
|
141
|
+
async function loadExtension(workspace: { agentDir: string; projectDir: string }): Promise<LoadedExtension> {
|
|
142
|
+
assert.ok(pi);
|
|
143
|
+
const loaded = await pi.discoverAndLoadExtensions(
|
|
144
|
+
[EXTENSION_PATH],
|
|
145
|
+
workspace.projectDir,
|
|
146
|
+
workspace.agentDir,
|
|
147
|
+
createTestBus(),
|
|
148
|
+
);
|
|
149
|
+
assert.deepEqual(loaded.errors, [], "pi 的扩展加载器不应该报错");
|
|
150
|
+
assert.equal(loaded.extensions.length, 1);
|
|
151
|
+
const extension = loaded.extensions[0];
|
|
152
|
+
assert.ok(extension);
|
|
153
|
+
return extension;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 走 pi 自己的组件渲染一条 assistant 文本消息(`outputPad = 1`,与真实渲染一致)。 */
|
|
157
|
+
function renderAssistantText(text: string, width = 80): string[] {
|
|
158
|
+
assert.ok(pi);
|
|
159
|
+
pi.initTheme("dark");
|
|
160
|
+
// 真实路径上 pi 用的是 getMarkdownThemeWithSettings()(= 这里再合并设置里的缩进)。
|
|
161
|
+
const markdownTheme = { ...pi.getMarkdownTheme(), codeBlockIndent: " " };
|
|
162
|
+
const message = { role: "assistant", content: [{ type: "text", text }], stopReason: "stop" };
|
|
163
|
+
const component = new pi.AssistantMessageComponent(message, false, markdownTheme, "Thinking...", 1, []);
|
|
164
|
+
return component.render(width);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 去掉 CSI 颜色与 OSC 133 标记(assistant 消息首尾各挂一个)。 */
|
|
168
|
+
const plainText = (line: string): string =>
|
|
169
|
+
line.replace(/\u001b\][^\u0007]*\u0007/g, "").replace(/\u001b\[[0-9;]*m/g, "");
|
|
170
|
+
|
|
171
|
+
test("扩展能被 pi 的加载器加载(本扩展不注册事件与命令)", { skip, timeout: 30_000 }, async () => {
|
|
172
|
+
const workspace = makeWorkspace();
|
|
173
|
+
try {
|
|
174
|
+
const extension = await loadExtension(workspace);
|
|
175
|
+
assert.equal(extension.handlers.size, 0, "补丁在模块求值时装好,不需要 session_start");
|
|
176
|
+
assert.equal(extension.commands.size, 0, "本扩展不提供命令");
|
|
177
|
+
} finally {
|
|
178
|
+
workspace.cleanup();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("pi 渲染的代码块没有围栏,且不铺任何底色", { skip, timeout: 30_000 }, async () => {
|
|
183
|
+
const workspace = makeWorkspace();
|
|
184
|
+
try {
|
|
185
|
+
await loadExtension(workspace);
|
|
186
|
+
|
|
187
|
+
const lines = renderAssistantText("before\n\n```js\nconst x = 1;\n```\n\nafter");
|
|
188
|
+
const plain = lines.map(plainText);
|
|
189
|
+
|
|
190
|
+
assert.equal(plain.some((line) => line.includes("```")), false, "围栏不该出现");
|
|
191
|
+
assert.equal(plain.some((line) => line.trim() === "js"), false, "语言标签不该单独占一行");
|
|
192
|
+
|
|
193
|
+
const codeLine = lines.find((line) => plainText(line).includes("const x = 1;"));
|
|
194
|
+
assert.ok(codeLine, "代码行应该在");
|
|
195
|
+
assert.ok(codeLine.includes(`${ESC}[38;`), "语法着色不该被我们的渲染吃掉");
|
|
196
|
+
assert.equal(codeLine.includes(`${ESC}[48;`), false, "代码行不该有底色");
|
|
197
|
+
assert.equal(lines.some((line) => line.includes(`${ESC}[48;`)), false, "整条消息都不该出现底色 SGR");
|
|
198
|
+
|
|
199
|
+
assert.equal(plainText(codeLine).trim(), "const x = 1;", "只留 pi 的缩进,行尾没有多余内容");
|
|
200
|
+
assert.ok(plain.some((line) => line.includes("before")), "代码块前面的正文还在");
|
|
201
|
+
assert.ok(plain.some((line) => line.includes("after")), "代码块后面的正文还在");
|
|
202
|
+
for (const line of plain.filter((text) => text.trim() !== "")) {
|
|
203
|
+
assert.equal(line.length, 80, "pi 的整行补白仍然生效");
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
workspace.cleanup();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fenceless-code-block — Markdown 代码块去掉围栏(不加底色)
|
|
3
|
+
*
|
|
4
|
+
* 本文件只做接线:把 pi-tui 的量度 / 折行函数交给 `render.ts` 的纯逻辑。原型补丁本身在
|
|
5
|
+
* `installFencelessCodeBlocks` 里 —— 为什么必须打原型、为什么打得中(bundle 内联 vs 加载器
|
|
6
|
+
* virtualModules),见 `render.ts` 的文件头。
|
|
7
|
+
*
|
|
8
|
+
* 只管围栏:代码正文保持 pi 原本的 `mdCodeBlock` 前景色,不铺底色、不碰引用块的 gutter,
|
|
9
|
+
* 也不改行尾补白(那是 `Markdown.render()` 自己的事)。所以不需要 `ctx`、不注册任何事件 ——
|
|
10
|
+
* 扩展在模块求值时就把补丁装好。
|
|
11
|
+
*
|
|
12
|
+
* `PI_FENCELESS_CODE=off` 关闭(与 `PI_LOGO=off` 同一套约定)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
import { installFencelessCodeBlocks } from "./render.ts";
|
|
19
|
+
|
|
20
|
+
export default function (pi: ExtensionAPI) {
|
|
21
|
+
if (process.env.PI_FENCELESS_CODE === "off") return;
|
|
22
|
+
|
|
23
|
+
installFencelessCodeBlocks({
|
|
24
|
+
Markdown,
|
|
25
|
+
measure: visibleWidth,
|
|
26
|
+
wrap: wrapTextWithAnsi,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for render.ts — 代码块去围栏的纯逻辑(行渲染 + 原型补丁)。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/fenceless-code-block/render.test.ts
|
|
5
|
+
*
|
|
6
|
+
* 被测模块不 import pi / pi-tui(量度、折行、Markdown 类都是注入的),所以这里喂假实现:
|
|
7
|
+
* ASCII = 1 列、CJK 全角 = 2 列(口径与 pi-tui 的 visibleWidth 一致);折行是朴素的按列硬切
|
|
8
|
+
* (输入不含 ANSI,正好等价于 wrapTextWithAnsi 在"没有可断词"情形下的结果);"Markdown 类"
|
|
9
|
+
* 只是个带 theme 字段和 renderToken 的假类,用来验证补丁的接线(转发、幂等、读实例上的主题)。
|
|
10
|
+
* 真实的端到端链路(pi 自己的加载器 → pi 自己的渲染组件)在 `index.test.ts`。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
|
|
16
|
+
import { installFencelessCodeBlocks, renderCodeBlockLines } from "./render.ts";
|
|
17
|
+
|
|
18
|
+
const ESC = "\u001b";
|
|
19
|
+
const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, "");
|
|
20
|
+
/** CJK 全角区间(够这几条用例用;口径与 pi-tui 的 visibleWidth 一致)。 */
|
|
21
|
+
const WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/;
|
|
22
|
+
const charWidth = (ch: string): number => (WIDE.test(ch) ? 2 : 1);
|
|
23
|
+
const measure = (text: string): number => [...stripAnsi(text)].reduce((sum, ch) => sum + charWidth(ch), 0);
|
|
24
|
+
/** 朴素折行:按列硬切。输入不含 ANSI,够用。 */
|
|
25
|
+
const wrap = (text: string, width: number): string[] => {
|
|
26
|
+
if (measure(text) <= width) return [text];
|
|
27
|
+
const pieces: string[] = [];
|
|
28
|
+
let current = "";
|
|
29
|
+
let used = 0;
|
|
30
|
+
for (const ch of text) {
|
|
31
|
+
const w = charWidth(ch);
|
|
32
|
+
if (used > 0 && used + w > width) {
|
|
33
|
+
pieces.push(current);
|
|
34
|
+
current = "";
|
|
35
|
+
used = 0;
|
|
36
|
+
}
|
|
37
|
+
current += ch;
|
|
38
|
+
used += w;
|
|
39
|
+
}
|
|
40
|
+
pieces.push(current);
|
|
41
|
+
return pieces;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("renderCodeBlockLines", () => {
|
|
45
|
+
it("围栏不参与:正文按缩进铺开", () => {
|
|
46
|
+
assert.deepEqual(renderCodeBlockLines("a\nbb", 10, { measure, wrap }), [" a", " bb"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("行尾不补白:补白交给 pi 的 Markdown.render(),这里只保证不超过 width", () => {
|
|
50
|
+
const lines = renderCodeBlockLines("a", 10, { measure, wrap });
|
|
51
|
+
assert.deepEqual(lines, [" a"], "末尾不该有我们加的空格");
|
|
52
|
+
for (const line of lines) assert.ok(measure(line) <= 10);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("不注入任何自己的转义(没有底色、没有额外的 SGR)", () => {
|
|
56
|
+
for (const line of renderCodeBlockLines("a\nbb", 10, { measure, wrap })) {
|
|
57
|
+
assert.equal(line.includes(ESC), false, "没有高亮时返回的应该是纯文本");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("长行自己先折,每行都不超过 width(外层不会再有第二次折行)", () => {
|
|
62
|
+
const lines = renderCodeBlockLines("xxxxxxxxxx", 6, { measure, wrap });
|
|
63
|
+
assert.deepEqual(lines, [" xxxx", " xxxx", " xx"]);
|
|
64
|
+
for (const line of lines) assert.ok(measure(line) <= 6);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("缩进放不下时丢缩进,行宽仍然不超过 width", () => {
|
|
68
|
+
const lines = renderCodeBlockLines("abcdef", 3, { measure, wrap, indent: " " });
|
|
69
|
+
assert.deepEqual(lines, ["abc", "def"]);
|
|
70
|
+
for (const line of lines) assert.ok(measure(line) <= 3);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("空代码块 / 高亮返回空数组都至少留一行", () => {
|
|
74
|
+
assert.deepEqual(renderCodeBlockLines("", 4, { measure, wrap }), [" "], "空代码块留一行,只带缩进");
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
renderCodeBlockLines("a", 4, { measure, wrap, highlight: () => [] }),
|
|
77
|
+
[" a"],
|
|
78
|
+
"高亮没产出时退回纯文本,不能一行都不画",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("语言标签与正文一起交给高亮函数", () => {
|
|
83
|
+
const seen: Array<[string, string | undefined]> = [];
|
|
84
|
+
const lines = renderCodeBlockLines("const x = 1;", 40, {
|
|
85
|
+
measure,
|
|
86
|
+
wrap,
|
|
87
|
+
lang: "js",
|
|
88
|
+
highlight: (code, lang) => {
|
|
89
|
+
seen.push([code, lang]);
|
|
90
|
+
return [`<hl>${code}</hl>`];
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
assert.deepEqual(seen, [["const x = 1;", "js"]]);
|
|
94
|
+
assert.deepEqual(lines, [" <hl>const x = 1;</hl>"], "着色行原样保留,不再包一层");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("没有高亮函数时用兜底着色(等价官方渲染器的 else 分支)", () => {
|
|
98
|
+
assert.deepEqual(renderCodeBlockLines("a", 8, { measure, wrap, fallbackStyle: (text) => `[${text}]` }), [" [a]"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("CJK 按两列计:折行按列,不按字符", () => {
|
|
102
|
+
assert.deepEqual(renderCodeBlockLines("中文中文", 10, { measure, wrap }), [" 中文中文"]);
|
|
103
|
+
// 可用宽度 6 列时装不下 8 列正文:切成 6 + 2。
|
|
104
|
+
assert.deepEqual(renderCodeBlockLines("中文中文", 8, { measure, wrap }), [" 中文中", " 文"]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
interface FakeCalls {
|
|
109
|
+
forwarded: unknown[][];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 假 Markdown 类:renderToken 记下参数并返回可辨认的文本。 */
|
|
113
|
+
function makeFakeMarkdown(theme: Record<string, unknown> = {}) {
|
|
114
|
+
const calls: FakeCalls = { forwarded: [] };
|
|
115
|
+
class FakeMarkdown {
|
|
116
|
+
theme: Record<string, unknown>;
|
|
117
|
+
constructor() {
|
|
118
|
+
this.theme = theme;
|
|
119
|
+
}
|
|
120
|
+
renderToken(token: { type?: string }, width: number, nextType?: string, styleContext?: unknown): string[] {
|
|
121
|
+
calls.forwarded.push([token, width, nextType, styleContext]);
|
|
122
|
+
return [`<<original ${token?.type}>>`];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { FakeMarkdown, calls };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("installFencelessCodeBlocks", () => {
|
|
129
|
+
it("只接管 code token:其它 token 连参数一起原样转给内置渲染器", () => {
|
|
130
|
+
const { FakeMarkdown, calls } = makeFakeMarkdown();
|
|
131
|
+
assert.equal(installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }), true);
|
|
132
|
+
|
|
133
|
+
const md = new FakeMarkdown();
|
|
134
|
+
const context = { marker: 1 };
|
|
135
|
+
assert.deepEqual(md.renderToken({ type: "paragraph" }, 12, "space", context), ["<<original paragraph>>"]);
|
|
136
|
+
assert.deepEqual(calls.forwarded, [[{ type: "paragraph" }, 12, "space", context]]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("code token 渲染成无围栏的行,并把 lang 交给实例主题上的高亮", () => {
|
|
140
|
+
const { FakeMarkdown } = makeFakeMarkdown({
|
|
141
|
+
codeBlockIndent: " ",
|
|
142
|
+
highlightCode: (code: string, lang?: string) => [`<${lang}>${code}`],
|
|
143
|
+
});
|
|
144
|
+
installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap });
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "const x = 1;", lang: "js" }, 20), [
|
|
147
|
+
" <js>const x = 1;",
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("采信实例 theme 上的 codeBlockIndent", () => {
|
|
152
|
+
const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " " });
|
|
153
|
+
installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap });
|
|
154
|
+
assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 6), [" a"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("装了两次只包一层(/reload 幂等)", () => {
|
|
158
|
+
const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " " });
|
|
159
|
+
assert.equal(installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }), true);
|
|
160
|
+
assert.equal(installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap }), false);
|
|
161
|
+
assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 4), [" a"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("nextType 不是 space 时补一个空行", () => {
|
|
165
|
+
const { FakeMarkdown } = makeFakeMarkdown({ codeBlockIndent: " " });
|
|
166
|
+
installFencelessCodeBlocks({ Markdown: FakeMarkdown, measure, wrap });
|
|
167
|
+
|
|
168
|
+
assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 3, "space"), [" a"]);
|
|
169
|
+
assert.deepEqual(new FakeMarkdown().renderToken({ type: "code", text: "a" }, 3, "paragraph"), [" a", ""]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("原型没有 renderToken 时什么都不做、不抛", () => {
|
|
173
|
+
const Markdown = { prototype: {} as Record<string | symbol, unknown> };
|
|
174
|
+
assert.equal(installFencelessCodeBlocks({ Markdown, measure, wrap }), false);
|
|
175
|
+
assert.equal(Object.keys(Markdown.prototype).length, 0);
|
|
176
|
+
});
|
|
177
|
+
});
|