pi-cc-extensions 0.8.63 → 0.8.65

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
@@ -51,24 +51,38 @@ Run `/reload` after installation.
51
51
 
52
52
  ```js
53
53
  {
54
- "mode": "on", // on: Claude Code style; compact: one-line summaries; off: Pi native rendering
55
- "excludeRenderers": [], // exact tool names that keep their native renderer; Agent always keeps its dedicated renderer
56
- "diffViewMode": "auto", // diff layout: auto / split / unified
57
- "diffIndicatorMode": "bars", // diff change indicators: bars / classic / none
58
- "diffSplitMinWidth": 120, // min terminal width before auto layout uses side-by-side columns
59
- "editDiffCollapsedLines": 24, // Edit collapsed lines; beyond that shows the expand hint (Ctrl+O / click)
60
- "writeDiffCollapsedLines": 0, // write collapsed body lines; 0 = ↳ created • expand hint (stats stay on the title)
61
- "diffWordWrap": true, // whether long diff lines wrap (otherwise truncated)
62
- "expandedPreviewMaxLines": 40, // max body lines for expanded output/diff
63
- "useSummaryTitlesAsThinkingTitle": true, // use the latest provider summary as the active thinking title
64
- "previewLines": 3, // thinking preview lines; 0 hides the preview body
65
- "animationIntervalMs": 90, // thinking title animation interval in ms
66
- "showStartupHeader": true, // custom startup header (logo + tips) toggle
67
- "scrollStepLines": 3 // fullscreen mouse wheel scroll lines
54
+ "mode": "on", // on / compact / off
55
+ "excludeRenderers": [], // tools keeping the native renderer; Agent always keeps its dedicated renderer
56
+
57
+ // diff
58
+ "diffViewMode": "auto", // layout: auto / split / unified
59
+ "diffIndicatorMode": "bars", // change indicators: bars / classic / none
60
+ "diffSplitMinWidth": 120, // min terminal width for side-by-side columns
61
+ "editDiffCollapsedLines": 24, // Edit collapse lines; beyond that shows the expand hint
62
+ "writeDiffCollapsedLines": 0, // write collapse lines; 0 = creation summary only
63
+ "diffWordWrap": true, // wrap long diff lines
64
+ "expandedPreviewMaxLines": 40, // max lines for expanded bodies
65
+
66
+ // thinking
67
+ "useSummaryTitlesAsThinkingTitle": true, // use latest summary as thinking title
68
+ "previewLines": 3, // preview lines; 0 hides
69
+ "animationIntervalMs": 90, // title animation interval (ms)
70
+
71
+ // ui
72
+ "showStartupHeader": true, // startup header (logo + tips) toggle
73
+ "scrollStepLines": 3, // fullscreen wheel scroll step
74
+
75
+ // features
76
+ "enableSessionReference": true, // @ session references
77
+ "enableSubagentAutocomplete": true, // @ subagent completion and delegation hints
78
+ "enableContextCommand": true, // /context usage check
79
+ "enableAgentSummary": true, // per-turn tool summary
80
+ "enableWorkingMessage": true, // Working... bottom token/elapsed
81
+ "enableAliases": true // /clear, /exit aliases
68
82
  }
69
83
  ```
70
84
 
71
- > **Fullscreen clicks**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. Click a truncated Input/Output header to open the full preview. Double-click an expanded panel to collapse it. Single clicks on an expanded card still select text and open links.
85
+ > **Fullscreen**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. Double-click an expanded panel to collapse it.
72
86
  >
73
87
  > **Tip**: set `markdown.mermaid` to `final` via `~/.pi/agent/settings.json` or the Mermaid diagrams option in `/settings`. Default `streaming` redraws per frame; `final` renders once at completion.
74
88
 
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="./assets/readme/hero.svg" width="100%" alt="pi-cc-extensions:Pi 终端效率扩展套件">
2
+ <img src="./assets/readme/hero.svg" width="100%">
3
3
  </p>
4
4
 
5
5
  <p align="center">
@@ -51,30 +51,38 @@ pi install git:github.com/minuque/pi-cc-extensions
51
51
 
