pi-cc-extensions 1.0.4 → 1.0.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.
@@ -2,7 +2,7 @@
2
2
  * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
3
  * (`Ran for 8s, bash×2, read×2`),edit/write 独立标题行(`✓ write <path> (+25 -0)`),
4
4
  * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)时助手文本按原生渲染,
5
- * thinking 与工具卡装进 userMessageBg 面板(卡内恢复 compact-thinking/Pi 原生或专用
5
+ * thinking 与工具卡装进按状态着色的面板(卡内恢复 compact-thinking/Pi 原生或专用
6
6
  * renderer)。edit/write 复用 mode=on 的 rich diff
7
7
  * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
8
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
@@ -37,6 +37,7 @@ import {
37
37
  } from "@earendil-works/pi-coding-agent";
38
38
  import { truncateToWidth, visibleWidth, Box, Spacer } from "@earendil-works/pi-tui";
39
39
  import { config, getToolDisplayConfig } from "../config/config.ts";
40
+ import { aggregateToolCardBgSlot, toolCardBgSlot } from "./tool/tool-state.ts";
40
41
  import { ASK_USER_QUESTION_TOOL_NAME } from "./tool/ask-user-question.ts";
41
42
  import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
42
43
  import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
@@ -53,7 +54,7 @@ import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
53
54
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
54
55
  import { isToolCallHovered } from "./mouse/hover.ts";
55
56
  import { getToolMouseTui } from "./mouse/scroll.ts";
56
- import { insetComponent } from "./tool/result.ts";
57
+ import { expandedBodyComponent, insetComponent } from "./tool/result.ts";
57
58
  import { isLiveToolCall, scheduleAnimation } from "./tool/tool-state.ts";
58
59
  import { renderExpandedToolResult } from "./tool/tool-io.ts";
59
60
  import { paddedBackgroundRow } from "./tool/grouping.ts";
@@ -114,8 +115,7 @@ export function styleCompactThinkingText(
114
115
  bold = false,
115
116
  ): string {
116
117
  if (!theme) return text;
117
- const color = config.dimThinkingText ? "dim" : "thinkingText";
118
- const colored = typeof theme.fg === "function" ? theme.fg(color, text) : text;
118
+ const colored = typeof theme.fg === "function" ? theme.fg("dim", text) : text;
119
119
  const weighted = bold && typeof theme.bold === "function" ? theme.bold(colored) : colored;
120
120
  return typeof theme.italic === "function" ? theme.italic(weighted) : weighted;
121
121
  }
@@ -333,7 +333,7 @@ function darkenBgAnsi(theme: any, slot: string): string {
333
333
  return `\x1b[48;2;${r};${g};${b}m`;
334
334
  }
335
335
 
336
- /** 工具卡深色行:左右内缩(左 2 右 3 格,含工具卡自身 padding),背景只到内容区,
336
+ /** 工具卡深色行:左右各缩 1 格(含工具卡自身 padding),背景只到内容区,
337
337
  * 修复行尾 reset 截断。 */
338
338
  function toolCardBgRow(
339
339
  theme: any,
@@ -342,8 +342,8 @@ function toolCardBgRow(
342
342
  line: string,
343
343
  width: number,
344
344
  ): string {
345
- const leftInset = 2;
346
- const rightInset = 3;
345
+ const leftInset = 1;
346
+ const rightInset = 1;
347
347
  const contentWidth = Math.max(0, width - leftInset - rightInset);
348
348
  // 深色块内左右各 1 格内部 padding;去掉行首原有空格后重新对齐
349
349
  const text = stripBackgroundAnsi(line).replace(/^ +/, "");
@@ -356,14 +356,13 @@ function toolCardBgRow(
356
356
  const inset = (n: number) => (outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n));
357
357
  return `${inset(leftInset)}${bgAnsi}${stable}\x1b[49m${inset(rightInset)}`;
358
358
  }
359
- /** edit/write 展开卡:保持原样式(展开卡背景 Box),不应用工具卡深色/间距改动。 */
360
- function editWriteExpandedCard(theme: any): any {
359
+ /** edit/write 展开卡:按这张工具卡自己的状态取底色,不应用工具卡深色/间距改动。 */
360
+ function editWriteExpandedCard(theme: any, component: any): any {
361
+ const slot = toolCardBgSlot(component);
361
362
  return new Box(
362
363
  1,
363
364
  1,
364
- typeof theme.bg === "function"
365
- ? (text: string) => theme.bg(config.expandedCardBackground, text)
366
- : undefined,
365
+ typeof theme.bg === "function" ? (text: string) => theme.bg(slot, text) : undefined,
367
366
  );
368
367
  }
369
368
 
@@ -384,7 +383,7 @@ function layoutExpandedToolCard(
384
383
  toolHits = false,
385
384
  live = false,
386
385
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
387
- const slot = config.expandedCardBackground;
386
+ const slot = aggregateToolCardBgSlot(children.map((child) => child?.__ccTool).filter(Boolean));
388
387
  const toolBgAnsi = darkenBgAnsi(theme, slot);
389
388
  /** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
390
389
  const markerWidth = live ? 2 : 0;
@@ -397,7 +396,10 @@ function layoutExpandedToolCard(
397
396
  let liveLead = 0;
398
397
  const pushRow = (raw: string) => {
399
398
  if (!live) {
400
- lines.push(paddedBackgroundRow(theme, slot, raw, width));
399
+ // 面板 pad 1 是唯一左边距:剥掉子行 collapsed 态自带的 1 格 lead,
400
+ // 否则标题行偏到 2、↳ 行偏到 4,左右不再对称。
401
+ const line = raw.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*) /, "$1");
402
+ lines.push(paddedBackgroundRow(theme, slot, line, width));
401
403
  return;
402
404
  }
403
405
  // 槽位里不重复展开入口(见 SLOT_HINT_PATTERNS)。
@@ -500,7 +502,9 @@ function compactRoundCard(
500
502
  | undefined;
501
503
  const layout = (width: number) => {
502
504
  const theme = themeOf();
503
- const bgSlot = config.expandedCardBackground;
505
+ const bgSlot = aggregateToolCardBgSlot(
506
+ children.map((child) => child?.__ccTool).filter(Boolean),
507
+ );
504
508
  const innerWidth = contentWidth(width);
505
509
  const paints = children.map((child) => {
506
510
  const lines = child.render?.(innerWidth);
@@ -706,7 +710,7 @@ function compactEditWriteLine(
706
710
  const isError = component.result?.isError === true;
707
711
  const isPending = !component.result || component.isPartial === true;
708
712
  const icon = isError ? "✗" : isPending ? toolLoadingIcon() : "✓";
709
- const iconColor = isError ? "error" : isPending ? "accent" : "success";
713
+ const iconColor = isError ? "error" : isPending ? "dim" : "success";
710
714
  let statsText = "";
711
715
  let statsStyled = "";
712
716
  // 统计只显示一处:正文自带 +A -D 时标题不再重复(showStats=false)。
@@ -749,7 +753,7 @@ function compactEditWriteLine(
749
753
  const hintPart = clickable
750
754
  ? `${theme.fg("dim", " · ")}${theme.fg(isToolCallHovered(component.toolCallId) ? "text" : "dim", clickText)}`
751
755
  : "";
752
- const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintPart}`;
756
+ const line = `${iconPart}${namePart}${theme.fg("muted", pathPart)}${statsStyled}${hintPart}`;
753
757
  return ["", truncateToWidth(line, width, "")];
754
758
  }
755
759
 
@@ -809,7 +813,7 @@ function compactEditWriteLines(
809
813
  hit &&
810
814
  hit.width === width &&
811
815
  hit.theme === theme &&
812
- hit.bgSlot === config.expandedCardBackground &&
816
+ hit.bgSlot === toolCardBgSlot(component) &&
813
817
  hit.diffConfigKey === diffConfigKey &&
814
818
  hit.expanded === expanded &&
815
819
  hit.result === component.result &&
@@ -826,7 +830,7 @@ function compactEditWriteLines(
826
830
  compactEditPaintCache.set(component, {
827
831
  width,
828
832
  theme,
829
- bgSlot: config.expandedCardBackground,
833
+ bgSlot: toolCardBgSlot(component),
830
834
  diffConfigKey,
831
835
  expanded,
832
836
  result: component.result,
@@ -894,7 +898,7 @@ function paintCompactEditWrite(
894
898
 
895
899
  let detail: any;
896
900
  if (hasRich) {
897
- detail = expanded ? candidate : insetComponent(candidate as any);
901
+ detail = expanded ? expandedBodyComponent(candidate) : insetComponent(candidate as any);
898
902
  } else {
899
903
  const outputText = sanitizeToolResultText(
900
904
  Array.isArray(result?.content)
@@ -921,7 +925,7 @@ function paintCompactEditWrite(
921
925
  return [...title, ...detail.render(width)];
922
926
  }
923
927
 
924
- const box = editWriteExpandedCard(theme);
928
+ const box = editWriteExpandedCard(theme, component);
925
929
  box.addChild({
926
930
  render(innerWidth: number): string[] {
927
931
  return compactEditWriteLine(component, innerWidth, writeMetadata, {
@@ -985,8 +989,7 @@ function compactAssistantLineComponent(
985
989
  return paint.lines;
986
990
  }
987
991
  const plainText = truncateToWidth(resolved, summaryWidth, "…");
988
- // Dim thinking text 开启时整行统一 dim,否则工具计数保持 muted。
989
- const plain = (value: string) => theme.fg(config.dimThinkingText ? "dim" : "muted", value);
992
+ const plain = (value: string) => theme.fg("dim", value);
990
993
  let text = plain(plainText);
991
994
  if (runningActive || plainText.startsWith("Ran for ")) {
992
995
  const separator = plainText.indexOf(", ");
@@ -1195,6 +1198,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1195
1198
  foldTimer?: ReturnType<typeof setTimeout>;
1196
1199
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
1197
1200
  startedAt: number;
1201
+ /** 本进程看见回合的挂钟起点。消息 timestamp 不随工具执行前进。 */
1202
+ wallStartedAt: number;
1198
1203
  endedAt?: number;
1199
1204
  /** 回合尾行(摘要 + live 槽位),挂在 transcript 容器的回合末尾。 */
1200
1205
  tail?: MountedRoundTail;
@@ -1268,8 +1273,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1268
1273
  value >= 1_600_000_000_000 &&
1269
1274
  value <= Date.now() + 60_000;
1270
1275
 
1271
- /** 结束回合活动态;不做任何重绘。endTimestamp 用后一段内容的真实时刻收边(回放里
1272
- * 同一毫秒完成的 create/finalize 差不出真实耗时);缺省取回合内消息的最大 timestamp。 */
1276
+ /** 结束回合活动态;不做任何重绘。更晚的消息 timestamp 用来保住回放时长;
1277
+ * 同一条消息的 timestamp 不动,活回合改用本进程挂钟,避免把历史 timestamp 收到「现在」。 */
1273
1278
  const finalizeRound = (round: CompactRound, endTimestamp?: number): void => {
1274
1279
  clearFoldTimer(round);
1275
1280
  round.active = false;
@@ -1280,10 +1285,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1280
1285
  fallback = message.timestamp;
1281
1286
  }
1282
1287
  }
1283
- round.endedAt = Math.max(
1284
- round.startedAt,
1285
- isUsableTimestamp(endTimestamp) ? endTimestamp : fallback || Date.now(),
1286
- );
1288
+ const stamped = isUsableTimestamp(endTimestamp) ? endTimestamp : fallback;
1289
+ if (stamped > round.startedAt) round.endedAt = stamped;
1290
+ else if (typeof round.wallStartedAt === "number") {
1291
+ round.endedAt = round.startedAt + Math.max(0, Date.now() - round.wallStartedAt);
1292
+ } else round.endedAt = Math.max(round.startedAt, Date.now());
1287
1293
  }
1288
1294
  if (activeRound === round) {
1289
1295
  activeRound = undefined;
@@ -1658,6 +1664,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1658
1664
  }
1659
1665
  if (!prior) return;
1660
1666
  round.startedAt = prior.startedAt;
1667
+ if (typeof prior.wallStartedAt === "number") round.wallStartedAt = prior.wallStartedAt;
1661
1668
  if (prior.detachedMessages.length) round.detachedMessages.push(...prior.detachedMessages);
1662
1669
  if (!prior.active) {
1663
1670
  round.active = false;
@@ -1683,6 +1690,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1683
1690
  suppressedToolIds: new Set(),
1684
1691
  liveSlotToolIds: new Set(),
1685
1692
  startedAt: isUsableTimestamp(message?.timestamp) ? message.timestamp : Date.now(),
1693
+ wallStartedAt: Date.now(),
1686
1694
  };
1687
1695
  liveRounds.add(round);
1688
1696
  activateRound(round);
@@ -2060,7 +2068,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2060
2068
  const sweepOrphanTails = (): void => {
2061
2069
  for (const tail of [...mountedTails]) {
2062
2070
  const round = tail.round as CompactRound | undefined;
2063
- if (round && liveRounds.has(round)) continue;
2071
+ // 回合还在本 pass 名单里、且没有同 anchor 的更新 tail 顶位时保留。
2072
+ if (round && liveRounds.has(round)) {
2073
+ const anchor = round.anchor;
2074
+ if (!anchor || tailByAnchor.get(anchor) === tail) continue;
2075
+ }
2064
2076
  removeTailHost(tail);
2065
2077
  mountedTails.delete(tail);
2066
2078
  if (round?.anchor && tailByAnchor.get(round.anchor) === tail) {
@@ -2138,16 +2150,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2138
2150
 
2139
2151
  if (hasToolCalls) {
2140
2152
  let round = roundByComponent.get(this);
2141
- if (hasText && (!round || round.anchor !== this)) {
2142
- if (round) {
2143
- round.messages.delete(this);
2144
- clearRoundAnchor(this);
2145
- }
2146
- if (activeRound) finishRound(activeRound, message.timestamp);
2147
- round = createRound(this, message);
2148
- roundByComponent.set(this, round);
2149
- } else if (!round) {
2150
- // 中间隔着用户消息就另起一回合,不并进上一个回合。
2153
+ if (!round) {
2154
+ // 本条消息是否带 text 不是回合边界:pi 流式协议里同一条
2155
+ // assistant 消息会以 text+toolCall、toolCall-only 反复到达。
2156
+ // 只在「组件未挂进任何回合」时并入 activeRound;被用户消息
2157
+ // 隔开(steer/上回合没正文)时由 joinOrCreateRound 收尾另起。
2151
2158
  round = joinOrCreateRound(this, message);
2152
2159
  roundByComponent.set(this, round);
2153
2160
  }
@@ -14,7 +14,7 @@ import {
14
14
  TOOL_EXPANDED_BACKGROUND_PATCH,
15
15
  } from "../utils/patch-keys.ts";
16
16
  import { isToolCallHovered } from "./mouse/hover.ts";
17
- import { isLiveToolCall } from "./tool/tool-state.ts";
17
+ import { isLiveToolCall, toolCardBgSlot } from "./tool/tool-state.ts";
18
18
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
19
19
  import { createCcstyleTool } from "./tool/ccstyle-tool.ts";
20
20
  import type { WriteExecutionMetadataStore } from "./tool/diff/index.ts";
@@ -373,7 +373,9 @@ function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
373
373
  }
374
374
  }
375
375
 
376
- /** 展开面板背景统一为 user message 背景色;折叠行保持原生状态色。
376
+ /** 展开面板背景按工具状态取槽位;折叠行保持原生状态色。
377
+ * compact 下 ask_user_question 等普通工具的展开也走原生 contentBox,
378
+ * 这里按同一套状态覆盖它的底色。
377
379
  * 必须在 compact-mode 之后安装,shutdown 时先于 compact-mode 释放。 */
378
380
  export function installToolExpandedBackground(): () => void {
379
381
  const previous = patchRegistry.get<ToolExpandedBackgroundPatch>(TOOL_EXPANDED_BACKGROUND_PATCH);
@@ -385,7 +387,7 @@ export function installToolExpandedBackground(): () => void {
385
387
  prototype,
386
388
  installed: function (this: any) {
387
389
  original.call(this);
388
- if (!patch.active || config.mode !== "on" || !this.expanded) return;
390
+ if (!patch.active || config.mode === "off" || !this.expanded) return;
389
391
  const theme = getMessageDisplayTheme();
390
392
  if (!theme?.bg) return;
391
393
  const box = this.contentBox;
@@ -394,7 +396,8 @@ export function installToolExpandedBackground(): () => void {
394
396
  box.paddingX = 1;
395
397
  box.paddingY = 1;
396
398
  if (box.setBgFn) {
397
- box.setBgFn((text: string) => theme.bg(config.expandedCardBackground, text));
399
+ const slot = toolCardBgSlot(this);
400
+ box.setBgFn((text: string) => theme.bg(slot, text));
398
401
  }
399
402
  }
400
403
  },
@@ -48,6 +48,7 @@ import {
48
48
  import {
49
49
  fullscreenLazyTui,
50
50
  hideScrollButton,
51
+ dockScrollButtonActive,
51
52
  restoreOfficialScrollToEnd,
52
53
  syncOfficialScrollToEnd,
53
54
  isScrollBottomInput,
@@ -503,8 +504,8 @@ function keepViewportOnExpand(tui: any): void {
503
504
  const view = tui.getPrimaryScrollView?.();
504
505
  if (typeof view?.scrollTo !== "function" || typeof view.scrollTop !== "number") return;
505
506
  view.scrollTo(view.scrollTop, { disableFollow: true });
506
- // 已脱离底部跟随:亮出回到底部按钮,新输出到来时仍有入口。
507
- if (view.isFollowingEnd === false) setScrollButtonVisible(true);
507
+ // 已脱离底部跟随:dock 模式亮出回到底部按钮,新输出到来时仍有入口。
508
+ if (view.isFollowingEnd === false && dockScrollButtonActive()) setScrollButtonVisible(true);
508
509
  }
509
510
 
510
511
  /**
@@ -579,6 +580,18 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
579
580
  applyFullscreenHover(tui, target);
580
581
  }
581
582
 
583
+ /** 布局缺失,或焦点 overlay 还不在上一帧列表里:这份矩形不能拿来判点击。 */
584
+ function overlayLayoutsCurrent(tui: any): boolean {
585
+ const layouts = tui?.renderedOverlayLayouts;
586
+ if (!Array.isArray(layouts) || layouts.length === 0) return false;
587
+ if (typeof tui.isOverlayFocused === "function" && tui.isOverlayFocused()) {
588
+ const focused = tui.focusedComponent;
589
+ if (focused && !layouts.some((layout: any) => layout?.entry?.component === focused))
590
+ return false;
591
+ }
592
+ return true;
593
+ }
594
+
582
595
  /**
583
596
  * 鼠标包(1-based 屏幕坐标)是否落在某个已渲染 overlay 的矩形内。
584
597
  * renderedOverlayLayouts 与官方 dispatchMouseToOverlay 同源:只含上一帧画出的可见 overlay。
@@ -623,8 +636,9 @@ function patchFullscreenViewportInput(tui: any): void {
623
636
  // dispatchMouseToOverlay → overlay.handleMouse;overlay 外的包仍
624
637
  // 走扩展收起/展开/hover。两条路径各管各的。
625
638
  if (packets) {
626
- // overlay 可见时:落在 overlay 矩形内的包全部放行官方(文本预览的
627
- // [esc] 点击/滚轮、官方搜索框等),扩展不展开/收起/hover。
639
+ // 布局还没出来或仍是上一帧:不要把点击收成工具展开。返回 undefined
640
+ // 让焦点 overlay 的 handleInput 拿到原始 SGR;官方链也靠这份布局,调它会消费且打到 transcript。
641
+ if (tui.hasOverlay?.() && !overlayLayoutsCurrent(tui)) return undefined;
628
642
  const overlayPackets = tui.hasOverlay?.()
629
643
  ? packets.filter((packet) => packetInsideOverlay(tui, packet))
630
644
  : undefined;
@@ -68,7 +68,7 @@ export function setScrollButtonVisible(visible: boolean): void {
68
68
  export function noteNewTranscriptItem(): void {
69
69
  const state = scrollButtonState();
70
70
  const tui = getToolMouseTui();
71
- if (!state.visible || !tui) return;
71
+ if (!dockScrollButtonActive() || !state.visible || !tui) return;
72
72
  // 提交新消息时官方可能直接跳回底部(不经过滚动输入):先按实时跟随状态校正,
73
73
  // 否则跟随期间的内容会被当成“离开底部时的新消息”计入。
74
74
  if (isAtTranscriptBottom(tui)) {
@@ -107,6 +107,11 @@ export function toolMouseInteractionActive(): boolean {
107
107
  return config.mode !== "off";
108
108
  }
109
109
 
110
+ /** dock 模式才用扩展的独占行;native 或 ccstyle off 时交还 Pi 原生浮层。 */
111
+ export function dockScrollButtonActive(): boolean {
112
+ return toolMouseInteractionActive() && config.scrollButtonMode === "dock";
113
+ }
114
+
110
115
  /** 惰性 Proxy 官方 fullscreen(TuiAltScreen)判定。 */
111
116
  export function fullscreenLazyTui(tui: any): boolean {
112
117
  return isLazyProxyTui(tui) && tui.mode === "fullscreen";
@@ -132,10 +137,15 @@ export function restoreOfficialScrollToEnd(tui: any): void {
132
137
  tui[OFFICIAL_SCROLL_TO_END_KEY] = undefined;
133
138
  }
134
139
 
135
- /** on/compact 用 dock 按钮;off 还回 pi 原生 Jump to latest。 */
140
+ /** dock 模式替换官方浮层;native 或 ccstyle off 时保留 Pi 原生 Jump to latest。 */
136
141
  export function syncOfficialScrollToEnd(tui: any): void {
137
- if (toolMouseInteractionActive()) disableOfficialScrollToEnd(tui);
138
- else restoreOfficialScrollToEnd(tui);
142
+ if (dockScrollButtonActive()) {
143
+ disableOfficialScrollToEnd(tui);
144
+ return;
145
+ }
146
+ restoreOfficialScrollToEnd(tui);
147
+ setScrollButtonVisible(false);
148
+ setScrollButtonHovered(false);
139
149
  }
140
150
 
141
151
  /** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */
@@ -202,7 +212,7 @@ export function hideScrollButton(tui: any): void {
202
212
  export function scheduleScrollButtonSync(tui: any, data: string): void {
203
213
  if (
204
214
  !fullscreenLazyTui(tui) ||
205
- !toolMouseInteractionActive() ||
215
+ !dockScrollButtonActive() ||
206
216
  !isScrollNavigationInput(data) ||
207
217
  scrollButtonSyncScheduled
208
218
  )
@@ -211,7 +221,7 @@ export function scheduleScrollButtonSync(tui: any, data: string): void {
211
221
  const previousLines = tui.previousLines;
212
222
  const check = (attempt: number) => {
213
223
  scrollButtonSyncScheduled = false;
214
- if (getToolMouseTui() !== tui) return;
224
+ if (getToolMouseTui() !== tui || !dockScrollButtonActive()) return;
215
225
  // Pi renders on its own frame timer. Inspect the resulting viewport before
216
226
  // showing the button so empty or non-scrollable transcripts never flash it.
217
227
  const rendered = tui.previousLines !== previousLines;
@@ -234,7 +244,7 @@ export function scheduleScrollButtonSync(tui: any, data: string): void {
234
244
  }
235
245
 
236
246
  export function updateScrollButtonFromInput(tui: any, data: string): void {
237
- if (!fullscreenLazyTui(tui) || !toolMouseInteractionActive()) return;
247
+ if (!fullscreenLazyTui(tui) || !dockScrollButtonActive()) return;
238
248
  if (matchesKey(data, "enter") || matchesKey(data, "return")) hideScrollButton(tui);
239
249
  }
240
250
 
@@ -247,7 +257,12 @@ function scrollButtonText(): string {
247
257
  }
248
258
 
249
259
  export function renderScrollButton(width: number, theme: any): string[] {
250
- if (!getScrollButtonVisible() || !fullscreenLazyTui(getToolMouseTui())) return [];
260
+ if (
261
+ !dockScrollButtonActive() ||
262
+ !getScrollButtonVisible() ||
263
+ !fullscreenLazyTui(getToolMouseTui())
264
+ )
265
+ return [];
251
266
  const label = theme.fg(
252
267
  getScrollButtonHovered() ? "text" : "accent",
253
268
  `[ ↓ ${scrollButtonText()} ]`,
@@ -27,7 +27,7 @@ import {
27
27
  } from "../../utils/patch-keys.ts";
28
28
  import { isToolTuiFullscreen } from "../tool/names.ts";
29
29
  import type { SgrMousePacket } from "./packets.ts";
30
- import { setScrollButtonVisible } from "./scroll.ts";
30
+ import { dockScrollButtonActive, setScrollButtonVisible } from "./scroll.ts";
31
31
 
32
32
  /** 消息正文首行/末行的零宽 APC 标记;终端忽略,合成前剥离。 */
33
33
  const MESSAGE_START = "\x1b_cc:p\x07";
@@ -338,7 +338,7 @@ export function handleStickyPromptClick(tui: any, packet: SgrMousePacket): boole
338
338
  const view = tui.getPrimaryScrollView?.();
339
339
  if (typeof view?.scrollTo === "function") {
340
340
  view.scrollTo(state.jumpRow, { disableFollow: true });
341
- if (view.isFollowingEnd === false) setScrollButtonVisible(true);
341
+ if (view.isFollowingEnd === false && dockScrollButtonActive()) setScrollButtonVisible(true);
342
342
  }
343
343
  // 落点已到消息头,这一帧的粘性行立即失效;等下一帧按新位置重算。
344
344
  state.visible = false;
@@ -27,6 +27,7 @@ import {
27
27
  } from "./names.ts";
28
28
  import {
29
29
  collapsedResultSummary,
30
+ expandedBodyComponent,
30
31
  formatToolDuration,
31
32
  insetComponent,
32
33
  renderCollapsedToolResultToWidth,
@@ -154,6 +155,23 @@ function renderExpandedTaskResult(
154
155
  return formatted ? new Text(`↳ ${formatted}`, 0, 0) : undefined;
155
156
  }
156
157
 
158
+ /**
159
+ * 工具耗时(毫秒)。结束时优先用 pi 记在结果上的 durationMs(单调,restore 出来的行也带得到);
160
+ * 运行中 pi 不给(partial 期间渲染上下文里恒为 undefined),退回 renderCall 记下的时钟。
161
+ * 拿不到返回 undefined。时钟回拨时钳到 0。
162
+ */
163
+ function toolElapsedMs(context: any, isPartial: boolean): number | undefined {
164
+ const recorded = context?.durationMs;
165
+ if (!isPartial && typeof recorded === "number" && Number.isFinite(recorded) && recorded >= 0) {
166
+ return recorded;
167
+ }
168
+ const startedAt = context?.state?.ccstyleShellStartedAt;
169
+ if (typeof startedAt !== "number") return undefined;
170
+ const endedAt = context?.state?.ccstyleShellEndedAt;
171
+ const end = typeof endedAt === "number" ? endedAt : Date.now();
172
+ return Math.max(0, end - startedAt);
173
+ }
174
+
157
175
  /**
158
176
  * shell 的耗时文本。Pi 原生 shell 渲染器会在结果底部报一行,ccstyle 的通用折叠行把它盖掉了。
159
177
  *
@@ -163,16 +181,8 @@ function renderExpandedTaskResult(
163
181
  */
164
182
  function shellDurationText(toolName: string, context: any, isPartial: boolean): string {
165
183
  if (!SHELL_TOOL_NAMES.has(toolName)) return "";
166
- const recorded = context?.durationMs;
167
- if (!isPartial && typeof recorded === "number" && Number.isFinite(recorded) && recorded >= 0) {
168
- return formatToolDuration(recorded);
169
- }
170
- const startedAt = context?.state?.ccstyleShellStartedAt;
171
- if (typeof startedAt !== "number") return "";
172
- const endedAt = context?.state?.ccstyleShellEndedAt;
173
- const end = typeof endedAt === "number" ? endedAt : Date.now();
174
- // 时钟回拨时别吐负数;快照固定时钟也会走到这。
175
- return formatToolDuration(Math.max(0, end - startedAt));
184
+ const elapsed = toolElapsedMs(context, isPartial);
185
+ return elapsed === undefined ? "" : formatToolDuration(elapsed);
176
186
  }
177
187
 
178
188
  /** 用 ccstyle call/result 包装任意工具定义。 */
@@ -328,6 +338,7 @@ export function createCcstyleTool(
328
338
  running: Boolean(options?.isPartial),
329
339
  isError,
330
340
  isHovered: () => isToolCallHovered(toolCallId),
341
+ code: context?.args?.code,
331
342
  });
332
343
  }
333
344
  // ask_user_question:折叠态摊成问答树,展开态按问题逐条列出选择。
@@ -405,8 +416,9 @@ export function createCcstyleTool(
405
416
  writeExecutionMetadata,
406
417
  getToolDisplayConfig,
407
418
  );
408
- // 展开态由 Box(1,1) 提供内边距;折叠态 inset 一级缩进
409
- if (richResult) return expanded ? richResult : insetComponent(richResult);
419
+ // 展开态由 Box(1,1) 提供内边距,首行再缩 2 格与标题文字列对齐;折叠态 inset 一级缩进
420
+ if (richResult)
421
+ return expanded ? expandedBodyComponent(richResult) : insetComponent(richResult);
410
422
  }
411
423
 
412
424
  const text = textFromResult(result, expanded);