@yinan_chen/pi-claude-code-ui 0.6.0 → 0.8.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](https://github.com/user-attachments/assets/d264935a-79fc-434e-be97-05c8210144fe)
8
+
9
+ 动图预览(不渲染视频的平台,如 npm): ![gif](assets/demo.gif)
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`;subagent / workflow 卡片整体接管为 Claude 风格,分发层拦截不受加载顺序影响(v0.6.0)
30
+ - **第三方渲染全接管** — 任何扩展的卡片(含未来未适配的)自动渲染为统一的 `● 卡片` 风格,工具/消息/条目三层全覆盖,零配置零适配;分发层拦截不受加载顺序影响(v0.7.0)
25
31
  - **富 diff 渲染** — edit/write 工具的语法高亮 diff(vendored, MIT)
26
32
  - **子代理面板** — 后台 agent 状态一目了然
27
33
  - **工作状态提示** — working message / running effect
@@ -48,6 +54,9 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
48
54
  "resultPrefix": "└",
49
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",
@@ -76,6 +85,8 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
76
85
  | `resultPrefixGap` | `1` | 前缀符号与内容之间的空格数(0–4) |
77
86
  | `normalizeForeignRenderers` | `true` | 第三方扩展卡片输出里行首 `⎿` 自动规范化为 `resultPrefix`(任何未适配扩展的渲染都能统一风格;关闭可看第三方原版) |
78
87
  | `workflowRendering` | `"claude"` | workflow 完成卡片样式:`● Workflow(name)` 风格 / `native` 保留包内卡片。分发层拦截实现,不受包加载顺序影响 |
88
+ | `foreignCardStyle` | `"generic"` | 未适配扩展的卡片:自动接管为通用 `● 卡片`(零配置);`symbols` = 仅统一符号不接管布局 |
89
+ | `foreignCardSkip` | `[]` | 不接管的 customType 黑名单(交互型第三方卡片异常时按类型跳过) |
79
90
  | `diff.diffViewMode` | `"auto"` | diff 视图:`auto`/`split`/`unified` |
80
91
  | `diff.diffIndicatorMode` | `"bars"` | 变更指示条:`bars`/`classic`/`none` |
81
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.6.0",
3
+ "version": "0.8.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",
@@ -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
@@ -31,6 +31,7 @@ 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
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";
@@ -90,6 +91,10 @@ interface Config {
90
91
  normalizeForeignRenderers: boolean;
91
92
  /** workflow 完成卡片(subagents:workflow entry)样式:claude = ● Workflow(name) 风格(默认);native = 保留包内卡片。 */
92
93
  workflowRendering: "claude" | "native";
94
+ /** 未适配 customType 的卡片:generic = 自动接管为通用 ● 卡片(默认);symbols = 仅统一符号不接管布局。 */
95
+ foreignCardStyle: "generic" | "symbols";
96
+ /** 不接管的 customType 黑名单(保留第三方/原生渲染)。 */
97
+ foreignCardSkip: string[];
93
98
  }
94
99
 
95
100
  function loadConfig(): Config {
@@ -110,6 +115,8 @@ function loadConfig(): Config {
110
115
  resultPrefixGap: 1,
111
116
  normalizeForeignRenderers: true,
112
117
  workflowRendering: "claude",
118
+ foreignCardStyle: "generic",
119
+ foreignCardSkip: [],
113
120
  };
114
121
  diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG };
115
122
  try {
@@ -134,6 +141,8 @@ function loadConfig(): Config {
134
141
  if (Number.isInteger(data?.resultPrefixGap) && data.resultPrefixGap >= 0 && data.resultPrefixGap <= 4) config.resultPrefixGap = data.resultPrefixGap;
135
142
  if (typeof data?.normalizeForeignRenderers === "boolean") config.normalizeForeignRenderers = data.normalizeForeignRenderers;
136
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;
137
146
  if (data?.diff && typeof data.diff === "object" && !Array.isArray(data.diff)) {
138
147
  diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG, ...data.diff };
139
148
  }
@@ -406,6 +415,7 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
406
415
  const hooks = { redraw: () => { LAST_MODE?.ui?.requestRender?.(); } };
407
416
  const subagentRenderer = createSubagentRenderer(hooks);
408
417
  const workflowRenderer = createWorkflowRenderer(hooks);
418
+ const genericCardRenderer = createGenericCardRenderer(hooks);
409
419
  if (config.subagentRendering === "claude") {
410
420
  registerSubagentPanels(pi, hooks);
411
421
  }
@@ -443,11 +453,12 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
443
453
  claude && chat ? claude.renderChat(container, width) : Container.prototype.render.call(container, width),
444
454
  normalizeForeign: (lines: readonly string[]): string[] =>
445
455
  config.normalizeForeignRenderers ? normalizeForeignLines(lines) : (lines as string[]),
446
- /** 分发层拦截:命中已知类型则改道为我们自己的渲染器,不受包加载顺序影响。 */
456
+ /** 分发层拦截:内置适配表优先,其余 generic 自动接管,黑名单跳过;不受包加载顺序影响。 */
447
457
  rendererOverride: (customType: string): any => {
458
+ if (config.foreignCardSkip.includes(customType)) return undefined;
448
459
  if (customType === "subagent-notification") return config.subagentRendering === "claude" ? subagentRenderer : undefined;
449
460
  if (customType === "subagents:workflow") return config.workflowRendering === "claude" ? workflowRenderer : undefined;
450
- return undefined;
461
+ return config.foreignCardStyle === "generic" ? genericCardRenderer : undefined;
451
462
  },
452
463
  } as RuntimeImpl;
453
464
  for (const method of IMPL_METHODS) {
@@ -95,8 +95,9 @@ function panelLines(d: any, expanded: boolean, theme: any, width: number): strin
95
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
- // 折叠态不显示正文,但保留可展开提示(有正文才提示)
99
- out.push(...prefixed(expandHint(theme), width, first, BRANCH_REST()));
98
+ // 折叠态不显示正文,但保留可展开提示(有正文才提示);格式与工具块 `… +N lines (ctrl+o to expand)` 一致。
99
+ const hidden = linesOf(d.resultPreview).length;
100
+ out.push(...prefixed(fg(theme, "dim", `… +${hidden} line${hidden === 1 ? "" : "s"}`) + expandHint(theme), width, first, BRANCH_REST()));
100
101
  }
101
102
  return out;
102
103
  }
@@ -17,6 +17,8 @@ export interface DiffRenderOptions {
17
17
  previousContent?: string;
18
18
  fileExistedBeforeWrite?: boolean;
19
19
  headerLabel?: string;
20
+ /** 折叠态隐藏的正文行数(write 折叠提示 `… +N lines` 用)。 */
21
+ collapsedHiddenLines?: number;
20
22
  /** Live hover state for the collapsed hint row (muted → text on hover). */
21
23
  isHovered?: () => boolean;
22
24
  invalidate?: () => void;
@@ -1,6 +1,6 @@
1
1
  import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
2
  import { sanitizeToolResultText } from "../tool-result-sanitize.ts";
3
- import { showMoreHintText } from "../show-more-hint.ts";
3
+ import { expandShortcutText } from "../show-more-hint.ts";
4
4
  import { splitWriteContentLines } from "./diff-text.ts";
5
5
  import {
6
6
  collectDiffStats,
@@ -291,21 +291,26 @@ function renderWriteOverwriteGuardRows(
291
291
  return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
292
292
  }
293
293
 
294
- /** Folded write with `writeDiffCollapsedLines: 0`: `↳ created • click to show more`. */
294
+ /** Folded write with `writeDiffCollapsedLines: 0`: `… +N lines (ctrl+o to expand)`(与工具块/subagent 折叠提示同款;无行数时退化为裸快捷键提示)。 */
295
295
  function renderWriteCollapsedHintLine(
296
296
  wasOverwrite: boolean,
297
297
  width: number,
298
298
  theme: DiffTheme,
299
299
  hovered: boolean,
300
300
  headerLabel?: string,
301
+ hiddenCount?: number,
301
302
  ): string[] {
302
303
  const safeWidth = normalizeDiffRenderWidth(width);
303
304
  if (safeWidth === 0) {
304
305
  return [""];
305
306
  }
306
- const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
307
- const clickLabel = showMoreHintText();
308
- const candidates = [`↳ ${actionLabel} • ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
307
+ void wasOverwrite; void headerLabel;
308
+ const clickLabel = `(${expandShortcutText()} to expand)`;
309
+ const counted =
310
+ typeof hiddenCount === "number" && hiddenCount > 0
311
+ ? `… +${hiddenCount} line${hiddenCount === 1 ? "" : "s"} ${clickLabel}`
312
+ : "";
313
+ const candidates = [counted, clickLabel.slice(1, -1), "…"].filter((candidate) => candidate !== "");
309
314
  let text = candidates[candidates.length - 1] ?? "…";
310
315
  for (const candidate of candidates) {
311
316
  if (visibleWidth(candidate) <= safeWidth) {
@@ -446,6 +451,7 @@ export function renderWriteDiffResult(
446
451
  theme,
447
452
  hovered,
448
453
  options.headerLabel,
454
+ options.collapsedHiddenLines,
449
455
  );
450
456
  return cache.set(
451
457
  safeWidth,
@@ -67,13 +67,15 @@ export function renderRichToolResult(
67
67
  if (metadata.diffUnavailableReason) {
68
68
  return unavailableComponent(metadata.diffUnavailableReason, theme);
69
69
  }
70
+ const writeContent = typeof context?.args?.content === "string" ? context.args.content : undefined;
70
71
  return renderWriteDiffResult(
71
- typeof context?.args?.content === "string" ? context.args.content : undefined,
72
+ writeContent,
72
73
  {
73
74
  expanded,
74
75
  filePath,
75
76
  previousContent: metadata.previousContent,
76
77
  fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
78
+ collapsedHiddenLines: writeContent !== undefined ? writeContent.split(/\r?\n/).length : undefined,
77
79
  isHovered: options?.isHovered,
78
80
  invalidate: () => context?.invalidate?.(),
79
81
  },