pi-cc-extensions 0.9.12 → 1.0.1

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.
@@ -20,4 +20,8 @@ export { shouldHighlightCodeBlock } from "./diff-highlight.ts";
20
20
  export type { DisplayConfigInput } from "./diff-component.ts";
21
21
  export { isRichDiffComponent } from "./diff-component.ts";
22
22
  export { countEditDiffStats, renderEditDiffResult } from "./diff-edit-render.ts";
23
- export { countWriteDiffStats, renderWriteDiffResult } from "./diff-write-render.ts";
23
+ export {
24
+ countWriteDiffStats,
25
+ renderWriteDiffResult,
26
+ writeStatsInCollapsedBody,
27
+ } from "./diff-write-render.ts";
@@ -1,4 +1,5 @@
1
1
  import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
+ import type { ToolDisplayConfig } from "../../../config/config.ts";
2
3
  import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
3
4
  import { showMoreHintText } from "../show-more-hint.ts";
4
5
  import { splitWriteContentLines } from "./diff-text.ts";
@@ -54,6 +55,7 @@ import {
54
55
  buildDiffSummaryText,
55
56
  normalizeDiffRenderWidth,
56
57
  resolveDiffPresentationMode,
58
+ styleDiffSummaryLine,
57
59
  type DiffPresentationMode,
58
60
  } from "./diff-presentation.ts";
59
61
 
@@ -290,13 +292,14 @@ function renderWriteOverwriteGuardRows(
290
292
  return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
291
293
  }
292
294
 
