pi-cc-extensions 1.0.14 → 1.0.16

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.
@@ -1,5 +1,6 @@
1
1
  import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { config } from "../../config/config.ts";
3
+ import { expandedLineLimit } from "../transcript-scope.ts";
3
4
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
4
5
  import {
5
6
  SHOW_MORE_LABEL,
@@ -106,8 +107,11 @@ export class ExpandedToolIoView {
106
107
 
107
108
  /** flushLeft:贴左渲染(外层 Box 负责 1 格 pad);默认 false 保留 1 格前导空格。 */
108
109
  private flushLeft: boolean;
109
- /** 尾部附注(shell 的耗时)。挂到 Output 的 show-more 行上,没有那一行时单独成行。 */
110
- private footer: string | undefined;
110
+ /** 尾部附注(shell 的耗时)。挂到 Output 的 show-more 行上,没有那一行时单独成行。
111
+ * 传取值函数时每帧求值,值变了才重画:运行中的秒数要跟着动画 tick 走。 */
112
+ private footer: string | (() => string | undefined) | undefined;
113
+ /** 上一次求值结果,用来判断 footer 是否变化。 */
114
+ private footerText: string | undefined;
111
115
 
112
116
  constructor(
113
117
  theme: any,
@@ -224,7 +228,7 @@ export class ExpandedToolIoView {
224
228
  }
225
229
 
226
230
  /** 尾部附注(耗时等)。挂在 show-more 行上;无 show-more 行时单独成行。 */
227
- setFooter(text: string | undefined): void {
231
+ setFooter(text: string | (() => string | undefined) | undefined): void {
228
232
  if (this.footer === text) return;
229
233
  this.footer = text;
230
234
  this.invalidate();
@@ -268,6 +272,12 @@ export class ExpandedToolIoView {
268
272
  }
269
273
 
270
274
  render(width: number): string[] {
275
+ // footer 可以是取值函数(运行中的耗时),每帧重算;变了就丢掉行缓存。
276
+ const footer = typeof this.footer === "function" ? this.footer() : this.footer;
277
+ if (footer !== this.footerText) {
278
+ this.footerText = footer;
279
+ this.cachedLines = undefined;
280
+ }
271
281
  if (this.cachedLines !== undefined && this.cachedWidth === width) {
272
282
  return withIoViewMarkers(this, this.cachedLines);
273
283
  }
@@ -397,7 +407,7 @@ export class ExpandedToolIoView {
397
407
  limit: this.maxOutputLines,
398
408
  continued: false,
399
409
  section: "output",
400
- footerNote: this.footer,
410
+ footerNote: footer,
401
411
  });
402
412
  } else {
403
413
  this.truncated.output = outputWouldTruncate;
@@ -406,14 +416,14 @@ export class ExpandedToolIoView {
406
416
  limit: this.maxOutputLines,
407
417
  continued: false,
408
418
  section: "output",
409
- footerNote: this.footer,
419
+ footerNote: footer,
410
420
  });
411
421
  }
412
422
 
413
423
  // 耗时挂在 Output 的 show-more 行上(与折叠态一样,提示留在行尾);
414
424
  // 没截断就没有那一行,才单独成行。放在最后追加,前面记下的行号不受影响。
415
- if (this.footer && this.showMoreHeaderRows.output === undefined) {
416
- pushRailLine(theme.fg("muted", this.footer), false);
425
+ if (footer && this.showMoreHeaderRows.output === undefined) {
426
+ pushRailLine(theme.fg("muted", footer), false);
417
427
  }
418
428
 
419
429
  this.cachedWidth = width;
@@ -639,8 +649,8 @@ export function renderExpandedToolResult(
639
649
  ): ExpandedToolIoView | ExpandedToolResultText | Text {
640
650
  const { text: inputBody, codeBlock } = formatToolInputBody(args);
641
651
  const outputBody = body;
642
- const maxOutputLines = config.expandedOutputMaxLines;
643
- const maxInputLines = config.expandedInputMaxLines;
652
+ const maxOutputLines = expandedLineLimit(config.expandedOutputMaxLines, context);
653
+ const maxInputLines = expandedLineLimit(config.expandedInputMaxLines, context);
644
654
 
645
655
  // Prefer structured Input/Output when we have args or non-empty output.
646
656
  if (inputBody.trim() || outputBody.trim()) {
@@ -0,0 +1,457 @@
1
+ /**
2
+ * 会话记录浏览层:对齐 Claude Code 的 transcript 视图。
3
+ *
4
+ * on / compact 模式、全屏、输入框有焦点时按「展开工具输出」键(默认 ctrl+o),
5
+ * 打开一个铺满屏幕的弹层,盖住对话区、输入框和底栏。弹层持有焦点,按键只用来翻阅。
6
+ *
7
+ * 弹层不走对话区的渲染流程:照着对话区现有组件新建一份副本来画。
8
+ * - 工具卡:按原参数与结果新建 ToolExecutionComponent,全部展开;
9
+ * - 模型回复:按原消息新建 AssistantMessageComponent,思考块完整显示;
10
+ * - 其余(用户消息、提示、摘要):不受展开影响,直接画原组件;
11
+ * - compact 挂进对话区的回合尾行:跳过。
12
+ * 副本带浏览标记(见 transcript-scope),compact 折叠、分组、互斥收起与行数上限都不作用于它们。
13
+ * 对话区本身一个状态都不改,关掉弹层即恢复原样。
14
+ *
15
+ * Pi 把 app.tools.expand 列为保留键,registerShortcut 注册不上;这里在原始输入到达
16
+ * 输入框之前拦截。只认 Pi 自己会响应该键的组件(带 actionHandlers 的编辑器)。
17
+ */
18
+
19
+ import {
20
+ AssistantMessageComponent,
21
+ ToolExecutionComponent,
22
+ UserMessageComponent,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { getKeybindings, isKeyRelease, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
25
+ import { config } from "../config/config.ts";
26
+ import { walkComponentTree } from "../utils/component-tree.ts";
27
+ import { isCompactRoundTail } from "./compact-mode.ts";
28
+ import { stripToolFrameMarkers } from "./mouse/packets.ts";
29
+ import { fullscreenLazyTui, getToolMouseTui } from "./mouse/scroll.ts";
30
+ import { ToolGroupComponent } from "./tool/grouping.ts";
31
+ import { expandShortcutText } from "./tool/names.ts";
32
+ import { markTranscriptComponent, withTranscriptScope } from "./transcript-scope.ts";
33
+
34
+ const EXPAND_ACTION = "app.tools.expand";
35
+ // 与 Pi 的 tui.altScreen.pageUp/pageDown 同一重叠行数,翻页后保留上下文。
36
+ const PAGE_SCROLL_OVERLAP = 4;
37
+ const WHEEL_LINES = 3;
38
+
39
+ type TranscriptUi = {
40
+ onTerminalInput(handler: (data: string) => { consume?: boolean } | undefined): () => void;
41
+ custom<T>(factory: (...args: any[]) => any, options?: any): Promise<T>;
42
+ };
43
+
44
+ type ScrollAction =
45
+ | { kind: "lines"; lines: number }
46
+ | { kind: "half"; direction: 1 | -1 }
47
+ | { kind: "page"; direction: 1 | -1 }
48
+ | { kind: "top" }
49
+ | { kind: "bottom" }
50
+ | { kind: "prompt"; direction: 1 | -1 };
51
+
52
+ const SCROLL_KEYS: Array<[string[], ScrollAction]> = [
53
+ [["j", "down", "ctrl+n"], { kind: "lines", lines: 1 }],
54
+ [["k", "up", "ctrl+p"], { kind: "lines", lines: -1 }],
55
+ [["ctrl+d"], { kind: "half", direction: 1 }],
56
+ [["ctrl+u"], { kind: "half", direction: -1 }],
57
+ [["space", "ctrl+f", "pageDown"], { kind: "page", direction: 1 }],
58
+ [["b", "ctrl+b", "shift+space", "pageUp"], { kind: "page", direction: -1 }],
59
+ [["g", "home"], { kind: "top" }],
60
+ [["shift+g", "end"], { kind: "bottom" }],
61
+ [["}"], { kind: "prompt", direction: 1 }],
62
+ [["{"], { kind: "prompt", direction: -1 }],
63
+ ];
64
+
65
+ const EXIT_KEYS = ["escape", "q", "ctrl+c"];
66
+
67
+ function matchesAny(data: string, keys: string[]): boolean {
68
+ return keys.some((key) => matchesKey(data, key as any));
69
+ }
70
+
71
+ export function transcriptScrollAction(data: string): ScrollAction | undefined {
72
+ for (const [keys, action] of SCROLL_KEYS) {
73
+ if (matchesAny(data, keys)) return action;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ export function isTranscriptExitKey(data: string): boolean {
79
+ return matchesAny(data, EXIT_KEYS) || getKeybindings().matches(data, EXPAND_ACTION as any);
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // 副本
84
+ // ---------------------------------------------------------------------------
85
+
86
+ type TranscriptItem = { view: any; prompt: boolean };
87
+ type CloneEntry = { view: any; signature: unknown[] };
88
+
89
+ const clones = new WeakMap<object, CloneEntry>();
90
+
91
+ function sameSignature(left: unknown[], right: unknown[]): boolean {
92
+ return left.length === right.length && left.every((value, index) => value === right[index]);
93
+ }
94
+
95
+ function reuseOrClone(live: any, signature: unknown[], build: () => any): any {
96
+ const cached = clones.get(live);
97
+ if (cached && sameSignature(cached.signature, signature)) return cached.view;
98
+ const view = withTranscriptScope(build);
99
+ markTranscriptComponent(view);
100
+ clones.set(live, { view, signature });
101
+ return view;
102
+ }
103
+
104
+ function cloneTool(live: any): any {
105
+ const signature = [
106
+ live.args,
107
+ live.result,
108
+ live.isPartial,
109
+ live.executionStarted,
110
+ live.argsComplete,
111
+ ];
112
+ return reuseOrClone(live, signature, () => {
113
+ const view: any = new ToolExecutionComponent(
114
+ live.toolName,
115
+ live.toolCallId,
116
+ live.args,
117
+ {
118
+ showImages: live.showImages,
119
+ imageWidthCells: live.imageWidthCells,
120
+ outputPad: live.outputPad,
121
+ },
122
+ live.toolDefinition,
123
+ live.ui,
124
+ live.cwd,
125
+ );
126
+ if (live.argsComplete) view.setArgsComplete();
127
+ if (live.executionStarted) view.markExecutionStarted();
128
+ if (live.result) view.updateResult(live.result, live.isPartial === true);
129
+ view.setExpanded(true);
130
+ return view;
131
+ });
132
+ }
133
+
134
+ function cloneAssistant(live: any): any {
135
+ const message = live.lastMessage;
136
+ if (!message) return undefined;
137
+ return reuseOrClone(live, [message, live.isStreaming], () => {
138
+ const view: any = new AssistantMessageComponent(
139
+ undefined,
140
+ false,
141
+ live.markdownTheme,
142
+ live.hiddenThinkingLabel,
143
+ live.outputPad,
144
+ live.markdownTransformers,
145
+ );
146
+ view.updateContent(message, live.isStreaming === true);
147
+ return view;
148
+ });
149
+ }
150
+
151
+ /** 对话区(Pi 的 chatContainer):直接装着回复或用户消息的那个容器。 */
152
+ export function findChatContainer(root: any): any {
153
+ let found: any;
154
+ walkComponentTree(root, (value: any) => {
155
+ if (found) return false;
156
+ const children = value?.children;
157
+ if (
158
+ Array.isArray(children) &&
159
+ children.some(
160
+ (child: any) =>
161
+ child instanceof AssistantMessageComponent || child instanceof UserMessageComponent,
162
+ )
163
+ ) {
164
+ found = value;
165
+ return false;
166
+ }
167
+ return undefined;
168
+ });
169
+ return found;
170
+ }
171
+
172
+ /** 按对话区现有内容生成浏览项:工具与回复用副本,其余直接用原组件。 */
173
+ export function collectTranscriptItems(container: any): TranscriptItem[] {
174
+ const items: TranscriptItem[] = [];
175
+ const pushTool = (tool: any) => {
176
+ if (tool instanceof ToolExecutionComponent)
177
+ items.push({ view: cloneTool(tool), prompt: false });
178
+ };
179
+ for (const child of Array.isArray(container?.children) ? container.children : []) {
180
+ if (isCompactRoundTail(child)) continue;
181
+ if (child instanceof ToolGroupComponent) {
182
+ for (const tool of child.children) pushTool(tool);
183
+ } else if (child instanceof ToolExecutionComponent) {
184
+ pushTool(child);
185
+ } else if (child instanceof AssistantMessageComponent) {
186
+ const view = cloneAssistant(child);
187
+ if (view) items.push({ view, prompt: false });
188
+ } else if (child && typeof child.render === "function") {
189
+ items.push({ view: child, prompt: child instanceof UserMessageComponent });
190
+ }
191
+ }
192
+ return items;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // 弹层
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export type TranscriptViewOptions = {
200
+ rows: () => number;
201
+ items: () => TranscriptItem[];
202
+ theme: any;
203
+ close: () => void;
204
+ focused: (view: object) => boolean;
205
+ /** 正文滚动了 delta 行(正数为向下翻),bodyHeight 为正文可见行数。 */
206
+ scrolled?: (delta: number, bodyHeight: number) => void;
207
+ };
208
+
209
+ type ScreenPoint = { row: number; col: number; scrollView?: unknown };
210
+
211
+ /**
212
+ * 让 Pi 的屏幕选区跟着浏览层的正文走。
213
+ *
214
+ * 有弹层时 Pi 的选区记的是屏幕行号(不挂 scrollView),复制也只从屏幕文字里取。
215
+ * 正文滚动 delta 行后把选区上移 delta 行,高亮与复制都跟着原来的文字。
216
+ * 选区有一端滚出正文区:屏幕上已没有那段文字,清掉选区,免得复制到别的内容。
217
+ */
218
+ export function followScreenSelection(tui: any, delta: number, bodyHeight: number): void {
219
+ if (!tui || delta === 0) return;
220
+ const anchor: ScreenPoint | undefined = tui.selectionAnchor;
221
+ const focus: ScreenPoint | undefined = tui.selectionFocus;
222
+ if (!anchor || !focus || anchor.scrollView || focus.scrollView) return;
223
+ const move = (point: ScreenPoint): ScreenPoint => ({ ...point, row: point.row - delta });
224
+ const nextAnchor = move(anchor);
225
+ const nextFocus = move(focus);
226
+ const inside = (point: ScreenPoint) => point.row >= 0 && point.row < bodyHeight;
227
+ if (!inside(nextAnchor) || !inside(nextFocus)) {
228
+ tui.resetTextSelection?.();
229
+ return;
230
+ }
231
+ tui.selectionAnchor = nextAnchor;
232
+ tui.selectionFocus = nextFocus;
233
+ const range = tui.selectionInitialRange;
234
+ if (range?.start && range?.end) {
235
+ tui.selectionInitialRange = { start: move(range.start), end: move(range.end) };
236
+ }
237
+ // 双击/三击按坐标累计:位置已变,不再和上一次点击连成多击。
238
+ tui.lastClick = undefined;
239
+ }
240
+
241
+ export class TranscriptView {
242
+ private scrollTop = 0;
243
+ private followEnd = true;
244
+ private bodyHeight = 1;
245
+ private totalLines = 0;
246
+ private promptRows: number[] = [];
247
+ private hadFocus = false;
248
+ private closed = false;
249
+ /** 上一帧画出的滚动位置:与本帧不同就通知选区跟随。 */
250
+ private renderedScrollTop: number | undefined;
251
+
252
+ private readonly options: TranscriptViewOptions;
253
+
254
+ constructor(options: TranscriptViewOptions) {
255
+ this.options = options;
256
+ }
257
+
258
+ private maxScroll(): number {
259
+ return Math.max(0, this.totalLines - this.bodyHeight);
260
+ }
261
+
262
+ private scrollTo(row: number): void {
263
+ this.scrollTop = Math.max(0, Math.min(this.maxScroll(), row));
264
+ this.followEnd = this.scrollTop >= this.maxScroll();
265
+ }
266
+
267
+ private scrollBy(lines: number): void {
268
+ this.scrollTo(this.scrollTop + lines);
269
+ }
270
+
271
+ private apply(action: ScrollAction): void {
272
+ switch (action.kind) {
273
+ case "lines":
274
+ this.scrollBy(action.lines);
275
+ break;
276
+ case "half":
277
+ this.scrollBy(action.direction * Math.max(1, Math.floor(this.bodyHeight / 2)));
278
+ break;
279
+ case "page":
280
+ this.scrollBy(action.direction * Math.max(1, this.bodyHeight - PAGE_SCROLL_OVERLAP));
281
+ break;
282
+ case "top":
283
+ this.scrollTo(0);
284
+ break;
285
+ case "bottom":
286
+ this.scrollTo(this.maxScroll());
287
+ break;
288
+ case "prompt": {
289
+ const target =
290
+ action.direction > 0
291
+ ? this.promptRows.find((row) => row > this.scrollTop)
292
+ : this.promptRows.findLast((row) => row < this.scrollTop);
293
+ if (target !== undefined) this.scrollTo(target);
294
+ break;
295
+ }
296
+ }
297
+ }
298
+
299
+ private close(): void {
300
+ if (this.closed) return;
301
+ this.closed = true;
302
+ this.options.close();
303
+ }
304
+
305
+ handleInput(data: string): void {
306
+ if (isKeyRelease(data)) return;
307
+ if (isTranscriptExitKey(data)) {
308
+ this.close();
309
+ return;
310
+ }
311
+ const action = transcriptScrollAction(data);
312
+ if (action) this.apply(action);
313
+ // 其余按键不做任何事:浏览层不编辑输入框。
314
+ }
315
+
316
+ /**
317
+ * 只接管滚轮。按下、拖动、松开不处理:Pi 有弹层时按屏幕文字做选中复制,
318
+ * 放行后浏览层里的文字照常能拖选、双击选词、复制。
319
+ */
320
+ handleMouse(event: any): { handled: boolean } | undefined {
321
+ if (event?.type !== "wheel") return undefined;
322
+ const delta = Number(event.wheelDelta) || 0;
323
+ if (delta !== 0) this.scrollBy(Math.sign(delta) * WHEEL_LINES);
324
+ return { handled: true };
325
+ }
326
+
327
+ render(width: number): string[] {
328
+ // 其他界面抢走焦点(提问、权限确认等对话框):让位给它。
329
+ if (this.options.focused(this)) this.hadFocus = true;
330
+ else if (this.hadFocus) queueMicrotask(() => this.close());
331
+
332
+ // 底部两行:空一行隔开正文,下面是提示。
333
+ const rows = Math.max(3, this.options.rows());
334
+ this.bodyHeight = rows - 2;
335
+ const lines: string[] = [];
336
+ const promptRows: number[] = [];
337
+ withTranscriptScope(() => {
338
+ for (const item of this.options.items()) {
339
+ if (item.prompt) promptRows.push(lines.length);
340
+ for (const line of item.view.render(width)) lines.push(stripToolFrameMarkers(line));
341
+ }
342
+ });
343
+ this.totalLines = lines.length;
344
+ this.promptRows = promptRows;
345
+ if (this.followEnd) this.scrollTop = this.maxScroll();
346
+ else this.scrollTop = Math.min(this.scrollTop, this.maxScroll());
347
+ // 按键、滚轮、跟随新内容都会改滚动位置,统一在画出时结算一次。
348
+ if (this.renderedScrollTop !== undefined && this.renderedScrollTop !== this.scrollTop) {
349
+ this.options.scrolled?.(this.scrollTop - this.renderedScrollTop, this.bodyHeight);
350
+ }
351
+ this.renderedScrollTop = this.scrollTop;
352
+
353
+ const body = lines.slice(this.scrollTop, this.scrollTop + this.bodyHeight);
354
+ while (body.length < this.bodyHeight) body.push("");
355
+ return [...body, "", this.hintLine(width)];
356
+ }
357
+
358
+ private hintLine(width: number): string {
359
+ const theme = this.options.theme;
360
+ const dim = (text: string) => (typeof theme?.fg === "function" ? theme.fg("dim", text) : text);
361
+ const position =
362
+ this.totalLines > this.bodyHeight
363
+ ? ` (${Math.min(100, Math.round(((this.scrollTop + this.bodyHeight) / this.totalLines) * 100))}%)`
364
+ : "";
365
+ // 窄屏从右侧截断:位置与退出方式放在最前。
366
+ return truncateToWidth(
367
+ dim(
368
+ ` Showing detailed transcript${position} · esc/q to exit · ${expandShortcutText()} to toggle · j/k scroll`,
369
+ ),
370
+ width,
371
+ );
372
+ }
373
+
374
+ invalidate(): void {}
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // 入口
379
+ // ---------------------------------------------------------------------------
380
+
381
+ let ui: TranscriptUi | undefined;
382
+ let unsubscribe: (() => void) | undefined;
383
+ let open = false;
384
+ let closeOpen: (() => void) | undefined;
385
+
386
+ /** 浏览层是否打开。 */
387
+ export function isTranscriptModeActive(): boolean {
388
+ return open;
389
+ }
390
+
391
+ /** Pi 会把展开键交给它处理的组件:默认编辑器与继承 CustomEditor 的编辑器。 */
392
+ function editorFocused(tui: any): boolean {
393
+ const handlers = tui?.focusedComponent?.actionHandlers;
394
+ return handlers instanceof Map && handlers.has(EXPAND_ACTION);
395
+ }
396
+
397
+ function canOpen(tui: any): boolean {
398
+ return (
399
+ config.mode !== "off" &&
400
+ fullscreenLazyTui(tui) &&
401
+ tui.hasOverlay?.() !== true &&
402
+ editorFocused(tui)
403
+ );
404
+ }
405
+
406
+ function openTranscript(): void {
407
+ if (!ui || open) return;
408
+ open = true;
409
+ void ui
410
+ .custom<void>(
411
+ (tui: any, theme: any, _keybindings: unknown, done: () => void) => {
412
+ closeOpen = done;
413
+ return new TranscriptView({
414
+ rows: () => Number(tui?.terminal?.rows) || 24,
415
+ items: () => collectTranscriptItems(findChatContainer(getToolMouseTui() ?? tui)),
416
+ theme,
417
+ close: done,
418
+ focused: (view) => tui?.focusedComponent === view,
419
+ scrolled: (delta, bodyHeight) => followScreenSelection(tui, delta, bodyHeight),
420
+ });
421
+ },
422
+ {
423
+ overlay: true,
424
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
425
+ },
426
+ )
427
+ .finally(() => {
428
+ open = false;
429
+ closeOpen = undefined;
430
+ });
431
+ }
432
+
433
+ function handleInput(data: string): { consume?: boolean } | undefined {
434
+ if (open) return undefined;
435
+ if (!getKeybindings().matches(data, EXPAND_ACTION as any)) return undefined;
436
+ if (!canOpen(getToolMouseTui())) return undefined;
437
+ if (!isKeyRelease(data)) openTranscript();
438
+ return { consume: true };
439
+ }
440
+
441
+ /** 装上展开键拦截;重复安装先拆旧的。 */
442
+ export function installTranscriptMode(ctx: any): void {
443
+ teardownTranscriptMode();
444
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
445
+ const candidate = ctx.ui as TranscriptUi | undefined;
446
+ if (typeof candidate?.onTerminalInput !== "function" || typeof candidate.custom !== "function")
447
+ return;
448
+ ui = candidate;
449
+ unsubscribe = candidate.onTerminalInput(handleInput);
450
+ }
451
+
452
+ export function teardownTranscriptMode(): void {
453
+ closeOpen?.();
454
+ unsubscribe?.();
455
+ unsubscribe = undefined;
456
+ ui = undefined;
457
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 会话记录浏览层的组件标记。
3
+ *
4
+ * 浏览层照着对话区现有组件新建一份来画。这些副本要绕开对话区的渲染规则:
5
+ * compact 的整轮折叠与组件跟踪、compact-thinking 的思考跟踪、展开后的行数上限。
6
+ *
7
+ * 副本的构造与渲染都包在 withTranscriptScope 里;构造完成后再用
8
+ * markTranscriptComponent 打上实例标记,覆盖动画定时器等在作用域外触发的重绘。
9
+ */
10
+
11
+ const TRANSCRIPT_STATE_KEY = "ccstyleTranscript";
12
+ const marked = new WeakSet<object>();
13
+ let depth = 0;
14
+
15
+ /** 在浏览层作用域里执行:期间构造或渲染的组件都按浏览副本处理。 */
16
+ export function withTranscriptScope<T>(run: () => T): T {
17
+ depth++;
18
+ try {
19
+ return run();
20
+ } finally {
21
+ depth--;
22
+ }
23
+ }
24
+
25
+ /** 给浏览副本打实例标记;渲染状态里也记一份,供只拿得到 context 的渲染器判断。 */
26
+ export function markTranscriptComponent(component: any): void {
27
+ if (!component || typeof component !== "object") return;
28
+ marked.add(component);
29
+ const state = component.rendererState;
30
+ if (state && typeof state === "object") state[TRANSCRIPT_STATE_KEY] = true;
31
+ }
32
+
33
+ /** 组件是否为浏览副本(或正处于浏览层作用域)。 */
34
+ export function isTranscriptComponent(component: unknown): boolean {
35
+ return (
36
+ depth > 0 || (typeof component === "object" && component !== null && marked.has(component))
37
+ );
38
+ }
39
+
40
+ /** 展开内容的行数上限:浏览副本不限行数,其余沿用配置。 */
41
+ export function expandedLineLimit(limit: number, context?: any): number {
42
+ if (depth > 0 || context?.state?.[TRANSCRIPT_STATE_KEY] === true) return Number.MAX_SAFE_INTEGER;
43
+ return limit;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",
@@ -31,7 +31,9 @@
31
31
  ],
32
32
  "themes": [
33
33
  "./themes/cc-dark.json",
34
- "./themes/cc-light.json"
34
+ "./themes/cc-light.json",
35
+ "./themes/cc-dark-hc.json",
36
+ "./themes/cc-light-hc.json"
35
37
  ],
36
38
  "image": "https://raw.githubusercontent.com/minuque/pi-cc-extensions/main/assets/readme/preview.webp",
37
39
  "video": "https://github.com/minuque/pi-cc-extensions/releases/download/v0.9.4/demo.mp4"
@@ -0,0 +1,90 @@
1
+ {
2
+ "$schema": "https://pi.dev/schemas/theme.schema.json",
3
+ "name": "cc-dark-hc",
4
+ "appearance": "dark",
5
+ "vars": {
6
+ "fg": "#d6dde3",
7
+ "fgMuted": "#929eab",
8
+ "fgSubtle": "#768494",
9
+ "canvas": "#0d1117",
10
+ "canvasSubtle": "#1c2033",
11
+ "canvasInset": "#161a2b",
12
+ "border": "#1c2d44",
13
+ "borderMuted": "#16233a",
14
+ "blue": "#4f9eff",
15
+ "blueBright": "#79c0ff",
16
+ "green": "#37b34a",
17
+ "red": "#ff6e62",
18
+ "redSoft": "#ff7b72",
19
+ "yellow": "#cb9214",
20
+ "orange": "#f0883e",
21
+ "orangeSoft": "#ffa657",
22
+ "purple": "#be8fff",
23
+ "pink": "#f778ba",
24
+ "cyan": "#39c5cf",
25
+ "selection": "#1a4480"
26
+ },
27
+ "colors": {
28
+ "accent": "blue",
29
+ "border": "border",
30
+ "borderAccent": "blue",
31
+ "borderMuted": "borderMuted",
32
+ "success": "green",
33
+ "error": "red",
34
+ "warning": "yellow",
35
+ "muted": "fgMuted",
36
+ "dim": "#71717a",
37
+ "text": "#f2f6fa",
38
+ "thinkingText": "#c6d0dc",
39
+ "selectedBg": "selection",
40
+ "scrollbarTrack": "borderMuted",
41
+ "scrollbarThumb": "#3a5274",
42
+ "searchMatchBg": "#1e1800",
43
+ "searchMatchText": "fg",
44
+ "userMessageBg": "#17375a",
45
+ "userMessageText": "#f2f6fa",
46
+ "customMessageBg": "borderMuted",
47
+ "customMessageText": "fgMuted",
48
+ "customMessageLabel": "blue",
49
+ "toolPendingBg": "#1a2534",
50
+ "toolSuccessBg": "#123c2b",
51
+ "toolErrorBg": "#4b1b21",
52
+ "toolTitle": "#f2f6fa",
53
+ "toolOutput": "#c6d0dc",
54
+ "mdHeading": "#b8ddff",
55
+ "mdLink": "#a3d0ff",
56
+ "mdLinkUrl": "#c6d0dc",
57
+ "mdCode": "#a5d6ff",
58
+ "mdCodeBlock": "#f2f6fa",
59
+ "mdCodeBlockBorder": "#6b95c7",
60
+ "mdQuote": "#c6d0dc",
61
+ "mdQuoteBorder": "#6b95c7",
62
+ "mdHr": "#6b95c7",
63
+ "mdListBullet": "#b8ddff",
64
+ "toolDiffAdded": "green",
65
+ "toolDiffRemoved": "red",
66
+ "toolDiffContext": "fgMuted",
67
+ "syntaxComment": "#c6d0dc",
68
+ "syntaxKeyword": "#ffc7c0",
69
+ "syntaxFunction": "#e0c9ff",
70
+ "syntaxVariable": "#ffc99b",
71
+ "syntaxString": "#a5d6ff",
72
+ "syntaxNumber": "#b8ddff",
73
+ "syntaxType": "#ffc98f",
74
+ "syntaxOperator": "#ffc7c0",
75
+ "syntaxPunctuation": "#f2f6fa",
76
+ "thinkingOff": "fgMuted",
77
+ "thinkingMinimal": "fgSubtle",
78
+ "thinkingLow": "blue",
79
+ "thinkingMedium": "blue",
80
+ "thinkingHigh": "blueBright",
81
+ "thinkingXhigh": "cyan",
82
+ "thinkingMax": "cyan",
83
+ "bashMode": "green"
84
+ },
85
+ "export": {
86
+ "pageBg": "#0a1424",
87
+ "cardBg": "canvasSubtle",
88
+ "infoBg": "#122544"
89
+ }
90
+ }