52
52
  ```js
53
53
  {
54
- "mode": "on", // on:Claude Code 风格;compact:单行摘要;off:Pi 原生渲染
55
- "excludeRenderers": [], // 走原生渲染的工具名列表(精确匹配),Agent 始终保留专用渲染器
56
- "diffViewMode": "auto", // diff 布局:auto / split / unified
57
- "diffIndicatorMode": "bars", // diff 变更指示:bars / classic / none
58
- "diffSplitMinWidth": 120, // 自动布局下使用左右分栏的最小终端宽度
59
- "editDiffCollapsedLines": 24, // Edit 折叠行数;超出显示展开提示(Ctrl+O / 点击)
60
- "writeDiffCollapsedLines": 0, // write 折叠行数;0 = ↳ created • 展开提示(统计在标题)
61
- "diffWordWrap": true, // 长 diff 行是否换行(否则截断)
62
- "expandedPreviewMaxLines": 40, // 展开后输出/diff 正文的最大行数
63
- "useSummaryTitlesAsThinkingTitle": true, // 用最新 provider 摘要作为思考标题
64
- "previewLines": 3, // thinking 预览行数,0 隐藏预览正文
65
- "animationIntervalMs": 90, // thinking 标题动画间隔(毫秒)
66
- "showStartupHeader": true, // 自定义启动头(logo + tips)开关
67
- "scrollStepLines": 3, // fullscreen 滚轮滚动步进行数
68
- "enableSessionReference": true, // @ session 引用(搜历史会话注入上下文)
69
- "enableSubagentAutocomplete": true, // @ subagent 自动补全与委派提示
70
- "enableContextCommand": true, // /context 上下文占用检查
71
- "enableAgentSummary": true, // 每回合工具统计摘要
72
- "enableWorkingMessage": true, // Working... 底部 token/耗时展示
73
- "enableAliases": true // /clear、/exit 别名
54
+ "mode": "on", // on / compact / off
55
+ "excludeRenderers": [], // 走原生渲染的工具名;Agent 始终保留专用渲染器
56
+
57
+ // diff
58
+ "diffViewMode": "auto", // 布局:auto / split / unified
59
+ "diffIndicatorMode": "bars", // 变更指示:bars / classic / none
60
+ "diffSplitMinWidth": 120, // 左右分栏的最小终端宽度
61
+ "editDiffCollapsedLines": 24, // Edit 折叠行数,超出显示展开提示
62
+ "writeDiffCollapsedLines": 0, // write 折叠行数,0 仅显示创建摘要
63
+ "diffWordWrap": true, // 长 diff 行换行
64
+ "expandedPreviewMaxLines": 40, // 展开正文最大行数
65
+
66
+ // thinking
67
+ "useSummaryTitlesAsThinkingTitle": true, // 用最新摘要作思考标题
68
+ "previewLines": 3, // 预览行数,0 隐藏
69
+ "animationIntervalMs": 90, // 标题动画间隔(毫秒)
70
+
71
+ // ui
72
+ "showStartupHeader": true, // 启动头(logo + tips)开关
73
+ "scrollStepLines": 3, // fullscreen 滚轮步进
74
+
75
+ // features
76
+ "enableSessionReference": true, // @ session 引用
77
+ "enableSubagentAutocomplete": true, // @ subagent 补全与委派提示
78
+ "enableContextCommand": true, // /context 上下文检查
79
+ "enableAgentSummary": true, // 每回合工具摘要
80
+ "enableWorkingMessage": true, // Working... 底部 token/耗时
81
+ "enableAliases": true // /clear、/exit 别名
74
82
  }
75
83
  ```
76
84
 
77
- > **Fullscreen 点击**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要。单击截断的 Input/Output 打开全量预览。双击展开面板收起。展开卡上的单击仍可选择文本、打开链接。
85
+ > **Fullscreen**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要,双击展开面板收起。
78
86
  >
79
87
  > **建议**:`markdown.mermaid` 设为 `final`(`~/.pi/agent/settings.json` 或 `/settings` 面板的 Mermaid diagrams 选项)。默认 `streaming` 逐帧重绘,`final` 渲染最终版更稳定。
80
88
 
@@ -88,7 +96,7 @@ npm run typecheck
88
96
 
89
97
  ## 兼容性
90
98
 
91
- - Node.js `>=22.19.0`,Pi `^0.84.0`(通过根目录 `package.json` 的 `pi.extensions` 和 `pi.themes` 加载)
99
+ - Node.js `>=22.19.0`,Pi `^0.84.0`
92
100
 
93
101
  ## 推荐搭配
94
102
 
