@herbertgao/pi-extensions 2026.10.3 → 2026.10.5

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.
Files changed (34) hide show
  1. package/README.md +3 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  28. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  29. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
  30. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
  31. package/node_modules/pi-provider-qoder/README.md +140 -0
  32. package/node_modules/pi-provider-qoder/dist/index.js +10249 -0
  33. package/node_modules/pi-provider-qoder/package.json +57 -0
  34. package/package.json +5 -2
@@ -15,11 +15,13 @@ import {
15
15
  } from "../utils/patch-keys.ts";
16
16
  import { isToolCallHovered } from "./mouse/hover.ts";
17
17
  import {
18
- countLines,
18
+ collapsedResultSummary,
19
+ formatToolDuration,
19
20
  hasExpandableDetail,
20
21
  insetComponent,
22
+ isExpandedToolIoView,
23
+ isLiveToolCall,
21
24
  isToolExpanded,
22
- outputLineCount,
23
25
  pendingIcon,
24
26
  renderCollapsedToolResultToWidth,
25
27
  renderExpandedToolResult,
@@ -31,20 +33,26 @@ import {
31
33
  toolIconColor,
32
34
  toolViewportWidth,
33
35
  } from "./tool/result.ts";
34
- import { oneLine } from "../utils/format.ts";
35
36
  import { showMoreHintText } from "./tool/show-more-hint.ts";
37
+ import {
38
+ ASK_USER_QUESTION_TOOL_NAME,
39
+ askUserExpandedBody,
40
+ askUserQuestionCount,
41
+ createAskUserResultComponent,
42
+ } from "./tool/ask-user-question.ts";
36
43
  import {
37
44
  CODEMODE_TOOL_NAME,
38
45
  codemodeExpandedBody,
39
46
  createCodemodeResultComponent,
40
47
  } from "./tool/codemode.ts";
41
- import { countWriteDiffStats } from "./tool/diff/diff-renderer.ts";
48
+ import { countWriteDiffStats, writeStatsInCollapsedBody } from "./tool/diff/diff-renderer.ts";
42
49
  import { renderRichToolResult, type WriteExecutionMetadataStore } from "./tool/diff/index.ts";
43
50
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
44
51
  import { mcpToolTitle } from "./tool/mcp-title.ts";
45
52
  import {
46
53
  fitToolCallSummary,
47
54
  humanizeToolLabel,
55
+ SHELL_TOOL_NAMES,
48
56
  renderToolSummary,
49
57
  toolCallSummary,
50
58
  } from "./tool/names.ts";
@@ -95,6 +103,9 @@ type GlobalToolRenderPatch = {
95
103
  renderPaint?: (width: number) => string[];
96
104
  invalidatePaint?: () => void;
97
105
  paintCache?: WeakMap<object, ToolPaintHit>;
106
+ /** 给渲染上下文补 result;卸下时还原。 */
107
+ originalGetRenderContext?: (...args: any[]) => any;
108
+ getRenderContext?: (this: any, ...args: any[]) => any;
98
109
  };
99
110
 
100
111
  type ToolExpandedBackgroundPatch = {
@@ -213,6 +224,35 @@ function renderExpandedTaskResult(
213
224
  return formatted ? new Text(`↳ ${formatted}`, 0, 0) : undefined;
214
225
  }
215
226
 
227
+ /**
228
+ * shell 的耗时文本。Pi 原生 shell 渲染器会在结果底部报一行,ccstyle 的通用折叠行把它盖掉了。
229
+ *
230
+ * 两条路都要:新 Pi 在渲染上下文里给 `durationMs`(单调,restore 出来的行也带得到),优先用它;
231
+ * 当前宿主只把它留在工具结果上,渲染上下文补丁会把整份结果挂到 `context.result`。
232
+ * 都拿不到再退回自己的时钟——开始时间在 renderCall 记,结束时间在结果定型时定一次。
233
+ * 运行中的实时计时也只能走时钟。
234
+ */
235
+ function recordedShellDurationMs(context: any): number | undefined {
236
+ for (const value of [context?.durationMs, context?.result?.durationMs]) {
237
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ function shellDurationText(toolName: string, context: any, isPartial: boolean): string {
243
+ if (!SHELL_TOOL_NAMES.has(toolName)) return "";
244
+ const recorded = recordedShellDurationMs(context);
245
+ if (!isPartial && recorded !== undefined) {
246
+ return formatToolDuration(recorded);
247
+ }
248
+ const startedAt = context?.state?.ccstyleShellStartedAt;
249
+ if (typeof startedAt !== "number") return "";
250
+ const endedAt = context?.state?.ccstyleShellEndedAt;
251
+ const end = typeof endedAt === "number" ? endedAt : Date.now();
252
+ // 时钟回拨时别吐负数;快照固定时钟也会走到这。
253
+ return formatToolDuration(Math.max(0, end - startedAt));
254
+ }
255
+
216
256
  /** 用 ccstyle call/result 包装任意工具定义。 */
217
257
  function createCcstyleTool(
218
258
  originalTool: any,
@@ -232,11 +272,24 @@ function createCcstyleTool(
232
272
  return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
233
273
  }
234
274
 
275
+ // shell 的耗时从执行开始算;restore 出来的行没有 executionStarted,不参与。
276
+ if (
277
+ SHELL_TOOL_NAMES.has(toolName) &&
278
+ context?.executionStarted &&
279
+ context.state &&
280
+ context.state.ccstyleShellStartedAt === undefined
281
+ ) {
282
+ context.state.ccstyleShellStartedAt = Date.now();
283
+ }
284
+
235
285
  const visualState = resolveToolVisualState(context);
236
286
  const isPending =
237
287
  visualState === "pending" ||
238
288
  (!visualState && (context?.isPartial || context?.executionStarted));
239
- const animating = isPending && Boolean(context?.executionStarted);
289
+ // 等待窗口(声明流式中或等待派发)也要转:Pi 要到 assistant 消息结束
290
+ // 才派发执行,这段只靠 executionStarted 会一直冻在第一帧。
291
+ const animating =
292
+ isPending && (Boolean(context?.executionStarted) || isLiveToolCall(context?.toolCallId));
240
293
  if (animating) scheduleAnimation(context, { light: true });
241
294
  const settledRawIcon = isPending ? "" : settledIcon(toolName, visualState);
242
295
  const settledIconStyled =
@@ -251,9 +304,23 @@ function createCcstyleTool(
251
304
  variant: "default",
252
305
  cwd: context?.cwd,
253
306
  });
307
+ // ask_user_question:问题原文在折叠结果树里逐条列出,标题只留工具名和问题数,
308
+ // 否则第一个问题会在标题和树里各出现一次。
309
+ if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
310
+ // Pi 的渲染上下文不带 result,问题数从这里取。
311
+ const questionCount = askUserQuestionCount(context?.result, args);
312
+ summary.main = title;
313
+ summary.detail = questionCount === undefined ? "" : ` (+${questionCount})`;
314
+ summary.payload = undefined;
315
+ }
254
316
  let writeStatsText = "";
255
317
  let writeStatsStyled = "";
256
- if (toolName === "write" && visualState === "success") {
318
+ // 折叠 0 行预览时计数由正文那行(↳ diff +A -D · created …)显示,标题不重复。
319
+ const statsInBody = writeStatsInCollapsedBody(
320
+ Boolean(context?.expanded),
321
+ getToolDisplayConfig(),
322
+ );
323
+ if (toolName === "write" && visualState === "success" && !statsInBody) {
257
324
  const meta = writeExecutionMetadata.get(context?.toolCallId);
258
325
  const stats = countWriteDiffStats(
259
326
  typeof args?.content === "string" ? args.content : undefined,
@@ -336,11 +403,62 @@ function createCcstyleTool(
336
403
  isHovered: () => isToolCallHovered(toolCallId),
337
404
  });
338
405
  }
406
+ // ask_user_question:折叠态摊成问答树,展开态按问题逐条列出选择。
407
+ if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
408
+ if (!options?.isPartial) setToolVisualState(context, isError ? "error" : "success");
409
+ if (options?.isPartial) {
410
+ // 折叠态 1 格缩进:与完成后的 ├/└ 对齐标题行的勾。
411
+ const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
412
+ return new Text(theme.fg("muted", pending), 0, 0);
413
+ }
414
+ if (expanded) {
415
+ return renderExpandedToolResult(
416
+ askUserExpandedBody(result),
417
+ theme,
418
+ isError,
419
+ context?.lastComponent,
420
+ context?.args,
421
+ context,
422
+ true,
423
+ );
424
+ }
425
+ if (context?.state) context.state.ccstyleIoView = undefined;
426
+ const toolCallId = context?.toolCallId;
427
+ return createAskUserResultComponent({
428
+ result,
429
+ theme,
430
+ isError,
431
+ isHovered: () => isToolCallHovered(toolCallId),
432
+ });
433
+ }
339
434
  if (options?.isPartial) {
340
- // 展开态贴左(Box 提供 1 格 pad);折叠态保持 3 格对齐标题
435
+ // 折叠态保持 3 格:与完成后的 ` ↳ …` 同列,工具跑完那行不横跳。
436
+ // 展开态贴左(Box 提供 1 格 pad)。
341
437
  const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
342
- return new Text(theme.fg("muted", pending), 0, 0);
438
+ if (!SHELL_TOOL_NAMES.has(toolName)) {
439
+ return new Text(theme.fg("muted", pending), 0, 0);
440
+ }
441
+ // shell 运行中报已用时间:数字在 render() 内取,动画 tick 才能带上新值。
442
+ return {
443
+ render(width: number) {
444
+ const elapsed = shellDurationText(toolName, context, true);
445
+ const suffix = elapsed ? theme.fg("dim", ` · ${elapsed}`) : "";
446
+ return [truncateToWidth(theme.fg("muted", pending) + suffix, width, "")];
447
+ },
448
+ invalidate(): void {},
449
+ };
450
+ }
451
+
452
+ // shell:结果定型时定一次结束时间,这一行之后不再变。
453
+ if (
454
+ SHELL_TOOL_NAMES.has(toolName) &&
455
+ context?.state &&
456
+ context.state.ccstyleShellEndedAt === undefined
457
+ ) {
458
+ context.state.ccstyleShellEndedAt = Date.now();
343
459
  }
460
+ // 折叠行接在摘要后,展开态接在 Output 正文后(原生也是在结果底部报一行)。
461
+ const duration = shellDurationText(toolName, context, false);
344
462
 
345
463
  setToolVisualState(context, isError ? "error" : "success");
346
464
  const toolCallId = context?.toolCallId;
@@ -371,26 +489,16 @@ function createCcstyleTool(
371
489
  if (taskResult) return taskResult;
372
490
  }
373
491
  const tasks = !isError && toolName === "TaskList" ? parseTaskList(text) : [];
374
- const outputLines = outputLineCount(result) || countLines(text);
375
- const lineWord = outputLines === 1 ? "line" : "lines";
376
- const action = toolName === "read" ? "loaded" : "returned";
377
492
  const rendered = tasks.length
378
493
  ? taskListSummary(tasks)
379
- : isError
380
- ? text
381
- ? // 72 与原 result.ts 默认一致,保持错误摘要截断宽度。
382
- oneLine(text, 72)
383
- : "Failed"
384
- : outputLines
385
- ? `${outputLines} ${lineWord} ${action}`
386
- : "Done";
494
+ : collapsedResultSummary({ result, toolName, text, isError });
387
495
  const expandable = !expanded && (tasks.length > 0 || hasExpandableDetail(text, args));
388
496
  const hintText = showMoreHintText();
389
497
  const hintPrefix = expandable ? theme.fg("dim", " · ") : "";
390
498
  const hint = expandable ? hintPrefix + theme.fg("dim", hintText) : "";
391
499
  const hoveredHint = expandable ? hintPrefix + theme.fg("text", hintText) : "";
392
500
  if (expanded) {
393
- return renderExpandedToolResult(
501
+ const view = renderExpandedToolResult(
394
502
  text || "",
395
503
  theme,
396
504
  Boolean(isError),
@@ -399,8 +507,14 @@ function createCcstyleTool(
399
507
  context,
400
508
  true, // mode=on:贴左,由外层 Box(1,1) 提供 1 格 padding
401
509
  );
510
+ // 尾部附注只能挂在 IoView 上:鼠标层按对象识别它,包一层会断掉命中。
511
+ // 与折叠态保持同一写法:裸值,不写 Took。
512
+ if (isExpandedToolIoView(view)) view.setFooter(duration || undefined);
513
+ return view;
402
514
  }
403
515
  if (context?.state) context.state.ccstyleIoView = undefined;
516
+ // 折叠态的耗时接在摘要后、展开提示前(提示必须留在行尾供鼠标层识别)。
517
+ const body = duration ? `${rendered}${theme.fg("dim", ` · ${duration}`)}` : rendered;
404
518
  let cachedWidth: number | undefined;
405
519
  let cachedLine: string | undefined;
406
520
  let cachedHoveredLine: string | undefined;
@@ -410,11 +524,11 @@ function createCcstyleTool(
410
524
  cachedWidth = width;
411
525
  cachedLine = theme.fg(
412
526
  isError ? "error" : "muted",
413
- renderCollapsedToolResultToWidth(rendered, hint, width),
527
+ renderCollapsedToolResultToWidth(body, hint, width),
414
528
  );
415
529
  cachedHoveredLine = theme.fg(
416
530
  isError ? "error" : "muted",
417
- renderCollapsedToolResultToWidth(rendered, hoveredHint, width),
531
+ renderCollapsedToolResultToWidth(body, hoveredHint, width),
418
532
  );
419
533
  }
420
534
  return [isToolCallHovered(toolCallId) ? cachedHoveredLine! : cachedLine];
@@ -566,6 +680,10 @@ function installGlobalToolRendering(
566
680
  previous?.originalInvalidate && prototype.invalidate === previous.invalidatePaint
567
681
  ? previous.originalInvalidate
568
682
  : prototype.invalidate;
683
+ const originalGetRenderContext =
684
+ previous?.originalGetRenderContext && prototype.getRenderContext === previous.getRenderContext
685
+ ? previous.originalGetRenderContext
686
+ : prototype.getRenderContext;
569
687
  if (isOwnershipAwarePatch(previous)) disconnectGlobalToolRenderPatch(previous);
570
688
 
571
689
  const patch: GlobalToolRenderPatch = {
@@ -581,6 +699,7 @@ function installGlobalToolRendering(
581
699
  installed: undefined as any,
582
700
  originalRender,
583
701
  originalInvalidate,
702
+ originalGetRenderContext,
584
703
  paintCache: new WeakMap(),
585
704
  };
586
705
 
@@ -618,6 +737,13 @@ function installGlobalToolRendering(
618
737
  prototype.getRenderShell = patch.installed.getRenderShell;
619
738
  prototype.getCallRenderer = patch.installed.getCallRenderer;
620
739
  prototype.getResultRenderer = patch.installed.getResultRenderer;
740
+ // 渲染上下文没有 result。ask_user_question 的标题要报问题数,只能从这里补。
741
+ patch.getRenderContext = function (this: any, ...args: any[]) {
742
+ const context = originalGetRenderContext.apply(this, args);
743
+ if (context && typeof context === "object") context.result = this.result;
744
+ return context;
745
+ };
746
+ prototype.getRenderContext = patch.getRenderContext;
621
747
  // fullscreen 滚动/选区每帧整树 render;内容未变时复用上一帧行,避免重做 diff/Box。
622
748
  patch.renderPaint = function (this: any, width: number): string[] {
623
749
  if (!patch.active || patch.mode() === "off") {
@@ -626,8 +752,11 @@ function installGlobalToolRendering(
626
752
  const cache = patch.paintCache;
627
753
  // A running partial tool owns a time-varying spinner. Light animation ticks
628
754
  // request a repaint without invalidating the component, so settled-content
629
- // caching must not intercept those paints.
630
- const cacheable = !(this.executionStarted && this.isPartial);
755
+ // caching must not intercept those paints. The waiting window before the
756
+ // tool is dispatched animates too, so it must bypass the cache as well.
757
+ const animatingRow =
758
+ this.isPartial && (this.executionStarted || isLiveToolCall(this.toolCallId));
759
+ const cacheable = !animatingRow;
631
760
  const hit = cacheable ? cache?.get(this) : undefined;
632
761
  if (hit && toolPaintMatches(hit, this, width)) return hit.lines;
633
762
  const lines = originalRender.call(this, width);
@@ -683,6 +812,13 @@ function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
683
812
  ) {
684
813
  prototype.invalidate = patch.originalInvalidate;
685
814
  }
815
+ if (
816
+ patch.getRenderContext &&
817
+ prototype.getRenderContext === patch.getRenderContext &&
818
+ patch.originalGetRenderContext
819
+ ) {
820
+ prototype.getRenderContext = patch.originalGetRenderContext;
821
+ }
686
822
  }
687
823
 
688
824
  /** 展开面板背景统一为 user message 背景色;折叠行保持原生状态色。
@@ -31,8 +31,18 @@ import {
31
31
  TOOL_MOUSE_DISABLE,
32
32
  } from "./mouse/interaction.ts";
33
33
  import { getToolMouseTui, noteNewTranscriptItem } from "./mouse/scroll.ts";
34
+ import {
35
+ disposeStickyPromptPatches,
36
+ installStickyPromptPatches,
37
+ setStickyPromptTheme,
38
+ } from "./mouse/sticky-prompt.ts";
34
39
  import { setHoveredToolGroup, setHoveredToolIo } from "./mouse/hover.ts";
35
- import { clearAllAnimations } from "./tool/result.ts";
40
+ import {
41
+ clearAllAnimations,
42
+ clearLiveToolCalls,
43
+ noteStreamingToolCalls,
44
+ retireLiveToolCall,
45
+ } from "./tool/result.ts";
36
46
  import { installWriteOverride, WriteExecutionMetadataStore } from "./tool/diff/index.ts";
37
47
  import {
38
48
  installMessageDisplayRendering,
@@ -123,6 +133,8 @@ export default function (
123
133
  if (staleGlobalPatch) staleGlobalPatch.mode = () => "off";
124
134
  // regular 主屏的差分渲染补丁:视口上方变化不再触发清回滚的 fullRender。
125
135
  installMainScreenDoRenderPatch();
136
+ // 粘性用户消息头:标记注入 + fullscreen 帧合成。
137
+ installStickyPromptPatches();
126
138
  const defaultMode = installDefaultMode(writeExecutionMetadata);
127
139
  const toolGrouping = installToolGrouping(() => config.mode === "on");
128
140
  const compactMode = installCompactMode({
@@ -189,6 +201,9 @@ export default function (
189
201
  });
190
202
 
191
203
  pi.on("message_update", async (event) => {
204
+ // 声明期(assistant 消息结束前)就记住工具 id:这段工具行已在等启动,
205
+ // spinner 该转,但 Pi 还没跑 markExecutionStarted。
206
+ if (event.message?.role === "assistant") noteStreamingToolCalls(event.message);
192
207
  // compact-thinking 在 session_start 时于 compact 补丁之上再装一层;
193
208
  // 扩展事件先于 interactive-mode 的 updateContent 派发,此处先重新认领。
194
209
  if (config.mode === "compact" && event.message?.role === "assistant") {
@@ -204,6 +219,7 @@ export default function (
204
219
  });
205
220
 
206
221
  pi.on("tool_execution_end", async (event) => {
222
+ retireLiveToolCall(event?.toolCallId);
207
223
  if (config.mode !== "compact") return;
208
224
  // Agent 等工具收尾后延迟刷新,让 compact-thinking 先落最终态。
209
225
  const toolCallId: string | undefined = event?.toolCallId;
@@ -232,6 +248,7 @@ export default function (
232
248
  if (!hooks) return;
233
249
  hooks.toolGrouping.setTheme(ctx.ui.theme);
234
250
  setMessageDisplayTheme(ctx.ui.theme);
251
+ setStickyPromptTheme(ctx.ui.theme);
235
252
  ctx.ui.setStatus("ccstyle", undefined);
236
253
  // 先收集 resume transcript,再同步 compact 补丁与全局展开状态。
237
254
  syncCompactMode(ctx);
@@ -249,6 +266,7 @@ export default function (
249
266
  if (!hooks) return;
250
267
  hooks.toolGrouping.setTheme(ctx.ui.theme);
251
268
  setMessageDisplayTheme(ctx.ui.theme);
269
+ setStickyPromptTheme(ctx.ui.theme);
252
270
  syncCompactMode(ctx);
253
271
  scheduleSessionRender(() => {
254
272
  syncCompactMode(ctx);
@@ -268,8 +286,18 @@ export default function (
268
286
  noteNewTranscriptItem();
269
287
  });
270
288
 
289
+ pi.on("turn_end", async () => {
290
+ clearLiveToolCalls();
291
+ });
292
+
293
+ pi.on("agent_end", async () => {
294
+ clearLiveToolCalls();
295
+ });
296
+
271
297
  pi.on("session_shutdown", async () => {
272
298
  writeExecutionMetadata.clear();
299
+ // 会话结束就清空,不能等 installation 早退判断;残留的 id 会让下一会话误转。
300
+ clearLiveToolCalls();
273
301
  // 鼠标交互独立于渲染层:fullscreen 下 installation 为 undefined,
274
302
  // 但 onTerminalInput 监听与 handleViewportInput 包装仍需释放。
275
303
  teardownToolMouseInteraction(mouseOwner);
@@ -281,6 +309,7 @@ export default function (
281
309
  current.compactMode.shutdown();
282
310
  compactModeHooks = undefined;
283
311
  current.disposeMessageDisplay();
312
+ disposeStickyPromptPatches();
284
313
  clearAllAnimations();
285
314
  installation = undefined;
286
315
  });
@@ -13,7 +13,6 @@ import {
13
13
  setHoveredCompactAssistant,
14
14
  } from "../compact-mode.ts";
15
15
  import { isMessageDisplayComponent } from "../tool/message-display.ts";
16
- import { config } from "../../config/config.ts";
17
16
  import { isLazyProxyTui } from "../../utils/fullscreen-detect.ts";
18
17
  import { setToolTuiFullscreen, isCollapseHintLine } from "../tool/show-more-hint.ts";
19
18
  import {
@@ -76,6 +75,11 @@ import {
76
75
  setHoveredToolIo,
77
76
  type FullscreenHoverTarget,
78
77
  } from "./hover.ts";
78
+ import {
79
+ handleStickyPromptClick,
80
+ resetStickyPromptState,
81
+ updateStickyPromptHover,
82
+ } from "./sticky-prompt.ts";
79
83
 
80
84
  type FrameToolRender = {
81
85
  component: any;
@@ -308,10 +312,6 @@ function ensureFullscreenToolMouseMotion(tui: any): void {
308
312
  releaseFullscreenToolMouseMotion(tui);
309
313
  return;
310
314
  }
311
- // 面板改 scrollStepLines 后,下一帧渲染即同步(restore 仍按 original 恢复)。
312
- if (typeof tui.wheelScrollLines === "number" && tui.wheelScrollLines !== config.scrollStepLines) {
313
- tui.wheelScrollLines = config.scrollStepLines;
314
- }
315
315
  if (
316
316
  !toolMouseInteractionActive() ||
317
317
  tui.mouseEnabled === false ||
@@ -348,7 +348,6 @@ function releaseFullscreenToolMouseMotion(tui?: any): void {
348
348
  }
349
349
 
350
350
  const FULLSCREEN_VIEWPORT_PATCH = Symbol("ccstyle.fullscreen-viewport-patch");
351
- const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol("ccstyle.fullscreen-wheel-scroll-original");
352
351
 
353
352
  /**
354
353
  * diff 结果组件自带 remainder 行:声明存在时只有那一行是展开入口(正文里的同名字样不算);
@@ -445,9 +444,15 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
445
444
  }
446
445
  // 展开 round 卡内工具时,让 compact 的强制折叠放行它(非 round 内工具为空操作)。
447
446
  if (isTool) markCompactRoundToolExpanded(component);
448
- // compact 摘要行自己记展开前的视口(收起时还原跟随),这里不动它。
449
- if (!isAssistant) keepViewportOnExpand(tui);
450
- component.setExpanded(true);
447
+ if (isAssistant) {
448
+ // compact 摘要行自己记展开前的视口(收起时还原跟随),所以它先展开再停跟随:
449
+ // 顺序反了快照会记下「未跟随」,收起时就交不回官方 follow。
450
+ component.setExpanded(true);
451
+ keepViewportOnExpand(tui);
452
+ } else {
453
+ keepViewportOnExpand(tui);
454
+ component.setExpanded(true);
455
+ }
451
456
  clearPendingCollapsePress();
452
457
  } else {
453
458
  // 普通工具截断头 show-more:打开全量预览(不收起)。
@@ -487,10 +492,6 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
487
492
  return true;
488
493
  }
489
494
 
490
- /**
491
- * fullscreen 鼠标悬停:collapsed 卡 [click to show more] hint、
492
- * expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
493
- */
494
495
  /** 点到的组件和外层卡是否属于同一张 compact 回合面板。 */
495
496
  function isSameExpandedRound(other: any, owner: any): boolean {
496
497
  if (!owner || !isCompactAssistantComponent(other)) return false;
@@ -501,6 +502,7 @@ function isSameExpandedRound(other: any, owner: any): boolean {
501
502
  /**
502
503
  * 原地展开时停在当前滚动位置:transcript 跟随底部时,内容变高会把视口拖到底,
503
504
  * 点击的行和它上方的外层面板被顶出屏幕。展开前关掉跟随,点击行留在原位。
505
+ * compact 摘要行同样适用:它的展开入口在 setExpanded 之后调用本函数(见调用点)。
504
506
  */
505
507
  function keepViewportOnExpand(tui: any): void {
506
508
  const view = tui.getPrimaryScrollView?.();
@@ -510,6 +512,10 @@ function keepViewportOnExpand(tui: any): void {
510
512
  if (view.isFollowingEnd === false) setScrollButtonVisible(true);
511
513
  }
512
514
 
515
+ /**
516
+ * fullscreen 鼠标悬停:collapsed 卡 [click to show more] hint、
517
+ * expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
518
+ */
513
519
  function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
514
520
  if (!isSgrIdleMotion(packet)) return;
515
521
  const layout = tui.currentLayout;
@@ -589,11 +595,6 @@ function patchFullscreenViewportInput(tui: any): void {
589
595
  const proto = Object.getPrototypeOf(tui);
590
596
  const original = proto?.handleViewportInput;
591
597
  if (typeof original !== "function") return;
592
- // 官方原生 routeWheel 已完整处理嵌套 ScrollView;只调整默认步进(config.scrollStepLines)。
593
- if (typeof tui.wheelScrollLines === "number") {
594
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = tui.wheelScrollLines;
595
- tui.wheelScrollLines = config.scrollStepLines;
596
- }
597
598
  tui[FULLSCREEN_VIEWPORT_PATCH] = true;
598
599
  tui.handleViewportInput = function (this: any, data: string) {
599
600
  if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
@@ -622,12 +623,17 @@ function patchFullscreenViewportInput(tui: any): void {
622
623
  }
623
624
  } else if (!isSgrLeftPress(packet) && !isSgrIdleMotion(packet))
624
625
  clearPendingCollapsePressOnMove(packet);
626
+ if (isSgrLeftPress(packet) && handleStickyPromptClick(tui, packet)) {
627
+ return { consume: true };
628
+ }
625
629
  if (isSgrLeftPress(packet) && handleFullscreenToolClick(tui, packet)) {
626
630
  return { consume: true };
627
631
  }
628
632
  // 仅无按键移动走 hover;左键拖动(文本多选)放行官方选区,避免每像素命中+重绘。
629
633
  if (isSgrIdleMotion(packet)) {
630
- handleFullscreenToolHover(tui, packet);
634
+ // 粘性行命中时不进工具 hover:同一行只能有一个高亮。
635
+ if (updateStickyPromptHover(tui, packet)) applyFullscreenHover(tui, null);
636
+ else handleFullscreenToolHover(tui, packet);
631
637
  }
632
638
  }
633
639
  }
@@ -642,11 +648,6 @@ function restoreFullscreenViewportInput(tui: any): void {
642
648
  if (typeof proto?.handleViewportInput === "function") {
643
649
  tui.handleViewportInput = proto.handleViewportInput;
644
650
  }
645
- const originalWheelScrollLines = tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL];
646
- if (typeof originalWheelScrollLines === "number") {
647
- tui.wheelScrollLines = originalWheelScrollLines;
648
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = undefined;
649
- }
650
651
  tui[FULLSCREEN_VIEWPORT_PATCH] = false;
651
652
  }
652
653
 
@@ -1037,6 +1038,7 @@ export function resetToolHoverState(): void {
1037
1038
  setHoveredCompactAssistant(null);
1038
1039
  setScrollButtonVisible(false);
1039
1040
  setScrollButtonHovered(false);
1041
+ resetStickyPromptState();
1040
1042
  restoreOfficialScrollToEnd(getToolMouseTui());
1041
1043
  releaseFullscreenToolMouseMotion(getToolMouseTui());
1042
1044
  }