pi-cc-extensions 0.9.6 → 0.9.8

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.
@@ -100,6 +100,8 @@ type ToolExpandedBackgroundPatch = {
100
100
  dispose: () => void;
101
101
  };
102
102
 
103
+ /** rich diff 的两个入口(本处与 compact 的 paintCompactEditWrite)都靠 renderRichToolResult
104
+ * 返回 undefined 让位,这里只判断模式与工具名,避免多一个可能与让位不一致的 gate。 */
103
105
  export function shouldRenderRichDiff(
104
106
  mode: CompactStyleMode,
105
107
  toolName: string,
@@ -12,7 +12,7 @@ import {
12
12
  installToolExpandedBackground,
13
13
  type DefaultModeHooks,
14
14
  } from "./default-mode.ts";
15
- import { isLazyProxyTui } from "../utils/fullscreen-detect.ts";
15
+ import { installMainScreenDoRenderPatch, isLazyProxyTui } from "../utils/fullscreen-detect.ts";
16
16
  import { showCcstylePanel } from "../config/panel.ts";
17
17
  import {
18
18
  config,
@@ -30,7 +30,7 @@ import {
30
30
  teardownToolMouseInteraction,
31
31
  TOOL_MOUSE_DISABLE,
32
32
  } from "./mouse/interaction.ts";
33
- import { getToolMouseTui } from "./mouse/scroll.ts";
33
+ import { getToolMouseTui, noteNewTranscriptItem } from "./mouse/scroll.ts";
34
34
  import { setHoveredToolGroup, setHoveredToolIo } from "./mouse/hover.ts";
35
35
  import { clearAllAnimations } from "./tool/result.ts";
36
36
  import { installWriteOverride, WriteExecutionMetadataStore } from "./tool/diff/index.ts";
@@ -116,6 +116,8 @@ export default function (
116
116
  // 渲染层(工具样式/分组)是原型与组件级 patch,fullscreen 官方布局
117
117
  // 同样渲染这些组件,因此两种模式都安装。
118
118
  if (installation) return installation;
119
+ // regular 主屏的差分渲染补丁:视口上方变化不再触发清回滚的 fullRender。
120
+ installMainScreenDoRenderPatch();
119
121
  const defaultMode = installDefaultMode(writeExecutionMetadata);
120
122
  const toolGrouping = installToolGrouping(() => config.mode === "on");
121
123
  const compactMode = installCompactMode({
@@ -189,6 +191,13 @@ export default function (
189
191
  }
190
192
  });
191
193
 
194
+ // 回到底部按钮的累计计数:离开底部期间每落一块内容 +1。
195
+ pi.on("message_start", async (event) => {
196
+ const role = event.message?.role;
197
+ // toolResult 不单独成块(渲染在工具卡内),跳过以免与工具卡重复计数。
198
+ if (role === "user" || role === "assistant" || role === "custom") noteNewTranscriptItem();
199
+ });
200
+
192
201
  pi.on("tool_execution_end", async (event) => {
193
202
  if (config.mode !== "compact") return;
194
203
  // Agent 等工具收尾后延迟刷新,让 compact-thinking 先落最终态。
@@ -199,10 +208,18 @@ export default function (
199
208
  });
200
209
 
201
210
  pi.on("session_start", async (event, ctx) => {
202
- // 延迟到 session_start 注册 write override:加载阶段 getAllTools 不可用且其他扩展
203
- // 尚未注册工具,无法检测外部 write 所有者(如 pi-spark),直接注册会与对方撞名。
204
- // session_start 时所有扩展已加载完毕,installWriteOverride 内部会检测并让位。
205
- installWriteOverride(pi, writeExecutionMetadata);
211
+ // 延后一拍再确认 write 归属:其他扩展同样在各自的 session_start 里注册工具,
212
+ // 本 handler 执行时可能还看不到对方,此时注册会与对方撞名(注册表先到先得),
213
+ // 对方的 write 会被静默丢弃。等所有 session_start 跑完,检测结果才是权威的。
214
+ setTimeout(() => {
215
+ installWriteOverride(pi, writeExecutionMetadata, (owner) => {
216
+ // 冲突可见:对方接管 write 后本插件不再提供富 diff。
217
+ ctx.ui?.notify?.(
218
+ `ccstyle: write 工具已被 ${owner.path || owner.source} 占用,富 diff 已让位`,
219
+ "warning",
220
+ );
221
+ });
222
+ }, 0);
206
223
  const hooks = ensureTuiInstallation(ctx);
207
224
  // 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
208
225
  // 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
@@ -243,6 +260,7 @@ export default function (
243
260
 
244
261
  pi.on("tool_execution_start", async (_event, ctx) => {
245
262
  installation?.toolGrouping.setTheme(ctx.ui.theme);
263
+ noteNewTranscriptItem();
246
264
  });
247
265
 
248
266
  pi.on("session_shutdown", async () => {
@@ -27,12 +27,19 @@ export function setToolMouseTui(tui: any): void {
27
27
  patchRegistry.install(TOOL_MOUSE_TUI_SLOT, tui);
28
28
  }
29
29
 
30
- type ScrollButtonState = { visible: boolean; hovered: boolean; widget: any };
30
+ type ScrollButtonState = {
31
+ visible: boolean;
32
+ hovered: boolean;
33
+ widget: any;
34
+ /** 按钮可见期间新落的 transcript 块数(消息 + 工具卡)。 */
35
+ newCount: number;
36
+ };
31
37
  function scrollButtonState(): ScrollButtonState {
32
38
  return patchRegistry.ensure(SCROLL_BUTTON_STATE_SLOT, () => ({
33
39
  visible: false,
34
40
  hovered: false,
35
41
  widget: null,
42
+ newCount: 0,
36
43
  }));
37
44
  }
38
45
  export function getScrollButtonVisible(): boolean {
@@ -44,8 +51,32 @@ export function getScrollButtonHovered(): boolean {
44
51
  export function getScrollButtonWidget(): any {
45
52
  return scrollButtonState().widget;
46
53
  }
54
+ export function getScrollButtonNewCount(): number {
55
+ return scrollButtonState().newCount;
56
+ }
47
57
  export function setScrollButtonVisible(visible: boolean): void {
48
- scrollButtonState().visible = visible;
58
+ const state = scrollButtonState();
59
+ state.visible = visible;
60
+ // 计数只在离开底部期间有效:跟随输出即清零,文案回到 Back to bottom。
61
+ if (!visible) state.newCount = 0;
62
+ }
63
+
64
+ /**
65
+ * 记账一块新落进 transcript 的内容(用户/助手消息、工具卡)。
66
+ * 仅在按钮可见(已滚动离开底部)时累加,并请求重绘让按钮文案立即更新。
67
+ */
68
+ export function noteNewTranscriptItem(): void {
69
+ const state = scrollButtonState();
70
+ const tui = getToolMouseTui();
71
+ if (!state.visible || !tui) return;
72
+ // 提交新消息时官方可能直接跳回底部(不经过滚动输入):先按实时跟随状态校正,
73
+ // 否则跟随期间的内容会被当成“离开底部时的新消息”计入。
74
+ if (isAtTranscriptBottom(tui)) {
75
+ hideScrollButton(tui);
76
+ return;
77
+ }
78
+ state.newCount += 1;
79
+ tui.requestRender?.();
49
80
  }
50
81
 
51
82
  /** 返回是否发生变化(调用方据此决定是否需要重渲染)。 */
@@ -64,6 +95,7 @@ export function resetScrollButtonState(): void {
64
95
  scrollButtonState().visible = false;
65
96
  scrollButtonState().hovered = false;
66
97
  scrollButtonState().widget = null;
98
+ scrollButtonState().newCount = 0;
67
99
  scrollButtonSyncScheduled = false;
68
100
  }
69
101
 
@@ -206,12 +238,19 @@ export function updateScrollButtonFromInput(tui: any, data: string): void {
206
238
  if (matchesKey(data, "enter") || matchesKey(data, "return")) hideScrollButton(tui);
207
239
  }
208
240
 
241
+ /** 无新内容时提示原文案;有新内容时换成累计条数。 */
242
+ function scrollButtonText(): string {
243
+ const count = getScrollButtonNewCount();
244
+ const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
245
+ if (count <= 0) return `Back to bottom · ${shortcut}`;
246
+ return `${count} new message${count === 1 ? "" : "s"} · ${shortcut}`;
247
+ }
248
+
209
249
  export function renderScrollButton(width: number, theme: any): string[] {
210
250
  if (!getScrollButtonVisible() || !fullscreenLazyTui(getToolMouseTui())) return [];
211
- const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
212
251
  const label = theme.fg(
213
252
  getScrollButtonHovered() ? "text" : "accent",
214
- `[ ↓ Back to bottom · ${shortcut} ]`,
253
+ `[ ↓ ${scrollButtonText()} ]`,
215
254
  );
216
255
  const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
217
256
  return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
@@ -6,6 +6,7 @@ import {
6
6
  type DisplayConfigInput,
7
7
  } from "./diff-renderer.ts";
8
8
  import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../../config/config.ts";
9
+ import { patchRegistry, WRITE_OWNERSHIP_SLOT } from "../../../utils/patch-keys.ts";
9
10
  import { executeWriteWithMetadata, WriteExecutionMetadataStore } from "./write-execution.ts";
10
11
 
11
12
  function resultText(result: any): string {
@@ -59,6 +60,8 @@ export function renderRichToolResult(
59
60
  );
60
61
  }
61
62
  if (toolName !== "write") return undefined;
63
+ // 让位点:write 归其他扩展时不提供富 diff,交回普通结果行。
64
+ if (!ownsWriteTool()) return undefined;
62
65
 
63
66
  const metadata = writeMetadata.get(context?.toolCallId);
64
67
  if (!metadata) {
@@ -83,23 +86,57 @@ export function renderRichToolResult(
83
86
  );
84
87
  }
85
88
 
86
- function hasExternalWriteOwner(pi: ExtensionAPI): boolean {
89
+ /** write 被其他扩展占用时的来源,用于提示冲突。 */
90
+ export type ExternalWriteOwner = { source: string; path: string };
91
+
92
+ type WriteOwnershipState = {
93
+ /** write 是否由本插件执行;undefined 表示尚未确认,按拥有处理以保持既有行为。 */
94
+ owned?: boolean;
95
+ };
96
+
97
+ function writeOwnership(): WriteOwnershipState {
98
+ return patchRegistry.ensure<WriteOwnershipState>(WRITE_OWNERSHIP_SLOT, () => ({}));
99
+ }
100
+
101
+ /**
102
+ * write 是否由本插件执行。只有注册过 write override 才有执行元数据,
103
+ * 否则渲染层要放行给普通结果行,避免每张卡降级成 "diff unavailable"。
104
+ */
105
+ export function ownsWriteTool(): boolean {
106
+ return writeOwnership().owned !== false;
107
+ }
108
+
109
+ /** 当前占用 write 的其他扩展;builtin 或未注册时返回 undefined。 */
110
+ function findExternalWriteOwner(pi: ExtensionAPI): ExternalWriteOwner | undefined {
87
111
  try {
88
- const tools = pi.getAllTools();
89
- const write = tools.find((tool: any) => tool?.name === "write") as any;
90
- const source = write?.sourceInfo?.source;
91
- return Boolean(write && typeof source === "string" && source !== "builtin");
112
+ const tools = pi.getAllTools() as any[];
113
+ const write = tools?.find((tool: any) => tool?.name === "write") as any;
114
+ const sourceInfo = write?.sourceInfo;
115
+ const source = sourceInfo?.source;
116
+ if (!write || typeof source !== "string" || source === "builtin") return undefined;
117
+ return { source, path: typeof sourceInfo?.path === "string" ? sourceInfo.path : "" };
92
118
  } catch {
93
- // getAllTools is unavailable before the extension runtime is bound.
94
- return false;
119
+ // getAllTools 在扩展运行时绑定前不可用。
120
+ return undefined;
95
121
  }
96
122
  }
97
123
 
98
124
  export function installWriteOverride(
99
125
  pi: ExtensionAPI,
100
126
  store = new WriteExecutionMetadataStore(),
127
+ /** write 已被其他扩展占用时回调一次,调用方据此提示冲突。 */
128
+ onExternalOwner?: (owner: ExternalWriteOwner) => void,
101
129
  ): WriteExecutionMetadataStore {
102
- if (typeof (pi as any).registerTool !== "function" || hasExternalWriteOwner(pi)) return store;
130
+ if (typeof (pi as any).registerTool !== "function") return store;
131
+ const state = writeOwnership();
132
+ const external = findExternalWriteOwner(pi);
133
+ if (external) {
134
+ // 让位:执行与 diff 都归对方,渲染层据 owned=false 走普通结果行。
135
+ state.owned = false;
136
+ onExternalOwner?.(external);
137
+ return store;
138
+ }
139
+ state.owned = true;
103
140
  const nativeWrite = createWriteToolDefinition(process.cwd()) as any;
104
141
  pi.registerTool({
105
142
  ...nativeWrite,
@@ -62,8 +62,8 @@ export type ToolCallSummary = {
62
62
  };
63
63
 
64
64
  function pathApi(value: string) {
65
- if (win32.isAbsolute(value)) return win32;
66
65
  if (posix.isAbsolute(value)) return posix;
66
+ if (win32.isAbsolute(value)) return win32;
67
67
  return undefined;
68
68
  }
69
69
 
@@ -45,3 +45,32 @@ export function stripLeadingStatusIcon(line: string): string {
45
45
  "$1",
46
46
  );
47
47
  }
48
+
49
+ /** 终端序列零宽(不计入 plain 下标),与 stripAnsi/stripTerminalSequencesPreservingLayout 一致。 */
50
+ const ANY_SEQUENCE_RE = new RegExp(`${CSI_SEQUENCE_RE.source}|${OSC_SEQUENCE_RE.source}`, "g");
51
+
52
+ /** 只保留 [start, end) 之外的字符(base 为 text 首字符在 plain 串中的下标)。 */
53
+ function keepOutsideRange(text: string, base: number, start: number, end: number): string {
54
+ if (base + text.length <= start || base >= end) return text;
55
+ return text.slice(0, Math.max(0, start - base)) + text.slice(Math.max(0, end - base));
56
+ }
57
+
58
+ /**
59
+ * 删除 plain 区间 [start, end) 的字符,保留其余字符的 ANSI 样式。
60
+ * 区间内的终端序列原样保留,因此区间后的字符(如收尾括号)沿用前文颜色,
61
+ * 不会像截断后拼纯文本那样掉回终端默认前景色。
62
+ */
63
+ export function removeStyledRange(styled: string, start: number, end: number): string {
64
+ if (end <= start) return styled;
65
+ let plain = 0;
66
+ let cursor = 0;
67
+ let out = "";
68
+ for (const match of styled.matchAll(ANY_SEQUENCE_RE)) {
69
+ const text = styled.slice(cursor, match.index);
70
+ out += keepOutsideRange(text, plain, start, end);
71
+ plain += text.length;
72
+ out += match[0];
73
+ cursor = match.index + match[0].length;
74
+ }
75
+ return out + keepOutsideRange(styled.slice(cursor), plain, start, end);
76
+ }
@@ -1,3 +1,6 @@
1
+ import { TuiMainScreen } from "@earendil-works/pi-tui";
2
+ import { MAIN_SCREEN_DO_RENDER_PATCH, patchRegistry } from "./patch-keys.ts";
3
+
1
4
  /**
2
5
  * 官方 0.84+ 的 tui 引用是惰性 Proxy(createInteractiveTuiReference):
3
6
  * 函数属性每次 get 都返回新包装,执行时才解析到当前实现。
@@ -9,3 +12,51 @@ export function isLazyProxyTui(tui: any): boolean {
9
12
  const probe = tui.requestRender;
10
13
  return typeof probe === "function" && probe !== tui.requestRender;
11
14
  }
15
+
16
+ type MainScreenDoRenderPatch = {
17
+ active: boolean;
18
+ original: (lines: string[]) => string[];
19
+ installed: (lines: string[]) => string[];
20
+ };
21
+
22
+ /**
23
+ * regular(TuiMainScreen)模式的 doRender 走差分绘制:一旦第一个变化行落在
24
+ * previousViewportTop 之上(终端写不进 scrollback 的区域),只能
25
+ * fullRender(true)——\x1b[2J\x1b[H\x1b[3J 清屏并清掉整段 scrollback。
26
+ * compact 直播式回合的摘要行动画与流式正文渲染在回合工具卡/diff 面板上方;
27
+ * diff 面板一落地把下方内容撑过屏高,之后每帧重绘都命中该分支:scrollback
28
+ * 被反复清空,滚动条回弹、视口钉住,直到输出结束才恢复。
29
+ *
30
+ * 视口上方的行本就无法重写,只剩「清回滚重打」与「scrollback 保留旧文本」
31
+ * 两条路。这里选后者:applyLineResets 是差分比较前最后一个拿到整帧的环节,
32
+ * 把 previousLines 的视口上方前缀直接对齐新帧,差分只看到可写区域,照常增量
33
+ * 绘制。代价仅是 scrollback 里那几行停留在最后一次实际写入的内容。
34
+ */
35
+ export function installMainScreenDoRenderPatch(): void {
36
+ const prototype = TuiMainScreen.prototype as any;
37
+ if (typeof prototype?.applyLineResets !== "function") return;
38
+ const previous = patchRegistry.get<MainScreenDoRenderPatch>(MAIN_SCREEN_DO_RENDER_PATCH);
39
+ if (previous) previous.active = false;
40
+ const original =
41
+ previous && prototype.applyLineResets === previous.installed
42
+ ? previous.original
43
+ : prototype.applyLineResets;
44
+ const patch: MainScreenDoRenderPatch = {
45
+ active: true,
46
+ original,
47
+ installed(this: any, lines: string[]) {
48
+ const result = original.call(this, lines);
49
+ if (!patch.active) return result;
50
+ const prev = this.previousLines;
51
+ if (!Array.isArray(prev)) return result;
52
+ const top = Math.min(
53
+ Math.max(0, Math.floor(Number(this.previousViewportTop) || 0)),
54
+ result.length,
55
+ );
56
+ for (let i = 0; i < top; i++) prev[i] = result[i];
57
+ return result;
58
+ },
59
+ };
60
+ prototype.applyLineResets = patch.installed;
61
+ patchRegistry.install(MAIN_SCREEN_DO_RENDER_PATCH, patch);
62
+ }
@@ -29,6 +29,8 @@ export const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-
29
29
  export const TOOL_EXPANDED_BACKGROUND_PATCH = Symbol.for(
30
30
  "pi.ccstyle.tool-expanded-background-patch",
31
31
  );
32
+ // write 归属:注册期确认一次,渲染期据此决定是否提供 rich diff。
33
+ export const WRITE_OWNERSHIP_SLOT = Symbol.for("pi.ccstyle.write-ownership");
32
34
 
33
35
  // ── 消息组件补丁 ──
34
36
  export const MESSAGE_DISPLAY_PATCH = Symbol.for("pi.ccstyle.message-display-patch");
@@ -38,6 +40,9 @@ export const TOOL_GROUPING_PATCH_KEY = Symbol.for("pi.ccstyle.tool-grouping-patc
38
40
  export const TOOL_GROUPING_PARENT_KEY = Symbol.for("pi.ccstyle.tool-grouping-parent");
39
41
  export const TOOL_GROUPING_GENERATION_KEY = Symbol.for("pi.ccstyle.tool-grouping-generation");
40
42
 
43
+ // ── regular 主屏渲染补丁 ──
44
+ export const MAIN_SCREEN_DO_RENDER_PATCH = Symbol.for("pi.ccstyle.main-screen-do-render-patch");
45
+
41
46
  // ── 鼠标交互 ──
42
47
  export const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi.ccstyle.tool-mouse-owner");
43
48
  export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi.ccstyle.tool-mouse-tui");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",