@yinan_chen/pi-claude-code-ui 0.0.0-stage → 0.2.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -2
  3. package/package.json +34 -5
  4. package/src/claude-tools.ts +274 -0
  5. package/src/claude-view.ts +775 -0
  6. package/src/index.ts +566 -0
  7. package/src/pi-runtime.ts +232 -0
  8. package/src/preview.ts +147 -0
  9. package/src/running-effect.ts +29 -0
  10. package/src/skill-block-recollapse.ts +362 -0
  11. package/src/sticky-first-message.ts +1114 -0
  12. package/src/subagent-panel.ts +144 -0
  13. package/src/vendor/ATTRIBUTION.md +26 -0
  14. package/src/vendor/ansi-color.ts +44 -0
  15. package/src/vendor/ansi-text.ts +47 -0
  16. package/src/vendor/diff/ATTRIBUTION.md +28 -0
  17. package/src/vendor/diff/ansi-utils.ts +113 -0
  18. package/src/vendor/diff/diff-component.ts +125 -0
  19. package/src/vendor/diff/diff-edit-render.ts +237 -0
  20. package/src/vendor/diff/diff-header.ts +137 -0
  21. package/src/vendor/diff/diff-highlight.ts +115 -0
  22. package/src/vendor/diff/diff-inline.ts +324 -0
  23. package/src/vendor/diff/diff-layout.ts +762 -0
  24. package/src/vendor/diff/diff-limits.ts +229 -0
  25. package/src/vendor/diff/diff-palette.ts +410 -0
  26. package/src/vendor/diff/diff-parse.ts +535 -0
  27. package/src/vendor/diff/diff-presentation.ts +70 -0
  28. package/src/vendor/diff/diff-renderer.ts +23 -0
  29. package/src/vendor/diff/diff-text.ts +45 -0
  30. package/src/vendor/diff/diff-write-render.ts +572 -0
  31. package/src/vendor/diff/index.ts +126 -0
  32. package/src/vendor/diff/line-width-safety.ts +92 -0
  33. package/src/vendor/diff/shiki-highlight.ts +92 -0
  34. package/src/vendor/diff/write-execution.ts +113 -0
  35. package/src/vendor/diff-config.ts +30 -0
  36. package/src/vendor/show-more-hint.ts +38 -0
  37. package/src/vendor/tool-result-sanitize.ts +24 -0
  38. package/src/working-message.ts +140 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * skill-block-recollapse — 把展开的 skill 全文折回 `/skill:名字 参数`
