pi-cc-extensions 1.0.3 → 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.
@@ -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);
@@ -8,8 +8,11 @@
8
8
  * - 输出以 "Script completed\nWall time …\nOutput:\n" 开头(pi 原生也切掉),行数统计与展开正文都得去掉它,
9
9
  * 否则折叠摘要会多算 3 行。
10
10
  */
11
+ import { highlightCode } from "@earendil-works/pi-coding-agent";
11
12
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { config } from "../../config/config.ts";
12
14
  import { stripAnsi } from "../../utils/ansi-text.ts";
15
+ import { oneLine } from "../../utils/format.ts";
13
16
  import { toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
14
17
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
15
18
  import { mcpToolTitle } from "./mcp-title.ts";
@@ -94,6 +97,105 @@ function fitSummaryParts(parts: readonly string[], width: number): string {
94
97
  return visibleWidth(text) <= width ? text : headTruncateToWidth(text, width);
95
98
  }
96
99
 
100
+ /**
101
+ * 摘要行的打分:调用最能说明脚本在干什么,字符串次之,行长只在前两者相同时决胜。
102
+ * 调用权重必须大于「字符串分 + 行长上限」,否则长赋值会压过短调用。
103
+ */
104
+ const CODE_SUMMARY_CALL_SCORE = 4;
105
+ const CODE_SUMMARY_STRING_SCORE = 2;
106
+
107
+ /**
108
+ * 无子调用时的脚本摘要:取信息量最大的一行语句,压成单行。
109
+ *
110
+ * 要的是一段真实的 JavaScript 片段,折叠行才能用 highlightCode 着色;
111
+ * 标识符链(`a → b → c`)着色器认不出,染不上。跳过注释、空行和 `// @options`。
112
+ * 只是折叠行的提示,不求还原语义;看代码还是展开 Input。
113
+ */
114
+ export function codemodeCodeSummary(code: unknown): string | undefined {
115
+ if (typeof code !== "string" || !code.trim()) return undefined;
116
+ let best: { text: string; score: number } | undefined;
117
+ let skippingBlock = false;
118
+ for (const raw of code.split("\n")) {
119
+ const line = raw.trim();
120
+ if (skippingBlock) {
121
+ if (line.includes("*/")) skippingBlock = false;
122
+ continue;
123
+ }
124
+ if (line.startsWith("/*")) {
125
+ if (!line.includes("*/")) skippingBlock = true;
126
+ continue;
127
+ }
128
+ if (!line || line.startsWith("//") || line.startsWith("*")) continue;
129
+ const text = oneLine(line);
130
+ if (!text) continue;
131
+ const score =
132
+ (text.includes("(") ? CODE_SUMMARY_CALL_SCORE : 0) +
133
+ (/["'`]/.test(text) ? CODE_SUMMARY_STRING_SCORE : 0) +
134
+ Math.min(text.length, 100) / 100;
135
+ if (!best || score > best.score) best = { text, score };
136
+ }
137
+ return best?.text;
138
+ }
139
+
140
+ /**
141
+ * 没有子调用时的脚本行:语句逐行用 │ 树线列出,行数跟 expandedInputMaxLines。
142
+ * 超出的收成一行 `… N more lines`。注释、空行和 `// @options` 不占行。
143
+ */
144
+ export function codemodeCodeLines(
145
+ code: unknown,
146
+ fg: (color: string, text: string) => string,
147
+ rowWidth: number,
148
+ ): string[] {
149
+ if (typeof code !== "string" || !code.trim()) return [];
150
+ const statements: string[] = [];
151
+ let skippingBlock = false;
152
+ for (const raw of code.split("\n")) {
153
+ const line = raw.trim();
154
+ if (skippingBlock) {
155
+ if (line.includes("*/")) skippingBlock = false;
156
+ continue;
157
+ }
158
+ if (line.startsWith("/*")) {
159
+ if (!line.includes("*/")) skippingBlock = true;
160
+ continue;
161
+ }
162
+ if (!line || line.startsWith("//") || line.startsWith("*")) continue;
163
+ const text = oneLine(line);
164
+ if (text) statements.push(text);
165
+ }
166
+ if (statements.length === 0) return [];
167
+ const limit = Math.max(1, config.expandedInputMaxLines);
168
+ const visible = statements.slice(0, limit);
169
+ const rail = `${INDENT}${fg("dim", "│")} `;
170
+ const lines = visible.map((statement) =>
171
+ truncateToWidth(`${rail}${highlightCodeSummary(statement)}`, rowWidth, ""),
172
+ );
173
+ const hidden = statements.length - visible.length;
174
+ if (hidden > 0) {
175
+ lines.push(
176
+ truncateToWidth(
177
+ `${rail}${fg("dim", `… ${hidden} more ${hidden === 1 ? "line" : "lines"}`)}`,
178
+ rowWidth,
179
+ "",
180
+ ),
181
+ );
182
+ }
183
+ return lines;
184
+ }
185
+
186
+ /**
187
+ * 所以包一层 `() => { … }` 再取中间那一行。失败或没着色时退回原文。
188
+ */
189
+ export function highlightCodeSummary(summary: string): string {
190
+ try {
191
+ const lines = highlightCode(`() => { ${summary} }`, "javascript");
192
+ const colored = lines[0]?.replace(/^\(\) => \{ /, "").replace(/ \}$/, "");
193
+ return colored && stripAnsi(colored) === summary ? colored : summary;
194
+ } catch {
195
+ return summary;
196
+ }
197
+ }
198
+
97
199
  /** 与 pi 原生一致:不到 1 分显示两位有效数字。 */
98
200
  function formatCost(cost: number): string {
99
201
  return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`;
@@ -159,9 +261,12 @@ function callSummary(call: CodemodeNestedCall): ToolCallSummary {
159
261
  const parsed = parseCallArgs(raw);
160
262
  if (parsed) {
161
263
  const summary = toolCallSummary(name, parsed.args, { title, variant: "default" });
162
- // 截断串只恢复出部分字段:字段链认出来了才用,认不出仍旧贴原文,
163
- // 免得把不完整的 JSON 当成完整入参摊开。
164
- if (parsed.complete || summary.payload === undefined) return summary;
264
+ // 截断串只恢复出部分字段:字段链认出来了才用(payload 是字段链挑出的值,
265
+ // path/main 的兜底标题不算);认不出仍旧贴原文,免得把不完整的 JSON 当成完整入参摊开。
266
+ const recognized = summary.payload !== undefined || summary.path !== undefined;
267
+ // 完全还原的入参:标题与载荷分色后的 summary 直接用;恢复了字段的也用结构化
268
+ // 结果(字段链认出的值比截断原文更可读)。认不出的才退回贴原文。
269
+ if (parsed.complete || recognized) return summary;
165
270
  }
166
271
  return { main: title, detail: "", payload: raw };
167
272
  }
@@ -176,7 +281,7 @@ function statusIcon(status: unknown, theme: any): string {
176
281
  return theme.fg("muted", "⊘");
177
282
  default:
178
283
  // 运行中:帧在 render() 内取,动画定时器只负责 requestRender
179
- return theme.fg("accent", toolLoadingIcon());
284
+ return theme.fg("dim", toolLoadingIcon());
180
285
  }
181
286
  }
182
287
 
@@ -209,6 +314,8 @@ export function codemodeCollapsedLines(options: {
209
314
  width: number;
210
315
  /** 鼠标悬停整卡时展开提示用 text 色,与其它折叠卡一致。 */
211
316
  hovered?: boolean;
317
+ /** 脚本源码;没有子调用时用来补一行 `code:` 摘要。 */
318
+ code?: unknown;
212
319
  }): string[] {
213
320
  const { result, theme, running, isError } = options;
214
321
  const fg = theme.fg.bind(theme);
@@ -216,6 +323,11 @@ export function codemodeCollapsedLines(options: {
216
323
  const rowWidth = toolViewportWidth(options.width);
217
324
  const shown = calls.slice(-COLLAPSED_CALL_LIMIT);
218
325
  const lines: string[] = [];
326
+ // 没有子调用时脚本本身是唯一线索:逐行用 │ 树线列出,行数跟 expandedInputMaxLines,
327
+ // 汇总行仍用 └ 收尾。
328
+ if (shown.length === 0) {
329
+ lines.push(...codemodeCodeLines(options.code, fg, rowWidth));
330
+ }
219
331
  if (shown.length < calls.length) {
220
332
  const hidden = calls.length - shown.length;
221
333
  lines.push(
@@ -229,8 +341,8 @@ export function codemodeCollapsedLines(options: {
229
341
  for (const call of shown) lines.push(callRow(call, theme, rowWidth));
230
342
 
231
343
  const summary = codemodeSummaryParts(calls, codemodeOutputLineCount(result), running);
232
- // 没有子调用时不画衔接符,避免孤零零一个 └
233
- const prefix = shown.length > 0 ? `${INDENT}${fg("dim", "└")} ` : INDENT;
344
+ // 上面已有行(子调用或代码摘要)时用 └ 收尾,否则不画衔接符
345
+ const prefix = lines.length > 0 ? `${INDENT}${fg("dim", "└")} ` : INDENT;
234
346
  // 圆点保持 dim;hover 只高亮可点击文字。
235
347
  const hint = running
236
348
  ? ""
@@ -281,6 +393,8 @@ export function createCodemodeResultComponent(options: {
281
393
  isError: boolean;
282
394
  /** 折叠态 hover 查询,与 diff 卡同一套(mouse/hover)。 */
283
395
  isHovered?: () => boolean;
396
+ /** 脚本源码;没有子调用时用来补一行 `code:` 摘要。 */
397
+ code?: unknown;
284
398
  }): any {
285
399
  const { result, theme, running, isError } = options;
286
400
  return {
@@ -292,6 +406,7 @@ export function createCodemodeResultComponent(options: {
292
406
  isError,
293
407
  width,
294
408
  hovered: options.isHovered?.() ?? false,
409
+ code: options.code,
295
410
  });
296
411
  },
297
412
  invalidate(): void {},
@@ -135,10 +135,11 @@ export function applyLineLimit(
135
135
  const clickLabel = showMoreHintText();
136
136
  const clickIndex = hintText.lastIndexOf(clickLabel);
137
137
  // 展开态不会再截断,剩下的分支必是折叠态提示。
138
+ // 提示文本与其余折叠卡一致用 dim,hover 时 clickLabel 提到 text。
138
139
  const styledHint =
139
- hovered && clickIndex >= 0
140
+ clickIndex >= 0
140
141
  ? theme.fg("muted", hintText.slice(0, clickIndex)) +
141
- theme.fg("text", clickLabel) +
142
+ theme.fg(hovered ? "text" : "dim", clickLabel) +
142
143
  theme.fg("muted", hintText.slice(clickIndex + clickLabel.length))
143
144
  : theme.fg("muted", hintText);
144
145
  return {
@@ -31,12 +31,18 @@ export interface DiffPalette {
31
31
  removeEmphasisBgAnsi: string;
32
32
  }
33
33
 
34
- const ADD_ROW_BACKGROUND_MIX_RATIO = 0.12;
34
+ const ADD_ROW_BACKGROUND_MIX_RATIO = 0.04;
35
35
  const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
36
36
  const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.26;
37
- const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
37
+ const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.5;
38
38
  const ADDITION_TINT_TARGET: RgbColor = { r: 84, g: 190, b: 118 };
39
39
  const DELETION_TINT_TARGET: RgbColor = { r: 232, g: 95, b: 122 };
40
+ // GitHub dark 删除行底 ~#67060c:混向深酒红目标,压掉主题基底的粉/灰感。
41
+ const DELETION_ROW_TARGET: RgbColor = { r: 103, g: 6, b: 12 };
42
+ const DELETION_EMPHASIS_TARGET: RgbColor = { r: 160, g: 17, b: 23 };
43
+ // 行底/强调底混完再向自身亮度去饱和,留灰感,避免纯色底过艳。
44
+ const ROW_DESATURATE_RATIO = 0.3;
45
+ const EMPHASIS_DESATURATE_RATIO = 0.2;
40
46
  export const ANSI_BG_RESET = "\x1b[49m";
41
47
 
42
48
  export function emphasis(theme: DiffTheme, text: string): string {
@@ -202,6 +208,11 @@ function rgbToBgAnsi(color: RgbColor): string {
202
208
  return `\x1b[48;2;${r};${g};${b}m`;
203
209
  }
204
210
 
211
+ function desaturateRgb(color: RgbColor, ratio: number): RgbColor {
212
+ const luma = (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
213
+ return mixRgb(color, { r: luma, g: luma, b: luma }, ratio);
214
+ }
215
+
205
216
  function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
206
217
  const clamped = Math.max(0, Math.min(1, ratio));
207
218
  return {
@@ -263,9 +274,11 @@ export function readThemeAnsi(
263
274
  }
264
275
 
265
276
  export function resolveDiffPalette(theme: DiffTheme): DiffPalette {
266
- const baseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
267
- parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
277
+ const fallbackBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
268
278
  parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg")) ?? { r: 32, g: 35, b: 42 };
279
+ // 删除行用 toolErrorBg 做基底(主题里是 diffDelBg 暗红);不定义时退回通用基底混 tint。
280
+ const addBaseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ?? fallbackBg;
281
+ const removeBaseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolErrorBg")) ?? addBaseBg;
269
282
  const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffAdded")) ?? {
270
283
  r: 88,
271
284
  g: 173,
@@ -279,10 +292,28 @@ export function resolveDiffPalette(theme: DiffTheme): DiffPalette {
279
292
  const addTint = mixRgb(addFg, ADDITION_TINT_TARGET, 0.35);
280
293
  const removeTint = mixRgb(removeFg, DELETION_TINT_TARGET, 0.65);
281
294
 
282
- const addRowBg = mixRgb(baseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO);
283
- const removeRowBg = mixRgb(baseBg, removeTint, REMOVE_ROW_BACKGROUND_MIX_RATIO);
284
- const addEmphasisBg = mixRgb(baseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO);
285
- const removeEmphasisBg = mixRgb(baseBg, removeTint, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
295
+ const addRowBg = desaturateRgb(
296
+ mixRgb(addBaseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO),
297
+ ROW_DESATURATE_RATIO,
298
+ );
299
+ const addEmphasisBg = desaturateRgb(
300
+ mixRgb(addBaseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO),
301
+ EMPHASIS_DESATURATE_RATIO,
302
+ );
303
+ // 暗色基底下向 GitHub 深酒红(~#67060c)加权,压掉主题基底的粉/灰感;
304
+ // 浅色基底保持原 tint 口径,免得行底深得盖过文字。
305
+ const removeBaseLuminance =
306
+ (removeBaseBg.r * 299 + removeBaseBg.g * 587 + removeBaseBg.b * 114) / 1000;
307
+ const removeRowTarget = removeBaseLuminance < 128 ? DELETION_ROW_TARGET : removeTint;
308
+ const removeEmphasisTarget = removeBaseLuminance < 128 ? DELETION_EMPHASIS_TARGET : removeTint;
309
+ const removeRowBg = desaturateRgb(
310
+ mixRgb(removeBaseBg, removeRowTarget, REMOVE_ROW_BACKGROUND_MIX_RATIO),
311
+ ROW_DESATURATE_RATIO,
312
+ );
313
+ const removeEmphasisBg = desaturateRgb(
314
+ mixRgb(removeBaseBg, removeEmphasisTarget, REMOVE_INLINE_EMPHASIS_MIX_RATIO),
315
+ EMPHASIS_DESATURATE_RATIO,
316
+ );
286
317
 
287
318
  return {
288
319
  addRowBgAnsi: rgbToBgAnsi(addRowBg),
@@ -71,7 +71,7 @@ export function buildDiffSummaryText(stats: DiffSummaryStats, width: number): st
71
71
  }
72
72
 
73
73
  /**
74
- * 折叠摘要行样式:整行 muted,`+A -D` 用 diff 头行同一套绿/红,hover 只把动作词提到 text。
74
+ * 折叠摘要行样式:整行 muted,`+A -D` 用 diff 头行同一套绿/红,`click to show more` 与其余折叠卡一致用 dim,hover 提到 text。
75
75
  * 0 行预览与多行预览共用这一套颜色:改折叠行数时同一处信息不变色。
76
76
  */
77
77
  export function styleDiffSummaryLine(
@@ -103,7 +103,7 @@ export function styleDiffSummaryLine(
103
103
  }
104
104
  if (hintIndex >= cursor) {
105
105
  pushMuted(hintIndex);
106
- pieces.push(theme.fg(hovered ? "text" : "muted", hint));
106
+ pieces.push(theme.fg(hovered ? "text" : "dim", hint));
107
107
  cursor = hintIndex + hint.length;
108
108
  }
109
109
  pushMuted(text.length);
@@ -7,6 +7,7 @@ import {
7
7
  type Component,
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { config } from "../../config/config.ts";
10
+ import { aggregateToolCardBgSlot, toolCardStatus } from "./tool-state.ts";
10
11
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
11
12
  import {
12
13
  ASK_USER_QUESTION_TOOL_NAME,
@@ -105,9 +106,7 @@ function previousSibling(
105
106
  type ToolStatus = "pending" | "success" | "error";
106
107
 
107
108
  function status(tool: any): ToolStatus {
108
- if (tool?.result?.isError) return "error";
109
- if (tool?.isPartial === true || (tool?.executionStarted && !tool?.result)) return "pending";
110
- return tool?.result ? "success" : "pending";
109
+ return toolCardStatus(tool);
111
110
  }
112
111
 
113
112
  function statusIcon(value: ToolStatus): string {
@@ -235,14 +234,15 @@ function askUserResultSummary(tool: any): ToolCallSummary | undefined {
235
234
  .map((entry) => entry.answer);
236
235
  if (answers.length === 0) {
237
236
  const label = tool.result?.details?.cancelled === true ? "Declined" : "Done";
238
- return { main: `${toolTitle(tool)} ${label}`, detail: "" };
237
+ return { main: toolTitle(tool), detail: "", payload: label };
239
238
  }
240
239
  const first = answers[0]!;
241
240
  const question = String(first.question ?? "").trim() || "Question";
242
241
  const more = answers.length > 1 ? ` (+${answers.length - 1})` : "";
243
242
  return {
244
- main: `${toolTitle(tool)} ${question} -> ${askUserAnswerText(first)}${more}`,
243
+ main: toolTitle(tool),
245
244
  detail: "",
245
+ payload: `${question} -> ${askUserAnswerText(first)}${more}`,
246
246
  };
247
247
  }
248
248
 
@@ -253,7 +253,7 @@ function codemodeResultSummary(tool: any): ToolCallSummary | undefined {
253
253
  codemodeOutputLineCount(tool.result),
254
254
  tool?.isPartial === true,
255
255
  );
256
- return { main: `${toolTitle(tool)} ${parts.join(" · ")}`, detail: "" };
256
+ return { main: toolTitle(tool), detail: "", payload: parts.join(" · ") };
257
257
  }
258
258
 
259
259
  /**
@@ -283,7 +283,7 @@ function collapsedGroupToolLine(
283
283
  width: number,
284
284
  ): string {
285
285
  const toolStatus = status(tool);
286
- const color = toolStatus === "pending" ? "accent" : toolStatus;
286
+ const color = toolStatus === "pending" ? "dim" : toolStatus;
287
287
  const summary = askUserResultSummary(tool) ?? codemodeResultSummary(tool) ?? toolSummary(tool);
288
288
  const prefix = ` ${fg("dim", last ? "└" : "├")} ${fg(color, statusIcon(toolStatus))} `;
289
289
  const detail = fg("dim", summary.detail);
@@ -311,7 +311,7 @@ function groupCountText(
311
311
  .filter((key) => counts[key])
312
312
  .map((key) => {
313
313
  const label = key === "pending" ? "running" : key === "success" ? "done" : "failed";
314
- const color = key === "pending" ? "accent" : key;
314
+ const color = key === "pending" ? "dim" : key;
315
315
  return `${fg(color, String(counts[key]))} ${label}`;
316
316
  })
317
317
  .join(` ${fg("dim", "·")} `);
@@ -499,7 +499,7 @@ export class ToolGroupComponent extends Container {
499
499
  const label = names.size === 1 ? toolTitle(this.children[0]) : "Multiple Tools";
500
500
  const overall = groupOverallStatus(counts);
501
501
  if ((this.children as any[]).some(isAnimatingTool)) scheduleGroupAnimation(this.patch);
502
- const overallColor = overall === "pending" ? "accent" : overall;
502
+ const overallColor = overall === "pending" ? "dim" : overall;
503
503
  // 圆点保持 dim;hover 只高亮可点击文字。
504
504
  const hintText = this._expanded ? collapseHintText() : showMoreHintText();
505
505
  const hint = `${fg("dim", "·")} ${fg(this.hintHovered ? "text" : "dim", hintText)}`;
@@ -527,7 +527,7 @@ export class ToolGroupComponent extends Container {
527
527
  expandedHit.hover === this.hintHovered &&
528
528
  expandedHit.theme === this.patch.theme &&
529
529
  expandedHit.fullscreen === isToolTuiFullscreen() &&
530
- expandedHit.bgSlot === config.expandedCardBackground &&
530
+ expandedHit.bgSlot === aggregateToolCardBgSlot(this.children) &&
531
531
  expandedHit.paints.length === childPaints.length &&
532
532
  expandedHit.paints.every((paint, index) => paint === childPaints[index])
533
533
  ) {
@@ -538,7 +538,7 @@ export class ToolGroupComponent extends Container {
538
538
  for (let index = 0; index < total; index++) {
539
539
  const tool = this.children[index];
540
540
  const toolStatus = status(tool);
541
- const color = toolStatus === "pending" ? "accent" : toolStatus;
541
+ const color = toolStatus === "pending" ? "dim" : toolStatus;
542
542
  const branch = index === total - 1 ? "└" : "├";
543
543
  const continuation = index === total - 1 ? " " : "│ ";
544
544
  if (!this._expanded) {
@@ -564,8 +564,8 @@ export class ToolGroupComponent extends Container {
564
564
  }
565
565
  }
566
566
  if (this._expanded) {
567
- // 展开面板统一一个背景槽位(ccstyle 约定),不按状态区分。
568
- const backgroundSlot = config.expandedCardBackground;
567
+ // 有一个失败整卡用失败底色;没有失败但还有进行中用进行中底色。
568
+ const backgroundSlot = aggregateToolCardBgSlot(this.children);
569
569
  for (const line of expandedLines) {
570
570
  lines.push(paddedBackgroundRow(theme, backgroundSlot, line, width));
571
571
  }
@@ -6,6 +6,7 @@ import {
6
6
  import { Markdown, Spacer, Text } from "@earendil-works/pi-tui";
7
7
  import { MESSAGE_DISPLAY_PATCH, patchRegistry } from "../../utils/patch-keys.ts";
8
8
  import { config } from "../../config/config.ts";
9
+ import { TOOL_CARD_BG_SLOT } from "./tool-state.ts";
9
10
  import { showMoreHintText } from "./names.ts";
10
11
  import { walkComponentTree } from "../../utils/component-tree.ts";
11
12
 
@@ -82,11 +83,11 @@ function renderCcstyle(component: any, kind: DisplayKind): void {
82
83
  component.addChild(new Text(`${icon} ${title}${hint}`, 0, 0));
83
84
  return;
84
85
  }
85
- // 展开卡与 tool 一致:同一个展开卡背景 + 上下左右 1 格
86
+ // 展开卡与成功的工具卡同底色,上下左右各 1 格。
86
87
  component.paddingX = 1;
87
88
  component.paddingY = 1;
88
89
  if (typeof theme.bg === "function" && typeof component.setBgFn === "function") {
89
- component.setBgFn((text: string) => theme.bg(config.expandedCardBackground, text));
90
+ component.setBgFn((text: string) => theme.bg(TOOL_CARD_BG_SLOT.success, text));
90
91
  }
91
92
  component.addChild(new Text(`${icon} ${title}`, 0, 0));
92
93
  component.addChild(new Spacer(1));