pi-cc-extensions 0.8.43 → 0.8.45

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.en.md CHANGED
@@ -42,13 +42,13 @@ Run `/reload` after installation, then try:
42
42
  ## Features
43
43
 
44
44
 
45
- | Feature | Description | Entry point |
46
- | --------------------------- | -------------------------------------------------------------------------------------------------- | --------------------- |
47
- | Claude Code-style output | Tool summaries, expand/collapse, rich edit/write diffs, and `on` / `off` / `compact` modes | `/ccstyle` |
48
- | Fixed-editor interaction | Powered by `@tifan/pi-fixed-editor`, with runtime toggling, five-row wheel scrolling, tool clicks, and back-to-bottom control | `/ccstyle` |
49
- | Context inspection | Usage breakdown and previews for the system prompt, tools, skills, and messages | `/context` |
50
- | Session references | Search and inject effective context from previous Sessions or existing SubAgents | `@session:` |
51
- | Theme | Includes the GitHub Dark Default theme | `/theme` |
45
+ | Feature | Description | Entry point |
46
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------- |
47
+ | Claude Code-style output | Tool summaries, expand/collapse, rich edit/write diffs, and`on` / `off` / `compact` modes | `/ccstyle` |
48
+ | Fixed-editor interaction | Powered by`@tifan/pi-fixed-editor`, with runtime toggling, five-row wheel scrolling, tool clicks, and back-to-bottom control | `/ccstyle` |
49
+ | Context inspection | Usage breakdown and previews for the system prompt, tools, skills, and messages | `/context` |
50
+ | Session references | Search and inject effective context from previous Sessions or existing SubAgents | `@session:` |
51
+ | Theme | Bundled GitHub Dark Default and CC Dark themes | `/theme` |
52
52
 
53
53
  ### Output modes
54
54
 
@@ -126,12 +126,16 @@ Run `/reload` after changing extensions.
126
126
  ## Recommended companions
127
127
 
128
128
 
129
- | Extension | Purpose |
130
- | ------------------------------- | -------------------------------------------------------------- |
131
- | `npm:@tintinweb/pi-subagents` | Parallel SubAgents, background tasks, and worktree isolation |
132
- | `npm:pi-mcp-adapter` | On-demand MCP tool discovery with lower context usage |
133
- | `npm:pi-theme-picker` | Theme search and live preview |
134
- | `npm:@ayulab/pi-rewind` | Checkpoint-based code or conversation rollback |
129
+ | Extension | Purpose |
130
+ | ------------------------------------------ | -------------------------------------------------------------- |
131
+ | `npm:@tintinweb/pi-subagents` | Parallel SubAgents, background tasks, and worktree isolation |
132
+ | `npm:@tintinweb/pi-tasks` | Claude Code-style task tracking and coordination |
133
+ | `npm:pi-mcp-adapter` | On-demand MCP tool discovery with lower context usage |
134
+ | `npm:@ff-labs/pi-fff` | FFF-powered fuzzy file and content search (fffind / ffgrep) |
135
+ | `npm:pi-web-access` | Web search, URL fetching, GitHub cloning, PDF/video parsing |
136
+ | `npm:pi-theme-picker` | Theme search and live preview |
137
+ | `npm:@narumitw/pi-usage` | Current-account usage for Codex / Copilot / OpenRouter |
138
+ | `git:github.com/DietrichGebert/ponytail` | Lazy-mode coding: forces the simplest working solution |
135
139
 
136
140
  ## Credits
137
141
 
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- 类 Claude Code 风格界面、固定编辑器、上下文检查,以及 Agent / Session 引用。
13
+ 类 Claude Code TUI输出风格、固定编辑器、上下文检查,以及 Agent / Session 引用。
14
14
  </p>
15
15
 
16
16
  <p align="center">
@@ -38,13 +38,14 @@ pi install git:github.com/minuque/pi-cc-extensions
38
38
 
39
39
  ## 功能
40
40
 
41
- | 功能 | 说明 | 入口 |
42
- | -------------------- | ------------------------------------------------------------------------------------- | ------------- |
41
+
42
+ | 功能 | 说明 | 入口 |
43
+ | ---------------------- | --------------------------------------------------------------------------------- | ------------- |
43
44
  | Claude Code 风格输出 | 工具摘要、折叠展开、rich edit/write diff,以及`on` / `off` / `compact` 三种模式 | `/ccstyle` |
44
- | Fixed editor 交互 | 基于`@tifan/pi-fixed-editor`,支持动态开关、每刻度 5 行滚动、工具点击与回到底部 | `/ccstyle` |
45
- | 上下文检查 | 查看上下文占用,并预览 System prompt、Tools、Skills 和消息内容 | `/context` |
46
- | Session 引用 | 搜索并注入历史 Session 或现有 SubAgent 的有效上下文 | `@session:` |
47
- | 主题 | 随包提供内置 GitHub Dark Default 主题 | `/theme` |
45
+ | Fixed editor 交互 | 基于`@tifan/pi-fixed-editor`,支持动态开关、每刻度 5 行滚动、工具点击与回到底部 | `/ccstyle` |
46
+ | 上下文检查 | 查看上下文占用,并预览 System prompt、Tools、Skills 和消息内容 | `/context` |
47
+ | Session 引用 | 搜索并注入历史 Session 或现有 SubAgent 的有效上下文 | `@session:` |
48
+ | 主题 | 随包提供内置 GitHub Dark Default、CC Dark 主题 | `/theme` |
48
49
 
49
50
  ## 本地开发
50
51
 
@@ -73,12 +74,17 @@ pi -e .
73
74
 
74
75
  ## 推荐搭配
75
76
 
76
- | 扩展 | 用途 |
77
- | ------------------------------- | ----------------------------------- |
78
- | `npm:@tintinweb/pi-subagents` | 并行 SubAgent、后台任务与工作树隔离 |
79
- | `npm:pi-mcp-adapter` | 按需发现 MCP 工具,减少上下文占用 |
80
- | `npm:pi-theme-picker` | 主题搜索和实时预览 |
81
- | `npm:@ayulab/pi-rewind` | 基于 checkpoint 回退代码或对话 |
77
+
78
+ | 扩展 | 用途 |
79
+ | ------------------------------------------ | -------------------------------------------------- |
80
+ | `npm:@tintinweb/pi-subagents` | 并行 SubAgent、后台任务与工作树隔离 |
81
+ | `npm:@tintinweb/pi-tasks` | Claude Code 风格任务跟踪与协调 |
82
+ | `npm:pi-mcp-adapter` | 按需发现 MCP 工具,减少上下文占用 |
83
+ | `npm:@ff-labs/pi-fff` | 模糊文件与内容检索(fffind / ffgrep) |
84
+ | `npm:pi-web-access` | 网页搜索、URL 抓取、GitHub 克隆、PDF/视频解析 |
85
+ | `npm:pi-theme-picker` | 主题搜索和实时预览 |
86
+ | `npm:@narumitw/pi-usage` | 查看当前账号用量(Codex / Copilot / OpenRouter) |
87
+ | `git:github.com/DietrichGebert/ponytail` | 极简编码:强制最懒但有效的方案 |
82
88
 
83
89
  ## 致谢
84
90
 
