pi-cc-extensions 0.8.59 → 0.8.61

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
@@ -56,7 +56,8 @@ Run `/reload` after installation.
56
56
  "diffViewMode": "auto", // diff layout: auto / split / unified
57
57
  "diffIndicatorMode": "bars", // diff change indicators: bars / classic / none
58
58
  "diffSplitMinWidth": 120, // min terminal width before auto layout uses side-by-side columns
59
- "diffCollapsedLines": 24, // diff body lines shown when collapsed; beyond that shows the expand hint (Ctrl+O / click)
59
+ "diffCollapsedLines": 24, // edit/diff body lines shown when collapsed; 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)
60
61
  "diffWordWrap": true, // whether long diff lines wrap (otherwise truncated)
61
62
  "expandedPreviewMaxLines": 40, // max body lines for expanded output/diff
62
63
  "useSummaryTitlesAsThinkingTitle": true, // use the latest provider summary as the active thinking title
package/README.md CHANGED
@@ -56,7 +56,8 @@ pi install git:github.com/minuque/pi-cc-extensions
56
56
  "diffViewMode": "auto", // diff 布局:auto / split / unified
57
57
  "diffIndicatorMode": "bars", // diff 变更指示:bars / classic / none
58
58
  "diffSplitMinWidth": 120, // 自动布局下使用左右分栏的最小终端宽度
59
- "diffCollapsedLines": 24, // 折叠时展示的 diff 行数,超出显示展开提示(Ctrl+O / 点击)
59
+ "diffCollapsedLines": 24, // 折叠时展示的 edit/diff 行数,超出显示展开提示(Ctrl+O / 点击)
60
+ "writeDiffCollapsedLines": 0, // write 折叠行数;0 = ↳ created • 展开提示(统计在标题)
60
61
  "diffWordWrap": true, // 长 diff 行是否换行(否则截断)
61
62
  "expandedPreviewMaxLines": 40, // 展开后输出/diff 正文的最大行数
62
63
  "useSummaryTitlesAsThinkingTitle": true, // 用最新 provider 摘要作为思考标题
@@ -16,6 +16,8 @@ export interface ToolDisplayConfig {
16
16
  diffIndicatorMode: DiffIndicatorMode;
17
17
  diffSplitMinWidth: number;
18
18
  diffCollapsedLines: number;
19
+ /** Write-only collapsed body lines. 0 = `↳ created • click to show more`. */
20
+ writeDiffCollapsedLines: number;
19
21
  diffWordWrap: boolean;
20
22
  expandedPreviewMaxLines: number;
21
23
  }
@@ -26,6 +28,11 @@ export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
26
28
  diffSplitMinWidth: 120,
27
29
  /** Collapsed tool/diff body: ~half a typical terminal after chrome. */
28
30
  diffCollapsedLines: 24,
31
+ /**
32
+ * Write create/overwrite collapsed body.
33
+ * 0 = `↳ created • click to show more` (stats stay on the title).
34
+ */
35
+ writeDiffCollapsedLines: 0,
29
36
  diffWordWrap: true,
30
37
  /**
31
38
  * Expanded tool/diff body cap. 40 ≈ one screen of content after title,
@@ -42,6 +49,7 @@ export type Config = {
42
49
  diffIndicatorMode: DiffIndicatorMode;
43
50
  diffSplitMinWidth: number;
44
51
  diffCollapsedLines: number;
52
+ writeDiffCollapsedLines: number;
45
53
  diffWordWrap: boolean;
46
54
  expandedPreviewMaxLines: number;
47
55
  useSummaryTitlesAsThinkingTitle: boolean;
@@ -64,6 +72,8 @@ export const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
64
72
  export const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
65
73
  export const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
66
74
  export const DIFF_COLLAPSED_LINES_VALUES = ["12", "24", "36", "48", "80", "120"];
75
+ /** Write collapsed presets. 0 = stats only (`+N -0` + expand hint). */
76
+ export const WRITE_DIFF_COLLAPSED_LINES_VALUES = ["0", "4", "8", "12", "24", "36"];
67
77
  /** Presets for expanded body height — keep low options first so cycling stays TUI-friendly. */
68
78
  export const EXPANDED_PREVIEW_MAX_LINES_VALUES = ["40", "60", "80", "120", "200", "500", "2000"];
69
79
  export const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
