@yinan_chen/pi-claude-code-ui 0.2.1 → 0.5.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/README.md CHANGED
@@ -21,11 +21,67 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
21
21
  - **Claude 式视图** — 对话转录适配器,Pi 原生回退
22
22
  - **吸附消息泡(sticky)** — 滚动时顶部吸附显示当前正在回答的用户消息原文,点击回位(v0.2.0 内置,原 sticky-first-message 扩展)
23
23
  - **Skill 折叠(recollapse)** — 展开的 skill 全文自动折回 `/skill:名 参数`,覆盖 /tree、/fork 回填等四处盲区(v0.2.0 内置,原 skill-block-recollapse 扩展)
24
+ - **第三方渲染规范化** — 任何扩展的卡片(如 pi-subagents 的 workflow 卡)行首 `⎿` 自动统一为 `resultPrefix`,不依赖适配(v0.5.0)
24
25
  - **富 diff 渲染** — edit/write 工具的语法高亮 diff(vendored, MIT)
25
26
  - **子代理面板** — 后台 agent 状态一目了然
26
27
  - **工作状态提示** — working message / running effect
27
28
  - **只读工具美化** — read/bash/grep/find/ls 结果渲染
28
29
 
30
+ ## 配置
31
+
32
+ 配置文件:`~/.pi/agent/lean-tidy.json`(不存在时全部使用默认值):
33
+
34
+ ```json
35
+ {
36
+ "profile": "claude",
37
+ "skillRendering": "native",
38
+ "subagentRendering": "claude",
39
+ "detailsBackground": "selectedBg",
40
+ "bashPreviewLines": 0,
41
+ "codePreviewLines": 0,
42
+ "errorPreviewLines": 0,
43
+ "liveThinking": true,
44
+ "thinkingTailLines": 5,
45
+ "foldAnimMs": 240,
46
+ "stickyIncludeCustom": false,
47
+ "skillResumePreview": true,
48
+ "resultPrefix": "└",
49
+ "resultPrefixGap": 0,
50
+ "normalizeForeignRenderers": true,
51
+ "diff": {
52
+ "diffViewMode": "auto",
53
+ "diffIndicatorMode": "bars",
54
+ "diffSplitMinWidth": 120,
55
+ "editDiffCollapsedLines": 0,
56
+ "writeDiffCollapsedLines": 0,
57
+ "diffWordWrap": true,
58
+ "expandedPreviewMaxLines": 40
59
+ }
60
+ }
61
+ ```
62
+
63
+ | 键 | 默认 | 说明 |
64
+ |---|---|---|
65
+ | `profile` | `"claude"` | 整体风格:`claude` / `native`(pi 原生) |
66
+ | `skillRendering` | `"native"` | skill 块样式:pi 原生折叠行 / `claude` 两行风格 |
67
+ | `subagentRendering` | `"claude"` | 子代理完成通知:`claude` 分支风格 / `native` 保留包内面板 |
68
+ | `detailsBackground` | `"selectedBg"` | 详情背景色(主题色键名,可选 `customMessageBg`/`toolPendingBg`/`toolSuccessBg`/`toolErrorBg`/`""`) |
69
+ | `bash/code/errorPreviewLines` | `0`/`0`/`0` | 三类工具结果的预览行数(0–100;0 = 默认折叠) |
70
+ | `liveThinking` | `true` | 思考过程流式展示 |
71
+ | `thinkingTailLines` | `5` | 思考尾行数 |
72
+ | `foldAnimMs` | `240` | 折叠动画时长(ms,0 关闭) |
73
+ | `stickyIncludeCustom` | `false` | 吸附泡是否包含扩展注入/跨会话转发的旁路消息 |
74
+ | `skillResumePreview` | `true` | `/resume` 列表预览是否折回 `[skill] 名` 单行 |
75
+ | `resultPrefix` | `"└"` | 工具结果行拐角前缀(制表符族,等宽字体最稳;可换 `↳` 或 Claude Code 官方的 `⎿`,后两者在部分字体下会变宽) |
76
+ | `resultPrefixGap` | `0` | 前缀符号与内容之间的空格数(0–4) |
77
+ | `normalizeForeignRenderers` | `true` | 第三方扩展卡片输出里行首 `⎿` 自动规范化为 `resultPrefix`(任何未适配扩展的渲染都能统一风格;关闭可看第三方原版) |
78
+ | `diff.diffViewMode` | `"auto"` | diff 视图:`auto`/`split`/`unified` |
79
+ | `diff.diffIndicatorMode` | `"bars"` | 变更指示条:`bars`/`classic`/`none` |
80
+ | `diff.diffSplitMinWidth` | `120` | 宽于此才用 split 视图 |
81
+ | `diff.edit/writeDiffCollapsedLines` | `0`/`0` | edit/write 默认折叠行数(0 = 默认展开) |
82
+ | `diff.diffWordWrap` | `true` | diff 自动换行 |
83
+ | `diff.expandedPreviewMaxLines` | `40` | 展开后最多显示行数 |
84
+
29
85
  ## 兼容性