293
- /** Folded write with `writeDiffCollapsedLines: 0`: `↳ created · click to show more`. */
295
+ /** Folded write with `writeDiffCollapsedLines: 0`: `↳ diff +A -D · created · click to show more`. */
294
296
  function renderWriteCollapsedHintLine(
295
297
  wasOverwrite: boolean,
296
298
  width: number,
297
299
  theme: DiffTheme,
298
300
  hovered: boolean,
299
301
  headerLabel?: string,
302
+ stats?: { added: number; removed: number },
300
303
  ): string[] {
301
304
  const safeWidth = normalizeDiffRenderWidth(width);
302
305
  if (safeWidth === 0) {
@@ -304,7 +307,20 @@ function renderWriteCollapsedHintLine(
304
307
  }
305
308
  const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
306
309
  const clickLabel = showMoreHintText();
307
- const candidates = [`↳ ${actionLabel} · ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
310
+ const counts = stats ? `+${stats.added} -${stats.removed}` : "";
311
+ const candidates = stats
312
+ ? [
313
+ `↳ diff ${counts} · ${actionLabel} · ${clickLabel}`,
314
+ `↳ diff ${counts} · ${clickLabel}`,
315
+ `↳ diff ${counts} · ${actionLabel}`,
316
+ `↳ diff ${counts}`,
317
+ `↳ ${counts} · ${clickLabel}`,
318
+ `↳ ${actionLabel} · ${clickLabel}`,
319
+ `↳ ${actionLabel}`,
320
+ actionLabel,
321
+ "…",
322
+ ]
323
+ : [`↳ ${actionLabel} · ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
308
324
  let text = candidates[candidates.length - 1] ?? "…";
309
325
  for (const candidate of candidates) {
310
326
  if (visibleWidth(candidate) <= safeWidth) {
@@ -315,16 +331,21 @@ function renderWriteCollapsedHintLine(
315
331
  if (visibleWidth(text) > safeWidth) {
316
332
  text = truncateToWidth(text, safeWidth, "");
317
333
  }
318
- const clickIndex = text.lastIndexOf(clickLabel);
319
- const styled =
320
- hovered && clickIndex >= 0
321
- ? theme.fg("muted", text.slice(0, clickIndex)) +
322
- theme.fg("text", clickLabel) +
323
- theme.fg("muted", text.slice(clickIndex + clickLabel.length))
324
- : theme.fg("muted", text);
334
+ const styled = styleDiffSummaryLine(text, stats, clickLabel, theme, hovered);
325
335
  return [clampDiffLineToWidth(styled, safeWidth)];
326
336
  }
327
337
 
338
+ /**
339
+ * 折叠 0 行预览时计数由正文那行显示(`↳ diff +A -D · created · click to show more`),
340
+ * 标题不再重复。compact 与 default 两个模式的写标题共用这条口径。
341
+ */
342
+ export function writeStatsInCollapsedBody(
343
+ expanded: boolean,
344
+ display: Pick<ToolDisplayConfig, "writeDiffCollapsedLines">,
345
+ ): boolean {
346
+ return !expanded && resolveWriteCollapsedLimit(display) === 0;
347
+ }
348
+
328
349
  /**
329
350
  * write 变更行统计(compact 单行用):复用当前 write diff 算法。
330
351
  * 新文件 = 全部新内容为 added;覆盖已有文件用 LCS 精确统计。
@@ -413,6 +434,10 @@ export function renderWriteDiffResult(
413
434
  const hint = cache.getHintLine();
414
435
  return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
415
436
  },
437
+ /** 折叠 0 行预览时正文那行自带 +A -D:标题不再重复计数。 */
438
+ bodyShowsStats(): boolean {
439
+ return writeStatsInCollapsedBody(options.expanded, resolveLiveDisplayConfig(config));
440
+ },
416
441
  render(width: number): string[] {
417
442
  // Live config: panel can change indicator/wrap/limits after this component is created.
418
443
  const live = resolveLiveDisplayConfig(config);
@@ -445,6 +470,7 @@ export function renderWriteDiffResult(
445
470
  theme,
446
471
  hovered,
447
472
  options.headerLabel,
473
+ countWriteDiffStats(content, options.previousContent, options.fileExistedBeforeWrite),
448
474
  );
449
475
  return cache.set(
450
476
  safeWidth,
@@ -8,6 +8,12 @@ import {
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { config } from "../../config/config.ts";
10
10
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
11
+ import {
12
+ ASK_USER_QUESTION_TOOL_NAME,
13
+ askUserAnswerText,
14
+ askUserAnswers,
15
+ type AskUserAnswer,
16
+ } from "./ask-user-question.ts";
11
17
  import {
12
18
  CODEMODE_TOOL_NAME,
13
19
  codemodeCalls,
@@ -23,10 +29,16 @@ import {
23
29
  fitToolCallSummary,
24
30
  humanizeToolLabel,
25
31
  renderToolSummary,
32
+ SHELL_TOOL_NAMES,
26
33
  toolCallSummary,
27
34
  type ToolCallSummary,
28
35
  } from "./names.ts";
29
- import { toolViewportWidth } from "./result.ts";
36
+ import {
37
+ formatToolDuration,
38
+ headTruncateToWidth,
39
+ isLiveToolCall,
40
+ toolViewportWidth,
41
+ } from "./result.ts";
30
42
  import {
31
43
  patchRegistry,
32
44
  TOOL_GROUPING_GENERATION_KEY as GENERATION_KEY,
@@ -108,6 +120,12 @@ function statusIcon(value: ToolStatus): string {
108
120
  return toolLoadingIcon();
109
121
  }
110
122
 
123
+ /** 等待窗口或运行中的行:spinner 逐帧变化,分组卡要跟着跳帧。 */
124
+ function isAnimatingTool(tool: any): boolean {
125
+ if (status(tool) !== "pending") return false;
126
+ return Boolean(tool?.executionStarted) || isLiveToolCall(tool?.toolCallId);
127
+ }
128
+
111
129
  function scheduleGroupAnimation(patch: Patch): void {
112
130
  if (patch.animationTimer || !patch.active) return;
113
131
  patch.animationTimer = setTimeout(() => {
@@ -115,11 +133,7 @@ function scheduleGroupAnimation(patch: Patch): void {
115
133
  if (!patch.active) return;
116
134
  let needsRender = false;
117
135
  for (const group of patch.groups) {
118
- if (
119
- (group.children as any[]).some(
120
- (tool) => tool?.executionStarted && status(tool) === "pending",
121
- )
122
- ) {
136
+ if ((group.children as any[]).some(isAnimatingTool)) {
123
137
  group.invalidate();
124
138
  // 分组卡不是 ToolExecutionComponent:它的 invalidate() 不会请求渲染,
125
139
  // 不补这一步 spinner 只在别人渲染时才跳帧(并行工具下表现为卡顿/冻结)。
@@ -203,6 +217,39 @@ function toolSummary(tool: any): ToolCallSummary {
203
217
  * codemode 的折叠行:有结果就报该次调用的统计(与单卡汇总行同一套口径),
204
218
  * 免得一张卡里几行长代码预览互相看不出区别。还没结果时退回代码预览。
205
219
  */
220
+ /**
221
+ * ask_user_question 的折叠行:有结果就报「问题 -> 回答」,
222
+ * 多题压成一行并标注剩余条数。还没结果时退回问题预览。
223
+ */
224
+ function askUserResultSummary(tool: any): ToolCallSummary | undefined {
225
+ if (toolName(tool) !== ASK_USER_QUESTION_TOOL_NAME || !tool?.result) return undefined;
226
+ const answers = askUserAnswers(tool.result)
227
+ .map((answer: AskUserAnswer, index: number) => ({ answer, index }))
228
+ .sort((left, right) => {
229
+ const leftIndex = Number(left.answer.questionIndex);
230
+ const rightIndex = Number(right.answer.questionIndex);
231
+ const leftMissing = !Number.isFinite(leftIndex);
232
+ const rightMissing = !Number.isFinite(rightIndex);
233
+ if (leftMissing || rightMissing) {
234
+ if (leftMissing !== rightMissing) return leftMissing ? 1 : -1;
235
+ return left.index - right.index;
236
+ }
237
+ return leftIndex - rightIndex;
238
+ })
239
+ .map((entry) => entry.answer);
240
+ if (answers.length === 0) {
241
+ const label = tool.result?.details?.cancelled === true ? "Declined" : "Done";
242
+ return { main: `${toolTitle(tool)} ${label}`, detail: "" };
243
+ }
244
+ const first = answers[0]!;
245
+ const question = String(first.question ?? "").trim() || "Question";
246
+ const more = answers.length > 1 ? ` (+${answers.length - 1})` : "";
247
+ return {
248
+ main: `${toolTitle(tool)} ${question} -> ${askUserAnswerText(first)}${more}`,
249
+ detail: "",
250
+ };
251
+ }
252
+
206
253
  function codemodeResultSummary(tool: any): ToolCallSummary | undefined {
207
254
  if (toolName(tool) !== CODEMODE_TOOL_NAME || !tool?.result) return undefined;
208
255
  const parts = codemodeSummaryParts(
@@ -213,10 +260,36 @@ function codemodeResultSummary(tool: any): ToolCallSummary | undefined {
213
260
  return { main: `${toolTitle(tool)} ${parts.join(" · ")}`, detail: "" };
214
261
  }
215
262
 
263
+ /**
264
+ * 组内 shell 行的尾巴:只报耗时(`· 12.4s`)。输出和错误正文都在展开后看;
265
+ * 失败靠行首的 ✗,组头另有 failed 计数。
266
+ * 耗时取 pi 记在结果上的 durationMs(单卡渲染上下文里的同一个值),运行中还拿不到就不报。
267
+ */
268
+ function shellResultTail(
269
+ tool: any,
270
+ fg: (color: string, text: string) => string,
271
+ toolStatus: ToolStatus,
272
+ ): string {
273
+ if (!SHELL_TOOL_NAMES.has(toolName(tool)) || toolStatus === "pending" || !tool?.result) return "";
274
+ const durationMs = tool.result?.durationMs;
275
+ const duration =
276
+ typeof durationMs === "number" && Number.isFinite(durationMs) && durationMs >= 0
277
+ ? formatToolDuration(durationMs)
278
+ : "";
279
+ return duration ? ` ${fg("dim", `· ${duration}`)}` : "";
280
+ }
281
+
282
+ /**
283
+ * 组头的工具列表:名称用与单组标题、子行同一套人性化写法,次数一律带上(各跑一次也报 ×1)。
284
+ * 同名(同显示标题)的合并成一项,避免 `MCP call×1, MCP call×1` 这种重复。
285
+ */
216
286
  function toolNameList(tools: any[]): string {
217
287
  const counts = new Map<string, number>();
218
- for (const tool of tools) counts.set(toolName(tool), (counts.get(toolName(tool)) ?? 0) + 1);
219
- return [...counts].map(([name, count]) => `${name}${count > 1 ? `×${count}` : ""}`).join(", ");
288
+ for (const tool of tools) {
289
+ const label = toolTitle(tool);
290
+ counts.set(label, (counts.get(label) ?? 0) + 1);
291
+ }
292
+ return [...counts].map(([label, count]) => `${label}×${count}`).join(", ");
220
293
  }
221
294
 
222
295
  let nextGroupId = 1;
@@ -389,23 +462,23 @@ export class ToolGroupComponent extends Container {
389
462
  const names = new Set(this.children.map(toolName));
390
463
  const label = names.size === 1 ? toolTitle(this.children[0]) : "Multiple Tools";
391
464
  const overall: ToolStatus = counts.error ? "error" : counts.pending ? "pending" : "success";
392
- if (
393
- (this.children as any[]).some((tool) => tool?.executionStarted && status(tool) === "pending")
394
- )
395
- scheduleGroupAnimation(this.patch);
465
+ if ((this.children as any[]).some(isAnimatingTool)) scheduleGroupAnimation(this.patch);
396
466
  const overallColor = overall === "pending" ? "accent" : overall;
397
- const nameList = names.size > 1 ? ` ${fg("dim", `· ${toolNameList(this.children)}`)}` : "";
398
467
  // 圆点保持 dim;hover 只高亮可点击文字。
399
468
  const hintText = this._expanded ? collapseHintText() : showMoreHintText();
400
469
  const hint = `${fg("dim", "·")} ${fg(this.hintHovered ? "text" : "dim", hintText)}`;
401
- const lines = [
402
- "",
403
- truncateToWidth(
404
- ` ${fg(overallColor, "●")} ${label}: ${countText}${nameList} ${hint}`,
405
- toolViewportWidth(width),
406
- "…",
407
- ),
408
- ];
470
+ const rowWidth = toolViewportWidth(width);
471
+ // 工具列表让位给展开提示:提示被截断的话,鼠标层要在行尾找 `click to show more`
472
+ // 就找不到,整张卡在窄宽度下点不开。名字截断后子行里仍能看全。
473
+ const head = ` ${fg(overallColor, "●")} ${label}: ${countText}`;
474
+ const tail = ` ${hint}`;
475
+ const listRoom = rowWidth - visibleWidth(head) - visibleWidth(tail) - 3;
476
+ // 连一个工具名都放不下就不放列表:退化成 `· …` 只会把行撑爆。
477
+ const nameList =
478
+ names.size > 1 && listRoom >= 4
479
+ ? ` ${fg("dim", `· ${headTruncateToWidth(toolNameList(this.children), listRoom)}`)}`
480
+ : "";
481
+ const lines = ["", truncateToWidth(`${head}${nameList}${tail}`, rowWidth, "")];
409
482
  const total = this.children.length;
410
483
  const childPaints = this._expanded
411
484
  ? (this.children as any[]).map((tool) => tool.render?.(Math.max(1, width - 2)))
@@ -433,15 +506,20 @@ export class ToolGroupComponent extends Container {
433
506
  const branch = index === total - 1 ? "└" : "├";
434
507
  const continuation = index === total - 1 ? " " : "│ ";
435
508
  if (!this._expanded) {
436
- const summary = codemodeResultSummary(tool) ?? toolSummary(tool);
509
+ const summary =
510
+ askUserResultSummary(tool) ?? codemodeResultSummary(tool) ?? toolSummary(tool);
437
511
  const prefix = ` ${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} `;
438
512
  const detail = fg("dim", summary.detail);
513
+ const result = shellResultTail(tool, fg, toolStatus);
439
514
  // 与单工具卡标题同宽,宽屏右侧留白一致
440
515
  const rowWidth = toolViewportWidth(width);
441
- const mainWidth = Math.max(0, rowWidth - visibleWidth(prefix) - visibleWidth(detail));
516
+ const mainWidth = Math.max(
517
+ 0,
518
+ rowWidth - visibleWidth(prefix) - visibleWidth(detail) - visibleWidth(result),
519
+ );
442
520
  lines.push(
443
521
  truncateToWidth(
444
- `${prefix}${renderToolSummary(summary, mainWidth, fg)}${detail}`,
522
+ `${prefix}${renderToolSummary(summary, mainWidth, fg)}${detail}${result}`,
445
523
  rowWidth,
446
524
  "",
447
525
  ),
@@ -157,7 +157,7 @@ function pathSummary(
157
157
  }
158
158
 
159
159
  /** 入参是 shell 命令的工具。 */
160
- const SHELL_TOOL_NAMES = new Set(["bash", "powershell", "pwsh", "sh", "zsh", "cmd"]);
160
+ export const SHELL_TOOL_NAMES = new Set(["bash", "powershell", "pwsh", "sh", "zsh", "cmd"]);
161
161
 
162
162
  /** 内置检索工具:pattern 缺失(无效调用)时保留 "…" 占位。 */
163
163
  const SEARCH_TOOL_NAMES = new Set(["grep", "find"]);
@@ -6,6 +6,7 @@ import { showMoreHintText } from "./show-more-hint.ts";
6
6
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
7
7
  import { getToolMouseTui } from "../mouse/scroll.ts";
8
8
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
9
+ import { oneLine } from "../../utils/format.ts";
9
10
 
10
11
  const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
11
12
  /** 宽屏右侧留白上限:比例留白超过这么多列时改用固定留白。 */
@@ -75,10 +76,46 @@ export function countLines(text: string): number {
75
76
  .filter((line) => line.trim().length > 0).length;
76
77
  }
77
78
 
79
+ /**
80
+ * 折叠行的结果文案:`N lines returned`(read 说 loaded)、`Done`、失败时首行错误。
81
+ * 单卡折叠行与工具组的子行共用这一处口径;TaskList 的树形摘要不走这里,由调用方先判。
82
+ */
83
+ export function collapsedResultSummary(options: {
84
+ result: any;
85
+ toolName: string;
86
+ text: string;
87
+ isError: boolean;
88
+ }): string {
89
+ const { result, toolName, text, isError } = options;
90
+ if (isError) {
91
+ // 72 与原 result.ts 默认一致,保持错误摘要截断宽度。
92
+ return text ? oneLine(text, 72) : "Failed";
93
+ }
94
+ const outputLines = outputLineCount(result) || countLines(text);
95
+ if (!outputLines) return "Done";
96
+ const action = toolName === "read" ? "loaded" : "returned";
97
+ return `${outputLines} ${outputLines === 1 ? "line" : "lines"} ${action}`;
98
+ }
99
+
78
100
  function hasExpandableResult(text: string): boolean {
79
101
  return countLines(text) > 1;
80
102
  }
81
103
 
104
+ /**
105
+ * 工具耗时:不到 1 秒报毫秒,分钟级起带上分/时。
106
+ * 只报到秒的话,几分钟的长命令会显示成 `300.0s`。
107
+ */
108
+ export function formatToolDuration(ms: number): string {
109
+ const seconds = ms / 1000;
110
+ if (seconds < 1) return `${Math.round(ms)}ms`;
111
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
112
+ const totalSeconds = Math.floor(seconds);
113
+ const minutes = Math.floor(totalSeconds / 60);
114
+ const remainder = totalSeconds % 60;
115
+ if (minutes < 60) return `${minutes}m ${remainder}s`;
116
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainder}s`;
117
+ }
118
+
82
119
  const activeAnimationContexts = new Set<any>();
83
120
  let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
84
121
 
@@ -135,6 +172,36 @@ export function scheduleAnimation(
135
172
  }
136
173
  }
137
174
 
175
+ /**
176
+ * 本回合流式消息里出现过的工具调用。Pi 要等整条 assistant 消息结束才派发执行,
177
+ * 这段窗口工具行已经在等启动,spinner 该逐帧转。
178
+ * 恢复或翻历史重建的旧行不在这个集合里,保持静止。
179
+ */
180
+ const liveToolCalls = new Set<string>();
181
+
182
+ /** 收下当前流式 assistant 消息里的工具调用 id。 */
183
+ export function noteStreamingToolCalls(message: any): void {
184
+ const content = Array.isArray(message?.content) ? message.content : [];
185
+ for (const block of content) {
186
+ const id = block?.type === "toolCall" ? block.id : undefined;
187
+ if (typeof id === "string" && id) liveToolCalls.add(id);
188
+ }
189
+ }
190
+
191
+ /** 工具收尾后移出等待窗口。 */
192
+ export function retireLiveToolCall(toolCallId: unknown): void {
193
+ if (typeof toolCallId === "string") liveToolCalls.delete(toolCallId);
194
+ }
195
+
196
+ export function clearLiveToolCalls(): void {
197
+ liveToolCalls.clear();
198
+ }
199
+
200
+ /** 工具行是否处于等待窗口(流式声明中或等待派发)。 */
201
+ export function isLiveToolCall(toolCallId: unknown): boolean {
202
+ return typeof toolCallId === "string" && liveToolCalls.has(toolCallId);
203
+ }
204
+
138
205
  export function pendingIcon(_name: string): string {
139
206
  return toolLoadingIcon();
140
207
  }
@@ -231,7 +298,7 @@ export class ExpandedToolResultText {
231
298
  }
232
299
  }
233
300
 
234
- /** 截断体末行 `… +N more lines` 旁的展开提示,点击打开全量预览。 */
301
+ /** 截断体末行 `… N more lines` 旁的展开提示,点击打开全量预览。 */
235
302
  export const SHOW_MORE_LABEL = "· click to show more";
236
303
 
237
304
  export type ToolIoSection = "input" | "output";
@@ -243,14 +310,12 @@ const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view")
243
310
  /**
244
311
  * Expanded tool body with clear Input / Output sections (Grok Build–style).
245
312
  *
246
- * Visual frame:
247
- * ├ Input click to show more
248
- * │ path: src/a.ts
249
- * │
250
- * └ Output click to show more
251
- * result line…
252
- *
253
- * flushLeft=true(仅 mode=on 展开卡):去掉树线前导空格,由外层 Box(1,1) 提供 1 格 padding。
313
+ * Visual frame(flushLeft=true:树线的前导空格交给外层 Box(1,1),├/└ 与标题行的勾同列):
314
+ * ├ Input click to show more
315
+ * │ path: src/a.ts
316
+ * │
317
+ * └ Output click to show more
318
+ * result line…
254
319
  *
255
320
  * Reused across re-renders via context.lastComponent when possible.
256
321
  */
@@ -281,8 +346,10 @@ export class ExpandedToolIoView {
281
346
  }
282
347
  | undefined;
283
348
 
284
- /** flushLeft:贴左渲染(mode=on 展开卡);默认 false 保留前导空格(compact 等共用路径)。 */
349
+ /** flushLeft:贴左渲染(外层 Box 负责 1 格 pad);默认 false 保留 1 格前导空格。 */
285
350
  private flushLeft: boolean;
351
+ /** 尾部附注(shell 的耗时)。挂到 Output 的 show-more 行上,没有那一行时单独成行。 */
352
+ private footer: string | undefined;
286
353
 
287
354
  constructor(
288
355
  theme: any,
@@ -398,6 +465,13 @@ export class ExpandedToolIoView {
398
465
  this.invalidate();
399
466
  }
400
467
 
468
+ /** 尾部附注(耗时等)。挂在 show-more 行上;无 show-more 行时单独成行。 */
469
+ setFooter(text: string | undefined): void {
470
+ if (this.footer === text) return;
471
+ this.footer = text;
472
+ this.invalidate();
473
+ }
474
+
401
475
  getHoveredSection(): ToolIoSection | null {
402
476
  return this.hoveredSection;
403
477
  }
@@ -405,7 +479,7 @@ export class ExpandedToolIoView {
405
479
  /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
406
480
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
407
481
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
408
- if (!line.includes(` · ${showMoreHintText()}`) || !/\+\d+ more lines/.test(line)) return null;
482
+ if (!line.includes(` · ${showMoreHintText()}`) || !/\d+ more lines/.test(line)) return null;
409
483
  if (this.truncated.input && line.includes("│")) return "input";
410
484
  if (this.truncated.output) return "output";
411
485
  return null;
@@ -442,7 +516,7 @@ export class ExpandedToolIoView {
442
516
 
443
517
  const theme = this.theme;
444
518
  const safeWidth = Math.max(1, Math.floor(width));
445
- // flushLeft:贴左(外层 Box 负责 1 格 pad);否则保留 1 格前导空格(compact 共用)
519
+ // flushLeft:贴左(外层 Box 负责 1 格 pad);否则保留 1 格前导空格
446
520
  const lead = this.flushLeft ? "" : " ";
447
521
  const rail = `${lead}│ `;
448
522
  const railWidth = visibleWidth(rail);
@@ -474,7 +548,14 @@ export class ExpandedToolIoView {
474
548
 
475
549
  const pushBody = (
476
550
  body: string,
477
- opts: { input?: boolean; limit: number; continued?: boolean; section: ToolIoSection },
551
+ opts: {
552
+ input?: boolean;
553
+ limit: number;
554
+ continued?: boolean;
555
+ section: ToolIoSection;
556
+ /** 挂在 show-more 行上的附注(shell 耗时),插在展开提示前。 */
557
+ footerNote?: string;
558
+ },
478
559
  ): boolean /* truncated */ => {
479
560
  const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
480
561
  if (!raw.trim()) {
@@ -502,6 +583,8 @@ export class ExpandedToolIoView {
502
583
  if (truncated) {
503
584
  const hidden = Math.max(0, wrapped.length - visible.length);
504
585
  if (hidden > 0) {
586
+ // 省略号已经表达了「还有」,计数前不再加 +。
587
+ const note = opts.footerNote ? theme.fg("dim", ` · ${opts.footerNote}`) : "";
505
588
  // hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
506
589
  const more =
507
590
  theme.fg("dim", " ·") +
@@ -509,7 +592,7 @@ export class ExpandedToolIoView {
509
592
  this.hoveredSection === opts.section ? "text" : "dim",
510
593
  ` ${showMoreHintText()}`,
511
594
  );
512
- pushRailLine(theme.fg("dim", `… +${hidden} more lines`) + more, opts.continued);
595
+ pushRailLine(theme.fg("dim", `… ${hidden} more lines`) + note + more, opts.continued);
513
596
  this.showMoreHeaderRows[opts.section] = lines.length - 1;
514
597
  }
515
598
  }
@@ -556,6 +639,7 @@ export class ExpandedToolIoView {
556
639
  limit: this.maxOutputLines,
557
640
  continued: false,
558
641
  section: "output",
642
+ footerNote: this.footer,
559
643
  });
560
644
  } else {
561
645
  this.truncated.output = outputWouldTruncate;
@@ -564,9 +648,16 @@ export class ExpandedToolIoView {
564
648
  limit: this.maxOutputLines,
565
649
  continued: false,
566
650
  section: "output",
651
+ footerNote: this.footer,
567
652
  });
568
653
  }
569
654
 
655
+ // 耗时挂在 Output 的 show-more 行上(与折叠态一样,提示留在行尾);
656
+ // 没截断就没有那一行,才单独成行。放在最后追加,前面记下的行号不受影响。
657
+ if (this.footer && this.showMoreHeaderRows.output === undefined) {
658
+ pushRailLine(theme.fg("muted", this.footer), false);
659
+ }
660
+
570
661
  this.cachedWidth = width;
571
662
  this.cachedLines = lines;
572
663
  return withIoViewMarkers(this, lines);
@@ -830,7 +921,7 @@ export function renderExpandedToolResult(
830
921
  lastComponent?: unknown,
831
922
  args?: unknown,
832
923
  context?: any,
833
- /** mode=on 展开卡贴左;compact 等保持默认前导空格 */
924
+ /** 贴左渲染:树线的前导空格交给外层 Box(1,1) */
834
925
  flushLeft = false,
835
926
  ): ExpandedToolIoView | ExpandedToolResultText | Text {
836
927
  const { text: inputBody, codeBlock } = formatToolInputBody(args);
@@ -1,6 +1,15 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
3
3
 
4
+ /** token/计数缩写:<1k 原数,<1M 用 k,≥1M 用 M。footer 与 working message 共用。 */
5
+ export function formatTokenCount(value: number): string {
6
+ if (value < 1000) return `${value}`;
7
+ if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
8
+ if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
9
+ if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
10
+ return `${Math.round(value / 1_000_000)}M`;
11
+ }
12
+
4
13
  /** 毫秒 → "1h 2m 3s"/"2m 3s"/"3s";低于 1 秒返回 ""(省略)。 */
5
14
  export function formatDuration(ms: number): string {
6
15
  const totalSec = Math.floor(ms / 1000);
@@ -43,6 +43,11 @@ export const TOOL_GROUPING_GENERATION_KEY = Symbol.for("pi.ccstyle.tool-grouping
43
43
  // ── regular 主屏渲染补丁 ──
44
44
  export const MAIN_SCREEN_DO_RENDER_PATCH = Symbol.for("pi.ccstyle.main-screen-do-render-patch");
45
45
 
46
+ // ── fullscreen 粘性用户消息头 ──
47
+ export const STICKY_PROMPT_MARKER_PATCH = Symbol.for("pi.ccstyle.sticky-prompt-marker-patch");
48
+ export const STICKY_PROMPT_FRAME_PATCH = Symbol.for("pi.ccstyle.sticky-prompt-frame-patch");
49
+ export const STICKY_PROMPT_STATE_SLOT = Symbol.for("pi.ccstyle.sticky-prompt-state");
50
+
46
51
  // ── 鼠标交互 ──
47
52
  export const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi.ccstyle.tool-mouse-owner");
48
53
  export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi.ccstyle.tool-mouse-tui");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.12",
3
+ "version": "1.0.1",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",
@@ -65,9 +65,6 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@biomejs/biome": "2.5.5",
68
- "@earendil-works/pi-ai": "0.99.1",
69
- "@earendil-works/pi-coding-agent": "0.99.1",
70
- "@earendil-works/pi-tui": "0.99.1",
71
68
  "@types/node": "^26.1.1",
72
69
  "typescript": "^5.9.3"
73
70
  }
@@ -18,7 +18,7 @@
18
18
  "red": "#fb7185",
19
19
  "yellow": "#facc15",
20
20
  "orange": "#fb923c",
21
- "text": "#f5f5f5",
21
+ "text": "#e4e4e7",
22
22
  "muted": "#a1a1aa",
23
23
  "dim": "#71717a",
24
24
  "selectedBg": "#252035",
@@ -18,7 +18,7 @@
18
18
  "red": "#dc2626",
19
19
  "yellow": "#a16207",
20
20
  "orange": "#c2410c",
21
- "text": "#242127",
21
+ "text": "#282530",
22
22
  "muted": "#625f68",
23
23
  "dim": "#76717d",
24
24
  "selectedBg": "#e3e8f5",