@@ -200,12 +200,58 @@ function cachePreview(key: string, entry: PreviewCacheEntry): void {
200
200
 
201
201
  export function clearThinkingPreviewCache(): void {
202
202
  previewCache.clear();
203
+ expandedWrapCache.clear();
204
+ }
205
+
206
+ type ExpandedWrapEntry = { text: string; lines: string[] };
207
+ const expandedWrapCache = new Map<string, ExpandedWrapEntry>();
208
+ export const THINKING_EXPANDED_WRAP_CACHE_MAX = 64;
209
+
210
+ function expandedWrapKey(messageTimestamp: number, runStartIndex: number, width: number): string {
211
+ return `${messageTimestamp}:${runStartIndex}:${width}`;
212
+ }
213
+
214
+ function wrapExpandedThinking(
215
+ messageTimestamp: number,
216
+ runStartIndex: number,
217
+ text: string,
218
+ width: number,
219
+ ): string[] {
220
+ const key = expandedWrapKey(messageTimestamp, runStartIndex, width);
221
+ const hit = expandedWrapCache.get(key);
222
+ if (hit && hit.text === text) {
223
+ expandedWrapCache.delete(key);
224
+ expandedWrapCache.set(key, hit);
225
+ return hit.lines;
226
+ }
227
+ const lines = wrapTextWithAnsi(text.replace(/\t/g, " "), Math.max(1, width));
228
+ expandedWrapCache.delete(key);
229
+ while (expandedWrapCache.size >= THINKING_EXPANDED_WRAP_CACHE_MAX) {
230
+ const oldest = expandedWrapCache.keys().next().value;
231
+ if (oldest === undefined) break;
232
+ expandedWrapCache.delete(oldest);
233
+ }
234
+ expandedWrapCache.set(key, { text, lines });
235
+ return lines;
236
+ }
237
+
238
+ function evictExpandedWraps(messageTimestamp: number, runStartIndex: number): void {
239
+ for (const key of expandedWrapCache.keys()) {
240
+ const [ts, run] = key.split(":");
241
+ if (Number(ts) === messageTimestamp && Number(run) === runStartIndex) {
242
+ expandedWrapCache.delete(key);
243
+ }
244
+ }
203
245
  }
204
246
 
205
247
  export function thinkingPreviewCacheSize(): number {
206
248
  return previewCache.size;
207
249
  }
208
250
 
251
+ export function thinkingExpandedWrapCacheSize(): number {
252
+ return expandedWrapCache.size;
253
+ }
254
+
209
255
  /** 按可见宽度估算折行数,不走 Text 全量 wrap。无换行长段也能计到隐藏行。 */
210
256
  function countWrappedLines(text: string, contentWidth: number): number {
211
257
  if (!text) return 0;
@@ -285,12 +331,12 @@ export class ThinkingPreviewBlock implements Component {
285
331
  private heading: string;
286
332
  private text: string;
287
333
  private padding: number;
288
- private messageTimestamp: number;
334
+ readonly messageTimestamp: number;
335
+ readonly runStartIndex: number;
289
336
  private style: (text: string) => string;
290
337
  private theme: Theme | undefined;
291
338
  private _expanded: boolean;
292
339
  private hintHovered = false;
293
- private expandedBody: { width: number; lines: string[] } | undefined;
294
340
  private collapsedMemo:
295
341
  | {
296
342
  width: number;
@@ -308,11 +354,13 @@ export class ThinkingPreviewBlock implements Component {
308
354
  messageTimestamp: number,
309
355
  style: (text: string) => string,
310
356
  theme?: Theme,
357
+ runStartIndex = 0,
311
358
  ) {
312
359
  this.heading = heading;
313
360
  this.text = text;
314
361
  this.padding = padding;
315
362
  this.messageTimestamp = messageTimestamp;
363
+ this.runStartIndex = runStartIndex;
316
364
  this.style = style;
317
365
  this.theme = theme;
318
366
  this._expanded = expandedThinking.has(messageTimestamp);
@@ -334,7 +382,7 @@ export class ThinkingPreviewBlock implements Component {
334
382
  if (expanded) expandedThinking.add(this.messageTimestamp);
335
383
  else {
336
384
  expandedThinking.delete(this.messageTimestamp);
337
- this.expandedBody = undefined;
385
+ evictExpandedWraps(this.messageTimestamp, this.runStartIndex);
338
386
  }
339
387
  }
340
388
 
@@ -368,13 +416,10 @@ export class ThinkingPreviewBlock implements Component {
368
416
  return { lines: [], hiddenLines: 0 };
369
417
  }
370
418
  if (this._expanded) {
371
- if (this.expandedBody?.width !== width) {
372
- this.expandedBody = {
373
- width,
374
- lines: wrapTextWithAnsi(this.text.replace(/\t/g, " "), Math.max(1, width)),
375
- };
376
- }
377
- return { lines: this.expandedBody.lines, hiddenLines: 0 };
419
+ return {
420
+ lines: wrapExpandedThinking(this.messageTimestamp, this.runStartIndex, this.text, width),
421
+ hiddenLines: 0,
422
+ };
378
423
  }
379
424
  const preview = layoutThinkingPreview(this.text, width, padding);
380
425
  return { lines: preview.visible, hiddenLines: preview.hiddenLines };
@@ -430,7 +475,6 @@ export class ThinkingPreviewBlock implements Component {
430
475
 
431
476
  invalidate() {
432
477
  this.collapsedMemo = undefined;
433
- this.expandedBody = undefined;
434
478
  }
435
479
  }
436
480
 
@@ -715,6 +759,7 @@ function compactThinking(pi: ExtensionAPI) {
715
759
  message.timestamp,
716
760
  thinkingStyle,
717
761
  activeTheme,
762
+ runStartIndex,
718
763
  ),
719
764
  );
720
765
 
@@ -13,10 +13,10 @@ import { padLine } from "../utils/format.ts";
13
13
  export type ContextPart = {
14
14
  label: string;
15
15
  tokens: number;
16
- color: "accent" | "success" | "warning" | "customMessageLabel" | "muted" | "dim";
16
+ color: "accent" | "success" | "warning" | "customMessageLabel" | "muted" | "dim" | "error";
17
17
  };
18
18
 
19
- type PreviewKey = "systemPrompt" | "tools" | "toolResults" | "contextFiles";
19
+ type PreviewKey = "systemPrompt" | "memoryFiles" | "tools" | "toolResults" | "contextFiles";
20
20
 
21
21
  type ContextPreview = {
22
22
  key: PreviewKey;
@@ -47,11 +47,16 @@ export function escCloseHitbox(bounds: DialogBounds): {
47
47
  };
48
48
  }
49
49
 
50
- let activeTextPreviews = 0;
50
+ let activeContextOverlays = 0;
51
51
 
52
- /** fullscreen 输入包装用于把鼠标事件继续传给当前文本预览 overlay。 */
52
+ /** fullscreen 输入包装用于把鼠标事件继续传给当前 context 主弹框或文本预览 overlay。 */
53
53
  export function hasActiveTextPreview(): boolean {
54
- return activeTextPreviews > 0;
54
+ return activeContextOverlays > 0;
55
+ }
56
+
57
+ /** 官方 fullscreen 打开 overlay 时会退回 1002;重新启用 1003 才能收到无按键 hover。 */
58
+ function ensureFullscreenMouseMotion(tui: any): void {
59
+ if (tui.mode === "fullscreen") tui.terminal?.write?.("\x1b[?1003h\x1b[?1006h");
55
60
  }
56
61
 
57
62
  export async function showTextPreview(
@@ -60,10 +65,11 @@ export async function showTextPreview(
60
65
  rawContent: string,
61
66
  ): Promise<void> {
62
67
  const content = normalizePreviewText(rawContent);
63
- activeTextPreviews++;
68
+ activeContextOverlays++;
64
69
  try {
65
70
  await ctx.ui.custom(
66
71
  (tui, theme, _keybindings, done) => {
72
+ ensureFullscreenMouseMotion(tui);
67
73
  let scrollOffset = 0;
68
74
  let pageSize = 1;
69
75
  let totalLines = 1;
@@ -249,7 +255,7 @@ export async function showTextPreview(
249
255
  },
250
256
  );
251
257
  } finally {
252
- activeTextPreviews--;
258
+ activeContextOverlays--;
253
259
  }
254
260
  }
255
261
 
@@ -337,6 +343,13 @@ export function collectContextBreakdown(
337
343
  let toolResultTokens = 0;
338
344
  let contextTokens = 0;
339
345
 
346
+ const memoryPreview: string[] = [];
347
+ let memoryTokens = 0;
348
+ for (const file of options.contextFiles ?? []) {
349
+ memoryTokens += tokenEstimate(file.content);
350
+ memoryPreview.push(`## ${file.path}\n\n${previewValue(file.content)}`);
351
+ }
352
+
340
353
  for (const tool of allTools) {
341
354
  if (!selectedTools.has(tool.name)) continue;
342
355
  const definition = {
@@ -397,12 +410,14 @@ export function collectContextBreakdown(
397
410
  return {
398
411
  parts: [
399
412
  { label: "System prompt", tokens: tokenEstimate(systemPrompt), color: "accent" },
413
+ { label: "Memory", tokens: memoryTokens, color: "error" },
400
414
  { label: "Tools", tokens: toolDefinitionTokens, color: "success" },
401
415
  { label: "Tool results", tokens: toolResultTokens, color: "customMessageLabel" },
402
416
  { label: "Context", tokens: contextTokens, color: "warning" },
403
417
  ] satisfies ContextPart[],
404
418
  previews: {
405
419
  systemPrompt: systemPrompt || "No system prompt.",
420
+ memoryFiles: memoryPreview.join("\n\n") || "No memory files in context.",
406
421
  tools: toolDefinitionPreview.join("\n\n") || "No active tool definitions.",
407
422
  toolResults: toolResultPreview.join("\n\n") || "No tool results in the current context.",
408
423
  contextFiles: contextPreview.join("\n\n") || "No conversation context.",
@@ -419,9 +434,10 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
419
434
  const tools = pi.getAllTools();
420
435
  const breakdown = collectContextBreakdown(ctx, tools);
421
436
  const estimated = breakdown.parts.reduce((sum, part) => sum + part.tokens, 0);
422
- const fixedTokens = breakdown.parts.slice(0, 2).reduce((sum, part) => sum + part.tokens, 0);
437
+ // System prompt、Memory、Tools 为固定项,capParts 时保持原值不压缩。
438
+ const fixedTokens = breakdown.parts.slice(0, 3).reduce((sum, part) => sum + part.tokens, 0);
423
439
  const used = Math.max(resolveUsedTokens(usage, estimated, contextWindow), fixedTokens);
424
- const parts = capParts(breakdown.parts, used, 2);
440
+ const parts = capParts(breakdown.parts, used, 3);
425
441
  const attributed = parts.reduce((sum, part) => sum + part.tokens, 0);
426
442
  const other = Math.max(0, used - attributed);
427
443
  const free = Math.max(0, contextWindow - used);
@@ -444,6 +460,12 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
444
460
  title: "System Prompt",
445
461
  content: breakdown.previews.systemPrompt,
446
462
  },
463
+ {
464
+ key: "memoryFiles",
465
+ label: "Memory",
466
+ title: "Memory Files",
467
+ content: breakdown.previews.memoryFiles,
468
+ },
447
469
  {
448
470
  key: "tools",
449
471
  label: "Tools",
@@ -474,158 +496,187 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
474
496
  let selectedPreviewIndex = 0;
475
497
 
476
498
  while (true) {
477
- const action = await ctx.ui.custom(
478
- (tui, theme, _keybindings, done) => {
479
- let previewHitboxes: Array<{
480
- key: PreviewKey;
481
- row: number;
482
- startCol: number;
483
- endCol: number;
484
- }> = [];
485
- let escHitbox: { row: number; startCol: number; endCol: number } | undefined;
486
-
487
- return {
488
- invalidate() {},
489
- handleInput(data: string) {
490
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
491
- done(undefined);
492
- return;
493
- }
494
- if (matchesKey(data, Key.up) && visiblePreviews.length > 0) {
495
- selectedPreviewIndex =
496
- (selectedPreviewIndex - 1 + visiblePreviews.length) % visiblePreviews.length;
497
- tui.requestRender();
498
- return;
499
- }
500
- if (matchesKey(data, Key.down) && visiblePreviews.length > 0) {
501
- selectedPreviewIndex = (selectedPreviewIndex + 1) % visiblePreviews.length;
502
- tui.requestRender();
503
- return;
504
- }
505
- if (matchesKey(data, Key.enter)) {
506
- done(visiblePreviews[selectedPreviewIndex]?.key);
507
- return;
508
- }
509
-
510
- const mouse = parseSgrMousePacket(data);
511
- if (
512
- mouse?.final !== "M" ||
513
- mouseBaseButton(mouse.code) !== 0 ||
514
- (mouse.code & 32) !== 0
515
- )
516
- return;
517
- if (
518
- escHitbox &&
519
- mouse.row === escHitbox.row &&
520
- mouse.col >= escHitbox.startCol &&
521
- mouse.col <= escHitbox.endCol
522
- ) {
523
- done(undefined);
524
- return;
525
- }
526
- const hitbox = previewHitboxes.find(
527
- (candidate) =>
528
- mouse.row === candidate.row &&
529
- mouse.col >= candidate.startCol &&
530
- mouse.col <= candidate.endCol,
531
- );
532
- if (hitbox) {
533
- selectedPreviewIndex = Math.max(
534
- 0,
535
- visiblePreviews.findIndex((preview) => preview.key === hitbox.key),
499
+ // 主弹框也计入活动 overlay,fullscreen 下官方输入链才会把鼠标包放行给行点击。
500
+ activeContextOverlays++;
501
+ let action;
502
+ try {
503
+ action = await ctx.ui.custom(
504
+ (tui, theme, _keybindings, done) => {
505
+ ensureFullscreenMouseMotion(tui);
506
+ let previewHitboxes: Array<{
507
+ key: PreviewKey;
508
+ row: number;
509
+ startCol: number;
510
+ endCol: number;
511
+ }> = [];
512
+ let escHitbox: { row: number; startCol: number; endCol: number } | undefined;
513
+ let escHovered = false;
514
+ let hoveredKey: PreviewKey | undefined;
515
+
516
+ return {
517
+ invalidate() {},
518
+ handleInput(data: string) {
519
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
520
+ done(undefined);
521
+ return;
522
+ }
523
+ if (matchesKey(data, Key.up) && visiblePreviews.length > 0) {
524
+ selectedPreviewIndex =
525
+ (selectedPreviewIndex - 1 + visiblePreviews.length) % visiblePreviews.length;
526
+ tui.requestRender();
527
+ return;
528
+ }
529
+ if (matchesKey(data, Key.down) && visiblePreviews.length > 0) {
530
+ selectedPreviewIndex = (selectedPreviewIndex + 1) % visiblePreviews.length;
531
+ tui.requestRender();
532
+ return;
533
+ }
534
+ if (matchesKey(data, Key.enter)) {
535
+ done(visiblePreviews[selectedPreviewIndex]?.key);
536
+ return;
537
+ }
538
+
539
+ const mouse = parseSgrMousePacket(data);
540
+ if (!mouse || mouse.final !== "M") return;
541
+ if ((mouse.code & 32) !== 0) {
542
+ // SGR 1003 的无按键 hover code 为 35,不能按左键事件过滤。
543
+ const overEsc = Boolean(
544
+ escHitbox &&
545
+ mouse.row === escHitbox.row &&
546
+ mouse.col >= escHitbox.startCol &&
547
+ mouse.col <= escHitbox.endCol,
548
+ );
549
+ const hovered = previewHitboxes.find(
550
+ (candidate) =>
551
+ mouse.row === candidate.row &&
552
+ mouse.col >= candidate.startCol &&
553
+ mouse.col <= candidate.endCol,
554
+ );
555
+ if (overEsc !== escHovered || hovered?.key !== hoveredKey) {
556
+ escHovered = overEsc;
557
+ hoveredKey = hovered?.key;
558
+ tui.requestRender();
559
+ }
560
+ return;
561
+ }
562
+ if (mouseBaseButton(mouse.code) !== 0) return;
563
+ if (
564
+ escHitbox &&
565
+ mouse.row === escHitbox.row &&
566
+ mouse.col >= escHitbox.startCol &&
567
+ mouse.col <= escHitbox.endCol
568
+ ) {
569
+ done(undefined);
570
+ return;
571
+ }
572
+ const hitbox = previewHitboxes.find(
573
+ (candidate) =>
574
+ mouse.row === candidate.row &&
575
+ mouse.col >= candidate.startCol &&
576
+ mouse.col <= candidate.endCol,
536
577
  );
537
- done(hitbox.key);
538
- }
539
- },
540
- render(width: number) {
541
- const inner = Math.max(1, width - 2);
542
- const escWidth = visibleWidth("[esc]");
543
- const percent = contextWindow > 0 ? (used / contextWindow) * 100 : 0;
544
- const title = theme.bold(theme.fg("accent", "Context Usage"));
545
- const subtitle = `${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`;
546
- const barWidth = Math.max(1, Math.min(60, inner - 2));
547
- let remaining = barWidth;
548
- const segments = allParts
549
- .map((part, index) => {
550
- const cells =
551
- index === allParts.length - 1
552
- ? remaining
553
- : Math.min(
554
- remaining,
555
- Math.round((part.tokens / Math.max(1, contextWindow)) * barWidth),
556
- );
557
- remaining -= cells;
558
- return theme.fg(part.color, "█".repeat(Math.max(0, cells)));
559
- })
560
- .join("");
561
- const labelWidth = Math.min(
562
- 24,
563
- Math.max(...allParts.map((part) => part.label.length)),
564
- );
565
- const selectedLabel = visiblePreviews[selectedPreviewIndex]?.label;
566
- const partRows = allParts.map((part) => {
567
- const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
568
- const swatch = theme.fg(part.color, "■");
569
- const label = part.label.padEnd(labelWidth);
570
- const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
571
- const selected = part.label === selectedLabel;
572
- const prefix = selected ? "› " : " ";
573
- const row = padLine(`${prefix}${swatch} ${label} ${amount}`, inner);
574
- return selected ? theme.bg("selectedBg", row) : row;
575
- });
576
- const border = (text: string) => theme.fg("border", text);
577
- const lines = [
578
- border(`╭${"─".repeat(inner)}╮`),
579
- `${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner - escWidth)}${theme.fg("muted", "[esc]")}${border("│")}`,
580
- `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
581
- `${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
582
- `${border("│")}${" ".repeat(inner)}${border("│")}`,
583
- ...partRows.map((row) => `${border("│")}${row}${border("│")}`),
584
- `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
585
- `${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview · [esc] close"), inner)}${border("│")}`,
586
- border(`╰${"─".repeat(inner)}╯`),
587
- ];
588
-
589
- const terminalHeight = Math.max(1, tui.terminal.rows);
590
- const maxHeight = Math.min(
591
- Math.max(1, Math.floor(terminalHeight * 0.9)),
592
- Math.max(1, terminalHeight - 2),
593
- );
594
- const visibleHeight = Math.min(lines.length, maxHeight);
595
- const overlayTop =
596
- 1 + Math.floor((Math.max(1, terminalHeight - 2) - visibleHeight) / 2);
597
- const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
598
- escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
599
- previewHitboxes = visiblePreviews.flatMap((preview) => {
600
- const partIndex = allParts.findIndex((part) => part.label === preview.label);
601
- const line = 5 + partIndex;
602
- return partIndex >= 0 && line < visibleHeight
603
- ? [
604
- {
605
- key: preview.key,
606
- row: overlayTop + line + 1,
607
- startCol: overlayLeft + 1,
608
- endCol: overlayLeft + width,
609
- },
610
- ]
611
- : [];
612
- });
613
-
614
- return lines;
578
+ if (hitbox) {
579
+ selectedPreviewIndex = Math.max(
580
+ 0,
581
+ visiblePreviews.findIndex((preview) => preview.key === hitbox.key),
582
+ );
583
+ done(hitbox.key);
584
+ }
585
+ },
586
+ render(width: number) {
587
+ const inner = Math.max(1, width - 2);
588
+ const escWidth = visibleWidth("[esc]");
589
+ const percent = contextWindow > 0 ? (used / contextWindow) * 100 : 0;
590
+ const title = theme.bold(theme.fg("accent", "Context Usage"));
591
+ const subtitle = `${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`;
592
+ const barWidth = Math.max(1, Math.min(60, inner - 2));
593
+ let remaining = barWidth;
594
+ const segments = allParts
595
+ .map((part, index) => {
596
+ const cells =
597
+ index === allParts.length - 1
598
+ ? remaining
599
+ : Math.min(
600
+ remaining,
601
+ Math.round((part.tokens / Math.max(1, contextWindow)) * barWidth),
602
+ );
603
+ remaining -= cells;
604
+ return theme.fg(part.color, "█".repeat(Math.max(0, cells)));
605
+ })
606
+ .join("");
607
+ const labelWidth = Math.min(
608
+ 24,
609
+ Math.max(...allParts.map((part) => part.label.length)),
610
+ );
611
+ const selectedLabel = visiblePreviews[selectedPreviewIndex]?.label;
612
+ const partRows = allParts.map((part) => {
613
+ const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
614
+ const swatch = theme.fg(part.color, "■");
615
+ const label = part.label.padEnd(labelWidth);
616
+ const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
617
+ const selected = part.label === selectedLabel;
618
+ const hoverLabel = visiblePreviews.find((p) => p.key === hoveredKey)?.label;
619
+ const prefix = selected ? "› " : " ";
620
+ const row = padLine(`${prefix}${swatch} ${label} ${amount}`, inner);
621
+ if (selected) return theme.bg("selectedBg", row);
622
+ return part.label === hoverLabel ? theme.bg("customMessageBg", row) : row;
623
+ });
624
+ const border = (text: string) => theme.fg("border", text);
625
+ const lines = [
626
+ border(`╭${"─".repeat(inner)}╮`),
627
+ `${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner - escWidth)}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
628
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
629
+ `${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
630
+ `${border("│")}${" ".repeat(inner)}${border("│")}`,
631
+ ...partRows.map((row) => `${border("│")}${row}${border("│")}`),
632
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
633
+ `${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview · [esc] close"), inner)}${border("│")}`,
634
+ border(`╰${"─".repeat(inner)}╯`),
635
+ ];
636
+
637
+ const terminalHeight = Math.max(1, tui.terminal.rows);
638
+ const maxHeight = Math.min(
639
+ Math.max(1, Math.floor(terminalHeight * 0.9)),
640
+ Math.max(1, terminalHeight - 2),
641
+ );
642
+ const visibleHeight = Math.min(lines.length, maxHeight);
643
+ const overlayTop =
644
+ 1 + Math.floor((Math.max(1, terminalHeight - 2) - visibleHeight) / 2);
645
+ const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
646
+ escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
647
+ previewHitboxes = visiblePreviews.flatMap((preview) => {
648
+ const partIndex = allParts.findIndex((part) => part.label === preview.label);
649
+ const line = 5 + partIndex;
650
+ return partIndex >= 0 && line < visibleHeight
651
+ ? [
652
+ {
653
+ key: preview.key,
654
+ row: overlayTop + line + 1,
655
+ startCol: overlayLeft + 1,
656
+ endCol: overlayLeft + width,
657
+ },
658
+ ]
659
+ : [];
660
+ });
661
+
662
+ return lines;
663
+ },
664
+ };
665
+ },
666
+ {
667
+ overlay: true,
668
+ overlayOptions: {
669
+ anchor: "center",
670
+ width: 64,
671
+ minWidth: 44,
672
+ maxHeight: "90%",
673
+ margin: 1,
615
674
  },
616
- };
617
- },
618
- {
619
- overlay: true,
620
- overlayOptions: {
621
- anchor: "center",
622
- width: 64,
623
- minWidth: 44,
624
- maxHeight: "90%",
625
- margin: 1,
626
675
  },
627
- },
628
- );
676
+ );
677
+ } finally {
678
+ activeContextOverlays--;
679
+ }
629
680
 
630
681
  if (!action) break;
631
682
  const preview = previewByKey.get(action as PreviewKey);
@@ -5,7 +5,6 @@ import { config } from "./config/config.ts";
5
5
  import piAliases from "./feature/shell/aliases.ts";
6
6
  import piStartupHeader from "./feature/shell/startup-header.ts";
7
7
  import workingMessage from "./feature/shell/working-message.ts";
8
- import modelStatus from "./feature/shell/model-status.ts";
9
8
 
10
9
  // feature
11
10
  import agentAutocomplete from "./feature/reference/subagent.ts";
@@ -23,7 +22,6 @@ export default function (pi: ExtensionAPI): void {
23
22
  if (config.enableAliases) piAliases(pi);
24
23
  piStartupHeader(pi);
25
24
  if (config.enableWorkingMessage) workingMessage(pi);
26
- modelStatus(pi);
27
25
 
28
26
  // render stack:thinking controller 直接交给 style 作 query
29
27
  markdownEnhance(pi);
@@ -54,6 +54,16 @@ export function setHoveredThinking(block: ThinkingPreviewBlock | null): boolean
54
54
  return true;
55
55
  }
56
56
 
57
+ let hoveredMessageDisplay: any = null;
58
+
59
+ export function setHoveredMessageDisplay(component: any): boolean {
60
+ if (component === hoveredMessageDisplay) return false;
61
+ hoveredMessageDisplay?.setHintHovered?.(false);
62
+ hoveredMessageDisplay = component;
63
+ component?.setHintHovered?.(true);
64
+ return true;
65
+ }
66
+
57
67
  let hoveredToolIoView: ExpandedToolIoView | null = null;
58
68
  let hoveredToolIoSection: ToolIoSection | null = null;
59
69
 
@@ -110,6 +120,7 @@ export type FullscreenHoverTarget =
110
120
  | { kind: "button" }
111
121
  | { kind: "group"; component: ToolGroupComponent }
112
122
  | { kind: "thinking"; component: ThinkingPreviewBlock }
123
+ | { kind: "message"; component: any }
113
124
  | { kind: "assistant"; component: any }
114
125
  | {
115
126
  kind: "tool";
@@ -133,6 +144,8 @@ export function applyFullscreenHover(tui: any, target: FullscreenHoverTarget | n
133
144
  if (setHoveredToolGroup(nextGroup)) changed = true;
134
145
  const nextThinking = target?.kind === "thinking" ? target.component : null;
135
146
  if (setHoveredThinking(nextThinking)) changed = true;
147
+ const nextMessage = target?.kind === "message" ? target.component : null;
148
+ if (setHoveredMessageDisplay(nextMessage)) changed = true;
136
149
  const nextAssistant = target?.kind === "assistant" ? target.component : null;
137
150
  if (setHoveredCompactAssistant(nextAssistant)) changed = true;
138
151
  const nextView = target?.kind === "tool" ? target.view : null;
@@ -59,6 +59,7 @@ import {
59
59
  setHoveredToolCallId,
60
60
  setHoveredToolGroup,
61
61
  setHoveredThinking,
62
+ setHoveredMessageDisplay,
62
63
  setHoveredToolIo,
63
64
  type FullscreenHoverTarget,
64
65
  } from "./hover.ts";
@@ -155,13 +156,20 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
155
156
  }
156
157
 
157
158
  const EXPAND_PANEL_DOUBLE_CLICK_MS = 400;
158
- let lastExpandPanelClick: { card: any; at: number } | null = null;
159
+ let lastExpandPanelClick: { id: unknown; at: number } | null = null;
160
+
161
+ function expandPanelIdentity(card: any): unknown {
162
+ return card instanceof ThinkingPreviewBlock
163
+ ? `${card.messageTimestamp}:${card.runStartIndex}`
164
+ : card;
165
+ }
159
166
 
160
167
  function isExpandPanelDoubleClick(card: any): boolean {
161
168
  const now = Date.now();
169
+ const id = expandPanelIdentity(card);
162
170
  const prev = lastExpandPanelClick;
163
- lastExpandPanelClick = { card, at: now };
164
- return Boolean(prev && prev.card === card && now - prev.at <= EXPAND_PANEL_DOUBLE_CLICK_MS);
171
+ lastExpandPanelClick = { id, at: now };
172
+ return Boolean(prev && prev.id === id && now - prev.at <= EXPAND_PANEL_DOUBLE_CLICK_MS);
165
173
  }
166
174
 
167
175
  function clearExpandPanelDoubleClick(): void {
@@ -174,6 +182,7 @@ function collapseExpandedCard(tui: any, card: any): boolean {
174
182
  setHoveredToolCallId(null);
175
183
  setHoveredToolGroup(null);
176
184
  setHoveredThinking(null);
185
+ setHoveredMessageDisplay(null);
177
186
  setHoveredToolIo(null, null);
178
187
  setHoveredCompactAssistant(null);
179
188
  card.invalidate?.();
@@ -345,6 +354,7 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
345
354
  setHoveredToolCallId(null);
346
355
  setHoveredToolGroup(null);
347
356
  setHoveredThinking(null);
357
+ setHoveredMessageDisplay(null);
348
358
  setHoveredToolIo(null, null);
349
359
  setHoveredCompactAssistant(null);
350
360
  card.invalidate?.();
@@ -416,8 +426,7 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
416
426
  // compact 摘要行:折叠时仅提示文字高亮,展开卡整体高亮。
417
427
  if (component.expanded || overHint) target = { kind: "assistant", component };
418
428
  } else if (isMessageDisplayComponent(component)) {
419
- // skill/compaction/branch:折叠只点 hint;展开后双击收起。
420
- if (component.expanded || overHint) target = { kind: "assistant", component };
429
+ if (overHint) target = { kind: "message", component };
421
430
  }
422
431
  }
423
432
  }
@@ -814,6 +823,7 @@ export function teardownToolMouseInteraction(
814
823
  setHoveredToolCallId(null);
815
824
  setHoveredToolGroup(null);
816
825
  setHoveredThinking(null);
826
+ setHoveredMessageDisplay(null);
817
827
  setHoveredToolIo(null, null);
818
828
  setHoveredCompactAssistant(null);
819
829
  clearExpandPanelDoubleClick();
@@ -841,6 +851,7 @@ export function teardownToolMouseInteraction(
841
851
  export function resetToolHoverState(): void {
842
852
  setHoveredToolCallId(null);
843
853
  setHoveredThinking(null);
854
+ setHoveredMessageDisplay(null);
844
855
  setHoveredCompactAssistant(null);
845
856
  setScrollButtonVisible(false);
846
857
  setScrollButtonHovered(false);
@@ -55,6 +55,15 @@ const BRANCH_KIND: DisplayKind = {
55
55
  body: (component) => String(component.message?.summary ?? ""),
56
56
  };
57
57
 
58
+ function ensureHintHover(component: any): void {
59
+ if (typeof component.setHintHovered === "function") return;
60
+ component.setHintHovered = function (this: any, hovered: boolean) {
61
+ if (this.hintHovered === hovered) return;
62
+ this.hintHovered = hovered;
63
+ this.invalidate?.();
64
+ };
65
+ }
66
+
58
67
  function renderCcstyle(component: any, kind: DisplayKind): void {
59
68
  const theme = displayTheme;
60
69
  if (!theme) return; // 主题未就绪时保留原生渲染
@@ -64,13 +73,21 @@ function renderCcstyle(component: any, kind: DisplayKind): void {
64
73
  component.paddingY = 0; // 工具组件 paddingY=0;原生 Box 默认 1,会上下各留一个空行
65
74
  }
66
75
  component.clear();
76
+ ensureHintHover(component);
67
77
  const icon = `${BRIGHT_GREEN}✓${ANSI_FG_RESET}`; // 已完成消息,等同工具成功态
68
78
  const title = theme.fg("toolTitle", kind.title(component));
69
79
  if (!component.expanded) {
70
- const hint = theme.fg("dim", ` • ${showMoreHintText()}`);
80
+ const hovered = component.hintHovered === true;
81
+ const hint = `${theme.fg("dim", " • ")}${theme.fg(hovered ? "text" : "dim", showMoreHintText())}`;
71
82
  component.addChild(new Text(`${icon} ${title}${hint}`, 0, 0));
72
83
  return;
73
84
  }
85
+ // 展开卡与 tool 一致:userMessageBg + 上下左右 1 格
86
+ component.paddingX = 1;
87
+ component.paddingY = 1;
88
+ if (typeof theme.bg === "function" && typeof component.setBgFn === "function") {
89
+ component.setBgFn((text: string) => theme.bg("userMessageBg", text));
90
+ }
74
91
  component.addChild(new Text(`${icon} ${title}`, 0, 0));
75
92
  component.addChild(new Spacer(1));
76
93
  component.addChild(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.63",
3
+ "version": "0.8.65",
4
4
  "license": "MIT",
5
5
  "description": "A Pi productivity suite with Claude Code-style UI, context inspection, and agent/session references",
6
6
  "type": "module",
@@ -1,19 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
-
3
- function modelLabel(model: any): string | undefined {
4
- if (!model) return undefined;
5
- const name = model.name || model.id;
6
- if (!name) return undefined;
7
- return model.provider ? `${model.provider} · ${name}` : name;
8
- }
9
-
10
- function apply(ctx: any): void {
11
- if (!ctx?.hasUI) return;
12
- ctx.ui?.setStatus?.("model", modelLabel(ctx.model));
13
- }
14
-
15
- /** 用 setStatus 挂模型名,不 setFooter,不接管原生 footer。 */
16
- export default function (pi: ExtensionAPI): void {
17
- pi.on("session_start", async (_event, ctx) => apply(ctx));
18
- pi.on("model_select", async (_event, ctx) => apply(ctx));
19
- }