3
+ *
4
+ * 覆盖四处 pi 没做折叠的地方:
5
+ * A. `/tree` 回溯 / `/fork` 后回填到「输入框」的文本
6
+ * B. `/tree` 列表里每一行的「预览文本」
7
+ * C. `/fork` 的「Fork from Message」候选列表
8
+ * D. `/resume` 会话列表里的首条消息摘要
9
+ *
10
+ * 上游已有两个同诉求 issue 被关(no-action):#4995(/tree、/fork 回填)、#7424(/resume
11
+ * 列表)。维护者的立场是「core 保持最小,这类需求用 extension 解决」,本文件就是那个 extension。
12
+ *
13
+ * ── 问题 ──────────────────────────────────────────────────────────────────
14
+ * `/skill:foo bar` 在提交时被 AgentSession._expandSkillCommand() 展开成
15
+ * <skill name="foo" location="/abs/SKILL.md">\n...SKILL.md 全文...\n</skill>\n\nbar
16
+ * 并以「展开后的全文」写入 session。聊天区显示成 `[skill] foo` 只是显示层的糖:
17
+ * TUI 用 parseSkillBlock() 识别这段文本后换成 SkillInvocationMessageComponent 折叠渲染。
18
+ *
19
+ * 但 `/tree` 回溯(AgentSession.navigateTree)用
20
+ * editorText = contentText(targetEntry.message.content, "")
21
+ * 把「原始展开全文」回填输入框,没做反向折叠 —— 于是输入框里出现一整篇 SKILL.md。
22
+ * `/fork` 走 runtimeHost.fork() → editor.setText(result.selectedText) 是同一个毛病。
23
+ *
24
+ * 同理 `/tree` 列表:TreeList.getEntryDisplayText() → extractContent() 只是把 text block
25
+ * 拼起来再截断 200 字符,于是一行预览就是 `user: <skill name="herdr" location=…> # Herdr …`。
26
+ * `/fork` 的候选列表更直接:UserMessageList.render() 拿 item.text 把\n换空格就上屏。
27
+ *
28
+ * ── 修法 A:输入框 ────────────────────────────────────────────────────────
29
+ * 事件后补:session_tree(/tree 回溯)与 session_start(/fork、/clone 会开新 session)
30
+ * 触发后,等 TUI 把文本写进编辑器,再读回来;命中 skill block 就改写成
31
+ * /skill:<name>[ <userMessage>]
32
+ * 光标随 setEditorText 落到末尾,可以直接继续敲参数或回车重发。
33
+ *
34
+ * ── 修法 B:tree 预览 ─────────────────────────────────────────────────────
35
+ * TreeList 类没有导出,但 TreeSelectorComponent 导出了,且它在构造函数里 new 了一个 TreeList
36
+ * 并用 getTreeList() 暴露。所以造一个空的探针实例 → 取到 TreeList.prototype → 包一层
37
+ * extractContent():先用 extractFullContent() 拿未截断的全文(原实现先截 200 字符,skill
38
+ * 块的 ^…$ 锚定正则会失配),命中就返回 `[skill] <name>`,与聊天区的折叠渲染一致。
39
+ *
40
+ * 原型补丁对 TUI 之后 new 出来的所有 TreeList 生效(bundle 内部 new 的是模块局部绑定,
41
+ * 替换导出符号没用,但它们共享同一个 prototype)。副作用:tree 内搜索(Type to search)
42
+ * 对 skill 正文不再命中 —— 但原实现已经只索引前 200 字符,实际损失接近于零,而技能名仍可搜。
43
+ *
44
+ * ── 修法 C:fork 候选列表 ───────────────────────────────────────────────
45
+ * 同样的探针手法:UserMessageSelectorComponent 导出了,getMessageList() 拿到内部的
46
+ * UserMessageList → prototype。包一层 render():上屏前把 this.messages 里命中 skill block
47
+ * 的 text 就地改写成 `[skill] <name>`。
48
+ * 就地改写是安全的:这个数组是 TUI 每次打开选择器时 `.map(m => ({id, text}))` 现造的副本,
49
+ * 而 onSelect 只用 id,真正回填编辑器的 selectedText 另走 runtimeHost.fork(entryId)。
50
+ *
51
+ * ── 修法 D:/resume 列表 ───────────────────────────────────────────────
52
+ * SessionList.render() 用 `session.name ?? session.firstMessage`,而 firstMessage 是
53
+ * buildSessionInfo() 直接取的首条 user 消息全文(未截断)。这里不走探针,而是直接包
54
+ * SessionManager.list / listAll 两个静态方法:调用方写的是 `SessionManager.list(…)`,
55
+ * 属性查找落在同一个类对象上,所以静态方法补丁能穿过模块局部绑定(这也是它比
56
+ * 替换导出符号可靠的原因)。包返回值足够的前提已变:0.86.0 起 --resume 改为渐进式
57
+ * 加载,onProgress 新增第三参 partialSessions(会话对象)直接流给 SessionSelector
58
+ * 渐进上屏(session-selector.js onProgress 分支),不经此补丁——加载过程中 skill
59
+ * 会话首消息会短暂闪现全文;resolve 后 SessionList.setSessions 用折叠过的最终列表
60
+ * 替换,最终显示仍折叠(一南 2026-09-20 目测验收)。若要消除闪烁,需把补丁扩展到
61
+ * onProgress 第三参。
62
+ *
63
+ * ── 时序 ──────────────────────────────────────────────────────────────────
64
+ * 事件在 navigateTree/fork 内部 await emit,TUI 的 editor.setText 在其返回后同步执行,
65
+ * 所以 setTimeout(0) 的宏任务天然排在写入之后。但 fork 路径要跨 session 重建 TUI,
66
+ * 时序没有保证,故用一串递增延迟轮询(0/16/50/120/250ms),命中即停。
67
+ *
68
+ * ── 安全性 ────────────────────────────────────────────────────────────────
69
+ * 只在编辑器内容「整体严格匹配」skill block 时才改写(parseSkillBlock 的正则带 ^…$ 锚定)。
70
+ * 用户自己手打的普通文本、粘贴的代码、含 <skill> 片段的说明文字都不会被动到。
71
+ * 用户若在补偿窗口内抢先改了内容,正则自然失配,本扩展直接放手。
72
+ */
73
+
74
+ import {
75
+ parseSkillBlock,
76
+ SessionManager,
77
+ TreeSelectorComponent,
78
+ UserMessageSelectorComponent,
79
+ type ExtensionAPI,
80
+ type ExtensionContext,
81
+ } from "@earendil-works/pi-coding-agent";
82
+ import { truncateToWidth } from "@earendil-works/pi-tui";
83
+ import { readFileSync } from "node:fs";
84
+ import { join } from "node:path";
85
+ // 只取纯常量/纯函数导出:pi-runtime 模块级零副作用,import 本身不触发 chunk 扫描
86
+ // (扫描仅在 getPiRuntime() 被调用时发生),版本核对因此不付扫描成本。
87
+ import { resolvePkgRoot, VERIFIED_PI_VERSION } from "./pi-runtime.ts";
88
+
89
+ /** TUI 写入编辑器与本扩展读取之间的补偿轮询点(毫秒)。命中即停。 */
90
+ const RETRY_DELAYS_MS = [0, 16, 50, 120, 250];
91
+
92
+ /** 原实现 extractContent() 的截断长度,补丁保持一致。 */
93
+ const TREE_PREVIEW_MAX_CHARS = 200;
94
+
95
+ /** 原型补丁的幂等标记,避免 /reload 后反复套娃。 */
96
+ const TREE_PATCHED = Symbol.for("skill-block-recollapse:treeListPatched");
97
+ const FORK_PATCHED = Symbol.for("skill-block-recollapse:userMessageListPatched");
98
+ const RESUME_PATCHED = Symbol.for("skill-block-recollapse:sessionListPatched");
99
+
100
+ /** 三处列表补丁的安装结果:false = 未装上(留待事件重试),session_start 据此做一次性有声降级。 */
101
+ const patchInstalled = { tree: false, fork: false, resume: false };
102
+
103
+ /** 有声降级幂等标记:挂 globalThis(Symbol.for),/reload 重载模块后同一进程不再重弹。 */
104
+ const DEGRADED_NOTIFIED = Symbol.for("skill-block-recollapse:degradedNotified");
105
+
106
+ /** 补丁失败点 → 用户可感知的折叠点名称(对应文件头 B/C/D 三个列表覆盖点;A 是事件后补,不走补丁)。 */
107
+ const PATCH_LABELS: Record<keyof typeof patchInstalled, string> = {
108
+ tree: "/tree 列表预览",
109
+ fork: "/fork 候选列表",
110
+ resume: "/resume 列表",
111
+ };
112
+
113
+ /** 当前 pi 版本(懒读一次,进程内复用);取自 pi 包根 package.json,与 _shared 同源避免硬编码漂移。 */
114
+ let piVersionMemo: string | undefined;
115
+ function currentPiVersion(): string {
116
+ if (piVersionMemo === undefined) {
117
+ try {
118
+ piVersionMemo = String(JSON.parse(readFileSync(join(resolvePkgRoot(), "package.json"), "utf8")).version ?? "");
119
+ } catch {
120
+ piVersionMemo = "";
121
+ }
122
+ }
123
+ return piVersionMemo;
124
+ }
125
+
126
+ /**
127
+ * 有声降级(每进程一次):补丁目标是 pi 私有 API,升级改名时只会静默 return false,
128
+ * 折叠功能无声消失。这里把「未装上」与「pi 版本 ≠ 逐版验证值」变成一条看得见的
129
+ * warning,明确告知对应点已回退原生显示,而不是让用户以为扩展坏了。
130
+ */
131
+ function notifyDegradedOnce(ctx: ExtensionContext): void {
132
+ if (!ctx.hasUI) return;
133
+ const g = globalThis as any;
134
+ if (g[DEGRADED_NOTIFIED]) return;
135
+
136
+ const parts: string[] = [];
137
+ const failed = (Object.keys(patchInstalled) as (keyof typeof patchInstalled)[]).filter((k) => !patchInstalled[k]);
138
+ if (failed.length) parts.push(`${failed.map((k) => PATCH_LABELS[k]).join("、")}补丁未装上,已回退原生显示`);
139
+ const version = currentPiVersion();
140
+ // 版本比对警告默认静默(一南 2026-10-04:不想每次 pi 升级都提示);PI_PATCH_VERSION_WARN=1 可临时启用。
141
+ // 真正的退化警告(上面的 failed 补丁未装上)不受此开关影响,出问题仍会提示。
142
+ if (version && version !== VERIFIED_PI_VERSION && (process.env.PI_PATCH_VERSION_WARN || "").trim() !== "")
143
+ parts.push(`pi ${version} ≠ 已验证 ${VERIFIED_PI_VERSION},列表折叠未逐版核验`);
144
+ if (!parts.length) return;
145
+
146
+ try {
147
+ ctx.ui.notify(`skill-block-recollapse: ${parts.join(";")}`, "warning");
148
+ g[DEGRADED_NOTIFIED] = true; // notify 成功才记账;UI 未就绪抛错时留给下个 session_start 重试
149
+ } catch {
150
+ /* 静默 */
151
+ }
152
+ }
153
+
154
+ /** 把展开的 skill block 还原成斜杠命令;不是 skill block 返回 null。 */
155
+ function collapseToSlashCommand(text: string): string | null {
156
+ const block = parseSkillBlock(text);
157
+ if (!block) return null;
158
+ return block.userMessage ? `/skill:${block.name} ${block.userMessage}` : `/skill:${block.name}`;
159
+ }
160
+
161
+ /**
162
+ * 列表类界面统一的单行标签:`[skill] name — 参数首行`(无参数则 `[skill] name`)。
163
+ * 聊天区折叠行只显示 `[skill] name`(pi 原生),列表预览多带参数首行是有意为之——列表里没有展开
164
+ * 的机会,参数是区分两次同名调用的唯一线索;sticky-first-message 的吸附泡用同一格式。
165
+ * 截断按可见宽度(不会切断代理对/组合字),与原实现的 200 字符上限对齐。
166
+ */
167
+ function collapseToPreviewLabel(text: string): string | null {
168
+ const block = parseSkillBlock(text);
169
+ if (!block) return null;
170
+ const msg = (block.userMessage ?? "").split("\n").map((l) => l.trim()).find(Boolean) ?? "";
171
+ const label = msg ? `[skill] ${block.name} — ${msg}` : `[skill] ${block.name}`;
172
+ return truncateToWidth(label, TREE_PREVIEW_MAX_CHARS, "…");
173
+ }
174
+
175
+ /**
176
+ * 给 TreeList.prototype.extractContent 套一层 skill 折叠。只做一次。
177
+ * @returns 是否已处于「已打好补丁」状态
178
+ */
179
+ function patchTreePreview(): boolean {
180
+ let proto: Record<string | symbol, unknown>;
181
+ try {
182
+ // 空 tree 会让构造函数排一个 setTimeout(onCancel, 100),传 noop 即可;探针实例不入任何容器,
183
+ // 构造完就丢,只为了拿 TreeList.prototype。那个定时器拿不到句柄,构造期间临时包一层
184
+ // setTimeout 给它 unref,别让探针把进程退出钉住 100ms。
185
+ const origSetTimeout = globalThis.setTimeout;
186
+ let probe: unknown;
187
+ try {
188
+ (globalThis as any).setTimeout = (fn: any, ms?: any, ...args: any[]) => {
189
+ const h = origSetTimeout(fn, ms, ...args);
190
+ (h as any)?.unref?.();
191
+ return h;
192
+ };
193
+ probe = new TreeSelectorComponent([], null, 20, () => {}, () => {});
194
+ } finally {
195
+ globalThis.setTimeout = origSetTimeout;
196
+ }
197
+ proto = Object.getPrototypeOf((probe as unknown as { getTreeList(): object }).getTreeList());
198
+ } catch {
199
+ // 主题/键位尚未初始化等情况:这次不打,下个事件再试。
200
+ return false;
201
+ }
202
+ if (proto[TREE_PATCHED]) return true;
203
+
204
+ const original = proto.extractContent as (content: unknown) => string;
205
+ const full = proto.extractFullContent as (content: unknown) => string;
206
+ if (typeof original !== "function" || typeof full !== "function") return false;
207
+
208
+ proto.extractContent = function patchedExtractContent(this: unknown, content: unknown): string {
209
+ let label: string | null = null;
210
+ try {
211
+ label = collapseToPreviewLabel(full.call(this, content));
212
+ } catch {
213
+ label = null;
214
+ }
215
+ return label ?? original.call(this, content);
216
+ };
217
+ proto[TREE_PATCHED] = true;
218
+ return true;
219
+ }
220
+
221
+ /**
222
+ * 给 UserMessageList.prototype.render 套一层 skill 折叠(/fork 的候选列表)。只做一次。
223
+ * @returns 是否已处于「已打好补丁」状态
224
+ */
225
+ function patchForkPreview(): boolean {
226
+ let proto: Record<string | symbol, unknown>;
227
+ try {
228
+ const probe = new UserMessageSelectorComponent([], () => {}, () => {});
229
+ proto = Object.getPrototypeOf((probe as unknown as { getMessageList(): object }).getMessageList());
230
+ } catch {
231
+ return false;
232
+ }
233
+ if (proto[FORK_PATCHED]) return true;
234
+
235
+ const original = proto.render as (width: number) => string[];
236
+ if (typeof original !== "function") return false;
237
+
238
+ proto.render = function patchedRender(this: { messages?: { text?: string }[] }, width: number): string[] {
239
+ try {
240
+ for (const item of this.messages ?? []) {
241
+ if (typeof item?.text !== "string") continue;
242
+ const label = collapseToPreviewLabel(item.text);
243
+ // 写回副本对象;onSelect 只用 id,不影响 fork 结果。就地改写的安全性依赖
244
+ // pi 0.86.0 interactive-mode.js:4400 打开选择器时 `userMessages.map(m => ({id, text}))`
245
+ // 现造副本的实现细节——升级 pi 时必须核验该调用点仍在造副本,否则会污染源消息。
246
+ if (label) item.text = label;
247
+ }
248
+ } catch {
249
+ /* 折叠失败不能阻断渲染 */
250
+ }
251
+ return original.call(this, width);
252
+ };
253
+ proto[FORK_PATCHED] = true;
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * 给 SessionManager.list / listAll 套一层 skill 折叠(/resume 列表)。只做一次。
259
+ *
260
+ * ⚠️ 全局副作用:这是类静态方法补丁,进程内所有调用方(不只 /resume 的 SessionList)拿到的
261
+ * SessionInfo.firstMessage 都会被改写成 `[skill] name — …`。目前 pi 内只有 /resume 列表读这个字段;
262
+ * 若将来有全文搜索/去重/摘要等功能依赖 firstMessage 原文,在这里排查。改的是本次扫描现造的快照,
263
+ * 不回写 .jsonl。可用 PI_SKILL_RECOLLAPSE_RESUME=0 关闭这一处。
264
+ * @returns 是否已处于「已打好补丁」状态
265
+ */
266
+ function patchResumePreview(): boolean {
267
+ if ((process.env.PI_SKILL_RECOLLAPSE_RESUME || "").trim() === "0") return true;
268
+ const cls = SessionManager as unknown as Record<string | symbol, unknown>;
269
+ if (cls[RESUME_PATCHED]) return true;
270
+
271
+ let wrapped = 0;
272
+ for (const name of ["list", "listAll"]) {
273
+ const original = cls[name];
274
+ if (typeof original !== "function") continue;
275
+ const fn = original as (...args: unknown[]) => Promise<{ firstMessage?: string }[]>;
276
+ cls[name] = function patchedList(this: unknown, ...args: unknown[]) {
277
+ return Promise.resolve(fn.apply(this, args)).then((sessions) => {
278
+ if (!Array.isArray(sessions)) return sessions;
279
+ for (const session of sessions) {
280
+ if (typeof session?.firstMessage !== "string") continue;
281
+ const label = collapseToPreviewLabel(session.firstMessage);
282
+ // 改的是本次列表扫描现造的 SessionInfo 快照,不回写任何 .jsonl。
283
+ if (label) session.firstMessage = label;
284
+ }
285
+ return sessions;
286
+ });
287
+ };
288
+ wrapped++;
289
+ }
290
+ if (wrapped === 0) return false;
291
+
292
+ cls[RESUME_PATCHED] = true;
293
+ return true;
294
+ }
295
+
296
+ export default function (pi: ExtensionAPI) {
297
+ /**
298
+ * 计划一次「读回编辑器 → 命中则折叠」的补偿。
299
+ * @param ctx 事件上下文,仅用 ctx.ui 的编辑器读写
300
+ */
301
+ function scheduleRecollapse(ctx: ExtensionContext): void {
302
+ // 只有 TUI 有真正的输入框;print / json / rpc 模式直接跳过。
303
+ if (ctx.mode !== "tui") return;
304
+
305
+ let settled = false;
306
+
307
+ function attempt(): void {
308
+ if (settled) return;
309
+ let current: string;
310
+ try {
311
+ current = ctx.ui.getEditorText();
312
+ } catch {
313
+ // 编辑器暂不可用(/fork 跨 session 重建中途、自定义 editor 尚未就位):只跳过本次,
314
+ // 后续 16/50/120/250ms 的补偿点继续尝试;不能在这里 settled。
315
+ return;
316
+ }
317
+ const collapsed = collapseToSlashCommand(current);
318
+ if (collapsed === null) return; // 还没写入,或本来就不是 skill block
319
+ settled = true;
320
+ try {
321
+ ctx.ui.setEditorText(collapsed);
322
+ } catch {
323
+ /* 写入失败不影响会话,静默 */
324
+ }
325
+ }
326
+
327
+ for (const delay of RETRY_DELAYS_MS) {
328
+ // 别让补偿定时器把进程钉住(退出时 pi 不必等它)。
329
+ (setTimeout(attempt, delay) as unknown as { unref?: () => void }).unref?.();
330
+ }
331
+ }
332
+
333
+ // 即刻尝试一次:/reload 后不用等下一个事件,当场就能开 /tree、/fork 看到折叠效果。
334
+ // 非 TUI(主题未初始化)会安静失败返回 false,留给 session_start 重试。
335
+ patchInstalled.tree = patchTreePreview();
336
+ patchInstalled.fork = patchForkPreview();
337
+ // 不依赖主题/TUI,开机就能打上(首次 /resume 可能早于任何事件)。
338
+ patchInstalled.resume = patchResumePreview();
339
+
340
+ /** 打三个列表预览补丁;失败则留待下次事件重试。 */
341
+ function ensurePreviewsPatched(ctx: ExtensionContext): void {
342
+ if (ctx.mode !== "tui") return;
343
+ if (!patchInstalled.tree) patchInstalled.tree = patchTreePreview();
344
+ if (!patchInstalled.fork) patchInstalled.fork = patchForkPreview();
345
+ if (!patchInstalled.resume) patchInstalled.resume = patchResumePreview();
346
+ }
347
+
348
+ // /tree 回溯到某条 user 消息 → navigateTree 返回 editorText 后回填
349
+ pi.on("session_tree", async (_event, ctx) => {
350
+ ensurePreviewsPatched(ctx);
351
+ scheduleRecollapse(ctx);
352
+ });
353
+
354
+ // /fork、/clone、resume:新 session 起来后 TUI 才 setText(selectedText)
355
+ // 同时这里是打预览补丁最早的可靠时机(主题已初始化)。
356
+ pi.on("session_start", async (_event, ctx) => {
357
+ ensurePreviewsPatched(ctx);
358
+ // 补丁仍未装上/版本不符 → 一次性有声降级(幂等标记在 globalThis,reload 不会重弹)。
359
+ notifyDegradedOnce(ctx);
360
+ scheduleRecollapse(ctx);
361
+ });
362
+ }