30
86
 
31
87
  按 `VERIFIED_PI_VERSION = 1.0.2` 验收;版本不一致时扩展会在 session 启动时提示一次。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yinan_chen/pi-claude-code-ui",
3
- "version": "0.2.1",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code-style UI for Pi: transcript view, rich diffs, subagent panels and working-message status.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,6 +3,15 @@ import { getLanguageFromPath, highlightCode, keyText } from "@earendil-works/pi-
3
3
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
4
  import { homedir } from "node:os";
5
5
 
6
+ /** UI 符号集(lean-tidy.json 可覆盖;见 index.ts loadConfig 的 resultPrefix)。 */
7
+ export const SYMBOLS = { resultPrefix: "└", resultPrefixGap: 0 };
8
+
9
+ /** 结果行缩进对:first = 3 空格 + 符号 + gap 空格;cont = 4 + gap 空格。运行时读 SYMBOLS。 */
10
+ export function resultIndent(theme: any): { first: string; cont: string } {
11
+ return { first: ` ${fg(theme, "dim", SYMBOLS.resultPrefix)}${" ".repeat(SYMBOLS.resultPrefixGap)}`, cont: RESULT_CONT() };
12
+ }
13
+ export const RESULT_CONT = (): string => " ".repeat(4 + SYMBOLS.resultPrefixGap);
14
+
6
15
  export interface ToolView {
7
16
  name: string;
8
17
  args: Record<string, any>;
@@ -194,13 +203,13 @@ export function summaryOf(v: ToolView, theme: any): string | undefined {
194
203
  export function paintTool(v: ToolView, theme: any, width: number, config: ToolAppearance): PaintedTool {
195
204
  const lines = headerOf(v, theme, width), headerHeight = lines.length;
196
205
  let moreRow: number | undefined;
197
- const result = (s: string) => lines.push(...prefixed(s, width, ` ${fg(theme, "dim", "⎿")} `, " "));
206
+ const result = (s: string) => lines.push(...prefixed(s, width, resultIndent(theme).first, RESULT_CONT()));
198
207
  const body = (source: string[], limit: number, full = v.expanded, firstResult = false) => {
199
208
  const shown = full ? source : source.slice(0, limit);
200
209
  let clipped = false;
201
210
  shown.forEach((line, i) => {
202
- const first = firstResult && i === 0 ? ` ${fg(theme, "dim", "⎿")} ` : " ";
203
- if (full) lines.push(...prefixed(line, width, first, " "));
211
+ const first = firstResult && i === 0 ? resultIndent(theme).first : RESULT_CONT();
212
+ if (full) lines.push(...prefixed(line, width, first, RESULT_CONT()));
204
213
  else {
205
214
  const pw = Math.min(6, Math.max(0, width - 1)), bw = Math.max(1, width - pw);
206
215
  clipped ||= visibleWidth(line) > bw;
@@ -1,7 +1,7 @@
1
1
  /** Pi-specific transcript adapter. All private component fields are isolated here. */
2
2
  import { Box, Container, Markdown, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
4
- import { BUILTIN_NAMES, PROCESS_TOOL_NAMES, bold, canExpandTool, expandHint, failedTool, fg, fit, headerOf, isBlank, isImageLine, oneLine, paintBackground, paintTool, prefixed, prefixZones, targetOf, type ToolAppearance, type ToolView } from "./claude-tools.ts";
4
+ import { BUILTIN_NAMES, PROCESS_TOOL_NAMES, bold, canExpandTool, expandHint, failedTool, fg, fit, headerOf, isBlank, isImageLine, oneLine, paintBackground, paintTool, prefixed, prefixZones, resultIndent, RESULT_CONT, targetOf, type ToolAppearance, type ToolView } from "./claude-tools.ts";
5
5
  import { animateRunningSummary } from "./running-effect.ts";
6
6
 
7
7
  export interface ClaudeConfig extends ToolAppearance {
@@ -313,7 +313,7 @@ export function createClaudeView(
313
313
  if (raw.length > rowsLeft) { hidden += raw.length - rowsLeft; raw = raw.slice(0, Math.max(0, rowsLeft)); }
314
314
  rowsLeft -= raw.length;
315
315
  const start = lines.length;
316
- for (let i = 0; i < raw.length; i++) lines.push(isImageLine(raw[i]) ? raw[i] : fit(truncateToWidth(start + i === 0 ? ` ${fg(theme(), "dim", "⎿")} ` : " ", indent, "") + raw[i], width));
316
+ for (let i = 0; i < raw.length; i++) lines.push(isImageLine(raw[i]) ? raw[i] : fit(truncateToWidth(start + i === 0 ? resultIndent(theme()).first : RESULT_CONT(), indent, "") + raw[i], width));
317
317
  const proxy = {
318
318
  handleMouse(e: any) {
319
319
  if (e.x < indent) return undefined;
@@ -346,7 +346,7 @@ export function createClaudeView(
346
346
  const last = entries.at(-1);
347
347
  if (last && --last.height <= 0) entries.pop();
348
348
  }
349
- const more = prefixed(fg(theme(), "dim", `… +${hidden} lines`) + expandHint(theme()), width, ` ${fg(theme(), "dim", "⎿")} `, " ");
349
+ const more = prefixed(fg(theme(), "dim", `… +${hidden} lines`) + expandHint(theme()), width, resultIndent(theme()).first, RESULT_CONT());
350
350
  lines.push(...more);
351
351
  entries.push({ component: toggleTool(self), height: more.length });
352
352
  }
@@ -368,7 +368,7 @@ export function createClaudeView(
368
368
  lines = [...head, ...(body?.lines ?? [])];
369
369
  entries = [{ component: toggleTool(self), height: head.length }, ...(body?.entries ?? [])];
370
370
  if (v.partial && !body?.lines.length) {
371
- const pending = prefixed(fg(t, "dim", v.started ? "Running…" : "Waiting…"), width, ` ${fg(t, "dim", "⎿")} `, " ");
371
+ const pending = prefixed(fg(t, "dim", v.started ? "Running…" : "Waiting…"), width, resultIndent(t).first, RESULT_CONT());
372
372
  lines.push(...pending); entries.push({ component: {}, height: pending.length });
373
373
  }
374
374
  } else {
@@ -390,12 +390,12 @@ export function createClaudeView(
390
390
  // The vendored renderer limits body rows and supplies its own remainder hint.
391
391
  // Do not also slice by codePreviewLines: that counts the diff header/frame
392
392
  // against the body and hides almost every changed row.
393
- const prefix = ` ${fg(t, "dim", "⎿")} `;
393
+ const prefix = resultIndent(t).first;
394
394
  // The rich edit header already reports +N/-M; do not repeat
395
395
  // paintTool's "Added N lines, removed M lines" summary above it.
396
396
  const introRows = painted.headerHeight + (v.name === "edit" ? 0 : 1);
397
397
  lines = [...painted.lines.slice(0, introRows),
398
- ...detail.map((line, i) => fit((i === 0 ? prefix : " ") + line, width))];
398
+ ...detail.map((line, i) => fit((i === 0 ? prefix : RESULT_CONT()) + line, width))];
399
399
  } catch {
400
400
  // Keep the existing edit/write renderer as a fail-open fallback.
401
401
  }
@@ -610,7 +610,7 @@ export function createClaudeView(
610
610
  if (active && tools.length) {
611
611
  const focus = [...tools].reverse().find((p) => p.owner.isPartial) ?? tools.at(-1)!;
612
612
  const v = toolView(focus.owner), target = targetOf(v.name, v.args, v.cwd).text;
613
- if (target) rows.push(...prefixed(fg(theme(), "muted", (v.name === "bash" || v.name === "powershell" ? "$ " : "") + target), width, ` ${fg(theme(), "dim", "⎿")} `, " ").slice(0, 2));
613
+ if (target) rows.push(...prefixed(fg(theme(), "muted", (v.name === "bash" || v.name === "powershell" ? "$ " : "") + target), width, resultIndent(theme()).first, RESULT_CONT()).slice(0, 2));
614
614
  }
615
615
  append(rows, toggle);
616
616
  // Failures stay folded like the rest: the summary counts them ("N failed")
@@ -700,7 +700,7 @@ export function createClaudeView(
700
700
  return trimmed;
701
701
  }
702
702
  const name = oneLine(self.skillBlock.name), lines = [` ${fg(theme(), "success", "●")} ${bold(theme(), "Skill")}(${name})`];
703
- lines.push(...prefixed(fg(theme(), "muted", "Loaded skill") + (self.expanded ? "" : ` (${keyText("app.tools.expand")} to expand)`), width, ` ${fg(theme(), "dim", "⎿")} `, " "));
703
+ lines.push(...prefixed(fg(theme(), "muted", "Loaded skill") + (self.expanded ? "" : ` (${keyText("app.tools.expand")} to expand)`), width, resultIndent(theme()).first, RESULT_CONT()));
704
704
  if (self.expanded) {
705
705
  const md = new Markdown(self.skillBlock.content ?? "", 0, 0, self.markdownTheme ?? getMarkdownTheme());
706
706
  for (const l of md.render(Math.max(1, width - 3))) lines.push(fit(" " + l, width));
@@ -0,0 +1,59 @@
1
+ /**
2
+ * foreign-normalizer — 第三方渲染输出的符号规范化层(v0.5.0)。
3
+ *
4
+ * 动机:任何扩展都能 registerMessageRenderer / registerEntryRenderer 输出
5
+ * 自带风格的卡片(如 @tintinweb/pi-subagents 的 workflow 卡片硬编码 ⎿)。
6
+ * lean-tidy 无法逐个接管——类型无限多,且"先加载者胜"的分发顺序不受本包
7
+ * 控制(依赖包加载序)。一劳永逸的做法:在 pi 的两个渲染宿主组件
8
+ * (CustomMessageComponent = custom 消息宿主;CustomEntryComponent =
9
+ * custom 会话条目宿主,如 workflow 卡片)的 render 输出上做行级后处理:
10
+ *
11
+ * 行首(仅空白/ANSI 转义之后)的 ⎿ 等宽替换为 SYMBOLS.resultPrefix。
12
+ *
13
+ * 性质:
14
+ * - 宽度守恒(⎿ 与 └ 均 1 列),布局与测量零影响;正文里的 ⎿ 不动。
15
+ * - 幂等:lean-tidy 自身渲染已是 resultPrefix,不匹配 ⎿ 模式,无叠加。
16
+ * - trampoline:wrapper 每次调用读 globalThis.__leanTidyImpl.normalizeForeign
17
+ * (由 index.ts 组装,闭包捕获当次 loadConfig 的开关与 SYMBOLS),
18
+ * /reload 后新配置立即生效,无需摘除原型补丁。
19
+ * - 关闭(normalizeForeignRenderers=false)时 wrapper 空转透传。
20
+ */
21
+ import { SYMBOLS } from "./claude-tools.ts";
22
+
23
+ const FLAG = Symbol.for("lean-tidy.foreign-normalizer");
24
+ /** 行首到 ⎿ 之间只允许空白与 ANSI SGR 转义序列。 */
25
+ const LEADING = /^((?:\s|\x1b\[[0-9;]*m)*)⎿/;
26
+
27
+ /** 行级符号规范化:仅替换行首 ⎿(等宽),其余原样;无变化时返回原数组。 */
28
+ export function normalizeForeignLines(lines: readonly string[]): string[] {
29
+ const sym = SYMBOLS.resultPrefix;
30
+ let changed = false;
31
+ const out = lines.map((line) => {
32
+ if (typeof line !== "string" || !line.includes("⎿")) return line;
33
+ const replaced = line.replace(LEADING, `$1${sym}`);
34
+ if (replaced !== line) changed = true;
35
+ return replaced;
36
+ });
37
+ return changed ? out : (lines as string[]);
38
+ }
39
+
40
+ /**
41
+ * patch 渲染宿主组件类的 prototype.render(幂等,FLAG 防重复包裹)。
42
+ * wrapper 不做任何本地决策,全部经 __leanTidyImpl trampoline 转发。
43
+ */
44
+ export function patchForeignHost(cls: any): boolean {
45
+ const proto = cls?.prototype;
46
+ if (!proto || typeof proto.render !== "function" || proto[FLAG]) return false;
47
+ const orig = proto.render;
48
+ proto[FLAG] = true;
49
+ proto.render = function (width: number) {
50
+ const lines = orig.call(this, width);
51
+ const impl = (globalThis as any).__leanTidyImpl;
52
+ try {
53
+ return typeof impl?.normalizeForeign === "function" ? impl.normalizeForeign(lines) : lines;
54
+ } catch {
55
+ return lines;
56
+ }
57
+ };
58
+ return true;
59
+ }
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ import { createClaudeView } from "./claude-view.ts";
29
29
  import registerSubagentPanels from "./subagent-panel.ts";
30
30
  import stickyFirstMessage from "./sticky-first-message.ts";
31
31
  import registerSkillRecollapse from "./skill-block-recollapse.ts";
32
+ import { SYMBOLS } from "./claude-tools.ts";
33
+ import { normalizeForeignLines, patchForeignHost } from "./foreign-normalizer.ts";
32
34
  import registerWorkingMessage from "./working-message.ts";
33
35
  // Vendored rich diff (MIT; see vendor/diff/ATTRIBUTION.md).
34
36
  import { renderRichToolResult, WriteExecutionMetadataStore } from "./vendor/diff/index.ts";
@@ -76,6 +78,16 @@ interface Config {
76
78
  liveThinking: boolean;
77
79
  thinkingTailLines: number;
78
80
  foldAnimMs: number;
81
+ /** sticky 吸附池是否含 CustomMessage(扩展注入/跨会话转发),默认 false。 */
82
+ stickyIncludeCustom: boolean;
83
+ /** /resume 列表预览是否折回 [skill] 名,默认 true。 */
84
+ skillResumePreview: boolean;
85
+ /** 工具结果行的拐角前缀符号(默认 └;可换 ↳ 或 Claude Code 官方的 ⎿)。 */
86
+ resultPrefix: string;
87
+ /** 前缀符号与内容之间的空格数(0–4,默认 0)。 */
88
+ resultPrefixGap: number;
89
+ /** 第三方扩展渲染输出的行首 ⎿ 是否规范化为 resultPrefix(默认 true)。 */
90
+ normalizeForeignRenderers: boolean;
79
91
  }
80
92
 
81
93
  function loadConfig(): Config {
@@ -84,12 +96,17 @@ function loadConfig(): Config {
84
96
  skillRendering: "native",
85
97
  subagentRendering: "claude",
86
98
  detailsBackground: "selectedBg",
87
- bashPreviewLines: 1,
88
- codePreviewLines: 3,
89
- errorPreviewLines: 1,
99
+ bashPreviewLines: 0,
100
+ codePreviewLines: 0,
101
+ errorPreviewLines: 0,
90
102
  liveThinking: true,
91
- thinkingTailLines: 3,
103
+ thinkingTailLines: 5,
92
104
  foldAnimMs: 240,
105
+ stickyIncludeCustom: false,
106
+ skillResumePreview: true,
107
+ resultPrefix: "└",
108
+ resultPrefixGap: 0,
109
+ normalizeForeignRenderers: true,
93
110
  };
94
111
  diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG };
95
112
  try {
@@ -108,6 +125,11 @@ function loadConfig(): Config {
108
125
  config.thinkingTailLines = Math.floor(data.thinkingTailLines);
109
126
  }
110
127
  if (Number.isFinite(data?.foldAnimMs) && data.foldAnimMs >= 0) config.foldAnimMs = Math.floor(data.foldAnimMs);
128
+ if (typeof data?.stickyIncludeCustom === "boolean") config.stickyIncludeCustom = data.stickyIncludeCustom;
129
+ if (typeof data?.skillResumePreview === "boolean") config.skillResumePreview = data.skillResumePreview;
130
+ if (typeof data?.resultPrefix === "string" && data.resultPrefix.trim()) config.resultPrefix = data.resultPrefix;
131
+ if (Number.isInteger(data?.resultPrefixGap) && data.resultPrefixGap >= 0 && data.resultPrefixGap <= 4) config.resultPrefixGap = data.resultPrefixGap;
132
+ if (typeof data?.normalizeForeignRenderers === "boolean") config.normalizeForeignRenderers = data.normalizeForeignRenderers;
111
133
  if (data?.diff && typeof data.diff === "object" && !Array.isArray(data.diff)) {
112
134
  diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG, ...data.diff };
113
135
  }
@@ -329,7 +351,8 @@ function patchModule(mod: any, where: string): number {
329
351
  try { cls = mod[key]; } catch { continue; }
330
352
  if (typeof cls !== "function") continue;
331
353
  let did = false;
332
- if (key.includes("ToolExecution")) did = patchToolExec(cls);
354
+ if (key === "CustomMessageComponent" || key === "CustomEntryComponent") did = patchForeignHost(cls);
355
+ else if (key.includes("ToolExecution")) did = patchToolExec(cls);
333
356
  else if (key.includes("AssistantMessage")) did = patchAssistant(cls);
334
357
  else if (key.includes("UserMessage")) did = patchUserMessage(cls);
335
358
  else if (key.includes("SkillInvocation")) did = patchSkill(cls);
@@ -367,9 +390,11 @@ async function patchAllCopies(): Promise<ScanInfo> {
367
390
 
368
391
  export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
369
392
  registerWorkingMessage(pi);
370
- await stickyFirstMessage(pi);
371
- registerSkillRecollapse(pi);
372
393
  const config = loadConfig();
394
+ SYMBOLS.resultPrefix = config.resultPrefix;
395
+ SYMBOLS.resultPrefixGap = config.resultPrefixGap;
396
+ await stickyFirstMessage(pi, { includeCustom: config.stickyIncludeCustom });
397
+ registerSkillRecollapse(pi, { resumePreview: config.skillResumePreview });
373
398
  // Subagent 通知接管必须发生在加载期(registerMessageRenderer 仅 factory 内合法):
374
399
  // lean-tidy 先于 npm 包加载,首个命中 customType 的渲染器胜出(一南 2026-02-12)。
375
400
  // redraw 闭包延迟读 LAST_MODE(hookMode 后才看得到 TUI)。
@@ -405,6 +430,8 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
405
430
  hookMode,
406
431
  containerRender: (container: any, width: number, chat: boolean): string[] =>
407
432
  claude && chat ? claude.renderChat(container, width) : Container.prototype.render.call(container, width),
433
+ normalizeForeign: (lines: readonly string[]): string[] =>
434
+ config.normalizeForeignRenderers ? normalizeForeignLines(lines) : (lines as string[]),
408
435
  } as RuntimeImpl;
409
436
  for (const method of IMPL_METHODS) {
410
437
  impl[method] = (self, orig, ...args) => {
@@ -263,8 +263,11 @@ function patchForkPreview(): boolean {
263
263
  * 不回写 .jsonl。可用 PI_SKILL_RECOLLAPSE_RESUME=0 关闭这一处。
264
264
  * @returns 是否已处于「已打好补丁」状态
265
265
  */
266
+ /** /resume 列表预览改写:json 显式配置(skillResumePreview) > env 兼容 > 默认开启 */
267
+ let RESUME_PREVIEW = true;
268
+
266
269
  function patchResumePreview(): boolean {
267
- if ((process.env.PI_SKILL_RECOLLAPSE_RESUME || "").trim() === "0") return true;
270
+ if (!RESUME_PREVIEW) return true;
268
271
  const cls = SessionManager as unknown as Record<string | symbol, unknown>;
269
272
  if (cls[RESUME_PATCHED]) return true;
270
273
 
@@ -293,7 +296,9 @@ function patchResumePreview(): boolean {
293
296
  return true;
294
297
  }
295
298
 
296
- export default function (pi: ExtensionAPI) {
299
+ export default function (pi: ExtensionAPI, opts?: { resumePreview?: boolean }) {
300
+ // json 配置优先,未提供时回退旧 env 逃生口(PI_SKILL_RECOLLAPSE_RESUME=0),再回退默认 true。
301
+ RESUME_PREVIEW = opts?.resumePreview ?? (process.env.PI_SKILL_RECOLLAPSE_RESUME || "").trim() !== "0";
297
302
  /**
298
303
  * 计划一次「读回编辑器 → 命中则折叠」的补偿。
299
304
  * @param ctx 事件上下文,仅用 ctx.ui 的编辑器读写
@@ -67,7 +67,10 @@ const VERSION = "2.2.1-20260910";
67
67
 
68
68
  const env = (k: string) => (process.env[k] || "").trim();
69
69
  const killed = () => env("PI_STICKY_FIRST_MESSAGE") === "0";
70
- const includeCustom = () => env("PI_STICKY_INCLUDE_CUSTOM") === "1";
70
+ /** 是否吸附 CustomMessage:json 显式配置(stickyIncludeCustom,入口传入) > env 兼容 > 默认不吸附。
71
+ * 每次调用动态求值,json 未提供(undefined)时 env 可随时启停。 */
72
+ let OPTS_INCLUDE_CUSTOM: boolean | undefined;
73
+ const includeCustom = () => OPTS_INCLUDE_CUSTOM ?? env("PI_STICKY_INCLUDE_CUSTOM") === "1";
71
74
  /** 健康日志 + 快照落盘开关;默认关闭,不产生任何文件(状态机仍运行,仅不写盘) */
72
75
  const healthEnabled = () => env("PI_STICKY_HEALTH") === "1";
73
76
 
@@ -1087,7 +1090,9 @@ export const __stickyTest = {
1087
1090
  }),
1088
1091
  };
1089
1092
 
1090
- export default async function stickyFirstMessage(pi: ExtensionAPI): Promise<void> {
1093
+ export default async function stickyFirstMessage(pi: ExtensionAPI, opts?: { includeCustom?: boolean }): Promise<void> {
1094
+ // json 配置优先(undefined = 未配置,回退旧 env 开关 PI_STICKY_INCLUDE_CUSTOM=1,再回退默认 false)。
1095
+ OPTS_INCLUDE_CUSTOM = opts?.includeCustom;
1091
1096
  // 主题探针:零高 widget,只为拿到 (tui, theme) 里的 theme(泡的底色/前景跟随主题)
1092
1097
  pi.on("session_start", async (_e, ctx: any) => {
1093
1098
  if (!ctx?.hasUI || typeof ctx.ui?.setWidget !== "function") return;
@@ -22,7 +22,7 @@
22
22
  * host 驱动,两者 OR 叠加。
23
23
  */
24
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
- import { bold, expandHint, fg, fit, linesOf, oneLine, prefixed } from "./claude-tools.ts";
25
+ import { bold, expandHint, fg, fit, linesOf, oneLine, prefixed, resultIndent, RESULT_CONT } from "./claude-tools.ts";
26
26
 
27
27
  /** 只用到 pi 传入 options 的 expanded;outputPad 由内置 Spacer(1) 承担,忽略。 */
28
28
  interface PanelOptions {
@@ -35,8 +35,8 @@ export interface SubagentPanelHooks {
35
35
  }
36
36
 
37
37
  /** ⎿ 分支前缀,与 claude-view.ts 工具结果/技能块同款(3 空格 + ⎿ + 2 空格,续行 6 空格)。 */
38
- const branch = (theme: any): string => ` ${fg(theme, "dim", "⎿")} `;
39
- const BRANCH_REST = " ";
38
+ const branch = (theme: any): string => resultIndent(theme).first;
39
+ const BRANCH_REST = () => RESULT_CONT();
40
40
 
41
41
  /** 与该包 ui/agent-widget.ts 的 formatTokens 对齐:"33.8k token" / "1.2M token"。 */
42
42
  function tokensText(count: unknown): string {
@@ -85,18 +85,18 @@ function panelLines(d: any, expanded: boolean, theme: any, width: number): strin
85
85
  if (stats.length) tail += fg(theme, "dim", ` · ${stats.join(" · ")}`);
86
86
 
87
87
  const out = [fit(` ${icon} ${bold(theme, "Subagent")}(${description})`, width)];
88
- out.push(...prefixed(tail, width, first, BRANCH_REST));
88
+ out.push(...prefixed(tail, width, first, BRANCH_REST()));
89
89
 
90
90
  // 展开态:resultPreview 全文(其本身已在包内截到 resultMaxLen)+ transcript 路径
91
91
  if (expanded) {
92
92
  const body = linesOf(typeof d?.resultPreview === "string" ? d.resultPreview : "");
93
- for (const line of body) out.push(...prefixed(fg(theme, "dim", line), width, first, BRANCH_REST));
93
+ for (const line of body) out.push(...prefixed(fg(theme, "dim", line), width, first, BRANCH_REST()));
94
94
  if (typeof d?.outputFile === "string" && d.outputFile) {
95
- out.push(...prefixed(fg(theme, "dim", `transcript: ${d.outputFile}`), width, first, BRANCH_REST));
95
+ out.push(...prefixed(fg(theme, "dim", `transcript: ${d.outputFile}`), width, first, BRANCH_REST()));
96
96
  }
97
97
  } else if (typeof d?.resultPreview === "string" && d.resultPreview.trim()) {
98
98
  // 折叠态不显示正文,但保留可展开提示(有正文才提示)
99
- out.push(...prefixed(expandHint(theme), width, first, BRANCH_REST));
99
+ out.push(...prefixed(expandHint(theme), width, first, BRANCH_REST()));
100
100
  }
101
101
  return out;
102
102
  }
@@ -20,7 +20,7 @@ export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
20
20
  diffViewMode: "auto",
21
21
  diffIndicatorMode: "bars",
22
22
  diffSplitMinWidth: 120,
23
- editDiffCollapsedLines: 24,
23
+ editDiffCollapsedLines: 0,
24
24
  writeDiffCollapsedLines: 0,
25
25
  diffWordWrap: true,
26
26
  expandedPreviewMaxLines: 40,