@@ -1,12 +1,25 @@
1
- import * as piAi from "@earendil-works/pi-ai";
2
- import * as piCodingAgent from "@earendil-works/pi-coding-agent";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import * as piTui from "@earendil-works/pi-tui";
5
- import { readFileSync, rmSync } from "node:fs";
6
- import { createRequire } from "node:module";
7
- import { homedir } from "node:os";
8
- import { join } from "node:path";
9
- import { createJiti } from "jiti";
1
+ // compact-thinking 本地内联实现。fork 自 pi-compact-thinking(MIT,
2
+ // https://github.com/tifandotme/pi-extensions/tree/master/packages/pi-compact-thinking)。
3
+ // 差异:
4
+ // 1. 不再依赖上游包 —— 配置解耦,由 claude-code-style 经 installCompactThinking
5
+ // /updateConfig 管控(模块级 config 对象,不再读写 compact-thinking.json)。
6
+ // 2. 合并了上游 fork patch:subagent 工具(Agent/Agents)执行期间保持思考动画,
7
+ // 直到 tool_execution_end 或下一个 text/thinking 边界才收尾。
8
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
9
+ import {
10
+ AssistantMessageComponent,
11
+ type ExtensionAPI,
12
+ type Theme,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ // @ts-expect-error pi-tui 类型声明的 .ts 后缀 re-export 解析失败;运行时存在
15
+ import { getKeybindings } from "@earendil-works/pi-tui";
16
+ import { Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
17
+
18
+ // pi-tui 类型声明中 TUI 的 re-export 解析失败,本地用最小结构化类型(只用到 requestRender)。
19
+ type RenderTui = { requestRender(force?: boolean): void };
20
+
21
+ // pi-coding-agent 的类型声明中 AssistantMessageComponent 仅能以 value 形式使用。
22
+ type AssistantMessageComponentLike = InstanceType<typeof AssistantMessageComponent>;
10
23
 
11
24
  export type CompactThinkingConfig = {
12
25
  useSummaryTitlesAsThinkingTitle: boolean;
@@ -18,120 +31,639 @@ export type CompactThinkingController = {
18
31
  updateConfig(next: CompactThinkingConfig): void;
19
32
  };
20
33
 
21
- type CompactThinkingOwner = {
22
- owner: object;
23
- stop(event?: any, ctx?: any): void;
34
+ type DurationEntryData = {
35
+ messageTimestamp: number;
36
+ contentIndex: number;
37
+ durationMs: number;
24
38
  };
25
39
 
26
- type UpstreamHandler = (event: any, ctx: any) => void;
40
+ type SummaryPart = {
41
+ title: string;
42
+ body: string;
43
+ };
27
44
 
28
- const COMPACT_THINKING_OWNER = Symbol.for("pi.ccstyle.compact-thinking-owner");
45
+ type ActiveThinking = {
46
+ messageTimestamp: number;
47
+ contentIndex: number;
48
+ startedAt: number;
49
+ };
29
50
 
30
- function loadPatchedUpstream(): {
31
- config: CompactThinkingConfig;
32
- compactThinking: (api: ExtensionAPI) => void;
33
- } {
34
- // Fork the upstream entry so a running subagent tool keeps the thinking
35
- // loading animation until the model emits the next text/thinking boundary.
36
- // Upstream finalizes on tool_execution_start / message_end, freezing the
37
- // summary into a static "Thought for Xs" for the whole subagent run.
38
- const upstreamRequire = createRequire(import.meta.url);
39
- const upstreamIndexPath = upstreamRequire.resolve("pi-compact-thinking/index.ts");
40
- const toolStartOriginal = ` pi.on("tool_execution_start", finishThinking);`;
41
- const toolStartPatched = ` // Subagent tools can run for minutes: keep the thinking loading animation
42
- // alive for the whole execution and only finalize once the tool ends or the
43
- // model emits the next text/thinking boundary.
44
- function resumeAgentThinking(message: AssistantMessage | undefined) {
45
- if (activeThinking) return;
46
- const content = message?.content;
47
- if (!Array.isArray(content)) return;
48
- const index = content.findIndex((item) => item?.type === "thinking");
49
- if (index < 0) return;
50
- startThinking(message, index);
51
- }
52
- function messageHasAgentTool(message: AssistantMessage | undefined) {
53
- return (
54
- Array.isArray(message?.content) &&
55
- message.content.some(
56
- (content) =>
57
- content?.type === "toolCall" &&
58
- (content.name === "Agent" ||
59
- content.name === "Agents" ||
60
- content.args?.subagent_type != null),
61
- )
62
- );
63
- }
64
- pi.on("tool_execution_start", (event: any) => {
65
- if (event.toolName === "Agent" || event.toolName === "Agents") {
66
- resumeAgentThinking(latestComponent?.lastMessage ?? undefined);
67
- } else {
68
- finishThinking();
69
- }
70
- });
71
- pi.on("tool_execution_end", (event: any) => {
72
- if (event.toolName === "Agent" || event.toolName === "Agents") finishThinking();
73
- });`;
74
- const updateBoundaryOriginal = ` } else if (
75
- update.type === "text_start" ||
76
- update.type === "toolcall_start" ||
77
- update.type === "toolcall_delta"
78
- ) {
79
- finishThinking();
80
- }`;
81
- const updateBoundaryPatched = ` } else if (update.type === "text_start") {
82
- finishThinking();
83
- } else if (
84
- update.type === "toolcall_start" ||
85
- update.type === "toolcall_delta"
86
- ) {
87
- if (messageHasAgentTool(event.message)) {
88
- resumeAgentThinking(event.message);
89
- } else {
90
- finishThinking();
91
- }
92
- }`;
93
- const messageEndOriginal = ` pi.on("message_end", (event) => {
94
- if (event.message.role === "assistant") finishThinking();
95
- });`;
96
- const messageEndPatched = ` pi.on("message_end", (event) => {
97
- if (event.message.role !== "assistant") return;
98
- // Agent tool runs after message_end; keep the ticker until tool_execution_end
99
- // or the next text/thinking boundary.
100
- if (messageHasAgentTool(event.message)) return;
101
- finishThinking();
102
- });`;
103
-
104
- let upstreamIndexSource = readFileSync(upstreamIndexPath, "utf8");
105
- if (
106
- upstreamIndexSource.includes(toolStartOriginal) &&
107
- upstreamIndexSource.includes(updateBoundaryOriginal) &&
108
- upstreamIndexSource.includes(messageEndOriginal)
109
- ) {
110
- upstreamIndexSource = upstreamIndexSource
111
- .replace(toolStartOriginal, toolStartPatched)
112
- .replace(updateBoundaryOriginal, updateBoundaryPatched)
113
- .replace(messageEndOriginal, messageEndPatched);
51
+ type AssistantInternals = {
52
+ contentContainer: {
53
+ clear(): void;
54
+ addChild(component: Component): void;
55
+ };
56
+ hideThinkingBlock: boolean;
57
+ markdownTheme: ConstructorParameters<typeof Markdown>[3];
58
+ hiddenThinkingLabel: string;
59
+ outputPad: number;
60
+ lastMessage?: AssistantMessage;
61
+ hasToolCalls: boolean;
62
+ updateContent(message: AssistantMessage): void;
63
+ };
64
+
65
+ type PatchedPrototype = typeof AssistantMessageComponent.prototype & {
66
+ updateContent: (message: AssistantMessage) => void;
67
+ };
68
+
69
+ // 配置由 claude-code-style 管控(installCompactThinking 注入初始值,
70
+ // updateConfig 运行时更新),上游的配置文件读写已移除。
71
+ const config: CompactThinkingConfig = {
72
+ useSummaryTitlesAsThinkingTitle: true,
73
+ previewLines: 3,
74
+ animationIntervalMs: 90,
75
+ };
76
+
77
+ const DURATION_ENTRY_TYPE = "compact-thinking-duration";
78
+
79
+ function restoreDurationEntries(
80
+ entries: Array<{ type: string; customType?: string; data?: unknown }>,
81
+ completedDurations: Map<number, Map<number, number>>,
82
+ ) {
83
+ completedDurations.clear();
84
+ for (const entry of entries) {
85
+ if (
86
+ entry.type !== "custom" ||
87
+ entry.customType !== DURATION_ENTRY_TYPE ||
88
+ !entry.data ||
89
+ typeof entry.data !== "object"
90
+ ) {
91
+ continue;
92
+ }
93
+
94
+ const data = entry.data as Partial<DurationEntryData>;
95
+ if (
96
+ typeof data.messageTimestamp !== "number" ||
97
+ !Number.isFinite(data.messageTimestamp) ||
98
+ typeof data.contentIndex !== "number" ||
99
+ !Number.isInteger(data.contentIndex) ||
100
+ typeof data.durationMs !== "number" ||
101
+ !Number.isFinite(data.durationMs) ||
102
+ data.durationMs < 1
103
+ ) {
104
+ continue;
105
+ }
106
+
107
+ let durations = completedDurations.get(data.messageTimestamp);
108
+ if (!durations) {
109
+ durations = new Map();
110
+ completedDurations.set(data.messageTimestamp, durations);
111
+ }
112
+ durations.set(data.contentIndex, data.durationMs);
114
113
  }
114
+ }
115
115
 
116
- // Reuse Pi's live modules so the upstream prototype patch reaches runtime components.
117
- const jiti = createJiti(import.meta.url, {
118
- virtualModules: {
119
- "@earendil-works/pi-ai": piAi,
120
- "@earendil-works/pi-coding-agent": piCodingAgent,
121
- "@earendil-works/pi-tui": piTui,
122
- },
116
+ function formatThoughtDuration(durationMs: number) {
117
+ if (durationMs < 1_000) {
118
+ return `${Math.max(1, Math.round(durationMs))}ms`;
119
+ }
120
+
121
+ const totalSeconds = Math.max(1, Math.round(durationMs / 1_000));
122
+ const minutes = Math.floor(totalSeconds / 60);
123
+ const seconds = totalSeconds % 60;
124
+ if (minutes === 0) return `${seconds}s`;
125
+ return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
126
+ }
127
+
128
+ function getThinkingToggleHint() {
129
+ const keys = getKeybindings().getKeys("app.thinking.toggle");
130
+ return keys.length > 0 ? `${keys.join("/")} to expand` : undefined;
131
+ }
132
+
133
+ class StrictThinkingPreview implements Component {
134
+ private text: string;
135
+ private padding: number;
136
+ private style: (text: string) => string;
137
+
138
+ constructor(text: string, padding: number, style: (text: string) => string) {
139
+ this.text = text;
140
+ this.padding = padding;
141
+ this.style = style;
142
+ }
143
+
144
+ render(width: number) {
145
+ const lines = new Text(this.style(this.text), this.padding, 0).render(width);
146
+ if (lines.length <= config.previewLines) return lines;
147
+
148
+ const hiddenLines = lines.length - config.previewLines;
149
+ const noun = hiddenLines === 1 ? "line" : "lines";
150
+ const toggleHint = getThinkingToggleHint();
151
+ const hint = `... (${hiddenLines} more ${noun}${toggleHint ? `, ${toggleHint}` : ""})`;
152
+ const hintLines = new Text(this.style(hint), this.padding, 0).render(width);
153
+ return [...hintLines, ...lines.slice(-config.previewLines)];
154
+ }
155
+
156
+ invalidate() {}
157
+ }
158
+
159
+ function parseSummaryPart(text: string): SummaryPart | undefined {
160
+ const match = /^\s*\*\*([^\n]+?)\*\*[ \t]*(?:\r?\n(?:\r?\n)?([\s\S]*))?\s*$/.exec(text);
161
+ if (!match) return undefined;
162
+ return { title: match[1].trim(), body: (match[2] ?? "").trim() };
163
+ }
164
+
165
+ function parseLatestStreamingSummary(text: string): SummaryPart | undefined {
166
+ // Providers do not consistently insert a blank line between streamed
167
+ // summary parts, so accept a bold title at the start of any source line.
168
+ const titlePattern = /(?:^|\r?\n)\s*\*\*([^\n*]+?)\*\*[ \t]*(?:\r?\n)?/g;
169
+ let latest: RegExpExecArray | undefined;
170
+ let match: RegExpExecArray | null;
171
+ while ((match = titlePattern.exec(text))) latest = match;
172
+ if (!latest) return parseSummaryPart(text);
173
+
174
+ return {
175
+ title: latest[1].trim(),
176
+ body: text.slice(latest.index + latest[0].length).trim(),
177
+ };
178
+ }
179
+
180
+ function isOpenAiResponsesMessage(message: AssistantMessage) {
181
+ return (
182
+ message.api === "openai-responses" ||
183
+ message.api === "openai-codex-responses" ||
184
+ message.api === "azure-openai-responses"
185
+ );
186
+ }
187
+
188
+ function getLatestOpenAiSummary(thinkingSignature: string | undefined): SummaryPart | undefined {
189
+ if (!thinkingSignature) return undefined;
190
+
191
+ try {
192
+ const item = JSON.parse(thinkingSignature) as {
193
+ type?: unknown;
194
+ summary?: Array<{ type?: unknown; text?: unknown }>;
195
+ };
196
+ if (item.type !== "reasoning" || !Array.isArray(item.summary)) {
197
+ return undefined;
198
+ }
199
+
200
+ for (let i = item.summary.length - 1; i >= 0; i--) {
201
+ const part = item.summary[i];
202
+ if (part.type !== "summary_text" || typeof part.text !== "string") {
203
+ continue;
204
+ }
205
+ const parsed = parseSummaryPart(part.text);
206
+ if (parsed) return parsed;
207
+ }
208
+ } catch {
209
+ // Invalid or provider-specific signatures use the generic fallback.
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ const WIDGET_ID = "compact-thinking-render-loop";
215
+
216
+ // 上游 index.ts 内联(含 subagent fork patch)。
217
+ function compactThinking(pi: ExtensionAPI) {
218
+ const prototype = AssistantMessageComponent.prototype as PatchedPrototype;
219
+ const originalUpdateContent = prototype.updateContent;
220
+
221
+ const completedDurations = new Map<number, Map<number, number>>();
222
+ const renderedComponents = new Set<AssistantMessageComponentLike>();
223
+ const streamingComponents = new Set<AssistantMessageComponentLike>();
224
+ let activeThinking: ActiveThinking | undefined;
225
+ let activeTheme: Theme | undefined;
226
+ let activeTui: RenderTui | undefined;
227
+ let latestComponent: AssistantMessageComponentLike | undefined;
228
+ let latestComponentTimestamp: number | undefined;
229
+ let animationTimer: ReturnType<typeof setInterval> | undefined;
230
+ let animationFrame = 0;
231
+ let patchInstalled = true;
232
+
233
+ function refreshRenderedComponents() {
234
+ for (const component of renderedComponents) {
235
+ const self = component as unknown as AssistantInternals;
236
+ if (self.lastMessage) self.updateContent(self.lastMessage);
237
+ }
238
+ activeTui?.requestRender(true);
239
+ }
240
+
241
+ function thinkingStyle(text: string) {
242
+ return activeTheme ? activeTheme.italic(activeTheme.fg("thinkingText", text)) : text;
243
+ }
244
+
245
+ function summaryTitleStyle(text: string) {
246
+ return activeTheme
247
+ ? activeTheme.italic(activeTheme.bold(activeTheme.fg("thinkingText", text)))
248
+ : text;
249
+ }
250
+
251
+ function animatedText(text: string, baseStyle: (value: string) => string, animate: boolean) {
252
+ if (!animate || !activeTheme) return baseStyle(text);
253
+
254
+ const characters = Array.from(text);
255
+ if (characters.length === 0) return "";
256
+ const highlightWidth = Math.max(1, Math.min(5, Math.ceil(characters.length * 0.28)));
257
+ const start = (animationFrame % (characters.length + highlightWidth)) - highlightWidth;
258
+ const end = start + highlightWidth;
259
+ const before = characters.slice(0, Math.max(0, start)).join("");
260
+ const highlighted = characters
261
+ .slice(Math.max(0, start), Math.min(characters.length, end))
262
+ .join("");
263
+ const after = characters.slice(Math.max(0, end)).join("");
264
+
265
+ return (
266
+ baseStyle(before) +
267
+ (highlighted
268
+ ? activeTheme.italic(activeTheme.bold(activeTheme.fg("text", highlighted)))
269
+ : "") +
270
+ baseStyle(after)
271
+ );
272
+ }
273
+
274
+ function getCompletedDuration(messageTimestamp: number, startIndex: number, endIndex: number) {
275
+ const durations = completedDurations.get(messageTimestamp);
276
+ if (!durations) return undefined;
277
+ for (let index = endIndex; index >= startIndex; index--) {
278
+ const duration = durations.get(index);
279
+ if (duration !== undefined) return duration;
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ function isActiveRun(message: AssistantMessage, startIndex: number, endIndex: number) {
285
+ return (
286
+ activeThinking?.messageTimestamp === message.timestamp &&
287
+ activeThinking.contentIndex >= startIndex &&
288
+ activeThinking.contentIndex <= endIndex
289
+ );
290
+ }
291
+
292
+ prototype.updateContent = function patchedUpdateContent(message: AssistantMessage) {
293
+ const component = this as AssistantMessageComponentLike;
294
+ const self = this as unknown as AssistantInternals;
295
+ self.lastMessage = message;
296
+ renderedComponents.add(component);
297
+ latestComponent = component;
298
+ latestComponentTimestamp = message.timestamp;
299
+
300
+ // Visible mode is intentionally untouched: Shift+Tab restores Pi's exact
301
+ // built-in Thinking Markdown renderer, including every OpenAI summary stage.
302
+ if (!self.hideThinkingBlock) {
303
+ originalUpdateContent.call(this, message);
304
+ return;
305
+ }
306
+
307
+ if (activeThinking?.messageTimestamp === message.timestamp) {
308
+ streamingComponents.add(component);
309
+ }
310
+
311
+ self.contentContainer.clear();
312
+ const hasActiveThinking =
313
+ activeThinking?.messageTimestamp === message.timestamp &&
314
+ message.content.some((content) => content.type === "thinking");
315
+ const hasVisibleContent =
316
+ hasActiveThinking ||
317
+ message.content.some(
318
+ (content) =>
319
+ (content.type === "text" && content.text.trim()) ||
320
+ (content.type === "thinking" && content.thinking.trim()),
321
+ );
322
+ // Reserve Pi's normal leading spacer even before the first thinking token.
323
+ // This prevents the placeholder heading from jumping down one row when
324
+ // summary/body content begins streaming.
325
+ if (hasVisibleContent) self.contentContainer.addChild(new Spacer(1));
326
+
327
+ for (let i = 0; i < message.content.length; i++) {
328
+ const content = message.content[i];
329
+
330
+ if (content.type === "text" && content.text.trim()) {
331
+ self.contentContainer.addChild(
332
+ new Markdown(content.text.trim(), self.outputPad, 0, self.markdownTheme),
333
+ );
334
+ continue;
335
+ }
336
+
337
+ if (content.type !== "thinking") continue;
338
+
339
+ const runStartIndex = i;
340
+ const hasVisibleContentBefore = message.content
341
+ .slice(0, runStartIndex)
342
+ .some(
343
+ (previous) =>
344
+ (previous.type === "text" && previous.text.trim()) ||
345
+ (previous.type === "thinking" && previous.thinking.trim()),
346
+ );
347
+ const thinkingBlocks: string[] = [];
348
+ let latestSummary: SummaryPart | undefined;
349
+ for (; i < message.content.length; i++) {
350
+ const thinkingContent = message.content[i];
351
+ if (thinkingContent.type !== "thinking") break;
352
+ const thinking = thinkingContent.thinking.trim();
353
+ if (!thinking) continue;
354
+ thinkingBlocks.push(thinking);
355
+
356
+ if (config.useSummaryTitlesAsThinkingTitle && isOpenAiResponsesMessage(message)) {
357
+ const contentIsActive =
358
+ activeThinking?.messageTimestamp === message.timestamp &&
359
+ activeThinking.contentIndex === i;
360
+ // During streaming, thinkingSignature may contain only the previously
361
+ // completed summary parts. Prefer the live text so a newly arriving
362
+ // title immediately replaces the old one instead of appearing in its
363
+ // preview body. Once complete, the structured signature is canonical.
364
+ latestSummary = contentIsActive
365
+ ? (parseLatestStreamingSummary(thinking) ??
366
+ getLatestOpenAiSummary(thinkingContent.thinkingSignature) ??
367
+ latestSummary)
368
+ : (getLatestOpenAiSummary(thinkingContent.thinkingSignature) ??
369
+ parseLatestStreamingSummary(thinking) ??
370
+ latestSummary);
371
+ }
372
+ }
373
+ const runEndIndex = i - 1;
374
+ i--;
375
+ const active = isActiveRun(message, runStartIndex, runEndIndex);
376
+ // OpenAI can spend several seconds reasoning before it emits the first
377
+ // summary token. Keep an empty active block visible as animated
378
+ // "Thinking..." during that otherwise silent interval.
379
+ if (thinkingBlocks.length === 0 && !active) continue;
380
+ if (hasVisibleContentBefore) {
381
+ self.contentContainer.addChild(new Spacer(1));
382
+ }
383
+
384
+ const elapsedMs = activeThinking
385
+ ? Math.max(1, Date.now() - activeThinking.startedAt)
386
+ : undefined;
387
+ const completedMs = getCompletedDuration(message.timestamp, runStartIndex, runEndIndex);
388
+ const durationMs = active ? elapsedMs : completedMs;
389
+ const durationText = durationMs === undefined ? undefined : formatThoughtDuration(durationMs);
390
+
391
+ let heading: string;
392
+ if (active && latestSummary) {
393
+ heading =
394
+ animatedText(latestSummary.title, summaryTitleStyle, true) +
395
+ (durationText ? thinkingStyle(` · ${durationText}`) : "");
396
+ } else if (active) {
397
+ const label = self.hiddenThinkingLabel || "Thinking...";
398
+ heading =
399
+ animatedText(label, thinkingStyle, true) +
400
+ (durationText ? thinkingStyle(` · ${durationText}`) : "");
401
+ } else {
402
+ // Completed compact blocks use one provider-independent status line.
403
+ heading = thinkingStyle(
404
+ durationText ? `Thought for ${durationText}` : self.hiddenThinkingLabel || "Thinking...",
405
+ );
406
+ }
407
+ self.contentContainer.addChild(new Text(heading, self.outputPad, 0));
408
+
409
+ const previewSource = latestSummary?.body ?? thinkingBlocks.join("\n\n");
410
+ if (config.previewLines > 0 && previewSource.trim()) {
411
+ self.contentContainer.addChild(
412
+ new StrictThinkingPreview(previewSource.trim(), self.outputPad, thinkingStyle),
413
+ );
414
+ }
415
+
416
+ const hasVisibleContentAfter = message.content
417
+ .slice(i + 1)
418
+ .some(
419
+ (next) =>
420
+ (next.type === "text" && next.text.trim()) ||
421
+ (next.type === "thinking" && next.thinking.trim()),
422
+ );
423
+ if (hasVisibleContentAfter) self.contentContainer.addChild(new Spacer(1));
424
+ }
425
+
426
+ const hasToolCalls = message.content.some((content) => content.type === "toolCall");
427
+ self.hasToolCalls = hasToolCalls;
428
+
429
+ if (message.stopReason === "length") {
430
+ self.contentContainer.addChild(new Spacer(1));
431
+ self.contentContainer.addChild(
432
+ new Text(
433
+ activeTheme
434
+ ? activeTheme.fg(
435
+ "error",
436
+ "Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
437
+ )
438
+ : "Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
439
+ self.outputPad,
440
+ 0,
441
+ ),
442
+ );
443
+ } else if (!hasToolCalls) {
444
+ if (message.stopReason === "aborted") {
445
+ const abortMessage =
446
+ message.errorMessage && message.errorMessage !== "Request was aborted"
447
+ ? message.errorMessage
448
+ : "Operation aborted";
449
+ self.contentContainer.addChild(new Spacer(1));
450
+ self.contentContainer.addChild(
451
+ new Text(
452
+ activeTheme ? activeTheme.fg("error", abortMessage) : abortMessage,
453
+ self.outputPad,
454
+ 0,
455
+ ),
456
+ );
457
+ } else if (message.stopReason === "error") {
458
+ const errorMessage = message.errorMessage || "Unknown error";
459
+ self.contentContainer.addChild(new Spacer(1));
460
+ self.contentContainer.addChild(
461
+ new Text(
462
+ activeTheme
463
+ ? activeTheme.fg("error", `Error: ${errorMessage}`)
464
+ : `Error: ${errorMessage}`,
465
+ self.outputPad,
466
+ 0,
467
+ ),
468
+ );
469
+ }
470
+ }
471
+ };
472
+
473
+ function startThinking(message: AssistantMessage, contentIndex: number) {
474
+ activeThinking = {
475
+ messageTimestamp: message.timestamp,
476
+ contentIndex,
477
+ startedAt: Date.now(),
478
+ };
479
+ streamingComponents.clear();
480
+ animationFrame = 0;
481
+
482
+ // Depending on event-listener order, Pi may have rendered the empty
483
+ // thinking_start partial just before this extension receives the event.
484
+ // Re-render that component immediately so users do not wait for the first
485
+ // summary delta before seeing the animation.
486
+ if (latestComponent && latestComponentTimestamp === message.timestamp) {
487
+ streamingComponents.add(latestComponent);
488
+ const self = latestComponent as unknown as AssistantInternals;
489
+ self.updateContent(message);
490
+ activeTui?.requestRender();
491
+ }
492
+
493
+ if (animationTimer) clearInterval(animationTimer);
494
+ animationTimer = setInterval(() => {
495
+ animationFrame++;
496
+ for (const component of streamingComponents) {
497
+ const self = component as unknown as AssistantInternals;
498
+ if (self.lastMessage) self.updateContent(self.lastMessage);
499
+ }
500
+ activeTui?.requestRender();
501
+ }, config.animationIntervalMs);
502
+ }
503
+
504
+ function finishThinking() {
505
+ if (!activeThinking) return;
506
+ const finished = activeThinking;
507
+ const durationMs = Math.max(1, Date.now() - finished.startedAt);
508
+ let durations = completedDurations.get(finished.messageTimestamp);
509
+ if (!durations) {
510
+ durations = new Map();
511
+ completedDurations.set(finished.messageTimestamp, durations);
512
+ }
513
+ durations.set(finished.contentIndex, durationMs);
514
+ pi.appendEntry(DURATION_ENTRY_TYPE, {
515
+ messageTimestamp: finished.messageTimestamp,
516
+ contentIndex: finished.contentIndex,
517
+ durationMs,
518
+ } as DurationEntryData);
519
+
520
+ activeThinking = undefined;
521
+ if (animationTimer) clearInterval(animationTimer);
522
+ animationTimer = undefined;
523
+
524
+ const components = [...streamingComponents];
525
+ streamingComponents.clear();
526
+ for (const component of components) {
527
+ const self = component as unknown as AssistantInternals;
528
+ if (self.lastMessage) self.updateContent(self.lastMessage);
529
+ }
530
+ activeTui?.requestRender();
531
+ }
532
+
533
+ // ---- fork patch:subagent 工具执行期间保持思考动画 ----
534
+
535
+ // Subagent tools can run for minutes: keep the thinking loading animation
536
+ // alive for the whole execution and only finalize once the tool ends or the
537
+ // model emits the next text/thinking boundary.
538
+ function resumeAgentThinking(message: AssistantMessage | undefined) {
539
+ if (activeThinking) return;
540
+ const content = message?.content;
541
+ if (!Array.isArray(content)) return;
542
+ const index = content.findIndex((item) => item?.type === "thinking");
543
+ if (index < 0) return;
544
+ startThinking(message as AssistantMessage, index);
545
+ }
546
+
547
+ function messageHasAgentTool(message: AssistantMessage | undefined) {
548
+ return (
549
+ Array.isArray(message?.content) &&
550
+ message.content.some(
551
+ (content) =>
552
+ content?.type === "toolCall" &&
553
+ (content.name === "Agent" ||
554
+ content.name === "Agents" ||
555
+ content.args?.subagent_type != null),
556
+ )
557
+ );
558
+ }
559
+
560
+ pi.on("message_update", (event) => {
561
+ if (event.message.role !== "assistant") return;
562
+ const update = event.assistantMessageEvent;
563
+
564
+ if (update.type === "thinking_start") {
565
+ if (activeThinking?.messageTimestamp === event.message.timestamp) {
566
+ // Some Responses-compatible providers emit a fresh thinking_start for
567
+ // each summary/reasoning item even though no text or tool boundary has
568
+ // ended the visible Thinking run. Follow the new content block without
569
+ // resetting the run's original start time.
570
+ activeThinking.contentIndex = update.contentIndex;
571
+ } else {
572
+ if (activeThinking) finishThinking();
573
+ startThinking(event.message, update.contentIndex);
574
+ }
575
+ } else if (update.type === "thinking_delta") {
576
+ if (!activeThinking) {
577
+ startThinking(event.message, update.contentIndex);
578
+ } else if (activeThinking.messageTimestamp === event.message.timestamp) {
579
+ activeThinking.contentIndex = update.contentIndex;
580
+ }
581
+ } else if (update.type === "text_start") {
582
+ finishThinking();
583
+ } else if (update.type === "toolcall_start" || update.type === "toolcall_delta") {
584
+ if (messageHasAgentTool(event.message)) {
585
+ resumeAgentThinking(event.message);
586
+ } else {
587
+ finishThinking();
588
+ }
589
+ }
590
+ // Do not finalize on thinking_end alone. OpenAI Responses providers can
591
+ // close one reasoning item and immediately open another for the next
592
+ // summary while Pi still renders both as one contiguous Thinking run.
593
+ // A text/tool transition or message_end is the actual visible boundary.
594
+ });
595
+
596
+ // OpenAI-compatible providers may not close reasoning until the response ends.
597
+ pi.on("tool_execution_start", (event: any) => {
598
+ if (event.toolName === "Agent" || event.toolName === "Agents") {
599
+ resumeAgentThinking(latestComponent?.lastMessage ?? undefined);
600
+ } else {
601
+ finishThinking();
602
+ }
603
+ });
604
+ pi.on("tool_execution_end", (event: any) => {
605
+ if (event.toolName === "Agent" || event.toolName === "Agents") finishThinking();
606
+ });
607
+ pi.on("message_end", (event) => {
608
+ if (event.message.role !== "assistant") return;
609
+ // Agent tool runs after message_end; keep the ticker until tool_execution_end
610
+ // or the next text/thinking boundary.
611
+ if (messageHasAgentTool(event.message)) return;
612
+ finishThinking();
613
+ });
614
+
615
+ pi.on("session_start", (_event, ctx) => {
616
+ restoreDurationEntries(ctx.sessionManager.getBranch(), completedDurations);
617
+ activeTheme = ctx.ui.theme;
618
+ if (ctx.mode !== "tui") return;
619
+
620
+ // An empty widget gives the animation loop access to requestRender without
621
+ // enabling terminal mouse reporting or intercepting native scrollback input.
622
+ ctx.ui.setWidget(WIDGET_ID, (tui) => {
623
+ activeTui = tui;
624
+ return { render: () => [], invalidate() {} };
625
+ });
626
+
627
+ // On resume, Pi may construct the chat before session_start is emitted.
628
+ // Rebuild those already-rendered components now that persisted durations
629
+ // and the active theme are available.
630
+ refreshRenderedComponents();
631
+ });
632
+
633
+ pi.on("session_tree", (_event, ctx) => {
634
+ restoreDurationEntries(ctx.sessionManager.getBranch(), completedDurations);
635
+ refreshRenderedComponents();
636
+ });
637
+
638
+ pi.on("session_shutdown", (_event, ctx) => {
639
+ finishThinking();
640
+ if (animationTimer) clearInterval(animationTimer);
641
+ animationTimer = undefined;
642
+ activeTui = undefined;
643
+ activeTheme = undefined;
644
+ latestComponent = undefined;
645
+ latestComponentTimestamp = undefined;
646
+ completedDurations.clear();
647
+ renderedComponents.clear();
648
+ streamingComponents.clear();
649
+ if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_ID, undefined);
650
+
651
+ if (patchInstalled) {
652
+ prototype.updateContent = originalUpdateContent;
653
+ patchInstalled = false;
654
+ }
123
655
  });
124
- const upstreamConfig = (
125
- jiti("pi-compact-thinking/lib/config.ts") as { config: CompactThinkingConfig }
126
- ).config;
127
- const compactThinking = (
128
- jiti.evalModule(upstreamIndexSource, { filename: upstreamIndexPath }) as {
129
- default: (api: ExtensionAPI) => void;
130
- }
131
- ).default;
132
- return { config: upstreamConfig, compactThinking };
133
656
  }
134
657
 
658
+ type CompactThinkingOwner = {
659
+ owner: object;
660
+ stop(event?: any, ctx?: any): void;
661
+ };
662
+
663
+ const COMPACT_THINKING_OWNER = Symbol.for("pi.ccstyle.compact-thinking-owner");
664
+
665
+ type UpstreamHandler = (event: any, ctx: any) => void;
666
+
135
667
  export function installCompactThinking(
136
668
  pi: ExtensionAPI,
137
669
  initialConfig: CompactThinkingConfig,
@@ -141,7 +673,6 @@ export function installCompactThinking(
141
673
  [COMPACT_THINKING_OWNER]?: CompactThinkingOwner;
142
674
  };
143
675
 
144
- let upstreamConfig: CompactThinkingConfig | undefined;
145
676
  let session: { event: any; ctx: any } | undefined;
146
677
  let active = false;
147
678
  // Stable pi.on wrappers delegate here so activate/reload never double-binds.
@@ -188,22 +719,11 @@ export function installCompactThinking(
188
719
  host[COMPACT_THINKING_OWNER]?.stop(event, ctx);
189
720
  session = { event, ctx };
190
721
 
191
- // compact-thinking.json 已日落:不预写、不读取,配置统一由
192
- // claude-code-style.json 管控(下方 Object.assign 覆盖库默认值)。
193
- // 库首次加载会在 .pi/agent 下自建默认文件,加载完成后立即删除,不留残留。
194
- const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
195
- const configPath = join(agentDir, "compact-thinking.json");
196
- let loaded: ReturnType<typeof loadPatchedUpstream>;
197
- try {
198
- loaded = loadPatchedUpstream();
199
- } finally {
200
- rmSync(configPath, { force: true });
201
- }
202
- upstreamConfig = loaded.config;
203
- Object.assign(upstreamConfig, initialConfig);
722
+ // 配置统一由 claude-code-style 管控,加载时覆盖库默认值。
723
+ Object.assign(config, initialConfig);
204
724
  delegates.clear();
205
725
 
206
- loaded.compactThinking({
726
+ compactThinking({
207
727
  on(eventName: string, handler: UpstreamHandler) {
208
728
  if (eventName === "session_start") {
209
729
  // Already inside session_start — run immediately.
@@ -236,7 +756,7 @@ export function installCompactThinking(
236
756
  return {
237
757
  updateConfig(next) {
238
758
  Object.assign(initialConfig, next);
239
- if (upstreamConfig) Object.assign(upstreamConfig, next);
759
+ Object.assign(config, next);
240
760
  },
241
761
  };
242
762
  }
@@ -8,18 +8,6 @@ import { createJiti } from "jiti";
8
8
  type LifecycleHandler = (event: any, ctx: any) => void;
9
9
  type FixedEditorExtension = (pi: ExtensionAPI) => void;
10
10
 
11
- // Upstream publishes TS only. Nested jiti must reuse Pi's live modules — peer
12
- // deps are not resolvable from ~/.pi/agent/npm/node_modules/pi-cc-extensions.
13
- const jiti = createJiti(import.meta.url, {
14
- virtualModules: {
15
- "@earendil-works/pi-coding-agent": piCodingAgent,
16
- "@earendil-works/pi-tui": piTui,
17
- },
18
- });
19
- // Match pi-fixed-editor's own `.js` import specifier; Jiti caches `.ts` separately.
20
- const terminalSplit = jiti("@tifan/pi-fixed-editor/src/terminal-split.js") as {
21
- TerminalSplitCompositor?: { prototype: Record<PropertyKey, any> };
22
- };
23
11
  export type FixedEditorHitbox = { row: number; startCol: number; endCol: number };
24
12
  export type FixedEditorViewportSnapshot = {
25
13
  tui: object;
@@ -61,48 +49,104 @@ function clearFixedEditorViewport(): void {
61
49
  fixedEditorState.viewports = new WeakMap();
62
50
  }
63
51
 
64
- const FIXED_EDITOR_SPACING_PATCH = Symbol.for("pi.ccstyle.fixed-editor-spacing-patch");
65
- const clusterModule = jiti("@tifan/pi-fixed-editor/src/cluster.js") as {
66
- renderFixedEditorCluster?: (input: any) => any;
52
+ type UpstreamFixedEditor = {
53
+ start?: LifecycleHandler;
54
+ shutdown?: LifecycleHandler;
67
55
  };
68
- if (!(clusterModule as any)[FIXED_EDITOR_SPACING_PATCH] && clusterModule.renderFixedEditorCluster) {
69
- const renderCluster = clusterModule.renderFixedEditorCluster;
70
- const padWidgets = (lines: string[] | undefined) =>
71
- lines?.map((line) => (line.length > 0 && !line.includes("Ctrl+End") ? ` ${line}` : line));
72
- clusterModule.renderFixedEditorCluster = (input) => {
73
- const aboveWidgetLines = padWidgets(input.aboveWidgetLines);
74
- const result = renderCluster({
75
- ...input,
76
- // pi-fixed-editor strips Pi's leading widget spacer; restore it below the spinner.
77
- aboveWidgetLines:
78
- input.statusLines?.length && aboveWidgetLines?.length
79
- ? ["", ...aboveWidgetLines]
80
- : aboveWidgetLines,
81
- belowWidgetLines: padWidgets(input.belowWidgetLines),
56
+
57
+ // 上游按需加载:0.84+ 的 tui 是惰性 Proxy(installFixedEditor 用
58
+ // isLazyProxySession 探测),fixed-editor 整体停用 —— 连上游包都不解析,
59
+ // jiti 失败或包缺失都不影响主流程。
60
+ let upstreamLoaded = false;
61
+ let upstream: UpstreamFixedEditor | undefined;
62
+ function loadUpstream(): UpstreamFixedEditor | undefined {
63
+ if (upstreamLoaded) return upstream;
64
+ upstreamLoaded = true;
65
+ try {
66
+ // Upstream publishes TS only. Nested jiti must reuse Pi's live modules — peer
67
+ // deps are not resolvable from ~/.pi/agent/npm/node_modules/pi-cc-extensions.
68
+ const jiti = createJiti(import.meta.url, {
69
+ virtualModules: {
70
+ "@earendil-works/pi-coding-agent": piCodingAgent,
71
+ "@earendil-works/pi-tui": piTui,
72
+ },
82
73
  });
83
- const buttonRow = result.lines.findIndex((line: string) => line.includes("Ctrl+End"));
84
- if (buttonRow < 0) {
85
- fixedEditorState.scrollButtonHitbox = null;
86
- } else {
87
- const plain = result.lines[buttonRow]
88
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
89
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
90
- const start = plain.indexOf("[");
91
- fixedEditorState.scrollButtonHitbox = {
92
- row: input.terminalRows - result.lines.length + buttonRow + 1,
93
- startCol: visibleWidth(plain.slice(0, Math.max(0, start))) + 1,
94
- endCol: visibleWidth(plain.trimEnd()),
74
+ // Match pi-fixed-editor's own `.js` import specifier; Jiti caches `.ts` separately.
75
+ const terminalSplit = jiti("@tifan/pi-fixed-editor/src/terminal-split.js") as {
76
+ TerminalSplitCompositor?: { prototype: Record<PropertyKey, any> };
77
+ };
78
+
79
+ const FIXED_EDITOR_SPACING_PATCH = Symbol.for("pi.ccstyle.fixed-editor-spacing-patch");
80
+ const clusterModule = jiti("@tifan/pi-fixed-editor/src/cluster.js") as {
81
+ renderFixedEditorCluster?: (input: any) => any;
82
+ };
83
+ if (
84
+ !(clusterModule as any)[FIXED_EDITOR_SPACING_PATCH] &&
85
+ clusterModule.renderFixedEditorCluster
86
+ ) {
87
+ const renderCluster = clusterModule.renderFixedEditorCluster;
88
+ const padWidgets = (lines: string[] | undefined) =>
89
+ lines?.map((line) => (line.length > 0 && !line.includes("Ctrl+End") ? ` ${line}` : line));
90
+ clusterModule.renderFixedEditorCluster = (input) => {
91
+ const aboveWidgetLines = padWidgets(input.aboveWidgetLines);
92
+ const result = renderCluster({
93
+ ...input,
94
+ // pi-fixed-editor strips Pi's leading widget spacer; restore it below the spinner.
95
+ aboveWidgetLines:
96
+ input.statusLines?.length && aboveWidgetLines?.length
97
+ ? ["", ...aboveWidgetLines]
98
+ : aboveWidgetLines,
99
+ belowWidgetLines: padWidgets(input.belowWidgetLines),
100
+ });
101
+ const buttonRow = result.lines.findIndex((line: string) => line.includes("Ctrl+End"));
102
+ if (buttonRow < 0) {
103
+ fixedEditorState.scrollButtonHitbox = null;
104
+ } else {
105
+ const plain = result.lines[buttonRow]
106
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
107
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
108
+ const start = plain.indexOf("[");
109
+ fixedEditorState.scrollButtonHitbox = {
110
+ row: input.terminalRows - result.lines.length + buttonRow + 1,
111
+ startCol: visibleWidth(plain.slice(0, Math.max(0, start))) + 1,
112
+ endCol: visibleWidth(plain.trimEnd()),
113
+ };
114
+ }
115
+ return result;
95
116
  };
117
+ (clusterModule as any)[FIXED_EDITOR_SPACING_PATCH] = true;
96
118
  }
97
- return result;
98
- };
99
- (clusterModule as any)[FIXED_EDITOR_SPACING_PATCH] = true;
119
+ const loaded = jiti("@tifan/pi-fixed-editor/src/index.ts") as {
120
+ default?: FixedEditorExtension;
121
+ };
122
+ const fixedEditor = loaded.default ?? (loaded as unknown as FixedEditorExtension);
123
+
124
+ if (terminalSplit.TerminalSplitCompositor) {
125
+ installFixedEditorImePatch(terminalSplit.TerminalSplitCompositor);
126
+ // 0.84+ 的 tui 是惰性 Proxy:compositor 构造时捕获的 doRender/render 是
127
+ // 每次重新解析的包装,install 后执行会解析到 compositor 自身(无限递归)。
128
+ // 检测到惰性 Proxy 时跳过安装,渲染管线完全交给官方。
129
+ installProxyGuard(terminalSplit.TerminalSplitCompositor);
130
+ }
131
+
132
+ let start: LifecycleHandler | undefined;
133
+ let shutdown: LifecycleHandler | undefined;
134
+ fixedEditor({
135
+ on(event: string, handler: LifecycleHandler) {
136
+ if (event === "session_start") start = handler;
137
+ if (event === "session_shutdown") shutdown = handler;
138
+ },
139
+ } as unknown as ExtensionAPI);
140
+
141
+ upstream = { start, shutdown };
142
+ } catch (error) {
143
+ console.error("pi-cc-extensions: failed to load @tifan/pi-fixed-editor", error);
144
+ upstream = undefined;
145
+ }
146
+ return upstream;
100
147
  }
101
- const loaded = jiti("@tifan/pi-fixed-editor/src/index.ts") as {
102
- default?: FixedEditorExtension;
103
- };
104
- const fixedEditor = loaded.default ?? (loaded as unknown as FixedEditorExtension);
105
148
 
149
+ const COMPOSITOR_PROXY_GUARD = Symbol.for("pi.ccstyle.compositor-proxy-guard");
106
150
  const IME_CURSOR_PATCH = Symbol.for("pi.ccstyle.fixed-editor-ime-cursor-patch");
107
151
  const VIEWPORT_PATCH = Symbol.for("pi.ccstyle.fixed-editor-viewport-patch");
108
152
  const MOUSE_WRITE_PATCH = Symbol.for("pi.ccstyle.fixed-editor-mouse-write-patch");
@@ -197,16 +241,6 @@ export function installFixedEditorImePatch(compositorClass: {
197
241
  }
198
242
  }
199
243
 
200
- const COMPOSITOR_PROXY_GUARD = Symbol.for("pi.ccstyle.compositor-proxy-guard");
201
-
202
- if (terminalSplit.TerminalSplitCompositor) {
203
- installFixedEditorImePatch(terminalSplit.TerminalSplitCompositor);
204
- // 0.84+ 的 tui 是惰性 Proxy:compositor 构造时捕获的 doRender/render 是
205
- // 每次重新解析的包装,install 后执行会解析到 compositor 自身(无限递归)。
206
- // 检测到惰性 Proxy 时跳过安装,渲染管线完全交给官方。
207
- installProxyGuard(terminalSplit.TerminalSplitCompositor);
208
- }
209
-
210
244
  function installProxyGuard(compositorClass: { prototype: Record<PropertyKey, any> }): void {
211
245
  const prototype = compositorClass.prototype;
212
246
  const originalInstall = prototype.install;
@@ -277,20 +311,41 @@ function observeFooterReplacement(ui: any, listener: () => void): () => void {
277
311
  };
278
312
  }
279
313
 
314
+ const LAZY_PROXY_PROBE_KEY = "cc-lazy-proxy-probe";
315
+
316
+ /**
317
+ * 测试钩子:会话未激活时直接加载上游。懒加载下 patch 仅在 activate 时执行,
318
+ * 直接 new 上游 compositor / 调用上游 cluster 的测试需要显式触发。
319
+ */
320
+ export function loadFixedEditorUpstreamForTests(): void {
321
+ loadUpstream();
322
+ }
323
+
324
+ /** 探测当前会话的 TUI 是否为 0.84+ 惰性 Proxy(与 isFullscreenUi 同模式)。 */
325
+ function isLazyProxySession(ctx: any): boolean {
326
+ if (typeof ctx?.ui?.setWidget !== "function") return false;
327
+ let proxyTui = false;
328
+ try {
329
+ ctx.ui.setWidget(LAZY_PROXY_PROBE_KEY, (tui: any) => {
330
+ proxyTui = isLazyProxyTui(tui);
331
+ return { render: () => [], invalidate() {} };
332
+ });
333
+ } catch {
334
+ // 探测失败按 regular 处理,避免误伤
335
+ }
336
+ try {
337
+ ctx.ui.setWidget(LAZY_PROXY_PROBE_KEY, undefined);
338
+ } catch {
339
+ // 忽略
340
+ }
341
+ return proxyTui;
342
+ }
343
+
280
344
  /** Adds runtime on/off control around pi-fixed-editor's session lifecycle. */
281
345
  export function installFixedEditor(
282
346
  pi: ExtensionAPI,
283
347
  initiallyEnabled: boolean,
284
348
  ): FixedEditorController {
285
- let start: LifecycleHandler | undefined;
286
- let shutdown: LifecycleHandler | undefined;
287
- fixedEditor({
288
- on(event: string, handler: LifecycleHandler) {
289
- if (event === "session_start") start = handler;
290
- if (event === "session_shutdown") shutdown = handler;
291
- },
292
- } as unknown as ExtensionAPI);
293
-
294
349
  const owner = {};
295
350
  let enabled = initiallyEnabled;
296
351
  let active = false;
@@ -340,6 +395,11 @@ export function installFixedEditor(
340
395
  if (!session || session.ctx?.mode !== "tui" || active) return;
341
396
  // fullscreen 复用官方的 sticky editor,固定编辑器让位
342
397
  if (isFullscreenUi(session.ctx)) return;
398
+ // 0.84+ 的 tui 是惰性 Proxy:fixed-editor 整体停用,不加载上游包。
399
+ if (isLazyProxySession(session.ctx)) return;
400
+ const loadedUpstream = loadUpstream();
401
+ if (!loadedUpstream?.start || !loadedUpstream.shutdown) return;
402
+ const { start, shutdown } = loadedUpstream;
343
403
  const previous = (globalThis as any)[FIXED_EDITOR_OWNER] as FixedEditorOwner | undefined;
344
404
  if (previous?.owner !== owner) previous?.stop();
345
405
  const currentSession = session;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.43",
3
+ "version": "0.8.45",
4
4
  "license": "MIT",
5
5
  "description": "A Pi productivity suite with Claude Code-style UI, fixed editor, context inspection, and agent/session references",
6
6
  "type": "module",
@@ -31,7 +31,8 @@
31
31
  "./extensions/index.ts"
32
32
  ],
33
33
  "themes": [
34
- "./themes/github-dark-default.json"
34
+ "./themes/github-dark-default.json",
35
+ "./themes/cc-dark.json"
35
36
  ],
36
37
  "image": "https://raw.githubusercontent.com/minuque/pi-cc-extensions/main/assets/readme/welcome.webp"
37
38
  },
@@ -48,8 +49,7 @@
48
49
  "dependencies": {
49
50
  "@shikijs/cli": "^4.0.2",
50
51
  "@tifan/pi-fixed-editor": "^0.2.5",
51
- "jiti": "^2.7.0",
52
- "pi-compact-thinking": "^0.2.0"
52
+ "jiti": "^2.7.0"
53
53
  },
54
54
  "bundledDependencies": [
55
55
  "@tifan/pi-fixed-editor"
@@ -65,5 +65,8 @@
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "2.5.5",
67
67
  "@types/node": "^26.1.1"
68
- }
68
+ },
69
+ "bundleDependencies": [
70
+ "@tifan/pi-fixed-editor"
71
+ ]
69
72
  }
@@ -0,0 +1,96 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "cc-dark",
4
+ "vars": {
5
+ "bg": "#131217",
6
+ "panel": "#1b1922",
7
+ "surface": "#211e2b",
8
+ "surfaceRaised": "#292533",
9
+ "border": "#36313f",
10
+ "borderMuted": "#292631",
11
+ "accent": "#5e9cff",
12
+ "accentBright": "#8ab4ff",
13
+ "purple": "#c084fc",
14
+ "pink": "#f472b6",
15
+ "cyan": "#67e8f9",
16
+ "blue": "#60a5fa",
17
+ "green": "#4ade80",
18
+ "red": "#fb7185",
19
+ "yellow": "#facc15",
20
+ "orange": "#fb923c",
21
+ "text": "#f5f5f5",
22
+ "muted": "#a1a1aa",
23
+ "dim": "#71717a",
24
+ "selectedBg": "#252035",
25
+ "userMessageBg": "#1d1a27",
26
+ "toolPendingBg": "#1b1924",
27
+ "toolSuccessBg": "#18251f",
28
+ "toolErrorBg": "#2a1b22",
29
+ "customMessageBg": "#211c30"
30
+ },
31
+ "colors": {
32
+ "accent": "accent",
33
+ "border": "border",
34
+ "borderAccent": "accent",
35
+ "borderMuted": "borderMuted",
36
+ "success": "green",
37
+ "error": "red",
38
+ "warning": "yellow",
39
+ "muted": "muted",
40
+ "dim": "dim",
41
+ "text": "text",
42
+ "thinkingText": "muted",
43
+
44
+ "selectedBg": "selectedBg",
45
+ "userMessageBg": "userMessageBg",
46
+ "userMessageText": "text",
47
+ "customMessageBg": "customMessageBg",
48
+ "customMessageText": "text",
49
+ "customMessageLabel": "purple",
50
+ "toolPendingBg": "toolPendingBg",
51
+ "toolSuccessBg": "toolSuccessBg",
52
+ "toolErrorBg": "toolErrorBg",
53
+ "toolTitle": "accentBright",
54
+ "toolOutput": "muted",
55
+
56
+ "mdHeading": "accentBright",
57
+ "mdLink": "cyan",
58
+ "mdLinkUrl": "muted",
59
+ "mdCode": "accentBright",
60
+ "mdCodeBlock": "text",
61
+ "mdCodeBlockBorder": "border",
62
+ "mdQuote": "muted",
63
+ "mdQuoteBorder": "accent",
64
+ "mdHr": "borderMuted",
65
+ "mdListBullet": "accent",
66
+
67
+ "toolDiffAdded": "green",
68
+ "toolDiffRemoved": "red",
69
+ "toolDiffContext": "muted",
70
+
71
+ "syntaxComment": "#6b7280",
72
+ "syntaxKeyword": "pink",
73
+ "syntaxFunction": "accentBright",
74
+ "syntaxVariable": "blue",
75
+ "syntaxString": "green",
76
+ "syntaxNumber": "orange",
77
+ "syntaxType": "cyan",
78
+ "syntaxOperator": "text",
79
+ "syntaxPunctuation": "muted",
80
+
81
+ "thinkingOff": "muted",
82
+ "thinkingMinimal": "dim",
83
+ "thinkingLow": "blue",
84
+ "thinkingMedium": "accent",
85
+ "thinkingHigh": "accentBright",
86
+ "thinkingXhigh": "cyan",
87
+ "thinkingMax": "purple",
88
+
89
+ "bashMode": "accent"
90
+ },
91
+ "export": {
92
+ "pageBg": "bg",
93
+ "cardBg": "panel",
94
+ "infoBg": "surface"
95
+ }
96
+ }