@yinan_chen/pi-claude-code-ui 0.5.0 → 0.7.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
@@ -2,6 +2,12 @@
2
2
 
3
3
  ![preview](assets/preview.png)
4
4
 
5
+ ### 演示
6
+
7
+ ![demo](assets/demo.mp4)
8
+
9
+ > 若上方未内嵌播放,直接下载观看:[assets/demo.mp4](assets/demo.mp4)
10
+
5
11
  > 原名 lean-tidy —— Claude Code 风格的 Pi 界面增强扩展:Claude 式对话转录视图、富文本 diff 渲染、子代理面板与工作状态提示。
6
12
 
7
13
  ## 安装
@@ -21,7 +27,7 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
21
27
  - **Claude 式视图** — 对话转录适配器,Pi 原生回退
22
28
  - **吸附消息泡(sticky)** — 滚动时顶部吸附显示当前正在回答的用户消息原文,点击回位(v0.2.0 内置,原 sticky-first-message 扩展)
23
29
  - **Skill 折叠(recollapse)** — 展开的 skill 全文自动折回 `/skill:名 参数`,覆盖 /tree、/fork 回填等四处盲区(v0.2.0 内置,原 skill-block-recollapse 扩展)
24
- - **第三方渲染规范化** — 任何扩展的卡片(如 pi-subagents 的 workflow 卡)行首 `⎿` 自动统一为 `resultPrefix`,不依赖适配(v0.5.0)
30
+ - **第三方渲染全接管** — 任何扩展的卡片(含未来未适配的)自动渲染为统一的 `● 卡片` 风格,工具/消息/条目三层全覆盖,零配置零适配;分发层拦截不受加载顺序影响(v0.7.0)
25
31
  - **富 diff 渲染** — edit/write 工具的语法高亮 diff(vendored, MIT)
26
32
  - **子代理面板** — 后台 agent 状态一目了然
27
33
  - **工作状态提示** — working message / running effect
@@ -46,8 +52,11 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
46
52
  "stickyIncludeCustom": false,
47
53
  "skillResumePreview": true,
48
54
  "resultPrefix": "└",
49
- "resultPrefixGap": 0,
55
+ "resultPrefixGap": 1,
50
56
  "normalizeForeignRenderers": true,