@@ -90,6 +100,7 @@ export const DEFAULT_CONFIG: Config = {
90
100
  diffIndicatorMode: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode,
91
101
  diffSplitMinWidth: DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth,
92
102
  diffCollapsedLines: DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines,
103
+ writeDiffCollapsedLines: DEFAULT_TOOL_DISPLAY_CONFIG.writeDiffCollapsedLines,
93
104
  diffWordWrap: DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap,
94
105
  expandedPreviewMaxLines: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
95
106
  useSummaryTitlesAsThinkingTitle: true,
@@ -164,6 +175,12 @@ export function normalizeConfig(input: unknown): Config {
164
175
  1,
165
176
  500,
166
177
  ),
178
+ writeDiffCollapsedLines: pickPositiveInt(
179
+ source.writeDiffCollapsedLines,
180
+ DEFAULT_CONFIG.writeDiffCollapsedLines,
181
+ 0,
182
+ 500,
183
+ ),
167
184
  diffWordWrap: source.diffWordWrap !== false,
168
185
  expandedPreviewMaxLines: pickPositiveInt(
169
186
  source.expandedPreviewMaxLines,
@@ -207,6 +224,7 @@ export function getToolDisplayConfig(source: Config = config): ToolDisplayConfig
207
224
  diffIndicatorMode: source.diffIndicatorMode,
208
225
  diffSplitMinWidth: source.diffSplitMinWidth,
209
226
  diffCollapsedLines: source.diffCollapsedLines,
227
+ writeDiffCollapsedLines: source.writeDiffCollapsedLines,
210
228
  diffWordWrap: source.diffWordWrap,
211
229
  expandedPreviewMaxLines: source.expandedPreviewMaxLines,
212
230
  };
@@ -224,6 +242,7 @@ export function formatConfigStatus(source: Config = config): string {
224
242
  `diffIndicator=${source.diffIndicatorMode}`,
225
243
  `diffSplitMin=${source.diffSplitMinWidth}`,
226
244
  `diffCollapsed=${source.diffCollapsedLines}`,
245
+ `writeCollapsed=${source.writeDiffCollapsedLines}`,
227
246
  `diffWordWrap=${source.diffWordWrap ? "on" : "off"}`,
228
247
  `expandedMax=${source.expandedPreviewMaxLines}`,
229
248
  `thinkingTitle=${source.useSummaryTitlesAsThinkingTitle ? "summary" : "default"}`,
@@ -25,6 +25,7 @@ import {
25
25
  SCROLL_STEP_LINES_VALUES,
26
26
  THINKING_ANIMATION_INTERVAL_VALUES,
27
27
  THINKING_PREVIEW_LINES_VALUES,
28
+ WRITE_DIFF_COLLAPSED_LINES_VALUES,
28
29
  updateConfig,
29
30
  type CompactStyleMode,
30
31
  type Config,
@@ -40,7 +41,7 @@ export type CcstylePanelHooks = {
40
41
 
41
42
  function modeSettingDescription(mode: CompactStyleMode): string {
42
43
  if (mode === "compact") {
43
- return "(Experimental) One summary line per assistant round; expanded edit/write rows show rich diffs.";
44
+ return "(Experimental) One summary line per assistant round; edit/write reuse the same Diff preview settings as on.";
44
45
  }
45
46
  if (mode === "off") {
46
47
  return "Pi native tool rendering. Diff options below still apply independently.";
@@ -288,14 +289,24 @@ export async function showCcstylePanel(
288
289
  };
289
290
  const diffCollapsedSetting = {
290
291
  id: "diffCollapsedLines",
291
- label: "Collapsed lines",
292
+ label: "Edit collapsed lines",
292
293
  description:
293
- "How many diff body lines to show before the expand hint (Ctrl+O / click). Enter to type a custom value.",
294
+ "How many edit/diff body lines to show before the expand hint (Ctrl+O / click). Write uses its own setting below.",
294
295
  currentValue: String(config.diffCollapsedLines),
295
296
  values: [...DIFF_COLLAPSED_LINES_VALUES],
296
297
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
297
298
  buildNumberInputSubmenu(theme, diffCollapsedSetting, closeSubmenu),
298
299
  };
300
+ const writeDiffCollapsedSetting = {
301
+ id: "writeDiffCollapsedLines",
302
+ label: "Write collapsed lines",
303
+ description:
304
+ "Write-only collapsed body lines. 0 shows ↳ created + expand hint (stats stay on the title). Enter to type a custom value.",
305
+ currentValue: String(config.writeDiffCollapsedLines),
306
+ values: [...WRITE_DIFF_COLLAPSED_LINES_VALUES],
307
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
308
+ buildNumberInputSubmenu(theme, writeDiffCollapsedSetting, closeSubmenu),
309
+ };
299
310
  const diffWordWrapSetting = {
300
311
  id: "diffWordWrap",
301
312
  label: "Diff word wrap",
@@ -453,6 +464,17 @@ export async function showCcstylePanel(
453
464
  });
454
465
  diffCollapsedSetting.currentValue = String(config.diffCollapsedLines);
455
466
  break;
467
+ case "writeDiffCollapsedLines":
468
+ updateConfig({
469
+ writeDiffCollapsedLines: pickPositiveInt(
470
+ value,
471
+ DEFAULT_CONFIG.writeDiffCollapsedLines,
472
+ 0,
473
+ 500,
474
+ ),
475
+ });
476
+ writeDiffCollapsedSetting.currentValue = String(config.writeDiffCollapsedLines);
477
+ break;
456
478
  case "diffWordWrap":
457
479
  updateConfig({ diffWordWrap: value === "on" });
458
480
  diffWordWrapSetting.description = config.diffWordWrap
@@ -519,6 +541,7 @@ export async function showCcstylePanel(
519
541
  diffIndicatorSetting,
520
542
  diffSplitSetting,
521
543
  diffCollapsedSetting,
544
+ writeDiffCollapsedSetting,
522
545
  diffWordWrapSetting,
523
546
  expandedMaxSetting,
524
547
  ],
@@ -11,8 +11,14 @@ import {
11
11
  type ExtensionAPI,
12
12
  type Theme,
13
13
  } from "@earendil-works/pi-coding-agent";
14
- import { getKeybindings } from "@earendil-works/pi-tui";
15
- import { Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
14
+ import {
15
+ getKeybindings,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ wrapTextWithAnsi,
19
+ } from "@earendil-works/pi-tui";
20
+ import { Box, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
21
+ import { isToolTuiFullscreen } from "../renderer/tool/show-more-hint.ts";
16
22
  import {
17
23
  animateCompactThinkingText,
18
24
  formatThoughtDuration,
@@ -163,27 +169,206 @@ function getThinkingToggleHint() {
163
169
  return keys.length > 0 ? `${keys.join("/")} to expand` : undefined;
164
170
  }
165
171
 
166
- class StrictThinkingPreview implements Component {
172
+ function thinkingExpandAction(): string | undefined {
173
+ return isToolTuiFullscreen() ? "click to show more" : getThinkingToggleHint();
174
+ }
175
+
176
+ // 只 wrap 尾部窗口;缓存未着色折行,着色放到取出时做,避免主题切换命中旧 ANSI。
177
+ const previewCache = new Map<string, { lines: string[]; hiddenBefore: number }>();
178
+ const PREVIEW_CACHE_MAX = 500;
179
+ const PREVIEW_BUDGET_MIN_CHARS = 2_000;
180
+ const PREVIEW_BUDGET_SLACK_LINES = 2;
181
+
182
+ /** 按可见宽度估算折行数,不走 Text 全量 wrap。无换行长段也能计到隐藏行。 */
183
+ function countWrappedLines(text: string, contentWidth: number): number {
184
+ if (!text) return 0;
185
+ const width = Math.max(1, contentWidth);
186
+ let lines = 0;
187
+ for (const raw of text.replace(/\t/g, " ").split(/\r\n|\r|\n/)) {
188
+ const w = visibleWidth(raw);
189
+ lines += w === 0 ? 1 : Math.ceil(w / width);
190
+ }
191
+ return lines;
192
+ }
193
+
194
+ function padPreviewLine(line: string, width: number, padding: number): string {
195
+ const left = padding > 0 ? " ".repeat(padding) : "";
196
+ const right = padding > 0 ? " ".repeat(padding) : "";
197
+ const withMargins = left + line + right;
198
+ return withMargins + " ".repeat(Math.max(0, width - visibleWidth(withMargins)));
199
+ }
200
+
201
+ function layoutThinkingPreview(
202
+ text: string,
203
+ width: number,
204
+ padding: number,
205
+ ): { visible: string[]; hiddenLines: number } {
206
+ if (!text || config.previewLines <= 0) return { visible: [], hiddenLines: 0 };
207
+ const contentWidth = Math.max(1, width - padding * 2);
208
+ const budget = Math.max(
209
+ width * (config.previewLines + PREVIEW_BUDGET_SLACK_LINES),
210
+ PREVIEW_BUDGET_MIN_CHARS,
211
+ );
212
+ let source = text;
213
+ let cut = 0;
214
+ if (source.length > budget) {
215
+ const start = source.length - budget;
216
+ const newline = source.indexOf("\n", start);
217
+ cut = newline === -1 ? start : newline + 1;
218
+ if (cut < source.length) source = source.slice(cut);
219
+ else cut = 0;
220
+ }
221
+ const cacheKey = `${width}:${padding}:${cut}:${source}`;
222
+ let entry = previewCache.get(cacheKey);
223
+ if (!entry) {
224
+ entry = {
225
+ lines: wrapTextWithAnsi(source.replace(/\t/g, " "), contentWidth),
226
+ hiddenBefore: cut > 0 ? countWrappedLines(text.slice(0, cut), contentWidth) : 0,
227
+ };
228
+ if (previewCache.size >= PREVIEW_CACHE_MAX) previewCache.clear();
229
+ previewCache.set(cacheKey, entry);
230
+ }
231
+ const hiddenLines = entry.hiddenBefore + Math.max(0, entry.lines.length - config.previewLines);
232
+ const visible = hiddenLines > 0 ? entry.lines.slice(-config.previewLines) : entry.lines;
233
+ return { visible, hiddenLines };
234
+ }
235
+
236
+ function hiddenPreviewHint(
237
+ hiddenLines: number,
238
+ forceExpandHint = false,
239
+ ): { prefix: string; action: string; suffix: string } | undefined {
240
+ const action = thinkingExpandAction() ?? "";
241
+ if (hiddenLines > 0) {
242
+ const noun = hiddenLines === 1 ? "line" : "lines";
243
+ return {
244
+ prefix: ` • (${hiddenLines} more ${noun}${action ? ", " : ""}`,
245
+ action,
246
+ suffix: ")",
247
+ };
248
+ }
249
+ if (forceExpandHint && isToolTuiFullscreen()) {
250
+ return { prefix: " • ", action: "click to show more", suffix: "" };
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ const expandedThinking = new Set<number>();
256
+
257
+ /** 折叠预览 + 展开全文。fullscreen 点击 hint 展开、再点整块收起,对齐工具卡。 */
258
+ export class ThinkingPreviewBlock implements Component {
259
+ private heading: string;
167
260
  private text: string;
168
261
  private padding: number;
262
+ private messageTimestamp: number;
169
263
  private style: (text: string) => string;
170
-
171
- constructor(text: string, padding: number, style: (text: string) => string) {
264
+ private theme: Theme | undefined;
265
+ private _expanded: boolean;
266
+ private hintHovered = false;
267
+ private expandedBody: { width: number; lines: string[] } | undefined;
268
+
269
+ constructor(
270
+ heading: string,
271
+ text: string,
272
+ padding: number,
273
+ messageTimestamp: number,
274
+ style: (text: string) => string,
275
+ theme?: Theme,
276
+ ) {
277
+ this.heading = heading;
172
278
  this.text = text;
173
279
  this.padding = padding;
280
+ this.messageTimestamp = messageTimestamp;
174
281
  this.style = style;
282
+ this.theme = theme;
283
+ this._expanded = expandedThinking.has(messageTimestamp);
175
284
  }
176
285
 
177
- render(width: number) {
178
- const lines = new Text(this.style(this.text), this.padding, 0).render(width);
179
- if (lines.length <= config.previewLines) return lines;
286
+ private paint(color: string, text: string): string {
287
+ return this.theme && typeof this.theme.fg === "function"
288
+ ? this.theme.fg(color as never, text)
289
+ : text;
290
+ }
180
291
 
181
- const hiddenLines = lines.length - config.previewLines;
182
- const noun = hiddenLines === 1 ? "line" : "lines";
183
- const toggleHint = getThinkingToggleHint();
184
- const hint = `... (${hiddenLines} more ${noun}${toggleHint ? `, ${toggleHint}` : ""})`;
185
- const hintLines = new Text(this.style(hint), this.padding, 0).render(width);
186
- return [...hintLines, ...lines.slice(-config.previewLines)];
292
+ get expanded(): boolean {
293
+ return this._expanded;
294
+ }
295
+
296
+ setExpanded(expanded: boolean): void {
297
+ this._expanded = expanded;
298
+ if (expanded) expandedThinking.add(this.messageTimestamp);
299
+ else {
300
+ expandedThinking.delete(this.messageTimestamp);
301
+ this.expandedBody = undefined;
302
+ }
303
+ }
304
+
305
+ setHintHovered(hovered: boolean): void {
306
+ this.hintHovered = hovered;
307
+ }
308
+
309
+ private headingLines(width: number, hiddenLines: number, padding: number): string[] {
310
+ const hint = this._expanded
311
+ ? undefined
312
+ : hiddenPreviewHint(hiddenLines, Boolean(this.text) && config.previewLines <= 0);
313
+ if (!hint) return new Text(this.heading, padding, 0).render(width);
314
+ const rawHint = hint.prefix + hint.action + hint.suffix;
315
+ const contentWidth = Math.max(1, width - padding * 2);
316
+ const headingBudget = Math.max(0, contentWidth - visibleWidth(rawHint));
317
+ const clipped =
318
+ visibleWidth(this.heading) > headingBudget
319
+ ? truncateToWidth(this.heading, headingBudget, "")
320
+ : this.heading;
321
+ const action = this.paint(this.hintHovered ? "text" : "dim", hint.action);
322
+ return new Text(
323
+ clipped + this.paint("dim", hint.prefix) + action + this.paint("dim", hint.suffix),
324
+ padding,
325
+ 0,
326
+ ).render(width);
327
+ }
328
+
329
+ private bodyLines(width: number, padding: number): { lines: string[]; hiddenLines: number } {
330
+ if (!this.text || (config.previewLines <= 0 && !this._expanded)) {
331
+ return { lines: [], hiddenLines: 0 };
332
+ }
333
+ if (this._expanded) {
334
+ if (this.expandedBody?.width !== width) {
335
+ this.expandedBody = {
336
+ width,
337
+ lines: wrapTextWithAnsi(this.text.replace(/\t/g, " "), Math.max(1, width)),
338
+ };
339
+ }
340
+ return { lines: this.expandedBody.lines, hiddenLines: 0 };
341
+ }
342
+ const preview = layoutThinkingPreview(this.text, width, padding);
343
+ return { lines: preview.visible, hiddenLines: preview.hiddenLines };
344
+ }
345
+
346
+ render(width: number) {
347
+ if (this._expanded) {
348
+ const innerWidth = Math.max(1, width - 2);
349
+ const body = this.bodyLines(innerWidth, 0);
350
+ const heading = this.headingLines(innerWidth, 0, 0);
351
+ const inner = body.lines.length
352
+ ? [...heading, ...body.lines.map((line) => this.style(line))]
353
+ : heading;
354
+ const bgFn =
355
+ this.theme && typeof this.theme.bg === "function"
356
+ ? (text: string) => this.theme!.bg("userMessageBg" as never, text)
357
+ : undefined;
358
+ const box = new Box(1, 1, bgFn);
359
+ box.addChild({
360
+ render: () => inner,
361
+ invalidate() {},
362
+ });
363
+ return box.render(width);
364
+ }
365
+ const body = this.bodyLines(width, this.padding);
366
+ const heading = this.headingLines(width, body.hiddenLines, this.padding);
367
+ if (!body.lines.length) return heading;
368
+ return [
369
+ ...heading,
370
+ ...body.lines.map((line) => padPreviewLine(this.style(line), width, this.padding)),
371
+ ];
187
372
  }
188
373
 
189
374
  invalidate() {}
@@ -461,14 +646,17 @@ function compactThinking(pi: ExtensionAPI) {
461
646
  durationText ? `Thought for ${durationText}` : (latestSummary?.title ?? "Thought"),
462
647
  );
463
648
  }
464
- self.contentContainer.addChild(new Text(heading, self.outputPad, 0));
465
-
466
- const previewSource = latestSummary?.body ?? thinkingBlocks.join("\n\n");
467
- if (config.previewLines > 0 && previewSource.trim()) {
468
- self.contentContainer.addChild(
469
- new StrictThinkingPreview(previewSource.trim(), self.outputPad, thinkingStyle),
470
- );
471
- }
649
+ const previewSource = (latestSummary?.body ?? thinkingBlocks.join("\n\n")).trim();
650
+ self.contentContainer.addChild(
651
+ new ThinkingPreviewBlock(
652
+ heading,
653
+ previewSource,
654
+ self.outputPad,
655
+ message.timestamp,
656
+ thinkingStyle,
657
+ activeTheme,
658
+ ),
659
+ );
472
660
 
473
661
  const hasVisibleContentAfter = message.content
474
662
  .slice(i + 1)
@@ -733,6 +921,7 @@ function compactThinking(pi: ExtensionAPI) {
733
921
  latestComponentTimestamp = undefined;
734
922
  completedDurations.clear();
735
923
  streamingComponents.clear();
924
+ expandedThinking.clear();
736
925
  if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_ID, undefined);
737
926
 
738
927
  if (patchInstalled) {