57
+ "workflowRendering": "claude",
58
+ "foreignCardStyle": "generic",
59
+ "foreignCardSkip": [],
51
60
  "diff": {
52
61
  "diffViewMode": "auto",
53
62
  "diffIndicatorMode": "bars",
@@ -73,8 +82,11 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
73
82
  | `stickyIncludeCustom` | `false` | 吸附泡是否包含扩展注入/跨会话转发的旁路消息 |
74
83
  | `skillResumePreview` | `true` | `/resume` 列表预览是否折回 `[skill] 名` 单行 |
75
84
  | `resultPrefix` | `"└"` | 工具结果行拐角前缀(制表符族,等宽字体最稳;可换 `↳` 或 Claude Code 官方的 `⎿`,后两者在部分字体下会变宽) |
76
- | `resultPrefixGap` | `0` | 前缀符号与内容之间的空格数(0–4) |
85
+ | `resultPrefixGap` | `1` | 前缀符号与内容之间的空格数(0–4) |
77
86
  | `normalizeForeignRenderers` | `true` | 第三方扩展卡片输出里行首 `⎿` 自动规范化为 `resultPrefix`(任何未适配扩展的渲染都能统一风格;关闭可看第三方原版) |
87
+ | `workflowRendering` | `"claude"` | workflow 完成卡片样式:`● Workflow(name)` 风格 / `native` 保留包内卡片。分发层拦截实现,不受包加载顺序影响 |
88
+ | `foreignCardStyle` | `"generic"` | 未适配扩展的卡片:自动接管为通用 `● 卡片`(零配置);`symbols` = 仅统一符号不接管布局 |
89
+ | `foreignCardSkip` | `[]` | 不接管的 customType 黑名单(交互型第三方卡片异常时按类型跳过) |
78
90
  | `diff.diffViewMode` | `"auto"` | diff 视图:`auto`/`split`/`unified` |
79
91
  | `diff.diffIndicatorMode` | `"bars"` | 变更指示条:`bars`/`classic`/`none` |
80
92
  | `diff.diffSplitMinWidth` | `120` | 宽于此才用 split 视图 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yinan_chen/pi-claude-code-ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -26,7 +26,8 @@
26
26
  "extensions": [
27
27
  "./src/index.ts"
28
28
  ],
29
- "image": "https://github.com/chenyn273/pi-claude-code-ui/raw/main/assets/preview.png"
29
+ "image": "https://github.com/chenyn273/pi-claude-code-ui/raw/main/assets/preview.png",
30
+ "video": "https://github.com/chenyn273/pi-claude-code-ui/raw/main/assets/demo.mp4"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@earendil-works/pi-coding-agent": "*",
@@ -4,7 +4,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works
4
4
  import { homedir } from "node:os";
5
5
 
6
6
  /** UI 符号集(lean-tidy.json 可覆盖;见 index.ts loadConfig 的 resultPrefix)。 */
7
- export const SYMBOLS = { resultPrefix: "└", resultPrefixGap: 0 };
7
+ export const SYMBOLS = { resultPrefix: "└", resultPrefixGap: 1 };
8
8
 
9
9
  /** 结果行缩进对:first = 3 空格 + 符号 + gap 空格;cont = 4 + gap 空格。运行时读 SYMBOLS。 */
10
10
  export function resultIndent(theme: any): { first: string; cont: string } {
@@ -38,9 +38,28 @@ export function normalizeForeignLines(lines: readonly string[]): string[] {
38
38
  }
39
39
 
40
40
  /**
41
- * patch 渲染宿主组件类的 prototype.render(幂等,FLAG 防重复包裹)。
42
- * wrapper 不做任何本地决策,全部经 __leanTidyImpl trampoline 转发。
41
+ * patch 渲染分发器(ExtensionRunner:同时有 getMessageRenderer + getEntryRenderer 原型方法)。
42
+ * 分发层拦截:不受包加载顺序影响,__leanTidyImpl.rendererOverride(customType) 命中即改道。
43
43
  */
44
+ export function patchRendererDispatch(cls: any): boolean {
45
+ const proto = cls?.prototype;
46
+ if (!proto || typeof proto.getMessageRenderer !== "function" || typeof proto.getEntryRenderer !== "function" || proto[FLAG]) return false;
47
+ proto[FLAG] = true;
48
+ for (const method of ["getMessageRenderer", "getEntryRenderer"]) {
49
+ const orig = proto[method];
50
+ proto[method] = function (customType: string) {
51
+ const impl = (globalThis as any).__leanTidyImpl;
52
+ try {
53
+ const override = impl?.rendererOverride?.(customType);
54
+ if (override) return override;
55
+ } catch {}
56
+ return orig.call(this, customType);
57
+ };
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /** patch 渲染宿主组件类的 prototype.render(幂等,FLAG 防重复包裹);wrapper 全部经 __leanTidyImpl trampoline 转发。 */
44
63
  export function patchForeignHost(cls: any): boolean {
45
64
  const proto = cls?.prototype;
46
65
  if (!proto || typeof proto.render !== "function" || proto[FLAG]) return false;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * generic-card — 未适配 customType 的通用 Claude 风格卡片(v0.7.0)。
3
+ *
4
+ * foreignCardStyle="generic"(默认)时,任何没有内置适配的 custom 消息 /
5
+ * 会话条目都自动渲染为:
6
+ *
7
+ * ● Label
8
+ * └ 首行内容 (ctrl+o to expand)
9
+ *
10
+ * 全自动、零配置;点击卡片 toggle 展开/折叠。内置适配表(subagent-notification /
11
+ * subagents:workflow,信息更丰富)优先于本通用卡片;第三方交互型卡片异常时可用
12
+ * lean-tidy.json 的 foreignCardSkip 黑名单按 customType 跳过(保留原生渲染)。
13
+ *
14
+ * 一个 renderer 同时服务 getMessageRenderer 与 getEntryRenderer 两个分发点:
15
+ * payload 兼容 message(content)与 entry(data),customType 两者都有。
16
+ */
17
+ import { bold, expandHint, fg, fit, linesOf, oneLine, prefixed, resultIndent, RESULT_CONT } from "./claude-tools.ts";
18
+
19
+ export interface GenericCardHooks {
20
+ /** 点击切换展开后请求整屏重绘(TUI requestRender)。 */
21
+ redraw(): void;
22
+ }
23
+
24
+ const clickExpanded = new WeakMap<object, boolean>();
25
+
26
+ function labelOf(customType: string): string {
27
+ const tail = customType.split(":").pop() ?? customType;
28
+ return tail.charAt(0).toUpperCase() + tail.slice(1);
29
+ }
30
+
31
+ /** 内容来源:message.content / entry.data(对象优先取 name/description,否则 JSON)。 */
32
+ function bodyOf(payload: any): string {
33
+ const source = typeof payload?.content === "string" ? payload.content : payload?.data;
34
+ if (typeof source === "string") return source;
35
+ if (source && typeof source === "object") {
36
+ const name = typeof source.name === "string" ? source.name : "";
37
+ const desc = typeof source.description === "string" ? source.description : "";
38
+ if (name || desc) return [name, desc].filter(Boolean).join(" — ");
39
+ try { return JSON.stringify(source, null, 2); } catch { return String(source); }
40
+ }
41
+ return source === undefined || source === null ? "" : String(source);
42
+ }
43
+
44
+ export function createGenericCardRenderer(hooks: GenericCardHooks) {
45
+ return (payload: any, options: { expanded?: boolean }, theme: any) => {
46
+ const customType = String(payload?.customType ?? "card");
47
+ const key = payload && typeof payload === "object" ? payload : undefined;
48
+ const label = labelOf(customType);
49
+ const first = resultIndent(theme).first;
50
+ const icon = fg(theme, "muted", "●");
51
+
52
+ return {
53
+ render(width: number): string[] {
54
+ const body = linesOf(bodyOf(payload)).filter((l: string) => l.trim() !== "");
55
+ const expanded = options?.expanded === true || (key !== undefined && clickExpanded.get(key) === true);
56
+ const lines = [fit(` ${icon} ${bold(theme, label)}`, width)];
57
+ const visible = body.length ? body : ["(empty)"];
58
+ if (expanded) {
59
+ for (const line of visible) lines.push(...prefixed(fg(theme, "dim", line), width, first, RESULT_CONT()));
60
+ } else {
61
+ const head = oneLine(visible[0]);
62
+ lines.push(...prefixed(fg(theme, "muted", head) + expandHint(theme), width, first, RESULT_CONT()));
63
+ }
64
+ return lines;
65
+ },
66
+ invalidate(): void {},
67
+ handleMouse(event: any): any {
68
+ if (event?.type !== "click" || event?.button !== "left" || key === undefined) return undefined;
69
+ clickExpanded.set(key, clickExpanded.get(key) !== true);
70
+ try { hooks.redraw(); } catch {}
71
+ return { handled: true };
72
+ },
73
+ };
74
+ };
75
+ }
package/src/index.ts CHANGED
@@ -26,11 +26,12 @@ import { homedir } from "node:os";
26
26
  import { Container } from "@earendil-works/pi-tui";
27
27
  import { getPiRuntime, VERIFIED_PI_VERSION } from "./pi-runtime.ts";
28
28
  import { createClaudeView } from "./claude-view.ts";
29
- import registerSubagentPanels from "./subagent-panel.ts";
29
+ import registerSubagentPanels, { createSubagentRenderer, createWorkflowRenderer, registerWorkflowPanels } from "./subagent-panel.ts";
30
30
  import stickyFirstMessage from "./sticky-first-message.ts";
31
31
  import registerSkillRecollapse from "./skill-block-recollapse.ts";
32
32
  import { SYMBOLS } from "./claude-tools.ts";
33
- import { normalizeForeignLines, patchForeignHost } from "./foreign-normalizer.ts";
33
+ import { normalizeForeignLines, patchForeignHost, patchRendererDispatch } from "./foreign-normalizer.ts";
34
+ import { createGenericCardRenderer } from "./generic-card.ts";
34
35
  import registerWorkingMessage from "./working-message.ts";
35
36
  // Vendored rich diff (MIT; see vendor/diff/ATTRIBUTION.md).
36
37
  import { renderRichToolResult, WriteExecutionMetadataStore } from "./vendor/diff/index.ts";
@@ -88,6 +89,12 @@ interface Config {
88
89
  resultPrefixGap: number;
89
90
  /** 第三方扩展渲染输出的行首 ⎿ 是否规范化为 resultPrefix(默认 true)。 */
90
91
  normalizeForeignRenderers: boolean;
92
+ /** workflow 完成卡片(subagents:workflow entry)样式:claude = ● Workflow(name) 风格(默认);native = 保留包内卡片。 */
93
+ workflowRendering: "claude" | "native";
94
+ /** 未适配 customType 的卡片:generic = 自动接管为通用 ● 卡片(默认);symbols = 仅统一符号不接管布局。 */
95
+ foreignCardStyle: "generic" | "symbols";
96
+ /** 不接管的 customType 黑名单(保留第三方/原生渲染)。 */
97
+ foreignCardSkip: string[];
91
98
  }
92
99
 
93
100
  function loadConfig(): Config {
@@ -105,8 +112,11 @@ function loadConfig(): Config {
105
112
  stickyIncludeCustom: false,
106
113
  skillResumePreview: true,
107
114
  resultPrefix: "└",
108
- resultPrefixGap: 0,
115
+ resultPrefixGap: 1,
109
116
  normalizeForeignRenderers: true,
117
+ workflowRendering: "claude",
118
+ foreignCardStyle: "generic",
119
+ foreignCardSkip: [],
110
120
  };
111
121
  diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG };
112
122
  try {
@@ -130,6 +140,9 @@ function loadConfig(): Config {
130
140
  if (typeof data?.resultPrefix === "string" && data.resultPrefix.trim()) config.resultPrefix = data.resultPrefix;
131
141
  if (Number.isInteger(data?.resultPrefixGap) && data.resultPrefixGap >= 0 && data.resultPrefixGap <= 4) config.resultPrefixGap = data.resultPrefixGap;
132
142
  if (typeof data?.normalizeForeignRenderers === "boolean") config.normalizeForeignRenderers = data.normalizeForeignRenderers;
143
+ if (data?.workflowRendering === "claude" || data?.workflowRendering === "native") config.workflowRendering = data.workflowRendering;
144
+ if (data?.foreignCardStyle === "generic" || data?.foreignCardStyle === "symbols") config.foreignCardStyle = data.foreignCardStyle;
145
+ if (Array.isArray(data?.foreignCardSkip) && data.foreignCardSkip.every((x: unknown) => typeof x === "string")) config.foreignCardSkip = data.foreignCardSkip;
133
146
  if (data?.diff && typeof data.diff === "object" && !Array.isArray(data.diff)) {
134
147
  diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG, ...data.diff };
135
148
  }
@@ -352,6 +365,7 @@ function patchModule(mod: any, where: string): number {
352
365
  if (typeof cls !== "function") continue;
353
366
  let did = false;
354
367
  if (key === "CustomMessageComponent" || key === "CustomEntryComponent") did = patchForeignHost(cls);
368
+ else if (typeof (cls.prototype as any)?.getMessageRenderer === "function" && typeof (cls.prototype as any)?.getEntryRenderer === "function") did = patchRendererDispatch(cls);
355
369
  else if (key.includes("ToolExecution")) did = patchToolExec(cls);
356
370
  else if (key.includes("AssistantMessage")) did = patchAssistant(cls);
357
371
  else if (key.includes("UserMessage")) did = patchUserMessage(cls);
@@ -398,8 +412,15 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
398
412
  // Subagent 通知接管必须发生在加载期(registerMessageRenderer 仅 factory 内合法):
399
413
  // lean-tidy 先于 npm 包加载,首个命中 customType 的渲染器胜出(一南 2026-02-12)。
400
414
  // redraw 闭包延迟读 LAST_MODE(hookMode 后才看得到 TUI)。
415
+ const hooks = { redraw: () => { LAST_MODE?.ui?.requestRender?.(); } };
416
+ const subagentRenderer = createSubagentRenderer(hooks);
417
+ const workflowRenderer = createWorkflowRenderer(hooks);
418
+ const genericCardRenderer = createGenericCardRenderer(hooks);
401
419
  if (config.subagentRendering === "claude") {
402
- registerSubagentPanels(pi, { redraw: () => { LAST_MODE?.ui?.requestRender?.(); } });
420
+ registerSubagentPanels(pi, hooks);
421
+ }
422
+ if (config.workflowRendering === "claude") {
423
+ registerWorkflowPanels(pi, hooks);
403
424
  }
404
425
  if (DEBUG) {
405
426
  try { writeFileSync(LOG, `=== lean-tidy load #${loadCounter} ${new Date().toISOString()} ===\n`); } catch {}
@@ -432,6 +453,13 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
432
453
  claude && chat ? claude.renderChat(container, width) : Container.prototype.render.call(container, width),
433
454
  normalizeForeign: (lines: readonly string[]): string[] =>
434
455
  config.normalizeForeignRenderers ? normalizeForeignLines(lines) : (lines as string[]),
456
+ /** 分发层拦截:内置适配表优先,其余 generic 自动接管,黑名单跳过;不受包加载顺序影响。 */
457
+ rendererOverride: (customType: string): any => {
458
+ if (config.foreignCardSkip.includes(customType)) return undefined;
459
+ if (customType === "subagent-notification") return config.subagentRendering === "claude" ? subagentRenderer : undefined;
460
+ if (customType === "subagents:workflow") return config.workflowRendering === "claude" ? workflowRenderer : undefined;
461
+ return config.foreignCardStyle === "generic" ? genericCardRenderer : undefined;
462
+ },
435
463
  } as RuntimeImpl;
436
464
  for (const method of IMPL_METHODS) {
437
465
  impl[method] = (self, orig, ...args) => {
@@ -101,11 +101,12 @@ function panelLines(d: any, expanded: boolean, theme: any, width: number): strin
101
101
  return out;
102
102
  }
103
103
 
104
- export default function registerSubagentPanels(pi: ExtensionAPI, hooks: SubagentPanelHooks): void {
104
+ /** Claude 风格的 subagent-notification 渲染器(与注册解耦,供分发层拦截复用)。 */
105
+ export function createSubagentRenderer(hooks: SubagentPanelHooks) {
105
106
  // 每个 agent 条目(details 对象,含 others 内的条目)独立的点击展开状态。
106
107
  const clickExpanded = new WeakMap<object, boolean>();
107
108
 
108
- pi.registerMessageRenderer("subagent-notification", (message: any, options: PanelOptions, theme: any) => {
109
+ return (message: any, options: PanelOptions, theme: any) => {
109
110
  const details = message?.details;
110
111
  if (!details || typeof details !== "object") return undefined;
111
112
 
@@ -140,5 +141,47 @@ export default function registerSubagentPanels(pi: ExtensionAPI, hooks: Subagent
140
141
  return { handled: true };
141
142
  },
142
143
  };
143
- });
144
+ };
145
+ }
146
+
147
+ export default function registerSubagentPanels(pi: ExtensionAPI, hooks: SubagentPanelHooks): void {
148
+ pi.registerMessageRenderer("subagent-notification", createSubagentRenderer(hooks));
149
+ }
150
+
151
+ /** Claude 风格的 subagents:workflow entry 渲染器(v0.6.0;data 契约见 pi-subagents dist/workflow/entry.js)。 */
152
+ export function createWorkflowRenderer(hooks: SubagentPanelHooks) {
153
+ return (entry: any, options: PanelOptions, theme: any) => {
154
+ const d = entry?.data;
155
+ if (!d || typeof d !== "object") return undefined;
156
+ const failed = d?.status !== "completed";
157
+ const icon = fg(theme, failed ? "error" : "success", "●");
158
+ const first = branch(theme);
159
+ const name = oneLine(d?.name) || "workflow";
160
+ const ms = typeof d?.endTime === "number" && typeof d?.startTime === "number" ? d.endTime - d.startTime : NaN;
161
+ const status = failed ? `Failed (${String(d?.status ?? "unknown")})` : `Completed in ${msText(ms)}`;
162
+ const stats: string[] = [];
163
+ if (typeof d?.totalTokens === "number" && d.totalTokens > 0) stats.push(tokensText(d.totalTokens));
164
+ if (typeof d?.agentCount === "number" && d.agentCount > 0) stats.push(`${d.agentCount} agent${d.agentCount === 1 ? "" : "s"}`);
165
+ let tail = fg(theme, "muted", status);
166
+ if (stats.length) tail += fg(theme, "dim", ` · ${stats.join(" · ")}`);
167
+
168
+ return {
169
+ render(width: number): string[] {
170
+ const lines = [fit(` ${icon} ${bold(theme, "Workflow")}(${name})`, width)];
171
+ lines.push(...prefixed(tail, width, first, BRANCH_REST()));
172
+ if (options.expanded && d?.meta && typeof d.meta === "object") {
173
+ for (const line of linesOf(JSON.stringify(d.meta, null, 2))) {
174
+ lines.push(...prefixed(fg(theme, "dim", line), width, first, BRANCH_REST()));
175
+ }
176
+ }
177
+ return lines;
178
+ },
179
+ invalidate(): void {},
180
+ };
181
+ };
182
+ }
183
+
184
+ /** 注册(顺序可能被其他包抢先;分发层拦截保证最终生效,见 index.ts rendererOverride)。 */
185
+ export function registerWorkflowPanels(pi: ExtensionAPI, hooks: SubagentPanelHooks): void {
186
+ pi.registerEntryRenderer("subagents:workflow", createWorkflowRenderer(hooks));
144
187
  }